Compare commits

...

540 Commits

Author SHA1 Message Date
Andrew Senetar 3a97ba941a
ci: Merge artifacts
- Merge the resulting artifacts
- Use only the .so files from build
2024-05-11 01:21:58 -07:00
Andrew Senetar e3bcf9d686
chore: Update VS Code configuration 2024-05-11 00:12:19 -07:00
Andrew Senetar a81069be61
fix: Photo matching fixes
- Correct bad query introduced in rotation matching
- Promote get_orientation from "private" on photo class
- Fix prepare_pictures to only generate the needed blocks, add check for missing blocks when rotation matchin is true
- Fix cache test inputs to match schema
2024-05-11 00:11:27 -07:00
Andrew Senetar 08154815d0
ci: Upgrade to latest actions & python versions 2024-02-19 14:39:04 -08:00
Andrew Senetar a95a9db08b
ci: Fix version for tx-push worklflow 2024-02-19 14:22:55 -08:00
Andrew Senetar 3d866cec9a
ci: Add action to push translation source to transifex 2024-02-19 14:17:40 -08:00
Andrew Senetar 253dfd897c
chore: Pull existing language translations from Transifex 2024-02-19 13:35:35 -08:00
Andrew Senetar 6e87f53f91
chore: Regenerate translation source files 2024-02-19 13:26:07 -08:00
Andrew Senetar 95e04c4d82
ci: Update .pre-commit-config.yaml
Update .pre-commit-config.yaml to use the latest versions of black,
flake8, etc.
2024-02-19 11:35:39 -08:00
Andrew Senetar e3a612a704
fix: Correct change that broke black formmating 2024-02-19 10:43:39 -08:00
Andrew Senetar 53d5ac06bf
fix: Linting Errors & VS Code config
- Add additional settings to VS Code for formatter changes in plugins
- Fix black formatting
- Fix flake8 errors due to long lines
- Fix flake8 errors due to type comparisons
2024-02-19 10:32:13 -08:00
Andrew Senetar 13dd00c798
fix: Replace use of `imp` with `importlib`
Original PR and information found at #1187
2024-02-19 09:48:54 -08:00
Luca Falavigna 9f22835f73
Use errno.EISDIR and errno.EACCESS instead of hardcoding values (#1196) 2024-02-19 09:38:24 -06:00
Bruno Cabral 85a4557525
match all orientations (#1127)
* match all orientations

* use rotation as option

---------

Co-authored-by: Andrew Senetar <arsenetar@gmail.com>
Co-authored-by: Luke <byunghun.hyun26@gmail.com>
2024-02-19 09:19:33 -06:00
Andrew Senetar 70d956b4f8
Merge pull request #1197 from dktrkranz/Hurd
Use isolation_level=None mode for GNU Hurd
2024-02-19 09:15:10 -06:00
Luca Falavigna 007404f46a Use isolation_level=None mode for GNU Hurd 2024-02-12 18:02:13 +01:00
Andrew Senetar 4385b50825
Merge pull request #1143 from cebtenzzre/fix-tox
Update python versions in tox.ini
2023-12-01 22:08:42 -06:00
Andrew Senetar 4ef1d24351
Merge pull request #1142 from cebtenzzre/fix-requirements
Update requirements.txt
2023-12-01 22:08:05 -06:00
Andrew Senetar 03be82c0b0
Merge pull request #1144 from Cebtenzzre/fix-arch-package
Do not package send2trash on Arch Linux
2023-07-17 21:54:22 -05:00
Cebtenzzre 332b814c00 Do not package send2trash on Arch Linux
send2trash is provided by the python-send2trash package in the 'extra'
repository.
2023-07-16 19:04:11 -04:00
Cebtenzzre f56bef67e1 Update python versions in tox.ini
Python 3.6 support was already dropped in commit b9dfeac2 ("Drop Python
3.6 Support"). Don't attempt to run tests with it if it is installed.

Python 3.11 is currently supported, so add it to the list.
2023-07-16 18:49:36 -04:00
Cebtenzzre 8160fe4fcc Update requirements.txt
sphinx 7.0.0 was released on April 29th.
2023-07-16 18:38:07 -04:00
Andrew Senetar 9ad84ade29
Merge pull request #1130 from lukehyun/master
RE: Rewrote some of the korean translation to be more understandable
2023-06-08 20:10:33 -05:00
Andrew Senetar 18f32fda19
chore(translations): Synchronize translations 2023-06-08 19:14:57 -05:00
Andrew Senetar 99ec4e0f27
fix: Minor cleanups and fixes
- Update NullJob to subclass Job
- Remove unnecessary size pre-read in _getMatches() as file sizes are
  already loaded during file scan via stat call
- Skip ref check if contents scan as the scan already prevents this from
  happening, some of the other scans do things differently and need to
  be reviewed before removing this post step completely
- Add guard on partial hashing to just hash the whole file if smaller
  than the offset and size and use the value for both the partial digest
  and digest
2023-06-08 01:14:52 -05:00
Luke fe0e4bef91 RE: Rewrote some of the korean translation to be more understandable
I have updated my fork and moved my changes from before.
2023-06-01 11:40:20 +10:00
Andrew Senetar 322d29a996
Merge pull request #1121 from arsenetar/as/upgrade-deps
feat: Upgrade dependencies
2023-04-27 02:35:48 -05:00
Andrew Senetar c5a71f61b8
feat: Upgrade dependencies
Upgrade required dependency versions for most dependencies.  Add maximum
version to most dependencies as well.
2023-04-27 02:25:22 -05:00
Andrew Senetar 10405ad063
fix(build): Clean prior qt/dg_rc.py file before build
Since calls without pyqrcc5 may result in a broken file, clean prior
qt/gg_rc.py file before calling pyqrcc5.  This makes troubleshooting
pyqrcc5 issues more straightforward.

Fix #1103
2023-04-27 01:36:32 -05:00
Andrew Senetar a257dbf0d5
fix(win): Shorten file description
Fix #1119
2023-04-27 01:22:06 -05:00
Daniel Chalmers 7a4506ece3
Github -> GitHub (#1115)
Co-authored-by: Andrew Senetar <arsenetar@gmail.com>
2023-04-27 00:54:39 -05:00
Andrew Senetar aade6593ac
feat: Update translations from transifex 2023-04-27 00:49:03 -05:00
Andrew Senetar 6d8b86b7eb
fix(core): Remove old directory state logic
- Remove code forcing the exclusion of `.` directories by default, the
  new default exclusion filters do this by default
- Change default state code to always return a value
2023-02-27 17:58:15 -06:00
Andrew Senetar e41c91623c
Merge pull request #1049 from Dobatymo/colors-bytes
serialize/deserialize colors to/from bytes instead of strings
2023-01-26 21:24:20 -06:00
Andrew Senetar 46521c8af1
feat: Add migration for picture cache db
- Add migration (just delete db and change to new schema) for picture
  cache following the same sort of strategy as the file digest cache
- Rename mtime column to mtime_ns to match file cache for consistency
2023-01-13 00:05:47 -06:00
Andrew Senetar 549eb7f153
chore: Add vscode launch.json 2023-01-12 23:51:05 -06:00
Andrew Senetar 8125e3ec97
chore: Add rulers to vscode settings, format 2023-01-12 23:30:35 -06:00
Andrew Senetar 8c5e18b980
Merge remote-tracking branch 'upstream/master' into colors-bytes 2023-01-12 00:14:17 -06:00
Andrew Senetar d81759f77f
fix: Specify maximum python version for deb
Specify maximum supported python version so attempts to install are met
with better errors.
2023-01-11 23:53:02 -06:00
Andrew Senetar c57042fdd2
fix: Resolve issue with mock object for core test
Last change introduced a new method on the fs.File object that the test
object did not have.  Add similar method to test object.
2023-01-11 23:20:40 -06:00
Andrew Senetar 057be0294a
fix: Prevent exception during existence check
- Add "safe" existence check to files which catches OSErrors that may
  occur when trying to stat files
- Use "safe" existence check during final existence check
2023-01-11 23:07:06 -06:00
Andrew Senetar 81daddd072
refactor: Improve digest cache db method performance
- Remove lock on read operations, only needed for write operations
- Change to use context manager for sqlite connection
- Remove long lived cursor object and use short lived cursors instead

Fixes #1080
2023-01-11 00:58:29 -06:00
Andrew Senetar 1e651a1603
Merge pull request #1089 from arsenetar/as/pre-commit
feat: Add pre-commit, include python 3.11 in tests
2023-01-09 23:18:13 -06:00
Andrew Senetar 78f4145910
chore: Remove unused qtlib.pot file 2023-01-09 23:02:19 -06:00
Andrew Senetar 46d1afb566
chore: Apply whitespace fixes from hooks
- Remove trailing whitespace
- Correct single newline at end of files (skip for json)
- Update to formatting in a few places due to black
2023-01-09 22:58:08 -06:00
Andrew Senetar a5e31f15f0
Merge pull request #1088 from arsenetar/as/remove-shelve
feat: Remove shelve picture cache
2023-01-09 22:48:37 -06:00
Andrew Senetar 0cf6c9a1a2
ci: Update to include python 3.11 & pre-commit 2023-01-09 22:44:10 -06:00
Andrew Senetar 6db2fa2be6
fix: Correct flake8 config
- Add exclude pattern for flake8 when running with pre-commit as it does
  not fully honor the exclude paths.
- Cleanup exclude paths for flake8 in tox.ini
- Re-enable line length check and correct three affected files
2023-01-09 22:35:12 -06:00
Andrew Senetar 2dd2a801cc
feat: Add pre-commit and commitlint 2023-01-09 21:53:22 -06:00
Andrew Senetar 83f5e80427
feat: Remove shelve picture cache
- Remove shelve picture cache as it has had a fair number of historical
  issues.  Original issue for which it was added should be long
  resolved.  Additionally this allows additional consolidation of the
  various cache code and potentially dbs in the future.
- Remove all related preferences and related code for changing cache
  backend between sqlite and shelve.
2023-01-06 00:35:23 -06:00
Andrew Senetar 091cae0cc6
feat: Add confirmation dialog when canceling job
- Implement a confirmation dialog for cancellation of jobs, required
  changing from QProgressDialog to QDialog to keep cleaner.
- Update ui translation source file

Close #1033, #515
2023-01-06 00:06:55 -06:00
Andrew Senetar e30a135451
feat: Add additional scan time options
- Add option to include file existence check at end of scan, speeds up
  end of scan operation time considerably, however if user has removed
  or moved files since starting a scan there could be later errors when
  interacting with results.  Defaults to existing behavior of including
  the check, until it can be verified later dialogs and actions handle
  non-existent items better.
- Add option to ignore differences in mtime when checking hash cache.
  Option is present in advanced tab of preferences.  Closes #1022.
- Regenerate pot files for translations
2023-01-05 23:01:16 -06:00
Andrew Senetar 1db93fd142
Merge pull request #1069 from eugenesan/master
Add webp image format support
2022-12-06 05:50:36 -06:00
Andrew Senetar 48862b6414
Merge pull request #1036 from dktrkranz/desktopfile
Add Keywords tag to desktop file
2022-12-06 05:48:50 -06:00
Eugene San (eugenesan) c920412856 Add webp image format support 2022-11-24 13:53:27 -07:00
Andrew Senetar 4448b999ab
fix: Add W503 to flake8 extend-ignore
For some reason flake8 is now throwing W503, which should be disabled by
default, adding to extend-ignore fixes it, so doing that for now.
2022-09-28 07:05:46 -05:00
Andrew Senetar af1ae33598
Merge pull request #1042 from fascox/patch-1
Update core.po for `it`
2022-09-28 06:52:52 -05:00
Andrew Senetar 265d10b261
Merge pull request #1026 from muath-ye/patch-1
Update columns.po for `ar`
2022-09-28 06:46:50 -05:00
Dobatymo f1153c85c0 serialize/deserialize colors to/from bytes instead of strings
it's a tiny bit faster and saves a bit of memory
2022-09-27 17:47:38 +08:00
Fabio Scognamiglio 1eee3fd7e4
Update core.po
fix mispelled translation
2022-09-10 13:29:04 +02:00
Luca Falavigna 1827827fdf Add Keywords tag to desktop file 2022-08-31 14:57:16 +00:00
Muath Alsowadi db174d4e63
Update columns.po 2022-08-07 09:32:33 +03:00
Andrew Senetar 1f1dfa88dc
Update version & changelog for 4.3.1 release 2022-07-07 22:06:06 -05:00
Andrew Senetar 916c5204cf
Update translations from transifex 2022-07-07 21:57:59 -05:00
Andrew Senetar 71af825b37
Move try/except of cache db to get() and put()
- Move the try/except of cache db calls to the calls themselves.
- Add some additional information to logging statements on cache db
  exception to improve troubleshooting.
2022-07-07 21:52:22 -05:00
Andrew Senetar 97f490b8b7
Fix typo in engine.py 2022-07-07 19:06:35 -05:00
Andrew Senetar d369bcddd7
Updates from investigation of #1015
- Add protection for empty hash digests in comparison of non-zero size
  files
- Bump version to 4.3.1-dev for identification
2022-07-07 19:00:09 -05:00
Andrew Senetar 360dceca7b
Update to version 4.3.0, update changelog 2022-06-30 23:27:14 -05:00
Andrew Senetar 92b27801c3
Update translations, remove iphoto_plist.py 2022-06-30 23:03:40 -05:00
Marcus Yanello b9aabb8545
Redirect stdout from custom command to the log files (#1008)
Send the logs for the custom command subprocess to the logs
Closes #1007
2022-06-13 21:04:40 -05:00
Andrew Senetar d5eeab4a17
Additional type hints in hscommon 2022-05-11 00:50:34 -05:00
Andrew Senetar 7865e4aeac
Type hinting hscommon & cleanup 2022-05-09 23:36:39 -05:00
Andrew Senetar 58863b1728
Change to use a real temporary directory for test
app_test was not using a real temporary location originally
2022-05-09 01:46:42 -05:00
Andrew Senetar e382683f66
Replace all relative imports 2022-05-09 01:40:08 -05:00
Andrew Senetar f7ed1c801c
Add type hinting to desktop.py 2022-05-09 01:15:25 -05:00
Andrew Senetar f587c7b5d8
Removed unused code in hscommon/util
Also added type hints throughout
2022-05-09 00:47:57 -05:00
Andrew Senetar 40ff40bea8
Move create_qsettings() out of preferences
- Load order was impacting translations
- Fix by moving create_qsettings() for now
2022-05-08 20:33:31 -05:00
Andrew Senetar 7a44c72a0a
Complete removal of qtlib locale files 2022-05-08 19:52:25 -05:00
Andrew Senetar 66aff9f74e
Update pot files
This "moves" the translation points from qtlib.pot to ui.pot.
Needs further updates to propagate across.
2022-05-08 19:28:37 -05:00
Andrew Senetar 5451f55219
Move qtlib localization files to top level 2022-05-08 19:23:13 -05:00
Andrew Senetar 36280b01e6
Finish moving all qtlib py files to qt 2022-05-08 19:22:08 -05:00
Andrew Senetar 18359c3ea6
Start flattening Qtlib into qt
- Remove app.py from qtlib (unused)
- Remove .gitignore from qtlib (unecessary)
- Move contents of preferences.py in qtlib to qt, clean up references
- Simplify language dropdown code
2022-05-08 18:51:10 -05:00
Andrew Senetar 0a4e61edf5
Additional cleanup per mypy
- Add Callable type to hasher (should realy be more specific...)
- Add type hint to COLUMNS in qtlib/table.py
- Use Qt.ItemFlag.ItemIsEnabled instead of Qt.itemIsEnabled in qtlib/table.py
2022-04-30 05:16:46 -05:00
Andrew Senetar d73a85b82e
Add type hints for compiled modules 2022-04-30 05:11:54 -05:00
Andrew Senetar 81c593399e
Format changes with black 2022-04-27 20:59:20 -05:00
Andrew Senetar 6a732a79a8
Remove old tx config 2022-04-27 20:58:30 -05:00
Andrew Senetar 63dd4d4561
Apply pyupgrade changes 2022-04-27 20:53:12 -05:00
Andrew Senetar e0061d7bc1
Fix #989, typo in debian control file 2022-04-02 16:43:19 -05:00
Andrew Senetar c5818b1d1f
Add option to profile scans
- Add preference for profiling scans
- Move debug options to tab in preferences
- Add label with clickable link to debug output (appdata) to debug tab in preferences
- Update translation source files
2022-03-31 00:16:37 -05:00
Andrew Senetar a470a8de25
Update fs.py to optimize stat() calls
- Update to get size and mtime at time of class creation when os.DirEntry is used for initialization.
- Folders still calculate size later for folder scans.
- Ref #962, #959
2022-03-30 22:58:01 -05:00
Andrew Senetar a37b5b0eeb
Fix #988 2022-03-30 01:06:51 -05:00
Andrew Senetar efd500ecc1
Update directory scanning to use os.scandir()
- Change to use os.scandir() instead of os.walk() to leverage DirEntry objects.
- Avoids extra calls to stat() on files during fs.can_handle()
- See 3x speed improvement on Windows in some cases
2022-03-29 23:37:56 -05:00
Andrew Senetar 43fcc52291
Replace pathlib.glob() with os.scandir() in fs.py 2022-03-29 22:35:38 -05:00
Andrew Senetar 50f5db1543
Update fs to support DirEntry on get_file() 2022-03-29 22:32:36 -05:00
Andrew Senetar a5b0ccdd02
Improve performance of Directories.get_state() 2022-03-29 21:48:14 -05:00
Andrew Senetar 143147cb8e
Remove Cocoa specific and other unused code 2022-03-28 00:47:46 -05:00
Andrew Senetar ebb81d9f03
Remove pathlib function added in Python 3.9 2022-03-28 00:06:32 -05:00
Andrew Senetar da9f8b2b9d
Squashed commit of the following:
commit 8b15fe9a502ebf4841c6529e7098cef03a6a5e6f
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Sun Mar 27 23:48:15 2022 -0500

    Finish up changes to copy_or_move

commit 21f6a32cf3186a400af8f30e67ad2743dc9a49bd
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Thu Mar 17 23:56:52 2022 -0500

    Migrate from hscommon.path to pathlib
    - Part one, this gets all hscommon and core tests passing
    - App appears to be able to load directories and complete scans, need further testing
    - app.py copy_or_move needs some additional work
2022-03-27 23:50:03 -05:00
Andrew Senetar 5ed5eddde6
Add polib back to requirements.txt 2022-03-27 22:35:34 -05:00
Andrew Senetar 9f40e4e786
Squashed commit of the following:
commit 5eb515f666bfa1ff06c2e96bdc351a4b7456580e
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Sun Mar 27 22:19:39 2022 -0500

    Add fallback to md5 if xxhash not available

    Mainly here for the case when distributions have not packaged python3-xxhash.

commit 51b18d4c84
Author: Andrew Senetar <arsenetar@gmail.com>
Date:   Sat Mar 19 15:25:46 2022 -0500

    Switch file hashing to xxhash instead of md5

    - Improves performance significantly in some cases
    - Add xxhash to requirements.txt and sort requirements
    - Rename md5 based members to digest
    - Update all tests to use new member names and hashing methods
    - Update hash db code to upgrade schema

    NOTE: May consider supporting multiple hashing algorithms in the future.
2022-03-27 22:27:13 -05:00
Andrew Senetar 86bf9b39d0
Add update check function and call from about
- Implement a update check against the GitHub releases via the api
- Add semantic-version dependency
- Add automatic check when opening about dialog
2022-03-27 21:13:27 -05:00
Andrew Senetar c0be0aecbd
Minor documentation update 2022-03-27 21:04:37 -05:00
Andrew Senetar c408873d20
Update changelog 2022-03-25 23:37:46 -05:00
Andrew Senetar bbcdfbf698
Add vscode extension recommendation 2022-03-21 22:27:16 -05:00
Andrew Senetar 8cee1a9467
Fix internal links in CONTRIBUTING.md 2022-03-21 22:19:58 -05:00
Andrew Senetar 448d33dcb6
Add workflow yml validation settings
- Add yml validation to project for vscode
- Allow .vscode/settings.json
- Apply formatting to workflow files
2022-03-21 22:18:22 -05:00
Andrew Senetar 8d414cadac
Add initial partial CONTRIBUTING.md
- Adopt a CONTRIBUTING.md format similar to that used by atom/atom.
- Add label section as replacement to wiki
- Add style guide section
- Setup basic document structure

TODO:
- Migrate some existing wiki information here where applicable.
- Migrate some existing help information here.
- Finish up remaining sections.
2022-03-21 22:04:45 -05:00
Andrew Senetar f902ee889a
Add configuration for isort to pyproject.toml 2022-03-21 00:25:36 -05:00
Andrew Senetar bc89e71935
Update .gitignore
- Pull from github/gitignore to cover some things better
- Organize remaining items
- Remove a few no longer relevant items
2022-03-20 23:25:01 -05:00
Andrew Senetar 17b83c8001
Move polib to setup_requires instead of install_requires 2022-03-20 22:48:03 -05:00
Andrew Senetar 0f845ee67a
Update min python version in Makefile 2022-03-20 01:23:01 -05:00
Andrew Senetar d40e32a143
Update transifex config & pull latest updates
- Update transifex configuration to new format
- Pull translation updates
2022-03-19 20:21:14 -05:00
Andrew Senetar 1bc206e62d
Bump version to 4.2.1 2022-03-19 19:02:41 -05:00
Andrew Senetar 106a0feaba
Add sponsor information 2022-03-19 17:46:12 -05:00
Andrew Senetar 984e0c4094
Fix help path for local files and some help doc updates 2022-03-19 17:43:11 -05:00
Andrew Senetar 9321e811d7
Enforce minimum Windows version ref #983 2022-03-19 17:01:54 -05:00
Andrew Senetar a64fcbfb5c
Fix deprecation warning from sqlite 2022-03-19 17:01:53 -05:00
Andrew Senetar cff07a12d6
Black formatter changes 2022-03-19 17:01:53 -05:00
Alfonso Montero b9c7832c4a
Apply @arsenetar's proposed change to fix for errors on window change event. Solves #937. (#980) 2022-03-15 20:47:48 -05:00
Andrew Senetar b9dfeac2f3
Drop Python 3.6 Support 2022-03-15 05:10:41 -05:00
Andrew Senetar efc99eee96
Merge pull request #978 from glubsy/fix_zoom_scrollbar
Fix image viewer scrollbar zoom
2022-03-14 20:43:40 -05:00
glubsy ff7733bb73 Fix image viewer
When zooming in or out, the value computed might be a float instead
of an int, which is what the QScrollBar expect for its setValue method.
Simply casting to int should be enough here.
2022-03-12 22:36:17 +01:00
Andrew Senetar 4b2fbe87ea
Default to English on unsupported system language Fix #976
- Add check for supported language to system locale detection
- Fall-back to English when not a supported locale
2022-03-12 04:36:13 -06:00
Andrew Senetar 9e4b41feb5
Fix BASE_PATH for frozen macOS app 2022-03-09 06:50:41 -06:00
Andrew Senetar cbfa8720f1
Update imports for objc module 2022-03-09 05:01:12 -06:00
Andrew Senetar a02c5e5b9b
Add built modules as artifacts 2022-03-04 01:14:01 -06:00
Andrew Senetar 35e6ffd6af
Fix macOS packaging issue 2022-02-09 22:33:41 -06:00
Andrew Senetar e957f840da
Fix python version check in makefile, close #971 2022-02-09 21:59:35 -06:00
Andrew Senetar 85e22089bd
Black formatting changes 2022-02-09 21:49:51 -06:00
Andrew Senetar b7d68b4458
Update debian control template depends 2022-02-09 21:45:45 -06:00
Andrew Senetar 8f440603ee
Add Python 3.10 to tox.ini 2022-01-25 10:39:52 -06:00
Andrew Senetar 5d8e559ca3
Fix issue introduced in fix for #900 2022-01-25 10:39:08 -06:00
Andrew Senetar 2c11eecf97
Update version and changelog to 4.2.0 2022-01-24 22:28:40 -06:00
Andrew Senetar 02803f738b
Update translation files including Malay 2022-01-24 21:05:33 -06:00
Andrew Senetar db27e6a645
Add Malay to language selection 2022-01-24 21:02:57 -06:00
Andrew Senetar c9c35cc60d
Add translation source file for dark style change. 2022-01-24 19:33:42 -06:00
Andrew Senetar 880205dbc8
Fix python 3.10 in default action 2022-01-24 19:30:42 -06:00
Andrew Senetar 6456e64328
Update python versions for CI/CD
- Update python versions for Default action
- Set python versions for sonarcloud
2022-01-24 19:27:29 -06:00
Andrew Senetar f6a0c0cc6d
Add initial dark style for use in Windows
- Other platforms can achieve this with the OS theme so not enabled for them at this time.
- Adds preference in display options to use dark style, default is false.
2022-01-24 19:14:30 -06:00
Andrew Senetar eb57d269fc
Update translation source files 2021-11-23 21:11:30 -06:00
Andrew Senetar 34f41dc522
Merge pull request #942 from Dobatymo/hash-cache
Implement hash cache for md5 hash based on sqlite
2021-11-23 21:08:22 -06:00
Dobatymo 77460045c4 clean up abstraction 2021-10-29 15:24:47 +08:00
Dobatymo 9753afba74 change FilesDB to singleton class
move hash calculation back in to Files class
clear cache now clears hash cache in addition to picture cache
2021-10-29 15:12:40 +08:00
Dobatymo 1ea108fc2b changed cache filename 2021-10-29 15:12:40 +08:00
Dobatymo 2f02a6010d implement hash cache for md5 hash based on sqlite 2021-10-29 15:12:40 +08:00
Andrew Senetar b80489fd66
Update translation source files 2021-09-15 20:15:09 -05:00
Andrew Senetar 1d60e124ee
Update invoke_custom_command to run for all selected items 2021-09-02 20:48:25 -05:00
Andrew Senetar e22d7d2fc9
Remove filtering of 0 size files in engine
Files size is already able to be filtered at a higher level, some users
may decide to see zero length files. Fix #321.
2021-08-28 18:16:22 -05:00
Andrew Senetar 0a0694e095
Expand fix for #630 to fix #551 2021-08-28 17:29:25 -05:00
Andrew Senetar 3da9d5d869
Update documentation files, add multi-language doc build
- Update links in documentation, and some errors
- Remove non-existent page
- Update build to build all languages with --alldoc flag
- Fix one minor debugging change introduced in package.py
2021-08-28 17:07:18 -05:00
Andrew Senetar 78fb052d77
Add more progress details to getmatches, ref #700 2021-08-28 04:58:22 -05:00
Andrew Senetar 9805cba10d
Use different message for direct delete success, close #904 2021-08-28 04:27:34 -05:00
Andrew Senetar 4c3dfe2f1f
Provide more feedback during scans
- Add output for number of collected files / folders
- Update to allow indeterminate progress bar
- Remove unused hscommon\jobprogress\qt.py
2021-08-28 04:05:07 -05:00
Andrew Senetar b0baa5bfd6
Add windows position handling at open, fix #653
- Move offscreen windows back on screen
- Restore maximized state without impacting resored size
- Fullscreen comes back on primary screen, needs further work to support
  restore on other screens
2021-08-27 23:26:19 -05:00
Andrew Senetar 22996ee914
Merge pull request #935 from chchia/master
resize preference dialog file size box
2021-08-27 21:57:03 -05:00
chchia 31ec9c667f resize preference dialog file size box 2021-08-28 10:28:06 +08:00
Andrew Senetar 3045361243
Add preference to ignore large files, close #430 2021-08-27 05:35:54 -05:00
Andrew Senetar 809116c764
Fix CodeQL Alerts
- Cast int to Py_ssize_t for multiplication
2021-08-26 03:43:31 -05:00
Andrew Senetar 83f401595d
Minor Updates
- Cleanup extension modules in setup.py to use correct namespaces
- Update build.py to leverage setup.py for modules
- Roll mutagen required version back to 1.44.0 to support more distros
- Change build.py and sphinxgen.py to use pathlib
- Remove hsaudiotag from package list for debian and arch
2021-08-26 03:29:24 -05:00
Andrew Senetar 814d145366
Updates to setup files
- Include additional non-python files in MANIFEST.in (package_data in
  setup.cfg was not including the files)
- Update requirements in setup.cfg
2021-08-25 04:10:38 -05:00
Andrew Senetar efb76c7686
Add OS and Python Information to error dialog 2021-08-25 02:05:18 -05:00
Andrew Senetar 47dbe805bb
More cleanup and fixed a flake8 build issue 2021-08-25 01:11:24 -05:00
Andrew Senetar f11fccc889
More cleanups
- Cleanup columns.py and tables
- Other misc cleanups
- Remove text_field.py from qtlib as it is not used
- Remove unused variables from image_viewer method
2021-08-25 00:46:33 -05:00
Andrew Senetar 2e13c4ccb5
Update internationalization files 2021-08-24 03:54:54 -05:00
Andrew Senetar da72ffd1fd
Add ability to use non-native dialog for directories
- Add preference for native dialogs
- Add non-native directory selection to allow selecting multiple folders
  fixes #874 when using non-native.
2021-08-24 03:52:43 -05:00
Andrew Senetar 2c9437bef4
Fix #897 2021-08-24 03:13:03 -05:00
Andrew Senetar f9085386a6
First pass code cleanup in qt/qtlib 2021-08-24 00:12:23 -05:00
Andrew Senetar d576a7043c
Code cleanups in core and other affected files 2021-08-21 18:02:02 -05:00
Andrew Senetar 1ef5f56158
Code cleanups in hscommon & external effects 2021-08-21 16:56:27 -05:00
Andrew Senetar f9316de244
Code cleanups in hscommon\tests 2021-08-21 16:25:33 -05:00
Andrew Senetar 0189c29f47
Misc cleanups in core/tests 2021-08-21 03:52:09 -05:00
Andrew Senetar b4fa1d68f0
Add check for python version to build.py, close #589 2021-08-20 23:49:20 -05:00
Andrew Senetar 16df882481
Update requirements.txt for previous change 2021-08-19 00:17:46 -05:00
Andrew Senetar 58c04ff9ad
Switch from hsaudiotag to mutagen, close #440
- This opens up the ability to support more tags and audio information
- Also makes progress on #333
2021-08-19 00:14:26 -05:00
Andrew Senetar 6b8f85e39a
Reveal in Explorer / Finder, close #895 2021-08-18 20:51:45 -05:00
Andrew Senetar 2fff1a3436
Add ablity to load results at start, closes #902
- Add ablility to load .dupguru file at start by passing as first argument
- Add file association to .dupeguru file in windows at install
2021-08-18 19:24:14 -05:00
Andrew Senetar a685524dd5
Add files for more standardized build tools
- Prior investigation into linux packaging (not using pyinstaller) suggested
having setuptools files could make packaging easier and automatable
- Add setup.cfg and setup.py as initial starting point
- Add MANIFEST.in (at least temporarily)

Currently with the python build module this almost works for main application.
It does not include all the extra data files right now.
2021-08-18 04:12:38 -05:00
Andrew Senetar 74918e2c56
Attempt to fix apt-get failure 2021-08-18 03:07:47 -05:00
Andrew Senetar 18895d983b
Fix syntax error in codeql-analysis.yml 2021-08-18 03:04:44 -05:00
Andrew Senetar fe720208ea
Add minimum custom build for codeql cpp 2021-08-18 02:49:20 -05:00
Andrew Senetar 091d9e9239
Create codeql-analysis.yml
Test out codeql
2021-08-18 02:33:40 -05:00
Andrew Senetar 5a4958cff9
Update translation .pot files 2021-08-17 21:18:47 -05:00
Andrew Senetar be10b462fc
Add portable mode
If settings.ini is present next to the executable, will run in portable mode.
This results in settings, data, and cache all being in same folder as dupeGuru.
2021-08-17 21:12:32 -05:00
Andrew Senetar d62b13bcdb
Removing travis
- All CI is now covered by Github Actions
- Remove .travis.yml
- Remove tox-travis in requirements-extra.txt
2021-08-17 18:16:20 -05:00
Andrew Senetar 06eca11f0b
Remove format check from lint job 2021-08-17 00:52:14 -05:00
Andrew Senetar 2879f18e0d
Run linting and formatting check in parallel before test 2021-08-17 00:50:41 -05:00
Andrew Senetar 3ee21771f9
Fix workflow file format 2021-08-17 00:33:54 -05:00
Andrew Senetar c0ba6fb57a
Test out github actions
Add a workflow to test
2021-08-17 00:31:15 -05:00
Andrew Senetar bc942b8263
Add black format check to tox runs 2021-08-15 04:10:46 -05:00
Andrew Senetar ffe6b7047c
Format all files with black correcting line length 2021-08-15 04:10:18 -05:00
Andrew Senetar 9446f37fad
Remove flake8 E731 Errors
Note: black formatting is now applying correctly as well.
2021-08-15 03:53:43 -05:00
Andrew Senetar af19660c18
Update flake8 and black configuration
- Update black to now use 120 lines
- Update flake8 to use recommended settings for black integration
2021-08-15 03:32:31 -05:00
Andrew Senetar 99ad297906
Change preferences to use spinboxes where applicable
- Change LineEdit to Spinbox for minimum file size 0-1,000,000KB
- Change LineEdit to Spinbox for big file size 0-1,000,000MB
2021-08-15 02:11:42 -05:00
Andrew Senetar e11f996dfc
Merge pull request #908 from glubsy/hash_sample_optimization
Hash sample optimization
2021-08-13 23:41:17 -05:00
glubsy e95306e58f Fix flake 8 2021-08-14 02:52:00 +02:00
glubsy 891a875990 Cache constant expression
Perhaps the python byte code is already optimized, but just in case it is not, keep pre-compute the constant expression.
2021-08-13 21:33:21 +02:00
glubsy 545a5a75fb Fix for older python versions
The "walrus" operator is only available in python 3.8 and later. Fall back to more traditional notation.
2021-08-13 20:56:33 +02:00
glubsy 7b764f183e Avoid partially hashing small files
Computing 3 hash samples for files less than 3MiB (3 * CHUNK_SIZE) is not efficient since spans of later samples would overlap a previous one.
Therefore we can simply return the hash of the entire small file instead.
2021-08-13 20:47:01 +02:00
Andrew Senetar fdc8a17d26
Update .travis.yml
- Windows test uses 3.9.6 now
- Intentation changes
2021-08-07 19:35:57 -05:00
Andrew Senetar cb3bbbec6e
Upgrade Requirement Minimums
- Upgrade requirements to specify more current minimums
- Remove compatability code from sphinxgen for old versions
- Upgrade pyinstaller to a minimum version that works with latest macOS
2021-08-07 19:28:41 -05:00
Andrew Senetar c51a82a2ce
Fix Issues from Translation Update
- Add Qtlib to transifex config
- Pull latest qtlib translations
- Fix flake8 error
- Remove code for manual translation import, use transifex-client instead
2021-08-06 22:21:35 -05:00
Andrew Senetar 0cd8f5e948
Update translation pot files 2021-08-06 21:41:52 -05:00
Andrew Senetar 9c09607c08
Add Turkish & Updates from Transifex
- Pull updates from Transifex
- Add Turkish
- Sort language lists in code
- Remove old locale conversion code as it appears to work correctly on
windows without different conversions.
2021-08-06 21:41:52 -05:00
Andrew Senetar 3bd342770c
Update configurations
- Enable Unicode for NSIS Installer
- Update transifex config to new project
2021-08-06 21:41:52 -05:00
Andrew Senetar 14b456dcf9
Merge pull request #927 from glubsy/fix_directories_tests
Fix Directories regex test
2021-08-06 20:08:27 -05:00
glubsy 3dccb686e2 Fix Directories regex test
The entire path to the file would match unless another path separator is added.
2021-08-06 17:18:23 +02:00
Andrew Senetar 0db66baace
Merge pull request #907 from glubsy/missing_renamed_regex
Missing renamed regex
2021-08-03 22:26:08 -05:00
Andrew Senetar e3828ae2ca
Merge pull request #911 from glubsy/fix_757_fix_regression
Fix infinite recursion
2021-06-22 22:44:12 -05:00
glubsy 23c59787e5 Fix infinite recursion
Force the Results to update its internal __dupes list whenever at least one group has re-prioritized and changed its dupes/ref.
2021-06-23 05:36:10 +02:00
Andrew Senetar 2f8d603251
Merge pull request #910 from glubsy/757_fix
Fix refs appearing in dupes-only view
2021-06-22 21:54:49 -05:00
glubsy a51f263632 Fix refs appearing in dupes-only view
* Some refs appeared in the dupes-only view after a re-prioritization was done a second time.
* It seems the core.Results.__dupes list was not properly updated whenever core.app.Dupeguru.reprioritize_groups() -> core.Results.sort_dupes() was called.
When a re-prioritization is done, some refs became dupe, and some dupes became ref in their place. So we need to update the new state of the internal list of dupes kept by the Results object, instead of relying on the outdated cached one.
* Fix #757.
2021-06-22 22:57:57 +02:00
glubsy 718ca5b313 Remove unused import 2021-06-22 02:41:33 +02:00
glubsy 277bc3fbb8 Add unit tests for hash sample optimization
* Instead of keeping md5 samples separate, merge them as one hash computed from the various selected chunks we picked.
* We don't need to keep a boolean to see whether or not the user chose to optimize; we can simply compare the value of the threshold, since 0 means no optimization currently active.
2021-06-21 22:44:05 +02:00
glubsy e07dfd5955 Add partial hashes optimization for big files
* Big files above the user selected threshold can be partially hashed in 3 places.
* If the user is willing to take the risk, we consider files with identical md5samples as being identical.
2021-06-21 19:03:21 +02:00
Andrew Senetar 4641bd6ec9
Merge pull request #905 from glubsy/fix_863
Fix exception when deleting while in delta view
2021-06-19 20:29:47 -05:00
glubsy a6f83ad3d7 Fix missing regexp after rename
* Doing a full match should be safer to avoid partial results which would result in overly aggressive filtering.
* Add new tests to test suite to cover this issue.
* Fixes #903.
2021-06-19 02:00:25 +02:00
glubsy ab8750eedb Fix partial regex match yielding false positive 2021-06-17 03:49:59 +02:00
glubsy 22033211d6 Fix exception when deleting while in delta view 2021-05-31 23:49:21 +02:00
Andrew Senetar 0b46ca2222
Merge pull request #879 from glubsy/fix_unicode
Fix stripping (japanese) unicode characters
2021-05-25 19:11:19 -05:00
Andrew Senetar 72e0f76242
Merge pull request #898 from AlttiRi/master
Change reference background color #894
2021-05-25 19:10:31 -05:00
[Alt'tiRi] 65c1d463f8
Change reference background color #894 2021-05-22 02:52:41 +03:00
Andrew Senetar e6c791ab0a
Merge pull request #884 from samusz/master
Small typo
2021-05-09 23:32:32 -05:00
Sacha Muszlak 78f5088101
Merge pull request #1 from samusz/samusz-patch-1
typo correction
2021-05-07 09:41:47 +02:00
Sacha Muszlak 095df5eb95
typo correction 2021-05-07 09:40:08 +02:00
glubsy f1ae478433 Fix including character at the border 2021-04-29 05:29:35 +02:00
glubsy c4dcfd3d4b Fix stripping (japanese) unicode characters
* Accents are getting removed from Unicode characters to generate similar "words".
* Non-latin characters which cannot be processed that way (eg. japanese, greek, russian, etc.) should not be filtered out at all otherwise files are erroneously skipped or detected as dupes if only some characters make it passed the filter.
* Starting from an arbitrary unicode codepoint (converted to decimal), above which we know it is pointless to try any sort of processing, we leave the characters as is.
* Fix #878.
2021-04-29 05:15:34 +02:00
Andrew Senetar 0840104edf
Merge pull request #873 from glubsy/fix_857
Fix 857
2021-04-20 20:05:05 -05:00
glubsy 6b4b436251 Fix crash on shutdown
* Fixes "'DetailsPanel' object has no attribute '_table'" error on shutdown if the Results table is updated (item removed) while the Details Dialog is shown as a floating window.
* It seems that QApplication.quit() triggers some sort of refresh on the floating QDockWidget, which in turn makes calls to the underlying model that is possibly being destroyed, ie. there might be a race condition here.
* Closing or hiding the QDockWidget before the cal to quit() is a workaround. Similarly, this is already done in the quitTriggered() method anyway.
* This fixes #857.
2021-04-16 17:54:49 +02:00
glubsy d18b8c10ec Remove redundant assignment
The "app" field is already set in the parent class.
2021-04-15 18:03:00 +02:00
Andrew Senetar 4a40b346a4
Update to 4.1.1 2021-03-21 22:50:33 -05:00
Andrew Senetar 035cdc23b1
Update translations from Transifex 2021-03-21 22:45:19 -05:00
Andrew Senetar fbdb333457
Update a few translation items
- Add Japanese as a selectable language
- Wrap a few missed strings in tr()
- Regenerate .pot files
2021-03-17 20:21:29 -05:00
Andrew Senetar e36aab177c
Add import feature to build.py for translations 2021-03-17 19:55:00 -05:00
Andrew Senetar 77116ba94b
Bring in the languages that came incorrect last import again 2021-03-17 19:44:16 -05:00
Andrew Senetar d7f79aefd2
Remove translations imported incorrectly 2021-03-17 19:40:47 -05:00
Andrew Senetar 4c939f379c
Update translations from transifex 2021-03-09 21:16:37 -06:00
Andrew Senetar d098fe2281
Update translation pot files 2021-03-09 20:38:03 -06:00
Andrew Senetar 09cfbad38d
Merge pull request #844 from glubsy/translation_fixes
Fix problematic string for translations
2021-03-09 20:19:08 -06:00
glubsy 528dedd813 Fix problematic string for translations
Some languages have very different phrase syntaxes depending on which word is used.
Better used two separate strings than a dynamically created one.
2021-02-09 01:40:00 +01:00
Andrew Senetar b30d67b834
Merge pull request #775 from glubsy/PR_typo_fix
Fix label strings
2021-02-02 19:08:28 -06:00
glubsy 3e6e74e2a9 Update URL 2021-01-30 22:17:43 +01:00
glubsy b919b3ddc8 Fix typo 2021-01-30 04:20:22 +01:00
glubsy be3862fa8f fix typo 2021-01-29 18:56:29 +01:00
glubsy da09920553 Update exclusion filter help string 2021-01-29 17:57:44 +01:00
glubsy 2baba3bfa0 Fix selection label 2021-01-29 17:38:37 +01:00
Andrew Senetar a659a70dbe
Add transifex project link to readme 2021-01-28 23:04:44 -06:00
Andrew Senetar c9e48a5e3b
Update pyrcc5 note with new information
New information about the other system package which resolves the dependency
added.
This was brought up in #766.
2021-01-21 19:08:59 -06:00
Andrew Senetar 68711162d1
Add note about pyrcc5 2021-01-21 18:49:44 -06:00
Andrew Senetar 0b0fd36629
Revert "Update ReadMe and requirements"
This reverts commit bf5d151799.
2021-01-21 18:33:40 -06:00
Andrew Senetar bf5d151799
Update ReadMe and requirements
- On linux (Debian based) pyrcc5 does not make it onto the path so
updating the notes here to take care of this behavior and update requirements
so virtual environment load it correctly.
- Fix #766
2021-01-21 18:13:17 -06:00
Andrew Senetar e29a427caf
Update translation files 2021-01-11 22:38:03 -06:00
Andrew Senetar 95ccbad92b
Fix #760, issue with language on windows
Fix the issue related to run.py qsettings not using the same options as
in preferences.py
2021-01-11 21:41:14 -06:00
Andrew Senetar 421a58a61c
Merge pull request #758 from serg-z/serg-z/prioritize-dialog-multi-selections
Prioritize dialog: adding/removing multiple items, adding/removing on double clicking an item, drag-n-drop fix
2021-01-11 18:50:15 -06:00
Sergey Zhuravlevich b5a3313f80 Prioritize dialog: fix drag-n-drop putting items before the last item
When the items in the prioritizations list were drag-n-dropped to the
empty space, the row was equal to -1 and the dropped items ended up
being moved to the position before the last item. Fixing the row value
helps to avoid that behavior.

Signed-off-by: Sergey Zhuravlevich <sergey@zhur.xyz>
2021-01-07 17:42:43 +01:00
Sergey Zhuravlevich 116ac18e13 Prioritize dialog: add/remove criteria on double clicking an item
Signed-off-by: Sergey Zhuravlevich <sergey@zhur.xyz>
2021-01-07 17:42:43 +01:00
Sergey Zhuravlevich 32dcd90b50 Prioritize dialog: allow removing multiple prioritizations at once
Removing prioritizations one-by-one can be tedious. This commit enables
extended selection in the prioritizations list. Multiple items can be
selected with conventional methods, such as holding down Ctrl or Shift
key and clicking the items or holding down the left mouse button and
hovering the cursor over the list. All items also can be selected with
Ctrl+A.

Multiple items drag-n-drop is also possible.

To avoid confusion, the selection in the prioritizations list is cleared
after the items are removed or drag-n-dropped.

Signed-off-by: Sergey Zhuravlevich <sergey@zhur.xyz>
2021-01-07 17:42:30 +01:00
Sergey Zhuravlevich c2fef8d624 Prioritize dialog: allow adding multiple criteria at once
Adding criteria to the prioritizations list one-by-one can be tedious.
This commit enables extended selection in the criteria list and
implements adding multiple items. Multiple criteria can be selected with
conventional methods, such as holding down Ctrl or Shift keys and
clicking the items or holding down the left mouse button and hovering
the cursor over the list. All items also can be selected with Ctrl+A.

Signed-off-by: Sergey Zhuravlevich <sergey@zhur.xyz>
2021-01-07 17:42:07 +01:00
Andrew Senetar fd0adc77b3
Update Readme notes for system setup 2021-01-06 12:22:15 -06:00
Andrew Senetar 6a03e1e399
Update URLs 2021-01-05 23:21:44 -06:00
Andrew Senetar ae51842007
Update README.md 2021-01-05 23:04:42 -06:00
Andrew Senetar ab6acd9e88
Merge pull request #733 from glubsy/dev
Increment version to 4.1.0
2021-01-05 22:48:21 -06:00
Andrew Senetar 6a2c1eb293
Fix flake8 issues introduced in package.py 2020-12-30 20:04:14 -06:00
Andrew Senetar 7b4c31d262
Update for macos Qt version
- Update package.py to include a pyinstaller based packaging
- Update requirements and requirements-extra
- Add icon for macos
- Add macos.md for instructions
2020-12-30 16:44:27 -06:00
glubsy 5553414205 Fix updating QTableView on input
* When clicking on the test regex button or editing the test input field, the tableView doesn't update its data properly.
* Somehow QTableView.update() doesn't request the data from the model.
* The workaround is to call refresh on the model directly, which will in turn update its view.
2020-12-30 23:18:42 +01:00
glubsy b138dfad33 Fix exception when testing invalid regex
* If a regex in the table is invalid and failed to compile, its "compiled" property is None.
* Only test against the regex if its compilation worked.
2020-12-30 22:50:42 +01:00
Andrew Senetar 701e6d4bb2
Merge pull request #755 from glubsy/packaging
Fix Debian packaging issues
2020-12-30 14:41:34 -06:00
Andrew Senetar b44d1652b6
Change windows to use ini in AppData 2020-12-30 12:43:10 -06:00
glubsy 990eaaa797 Update requirements.txt
* Recently, the "hsaudiotag3k" on pypi has changed name slightly
* The actual version is now "1.1.3.post1"
* This avoids errors when invoking `pip -r requirements.txt`
2020-12-30 18:52:37 +01:00
glubsy 348ce95f83 Remove comment
* There is a bug with pyqt5<=5.14 where the table does not update after a call to update() and needs to receive a mouse click event in order to repaint as expected.
* This does not affect Windows only as this is a Qt5 bug.
* This seems to be fixed with pyqt5>=5.15.1.
2020-12-30 18:44:38 +01:00
glubsy 3255bdf0a2 Fix incorrect path 2020-12-30 17:55:53 +01:00
glubsy 1058247b44 Fix missing application icon
Should be placed in /usr/share/pixmaps for .dekstop file to point to it.
2020-12-30 00:24:15 +01:00
glubsy 7414f82e28 Fix missing directory for pixmap symlink in Debian 2020-12-29 23:57:10 +01:00
glubsy 8105bb709f Fix debian src package build
Workaround "dpkg-source: error: can't build with source format '3.0 (native)': native package version may not have a revision" error as mentioned in #753
2020-12-29 23:45:15 +01:00
Andrew Senetar ec628751af
Minor cleanup to Windows.md 2020-12-29 14:56:37 -06:00
glubsy 288023d03e Update changelog 2020-12-29 21:51:16 +01:00
glubsy 7740dfca0e Update Readme 2020-12-29 21:31:36 +01:00
Andrew Senetar 1e12ad8d4c
Clean up Makefile & unused files
- Remove requirements-windows.txt as no longer used
- Remove srcpkg.sh as not up to date and not used
- Minor cleanup in makefile
- Update minimum python version to 3.6 in makefile
2020-12-29 14:08:37 -06:00
glubsy c1d94d6771 Merge branch 'master' into dev 2020-12-29 20:10:42 +01:00
Andrew Senetar 7f691d3c31
Merge pull request #705 from glubsy/exclude_list
Add Exclusion Filters
2020-12-29 12:56:44 -06:00
glubsy a93bd3aeee Add missing translation hooks 2020-12-29 18:52:22 +01:00
glubsy 39d353d073 Add comment about Win7 bug
* For some reason the table view doesn't update properly after the test string button is clicked nor when the input field is edited
* The table rows only get repainted the rows properly after receiving a mouse click event
* This doesn't happen on Linux
2020-12-29 18:28:30 +01:00
glubsy b76e86686a Tweak green color on exclude table 2020-12-29 16:41:34 +01:00
glubsy b5f59d27c9 Brighten up validation color
Dark green lacks contrast against black foreground font
2020-12-29 16:31:03 +01:00
glubsy f0d3dec517 Fix exclude tests 2020-12-29 16:07:55 +01:00
glubsy 90c7c067b7 Merge branch 'master' into exclude_list 2020-12-29 15:55:44 +01:00
Andrew Senetar c8cfa954d5
Minor packaging cleanups
- Fix issue with newline in pkg/debian/source/format
- Update pyinstaller requirement to support python 3.8/3.9
2020-12-28 22:51:09 -06:00
glubsy e533a396fb Remove redundant check 2020-12-29 05:39:26 +01:00
glubsy 4b4cc04e87 Fix directories tests on Windows
Regexes did not match properly because the separator for Windows is '\\'
2020-12-29 05:35:30 +01:00
Andrew Senetar e822a67b38
Force correct python environment for tox on windows 2020-12-28 21:18:16 -06:00
Andrew Senetar c30c3400d4
Fix typo in .travis.yml 2020-12-28 21:07:49 -06:00
Andrew Senetar d539517525
Update Windows Requirements & CI
- Merge windows requirements into requirements.txt and requirements-extra.txt
- Update tox.ini to always use build.py
- Update build.py to have module only option
- Update tox.ini to text python 3.9
- Update .travis.yml to test 3.8 and 3.9 on newer Ubuntu LTS
-Update .travis.yml to work with changes to windows tox
(also update windows to 3.8)
2020-12-28 20:59:01 -06:00
glubsy 07eba09ec2 Fix error after merging branches 2020-12-29 01:01:26 +01:00
glubsy 7f19647e4b Remove unused lines 2020-12-29 00:56:25 +01:00
Andrew Senetar bf7d720126
Merge pull request #746 from glubsy/PR_iconpath
Make icon path relative
2020-12-28 14:47:34 -06:00
glubsy 6bc619055e Change version to 4.1.0 2020-12-06 20:13:03 +01:00
glubsy 452d1604bd Make icon path relative
* Removes the hardcoded path to the icon in the .desktop file
* Allows themes to override the default application icon (icons are searched for in theme paths first)
* Debian: create symbolic link in /usr/share/pixmaps that points to the icon file
* Arch: the same thing is done by PKGBUILD maintainers downstream
2020-12-06 18:36:52 +01:00
glubsy 680cb581c1 Merge branch 'master' into exclude_list 2020-10-28 03:58:05 +01:00
Andrew Senetar 1d05f8910d
Merge pull request #701 from glubsy/PR_ref_row_background_color
Change reference row background color
2020-10-27 21:53:53 -05:00
glubsy bd09b30468 Merge branch 'master' into PR_ref_row_background_color 2020-10-28 03:50:13 +01:00
Andrew Senetar 8d9933d035
Merge pull request #683 from glubsy/details_dialog_improvements
Add image comparison features to details dialog
2020-10-27 21:28:23 -05:00
glubsy cf5ba038d7 Remove icon credits from about box
* Moved credits to CREDITS file
* Updated exchange icon with higher hue contrast for better visibility on dark backgrounds
2020-10-28 02:18:41 +01:00
glubsy 59ce740369 Remove print debug statements 2020-10-28 01:50:49 +01:00
glubsy 92feba5f08 Remove obsolete UI setup code 2020-10-28 01:48:39 +01:00
glubsy a265b71d36 Improve comment reflecting modification of function 2020-10-28 01:45:03 +01:00
Andrew Senetar 8d26c921a0
Merge pull request #706 from glubsy/save_directories
Save/Load directories in Directories
2020-10-27 19:10:11 -05:00
glubsy 32d66cd19b Move up to 4.0.5
* Initial push to 4.0.5 milestone
* Update changelog
2020-10-27 19:38:51 +01:00
glubsy 735ba2fd0e Update error dialog traceback message for users
* Incite users to look for already existing issues
* Also invite them to test the very latest version available first
2020-10-27 18:23:14 +01:00
glubsy b16b6ecf4d Fix error after merging branches 2020-10-27 18:15:15 +01:00
glubsy 2875448c71 Merge branch 'save_directories' into dev 2020-10-27 16:23:49 +01:00
glubsy 51b76385c0 Merge branch 'exclude_list' into dev 2020-10-27 16:23:43 +01:00
glubsy b9f8dd6ea0 Merge branch 'PR_ref_row_background_color' into dev 2020-10-27 16:23:35 +01:00
glubsy 6623b04403 Merge branch 'details_dialog_improvements' into dev 2020-10-27 16:23:23 +01:00
glubsy 424d34a7ed Add desktop.ini to filter list 2020-09-04 19:07:07 +02:00
glubsy 2a032d24bc Save/Load directories in Directories
* Add the ability to save / load directories as XML, just like the last_directories.xml which get loaded on program start.
2020-09-04 18:56:25 +02:00
glubsy b8af2a4eb5 Don't show parent window's context menu on viewers
* When right clicking on image viewers while they are docked, the context menu of the Results window showed up.
* This also enables capture of right click and middle click buttons to drag around images, which solves a conflict with some theme engines that enable left mouse button click to drag a window's position regardless of where the event happens, hence blocking the panning.
* Probably unnecessary to check which button is released.
2020-09-03 01:44:01 +02:00
glubsy a55e02b36d Fix table maximum size being off by a few pixels
* Sometimes, the splitter doesn't fully reach the table maximum height, and the scrollbar is still displayed on the right because a few pixels are still hidden.
* It seems the splitter handle counts towards the total height of the widget (the table), so we add it to the maximum height of the table
* The scrollbar disappears when we reach just above the actual table's height
2020-09-02 23:45:31 +02:00
glubsy 18c933b4bf Prevent widget from stretching in layout
* In some themes, the color picker widgets get stretched, while the color picker for the details dialog group doesn't.
This should keep them a bit more consistent across themes.
2020-09-02 20:26:23 +02:00
glubsy ea11a566af Highlight rows when testing regex string
* Add testing feature to Exclusion dialog to allow users to test regexes against an arbitrary string.
* Fixed test suites.
* Improve comments and help dialog box.
2020-09-01 23:02:58 +02:00
glubsy 584e9c92d9 Fix duplicate items in menubar
* When recreating the Results window, the menubar had duplicate items added each time.
* Removing the underlying C++ object is apparently enough to fix the issue.
* SetParent(None) can still be used in case of floating windows
2020-08-31 21:23:53 +02:00
glubsy 4a1641e39d Add test suite, fix bugs 2020-08-31 20:35:56 +02:00
glubsy 26d18945b1 Fix tab indices not aligned with stackwidget's
* The custom QStackWidget+QTabBar class did not manage the tabs properly because the indices in the stackwidget were not aligned with the ones in the tab bar.
* Properly disable exclude list action when it is the currently displayed widget.
* Merge action callbacks for triggering ignore list or exclude list to avoid repeating code and remove unused checks for tab visibility.
* Remove unused SetTabVisible() function.
2020-08-23 16:49:43 +02:00
glubsy 3382bd5e5b Fix crash when recreating Results window/tab
* We need to set the Details Dialog's previous instance to None when recreating a new Results window
otherwise Qt crashes since we are probably dereferencing a dangling reference.
* Also fixes Results tab not showing up when selecting it from the View menu.
2020-08-20 17:12:39 +02:00
glubsy 9f223f3964 Concatenate regexes prio to compilation
* Concatenating regexes into one Pattern might yield better performance under (un)certain conditions.
* Filenames are tested against regexes with no os.sep in them. This may or may not be what we want to do.
And alternative would be to test against the whole (absolute) path of each file, which would filter more agressively.
2020-08-20 02:46:06 +02:00
glubsy 2eaf7e7893 Implement exclude list dialog on the Qt side 2020-08-17 05:54:59 +02:00
glubsy a26de27c47 Implement dialog and base classes for model/view 2020-08-14 20:19:47 +02:00
glubsy 21e62b7374 Colorize background for reference row
As per issue #647, highlight background color for reference for better readability.
2020-08-12 21:37:29 +02:00
Andrew Senetar 9e6b117327
Merge pull request #698 from glubsy/fix_630
Workaround for #630
2020-08-06 23:16:02 -05:00
glubsy 3333d26557 Try to handle conversion to int or fail gracefully 2020-08-07 00:37:37 +02:00
glubsy 6e81042989 Workaround for #630
* In some cases, the function dump_IFD() in core/pe/exif.py assigns a string instead of an int as "values".
* This value is then used as _cached_orientation in core/pe/photo.py in _get_orientation().
* The method _plat_get_blocks() in qt/pe/photo.py was only expecting an integer for the orientation argument, so we work around the issue for now by ignoring the value if it's a string.
2020-08-06 00:23:49 +02:00
glubsy 470307aa3c Ignore path and filename based on regex
* Added initial draft for test suit
* Fixed small logging bug
2020-08-03 16:19:27 +02:00
glubsy 089f00adb8 Fix typo in class member reference 2020-08-03 16:18:15 +02:00
glubsy 76fbfc2822 Fix adding new Result tab if already existed
* Whenever the Result Window already existed and its tab was in second position, and if the ignore list tab was in 3rd position, asking to show the Result window through the View menu would add a new tab and push the Result tab to the third position (ignore list tab would then become 2nd position).
* Fix view menu Directories entry not switching to index "0" in custom tab bar.
2020-08-02 16:12:47 +02:00
glubsy 866bf996cf Prevent Directories tab from closing on MacOS
* The close button on custom tabs cannot be hidden on MacOS for some reason.
* Prevent the directories tab from closing if the close button was clicked by mistake
2020-08-01 19:35:12 +02:00
glubsy 0104d8922c Fix alignment for combo box's label 2020-08-01 19:11:37 +02:00
glubsy fbd7c4fe5f Tweak visuals for cache selection item 2020-08-01 19:07:45 +02:00
glubsy de5e61293b Add stretch to bottom of General pref tab 2020-08-01 19:02:04 +02:00
glubsy a3e402a3af Group general interface options together
* Use QGroupBox to keep items together on the display tab in the preference dialog just like for the other options.
* It is probably not be necessary to keep these as class members
2020-08-01 18:50:44 +02:00
glubsy 056fa819cc Revert stretching last section in Result window
* It seems that stretching the last section automatically is a bit inconvenient on MacOS as it will grow beyond the window border.
* Keep it as it was before for now until a better solution is devised.
2020-08-01 18:42:46 +02:00
glubsy 3be1ee87c6 Merge branch 'master' into details_dialog_improvements 2020-08-01 18:29:22 +02:00
glubsy 628d772766 Use FormLayout instead of GridLayout
QFormLayout should adhere to each platform's style better. It also simplifies the code a bit since we don't have to setup the labels, etc.
2020-08-01 17:40:31 +02:00
glubsy acdeb01206 Tweak preference layout for better readability
* We use GroupBoxes to group items together and surround them in a frame
* Remove separator lines to avoid cluttering
* Adjust columns and their stretch factors for better alignment of buttons
2020-08-01 16:42:14 +02:00
Andrew Senetar ab402d4024
Merge pull request #688 from glubsy/tab_window
Use tabs instead of floating windows
2020-07-31 22:11:31 -05:00
glubsy d2cdcc989b Fix 1 pixel sized color in color picker buttons
* On Linux, even with 1 pixel size, the button is filled entirely with the color selected
* On MacOS, the color pixmap stays at 1 pixel so we hard code the size to 16x16
2020-08-01 02:09:38 +02:00
glubsy 2620d0080c Fix layout error
* Avoid attempting to add a QLayout to DetailsDialog which already has a layout by removing superfluous layout setup.
2020-07-31 22:37:18 +02:00
glubsy 63a9f00552 Add minor change to variable names 2020-07-31 22:27:18 +02:00
glubsy 87f9317805 Place tab bar below menu bar by default 2020-07-31 16:59:34 +02:00
glubsy a542168a0d Reorganize view menu entries and keep consistency 2020-07-31 16:57:18 +02:00
glubsy 86e1b55b02 Fix menu items being wrongly disabled
* Add Directories to the View menu.
* View menu items should be disabled properly depending on whether they point to the current page/tab.
* Keep "Load scan results" actions active while viewing pages other than the Directories tab.
2020-07-31 05:08:08 +02:00
glubsy 1b3b40543b Fix ignore list view menu entry being disabled 2020-07-31 03:59:37 +02:00
glubsy dd6ffe08d7 Add option to place tab bar below main menu 2020-07-31 01:32:29 +02:00
glubsy 11254381a8 Save dock panel position on quit
* Restore the details dialog dock position if it was previously docked (i.e. not floating).
* Since the details_dialog instance was not deleted after closing by default, the previous instances were still saving their own geometry. We now delete them explicitely if we have to recreate a new instance to avoid the signal triggering the callback to save the geometry.
* Since restoreGeometry() and saveGeometry() are only called in our QDockWidget, it should be safe to modify the methods for the Preferences class (in qtlib).
2020-07-30 20:25:20 +02:00
glubsy 23642815f6 Remove unused properties in details table headers 2020-07-30 15:38:37 +02:00
glubsy 7e4f371841 Avoid crash when quitting
* If details dialog failed to be created for some reason, avoid crashing by dereferencing a null pointer
2020-07-30 15:30:09 +02:00
glubsy 9b8637ffc8 Stretch last header section in Result window 2020-07-30 15:16:31 +02:00
glubsy 79613f9b1e Fix crash quitting while details dialog active
* While the details dialog is opened, if quit is triggered, the error message "'DetailsPanel' object has no attribute '_table'" is reported
* A workaround is to cleanly close the dialog before tear down
2020-07-30 03:22:13 +02:00
glubsy fa54e93236 Add preference to turn off scrollbars in viewers
Refactor preference Display page to only include PE specific preferences in the PE mode.
2020-07-30 03:13:58 +02:00
glubsy 8fb82ae3d8 Merge branch 'master' into tab_window 2020-07-29 21:48:32 +02:00
glubsy eab5003e61 Add color preference for delta in details table 2020-07-29 21:43:45 +02:00
glubsy da8c493c9f Toggle visibility of details dialog
* When using the Ctrl+I shortcut or the "Details" button in the Results window, toggle the details dialog on/off.
* This works also while it is docked.
2020-07-29 20:43:18 +02:00
glubsy 9795f14176 Fix title bar toggling on/off when dialog 2020-07-29 20:00:27 +02:00
glubsy 1937120ad7 Fix toggling details view via menu or shortcut
* Using Ctrl+I would toggle the title bar on/off
2020-07-29 04:51:03 +02:00
glubsy 1823575af4 Fix swapping table view columns
We now have only two columns to swap, not 3.
2020-07-29 04:26:40 +02:00
glubsy 7dc9f25b06 Merge branch 'master' into details_dialog_improvements 2020-07-29 04:20:16 +02:00
Andrew Senetar 5502b48089
Merge pull request #685 from glubsy/fix_result_window_action
Fix updating result window action upon creation
2020-07-28 20:05:10 -05:00
Andrew Senetar f02b66fd54
Merge pull request #682 from glubsy/details_table_tweaks
Colorize details table differences, allow moving around of rows
2020-07-28 19:33:21 -05:00
Andrew Senetar d2235f9bc9
Merge pull request #694 from glubsy/fix_matchblock_freeze
Work around frozen progress dialog
2020-07-28 18:10:24 -05:00
glubsy 5f5f9232c1 Properly wait for multiprocesses to exit
* Fix for #693
2020-07-28 16:44:06 +02:00
Andrew Senetar c36fd84512
Merge pull request #691 from glubsy/fix_package_script
Fix error in package script for (Arch) Linux
2020-07-28 00:51:17 -05:00
glubsy 63b2f95cfa Work around frozen progress dialog
* It seems that matchblock.getmatches() returns too early and the (multi-)processes become zombies
* This is a workaround which seems to work by sleeping for one second and avoid zombie processes
2020-07-25 23:37:41 +02:00
glubsy d193e1fd12 Fix typo in error message 2020-07-24 03:50:08 +02:00
glubsy f0adf35db4 Add helpful message in build files are missing 2020-07-24 03:48:07 +02:00
glubsy 49a1beb225 Avoid using workarounds in package script
* Just like the Windows package function counterpart, better abort building the package if the help and locale files have not been build instead of ignoring the error
2020-07-24 03:33:13 +02:00
glubsy f19b5d6ea6 Fix error in package script for (Arch) Linux
* While packaging, the "build/help" and "build/locale" directories are not found.
* Work around the issue with try/except statements.
2020-07-24 03:23:03 +02:00
glubsy 730fadf63f Merge branch 'preferences_tabs' into details_dialog_improvements 2020-07-22 22:41:22 +02:00
glubsy 9ae0d7e5cf Add color picker buttons to preferences dialog
* Buttons display the color currently in use
* Result table uses selected colors accordingly
* Keep items aligned with GridLayouts in preference dialog
* Reordering of items in a more logical manner*
2020-07-22 22:12:46 +02:00
Andrew Senetar 1167519730
Merge pull request #687 from glubsy/ignore_list_wordwrap
Fix word wrap in ignore list dialog
2020-07-21 20:39:14 -05:00
glubsy cf64565012 Add option to use internal icons in details dialog
* On Windows and MacOS, no idea how themes work so only allow Linux to use their theme icons
* Internal icons are used by default on non-Linux platforms
2020-07-21 03:52:15 +02:00
glubsy 298f659f6e Fix Restore Default Preferences button
* When clicking the "Restore Default" in the preferences dialog, only affect the preferences displayed in the current tab. The hidden tab should not be affected by this button.
2020-07-20 05:04:25 +02:00
glubsy 3539263437 Add tabs to preference dialog. 2020-07-20 03:10:06 +02:00
glubsy 6213d50670 Squashed commit of the following:
commit ac941037ff
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Thu Jul 16 22:21:24 2020 +0200

    Fix resize of top frame not updating scaled pixmap

    * Also limit viewing features such as zoom levels when files have different dimensions
    * GraphicsViewImageViewer is still a bit buggy: the scrollbars are toggled on when the pixmap is null in the reference viewer (we do not use that class right anyway)

commit 733b3b0ed4
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Thu Jul 16 01:31:24 2020 +0200

    Prevent zoom for images of differing dimensions

    * If images are not the same size, prevent zooming features from being used by disabling the normal size button, only enable swap

commit 9168d72f38
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Wed Jul 15 22:47:32 2020 +0200

    Update preferences on show(), not in constructor

    * If the dialog window shouldn't have a titlebar during construction, update accordingly only when showing to fix Windows displaying a window without titlebar on first show
    * Only save geometry if the window is floating. Otherwise geometry while docked is saved whih gives weird results on subsequent starts, since it may be floating by default anyway (at least on Linux where titlebar being disabled is allowed while floating)
    * Vertical title bar doesn't seem to work on Windows, add note in preferences dialog

commit 75621cc816
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Wed Jul 15 22:04:19 2020 +0200

    Prevent Windows from floating if no decoration

    * Windows users cannot move a window which has no native decorations. Toggling a dock widget's titlebar off also removes native decorations on a floating window. Until we implement a replacement titlebar by overriding paintEvents, simply force the floating window to go back to docked state after we toggled the titlebar off.

commit 3c816b2f11
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Wed Jul 15 21:43:01 2020 +0200

    Fix computing and setting offset to 0 for tableview

commit 85d6e05cd4
Merge: 66127d02 3eddeb6a
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Wed Jul 15 21:25:44 2020 +0200

    Merge branch 'dockable_windows' into details_dialog_improvements_dev

commit 66127d025e
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Wed Jul 15 20:22:13 2020 +0200

    Add credit for icons used, upscale exchange icon

    * Jason Cho gave his express permission to use the icon (it was made 10 years ago and he doesn't have the source files anymore)
    * Used waifu2x to upscale the icon
    * Used GIMP to draw dark outline around the icon
    * Source files are included

commit 58c675d1fa
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Wed Jul 15 05:25:47 2020 +0200

    Add custom icons

    * Use custom icons on platforms which do not provide theme
    * Old zoom icons credits to "schollidesign" from icon pack Office and Entertainment (GPL licence).
    * Exchange icon credit to Jason Cho (Unknown license).
    * Use hack to resize viewers on first show() as well

commit 95b8406c7b
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Wed Jul 15 04:14:24 2020 +0200

    Fix scrollbar displayed while splitter maxed out

    * For some reason the table's height is a few pixel longer on Windows so we work around the issue by adding a small offset to the maximum height hint.
    * No idea about MacOS yet but this might need the same treatment.

commit 3eddeb6aeb
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Tue Jul 14 17:37:48 2020 +0200

    Fix ME/SE details dialogs, add preferences

    * Fix ME and SE versions of details dialog not displaying their content properly after change to QDockWidget
    * Add option to toggle titlebar and orientation of titlebar in preferences dialog
    * Fix setting layout on PE details dialog window while layout already set, by removing the self (parent) reference in constructing the QSplitter

commit 56912a7108
Author: glubsy <glubsy@users.noreply.github.com>
Date:   Mon Jul 13 05:06:04 2020 +0200

    Make details dialog dockable
2020-07-16 22:31:54 +02:00
glubsy ac941037ff Fix resize of top frame not updating scaled pixmap
* Also limit viewing features such as zoom levels when files have different dimensions
* GraphicsViewImageViewer is still a bit buggy: the scrollbars are toggled on when the pixmap is null in the reference viewer (we do not use that class right anyway)
2020-07-16 22:21:24 +02:00
glubsy 733b3b0ed4 Prevent zoom for images of differing dimensions
* If images are not the same size, prevent zooming features from being used by disabling the normal size button, only enable swap
2020-07-16 01:31:24 +02:00
glubsy 9168d72f38 Update preferences on show(), not in constructor
* If the dialog window shouldn't have a titlebar during construction, update accordingly only when showing to fix Windows displaying a window without titlebar on first show
* Only save geometry if the window is floating. Otherwise geometry while docked is saved whih gives weird results on subsequent starts, since it may be floating by default anyway (at least on Linux where titlebar being disabled is allowed while floating)
* Vertical title bar doesn't seem to work on Windows, add note in preferences dialog
2020-07-15 23:00:55 +02:00
glubsy 75621cc816 Prevent Windows from floating if no decoration
* Windows users cannot move a window which has no native decorations. Toggling a dock widget's titlebar off also removes native decorations on a floating window. Until we implement a replacement titlebar by overriding paintEvents, simply force the floating window to go back to docked state after we toggled the titlebar off.
2020-07-15 22:12:19 +02:00
glubsy 3c816b2f11 Fix computing and setting offset to 0 for tableview 2020-07-15 21:48:11 +02:00
glubsy 85d6e05cd4 Merge branch 'dockable_windows' into details_dialog_improvements_dev 2020-07-15 21:25:44 +02:00
glubsy 66127d025e Add credit for icons used, upscale exchange icon
* Jason Cho gave his express permission to use the icon (it was made 10 years ago and he doesn't have the source files anymore)
* Used waifu2x to upscale the icon
* Used GIMP to draw dark outline around the icon
* Source files are included
2020-07-15 20:22:13 +02:00
glubsy 58c675d1fa Add custom icons
* Use custom icons on platforms which do not provide theme
* Old zoom icons credits to "schollidesign" from icon pack Office and Entertainment (GPL licence).
* Exchange icon credit to Jason Cho (Unknown license).
* Use hack to resize viewers on first show() as well
2020-07-15 05:25:47 +02:00
glubsy 95b8406c7b Fix scrollbar displayed while splitter maxed out
* For some reason the table's height is a few pixel longer on Windows so we work around the issue by adding a small offset to the maximum height hint.
* No idea about MacOS yet but this might need the same treatment.
2020-07-15 04:14:24 +02:00
glubsy 3eddeb6aeb Fix ME/SE details dialogs, add preferences
* Fix ME and SE versions of details dialog not displaying their content properly after change to QDockWidget
* Add option to toggle titlebar and orientation of titlebar in preferences dialog
* Fix setting layout on PE details dialog window while layout already set, by removing the self (parent) reference in constructing the QSplitter
2020-07-14 17:37:48 +02:00
glubsy 56912a7108 Make details dialog dockable 2020-07-13 05:06:04 +02:00
glubsy 7ab299874d Merge commit 'b0a256f0' 2020-07-12 17:54:51 +02:00
glubsy a4265e7fff Use tabs instead of floating windows
* Directories dialog, Results window and ignore list dialog are the three dialog windows which can now be tabbed instead of previously floating.
* Menus are automatically updated depending on the type of dialog as the current tab. Menu items which do not apply to the currently displayed tab are disabled but not hidden.
* The floating windows logic is preserved in case we want to use them again later (I don't see why though)
* There are two different versions of the tab bar: the default one used in TabBarWindow class places the tabs next to the top menu to save screen real estate. The other option is to use TabWindow which uses a regular QTabWidget where the tab bar is placed right on top of the displayed window.
* There is a toggle option in the View menu to hide the tabs, the windows can still be navigated to with the View menu items.
2020-07-12 17:23:35 +02:00
glubsy db228ec8a3 Fix word wrap in ignore list dialog 2020-07-12 16:17:18 +02:00
glubsy 61fc4f07ae Fix updating result window action upon creation
* Result Window action was not being properly updated
after the ResultWindow had been created.
There was no way of retrieving the window after it had been closed.
2020-07-07 16:54:08 +02:00
glubsy b0a256f0d4 Fix flake8 minor issues 2020-07-02 23:09:02 +02:00
glubsy 4ee9479a5f Add image comparison features to details dialog
* Add splitter in order to hide the details table.
* Add a toolbar to the Details Dialog window to allow for better image
comparisons: zoom in/out, swap pixmaps in place, best-fit-to-viewport.
Scrollbars and viewports are synchronized.
2020-07-02 22:52:47 +02:00
glubsy e7b3252534 Cleanup of details table 2020-07-02 22:36:57 +02:00
glubsy 36ab84423a Move buttons into the toolbar class.
* Moved the QToolbar into the image viewer's  translation unit.
* QAction are still attached to the dialog window for shortcuts to work
2020-07-02 22:36:57 +02:00
glubsy 370b582c9b Add working zoom functions to GraphicsView viewers. 2020-07-02 22:36:57 +02:00
glubsy 9f15139d5f Fix view resetting when selecting reference only.
* Needed to ignore the scrollbar changes in the disabled
panel, sine a null pixmap would reset the bars to 0 and affect
the selected viewer.
* Keep view as same scale accross entries from the same group.
2020-07-02 22:36:57 +02:00
glubsy 011939f5ee Keep scale accross files of the same dupe group.
* Also fix scaled down pixmap when updating pixmap in the same group
* Fix ignoring mouse wheel event when max scale has been reached
* Fix toggle scrollbars when asking for normal size
2020-07-02 22:36:57 +02:00
glubsy 977c20f7c4 Add QSplitter to hide TableView in DetailsDialog 2020-07-02 22:36:57 +02:00
glubsy aa79b31aae Work around resizing down offset by 1 pixel. 2020-07-02 22:36:57 +02:00
glubsy 970bb5e19d Add mostly working ScrollArea imge viewers
* Work around flickering of scrollbars due to
GridLayout resizing on odd pixels by disabling
the scrollbars while BestFit is active
* Also setting minimum column width to work around
the issue above.
* Avoid updating scrollbar values twice by using a
simple boolean lock
2020-07-02 22:36:57 +02:00
glubsy a706d0ebe5 Implement mostly working ScrollArea viewer
Using a QWidget inside the QScrollArea mostly works
but we only move around the pixmap inside the QWidget,
not the QWidget itself, which doesn't update scrollbars.
Need a better implementation.
2020-07-02 22:36:57 +02:00
glubsy b7abcf2989 Use native QPixmap swap() method instead of manual setPixmap()
When swapping images, use getters to hopefully get a reference to
each pixmap and swap them within a single slot.
2020-07-02 22:36:57 +02:00
glubsy 8103cb3664 Disable unused methods from controller
* setPixmap() now disables the QWidget automatically if the pixmap passed is null.
* the controller relays repaint events to the other widget
2020-07-02 22:36:57 +02:00
glubsy c3797918d2 Controller class to decouple from the dialog class
The controller singleton acts as a proxy to relay
signals from each widget to the other
It should help encapsulating things better if we need to
use a different class for image viewers in the future.
2020-07-02 22:36:57 +02:00
glubsy 60ddb9b596 Working synchronized views. 2020-07-02 22:36:57 +02:00
glubsy a29f3fb407 only update delta when mouse is being dragged to reduce paint events 2020-07-02 22:36:57 +02:00
glubsy c6162914ed working synchronized panning 2020-07-02 22:36:57 +02:00
glubsy 02bd822ca0 working zoom functions, mouse wheel event 2020-07-02 22:36:57 +02:00
glubsy ea6197626b drag mouse with ImageViewer class 2020-07-02 22:36:57 +02:00
glubsy 468a736bfb add normal size button 2020-07-02 22:36:57 +02:00
glubsy f42df12a29 attempt at double click on Qlabel 2020-07-02 22:36:57 +02:00
glubsy 9b48e1851d add zoom and swap buttons to details dialog 2020-07-02 22:36:57 +02:00
glubsy c973224fa4 Fix flake8 identation warnings 2020-07-01 03:05:59 +02:00
Andrew Senetar 092cf1471b
Add details to commented out tests. 2020-06-30 12:25:23 -05:00
glubsy 5cbe342d5b Ignore formatting if no data returned from model 2020-06-30 18:32:20 +02:00
Andrew Senetar 4f252480d3
Fix pywin32 dependency 2020-06-30 00:52:04 -05:00
Andrew Senetar 5cc439d846
Clean up rest of DeprecationWarnings 2020-06-30 00:51:06 -05:00
glubsy c6f5031dd8 Add color and bold font if difference in model
* Could be better optimized if there is a way to
set those variables earlier in the model or somewhere
in the viewer when it requests the data.
* Right now it compares strings(?) many times for every role
we handle, which is not ideal.
2020-06-30 04:20:27 +02:00
glubsy eb6946343b Remove superflous top-left corner button 2020-06-30 01:19:25 +02:00
glubsy e41a6b878c Allow moving rows around in details table
* Replaces the "Attribute" column with a horizontal header
* We ignore the first value in each row from the model and instead
populate a horizontal header with the value in order to allow
2020-06-30 01:02:56 +02:00
Andrew Senetar ee2671a5f3
More Test and Flake8 Cleanup
- Allow flake8 to check more files as well.
2020-06-27 01:08:12 -05:00
Andrew Senetar e05c72ad8c
Upgrade to latest pytest
- Currently some incompatibility in the hscommon tests, commented out
the ones with issues temporarily
- Also updated some deprecation warnings, still more to do
2020-06-25 23:26:48 -05:00
Andrew Senetar 7658cdafbc
Merge pull request #665 from KIAaze/fix_packaging_ubu20.04
Fix packaging on *ubuntu 20.04 (more specifically python version >=3.8)
2020-06-24 18:47:09 -05:00
Andrew Senetar ecf005fad0
Add distro to requirements and use for packaging
- Add distro as a requirement
- Use distro.id() to get the id as it is a bit cleaner than distro.linux_distribution()
2020-06-24 18:39:06 -05:00
Andrew Senetar de0542d2a8
Merge pull request #677 from glubsy/fix_folder
Fix standard mode folder comparison view generating "---" in results table
2020-06-24 18:30:30 -05:00
glubsy bcb26507fe Remove superfluous argument 2020-06-25 01:23:03 +02:00
Andrew Senetar c35db7f698
Merge pull request #672 from jpvlsmv/variable_fix_trivial
Rename an ell variable into something that flake8 doesn't complain about
2020-06-24 17:18:49 -05:00
Andrew Senetar d2193328a7
Add `e` to `lin` 2020-06-24 17:11:09 -05:00
glubsy ed64428c80 Add missing file class for folder type.
* results.py doesn't set the proper type for dupes at the line
"file = get_file(path)" so we add it on top
* Perhap it could have been added to _get_fileclasses() in core.app.py too
but I have not tested it
2020-06-24 23:32:04 +02:00
glubsy e89156e55c Add temporary workaround for bug #676
* In standard mode, for folder comparison, dupe type is wrongly set as core.fs.Folder
while it should be core.se.fs.Folder.
* Catching the NotImplementedError exception redirects to the appropriate handler
* This is only a temporary workaround until a better fix is implemented
2020-06-24 22:01:30 +02:00
Andrew Senetar 4c9309ea9c
Add changelog to pkg/debian
May try some other way of doing this later, but for now this will
let the PPA build make some progress.
2020-06-16 20:45:48 -05:00
Andrew Senetar 1c00331bc2
Remove Old Issue Template 2020-06-15 23:28:31 -05:00
Andrew Senetar 427e32f406 Update issue templates
Change to the new issue template flow.
2020-06-15 23:18:13 -05:00
Joe Moore b048fa5968 Rename an ell variable into something that flake8 doesn't complain about 2020-06-05 19:44:08 -04:00
Andrew Senetar d5a6ca7488
Merge pull request #669 from jpvlsmv/refactor_ci
Refactor ci a little bit
2020-06-01 11:57:58 -05:00
Joe Moore d15dea7aa0 Move flake8 requirement out of .txt into tox environment spec 2020-05-30 09:49:17 -04:00
Joe Moore ccb1c75f22 Call style-checker tox environment 2020-05-30 09:40:23 -04:00
Joe Moore dffbed8e22 Use build and package scripts on windows 2020-05-30 09:34:03 -04:00
Joe Moore 50ce010212 Move flake8 to a separate tox env 2020-05-30 09:33:35 -04:00
KIAaze 0e8cd32a6e Changed to -F option to build everything (source and binary packages). 2020-05-20 23:15:49 +01:00
KIAaze ea191a8924 Fixed AttributeError in the packaging script when using python>=3.8.
platform.dist() is deprecated since python version 3.5 and was removed in version 3.8.
Added exception to use the distro package in that case, as suggested by the python documentation:
https://docs.python.org/3.7/library/platform.html?highlight=platform#module-platform
2020-05-20 23:13:11 +01:00
Andrew Senetar 6abcedddda
Merge pull request #656 from glubsy/selected_shortcut_description
Add shortcut description to mark selected action
2020-05-13 20:17:41 -05:00
Andrew Senetar debf309a9a
Merge pull request #655 from glubsy/fix_row_trimming
Fix row trimming
2020-05-08 22:07:38 -05:00
glubsy 4b1c925ab1 use a QKeySequence instead 2020-05-07 16:24:07 +02:00
glubsy 1c0990f610 Add shortcut description to mark selected action 2020-05-07 15:37:21 +02:00
glubsy 89f2dc3b15 prevent word wrapping from truncating row too agressively 2020-05-07 14:55:01 +02:00
glubsy ffae58040d prevent trimming too short in details panel's rows 2020-05-07 14:53:09 +02:00
Andrew Senetar 0cc1cb4cb8
Merge pull request #646 from glubsy/bold_font
Add a preference option to disable bold font on reference row.
2020-05-05 22:03:41 -05:00
glubsy dab762f05e Add a preference option to disable bold font on reference row. 2020-04-27 01:36:27 +02:00
Andrew Senetar c4a6958ef0
Merge pull request #628 from nikmartin/linuxBuild
remove 'm' from SO var on Linux and OSX
2020-03-04 19:34:49 -06:00
Andrew Senetar 98c6f12b39
Merge pull request #627 from ptman/patch-1
Fix handling of filenames with space
2020-03-04 19:34:38 -06:00
Andrew Senetar 5d21454789
Update .travis.yml
Remove python 3.5 and add 3.8
2020-03-04 19:30:30 -06:00
Andrew Senetar 3e4fe5b765
Update tox.ini
Remove python 3.5 add 3.8
2020-03-04 19:29:01 -06:00
Nik Martin bd0f53bcbe remove 'm' from SO var on Linux and OSX 2020-02-26 15:39:39 -06:00
Paul Tötterman d820fcc7b1
Fix handling of filenames with space
I got spaces in CURDIR for some reason
2020-02-21 16:02:30 +02:00
Andrew Senetar de8a0a21b2
Update Packaging
- Add changes from OSX build to local hscommon/build.py
- Update package.py & srcpkg.sh
  - Remove invalid submodule references
  - Update srcpkg.sh to use xz
- Update package.py pyinstaller configuration
  - Call PyInstaller inline
  - Add --noconfirm option to be more script friendly
  - Add UCRT Redist location to path should fix #545 as now all the dlls
    are included
2019-12-31 21:36:52 -06:00
Andrew Senetar 7ba8aa3514
Format files with black
- Format all files with black
- Update tox.ini flake8 arguments to be compatible
- Add black to requirements-extra.txt
- Reduce ignored flake8 rules and fix a few violations
2019-12-31 20:16:27 -06:00
Andrew Senetar 359d6498f7
Update documentation & CI
- Remove references to submodules as they are no longer used
- Update top level readme with more recent status
- Update travis configuration to use python 3.7 instead of latest for now
2019-12-31 17:33:17 -06:00
Andrew Senetar 2ea02bd7b5
Update hscommon/build.py
Update changelog format to use changes from
https://github.com/hsoft/hscommon/pull/6/.  This allows for changes from
 #593 to work correctly.
2019-11-06 20:25:20 -06:00
Andrew Senetar 8506d482af
Merge pull request #593 from eugenesan/master
Update packaging for 4.0.4
2019-10-08 20:14:49 -05:00
Andrew Senetar 411d0d6e4a
Cross platform fix for makefile #575 2019-09-09 20:23:37 -05:00
Andrew Senetar 95ff6b6b76
Add files from hscommon and qtlib 2019-09-09 19:54:28 -05:00
Andrew Senetar 334f6fe989
Remove qtlib and hscommon submodules 2019-09-09 19:45:58 -05:00
Eugene San (eugenesan) 080bb8935c Update packaging for 4.0.4
* Fix main version (Don't use spaces and capitals in versions!)
* Change debian changelog format in hscommon
* Fix build cleanup
* Switch to XZ compression
* Update build instructions
* Build single package for both Debian/Ubuntu
* Update packaging
2019-08-29 14:50:41 -07:00
Andrew Senetar ad2a07a289
Merge pull request #572 from jpvlsmv/issue-570
Issue 570 - CI process improvements
2019-05-23 18:08:41 -05:00
Joe Moore c61a7f1aaf
Use 3-ending python names consistantly 2019-05-23 10:43:28 -04:00
Joe Moore f536f32b19
Reference standard dependencies on Windows 2019-05-23 10:40:42 -04:00
Joe Moore 8cdff7b48c
Define tox windows test environment 2019-05-22 11:31:07 -04:00
Joe Moore 718e99e880
Explicitly call tox windows environment on windows 2019-05-22 11:29:37 -04:00
Joe Moore 3c2ef97ee2
Install requisites in install task, move tox-travis into -extras 2019-05-21 10:45:02 -04:00
Joe Moore 2f439d0fc7
Install requisites in install task, move tox-travis into -extras 2019-05-21 10:44:40 -04:00
Joe Moore 4f234f272f
Increase tox verbosity 2019-05-21 10:19:04 -04:00
Joe Moore 18acaae888
Attempt to build dupeguru before running the tox cases 2019-05-21 10:18:41 -04:00
Joe Moore be7d558dfe Add Windows build to the matrix 2019-05-18 14:36:43 -04:00
Joe Moore 0b12236537 Switch to explicit matrix build 2019-05-18 14:35:10 -04:00
Joe Moore ed2a0bcd4d Drop python 3.4 and test py 3.7 instead 2019-05-18 13:50:24 -04:00
Andrew Senetar 11e57b0316
Remove RC from changelog version
Adding RC would require updating other scripts to support it.  For the
purpose of the changelog just using 4.0.4 should suffice.  Might Add
support for this later.
2019-05-13 20:43:47 -05:00
Andrew Senetar c661905350
Bump version to 4.0.4 RC
- Also update package.py to allow version postfixes without causing
  issues with Windows build.
2019-05-13 20:18:56 -05:00
Andrew Senetar d819719eca
Update changelog for 4.0.4 RC
Marking this as an RC as it will take some time for me to get all
packages out and I might need to fix various packaging related bugs.
Will mark as 4.0.4 after all packages exist, should not have application
changes between these two.
2019-05-13 19:37:54 -05:00
Andrew Senetar 08722a30f2
Merge pull request #565 from arsenetar/542
Switch to pyinstaller from cx_freeze
2019-05-02 21:09:55 -05:00
Andrew Senetar a1cc0fe946
Merge branch 'master' into 542 2019-05-02 20:47:05 -05:00
Andrew Senetar 2a2c0061f1
Merge pull request #534 from arsenetar/504
Fix Issue #504
2019-04-26 22:02:57 -05:00
Andrew Senetar 15bfe059c7
Update Windows Build
Fix the issues with cx_Freeze builds on newer versions of python and
with newer version of PyQt5
- Update .gitignore to ignore .spec files
- Update package.py to use pyInstaller instead of cx_Freeze
- Update requirements-windows to have pyInstaller instead of cx_Freeze
- Update setup.nsi to work with build and packaging changes
- Add win_version_info.temp to build a version information file for
  pyInstaller as part of the package.py script
2019-03-24 21:35:34 -05:00
Andrew Senetar a1cacbe72b
Merge pull request #559 from arsenetar/444
Fix #444
2019-03-21 18:11:56 -05:00
Andrew Senetar 0a5db4c5c1
Merge pull request #558 from arsenetar/505
Fix #505
2019-03-21 18:11:22 -05:00
Andrew Senetar 1b879259a4
Fix #444
Update default INITIAL_FOLDER_IN_DIALOGS to use '/' as it is most
likely available on most unsuppored platforms.
2019-03-21 18:07:00 -05:00
Andrew Senetar 136342f7d0
Fix #505
Remove offending line in package.py
2019-03-21 17:59:33 -05:00
Andrew Senetar 9eb15509c1
Merge pull request #526 from arsenetar/templateUpdate
Update README.md and ISSUE_TEMPLATE.md
2019-02-19 22:18:31 -06:00
Andrew Senetar dde2c9bf8f
Fix Issue #504
- Update qt/preferences_dialog.py to resize based on layout with fixed
  sizing constraints
- Remove _setupUi from qt/se/preferences_dialog.py and just use the
  parent class's function
- Remove unused imports from qt/se/preferences_dialog.py
2018-12-04 21:10:01 -06:00
Andrew Senetar f7e20c8aa6
Merge pull request #533 from arsenetar/hscommonUpdate
Update hscommon & .travis.yml for Python 3.7
2018-12-04 20:20:36 -06:00
Andrew Senetar 1a04f6ee86
Update to fix Travis Build & flake8 warning
- Fix Travis build by using dist: xenial so python 3.7 is available
- Correct flake8 warning in core/test/app_test.py
2018-12-04 20:16:15 -06:00
Andrew Senetar bd3d47bf19
Update hscommon & .travis.yml for Python 3.7
- .travis.yml changes from a82ab3c237
- hscommon update from https://github.com/hsoft/hscommon/pull/5
2018-12-04 19:20:18 -06:00
Andrew Senetar f953bc4af4
Update README.md and ISSUE_TEMPLATE.md
- Update ISSUE_TEMPLATE.md to help obtain better information for bug
reports.
- Update README.md to reflect more recent changes and highlight areas of
need.
2018-10-10 21:12:01 -05:00
Andrew Senetar 10ac536c3b
Merge pull request #525 from arsenetar/521
Update qt/result_window.py to fix off-screen issue.
2018-10-10 20:03:03 -05:00
Andrew Senetar ab9703b86e
Update qt/result_window.py
Now previous changes comply with flake8 rules.
2018-10-10 19:48:32 -05:00
Andrew Senetar 79b97311e9
Update qt/result_window.py
Move the result window if fully or partially off-screen.
Fix #521.
Need to verify if this solves #500.
2018-10-08 21:20:09 -05:00
Andrew Senetar 48936b53a8
Merge pull request #514 from pzrrl/master
Updated wrongly translated wording
2018-07-15 10:36:53 -05:00
pzrrl 9bf1887109
Updated wrongly translated wording
"Font size" was wrongly translated for Simplified Chinese.
2018-07-11 14:26:27 +08:00
Andrew Senetar 30b6e5c68d
Merge pull request #492 from arsenetar/452+489
Update submodule for hscommon & qtlib
2018-05-12 12:57:11 -05:00
Andrew Senetar 20202d8dfa Update submodule for hscommon & qtlib
Fix issues #452 and #489
2018-02-27 19:23:05 -06:00
Andrew Senetar 6c6271bc69 Include dbm.dumb for windows build (#490)
cx_Freeze was only including dbm.ndbm which is not available on windows.
This should fix hsoft/dupeguru#474
2018-02-26 09:42:14 -05:00
Andrew Senetar f349f6a9b9 Fix #456 & #461 (#491)
* Update setup.nsi
- Always display all language choices to fix #461
- Stop recursively removing installation directory as that
  is not a safe operation.  This may leave the installation
  directory after uninstallation but should be empty.  Fix #456.

* Update requirements-windows.txt
- Add pypiwin32 for cx-freeze as it was issuing a warning
- Prevent version 5.1.0 of cx-freeze as it produces non-working
  packages for some reason.  Version 5.0.2 and 6.0b1 work fine.

* Update setup to delete correct files

Installer will now delete the files in the installation folder along
with the folder if it is empty.  Bumped minimum cx-Freeze version to
5.1.1 as files are structured differently in older versions.
2018-02-26 09:41:10 -05:00
Virgil Dupras afe1d4ed2e Current status: unmaintained 2018-02-14 22:42:40 -05:00
DJCrashdummy c37037ca4a added some lacking translations (#479)
* added some lacking translations

* added some lacking translations
2018-01-23 20:31:49 -05:00
Andrew Senetar 445f51d595 Update qtlib (#460)
Update qtlib to prevent error on multiple calls to close the progress window.  I have not been able to duplicate the issue again, to track down the root cause.
Ref #449
2017-11-16 10:53:30 -05:00
Andrew Senetar 6132d7c211 Fix syntax error in translation file. (#459)
Fix #451
2017-11-16 10:50:17 -05:00
Virgil Dupras 79adbfd4f2 Fix spurious flake8 failures 2017-11-16 10:49:03 -05:00
Virgil Dupras 45b907a529 Enable Travis CI 2017-10-08 20:37:59 -04:00
Virgil Dupras d5fef949e9 directories: un-recurse get_files() and get_state()
These methods were previously called recursively and it seemed to cause
problems in some cases. The recursive nature of these functions not
bringing any notable advantage and `os.walk()` being of better style
anyway, I removed that recursive nature.

Hopefully fixes #421
2017-10-08 20:35:58 -04:00
auanasgheps 899a42f6a9 Update ui.po (#448)
More translations (not all yet).
I've also revised some of them.
Changed _Last-Translato_r string to my nickname, hope that is okay.
2017-10-03 11:52:26 -04:00
auanasgheps 93a3978747 Update core.po (#447)
All strings should now be translated in Italia.
I've also changed Last-Translator string to my nickname, hope that is okay.
2017-10-03 11:51:17 -04:00
auanasgheps 5d15cd4c97 Update columns.po (#446)
Too much space on line #122
2017-10-03 11:50:41 -04:00
auanasgheps 7936339909 Update columns.po (#445)
Updated missing strings
2017-09-28 19:49:00 -04:00
Virgil Dupras 2f31dc7aab cache_shelve: wrap deletions in try..except in purge_outdated
Hopefully solves #402 and #439.
2017-09-19 13:22:33 -04:00
Virgil Dupras a6b1e6e9ab Make tox work with non-venv interpreters
Previously, as soon as an interpreter that wasn't the one having been
used for `env` was used by tox, we would get errors because our C
modules wouldn't be built for this interpreter.

The makefile has been changed to make `make modules` interpreter-aware,
thus fixing this problem.
2017-09-19 13:14:11 -04:00
Andrew Senetar 8cd0ef4c2b Initial Update of Windows Packaging (#438)
* Update run.py & .gitignore for windows

- Update run.py to execute on windows as SIGQUIT is not available.
- Update .gitignore to ignore the generate .pyd files
Ref #300, #393

* Update package.py for windows

Add package_windows back into package.py
- Using cx_freeze for freezing installation
- Will be using nsis for actual installer
Tested with python 3.5 64bit on windows 10
Ref #393

* Update makefile for windows (+2 misc)

- Update the makefile to support windows
- Use different bin path in virtualenv
- Use pyd instead of so files
- Tested with Msys2
- Add *.exe to .gitignore
- Fix minor format error in package.py
Ref #393

* Add requirements-windows

Add the requirements-windows.txt
- contains cx-Freeze for bundling
Ref #393

* Add initial setup.nsi

Initial Version of a NSIS installer script
- Multi-user install (install for just one or all)
- Registers uninstaller (more values need to finish up)
- Tested both single and all install / uninstall and works
- Still need to add parameters instead of hardcoded values in some spots
- Need to clean up vendor folders / keys if empty on uninstall
- Need to add the other dupeGuru languages to the language list
- Minor cleanup of script needed as well
Ref #393

* Update setup.nsi

Updates to setup.nsi including:
- Defines from CLI
- Version information (MAJOR, MINOR, PATCH)
- Bits (64 / 32)
- SourcePath (if we wanted something other than build)
- Added extra defines to move application specifics to one location
- Added extra defines for uninstall information
- Added calculation of install size
- Added switching between 64 and 32 bit contexts (need to verify
functionality)
- Updated output file naming
- Added NSIS supported languages which are also supported by dupeGuru
- Added rest of registry keys for uninstall information
- Added missing registry key for installType
- Added removeing Vendor folder and registry key if empty on uninstall

Should be very close to having this installer script ready to integrate
into the package.py script if desired.
Ref #393

* Update README & requirements-windows

Minor update to README to indicate windows is supported.  Add PyQt5 to
requirements-windows.txt to make installation easier.

* Update packaging for windows

- Update package.py to integrate NSIS for windows
- Update makefile to deal with a few additional windows issues
- Add Windows.md to contain specific windows instructions, if we want
this can be merged with README.md
- Minor formatting update to setup.nsi
Ref #393

* Update README & Windows Instructions

- Update the README to include a reference to the Windows instructions.
-  Add some additional notes into Windows Instructions and remove one
incorrect command.
- Update .gitignore to ignore all permutations of env* to allow for
multiple side by side virtual environments (used to build different
versions for windows)
Ref:  #393

* Update Window.md

Fix broken python link and move nsis link for consistency.

* More Details in Windows.md

Update Windows.md including:
- Information on compilier requirements for windows
- Notes about the windows 10 sdk
- Some clarification around some of the steps
- Addition of msys2 links

Going to review this a bit more to polish it up.

Ref #393.
2017-08-28 19:27:17 -04:00
Virgil Dupras 50e26928f4 Fix env creation in Makefile
The --user isn't a good idea at all to workaround Gentoo's patched pip.
2017-08-14 09:26:44 -04:00
Jocelyn Le Sage 84011fb46d Handle OS termination signals. (#425)
* Handle OS termination signals.

* Added comment about why a timer is required to handle OS signals.
2017-06-20 12:04:38 -04:00
Virgil Dupras 8861f6296e Makefile: add NO_VENV option
This option allows us to avoid venv+pip-install operations. We can use
this in situations where we already know we have all dependencies met
(in a Gentoo ebuild, for example...) and wish to avoid useless work and
potential problems.
2017-06-20 11:59:41 -04:00
Virgil Dupras 35ea499857 Make docs installation optional 2017-06-20 11:49:11 -04:00
Virgil Dupras a82a19e074 Remove cocoa-related code from build.py 2017-03-12 15:00:57 -04:00
Virgil Dupras e72cf917f1 Fix broken packaging
I forgot to remove references to the now-gone cocoalib folder
2017-03-11 20:46:57 -05:00
Virgil Dupras 245ed0ddec Remove cocoa
The cocoa UI code now lives in dupeguru-cocoa.
2017-03-11 20:41:47 -05:00
Nick Okasinski f51f94e03d Fix verb tense in README.md (#406) 2017-01-08 22:34:49 -05:00
Virgil Dupras 6a28017c49 v4.0.3 2016-11-25 01:04:31 +00:00
Virgil Dupras dc6933c90c Fix crash when cleaning picture cache 2016-11-25 00:59:51 +00:00
Virgil Dupras e0281dd740 Fix previous commit
I forgot to remove a sparkle reference in the build script.
2016-11-23 20:25:32 -05:00
Virgil Dupras 79e99db1d3 cocoa: remove Sparkle
It's a deployment headache. Old sparkle versions generate runtime warnings about security and up to date version requires me to compile on 10.10, but after many tries, it seems that I absolutely need to build on my minimum requirements version which is 10.8. So screw Sparkle.
2016-11-23 19:51:55 -05:00
Virgil Dupras 76cc2000ab Add UI preference to picture cache type under Qt 2016-11-22 02:41:43 +00:00
Virgil Dupras e4b6e12d4c Update tox warning exception
E305 somehow popped up as a default warning which I don't care about.
2016-11-22 02:39:51 +00:00
Virgil Dupras c58a4817ca Add shelve-based picture cache implementation
Hopefully, this will fix #394 for real this time, that is, without the
need for a messy python executable ship in the app.
2016-11-15 19:58:18 -05:00
Virgil Dupras f7adb5f11e Whitespace normalization 2016-11-15 19:57:30 -05:00
Virgil Dupras c43044ea4c Remove unused imports 2016-11-15 19:56:19 -05:00
Virgil Dupras cc01e8eb09 Move pe.cache.Cache into its own unit, cache_sqlite
This prepares us for an upcoming alternative cache implementation.
2016-11-13 17:01:20 -05:00
Virgil Dupras 1c20e5c770 v4.0.2 2016-10-09 12:32:04 -04:00
393 changed files with 38733 additions and 15477 deletions

13
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,13 @@
# These are supported funding model platforms
github: arsenetar
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. Windows 10 / OSX 10.15 / Ubuntu 20.04 / Arch Linux]
- Version [e.g. 4.1.0]
**Additional context**
Add any other context about the problem here. You may include the debug log although it is normally best to attach it as a file.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

50
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: "24 20 * * 2"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["cpp", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- if: matrix.language == 'cpp'
name: Build Cpp
run: |
sudo apt-get update
sudo apt-get install python3-pyqt5
make modules
- if: matrix.language == 'python'
name: Autobuild
uses: github/codeql-action/autobuild@v1
# Analysis
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

65
.github/workflows/default.yml vendored Normal file
View File

@ -0,0 +1,65 @@
# Workflow lints, and checks format in parallel then runs tests on all platforms
name: Default CI/CD
on:
push:
pull_request:
branches: [master]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: pre-commit/action@v3.0.1
test:
needs: [pre-commit]
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"]
include:
- os: windows-latest
python-version: "3.12"
- os: macos-latest
python-version: "3.12"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools
pip install -r requirements.txt -r requirements-extra.txt
- name: Build python modules
run: |
python build.py --modules
- name: Run tests
run: |
pytest core hscommon
- name: Upload Artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: modules ${{ matrix.python-version }}
path: build/**/*.so
merge-artifacts:
needs: [test]
runs-on: ubuntu-latest
steps:
- name: Merge Artifacts
uses: actions/upload-artifact/merge@v4
with:
name: modules
pattern: modules*
delete-merged: true

26
.github/workflows/tx-push.yml vendored Normal file
View File

@ -0,0 +1,26 @@
# Push translation source to Transifex
name: Transifex Sync
on:
push:
branches:
- master
paths:
- locale/*.pot
env:
TX_VERSION: "v1.6.10"
jobs:
push-source:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get Transifex Client
run: |
curl -o- https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash -s -- $TX_VERSION
- name: Update & Push Translation Sources
env:
TX_TOKEN: ${{ secrets.TX_TOKEN }}
run: |
./tx push -s --use-git-timestamps

118
.gitignore vendored
View File

@ -1,21 +1,111 @@
.DS_Store
__pycache__
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.waf*
.lock-waf*
.tox
/tags
#*.pot
build
dist
env
/deps
cocoa/autogen
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
/run.py
/cocoa/*/Info.plist
/cocoa/*/build
# Environments
.env
.venv
env*/
venv/
ENV/
env.bak/
venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# macOS
.DS_Store
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# dupeGuru Specific
/qt/*_rc.py
/help/*/conf.py
/help/*/changelog.rst
cocoa/autogen
/cocoa/*/Info.plist
/cocoa/*/build
*.waf*
.lock-waf*
/tags

12
.gitmodules vendored
View File

@ -1,12 +0,0 @@
[submodule "qtlib"]
path = qtlib
url = https://github.com/hsoft/qtlib.git
[submodule "hscommon"]
path = hscommon
url = https://github.com/hsoft/hscommon.git
[submodule "cocoalib"]
path = cocoalib
url = https://github.com/hsoft/cocoalib.git
[submodule "cocoa/Sparkle"]
path = cocoa/Sparkle
url = https://github.com/sparkle-project/Sparkle.git

24
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,24 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
exclude: ".*.json"
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
exclude: ^(.tox|env|build|dist|help|qt/dg_rc.py|pkg).*
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.11.0
hooks:
- id: commitlint
stages: [commit-msg]
additional_dependencies: ["@commitlint/config-conventional"]

1
.sonarcloud.properties Normal file
View File

@ -0,0 +1 @@
sonar.python.version=3.7, 3.8, 3.9, 3.10, 3.11

View File

@ -1,21 +1,20 @@
[main]
host = https://www.transifex.com
[dupeguru.core]
file_filter = locale/<lang>/LC_MESSAGES/core.po
source_file = locale/core.pot
source_lang = en
type = PO
[dupeguru.columns]
[o:voltaicideas:p:dupeguru-1:r:columns]
file_filter = locale/<lang>/LC_MESSAGES/columns.po
source_file = locale/columns.pot
source_lang = en
type = PO
type = PO
[dupeguru.ui]
[o:voltaicideas:p:dupeguru-1:r:core]
file_filter = locale/<lang>/LC_MESSAGES/core.po
source_file = locale/core.pot
source_lang = en
type = PO
[o:voltaicideas:p:dupeguru-1:r:ui]
file_filter = locale/<lang>/LC_MESSAGES/ui.po
source_file = locale/ui.pot
source_lang = en
type = PO
type = PO

12
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"redhat.vscode-yaml",
"ms-python.vscode-pylance",
"ms-python.python",
"ms-python.black-formatter",
],
// List of extensions recommended by VS Code that should not be recommended for
// users of this workspace.
"unwantedRecommendations": []
}

17
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "DupuGuru",
"type": "debugpy",
"request": "launch",
"program": "run.py",
"console": "integratedTerminal",
"subProcess": true,
"justMyCode": false
},
]
}

17
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
"cSpell.words": [
"Dupras",
"hscommon"
],
"editor.rulers": [
88,
120
],
"python.languageServer": "Pylance",
"yaml.schemaStore.enable": true,
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "ms-python.black-formatter"
},
"python.testing.pytestEnabled": true
}

88
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,88 @@
# Contributing to dupeGuru
The following is a set of guidelines and information for contributing to dupeGuru.
#### Table of Contents
[Things to Know Before Starting](#things-to-know-before-starting)
[Ways to Contribute](#ways-to-contribute)
* [Reporting Bugs](#reporting-bugs)
* [Suggesting Enhancements](#suggesting-enhancements)
* [Localization](#localization)
* [Code Contribution](#code-contribution)
* [Pull Requests](#pull-requests)
[Style Guides](#style-guides)
* [Git Commit Messages](#git-commit-messages)
* [Python Style Guide](#python-style-guide)
* [Documentation Style Guide](#documentation-style-guide)
[Additional Notes](#additional-notes)
* [Issue and Pull Request Labels](#issue-and-pull-request-labels)
## Things to Know Before Starting
**TODO**
## Ways to contribute
### Reporting Bugs
**TODO**
### Suggesting Enhancements
**TODO**
### Localization
**TODO**
### Code Contribution
**TODO**
### Pull Requests
Please follow these steps to have your contribution considered by the maintainers:
1. Keep Pull Request specific to one feature or bug.
2. Follow the [style guides](#style-guides)
3. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing <details><summary>What if the status checks are failing?</summary>If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.</details>
While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted.
## Style Guides
### Git Commit Messages
- Use the present tense ("Add feature" not "Added feature")
- Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
- Limit the first line to 72 characters or less
- Reference issues and pull requests liberally after the first line
### Python Style Guide
- All files are formatted with [Black](https://github.com/psf/black)
- Follow [PEP 8](https://peps.python.org/pep-0008/) as much as practical
- Pass [flake8](https://flake8.pycqa.org/en/latest/) linting
- Include [PEP 484](https://peps.python.org/pep-0484/) type hints (new code)
### Documentation Style Guide
**TODO**
## Additional Notes
### Issue and Pull Request Labels
This section lists and describes the various labels used with issues and pull requests. Each of the labels is listed with a search link as well.
#### Issue Type and Status
| Label name | Search | Description |
|------------|--------|-------------|
| `enhancement` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) | Feature requests and enhancements. |
| `bug` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Abug) | Bug reports. |
| `duplicate` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aduplicate) | Issue is a duplicate of existing issue. |
| `needs-reproduction` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aneeds-reproduction) | A bug that has not been able to be reproduced. |
| `needs-information` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aneeds-information) | More information needs to be collected about these problems or feature requests (e.g. steps to reproduce). |
| `blocked` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Ablocked) | Issue blocked by other issues. |
| `beginner` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Abeginner) | Less complex issues for users who want to start contributing. |
#### Category Labels
| Label name | Search | Description |
|------------|--------|-------------|
| `3rd party` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3A%223rd%20party%22) | Related to a 3rd party dependency. |
| `crash` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Acrash) | Related to crashes (complete, or unhandled). |
| `documentation` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Adocumentation) | Related to any documentation. |
| `linux` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3linux) | Related to running on Linux. |
| `mac` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Amac) | Related to running on macOS. |
| `performance` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aperformance) | Related to the performance. |
| `ui` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aui)| Related to the visual design. |
| `windows` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Awindows) | Related to running on Windows. |
#### Pull Request Labels
None at this time, if the volume of Pull Requests increase labels may be added to manage.

View File

@ -1,6 +1,8 @@
To know who contributed to dupeGuru, you can look at the commit log, but not all contributions
result in a commit. This file lists contributors who don't necessarily appear in the commit log.
* Jason Cho, Exchange icon
* schollidesign (https://findicons.com/pack/1035/human_o2), Zoom-in, Zoom-out, Zoom-best-fit, Zoom-original icons
* Jérôme Cantin, Main icon
* Gregor Tätzner, German localization
* Frank Weber, German localization

View File

@ -619,4 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS

5
MANIFEST.in Normal file
View File

@ -0,0 +1,5 @@
recursive-include core *.h
recursive-include core *.m
include run.py
graft locale
graft help

126
Makefile
View File

@ -1,15 +1,41 @@
PYTHON ?= python3
REQ_MINOR_VERSION = 4
PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)")
PYRCC5 ?= pyrcc5
REQ_MINOR_VERSION = 7
PREFIX ?= /usr/local
# Window compatability via Msys2
# - venv creates Scripts instead of bin
# - compile generates .pyd instead of .so
# - venv with --sytem-site-packages has issues on windows as well...
ifeq ($(shell ${PYTHON} -c "import platform; print(platform.system())"), Windows)
BIN = Scripts
SO = *.pyd
VENV_OPTIONS =
else
BIN = bin
SO = *.so
VENV_OPTIONS = --system-site-packages
endif
# Set this variable if all dependencies are already met on the system. We will then avoid the
# whole vitualenv creation and pip install dance.
NO_VENV ?=
ifdef NO_VENV
VENV_PYTHON = $(PYTHON)
else
VENV_PYTHON = ./env/$(BIN)/python
endif
# If you're installing into a path that is not going to be the final path prefix (such as a
# sandbox), set DESTDIR to that path.
# Our build scripts are not very "make like" yet and perform their task in a bundle. For now, we
# use one of each file to act as a representative, a target, of these groups.
submodules_target = hscommon/__init__.py
packages = hscommon qtlib core qt
packages = hscommon core qt
localedirs = $(wildcard locale/*/LC_MESSAGES)
pofiles = $(wildcard locale/*/LC_MESSAGES/*.po)
mofiles = $(patsubst %.po,%.mo,$(pofiles))
@ -17,76 +43,59 @@ mofiles = $(patsubst %.po,%.mo,$(pofiles))
vpath %.po $(localedirs)
vpath %.mo $(localedirs)
all : | run.py
all: | env i18n modules qt/dg_rc.py
@echo "Build complete! You can run dupeGuru with 'make run'"
run.py : | env i18n modules qt/dg_rc.py
cp qt/run_template.py run.py
run:
$(VENV_PYTHON) run.py
run: | run.py
./env/bin/python run.py
pyc: | env
${VENV_PYTHON} -m compileall ${packages}
pyc:
${PYTHON} -m compileall ${packages}
reqs :
@ret=`${PYTHON} -c "import sys; print(int(sys.version_info[:2] >= (3, ${REQ_MINOR_VERSION})))"`; \
if [ $${ret} -ne 1 ]; then \
echo "Python 3.${REQ_MINOR_VERSION}+ required. Aborting."; \
exit 1; \
fi
reqs:
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -ge $(REQ_MINOR_VERSION); echo $$?),0)
$(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.")
endif
ifndef NO_VENV
@${PYTHON} -m venv -h > /dev/null || \
echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv."
endif
@${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \
{ echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
# Ensure that submodules are initialized
$(submodules_target) :
git submodule init
git submodule update
env : | $(submodules_target) reqs
env: | reqs
ifndef NO_VENV
@echo "Creating our virtualenv"
${PYTHON} -m venv env --system-site-packages
./env/bin/python -m pip install -r requirements.txt
${PYTHON} -m venv env
$(VENV_PYTHON) -m pip install -r requirements.txt
# We can't use the "--system-site-packages" flag on creation because otherwise we end up with
# the system's pip and that messes up things in some cases (notably in Gentoo).
${PYTHON} -m venv --upgrade ${VENV_OPTIONS} env
endif
build/help : | env
./env/bin/python build.py --doc
build/help: | env
$(VENV_PYTHON) build.py --doc
qt/dg_rc.py : qt/dg.qrc
pyrcc5 qt/dg.qrc > qt/dg_rc.py
qt/dg_rc.py: qt/dg.qrc
$(PYRCC5) qt/dg.qrc > qt/dg_rc.py
i18n: $(mofiles)
%.mo : %.po
msgfmt -o $@ $<
%.mo: %.po
msgfmt -o $@ $<
core/pe/_block.*.so : core/pe/modules/block.c core/pe/modules/common.c | env
./env/bin/python hscommon/build_ext.py $^ _block
mv _block.*.so core/pe
modules: | env
$(VENV_PYTHON) build.py --modules
core/pe/_cache.*.so : core/pe/modules/cache.c core/pe/modules/common.c | env
./env/bin/python hscommon/build_ext.py $^ _cache
mv _cache.*.so core/pe
mergepot: | env
$(VENV_PYTHON) build.py --mergepot
qt/pe/_block_qt.*.so : qt/pe/modules/block.c | env
./env/bin/python hscommon/build_ext.py $^ _block_qt
mv _block_qt.*.so qt/pe
normpo: | env
$(VENV_PYTHON) build.py --normpo
modules : core/pe/_block.*.so core/pe/_cache.*.so qt/pe/_block_qt.*.so
mergepot :
./env/bin/python build.py --mergepot
normpo :
./env/bin/python build.py --normpo
srcpkg :
./scripts/srcpkg.sh
install: build/help | all pyc
install: all pyc
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
cp -rf ${packages} locale build/help ${DESTDIR}${PREFIX}/share/dupeguru
cp -rf ${packages} locale ${DESTDIR}${PREFIX}/share/dupeguru
cp -f run.py ${DESTDIR}${PREFIX}/share/dupeguru/run.py
chmod 755 ${DESTDIR}${PREFIX}/share/dupeguru/run.py
mkdir -p ${DESTDIR}${PREFIX}/bin
@ -96,16 +105,19 @@ install: build/help | all pyc
mkdir -p ${DESTDIR}${PREFIX}/share/pixmaps
cp -f images/dgse_logo_128.png ${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png
uninstall :
installdocs: build/help
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
cp -rf build/help ${DESTDIR}${PREFIX}/share/dupeguru
uninstall:
rm -rf "${DESTDIR}${PREFIX}/share/dupeguru"
rm -f "${DESTDIR}${PREFIX}/bin/dupeguru"
rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop"
rm -f "${DESTDIR}${PREFIX}/share/pixmaps/dupeguru.png"
clean:
-rm run.py
-rm -rf build
-rm locale/*/LC_MESSAGES/*.mo
-rm core/pe/*.so qt/pe/*.so
-rm core/pe/*.$(SO) qt/pe/*.$(SO)
.PHONY : clean srcpkg normpo mergepot modules i18n reqs run pyc install uninstall all
.PHONY: clean normpo mergepot modules i18n reqs run pyc install uninstall all

170
README.md
View File

@ -1,155 +1,85 @@
# dupeGuru
[dupeGuru][dupeguru] is a cross-platform (Linux and OS X) GUI tool to find duplicate files in
a system. It's written mostly in Python 3 and has the peculiarity of using
[multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer
is written in Objective-C and uses Cocoa. On Linux, it's written in Python and uses Qt5.
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
a system. It is written mostly in Python 3 and uses [qt](https://www.qt.io/) for the UI.
## Current status: People wanted
dupeGuru has currently only one maintainer, me. This is a dangerous situation that needs to be
corrected.
The goal is to eventually have another active maintainer, but before we can get there, the project
needs more contributors. It is very much lacking on that side right now.
Whatever your skills, if you are remotely interestested in being a contributor, I'm interested in
mentoring you. If that's the case, please refer to [the open ticket on the subject][contrib-issue]
and let's get started.
### Slowed development
Until I manage to find contributors, I'm slowing the development pace of dupeGuru. I'm not much
interested in maintaining it alone, I personally have no use for this app (it's been a *loooong*,
time since I had dupe problems :) )
I don't want to let it die, however, so I will still do normal maintainership, that is, issue
triaging, code review, critical bugfixes, releases management.
But anything non-critical, I'm not going to implement it myself because I see every issue as a
contribution opportunity.
### Windows maintainer wanted
As [described on my website][nowindows], dupeGuru v4.0 dropped Windows support
because there isn't anyone to bear the burden of Windows maintenance. If
you're a Windows developer and are interested in taking this task, [don't
hesitate to let me know][contrib-issue].
### OS X maintainer wanted
My Mac Mini is already a couple of years old and is likely to be my last Apple purchase. When it
dies, I will be unable maintain the OS X version of moneyGuru. I've already stopped paying for the
Mac Developer membership so I can't sign the apps anymore (in the "official way" I mean. The
download is still PGP signed) If you're a Mac developer and are interested in taking this task,
[don't hesitate to let me know][contrib-issue].
## Current status
Still looking for additional help especially with regards to:
* OSX maintenance: reproducing bugs, packaging verification.
* Linux maintenance: reproducing bugs, maintaining PPA repository, Debian package, rpm package.
* Translations: updating missing strings, transifex project at https://www.transifex.com/voltaicideas/dupeguru-1
* Documentation: keeping it up-to-date.
## Contents of this folder
This folder contains the source for dupeGuru. Its documentation is in `help`, but is also
[available online][documentation] in its built form. Here's how this source tree is organised:
[available online][documentation] in its built form. Here's how this source tree is organized:
* core: Contains the core logic code for dupeGuru. It's Python code.
* cocoa: UI code for the Cocoa toolkit. It's Objective-C code.
* qt: UI code for the Qt toolkit. It's written in Python and uses PyQt.
* images: Images used by the different UI codebases.
* pkg: Skeleton files required to create different packages
* help: Help document, written for Sphinx.
* locale: .po files for localisation.
There are also other sub-folder that comes from external repositories and are part of this repo as
git submodules:
* Sparkle: An auto-update library for the OS X version.
* locale: .po files for localization.
* hscommon: A collection of helpers used across HS applications.
* cocoalib: A collection of helpers used across Cocoa UI codebases of HS applications.
* qtlib: A collection of helpers used across Qt UI codebases of HS applications.
## How to build dupeGuru from source
### make
### Windows & macOS specific additional instructions
For windows instructions see the [Windows Instructions](Windows.md).
If you're on linux, you can build the ap for local development with `make`:
For macos instructions (qt version) see the [macOS Instructions](macos.md).
$ make
$ make run
### Prerequisites
* [Python 3.7+][python]
* PyQt5
The `Makefile` is a recent addition, however. You might have to fallback to the legacy build
scripts.
### System Setup
When running in a linux based environment the following system packages or equivalents are needed to build:
* python3-pyqt5
* pyqt5-dev-tools (on some systems, see note)
* python3-venv (only if using a virtual environment)
* python3-dev
* build-essential
### Legacy build
Note: On some linux systems pyrcc5 is not put on the path when installing python3-pyqt5, this will cause some issues with the resource files (and icons). These systems should have a respective pyqt5-dev-tools package, which should also be installed. The presence of pyrcc5 can be checked with `which pyrcc5`. Debian based systems need the extra package, and Arch does not.
If you're on OS X or that if the `make` method didn't work, you can build dupeGuru with the
legacy scripts.
To create packages the following are also needed:
* python3-setuptools
* debhelper
There's a bootstrap script that will make building very easy. There might be some things that you
need to install manually on your system, but the bootstrap script will tell you when what you need
to install. You can run the bootstrap with:
### Building with Make
dupeGuru comes with a makefile that can be used to build and run:
$ ./bootstrap.sh
$ make && make run
and follow instructions from the script.
### Building without Make
### Prerequisites installation
Prerequisites are installed through `pip`. However, some of them are not "pip installable" and have
to be installed manually.
* All systems: [Python 3.4+][python]
* Mac OS X: OS X 10.10+ with XCode command line tools.
* Linux: PyQt5
On Ubuntu (14.04+), the apt-get command to install all pre-requisites is:
$ apt-get install python3-dev python3-pyqt5 pyqt5-dev-tools python3-venv
### OS X and pyenv
[pyenv][pyenv] is a popular way to manage multiple python versions. However, be aware that dupeGuru
will not compile with a pyenv's python unless it's been built with `--enable-framework`. You can do
this with:
$ env PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install 3.4.3
### Setting up the virtual environment
*This is done automatically by the bootstrap script. This is a reference in case you need to do it
manually.*
Use Python's built-in `pyvenv` to create a virtual environment in which we're going to install our.
Python-related dependencies. In that environment, we then install our requirements with pip.
For Linux (`--system-site-packages` is to be able to import PyQt):
$ pyvenv --system-site-packages env
$ source env/bin/activate
$ cd <dupeGuru directory>
$ python3 -m venv --system-site-packages ./env
$ source ./env/bin/activate
$ pip install -r requirements.txt
For OS X:
$ pyvenv env
$ source env/bin/activate
$ pip install -r requirements-osx.txt
### Actual building and running
With your virtualenv activated, you can build and run dupeGuru with these commands:
$ python build.py
$ python run.py
You can also package dupeGuru into an installable package with:
### Generating Debian/Ubuntu package
To generate packages the extra requirements in requirements-extra.txt must be installed, the
steps are as follows:
$ cd <dupeGuru directory>
$ python3 -m venv --system-site-packages ./env
$ source ./env/bin/activate
$ pip install -r requirements.txt -r requirements-extra.txt
$ python build.py --clean
$ python package.py
This can be made a one-liner (once in the directory) as:
### Generate Ubuntu packages
$ bash -c "python3 -m venv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt -r requirements-extra.txt && python build.py --clean && python package.py"
$ bash -c "pyvenv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt && python3 build.py --clean && python3 package.py"
## Running tests
### Running tests
The complete test suite is ran with [Tox 1.7+][tox]. If you have it installed system-wide, you
The complete test suite is run with [Tox 1.7+][tox]. If you have it installed system-wide, you
don't even need to set up a virtualenv. Just `cd` into the root project folder and run `tox`.
If you don't have Tox system-wide, install it in your virtualenv with `pip install tox` and then
@ -159,13 +89,9 @@ You can also run automated tests without Tox. Extra requirements for running tes
`requirements-extra.txt`. So, you can do `pip install -r requirements-extra.txt` inside your
virtualenv and then `py.test core hscommon`
[dupeguru]: http://www.hardcoded.net/dupeguru/
[dupeguru]: https://dupeguru.voltaicideas.net/
[cross-toolkit]: http://www.hardcoded.net/articles/cross-toolkit-software
[contrib-issue]: https://github.com/hsoft/dupeguru/issues/300
[nowindows]: https://www.hardcoded.net/archive2015#2015-11-01
[documentation]: http://www.hardcoded.net/dupeguru/help/en/
[documentation]: http://dupeguru.voltaicideas.net/help/en/
[python]: http://www.python.org/
[pyqt]: http://www.riverbankcomputing.com
[pyenv]: https://github.com/yyuu/pyenv
[tox]: https://tox.readthedocs.org/en/latest/

55
Windows.md Normal file
View File

@ -0,0 +1,55 @@
## How to build dupeGuru for Windows
### Prerequisites
- [Python 3.7+][python]
- [Visual Studio 2019][vs] or [Visual Studio Build Tools 2019][vsBuildTools] with the Windows 10 SDK
- [nsis][nsis] (for installer creation)
- [msys2][msys2] (for using makefile method)
NOTE: When installing Visual Studio or the Visual Studio Build Tools with the Windows 10 SDK on versions of Windows below 10 be sure to make sure that the Universal CRT is installed before installing Visual studio as noted in the [Windows 10 SDK Notes][win10sdk] and found at [KB2999226][KB2999226].
After installing python it is recommended to update setuptools before compiling packages. To update run (example is for python launcher and 3.8):
$ py -3.8 -m pip install --upgrade setuptools
More details on setting up python for compiling packages on windows can be found on the [python wiki][pythonWindowsCompilers] Take note of the required vc++ versions.
### With build.py (preferred)
To build with a different python version 3.7 vs 3.8 or 32 bit vs 64 bit specify that version instead of -3.8 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each virtual environment.
$ cd <dupeGuru directory>
$ py -3.8 -m venv .\env
$ .\env\Scripts\activate
$ pip install -r requirements.txt
$ python build.py
$ python run.py
### With makefile
It is possible to build dupeGuru with the makefile on windows using a compatable POSIX environment. The following steps have been tested using [msys2][msys2]. Before running make:
1. Install msys2 or other POSIX environment
2. Install PyQt5 globally via pip
3. Use the respective console for msys2 it is `msys2 msys`
Then the following execution of the makefile should work. Pass the correct value for PYTHON to the makefile if not on the path as python3.
$ cd <dupeGuru directory>
$ make PYTHON='py -3.8'
$ make run
### Generate Windows Installer Packages
You need to use the respective x86 or x64 version of python to build the 32 bit and 64 bit versions. The build scripts will automatically detect the python architecture for you. When using build.py make sure the resulting python works before continuing to package.py. NOTE: package.py looks for the 'makensis' executable in the default location for a 64 bit windows system. The extra requirements need to be installed to run packaging: `pip install -r requirements-extra.txt`. Run the following in the respective virtual environment.
$ python package.py
### Running tests
The complete test suite can be run with tox just like on linux. NOTE: The extra requirements need to be installed to run unit tests: `pip install -r requirements-extra.txt`.
[python]: http://www.python.org/
[nsis]: http://nsis.sourceforge.net/Main_Page
[vs]: https://www.visualstudio.com/downloads/#visual-studio-community-2019
[vsBuildTools]: https://www.visualstudio.com/downloads/#build-tools-for-visual-studio-2019
[win10sdk]: https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk
[KB2999226]: https://support.microsoft.com/en-us/help/2999226/update-for-universal-c-runtime-in-windows
[pythonWindowsCompilers]: https://wiki.python.org/moin/WindowsCompilers
[msys2]: http://www.msys2.org/

View File

@ -1,41 +0,0 @@
#!/bin/bash
PYTHON=python3
ret=`$PYTHON -c "import sys; print(int(sys.version_info[:2] >= (3, 4)))"`
if [ $ret -ne 1 ]; then
echo "Python 3.4+ required. Aborting."
exit 1
fi
if [ -d ".git" ]; then
git submodule init
git submodule update
fi
if [ ! -d "env" ]; then
echo "No virtualenv. Creating one"
# We need a "system-site-packages" env to have PyQt, but we also need to ensure a local pip
# install. To achieve our latter goal, we start with a normal venv, which we later upgrade to
# a system-site-packages once pip is installed.
if ! $PYTHON -m venv env ; then
echo "Creation of our virtualenv failed. If you're on Ubuntu, you probably need python3-venv."
exit 1
fi
if [ "$(uname)" != "Darwin" ]; then
$PYTHON -m venv env --upgrade --system-site-packages
fi
fi
source env/bin/activate
echo "Installing pip requirements"
if [ "$(uname)" == "Darwin" ]; then
./env/bin/pip install -r requirements-osx.txt
else
./env/bin/python -c "import PyQt5" >/dev/null 2>&1 || { echo >&2 "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
./env/bin/pip install -r requirements.txt
fi
echo "Bootstrapping complete! You can now configure, build and run dupeGuru with:"
echo ". env/bin/activate && python build.py && python run.py"

415
build.py
View File

@ -1,382 +1,165 @@
# Copyright 2016 Hardcoded Software (http://www.hardcoded.net)
# Copyright 2017 Virgil Dupras
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from pathlib import Path
import sys
import os
import os.path as op
from optparse import OptionParser
import shutil
import compileall
from setuptools import setup, Extension
from multiprocessing import Pool
from setuptools import sandbox
from hscommon import sphinxgen
from hscommon.build import (
add_to_pythonpath, print_and_do, copy_packages, filereplace,
get_module_version, move_all, copy_all, OSXAppStructure,
build_cocoalib_xibless, fix_qt_resource_file, build_cocoa_ext, copy_embeddable_python_dylib,
collect_stdlib_dependencies
add_to_pythonpath,
print_and_do,
fix_qt_resource_file,
)
from hscommon import loc
from hscommon.plat import ISOSX
from hscommon.util import ensure_folder, delete_files_with_pattern
def parse_args():
usage = "usage: %prog [options]"
parser = OptionParser(usage=usage)
parser.add_option(
'--clean', action='store_true', dest='clean',
help="Clean build folder before building"
"--clean",
action="store_true",
dest="clean",
help="Clean build folder before building",
)
parser.add_option("--doc", action="store_true", dest="doc", help="Build only the help file (en)")
parser.add_option("--alldoc", action="store_true", dest="all_doc", help="Build only the help file in all languages")
parser.add_option("--loc", action="store_true", dest="loc", help="Build only localization")
parser.add_option(
"--updatepot",
action="store_true",
dest="updatepot",
help="Generate .pot files from source code.",
)
parser.add_option(
'--doc', action='store_true', dest='doc',
help="Build only the help file"
"--mergepot",
action="store_true",
dest="mergepot",
help="Update all .po files based on .pot files.",
)
parser.add_option(
'--ui', dest='ui',
help="Type of UI to build. 'qt' or 'cocoa'. Default is determined by your system."
"--normpo",
action="store_true",
dest="normpo",
help="Normalize all PO files (do this before commit).",
)
parser.add_option(
'--dev', action='store_true', dest='dev', default=False,
help="If this flag is set, will configure for dev builds."
)
parser.add_option(
'--loc', action='store_true', dest='loc',
help="Build only localization"
)
parser.add_option(
'--cocoa-ext', action='store_true', dest='cocoa_ext',
help="Build only Cocoa extensions"
)
parser.add_option(
'--cocoa-compile', action='store_true', dest='cocoa_compile',
help="Build only Cocoa executable"
)
parser.add_option(
'--xibless', action='store_true', dest='xibless',
help="Build only xibless UIs"
)
parser.add_option(
'--updatepot', action='store_true', dest='updatepot',
help="Generate .pot files from source code."
)
parser.add_option(
'--mergepot', action='store_true', dest='mergepot',
help="Update all .po files based on .pot files."
)
parser.add_option(
'--normpo', action='store_true', dest='normpo',
help="Normalize all PO files (do this before commit)."
"--modules",
action="store_true",
dest="modules",
help="Build the python modules.",
)
(options, args) = parser.parse_args()
return options
def cocoa_app():
app_path = 'build/dupeGuru.app'
return OSXAppStructure(app_path)
def build_xibless(dest='cocoa/autogen'):
import xibless
ensure_folder(dest)
FNPAIRS = [
('ignore_list_dialog.py', 'IgnoreListDialog_UI'),
('deletion_options.py', 'DeletionOptions_UI'),
('problem_dialog.py', 'ProblemDialog_UI'),
('directory_panel.py', 'DirectoryPanel_UI'),
('prioritize_dialog.py', 'PrioritizeDialog_UI'),
('result_window.py', 'ResultWindow_UI'),
('main_menu.py', 'MainMenu_UI'),
('details_panel.py', 'DetailsPanel_UI'),
('details_panel_picture.py', 'DetailsPanelPicture_UI'),
]
for srcname, dstname in FNPAIRS:
xibless.generate(
op.join('cocoa', 'ui', srcname), op.join(dest, dstname),
localizationTable='Localizable'
)
for appmode in ('standard', 'music', 'picture'):
xibless.generate(
op.join('cocoa', 'ui', 'preferences_panel.py'),
op.join(dest, 'PreferencesPanel%s_UI' % appmode.capitalize()),
localizationTable='Localizable',
args={'appmode': appmode},
)
def build_one_help(language):
print(f"Generating Help in {language}")
current_path = Path(".").absolute()
changelog_path = current_path.joinpath("help", "changelog")
tixurl = "https://github.com/arsenetar/dupeguru/issues/{}"
changelogtmpl = current_path.joinpath("help", "changelog.tmpl")
conftmpl = current_path.joinpath("help", "conf.tmpl")
help_basepath = current_path.joinpath("help", language)
help_destpath = current_path.joinpath("build", "help", language)
confrepl = {"language": language}
sphinxgen.gen(
help_basepath,
help_destpath,
changelog_path,
tixurl,
confrepl,
conftmpl,
changelogtmpl,
)
def build_cocoa(dev):
sparkle_framework_path = op.join('cocoa', 'Sparkle', 'build', 'Release', 'Sparkle.framework')
if not op.exists(sparkle_framework_path):
print("Building Sparkle")
os.chdir(op.join('cocoa', 'Sparkle'))
print_and_do('make build')
os.chdir(op.join('..', '..'))
print("Creating OS X app structure")
app = cocoa_app()
app_version = get_module_version('core')
cocoa_project_path = 'cocoa'
filereplace(op.join(cocoa_project_path, 'InfoTemplate.plist'), op.join('build', 'Info.plist'), version=app_version)
app.create(op.join('build', 'Info.plist'))
print("Building localizations")
build_localizations('cocoa')
print("Building xibless UIs")
build_cocoalib_xibless()
build_xibless()
print("Building Python extensions")
build_cocoa_proxy_module()
build_cocoa_bridging_interfaces()
print("Building the cocoa layer")
copy_embeddable_python_dylib('build')
pydep_folder = op.join(app.resources, 'py')
if not op.exists(pydep_folder):
os.mkdir(pydep_folder)
shutil.copy(op.join(cocoa_project_path, 'dg_cocoa.py'), 'build')
tocopy = [
'core', 'hscommon', 'cocoa/inter', 'cocoalib/cocoa', 'objp', 'send2trash', 'hsaudiotag',
]
copy_packages(tocopy, pydep_folder, create_links=dev)
sys.path.insert(0, 'build')
# ModuleFinder can't seem to correctly detect the multiprocessing dependency, so we have
# to manually specify it.
extra_deps = ['multiprocessing']
collect_stdlib_dependencies('build/dg_cocoa.py', pydep_folder, extra_deps=extra_deps)
del sys.path[0]
# Views are not referenced by python code, so they're not found by the collector.
copy_all('build/inter/*.so', op.join(pydep_folder, 'inter'))
if not dev:
# Important: Don't ever run delete_files_with_pattern('*.py') on dev builds because you'll
# be deleting all py files in symlinked folders.
compileall.compile_dir(pydep_folder, force=True, legacy=True)
delete_files_with_pattern(pydep_folder, '*.py')
delete_files_with_pattern(pydep_folder, '__pycache__')
print("Compiling with WAF")
os.chdir('cocoa')
print_and_do('{0} waf configure && {0} waf'.format(sys.executable))
os.chdir('..')
app.copy_executable('cocoa/build/dupeGuru')
build_help()
print("Copying resources and frameworks")
image_path = 'cocoa/dupeguru.icns'
resources = [image_path, 'cocoa/dsa_pub.pem', 'build/dg_cocoa.py', 'build/help']
app.copy_resources(*resources, use_symlinks=dev)
app.copy_frameworks('build/Python', sparkle_framework_path)
print("Creating the run.py file")
tmpl = open('cocoa/run_template.py', 'rt').read()
run_contents = tmpl.replace('{{app_path}}', app.dest)
open('run.py', 'wt').write(run_contents)
def build_qt(dev):
print("Building localizations")
build_localizations('qt')
print("Building Qt stuff")
print_and_do("pyrcc5 {0} > {1}".format(op.join('qt', 'dg.qrc'), op.join('qt', 'dg_rc.py')))
fix_qt_resource_file(op.join('qt', 'dg_rc.py'))
build_help()
print("Creating the run.py file")
shutil.copy(op.join('qt', 'run_template.py'), 'run.py')
def build_help():
print("Generating Help")
current_path = op.abspath('.')
help_basepath = op.join(current_path, 'help', 'en')
help_destpath = op.join(current_path, 'build', 'help')
changelog_path = op.join(current_path, 'help', 'changelog')
tixurl = "https://github.com/hsoft/dupeguru/issues/{}"
confrepl = {'language': 'en'}
changelogtmpl = op.join(current_path, 'help', 'changelog.tmpl')
conftmpl = op.join(current_path, 'help', 'conf.tmpl')
sphinxgen.gen(help_basepath, help_destpath, changelog_path, tixurl, confrepl, conftmpl, changelogtmpl)
languages = ["en", "de", "fr", "hy", "ru", "uk"]
# Running with Pools as for some reason sphinx seems to cross contaminate the output otherwise
with Pool(len(languages)) as p:
p.map(build_one_help, languages)
def build_qt_localizations():
loc.compile_all_po(op.join('qtlib', 'locale'))
loc.merge_locale_dir(op.join('qtlib', 'locale'), 'locale')
def build_localizations(ui):
loc.compile_all_po('locale')
if ui == 'cocoa':
app = cocoa_app()
loc.build_cocoa_localizations(app, en_stringsfile=op.join('cocoa', 'en.lproj', 'Localizable.strings'))
locale_dest = op.join(app.resources, 'locale')
elif ui == 'qt':
build_qt_localizations()
locale_dest = op.join('build', 'locale')
if op.exists(locale_dest):
def build_localizations():
loc.compile_all_po("locale")
locale_dest = Path("build", "locale")
if locale_dest.exists():
shutil.rmtree(locale_dest)
shutil.copytree('locale', locale_dest, ignore=shutil.ignore_patterns('*.po', '*.pot'))
shutil.copytree("locale", locale_dest, ignore=shutil.ignore_patterns("*.po", "*.pot"))
def build_updatepot():
if ISOSX:
print("Updating Cocoa strings file.")
build_cocoalib_xibless('cocoalib/autogen')
loc.generate_cocoa_strings_from_code('cocoalib', 'cocoalib/en.lproj')
build_xibless()
loc.generate_cocoa_strings_from_code('cocoa', 'cocoa/en.lproj')
print("Building .pot files from source files")
print("Building core.pot")
loc.generate_pot(['core'], op.join('locale', 'core.pot'), ['tr'])
loc.generate_pot(["core"], Path("locale", "core.pot"), ["tr"])
print("Building columns.pot")
loc.generate_pot(['core'], op.join('locale', 'columns.pot'), ['coltr'])
loc.generate_pot(["core"], Path("locale", "columns.pot"), ["coltr"])
print("Building ui.pot")
# When we're not under OS X, we don't want to overwrite ui.pot because it contains Cocoa locs
# We want to merge the generated pot with the old pot in the most preserving way possible.
ui_packages = ['qt', op.join('cocoa', 'inter')]
loc.generate_pot(ui_packages, op.join('locale', 'ui.pot'), ['tr'], merge=(not ISOSX))
print("Building qtlib.pot")
loc.generate_pot(['qtlib'], op.join('qtlib', 'locale', 'qtlib.pot'), ['tr'])
if ISOSX:
print("Building cocoalib.pot")
cocoalib_pot = op.join('cocoalib', 'locale', 'cocoalib.pot')
os.remove(cocoalib_pot)
loc.strings2pot(op.join('cocoalib', 'en.lproj', 'cocoalib.strings'), cocoalib_pot)
print("Enhancing ui.pot with Cocoa's strings files")
loc.strings2pot(
op.join('cocoa', 'en.lproj', 'Localizable.strings'),
op.join('locale', 'ui.pot')
)
loc.generate_pot(["qt"], Path("locale", "ui.pot"), ["tr"], merge=True)
def build_mergepot():
print("Updating .po files using .pot files")
loc.merge_pots_into_pos('locale')
loc.merge_pots_into_pos(op.join('qtlib', 'locale'))
loc.merge_pots_into_pos(op.join('cocoalib', 'locale'))
loc.merge_pots_into_pos("locale")
def build_normpo():
loc.normalize_all_pos('locale')
loc.normalize_all_pos(op.join('qtlib', 'locale'))
loc.normalize_all_pos(op.join('cocoalib', 'locale'))
loc.normalize_all_pos("locale")
def build_cocoa_proxy_module():
print("Building Cocoa Proxy")
import objp.p2o
objp.p2o.generate_python_proxy_code('cocoalib/cocoa/CocoaProxy.h', 'build/CocoaProxy.m')
build_cocoa_ext(
"CocoaProxy", 'cocoalib/cocoa',
[
'cocoalib/cocoa/CocoaProxy.m', 'build/CocoaProxy.m', 'build/ObjP.m',
'cocoalib/HSErrorReportWindow.m', 'cocoa/autogen/HSErrorReportWindow_UI.m'
],
['AppKit', 'CoreServices'],
['cocoalib', 'cocoa/autogen']
)
def build_cocoa_bridging_interfaces():
print("Building Cocoa Bridging Interfaces")
import objp.o2p
import objp.p2o
add_to_pythonpath('cocoa')
add_to_pythonpath('cocoalib')
from cocoa.inter import (
PyGUIObject, GUIObjectView, PyColumns, ColumnsView, PyOutline,
OutlineView, PySelectableList, SelectableListView, PyTable, TableView, PyBaseApp,
PyTextField, ProgressWindowView, PyProgressWindow
)
from inter.deletion_options import PyDeletionOptions, DeletionOptionsView
from inter.details_panel import PyDetailsPanel, DetailsPanelView
from inter.directory_outline import PyDirectoryOutline, DirectoryOutlineView
from inter.prioritize_dialog import PyPrioritizeDialog, PrioritizeDialogView
from inter.prioritize_list import PyPrioritizeList, PrioritizeListView
from inter.problem_dialog import PyProblemDialog
from inter.ignore_list_dialog import PyIgnoreListDialog, IgnoreListDialogView
from inter.result_table import PyResultTable, ResultTableView
from inter.stats_label import PyStatsLabel, StatsLabelView
from inter.app import PyDupeGuru, DupeGuruView
allclasses = [
PyGUIObject, PyColumns, PyOutline, PySelectableList, PyTable, PyBaseApp,
PyDetailsPanel, PyDirectoryOutline, PyPrioritizeDialog, PyPrioritizeList, PyProblemDialog,
PyIgnoreListDialog, PyDeletionOptions, PyResultTable, PyStatsLabel, PyDupeGuru,
PyTextField, PyProgressWindow
]
for class_ in allclasses:
objp.o2p.generate_objc_code(class_, 'cocoa/autogen', inherit=True)
allclasses = [
GUIObjectView, ColumnsView, OutlineView, SelectableListView, TableView,
DetailsPanelView, DirectoryOutlineView, PrioritizeDialogView, PrioritizeListView,
IgnoreListDialogView, DeletionOptionsView, ResultTableView, StatsLabelView,
ProgressWindowView, DupeGuruView
]
clsspecs = [objp.o2p.spec_from_python_class(class_) for class_ in allclasses]
objp.p2o.generate_python_proxy_code_from_clsspec(clsspecs, 'build/CocoaViews.m')
build_cocoa_ext('CocoaViews', 'cocoa/inter', ['build/CocoaViews.m', 'build/ObjP.m'])
def build_pe_modules(ui):
def build_pe_modules():
print("Building PE Modules")
exts = [
Extension(
"_block",
[op.join('core', 'pe', 'modules', 'block.c'), op.join('core', 'pe', 'modules', 'common.c')]
),
Extension(
"_cache",
[op.join('core', 'pe', 'modules', 'cache.c'), op.join('core', 'pe', 'modules', 'common.c')]
),
]
if ui == 'qt':
exts.append(Extension("_block_qt", [op.join('qt', 'pe', 'modules', 'block.c')]))
elif ui == 'cocoa':
exts.append(Extension(
"_block_osx",
[op.join('core', 'pe', 'modules', 'block_osx.m'), op.join('core', 'pe', 'modules', 'common.c')],
extra_link_args=[
"-framework", "CoreFoundation",
"-framework", "Foundation",
"-framework", "ApplicationServices",
]
))
setup(
script_args=['build_ext', '--inplace'],
ext_modules=exts,
)
move_all('_block_qt*', op.join('qt', 'pe'))
move_all('_block*', op.join('core', 'pe'))
move_all('_cache*', op.join('core', 'pe'))
# Leverage setup.py to build modules
sandbox.run_setup("setup.py", ["build_ext", "--inplace"])
def build_normal(ui, dev):
print("Building dupeGuru with UI {}".format(ui))
add_to_pythonpath('.')
def build_normal():
print("Building dupeGuru with UI qt")
add_to_pythonpath(".")
print("Building dupeGuru")
build_pe_modules(ui)
if ui == 'cocoa':
build_cocoa(dev)
elif ui == 'qt':
build_qt(dev)
build_pe_modules()
print("Building localizations")
build_localizations()
print("Building Qt stuff")
Path("qt", "dg_rc.py").unlink(missing_ok=True)
print_and_do("pyrcc5 {} > {}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py")))
fix_qt_resource_file(Path("qt", "dg_rc.py"))
build_help()
def main():
if sys.version_info < (3, 7):
sys.exit("Python < 3.7 is unsupported.")
options = parse_args()
ui = options.ui
if ui not in ('cocoa', 'qt'):
ui = 'cocoa' if ISOSX else 'qt'
if options.dev:
print("Building in Dev mode")
if options.clean:
for path in ['build', op.join('cocoa', 'build'), op.join('cocoa', 'autogen')]:
if op.exists(path):
shutil.rmtree(path)
if not op.exists('build'):
os.mkdir('build')
if options.clean and Path("build").exists():
shutil.rmtree("build")
if not Path("build").exists():
Path("build").mkdir()
if options.doc:
build_one_help("en")
elif options.all_doc:
build_help()
elif options.loc:
build_localizations(ui)
build_localizations()
elif options.updatepot:
build_updatepot()
elif options.mergepot:
build_mergepot()
elif options.normpo:
build_normpo()
elif options.cocoa_ext:
build_cocoa_proxy_module()
build_cocoa_bridging_interfaces()
elif options.cocoa_compile:
os.chdir('cocoa')
print_and_do('{0} waf configure && {0} waf'.format(sys.executable))
os.chdir('..')
cocoa_app().copy_executable('cocoa/build/dupeGuru')
elif options.xibless:
build_cocoalib_xibless()
build_xibless()
elif options.modules:
build_pe_modules()
else:
build_normal(ui, options.dev)
build_normal()
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@ -1,82 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import <Sparkle/SUUpdater.h>
#import "PyDupeGuru.h"
#import "ResultWindow.h"
#import "ResultTable.h"
#import "DetailsPanel.h"
#import "DirectoryPanel.h"
#import "IgnoreListDialog.h"
#import "ProblemDialog.h"
#import "DeletionOptions.h"
#import "HSAboutBox.h"
#import "HSRecentFiles.h"
#import "HSProgressWindow.h"
@interface AppDelegate : NSObject <NSFileManagerDelegate>
{
NSMenu *recentResultsMenu;
NSMenu *columnsMenu;
SUUpdater *updater;
PyDupeGuru *model;
ResultWindow *_resultWindow;
DirectoryPanel *_directoryPanel;
DetailsPanel *_detailsPanel;
IgnoreListDialog *_ignoreListDialog;
ProblemDialog *_problemDialog;
DeletionOptions *_deletionOptions;
HSProgressWindow *_progressWindow;
NSWindowController *_preferencesPanel;
HSAboutBox *_aboutBox;
HSRecentFiles *_recentResults;
}
@property (readwrite, retain) NSMenu *recentResultsMenu;
@property (readwrite, retain) NSMenu *columnsMenu;
@property (readwrite, retain) SUUpdater *updater;
/* Virtual */
+ (NSDictionary *)defaultPreferences;
- (PyDupeGuru *)model;
- (DetailsPanel *)createDetailsPanel;
- (void)setScanOptions;
/* Public */
- (void)finalizeInit;
- (ResultWindow *)resultWindow;
- (DirectoryPanel *)directoryPanel;
- (DetailsPanel *)detailsPanel;
- (HSRecentFiles *)recentResults;
- (NSInteger)getAppMode;
- (void)setAppMode:(NSInteger)appMode;
/* Delegate */
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
- (void)applicationWillBecomeActive:(NSNotification *)aNotification;
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender;
- (void)applicationWillTerminate:(NSNotification *)aNotification;
- (void)recentFileClicked:(NSString *)path;
/* Actions */
- (void)clearPictureCache;
- (void)loadResults;
- (void)openWebsite;
- (void)openHelp;
- (void)showAboutBox;
- (void)showDirectoryWindow;
- (void)showPreferencesPanel;
- (void)showResultWindow;
- (void)showIgnoreList;
- (void)startScanning;
/* model --> view */
- (void)showMessage:(NSString *)msg;
@end

View File

@ -1,402 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "AppDelegate.h"
#import "ProgressController.h"
#import "HSPyUtil.h"
#import "Consts.h"
#import "Dialogs.h"
#import "Utils.h"
#import "ValueTransformers.h"
#import "DetailsPanelPicture.h"
#import "PreferencesPanelStandard_UI.h"
#import "PreferencesPanelMusic_UI.h"
#import "PreferencesPanelPicture_UI.h"
@implementation AppDelegate
@synthesize recentResultsMenu;
@synthesize columnsMenu;
@synthesize updater;
+ (NSDictionary *)defaultPreferences
{
NSMutableDictionary *d = [NSMutableDictionary dictionary];
[d setObject:i2n(1) forKey:@"scanTypeStandard"];
[d setObject:i2n(3) forKey:@"scanTypeMusic"];
[d setObject:i2n(0) forKey:@"scanTypePicture"];
[d setObject:i2n(95) forKey:@"minMatchPercentage"];
[d setObject:i2n(30) forKey:@"smallFileThreshold"];
[d setObject:b2n(YES) forKey:@"wordWeighting"];
[d setObject:b2n(NO) forKey:@"matchSimilarWords"];
[d setObject:b2n(YES) forKey:@"ignoreSmallFiles"];
[d setObject:b2n(NO) forKey:@"scanTagTrack"];
[d setObject:b2n(YES) forKey:@"scanTagArtist"];
[d setObject:b2n(YES) forKey:@"scanTagAlbum"];
[d setObject:b2n(YES) forKey:@"scanTagTitle"];
[d setObject:b2n(NO) forKey:@"scanTagGenre"];
[d setObject:b2n(NO) forKey:@"scanTagYear"];
[d setObject:b2n(NO) forKey:@"matchScaled"];
[d setObject:i2n(1) forKey:@"recreatePathType"];
[d setObject:i2n(11) forKey:TableFontSize];
[d setObject:b2n(YES) forKey:@"mixFileKind"];
[d setObject:b2n(NO) forKey:@"useRegexpFilter"];
[d setObject:b2n(NO) forKey:@"ignoreHardlinkMatches"];
[d setObject:b2n(NO) forKey:@"removeEmptyFolders"];
[d setObject:b2n(NO) forKey:@"DebugMode"];
[d setObject:@"" forKey:@"CustomCommand"];
[d setObject:[NSArray array] forKey:@"recentDirectories"];
[d setObject:[NSArray array] forKey:@"columnsOrder"];
[d setObject:[NSDictionary dictionary] forKey:@"columnsWidth"];
return d;
}
+ (void)initialize
{
HSVTAdd *vt = [[[HSVTAdd alloc] initWithValue:4] autorelease];
[NSValueTransformer setValueTransformer:vt forName:@"vtRowHeightOffset"];
NSDictionary *d = [self defaultPreferences];
[[NSUserDefaultsController sharedUserDefaultsController] setInitialValues:d];
[[NSUserDefaults standardUserDefaults] registerDefaults:d];
}
- (id)init
{
self = [super init];
model = [[PyDupeGuru alloc] init];
[model bindCallback:createCallback(@"DupeGuruView", self)];
[self setUpdater:[SUUpdater sharedUpdater]];
NSMutableIndexSet *contentsIndexes = [NSMutableIndexSet indexSet];
[contentsIndexes addIndex:1];
[contentsIndexes addIndex:2];
VTIsIntIn *vt = [[[VTIsIntIn alloc] initWithValues:contentsIndexes reverse:YES] autorelease];
[NSValueTransformer setValueTransformer:vt forName:@"vtScanTypeIsNotContent"];
NSMutableIndexSet *i = [NSMutableIndexSet indexSetWithIndex:0];
VTIsIntIn *vtScanTypeIsFuzzy = [[[VTIsIntIn alloc] initWithValues:i reverse:NO] autorelease];
[NSValueTransformer setValueTransformer:vtScanTypeIsFuzzy forName:@"vtScanTypeIsFuzzy"];
i = [NSMutableIndexSet indexSetWithIndex:4];
VTIsIntIn *vtScanTypeIsNotContent = [[[VTIsIntIn alloc] initWithValues:i reverse:YES] autorelease];
[NSValueTransformer setValueTransformer:vtScanTypeIsNotContent forName:@"vtScanTypeMusicIsNotContent"];
VTIsIntIn *vtScanTypeIsTag = [[[VTIsIntIn alloc] initWithValues:[NSIndexSet indexSetWithIndex:3] reverse:NO] autorelease];
[NSValueTransformer setValueTransformer:vtScanTypeIsTag forName:@"vtScanTypeIsTag"];
return self;
}
- (void)finalizeInit
{
// We can only finalize initialization once the main menu has been created, which cannot happen
// before AppDelegate is created.
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
/* Because the pref pane is lazily loaded, we have to manually do the update check if the
preference is set.
*/
if ([ud boolForKey:@"SUEnableAutomaticChecks"]) {
[[SUUpdater sharedUpdater] checkForUpdatesInBackground];
}
_recentResults = [[HSRecentFiles alloc] initWithName:@"recentResults" menu:recentResultsMenu];
[_recentResults setDelegate:self];
_directoryPanel = [[DirectoryPanel alloc] initWithParentApp:self];
_ignoreListDialog = [[IgnoreListDialog alloc] initWithPyRef:[model ignoreListDialog]];
_problemDialog = [[ProblemDialog alloc] initWithPyRef:[model problemDialog]];
_deletionOptions = [[DeletionOptions alloc] initWithPyRef:[model deletionOptions]];
_progressWindow = [[HSProgressWindow alloc] initWithPyRef:[[self model] progressWindow] view:nil];
[_progressWindow setParentWindow:[_directoryPanel window]];
// Lazily loaded
_aboutBox = nil;
_preferencesPanel = nil;
_resultWindow = nil;
_detailsPanel = nil;
[[[self directoryPanel] window] makeKeyAndOrderFront:self];
}
/* Virtual */
- (PyDupeGuru *)model
{
return model;
}
- (DetailsPanel *)createDetailsPanel
{
NSInteger appMode = [self getAppMode];
if (appMode == AppModePicture) {
return [[DetailsPanelPicture alloc] initWithApp:model];
}
else {
return [[DetailsPanel alloc] initWithPyRef:[model detailsPanel]];
}
}
- (void)setScanOptions
{
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSString *scanTypeOptionName;
NSInteger appMode = [self getAppMode];
if (appMode == AppModePicture) {
scanTypeOptionName = @"scanTypePicture";
}
else if (appMode == AppModeMusic) {
scanTypeOptionName = @"scanTypeMusic";
}
else {
scanTypeOptionName = @"scanTypeStandard";
}
[model setScanType:n2i([ud objectForKey:scanTypeOptionName])];
[model setMinMatchPercentage:n2i([ud objectForKey:@"minMatchPercentage"])];
[model setWordWeighting:n2b([ud objectForKey:@"wordWeighting"])];
[model setMixFileKind:n2b([ud objectForKey:@"mixFileKind"])];
[model setIgnoreHardlinkMatches:n2b([ud objectForKey:@"ignoreHardlinkMatches"])];
[model setMatchSimilarWords:n2b([ud objectForKey:@"matchSimilarWords"])];
int smallFileThreshold = [ud integerForKey:@"smallFileThreshold"]; // In KB
int sizeThreshold = [ud boolForKey:@"ignoreSmallFiles"] ? smallFileThreshold * 1024 : 0; // The py side wants bytes
[model setSizeThreshold:sizeThreshold];
[model enable:n2b([ud objectForKey:@"scanTagTrack"]) scanForTag:@"track"];
[model enable:n2b([ud objectForKey:@"scanTagArtist"]) scanForTag:@"artist"];
[model enable:n2b([ud objectForKey:@"scanTagAlbum"]) scanForTag:@"album"];
[model enable:n2b([ud objectForKey:@"scanTagTitle"]) scanForTag:@"title"];
[model enable:n2b([ud objectForKey:@"scanTagGenre"]) scanForTag:@"genre"];
[model enable:n2b([ud objectForKey:@"scanTagYear"]) scanForTag:@"year"];
[model setMatchScaled:n2b([ud objectForKey:@"matchScaled"])];
}
/* Public */
- (ResultWindow *)resultWindow
{
return _resultWindow;
}
- (DirectoryPanel *)directoryPanel
{
return _directoryPanel;
}
- (DetailsPanel *)detailsPanel
{
return _detailsPanel;
}
- (HSRecentFiles *)recentResults
{
return _recentResults;
}
- (NSInteger)getAppMode
{
return [model getAppMode];
}
- (void)setAppMode:(NSInteger)appMode
{
[model setAppMode:appMode];
if (_preferencesPanel != nil) {
[_preferencesPanel release];
_preferencesPanel = nil;
}
}
/* Actions */
- (void)clearPictureCache
{
NSString *msg = NSLocalizedString(@"Do you really want to remove all your cached picture analysis?", @"");
if ([Dialogs askYesNo:msg] == NSAlertSecondButtonReturn) // NO
return;
[model clearPictureCache];
}
- (void)loadResults
{
NSOpenPanel *op = [NSOpenPanel openPanel];
[op setCanChooseFiles:YES];
[op setCanChooseDirectories:NO];
[op setCanCreateDirectories:NO];
[op setAllowsMultipleSelection:NO];
[op setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
[op setTitle:NSLocalizedString(@"Select a results file to load", @"")];
if ([op runModal] == NSOKButton) {
NSString *filename = [[[op URLs] objectAtIndex:0] path];
[model loadResultsFrom:filename];
[[self recentResults] addFile:filename];
}
}
- (void)openWebsite
{
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"http://www.hardcoded.net/dupeguru/"]];
}
- (void)openHelp
{
NSBundle *b = [NSBundle mainBundle];
NSString *p = [b pathForResource:@"index" ofType:@"html" inDirectory:@"help"];
NSURL *u = [NSURL fileURLWithPath:p];
[[NSWorkspace sharedWorkspace] openURL:u];
}
- (void)showAboutBox
{
if (_aboutBox == nil) {
_aboutBox = [[HSAboutBox alloc] initWithApp:model];
}
[[_aboutBox window] makeKeyAndOrderFront:nil];
}
- (void)showDirectoryWindow
{
[[[self directoryPanel] window] makeKeyAndOrderFront:nil];
}
- (void)showPreferencesPanel
{
if (_preferencesPanel == nil) {
NSWindow *window;
NSInteger appMode = [model getAppMode];
if (appMode == AppModePicture) {
window = createPreferencesPanelPicture_UI(nil);
}
else if (appMode == AppModeMusic) {
window = createPreferencesPanelMusic_UI(nil);
}
else {
window = createPreferencesPanelStandard_UI(nil);
}
_preferencesPanel = [[NSWindowController alloc] initWithWindow:window];
}
[_preferencesPanel showWindow:nil];
}
- (void)showResultWindow
{
[[[self resultWindow] window] makeKeyAndOrderFront:nil];
}
- (void)showIgnoreList
{
[model showIgnoreList];
}
- (void)startScanning
{
[[self directoryPanel] startDuplicateScan];
}
/* Delegate */
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
[model loadSession];
}
- (void)applicationWillBecomeActive:(NSNotification *)aNotification
{
if (![[[self directoryPanel] window] isVisible]) {
[[self directoryPanel] showWindow:NSApp];
}
}
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender
{
if ([model resultsAreModified]) {
NSString *msg = NSLocalizedString(@"You have unsaved results, do you really want to quit?", @"");
if ([Dialogs askYesNo:msg] == NSAlertSecondButtonReturn) { // NO
return NSTerminateCancel;
}
}
return NSTerminateNow;
}
- (void)applicationWillTerminate:(NSNotification *)aNotification
{
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSInteger sc = [ud integerForKey:@"sessionCountSinceLastIgnorePurge"];
if (sc >= 10) {
sc = -1;
[model purgeIgnoreList];
}
sc++;
[model saveSession];
[ud setInteger:sc forKey:@"sessionCountSinceLastIgnorePurge"];
// NSApplication does not release nib instances objects, we must do it manually
// Well, it isn't needed because the memory is freed anyway (we are quitting the application
// But I need to release HSRecentFiles so it saves the user defaults
[_directoryPanel release];
[_recentResults release];
}
- (void)recentFileClicked:(NSString *)path
{
[model loadResultsFrom:path];
}
/* model --> view */
- (void)showMessage:(NSString *)msg
{
[Dialogs showMessage:msg];
}
- (BOOL)askYesNoWithPrompt:(NSString *)prompt
{
return [Dialogs askYesNo:prompt] == NSAlertFirstButtonReturn;
}
- (void)createResultsWindow
{
if (_resultWindow != nil) {
[_resultWindow release];
}
if (_detailsPanel != nil) {
[_detailsPanel release];
}
// Warning: creation order is important
// If the details panel is not created first and that there are some results in the model
// (happens if we load results), a dupe selection event triggers a details refresh in the
// core before we have the chance to initialize it, and then we crash.
_detailsPanel = [self createDetailsPanel];
_resultWindow = [[ResultWindow alloc] initWithParentApp:self];
}
- (void)showResultsWindow
{
[[[self resultWindow] window] makeKeyAndOrderFront:nil];
}
- (void)showProblemDialog
{
[_problemDialog showWindow:self];
}
- (NSString *)selectDestFolderWithPrompt:(NSString *)prompt
{
NSOpenPanel *op = [NSOpenPanel openPanel];
[op setCanChooseFiles:NO];
[op setCanChooseDirectories:YES];
[op setCanCreateDirectories:YES];
[op setAllowsMultipleSelection:NO];
[op setTitle:prompt];
if ([op runModal] == NSOKButton) {
return [[[op URLs] objectAtIndex:0] path];
}
else {
return nil;
}
}
- (NSString *)selectDestFileWithPrompt:(NSString *)prompt extension:(NSString *)extension
{
NSSavePanel *sp = [NSSavePanel savePanel];
[sp setCanCreateDirectories:YES];
[sp setAllowedFileTypes:[NSArray arrayWithObject:extension]];
[sp setTitle:prompt];
if ([sp runModal] == NSOKButton) {
return [[sp URL] path];
}
else {
return nil;
}
}
@end

View File

@ -1,24 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#define JobStarted @"JobStarted"
#define JobInProgress @"JobInProgress"
#define TableFontSize @"TableFontSize"
#define jobLoad @"job_load"
#define jobScan @"job_scan"
#define jobCopy @"job_copy"
#define jobMove @"job_move"
#define jobDelete @"job_delete"
#define DGPrioritizeIndexPasteboardType @"DGPrioritizeIndexPasteboardType"
#define ImageLoadedNotification @"ImageLoadedNotification"
#define AppModeStandard 0
#define AppModeMusic 1
#define AppModePicture 2

View File

@ -1,33 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "PyDeletionOptions.h"
@interface DeletionOptions : NSWindowController
{
PyDeletionOptions *model;
NSTextField *messageTextField;
NSButton *linkButton;
NSMatrix *linkTypeRadio;
NSButton *directButton;
}
@property (readwrite, retain) NSTextField *messageTextField;
@property (readwrite, retain) NSButton *linkButton;
@property (readwrite, retain) NSMatrix *linkTypeRadio;
@property (readwrite, retain) NSButton *directButton;
- (id)initWithPyRef:(PyObject *)aPyRef;
- (void)updateOptions;
- (void)proceed;
- (void)cancel;
@end

View File

@ -1,72 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "DeletionOptions.h"
#import "DeletionOptions_UI.h"
#import "HSPyUtil.h"
@implementation DeletionOptions
@synthesize messageTextField;
@synthesize linkButton;
@synthesize linkTypeRadio;
@synthesize directButton;
- (id)initWithPyRef:(PyObject *)aPyRef
{
self = [super initWithWindow:nil];
model = [[PyDeletionOptions alloc] initWithModel:aPyRef];
[self setWindow:createDeletionOptions_UI(self)];
[model bindCallback:createCallback(@"DeletionOptionsView", self)];
return self;
}
- (void)dealloc
{
[model release];
[super dealloc];
}
- (void)updateOptions
{
[model setLinkDeleted:[linkButton state] == NSOnState];
[model setUseHardlinks:[linkTypeRadio selectedColumn] == 1];
[model setDirect:[directButton state] == NSOnState];
}
- (void)proceed
{
[NSApp stopModalWithCode:NSOKButton];
}
- (void)cancel
{
[NSApp stopModalWithCode:NSCancelButton];
}
/* model --> view */
- (void)updateMsg:(NSString *)msg
{
[messageTextField setStringValue:msg];
}
- (BOOL)show
{
[linkButton setState:NSOffState];
[directButton setState:NSOffState];
[linkTypeRadio selectCellAtRow:0 column:0];
NSInteger r = [NSApp runModalForWindow:[self window]];
[[self window] close];
return r == NSOKButton;
}
- (void)setHardlinkOptionEnabled:(BOOL)enabled
{
[linkTypeRadio setEnabled:enabled];
}
@end

View File

@ -1,31 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import <Python.h>
#import "PyDetailsPanel.h"
@interface DetailsPanel : NSWindowController <NSTableViewDataSource>
{
NSTableView *detailsTable;
PyDetailsPanel *model;
}
@property (readwrite, retain) NSTableView *detailsTable;
- (id)initWithPyRef:(PyObject *)aPyRef;
- (PyDetailsPanel *)model;
- (NSWindow *)createWindow;
- (BOOL)isVisible;
- (void)toggleVisibility;
/* Python --> Cocoa */
- (void)refresh;
@end

View File

@ -1,81 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "DetailsPanel.h"
#import "HSPyUtil.h"
#import "DetailsPanel_UI.h"
@implementation DetailsPanel
@synthesize detailsTable;
- (id)initWithPyRef:(PyObject *)aPyRef
{
self = [super initWithWindow:nil];
[self setWindow:[self createWindow]];
model = [[PyDetailsPanel alloc] initWithModel:aPyRef];
[model bindCallback:createCallback(@"DetailsPanelView", self)];
return self;
}
- (void)dealloc
{
[model release];
[super dealloc];
}
- (PyDetailsPanel *)model
{
return (PyDetailsPanel *)model;
}
- (NSWindow *)createWindow
{
return createDetailsPanel_UI(self);
}
- (void)refreshDetails
{
[detailsTable reloadData];
}
- (BOOL)isVisible
{
return [[self window] isVisible];
}
- (void)toggleVisibility
{
if ([self isVisible]) {
[[self window] close];
}
else {
[self refreshDetails]; // selection might have changed since last time
[[self window] orderFront:nil];
}
}
/* NSTableView Delegate */
- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView
{
return [[self model] numberOfRows];
}
- (id)tableView:(NSTableView *)tableView objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)row
{
return [[self model] valueForColumn:[column identifier] row:row];
}
/* Python --> Cocoa */
- (void)refresh
{
if ([[self window] isVisible]) {
[self refreshDetails];
}
}
@end

View File

@ -1,32 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "DetailsPanel.h"
#import "PyDupeGuru.h"
@interface DetailsPanelPicture : DetailsPanel
{
NSImageView *dupeImage;
NSProgressIndicator *dupeProgressIndicator;
NSImageView *refImage;
NSProgressIndicator *refProgressIndicator;
PyDupeGuru *pyApp;
BOOL _needsRefresh;
NSString *_dupePath;
NSString *_refPath;
}
@property (readwrite, retain) NSImageView *dupeImage;
@property (readwrite, retain) NSProgressIndicator *dupeProgressIndicator;
@property (readwrite, retain) NSImageView *refImage;
@property (readwrite, retain) NSProgressIndicator *refProgressIndicator;
- (id)initWithApp:(PyDupeGuru *)aApp;
@end

View File

@ -1,96 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "Utils.h"
#import "NSNotificationAdditions.h"
#import "NSImageAdditions.h"
#import "PyDupeGuru.h"
#import "DetailsPanelPicture.h"
#import "Consts.h"
#import "DetailsPanelPicture_UI.h"
@implementation DetailsPanelPicture
@synthesize dupeImage;
@synthesize dupeProgressIndicator;
@synthesize refImage;
@synthesize refProgressIndicator;
- (id)initWithApp:(PyDupeGuru *)aApp
{
self = [super initWithPyRef:[aApp detailsPanel]];
pyApp = aApp;
_needsRefresh = YES;
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(imageLoaded:) name:ImageLoadedNotification object:self];
return self;
}
- (NSWindow *)createWindow
{
return createDetailsPanelPicture_UI(self);
}
- (void)loadImageAsync:(NSString *)imagePath
{
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
NSImage *image = [[NSImage alloc] initByReferencingFile:imagePath];
NSImage *thumbnail = [image imageByScalingProportionallyToSize:NSMakeSize(512,512)];
[image release];
NSMutableDictionary *params = [NSMutableDictionary dictionary];
[params setValue:imagePath forKey:@"imagePath"];
[params setValue:thumbnail forKey:@"image"];
[[NSNotificationCenter defaultCenter] postNotificationOnMainThreadWithName:ImageLoadedNotification object:self userInfo:params waitUntilDone:YES];
[pool release];
}
- (void)refreshDetails
{
if (!_needsRefresh)
return;
[detailsTable reloadData];
NSString *refPath = [pyApp getSelectedDupeRefPath];
if (_refPath != nil)
[_refPath autorelease];
_refPath = [refPath retain];
[NSThread detachNewThreadSelector:@selector(loadImageAsync:) toTarget:self withObject:refPath];
NSString *dupePath = [pyApp getSelectedDupePath];
if (_dupePath != nil)
[_dupePath autorelease];
_dupePath = [dupePath retain];
if (![dupePath isEqual: refPath])
[NSThread detachNewThreadSelector:@selector(loadImageAsync:) toTarget:self withObject:dupePath];
[refProgressIndicator startAnimation:nil];
[dupeProgressIndicator startAnimation:nil];
_needsRefresh = NO;
}
/* Notifications */
- (void)imageLoaded:(NSNotification *)aNotification
{
NSString *imagePath = [[aNotification userInfo] valueForKey:@"imagePath"];
NSImage *image = [[aNotification userInfo] valueForKey:@"image"];
if ([imagePath isEqual: _refPath])
{
[refImage setImage:image];
[refProgressIndicator stopAnimation:nil];
}
if ([imagePath isEqual: _dupePath])
{
[dupeImage setImage:image];
[dupeProgressIndicator stopAnimation:nil];
}
}
/* Python --> Cocoa */
- (void)refresh
{
_needsRefresh = YES;
[super refresh];
}
@end

View File

@ -1,21 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import <Python.h>
#import "HSOutline.h"
#import "PyDirectoryOutline.h"
#define DGAddedFoldersNotification @"DGAddedFoldersNotification"
@interface DirectoryOutline : HSOutline {}
- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView;
- (PyDirectoryOutline *)model;
- (void)selectAll;
@end;

View File

@ -1,87 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "DirectoryOutline.h"
@implementation DirectoryOutline
- (id)initWithPyRef:(PyObject *)aPyRef outlineView:(HSOutlineView *)aOutlineView
{
self = [super initWithPyRef:aPyRef wrapperClass:[PyDirectoryOutline class]
callbackClassName:@"DirectoryOutlineView" view:aOutlineView];
[[self view] registerForDraggedTypes:[NSArray arrayWithObject:NSFilenamesPboardType]];
return self;
}
- (PyDirectoryOutline *)model
{
return (PyDirectoryOutline *)model;
}
/* Public */
- (void)selectAll
{
[[self model] selectAll];
}
/* Delegate */
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id < NSDraggingInfo >)info proposedItem:(id)item proposedChildIndex:(NSInteger)index
{
NSPasteboard *pboard;
NSDragOperation sourceDragMask;
sourceDragMask = [info draggingSourceOperationMask];
pboard = [info draggingPasteboard];
if ([[pboard types] containsObject:NSFilenamesPboardType]) {
if (sourceDragMask & NSDragOperationLink)
return NSDragOperationLink;
}
return NSDragOperationNone;
}
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id < NSDraggingInfo >)info item:(id)item childIndex:(NSInteger)index
{
NSPasteboard *pboard;
NSDragOperation sourceDragMask;
sourceDragMask = [info draggingSourceOperationMask];
pboard = [info draggingPasteboard];
if ([[pboard types] containsObject:NSFilenamesPboardType]) {
NSArray *foldernames = [pboard propertyListForType:NSFilenamesPboardType];
if (!(sourceDragMask & NSDragOperationLink))
return NO;
for (NSString *foldername in foldernames) {
[[self model] addDirectory:foldername];
}
NSDictionary *userInfo = [NSDictionary dictionaryWithObject:foldernames forKey:@"foldernames"];
[[NSNotificationCenter defaultCenter] postNotificationName:DGAddedFoldersNotification
object:self userInfo:userInfo];
}
return YES;
}
- (void)outlineView:(NSOutlineView *)aOutlineView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)tableColumn item:(id)item
{
if ([cell isKindOfClass:[NSTextFieldCell class]]) {
NSTextFieldCell *textCell = cell;
NSIndexPath *path = item;
BOOL selected = [path isEqualTo:[[self view] selectedPath]];
if (selected) {
[textCell setTextColor:[NSColor blackColor]];
return;
}
NSInteger state = [self intProperty:@"state" valueAtPath:path];
if (state == 1) {
[textCell setTextColor:[NSColor blueColor]];
}
else if (state == 2) {
[textCell setTextColor:[NSColor redColor]];
}
else {
[textCell setTextColor:[NSColor blackColor]];
}
}
}
@end

View File

@ -1,57 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "HSOutlineView.h"
#import "HSRecentFiles.h"
#import "DirectoryOutline.h"
#import "PyDupeGuru.h"
@class AppDelegate;
@interface DirectoryPanel : NSWindowController <NSOpenSavePanelDelegate>
{
AppDelegate *_app;
PyDupeGuru *model;
HSRecentFiles *_recentDirectories;
DirectoryOutline *outline;
BOOL _alwaysShowPopUp;
NSSegmentedControl *appModeSelector;
NSPopUpButton *scanTypePopup;
NSPopUpButton *addButtonPopUp;
NSPopUpButton *loadRecentButtonPopUp;
HSOutlineView *outlineView;
NSButton *removeButton;
NSButton *loadResultsButton;
}
@property (readwrite, retain) NSSegmentedControl *appModeSelector;
@property (readwrite, retain) NSPopUpButton *scanTypePopup;
@property (readwrite, retain) NSPopUpButton *addButtonPopUp;
@property (readwrite, retain) NSPopUpButton *loadRecentButtonPopUp;
@property (readwrite, retain) HSOutlineView *outlineView;
@property (readwrite, retain) NSButton *removeButton;
@property (readwrite, retain) NSButton *loadResultsButton;
- (id)initWithParentApp:(AppDelegate *)aParentApp;
- (void)fillPopUpMenu;
- (void)fillScanTypeMenu;
- (void)adjustUIToLocalization;
- (void)askForDirectory;
- (void)popupAddDirectoryMenu:(id)sender;
- (void)popupLoadRecentMenu:(id)sender;
- (void)removeSelectedDirectory;
- (void)startDuplicateScan;
- (void)addDirectory:(NSString *)directory;
- (void)refreshRemoveButtonText;
- (void)markAll;
@end

View File

@ -1,256 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "DirectoryPanel.h"
#import "DirectoryPanel_UI.h"
#import "Dialogs.h"
#import "Utils.h"
#import "AppDelegate.h"
#import "Consts.h"
@implementation DirectoryPanel
@synthesize appModeSelector;
@synthesize scanTypePopup;
@synthesize addButtonPopUp;
@synthesize loadRecentButtonPopUp;
@synthesize outlineView;
@synthesize removeButton;
@synthesize loadResultsButton;
- (id)initWithParentApp:(AppDelegate *)aParentApp
{
self = [super initWithWindow:nil];
[self setWindow:createDirectoryPanel_UI(self)];
_app = aParentApp;
model = [_app model];
[[self window] setTitle:[model appName]];
self.appModeSelector.selectedSegment = 0;
[self fillScanTypeMenu];
_alwaysShowPopUp = NO;
[self fillPopUpMenu];
_recentDirectories = [[HSRecentFiles alloc] initWithName:@"recentDirectories" menu:[addButtonPopUp menu]];
[_recentDirectories setDelegate:self];
outline = [[DirectoryOutline alloc] initWithPyRef:[model directoryTree] outlineView:outlineView];
[self refreshRemoveButtonText];
[self adjustUIToLocalization];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(directorySelectionChanged:)
name:NSOutlineViewSelectionDidChangeNotification object:outlineView];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(outlineAddedFolders:)
name:DGAddedFoldersNotification object:outline];
return self;
}
- (void)dealloc
{
[outline release];
[_recentDirectories release];
[super dealloc];
}
/* Private */
- (void)fillPopUpMenu
{
NSMenu *m = [addButtonPopUp menu];
NSMenuItem *mi = [m addItemWithTitle:NSLocalizedString(@"Add New Folder...", @"") action:@selector(askForDirectory) keyEquivalent:@""];
[mi setTarget:self];
[m addItem:[NSMenuItem separatorItem]];
}
- (void)fillScanTypeMenu
{
[[self scanTypePopup] unbind:@"selectedIndex"];
[[self scanTypePopup] removeAllItems];
[[self scanTypePopup] addItemsWithTitles:[[_app model] getScanOptions]];
NSString *keypath;
NSInteger appMode = [_app getAppMode];
if (appMode == AppModePicture) {
keypath = @"values.scanTypePicture";
}
else if (appMode == AppModeMusic) {
keypath = @"values.scanTypeMusic";
}
else {
keypath = @"values.scanTypeStandard";
}
[[self scanTypePopup] bind:@"selectedIndex" toObject:[NSUserDefaultsController sharedUserDefaultsController] withKeyPath:keypath options:nil];
}
- (void)adjustUIToLocalization
{
NSString *lang = [[NSBundle preferredLocalizationsFromArray:[[NSBundle mainBundle] localizations]] objectAtIndex:0];
NSInteger loadResultsWidthDelta = 0;
if ([lang isEqual:@"ru"]) {
loadResultsWidthDelta = 50;
}
else if ([lang isEqual:@"uk"]) {
loadResultsWidthDelta = 70;
}
else if ([lang isEqual:@"hy"]) {
loadResultsWidthDelta = 30;
}
if (loadResultsWidthDelta) {
NSRect r = [loadResultsButton frame];
r.size.width += loadResultsWidthDelta;
r.origin.x -= loadResultsWidthDelta;
[loadResultsButton setFrame:r];
}
}
/* Actions */
- (void)askForDirectory
{
NSOpenPanel *op = [NSOpenPanel openPanel];
[op setCanChooseFiles:YES];
[op setCanChooseDirectories:YES];
[op setAllowsMultipleSelection:YES];
[op setTitle:NSLocalizedString(@"Select a folder to add to the scanning list", @"")];
[op setDelegate:self];
if ([op runModal] == NSOKButton) {
for (NSURL *directoryURL in [op URLs]) {
[self addDirectory:[directoryURL path]];
}
}
}
- (void)changeAppMode:(id)sender
{
NSInteger appMode;
NSUInteger selectedSegment = self.appModeSelector.selectedSegment;
if (selectedSegment == 2) {
appMode = AppModePicture;
}
else if (selectedSegment == 1) {
appMode = AppModeMusic;
}
else {
appMode = AppModeStandard;
}
[_app setAppMode:appMode];
[self fillScanTypeMenu];
}
- (void)popupAddDirectoryMenu:(id)sender
{
if ((!_alwaysShowPopUp) && ([[_recentDirectories filepaths] count] == 0)) {
[self askForDirectory];
}
else {
[addButtonPopUp selectItem:nil];
[[addButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]];
}
}
- (void)popupLoadRecentMenu:(id)sender
{
if ([[[_app recentResults] filepaths] count] > 0) {
NSMenu *m = [loadRecentButtonPopUp menu];
while ([m numberOfItems] > 0) {
[m removeItemAtIndex:0];
}
NSMenuItem *mi = [m addItemWithTitle:NSLocalizedString(@"Load from file...", @"") action:@selector(loadResults) keyEquivalent:@""];
[mi setTarget:_app];
[m addItem:[NSMenuItem separatorItem]];
[[_app recentResults] fillMenu:m];
[loadRecentButtonPopUp selectItem:nil];
[[loadRecentButtonPopUp cell] performClickWithFrame:[sender frame] inView:[sender superview]];
}
else {
[_app loadResults];
}
}
- (void)removeSelectedDirectory
{
[[self window] makeKeyAndOrderFront:nil];
[[outline model] removeSelectedDirectory];
[self refreshRemoveButtonText];
}
- (void)startDuplicateScan
{
if ([model resultsAreModified]) {
if ([Dialogs askYesNo:NSLocalizedString(@"You have unsaved results, do you really want to continue?", @"")] == NSAlertSecondButtonReturn) // NO
return;
}
[_app setScanOptions];
[model doScan];
}
/* Public */
- (void)addDirectory:(NSString *)directory
{
[model addDirectory:directory];
[_recentDirectories addFile:directory];
[[self window] makeKeyAndOrderFront:nil];
}
- (void)refreshRemoveButtonText
{
if ([outlineView selectedRow] < 0) {
[removeButton setEnabled:NO];
return;
}
[removeButton setEnabled:YES];
NSIndexPath *path = [outline selectedIndexPath];
if (path != nil) {
NSInteger state = [outline intProperty:@"state" valueAtPath:path];
BOOL shouldDisplayArrow = ([path length] > 1) && (state == 2);
NSString *imgName = shouldDisplayArrow ? @"NSGoLeftTemplate" : @"NSRemoveTemplate";
[removeButton setImage:[NSImage imageNamed:imgName]];
}
}
- (void)markAll
{
/* markAll isn't very descriptive of what we do, but since we re-use the Mark All button from
the result window, we don't have much choice.
*/
[outline selectAll];
}
/* Delegate */
- (BOOL)panel:(id)sender shouldShowFilename:(NSString *)path
{
BOOL isdir;
[[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isdir];
return isdir;
}
- (void)recentFileClicked:(NSString *)path
{
[self addDirectory:path];
}
- (BOOL)validateMenuItem:(NSMenuItem *)item
{
if ([item action] == @selector(markAll)) {
[item setTitle:NSLocalizedString(@"Select All", @"")];
}
return YES;
}
/* Notifications */
- (void)directorySelectionChanged:(NSNotification *)aNotification
{
[self refreshRemoveButtonText];
}
- (void)outlineAddedFolders:(NSNotification *)aNotification
{
NSArray *foldernames = [[aNotification userInfo] objectForKey:@"foldernames"];
for (NSString *foldername in foldernames) {
[_recentDirectories addFile:foldername];
}
}
@end

View File

@ -1,25 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "PyIgnoreListDialog.h"
#import "HSTable.h"
@interface IgnoreListDialog : NSWindowController
{
PyIgnoreListDialog *model;
HSTable *ignoreListTable;
NSTableView *ignoreListTableView;
}
@property (readwrite, retain) PyIgnoreListDialog *model;
@property (readwrite, retain) NSTableView *ignoreListTableView;
- (id)initWithPyRef:(PyObject *)aPyRef;
- (void)initializeColumns;
@end

View File

@ -1,51 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "IgnoreListDialog.h"
#import "IgnoreListDialog_UI.h"
#import "HSPyUtil.h"
@implementation IgnoreListDialog
@synthesize model;
@synthesize ignoreListTableView;
- (id)initWithPyRef:(PyObject *)aPyRef
{
self = [super initWithWindow:nil];
self.model = [[[PyIgnoreListDialog alloc] initWithModel:aPyRef] autorelease];
[self.model bindCallback:createCallback(@"IgnoreListDialogView", self)];
[self setWindow:createIgnoreListDialog_UI(self)];
ignoreListTable = [[HSTable alloc] initWithPyRef:[model ignoreListTable] tableView:ignoreListTableView];
[self initializeColumns];
return self;
}
- (void)dealloc
{
[ignoreListTable release];
[super dealloc];
}
- (void)initializeColumns
{
HSColumnDef defs[] = {
{@"path1", 240, 40, 0, NO, nil},
{@"path2", 240, 40, 0, NO, nil},
nil
};
[[ignoreListTable columns] initializeColumns:defs];
[[ignoreListTable columns] setColumnsAsReadOnly];
}
/* model --> view */
- (void)show
{
[self showWindow:self];
}
@end

View File

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>dupeGuru</string>
<key>CFBundleHelpBookFolder</key>
<string>dupeguru_help</string>
<key>CFBundleHelpBookName</key>
<string>dupeGuru Help</string>
<key>CFBundleIconFile</key>
<string>dupeguru</string>
<key>CFBundleIdentifier</key>
<string>com.hardcoded-software.dupeguru</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>dupeGuru</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>hsft</string>
<key>CFBundleShortVersionString</key>
<string>{version}</string>
<key>CFBundleVersion</key>
<string>{version}</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHumanReadableCopyright</key>
<string>© Hardcoded Software, 2016</string>
<key>SUFeedURL</key>
<string>https://www.hardcoded.net/updates/dupeguru.appcast</string>
<key>SUPublicDSAKeyFile</key>
<string>dsa_pub.pem</string>
</dict>
</plist>

View File

@ -1,37 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "PyPrioritizeDialog.h"
#import "HSPopUpList.h"
#import "HSSelectableList.h"
#import "PrioritizeList.h"
#import "PyDupeGuru.h"
@interface PrioritizeDialog : NSWindowController
{
NSPopUpButton *categoryPopUpView;
NSTableView *criteriaTableView;
NSTableView *prioritizationTableView;
PyPrioritizeDialog *model;
HSPopUpList *categoryPopUp;
HSSelectableList *criteriaList;
PrioritizeList *prioritizationList;
}
@property (readwrite, retain) NSPopUpButton *categoryPopUpView;
@property (readwrite, retain) NSTableView *criteriaTableView;
@property (readwrite, retain) NSTableView *prioritizationTableView;
- (id)initWithApp:(PyDupeGuru *)aApp;
- (PyPrioritizeDialog *)model;
- (void)ok;
- (void)cancel;
@end;

View File

@ -1,56 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "PrioritizeDialog.h"
#import "PrioritizeDialog_UI.h"
#import "HSPyUtil.h"
@implementation PrioritizeDialog
@synthesize categoryPopUpView;
@synthesize criteriaTableView;
@synthesize prioritizationTableView;
- (id)initWithApp:(PyDupeGuru *)aApp
{
self = [super initWithWindowNibName:@"PrioritizeDialog"];
model = [[PyPrioritizeDialog alloc] initWithApp:[aApp pyRef]];
[self setWindow:createPrioritizeDialog_UI(self)];
categoryPopUp = [[HSPopUpList alloc] initWithPyRef:[[self model] categoryList] popupView:categoryPopUpView];
criteriaList = [[HSSelectableList alloc] initWithPyRef:[[self model] criteriaList] tableView:criteriaTableView];
prioritizationList = [[PrioritizeList alloc] initWithPyRef:[[self model] prioritizationList] tableView:prioritizationTableView];
[model bindCallback:createCallback(@"PrioritizeDialogView", self)];
return self;
}
- (void)dealloc
{
[categoryPopUp release];
[criteriaList release];
[prioritizationList release];
[model release];
[super dealloc];
}
- (PyPrioritizeDialog *)model
{
return (PyPrioritizeDialog *)model;
}
- (void)ok
{
[NSApp stopModal];
[self close];
}
- (void)cancel
{
[NSApp abortModal];
[self close];
}
@end

View File

@ -1,16 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "HSSelectableList.h"
#import "PyPrioritizeList.h"
@interface PrioritizeList : HSSelectableList {}
- (id)initWithPyRef:(PyObject *)aPyRef tableView:(NSTableView *)aTableView;
- (PyPrioritizeList *)model;
@end

View File

@ -1,58 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "PrioritizeList.h"
#import "Utils.h"
#import "Consts.h"
@implementation PrioritizeList
- (id)initWithPyRef:(PyObject *)aPyRef tableView:(NSTableView *)aTableView
{
self = [super initWithPyRef:aPyRef wrapperClass:[PyPrioritizeList class]
callbackClassName:@"PrioritizeListView" view:aTableView];
return self;
}
- (PyPrioritizeList *)model
{
return (PyPrioritizeList *)model;
}
- (void)setView:(NSTableView *)aTableView
{
[super setView:aTableView];
[[self view] registerForDraggedTypes:[NSArray arrayWithObject:DGPrioritizeIndexPasteboardType]];
}
- (BOOL)tableView:(NSTableView *)tv writeRowsWithIndexes:(NSIndexSet *)rowIndexes toPasteboard:(NSPasteboard*)pboard
{
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:rowIndexes];
[pboard declareTypes:[NSArray arrayWithObject:DGPrioritizeIndexPasteboardType] owner:self];
[pboard setData:data forType:DGPrioritizeIndexPasteboardType];
return YES;
}
- (NSDragOperation)tableView:(NSTableView*)tv validateDrop:(id <NSDraggingInfo>)info proposedRow:(NSInteger)row
proposedDropOperation:(NSTableViewDropOperation)op
{
if (op == NSTableViewDropAbove) {
return NSDragOperationMove;
}
return NSDragOperationNone;
}
- (BOOL)tableView:(NSTableView *)aTableView acceptDrop:(id <NSDraggingInfo>)info
row:(NSInteger)row dropOperation:(NSTableViewDropOperation)operation
{
NSPasteboard* pboard = [info draggingPasteboard];
NSData* rowData = [pboard dataForType:DGPrioritizeIndexPasteboardType];
NSIndexSet* rowIndexes = [NSKeyedUnarchiver unarchiveObjectWithData:rowData];
[[self model] moveIndexes:[Utils indexSet2Array:rowIndexes] toIndex:row];
return YES;
}
@end

View File

@ -1,26 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "PyProblemDialog.h"
#import "HSTable.h"
@interface ProblemDialog : NSWindowController
{
PyProblemDialog *model;
HSTable *problemTable;
NSTableView *problemTableView;
}
@property (readwrite, retain) PyProblemDialog *model;
@property (readwrite, retain) NSTableView *problemTableView;
- (id)initWithPyRef:(PyObject *)aPyRef;
- (void)initializeColumns;
@end

View File

@ -1,44 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "ProblemDialog.h"
#import "ProblemDialog_UI.h"
#import "Utils.h"
@implementation ProblemDialog
@synthesize model;
@synthesize problemTableView;
- (id)initWithPyRef:(PyObject *)aPyRef
{
self = [super initWithWindow:nil];
self.model = [[PyProblemDialog alloc] initWithModel:aPyRef];
[self setWindow:createProblemDialog_UI(self)];
problemTable = [[HSTable alloc] initWithPyRef:[self.model problemTable] tableView:problemTableView];
[self initializeColumns];
return self;
}
- (void)dealloc
{
[problemTable release];
[super dealloc];
}
- (void)initializeColumns
{
HSColumnDef defs[] = {
{@"path", 202, 40, 0, NO, nil},
{@"msg", 228, 40, 0, NO, nil},
nil
};
[[problemTable columns] initializeColumns:defs];
[[problemTable columns] setColumnsAsReadOnly];
}
@end

View File

@ -1,23 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import <Quartz/Quartz.h>
#import "HSTable.h"
#import "PyResultTable.h"
@interface ResultTable : HSTable <QLPreviewPanelDataSource, QLPreviewPanelDelegate>
{
}
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTableView *)aTableView;
- (PyResultTable *)model;
- (BOOL)powerMarkerMode;
- (void)setPowerMarkerMode:(BOOL)aPowerMarkerMode;
- (BOOL)deltaValuesMode;
- (void)setDeltaValuesMode:(BOOL)aDeltaValuesMode;
@end;

View File

@ -1,180 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "ResultTable.h"
#import "Dialogs.h"
#import "Utils.h"
#import "HSQuicklook.h"
@interface HSTable (private)
- (void)setPySelection;
- (void)setViewSelection;
@end
@implementation ResultTable
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTableView *)aTableView
{
self = [super initWithPyRef:aPyRef wrapperClass:[PyResultTable class] callbackClassName:@"ResultTableView" view:aTableView];
return self;
}
- (PyResultTable *)model
{
return (PyResultTable *)model;
}
/* Private */
- (void)updateQuicklookIfNeeded
{
if ([[QLPreviewPanel sharedPreviewPanel] dataSource] == self) {
[[QLPreviewPanel sharedPreviewPanel] reloadData];
}
}
- (void)setPySelection
{
[super setPySelection];
[self updateQuicklookIfNeeded];
}
- (void)setViewSelection
{
[super setViewSelection];
[self updateQuicklookIfNeeded];
}
/* Public */
- (BOOL)powerMarkerMode
{
return [[self model] powerMarkerMode];
}
- (void)setPowerMarkerMode:(BOOL)aPowerMarkerMode
{
[[self model] setPowerMarkerMode:aPowerMarkerMode];
}
- (BOOL)deltaValuesMode
{
return [[self model] deltaValuesMode];
}
- (void)setDeltaValuesMode:(BOOL)aDeltaValuesMode
{
[[self model] setDeltaValuesMode:aDeltaValuesMode];
}
/* Datasource */
- (id)tableView:(NSTableView *)aTableView objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)row
{
NSString *identifier = [column identifier];
if ([identifier isEqual:@"marked"]) {
return [[self model] valueForColumn:@"marked" row:row];
}
return [[self model] valueForRow:row column:identifier];
}
- (void)tableView:(NSTableView *)aTableView setObjectValue:(id)object forTableColumn:(NSTableColumn *)column row:(NSInteger)row
{
NSString *identifier = [column identifier];
if ([identifier isEqual:@"marked"]) {
[[self model] setValue:object forColumn:identifier row:row];
}
else if ([identifier isEqual:@"name"]) {
NSString *oldName = [[self model] valueForRow:row column:identifier];
NSString *newName = object;
if (![newName isEqual:oldName]) {
BOOL renamed = [[self model] renameSelected:newName];
if (!renamed) {
[Dialogs showMessage:[NSString stringWithFormat:NSLocalizedString(@"The name '%@' already exists.", @""), newName]];
}
else {
[[self view] setNeedsDisplay:YES];
}
}
}
}
/* Delegate */
- (void)tableView:(NSTableView *)aTableView didClickTableColumn:(NSTableColumn *)tableColumn
{
if ([[[self view] sortDescriptors] count] < 1)
return;
NSSortDescriptor *sd = [[[self view] sortDescriptors] objectAtIndex:0];
[[self model] sortBy:[sd key] ascending:[sd ascending]];
}
- (void)tableView:(NSTableView *)aTableView willDisplayCell:(id)cell forTableColumn:(NSTableColumn *)column row:(NSInteger)row
{
BOOL isSelected = [[self view] isRowSelected:row];
BOOL isMarkable = n2b([[self model] valueForColumn:@"markable" row:row]);
if ([[column identifier] isEqual:@"marked"]) {
[cell setEnabled:isMarkable];
// Low-tech solution, for indentation, but it works...
NSCellImagePosition pos = isMarkable ? NSImageRight : NSImageLeft;
[cell setImagePosition:pos];
}
if ([cell isKindOfClass:[NSTextFieldCell class]]) {
NSColor *color = [NSColor textColor];
if (isSelected) {
color = [NSColor selectedTextColor];
}
else if (isMarkable) {
if ([[self model] isDeltaAtRow:row column:[column identifier]]) {
color = [NSColor orangeColor];
}
}
else {
color = [NSColor blueColor];
}
[(NSTextFieldCell *)cell setTextColor:color];
}
}
- (BOOL)tableViewHadDeletePressed:(NSTableView *)tableView
{
[[self model] removeSelected];
return YES;
}
- (BOOL)tableViewHadSpacePressed:(NSTableView *)tableView
{
[[self model] markSelected];
return YES;
}
/* Quicklook */
- (NSInteger)numberOfPreviewItemsInPreviewPanel:(QLPreviewPanel *)panel
{
return [[[self model] selectedRows] count];
}
- (id <QLPreviewItem>)previewPanel:(QLPreviewPanel *)panel previewItemAtIndex:(NSInteger)index
{
NSArray *selectedRows = [[self model] selectedRows];
NSInteger absIndex = n2i([selectedRows objectAtIndex:index]);
NSString *path = [[self model] pathAtIndex:absIndex];
return [[HSQLPreviewItem alloc] initWithUrl:[NSURL fileURLWithPath:path] title:path];
}
- (BOOL)previewPanel:(QLPreviewPanel *)panel handleEvent:(NSEvent *)event
{
// redirect all key down events to the table view
if ([event type] == NSKeyDown) {
[[self view] keyDown:event];
return YES;
}
return NO;
}
/* Python --> Cocoa */
- (void)invalidateMarkings
{
[[self view] setNeedsDisplay:YES];
}
@end

View File

@ -1,76 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import <Quartz/Quartz.h>
#import "StatsLabel.h"
#import "ResultTable.h"
#import "HSTableView.h"
#import "PyDupeGuru.h"
@class AppDelegate;
@interface ResultWindow : NSWindowController
{
@protected
NSSegmentedControl *optionsSwitch;
NSToolbarItem *optionsToolbarItem;
HSTableView *matches;
NSTextField *stats;
NSSearchField *filterField;
AppDelegate *app;
PyDupeGuru *model;
ResultTable *table;
StatsLabel *statsLabel;
QLPreviewPanel* previewPanel;
}
@property (readwrite, retain) NSSegmentedControl *optionsSwitch;
@property (readwrite, retain) NSToolbarItem *optionsToolbarItem;
@property (readwrite, retain) HSTableView *matches;
@property (readwrite, retain) NSTextField *stats;
@property (readwrite, retain) NSSearchField *filterField;
- (id)initWithParentApp:(AppDelegate *)app;
/* Helpers */
- (void)fillColumnsMenu;
- (void)updateOptionSegments;
- (void)adjustUIToLocalization;
- (void)initResultColumns:(ResultTable *)aTable;
/* Actions */
- (void)changeOptions;
- (void)copyMarked;
- (void)trashMarked;
- (void)filter;
- (void)focusOnFilterField;
- (void)ignoreSelected;
- (void)invokeCustomCommand;
- (void)markAll;
- (void)markInvert;
- (void)markNone;
- (void)markSelected;
- (void)moveMarked;
- (void)openClicked;
- (void)openSelected;
- (void)removeMarked;
- (void)removeSelected;
- (void)renameSelected;
- (void)reprioritizeResults;
- (void)resetColumnsToDefault;
- (void)revealSelected;
- (void)saveResults;
- (void)switchSelected;
- (void)toggleColumn:(id)sender;
- (void)toggleDelta;
- (void)toggleDetailsPanel;
- (void)togglePowerMarker;
- (void)toggleQuicklookPanel;
@end

View File

@ -1,406 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "ResultWindow.h"
#import "ResultWindow_UI.h"
#import "Dialogs.h"
#import "ProgressController.h"
#import "Utils.h"
#import "AppDelegate.h"
#import "Consts.h"
#import "PrioritizeDialog.h"
@implementation ResultWindow
@synthesize optionsSwitch;
@synthesize optionsToolbarItem;
@synthesize matches;
@synthesize stats;
@synthesize filterField;
- (id)initWithParentApp:(AppDelegate *)aApp;
{
self = [super initWithWindow:nil];
app = aApp;
model = [app model];
[self setWindow:createResultWindow_UI(self)];
[[self window] setTitle:fmt(NSLocalizedString(@"%@ Results", @""), [model appName])];
/* Put a cute iTunes-like bottom bar */
[[self window] setContentBorderThickness:28 forEdge:NSMinYEdge];
table = [[ResultTable alloc] initWithPyRef:[model resultTable] view:matches];
statsLabel = [[StatsLabel alloc] initWithPyRef:[model statsLabel] view:stats];
[self initResultColumns:table];
[[table columns] setColumnsAsReadOnly];
[self fillColumnsMenu];
[matches setTarget:self];
[matches setDoubleAction:@selector(openClicked)];
[self adjustUIToLocalization];
return self;
}
- (void)dealloc
{
[table release];
[statsLabel release];
[super dealloc];
}
/* Helpers */
- (void)fillColumnsMenu
{
[[app columnsMenu] removeAllItems];
NSArray *menuItems = [[[table columns] model] menuItems];
for (NSInteger i=0; i < [menuItems count]; i++) {
NSArray *pair = [menuItems objectAtIndex:i];
NSString *display = [pair objectAtIndex:0];
BOOL marked = n2b([pair objectAtIndex:1]);
NSMenuItem *mi = [[app columnsMenu] addItemWithTitle:display action:@selector(toggleColumn:) keyEquivalent:@""];
[mi setTarget:self];
[mi setState:marked ? NSOnState : NSOffState];
[mi setTag:i];
}
[[app columnsMenu] addItem:[NSMenuItem separatorItem]];
NSMenuItem *mi = [[app columnsMenu] addItemWithTitle:NSLocalizedString(@"Reset to Default", @"")
action:@selector(resetColumnsToDefault) keyEquivalent:@""];
[mi setTarget:self];
}
- (void)updateOptionSegments
{
[optionsSwitch setSelected:[[app detailsPanel] isVisible] forSegment:0];
[optionsSwitch setSelected:[table powerMarkerMode] forSegment:1];
[optionsSwitch setSelected:[table deltaValuesMode] forSegment:2];
}
- (void)adjustUIToLocalization
{
NSString *lang = [[NSBundle preferredLocalizationsFromArray:[[NSBundle mainBundle] localizations]] objectAtIndex:0];
NSInteger seg1delta = 0;
NSInteger seg2delta = 0;
if ([lang isEqual:@"ru"]) {
seg2delta = 20;
}
else if ([lang isEqual:@"uk"]) {
seg2delta = 20;
}
else if ([lang isEqual:@"hy"]) {
seg1delta = 20;
}
if (seg1delta || seg2delta) {
[optionsSwitch setWidth:[optionsSwitch widthForSegment:0]+seg1delta forSegment:0];
[optionsSwitch setWidth:[optionsSwitch widthForSegment:1]+seg2delta forSegment:1];
NSSize s = [optionsToolbarItem maxSize];
s.width += seg1delta + seg2delta;
[optionsToolbarItem setMaxSize:s];
[optionsToolbarItem setMinSize:s];
}
}
- (void)initResultColumns:(ResultTable *)aTable
{
NSInteger appMode = [app getAppMode];
if (appMode == AppModePicture) {
HSColumnDef defs[] = {
{@"marked", 26, 26, 26, YES, [NSButtonCell class]},
{@"name", 162, 16, 0, YES, nil},
{@"folder_path", 142, 16, 0, YES, nil},
{@"size", 63, 16, 0, YES, nil},
{@"extension", 40, 16, 0, YES, nil},
{@"dimensions", 73, 16, 0, YES, nil},
{@"exif_timestamp", 120, 16, 0, YES, nil},
{@"mtime", 120, 16, 0, YES, nil},
{@"percentage", 58, 16, 0, YES, nil},
{@"dupe_count", 80, 16, 0, YES, nil},
nil
};
[[aTable columns] initializeColumns:defs];
NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"];
[[c dataCell] setButtonType:NSSwitchButton];
[[c dataCell] setControlSize:NSSmallControlSize];
c = [[aTable view] tableColumnWithIdentifier:@"size"];
[[c dataCell] setAlignment:NSRightTextAlignment];
}
else if (appMode == AppModeMusic) {
HSColumnDef defs[] = {
{@"marked", 26, 26, 26, YES, [NSButtonCell class]},
{@"name", 235, 16, 0, YES, nil},
{@"folder_path", 120, 16, 0, YES, nil},
{@"size", 63, 16, 0, YES, nil},
{@"duration", 50, 16, 0, YES, nil},
{@"bitrate", 50, 16, 0, YES, nil},
{@"samplerate", 60, 16, 0, YES, nil},
{@"extension", 40, 16, 0, YES, nil},
{@"mtime", 120, 16, 0, YES, nil},
{@"title", 120, 16, 0, YES, nil},
{@"artist", 120, 16, 0, YES, nil},
{@"album", 120, 16, 0, YES, nil},
{@"genre", 80, 16, 0, YES, nil},
{@"year", 40, 16, 0, YES, nil},
{@"track", 40, 16, 0, YES, nil},
{@"comment", 120, 16, 0, YES, nil},
{@"percentage", 57, 16, 0, YES, nil},
{@"words", 120, 16, 0, YES, nil},
{@"dupe_count", 80, 16, 0, YES, nil},
nil
};
[[aTable columns] initializeColumns:defs];
NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"];
[[c dataCell] setButtonType:NSSwitchButton];
[[c dataCell] setControlSize:NSSmallControlSize];
c = [[aTable view] tableColumnWithIdentifier:@"size"];
[[c dataCell] setAlignment:NSRightTextAlignment];
c = [[aTable view] tableColumnWithIdentifier:@"duration"];
[[c dataCell] setAlignment:NSRightTextAlignment];
c = [[aTable view] tableColumnWithIdentifier:@"bitrate"];
[[c dataCell] setAlignment:NSRightTextAlignment];
}
else {
HSColumnDef defs[] = {
{@"marked", 26, 26, 26, YES, [NSButtonCell class]},
{@"name", 195, 16, 0, YES, nil},
{@"folder_path", 183, 16, 0, YES, nil},
{@"size", 63, 16, 0, YES, nil},
{@"extension", 40, 16, 0, YES, nil},
{@"mtime", 120, 16, 0, YES, nil},
{@"percentage", 60, 16, 0, YES, nil},
{@"words", 120, 16, 0, YES, nil},
{@"dupe_count", 80, 16, 0, YES, nil},
nil
};
[[aTable columns] initializeColumns:defs];
NSTableColumn *c = [[aTable view] tableColumnWithIdentifier:@"marked"];
[[c dataCell] setButtonType:NSSwitchButton];
[[c dataCell] setControlSize:NSSmallControlSize];
c = [[aTable view] tableColumnWithIdentifier:@"size"];
[[c dataCell] setAlignment:NSRightTextAlignment];
}
[[aTable columns] restoreColumns];
}
/* Actions */
- (void)changeOptions
{
NSInteger seg = [optionsSwitch selectedSegment];
if (seg == 0) {
[self toggleDetailsPanel];
}
else if (seg == 1) {
[self togglePowerMarker];
}
else if (seg == 2) {
[self toggleDelta];
}
}
- (void)copyMarked
{
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
[model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])];
[model setCopyMoveDestType:n2i([ud objectForKey:@"recreatePathType"])];
[model copyMarked];
}
- (void)trashMarked
{
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
[model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])];
[model deleteMarked];
}
- (void)filter
{
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
[model setEscapeFilterRegexp:!n2b([ud objectForKey:@"useRegexpFilter"])];
[model applyFilter:[filterField stringValue]];
}
- (void)focusOnFilterField
{
[[self window] makeFirstResponder:filterField];
}
- (void)ignoreSelected
{
[model addSelectedToIgnoreList];
}
- (void)invokeCustomCommand
{
[model invokeCustomCommand];
}
- (void)markAll
{
[model markAll];
}
- (void)markInvert
{
[model markInvert];
}
- (void)markNone
{
[model markNone];
}
- (void)markSelected
{
[model toggleSelectedMark];
}
- (void)moveMarked
{
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
[model setRemoveEmptyFolders:n2b([ud objectForKey:@"removeEmptyFolders"])];
[model setCopyMoveDestType:n2i([ud objectForKey:@"recreatePathType"])];
[model moveMarked];
}
- (void)openClicked
{
if ([matches clickedRow] < 0) {
return;
}
[matches selectRowIndexes:[NSIndexSet indexSetWithIndex:[matches clickedRow]] byExtendingSelection:NO];
[model openSelected];
}
- (void)openSelected
{
[model openSelected];
}
- (void)removeMarked
{
[model removeMarked];
}
- (void)removeSelected
{
[model removeSelected];
}
- (void)renameSelected
{
NSInteger col = [matches columnWithIdentifier:@"name"];
NSInteger row = [matches selectedRow];
[matches editColumn:col row:row withEvent:[NSApp currentEvent] select:YES];
}
- (void)reprioritizeResults
{
PrioritizeDialog *dlg = [[PrioritizeDialog alloc] initWithApp:model];
NSInteger result = [NSApp runModalForWindow:[dlg window]];
if (result == NSRunStoppedResponse) {
[[dlg model] performReprioritization];
}
[dlg release];
[[self window] makeKeyAndOrderFront:nil];
}
- (void)resetColumnsToDefault
{
[[[table columns] model] resetToDefaults];
[self fillColumnsMenu];
}
- (void)revealSelected
{
[model revealSelected];
}
- (void)saveResults
{
NSSavePanel *sp = [NSSavePanel savePanel];
[sp setCanCreateDirectories:YES];
[sp setAllowedFileTypes:[NSArray arrayWithObject:@"dupeguru"]];
[sp setTitle:NSLocalizedString(@"Select a file to save your results to", @"")];
if ([sp runModal] == NSOKButton) {
[model saveResultsAs:[[sp URL] path]];
[[app recentResults] addFile:[[sp URL] path]];
}
}
- (void)switchSelected
{
[model makeSelectedReference];
}
- (void)toggleColumn:(id)sender
{
NSMenuItem *mi = sender;
BOOL checked = [[[table columns] model] toggleMenuItem:[mi tag]];
[mi setState:checked ? NSOnState : NSOffState];
}
- (void)toggleDetailsPanel
{
[[app detailsPanel] toggleVisibility];
[self updateOptionSegments];
}
- (void)toggleDelta
{
[table setDeltaValuesMode:![table deltaValuesMode]];
[self updateOptionSegments];
}
- (void)togglePowerMarker
{
[table setPowerMarkerMode:![table powerMarkerMode]];
[self updateOptionSegments];
}
- (void)toggleQuicklookPanel
{
if ([QLPreviewPanel sharedPreviewPanelExists] && [[QLPreviewPanel sharedPreviewPanel] isVisible]) {
[[QLPreviewPanel sharedPreviewPanel] orderOut:nil];
}
else {
[[QLPreviewPanel sharedPreviewPanel] makeKeyAndOrderFront:nil];
}
}
/* Quicklook */
- (BOOL)acceptsPreviewPanelControl:(QLPreviewPanel *)panel;
{
return YES;
}
- (void)beginPreviewPanelControl:(QLPreviewPanel *)panel
{
// This document is now responsible of the preview panel
// It is allowed to set the delegate, data source and refresh panel.
previewPanel = [panel retain];
panel.delegate = table;
panel.dataSource = table;
}
- (void)endPreviewPanelControl:(QLPreviewPanel *)panel
{
// This document loses its responsisibility on the preview panel
// Until the next call to -beginPreviewPanelControl: it must not
// change the panel's delegate, data source or refresh it.
[previewPanel release];
previewPanel = nil;
}
- (BOOL)validateToolbarItem:(NSToolbarItem *)theItem
{
return ![[ProgressController mainProgressController] isShown];
}
- (BOOL)validateMenuItem:(NSMenuItem *)item
{
if ([item action] == @selector(markAll)) {
[item setTitle:NSLocalizedString(@"Mark All", @"")];
}
return ![[ProgressController mainProgressController] isShown];
}
@end

@ -1 +0,0 @@
Subproject commit 1c8d54166b7829ec12dc659983c66be2b347d98e

View File

@ -1,17 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import "HSGUIController.h"
#import "PyStatsLabel.h"
@interface StatsLabel : HSGUIController {}
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTextField *)aLabelView;
- (PyStatsLabel *)model;
- (NSTextField *)labelView;
@end

View File

@ -1,34 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import "StatsLabel.h"
#import "Utils.h"
@implementation StatsLabel
- (id)initWithPyRef:(PyObject *)aPyRef view:(NSTextField *)aLabelView
{
return [super initWithPyRef:aPyRef wrapperClass:[PyStatsLabel class]
callbackClassName:@"StatsLabelView" view:aLabelView];
}
- (PyStatsLabel *)model
{
return (PyStatsLabel *)model;
}
- (NSTextField *)labelView
{
return (NSTextField *)view;
}
/* Python --> Cocoa */
- (void)refresh
{
[[self labelView] setStringValue:[[self model] display]];
}
@end

View File

@ -1,17 +0,0 @@
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.trans import install_gettext_trans_under_cocoa
install_gettext_trans_under_cocoa()
from cocoa.inter import PySelectableList, PyColumns, PyTable
from inter.all import *
from inter.app import PyDupeGuru
# When built under virtualenv, the dependency collector misses this module, so we have to force it
# to see the module.
import distutils.sysconfig

View File

@ -1,20 +0,0 @@
-----BEGIN PUBLIC KEY-----
MIIDOjCCAi0GByqGSM44BAEwggIgAoIBAQDSurIL+HKbw+jsppG6tp3+WOcA4W71
nhwR/DD2Se076AtCXJcssAhuCDUm+AVkQ3l34D++aYWtLR575rCrwU4lZXfQe+b9
plHK02oOuqAY8lO5y02xoHEh7XeGunZ0u8wOVZw8MI999vIJ8rtCdvIF3r26wkjx
9sieSxVpzJHDV5JHVdK3ObkXp/ts99dOD5B3CWGS8UiroMgS0FmRl7uPuADRRn2G
srHTBYMwJvq8HFzQmDxcLldGQMAKvRKchtH+nH6ci1unSnpDUyrsCd+7qv1cSTse
qc4OgXBDQ94MfVEh6Bs0S9stYfJf8cp6iV18J0sqMb9rbP4qC56iBsXfAhUAj6tx
gwima7VaNI4YiC69jpLod3MCggEAYx+/mbU8P/xGooV9MgA3nI2v2vVNkwZVFcPa
ROLQHg+R7bAftF3+1M9AnSP2O+PnXL65DwyTOab/Z/zM/vof3LLCGLYCmzPL+xvB
6PxlqO374kFsKHEaaw66nnFWzPSdks/il0rauAiEbO8Gn/a8F2HFdA/OCCzq83l6
cOhya7kGXZxdjeIfpfiNjDqZXi+8VRNDcDXx5u/T4vpkliQ+4O8ZXjwE4z2dPHfu
Bw/N7DUalkzhZygYqcgx3tUxu3x/Pso+inmIBbk/As0uZv2nEll2CkEI6CSJIpfn
pLKNQb4E4G7h+u+8kfHcwQ59RU1uGh0PU5uM+DOPg6HsC41RwgOCAQUAAoIBABLY
T8gN8KdxWheESorvgksdG+Fizhkafpac08MCwJFF24v5a8AvZbhcCMLhChrloKcQ
19qHshRIuWbSma/OqCmQKH752PTOKxRKsmqAfO0Rej2aDJrd0s7YBMY72DqeSYPP
peLlwv0gkgRW7/EbDvBI18iTbrQLZtdqs9Xajc3dyIG5wrMtAf/Gta2oWChHlBLZ
S45++Y9ou+LtW7dMc7c+aTxbzeLG36S57kAenRzjfP8zOi3P+Cc+5b9+SZgqfFrz
/ch/HjB2zYAKq9AZSmgp9qIlOIuXnctJUD9hHivuEXFDr6xi1cxj7Q8WnX4+C58/
QyGS4lebbLQ35x6fTQ8=
-----END PUBLIC KEY-----

View File

@ -1,140 +0,0 @@
"%@ Results" = "%@ Results";
"About dupeGuru" = "About dupeGuru";
"Action" = "Action";
"Actions" = "Actions";
"Add criteria to the right box and click OK to send the dupes that correspond the best to these criteria to their respective group's reference position. Read the help file for more information." = "Add criteria to the right box and click OK to send the dupes that correspond the best to these criteria to their respective group's reference position. Read the help file for more information.";
"Add New Folder..." = "Add New Folder...";
"Add Selected to Ignore List" = "Add Selected to Ignore List";
"Advanced" = "Advanced";
"After having deleted a duplicate, place a link targeting the reference file to replace the deleted file." = "After having deleted a duplicate, place a link targeting the reference file to replace the deleted file.";
"Album" = "Album";
"Application Mode:" = "Application Mode:";
"Artist" = "Artist";
"Attribute" = "Attribute";
"Automatically check for updates" = "Automatically check for updates";
"Basic" = "Basic";
"Bring All to Front" = "Bring All to Front";
"Can mix file kind" = "Can mix file kind";
"Cancel" = "Cancel";
"Check for update..." = "Check for update...";
"Clear" = "Clear";
"Clear Picture Cache" = "Clear Picture Cache";
"Close" = "Close";
"Close Window" = "Close Window";
"Columns" = "Columns";
"Copy" = "Copy";
"Copy and Move:" = "Copy and Move:";
"Copy Marked to..." = "Copy Marked to...";
"Custom command (arguments: %d for dupe, %r for ref):" = "Custom command (arguments: %d for dupe, %r for ref):";
"Cut" = "Cut";
"Debug mode (restart required)" = "Debug mode (restart required)";
"Deletion Options" = "Deletion Options";
"Delta" = "Delta";
"Details" = "Details";
"Details of Selected File" = "Details of Selected File";
"Details Panel" = "Details Panel";
"Directly delete files" = "Directly delete files";
"Directories" = "Directories";
"Do you really want to remove all your cached picture analysis?" = "Do you really want to remove all your cached picture analysis?";
"dupeGuru" = "dupeGuru";
"dupeGuru Help" = "dupeGuru Help";
"dupeGuru Preferences" = "dupeGuru Preferences";
"dupeGuru Results" = "dupeGuru Results";
"dupeGuru Website" = "dupeGuru Website";
"Dupes Only" = "Dupes Only";
"Edit" = "Edit";
"Excluded" = "Excluded";
"Export Results to CSV" = "Export Results to CSV";
"Export Results to XHTML" = "Export Results to XHTML";
"Fewer results" = "Fewer results";
"File" = "File";
"Filter" = "Filter";
"Filter hardness:" = "Filter hardness:";
"Filter Results..." = "Filter Results...";
"Folder Selection Window" = "Folder Selection Window";
"Font Size:" = "Font Size:";
"Genre" = "Genre";
"Help" = "Help";
"Hide dupeGuru" = "Hide dupeGuru";
"Hide Others" = "Hide Others";
"Ignore duplicates hardlinking to the same file" = "Ignore duplicates hardlinking to the same file";
"Ignore files smaller than:" = "Ignore files smaller than:";
"Ignore List" = "Ignore List";
"Instead of sending files to trash, delete them directly. This option is usually used as a workaround when the normal deletion method doesn't work." = "Instead of sending files to trash, delete them directly. This option is usually used as a workaround when the normal deletion method doesn't work.";
"Invert Marking" = "Invert Marking";
"Invoke Custom Command" = "Invoke Custom Command";
"KB" = "KB";
"Link deleted files" = "Link deleted files";
"Load from file..." = "Load from file...";
"Load Recent Results" = "Load Recent Results";
"Load Results" = "Load Results";
"Load Results..." = "Load Results...";
"Make Selected into Reference" = "Make Selected into Reference";
"Mark All" = "Mark All";
"Mark None" = "Mark None";
"Mark Selected" = "Mark Selected";
"Match pictures of different dimensions" = "Match pictures of different dimensions";
"Match similar words" = "Match similar words";
"Minimize" = "Minimize";
"Mode" = "Mode";
"More results" = "More results";
"Move Marked to..." = "Move Marked to...";
"Music" = "Music";
"Name" = "Name";
"Normal" = "Normal";
"Ok" = "Ok";
"Open Selected with Default Application" = "Open Selected with Default Application";
"Options" = "Options";
"Paste" = "Paste";
"Picture" = "Picture";
"Preferences..." = "Preferences...";
"Problems!" = "Problems!";
"Proceed" = "Proceed";
"Quick Look" = "Quick Look";
"Quit dupeGuru" = "Quit dupeGuru";
"Re-Prioritize duplicates" = "Re-Prioritize duplicates";
"Re-Prioritize Results..." = "Re-Prioritize Results...";
"Recreate absolute path" = "Recreate absolute path";
"Recreate relative path" = "Recreate relative path";
"Reference" = "Reference";
"Remove empty folders on delete or move" = "Remove empty folders on delete or move";
"Remove Marked from Results" = "Remove Marked from Results";
"Remove Selected" = "Remove Selected";
"Remove Selected from Results" = "Remove Selected from Results";
"Rename Selected" = "Rename Selected";
"Reset to Default" = "Reset to Default";
"Reset To Defaults" = "Reset To Defaults";
"Results Window" = "Results Window";
"Reveal" = "Reveal";
"Reveal Selected in Finder" = "Reveal Selected in Finder";
"Right in destination" = "Right in destination";
"Save Results..." = "Save Results...";
"Scan" = "Scan";
"Scan Type:" = "Scan Type:";
"Select a file to save your results to" = "Select a file to save your results to";
"Select a folder to add to the scanning list" = "Select a folder to add to the scanning list";
"Select a results file to load" = "Select a results file to load";
"Select All" = "Select All";
"Select folders to scan and press \"Scan\"." = "Select folders to scan and press \"Scan\".";
"Selected" = "Selected";
"Send Marked to Trash..." = "Send Marked to Trash...";
"Services" = "Services";
"Show All" = "Show All";
"Show Delta Values" = "Show Delta Values";
"Show Dupes Only" = "Show Dupes Only";
"Standard" = "Standard";
"Start Duplicate Scan" = "Start Duplicate Scan";
"State" = "State";
"Tags to scan:" = "Tags to scan:";
"The name '%@' already exists." = "The name '%@' already exists.";
"There were problems processing some (or all) of the files. The cause of these problems are described in the table below. Those files were not removed from your results." = "There were problems processing some (or all) of the files. The cause of these problems are described in the table below. Those files were not removed from your results.";
"Title" = "Title";
"Track" = "Track";
"Use regular expressions when filtering" = "Use regular expressions when filtering";
"Window" = "Window";
"Word weighting" = "Word weighting";
"Year" = "Year";
"You have unsaved results, do you really want to continue?" = "You have unsaved results, do you really want to continue?";
"You have unsaved results, do you really want to quit?" = "You have unsaved results, do you really want to quit?";
"Zoom" = "Zoom";

View File

@ -1,10 +0,0 @@
from cocoa.inter import PyTextField, PyProgressWindow
from .deletion_options import PyDeletionOptions
from .details_panel import PyDetailsPanel
from .directory_outline import PyDirectoryOutline
from .prioritize_dialog import PyPrioritizeDialog
from .prioritize_list import PyPrioritizeList
from .problem_dialog import PyProblemDialog
from .ignore_list_dialog import PyIgnoreListDialog
from .result_table import PyResultTable
from .stats_label import PyStatsLabel

View File

@ -1,251 +0,0 @@
import logging
from objp.util import pyref, dontwrap
from hscommon.path import Path, pathify
from cocoa import install_exception_hook, install_cocoa_logger, patch_threaded_job_performer
from cocoa.inter import PyBaseApp, BaseAppView
import core.pe.photo
from core.app import DupeGuru as DupeGuruBase, AppMode
from .directories import Directories, Bundle
from .photo import Photo
class DupeGuru(DupeGuruBase):
def __init__(self, view):
DupeGuruBase.__init__(self, view)
self.directories = Directories()
def selected_dupe_path(self):
if not self.selected_dupes:
return None
return self.selected_dupes[0].path
def selected_dupe_ref_path(self):
if not self.selected_dupes:
return None
ref = self.results.get_group_of_duplicate(self.selected_dupes[0]).ref
if ref is self.selected_dupes[0]: # we don't want the same pic to be displayed on both sides
return None
return ref.path
def _get_fileclasses(self):
result = DupeGuruBase._get_fileclasses(self)
if self.app_mode == AppMode.Standard:
result = [Bundle] + result
return result
class DupeGuruView(BaseAppView):
def askYesNoWithPrompt_(self, prompt: str) -> bool: pass
def createResultsWindow(self): pass
def showResultsWindow(self): pass
def showProblemDialog(self): pass
def selectDestFolderWithPrompt_(self, prompt: str) -> str: pass
def selectDestFileWithPrompt_extension_(self, prompt: str, extension: str) -> str: pass
class PyDupeGuru(PyBaseApp):
@dontwrap
def __init__(self):
core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = Photo
logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s')
install_exception_hook('https://github.com/hsoft/dupeguru/issues')
install_cocoa_logger()
patch_threaded_job_performer()
self.model = DupeGuru(self)
#---Sub-proxies
def detailsPanel(self) -> pyref:
return self.model.details_panel
def directoryTree(self) -> pyref:
return self.model.directory_tree
def problemDialog(self) -> pyref:
return self.model.problem_dialog
def statsLabel(self) -> pyref:
return self.model.stats_label
def resultTable(self) -> pyref:
return self.model.result_table
def ignoreListDialog(self) -> pyref:
return self.model.ignore_list_dialog
def progressWindow(self) -> pyref:
return self.model.progress_window
def deletionOptions(self) -> pyref:
return self.model.deletion_options
#---Directories
def addDirectory_(self, directory: str):
self.model.add_directory(directory)
#---Results
def doScan(self):
self.model.start_scanning()
def exportToXHTML(self):
self.model.export_to_xhtml()
def exportToCSV(self):
self.model.export_to_csv()
def loadSession(self):
self.model.load()
def loadResultsFrom_(self, filename: str):
self.model.load_from(filename)
def markAll(self):
self.model.mark_all()
def markNone(self):
self.model.mark_none()
def markInvert(self):
self.model.mark_invert()
def purgeIgnoreList(self):
self.model.purge_ignore_list()
def toggleSelectedMark(self):
self.model.toggle_selected_mark_state()
def saveSession(self):
self.model.save()
def saveResultsAs_(self, filename: str):
self.model.save_as(filename)
#---Actions
def addSelectedToIgnoreList(self):
self.model.add_selected_to_ignore_list()
def deleteMarked(self):
self.model.delete_marked()
def applyFilter_(self, filter: str):
self.model.apply_filter(filter)
def makeSelectedReference(self):
self.model.make_selected_reference()
def copyMarked(self):
self.model.copy_or_move_marked(copy=True)
def moveMarked(self):
self.model.copy_or_move_marked(copy=False)
def openSelected(self):
self.model.open_selected()
def removeMarked(self):
self.model.remove_marked()
def removeSelected(self):
self.model.remove_selected()
def revealSelected(self):
self.model.reveal_selected()
def invokeCustomCommand(self):
self.model.invoke_custom_command()
def showIgnoreList(self):
self.model.ignore_list_dialog.show()
def clearPictureCache(self):
self.model.clear_picture_cache()
#---Information
def getScanOptions(self) -> list:
return [o.label for o in self.model.SCANNER_CLASS.get_scan_options()]
def resultsAreModified(self) -> bool:
return self.model.results.is_modified
def getSelectedDupePath(self) -> str:
return str(self.model.selected_dupe_path())
def getSelectedDupeRefPath(self) -> str:
return str(self.model.selected_dupe_ref_path())
#---Properties
def getAppMode(self) -> int:
return self.model.app_mode
def setAppMode_(self, app_mode: int):
self.model.app_mode = app_mode
def setScanType_(self, scan_type_index: int):
scan_options = self.model.SCANNER_CLASS.get_scan_options()
try:
so = scan_options[scan_type_index]
self.model.options['scan_type'] = so.scan_type
except IndexError:
pass
def setMinMatchPercentage_(self, percentage: int):
self.model.options['min_match_percentage'] = int(percentage)
def setWordWeighting_(self, words_are_weighted: bool):
self.model.options['word_weighting'] = words_are_weighted
def setMatchSimilarWords_(self, match_similar_words: bool):
self.model.options['match_similar_words'] = match_similar_words
def setSizeThreshold_(self, size_threshold: int):
self.model.options['size_threshold'] = size_threshold
def enable_scanForTag_(self, enable: bool, scan_tag: str):
if 'scanned_tags' not in self.model.options:
self.model.options['scanned_tags'] = set()
if enable:
self.model.options['scanned_tags'].add(scan_tag)
else:
self.model.options['scanned_tags'].discard(scan_tag)
def setMatchScaled_(self, match_scaled: bool):
self.model.options['match_scaled'] = match_scaled
def setMixFileKind_(self, mix_file_kind: bool):
self.model.options['mix_file_kind'] = mix_file_kind
def setEscapeFilterRegexp_(self, escape_filter_regexp: bool):
self.model.options['escape_filter_regexp'] = escape_filter_regexp
def setRemoveEmptyFolders_(self, remove_empty_folders: bool):
self.model.options['clean_empty_dirs'] = remove_empty_folders
def setIgnoreHardlinkMatches_(self, ignore_hardlink_matches: bool):
self.model.options['ignore_hardlink_matches'] = ignore_hardlink_matches
def setCopyMoveDestType_(self, copymove_dest_type: int):
self.model.options['copymove_dest_type'] = copymove_dest_type
#--- model --> view
@dontwrap
def ask_yes_no(self, prompt):
return self.callback.askYesNoWithPrompt_(prompt)
@dontwrap
def create_results_window(self):
self.callback.createResultsWindow()
@dontwrap
def show_results_window(self):
self.callback.showResultsWindow()
@dontwrap
def show_problem_dialog(self):
self.callback.showProblemDialog()
@dontwrap
def select_dest_folder(self, prompt):
return self.callback.selectDestFolderWithPrompt_(prompt)
@dontwrap
def select_dest_file(self, prompt, extension):
return self.callback.selectDestFileWithPrompt_extension_(prompt, extension)

View File

@ -1,37 +0,0 @@
# Created On: 2012-05-30
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from objp.util import dontwrap
from cocoa.inter import PyGUIObject, GUIObjectView
class DeletionOptionsView(GUIObjectView):
def updateMsg_(self, msg: str): pass
def show(self) -> bool: pass
def setHardlinkOptionEnabled_(self, enabled: bool): pass
class PyDeletionOptions(PyGUIObject):
def setLinkDeleted_(self, link_deleted: bool):
self.model.link_deleted = link_deleted
def setUseHardlinks_(self, use_hardlinks: bool):
self.model.use_hardlinks = use_hardlinks
def setDirect_(self, direct: bool):
self.model.direct = direct
#--- model --> view
@dontwrap
def update_msg(self, msg):
self.callback.updateMsg_(msg)
@dontwrap
def show(self):
return self.callback.show()
@dontwrap
def set_hardlink_option_enabled(self, enabled):
self.callback.setHardlinkOptionEnabled_(enabled)

View File

@ -1,11 +0,0 @@
from cocoa.inter import PyGUIObject, GUIObjectView
class DetailsPanelView(GUIObjectView):
pass
class PyDetailsPanel(PyGUIObject):
def numberOfRows(self) -> int:
return self.model.row_count()
def valueForColumn_row_(self, column: str, row: int) -> object:
return self.model.row(row)[int(column)]

View File

@ -1,53 +0,0 @@
# Copyright 2016 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from cocoa import proxy
from hscommon.path import Path, pathify
from core.se import fs
from core.directories import Directories as DirectoriesBase, DirectoryState
def is_bundle(str_path):
uti = proxy.getUTI_(str_path)
if uti is None:
logging.warning('There was an error trying to detect the UTI of %s', str_path)
return proxy.type_conformsToType_(uti, 'com.apple.bundle') or proxy.type_conformsToType_(uti, 'com.apple.package')
class Bundle(fs.Folder):
@classmethod
@pathify
def can_handle(cls, path: Path):
return not path.islink() and path.isdir() and is_bundle(str(path))
class Directories(DirectoriesBase):
ROOT_PATH_TO_EXCLUDE = list(map(Path, ['/Library', '/Volumes', '/System', '/bin', '/sbin', '/opt', '/private', '/dev']))
HOME_PATH_TO_EXCLUDE = [Path('Library')]
def _default_state_for_path(self, path):
result = DirectoriesBase._default_state_for_path(self, path)
if result is not None:
return result
if path in self.ROOT_PATH_TO_EXCLUDE:
return DirectoryState.Excluded
if path[:2] == Path('/Users') and path[3:] in self.HOME_PATH_TO_EXCLUDE:
return DirectoryState.Excluded
def _get_folders(self, from_folder, j):
# We don't want to scan bundle's subfolder even in Folders mode. Bundle's integrity has to
# stay intact.
if is_bundle(str(from_folder.path)):
# just yield the current folder and bail
state = self.get_state(from_folder.path)
if state != DirectoryState.Excluded:
from_folder.is_ref = state == DirectoryState.Reference
yield from_folder
return
else:
yield from DirectoriesBase._get_folders(self, from_folder, j)
@staticmethod
def get_subfolders(path):
result = DirectoriesBase.get_subfolders(path)
return [p for p in result if not is_bundle(str(p))]

View File

@ -1,21 +0,0 @@
from objp.util import dontwrap
from cocoa.inter import PyOutline, GUIObjectView
class DirectoryOutlineView(GUIObjectView):
pass
class PyDirectoryOutline(PyOutline):
def addDirectory_(self, path: str):
self.model.add_directory(path)
def removeSelectedDirectory(self):
self.model.remove_selected()
def selectAll(self):
self.model.select_all()
# python --> cocoa
@dontwrap
def refresh_states(self):
# Under cocoa, both refresh() and refresh_states() do the same thing.
self.callback.refresh()

View File

@ -1,21 +0,0 @@
from objp.util import pyref, dontwrap
from cocoa.inter import PyGUIObject, GUIObjectView
class IgnoreListDialogView(GUIObjectView):
def show(self): pass
class PyIgnoreListDialog(PyGUIObject):
def ignoreListTable(self) -> pyref:
return self.model.ignore_list_table
def removeSelected(self):
self.model.remove_selected()
def clear(self):
self.model.clear()
#--- model --> view
@dontwrap
def show(self):
self.callback.show()

View File

@ -1,35 +0,0 @@
# Copyright 2016 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from cocoa import proxy
from core.pe import _block_osx
from core.pe.photo import Photo as PhotoBase
class Photo(PhotoBase):
HANDLED_EXTS = PhotoBase.HANDLED_EXTS.copy()
HANDLED_EXTS.update({'psd', 'nef', 'cr2', 'orf'})
def _plat_get_dimensions(self):
return _block_osx.get_image_size(str(self.path))
def _plat_get_blocks(self, block_count_per_side, orientation):
try:
blocks = _block_osx.getblocks(str(self.path), block_count_per_side, orientation)
except Exception as e:
raise IOError('The reading of "%s" failed with "%s"' % (str(self.path), str(e)))
if not blocks:
raise IOError('The picture %s could not be read' % str(self.path))
return blocks
def _get_exif_timestamp(self):
exifdata = proxy.readExifData_(str(self.path))
if exifdata:
try:
return exifdata['{Exif}']['DateTimeOriginal']
except KeyError:
return ''
else:
return ''

View File

@ -1,29 +0,0 @@
from objp.util import pyref
from cocoa.inter import PyGUIObject, GUIObjectView
from core.gui.prioritize_dialog import PrioritizeDialog
class PrioritizeDialogView(GUIObjectView):
pass
class PyPrioritizeDialog(PyGUIObject):
def __init__(self, app: pyref):
model = PrioritizeDialog(app.model)
PyGUIObject.__init__(self, model)
def categoryList(self) -> pyref:
return self.model.category_list
def criteriaList(self) -> pyref:
return self.model.criteria_list
def prioritizationList(self) -> pyref:
return self.model.prioritization_list
def addSelected(self):
self.model.add_selected()
def removeSelected(self):
self.model.remove_selected()
def performReprioritization(self):
self.model.perform_reprioritization()

View File

@ -1,8 +0,0 @@
from cocoa.inter import PySelectableList, SelectableListView
class PrioritizeListView(SelectableListView):
pass
class PyPrioritizeList(PySelectableList):
def moveIndexes_toIndex_(self, indexes: list, dest_index: int):
self.model.move_indexes(indexes, dest_index)

View File

@ -1,9 +0,0 @@
from objp.util import pyref
from cocoa.inter import PyGUIObject
class PyProblemDialog(PyGUIObject):
def problemTable(self) -> pyref:
return self.model.problem_table
def revealSelected(self):
self.model.reveal_selected_dupe()

View File

@ -1,50 +0,0 @@
from objp.util import dontwrap
from cocoa.inter import PyTable, TableView
class ResultTableView(TableView):
def invalidateMarkings(self): pass
class PyResultTable(PyTable):
def powerMarkerMode(self) -> bool:
return self.model.power_marker
def setPowerMarkerMode_(self, value: bool):
self.model.power_marker = value
def deltaValuesMode(self) -> bool:
return self.model.delta_values
def setDeltaValuesMode_(self, value: bool):
self.model.delta_values = value
def valueForRow_column_(self, row_index: int, column: str) -> object:
return self.model.get_row_value(row_index, column)
def isDeltaAtRow_column_(self, row_index: int, column: str) -> bool:
row = self.model[row_index]
return row.is_cell_delta(column)
def renameSelected_(self, newname: str) -> bool:
return self.model.rename_selected(newname)
def sortBy_ascending_(self, key: str, asc: bool):
self.model.sort(key, asc)
def markSelected(self):
self.model.app.toggle_selected_mark_state()
def removeSelected(self):
self.model.app.remove_selected()
def selectedDupeCount(self) -> int:
return self.model.selected_dupe_count
def pathAtIndex_(self, index: int) -> str:
row = self.model[index]
return str(row._dupe.path)
# python --> cocoa
@dontwrap
def invalidate_markings(self):
self.callback.invalidateMarkings()

View File

@ -1,9 +0,0 @@
from cocoa.inter import PyGUIObject, GUIObjectView
class StatsLabelView(GUIObjectView):
pass
class PyStatsLabel(PyGUIObject):
def display(self) -> str:
return self.model.display

View File

@ -1,49 +0,0 @@
/*
Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
which should be included with this package. The terms are also available at
http://www.gnu.org/licenses/gpl-3.0.html
*/
#import <Cocoa/Cocoa.h>
#import <Python.h>
#import <wchar.h>
#import <locale.h>
#import "AppDelegate.h"
#import "MainMenu_UI.h"
int main(int argc, char *argv[])
{
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
/* We have to set the locate to UTF8 for mbstowcs() to correctly convert non-ascii chars in paths */
setlocale(LC_ALL, "en_US.UTF-8");
NSString *respath = [[NSBundle mainBundle] resourcePath];
NSString *mainpy = [respath stringByAppendingPathComponent:@"dg_cocoa.py"];
wchar_t wPythonPath[PATH_MAX+1];
NSString *pypath = [respath stringByAppendingPathComponent:@"py"];
mbstowcs(wPythonPath, [pypath fileSystemRepresentation], PATH_MAX+1);
Py_SetPath(wPythonPath);
Py_SetPythonHome(wPythonPath);
Py_Initialize();
PyEval_InitThreads();
PyGILState_STATE gilState = PyGILState_Ensure();
FILE* fp = fopen([mainpy UTF8String], "r");
PyRun_SimpleFile(fp, [mainpy UTF8String]);
fclose(fp);
PyGILState_Release(gilState);
if (gilState == PyGILState_LOCKED) {
PyThreadState_Swap(NULL);
PyEval_ReleaseLock();
}
[NSApplication sharedApplication];
AppDelegate *appDelegate = [[AppDelegate alloc] init];
[NSApp setDelegate:appDelegate];
[NSApp setMainMenu:createMainMenu_UI(appDelegate)];
[appDelegate finalizeInit];
[pool release];
[NSApp run];
Py_Finalize();
return 0;
}

View File

@ -1,10 +0,0 @@
#!/usr/bin/env python3
import sys
import os
def main():
return os.system('open "{{app_path}}"')
if __name__ == '__main__':
sys.exit(main())

View File

@ -1,49 +0,0 @@
ownerclass = 'DeletionOptions'
ownerimport = 'DeletionOptions.h'
result = Window(450, 240, "Deletion Options")
messageLabel = Label(result, "")
linkCheckbox = Checkbox(result, "Link deleted files")
linkLabel = Label(result, "After having deleted a duplicate, place a link targeting the "
"reference file to replace the deleted file.")
linkTypeChoice = RadioButtons(result, ["Symlink", "Hardlink"], columns=2)
directCheckbox = Checkbox(result, "Directly delete files")
directLabel = Label(result, "Instead of sending files to trash, delete them directly. This option "
"is usually used as a workaround when the normal deletion method doesn't work.")
proceedButton = Button(result, "Proceed")
cancelButton = Button(result, "Cancel")
owner.linkButton = linkCheckbox
owner.linkTypeRadio = linkTypeChoice
owner.directButton = directCheckbox
owner.messageTextField = messageLabel
result.canMinimize = False
result.canResize = False
linkLabel.controlSize = ControlSize.Small
directLabel.controlSize = ControlSize.Small
linkTypeChoice.controlSize = ControlSize.Small
proceedButton.keyEquivalent = '\\r'
cancelButton.keyEquivalent = '\\e'
linkCheckbox.action = directCheckbox.action = linkTypeChoice.action = Action(owner, 'updateOptions')
proceedButton.action = Action(owner, 'proceed')
cancelButton.action = Action(owner, 'cancel')
linkLabel.height *= 2 # 2 lines
directLabel.height *= 3 # 3 lines
proceedButton.width = 92
cancelButton.width = 92
mainLayout = VLayout([messageLabel, linkCheckbox, linkLabel, linkTypeChoice, directCheckbox,
directLabel])
mainLayout.packToCorner(Pack.UpperLeft)
mainLayout.fill(Pack.Right)
buttonLayout = HLayout([cancelButton, proceedButton])
buttonLayout.packToCorner(Pack.LowerRight)
# indent the labels under checkboxes a little bit to the right
for indentedView in (linkLabel, directLabel, linkTypeChoice):
indentedView.x += 20
indentedView.width -= 20
# We actually don't want the link choice radio buttons to take all the width, it looks weird.
linkTypeChoice.width = 170

View File

@ -1,32 +0,0 @@
ownerclass = 'DetailsPanel'
ownerimport = 'DetailsPanel.h'
result = Panel(451, 146, "Details of Selected File")
table = TableView(result)
owner.detailsTable = table
result.style = PanelStyle.Utility
result.xProportion = 0.2
result.yProportion = 0.4
result.canMinimize = False
result.autosaveName = 'DetailsPanel'
result.minSize = Size(result.width, result.height)
table.dataSource = owner
table.allowsColumnReordering = False
table.allowsColumnSelection = False
table.allowsMultipleSelection = False
table.font = Font(FontFamily.System, FontSize.SmallSystem)
table.rowHeight = 14
table.editable = False
col = table.addColumn('0', "Attribute", 70)
col.autoResizable = True
col = table.addColumn('1', "Selected", 198)
col.autoResizable = True
col = table.addColumn('2', "Reference", 172)
col.autoResizable = True
table.packToCorner(Pack.UpperLeft, margin=0)
table.fill(Pack.LowerRight, margin=0)
table.setAnchor(Pack.UpperLeft, growX=True, growY=True)

View File

@ -1,70 +0,0 @@
ownerclass = 'DetailsPanelPicture'
ownerimport = 'DetailsPanelPicture.h'
result = Panel(593, 398, "Details of Selected File")
table = TableView(result)
split = SplitView(result, 2, vertical=True)
leftSplit, rightSplit = split.subviews
selectedLabel = Label(leftSplit, "Selected")
selectedImage = ImageView(leftSplit, 'NSApplicationIcon')
leftSpinner = ProgressIndicator(leftSplit)
referenceLabel = Label(rightSplit, "Reference")
referenceImage = ImageView(rightSplit, 'NSApplicationIcon')
rightSpinner = ProgressIndicator(rightSplit)
owner.detailsTable = table
owner.dupeImage = selectedImage
owner.dupeProgressIndicator = leftSpinner
owner.refImage = referenceImage
owner.refProgressIndicator = rightSpinner
table.dataSource = owner
result.style = PanelStyle.Utility
result.xProportion = 0.6
result.yProportion = 0.6
result.canMinimize = False
result.autosaveName = 'DetailsPanel'
result.minSize = Size(451, 240)
table.allowsColumnReordering = False
table.allowsColumnSelection = False
table.allowsMultipleSelection = False
table.font = Font(FontFamily.System, FontSize.SmallSystem)
table.rowHeight = 14
table.editable = False
col = table.addColumn('0', "Attribute", 70)
col.autoResizable = True
col = table.addColumn('1', "Selected", 198)
col.autoResizable = True
col = table.addColumn('2', "Reference", 172)
col.autoResizable = True
table.height = 165
sides = [
(leftSplit, selectedLabel, selectedImage, leftSpinner),
(rightSplit, referenceLabel, referenceImage, rightSpinner),
]
for subSplit, label, image, spinner in sides:
label.alignment = TextAlignment.Center
spinner.style = const.NSProgressIndicatorSpinningStyle
spinner.controlSize = const.NSSmallControlSize
spinner.displayedWhenStopped = False
label.packToCorner(Pack.UpperLeft, margin=0)
label.fill(Pack.Right, margin=0)
label.setAnchor(Pack.UpperLeft, growX=True)
image.packRelativeTo(label, Pack.Below)
image.fill(Pack.LowerRight, margin=0)
image.setAnchor(Pack.UpperLeft, growX=True, growY=True)
spinner.y = label.y
spinner.x = subSplit.width - 30
spinner.setAnchor(Pack.UpperRight)
table.packToCorner(Pack.UpperLeft, margin=0)
table.fill(Pack.Right, margin=0)
table.setAnchor(Pack.UpperLeft, growX=True)
split.packRelativeTo(table, Pack.Below)
split.fill(Pack.LowerRight, margin=0)
split.setAnchor(Pack.UpperLeft, growX=True, growY=True)

View File

@ -1,76 +0,0 @@
ownerclass = 'DirectoryPanel'
ownerimport = 'DirectoryPanel.h'
result = Window(425, 300, "dupeGuru")
promptLabel = Label(result, "Select folders to scan and press \"Scan\".")
directoryOutline = OutlineView(result)
directoryOutline.OBJC_CLASS = 'HSOutlineView'
appModeSelector = SegmentedControl(result)
appModeLabel = Label(result, "Application Mode:")
scanTypePopup = Popup(result)
scanTypeLabel = Label(result, "Scan Type:")
addButton = Button(result, "")
removeButton = Button(result, "")
loadResultsButton = Button(result, "Load Results")
scanButton = Button(result, "Scan")
addPopup = Popup(None)
loadRecentPopup = Popup(None)
owner.outlineView = directoryOutline
owner.appModeSelector = appModeSelector
owner.scanTypePopup = scanTypePopup
owner.removeButton = removeButton
owner.loadResultsButton = loadResultsButton
owner.addButtonPopUp = addPopup
owner.loadRecentButtonPopUp = loadRecentPopup
result.autosaveName = 'DirectoryPanel'
result.canMinimize = False
result.minSize = Size(400, 270)
for label in ["Standard", "Music", "Picture"]:
appModeSelector.addSegment(label, 80)
addButton.bezelStyle = removeButton.bezelStyle = const.NSTexturedRoundedBezelStyle
addButton.image = 'NSAddTemplate'
removeButton.image = 'NSRemoveTemplate'
for button in (addButton, removeButton):
button.style = const.NSTexturedRoundedBezelStyle
button.imagePosition = const.NSImageOnly
scanButton.keyEquivalent = '\\r'
appModeSelector.action = Action(owner, 'changeAppMode:')
addButton.action = Action(owner, 'popupAddDirectoryMenu:')
removeButton.action = Action(owner, 'removeSelectedDirectory')
loadResultsButton.action = Action(owner, 'popupLoadRecentMenu:')
scanButton.action = Action(None, 'startScanning')
directoryOutline.font = Font(FontFamily.System, FontSize.SmallSystem)
col = directoryOutline.addColumn('name', "Name", 100)
col.editable = False
col.autoResizable = True
col = directoryOutline.addColumn('state', "State", 85)
col.editable = True
col.autoResizable = False
col.dataCell = Popup(None, ["Normal", "Reference", "Excluded"])
col.dataCell.controlSize = const.NSSmallControlSize
directoryOutline.allowsColumnReordering = False
directoryOutline.allowsColumnSelection = False
directoryOutline.allowsMultipleSelection = True
appModeLabel.width = scanTypeLabel.width = 110
scanTypePopup.width = 248
appModeLayout = HLayout([appModeLabel, appModeSelector])
scanTypeLayout = HLayout([scanTypeLabel, scanTypePopup])
for button in (addButton, removeButton):
button.width = 28
for button in (loadResultsButton, scanButton):
button.width = 118
buttonLayout = HLayout([addButton, removeButton, None, loadResultsButton, scanButton])
mainLayout = VLayout([appModeLayout, scanTypeLayout, promptLabel, directoryOutline, buttonLayout], filler=directoryOutline)
mainLayout.packToCorner(Pack.UpperLeft)
mainLayout.fill(Pack.LowerRight)
directoryOutline.packRelativeTo(promptLabel, Pack.Below)
promptLabel.setAnchor(Pack.UpperLeft, growX=True)
directoryOutline.setAnchor(Pack.UpperLeft, growX=True, growY=True)
buttonLayout.setAnchor(Pack.Below)

View File

@ -1,30 +0,0 @@
ownerclass = 'IgnoreListDialog'
ownerimport = 'IgnoreListDialog.h'
result = Window(550, 350, "Ignore List")
table = TableView(result)
removeSelectedButton = Button(result, "Remove Selected")
clearButton = Button(result, "Clear")
closeButton = Button(result, "Close")
owner.ignoreListTableView = table
result.canMinimize = False
removeSelectedButton.action = Action(owner.model, 'removeSelected')
clearButton.action = Action(owner.model, 'clear')
closeButton.action = Action(result, 'performClose:')
closeButton.keyEquivalent = '\\r'
table.allowsColumnReordering = False
table.allowsColumnSelection = False
table.allowsMultipleSelection = True
removeSelectedButton.width = 142
clearButton.width = 142
closeButton.width = 84
buttonLayout = HLayout([removeSelectedButton, clearButton, None, closeButton])
buttonLayout.packToCorner(Pack.LowerLeft)
buttonLayout.fill(Pack.Right)
buttonLayout.setAnchor(Pack.Below)
table.packRelativeTo(buttonLayout, Pack.Above)
table.fill(Pack.UpperRight)
table.setAnchor(Pack.UpperLeft, growX=True, growY=True)

View File

@ -1,78 +0,0 @@
ownerclass = 'AppDelegate'
ownerimport = 'AppDelegate.h'
result = Menu("")
appMenu = result.addMenu("dupeGuru")
fileMenu = result.addMenu("File")
editMenu = result.addMenu("Edit")
actionMenu = result.addMenu("Actions")
owner.columnsMenu = result.addMenu("Columns")
modeMenu = result.addMenu("Mode")
windowMenu = result.addMenu("Window")
helpMenu = result.addMenu("Help")
appMenu.addItem("About dupeGuru", Action(owner, 'showAboutBox'))
appMenu.addItem("Check for update...", Action(owner.updater, 'checkForUpdates:'))
appMenu.addSeparator()
appMenu.addItem("Preferences...", Action(owner, 'showPreferencesPanel'), 'cmd+,')
appMenu.addSeparator()
NSApp.servicesMenu = appMenu.addMenu("Services")
appMenu.addSeparator()
appMenu.addItem("Hide dupeGuru", Action(NSApp, 'hide:'), 'cmd+h')
appMenu.addItem("Hide Others", Action(NSApp, 'hideOtherApplications:'), 'cmd+alt+h')
appMenu.addItem("Show All", Action(NSApp, 'unhideAllApplications:'))
appMenu.addSeparator()
appMenu.addItem("Quit dupeGuru", Action(NSApp, 'terminate:'), 'cmd+q')
fileMenu.addItem("Load Results...", Action(None, 'loadResults'), 'cmd+o')
owner.recentResultsMenu = fileMenu.addMenu("Load Recent Results")
fileMenu.addItem("Save Results...", Action(None, 'saveResults'), 'cmd+s')
fileMenu.addItem("Export Results to XHTML", Action(owner.model, 'exportToXHTML'), 'cmd+shift+e')
fileMenu.addItem("Export Results to CSV", Action(owner.model, 'exportToCSV'))
fileMenu.addItem("Clear Picture Cache", Action(owner, 'clearPictureCache'), 'cmd+shift+p')
editMenu.addItem("Mark All", Action(None, 'markAll'), 'cmd+a')
editMenu.addItem("Mark None", Action(None, 'markNone'), 'cmd+shift+a')
editMenu.addItem("Invert Marking", Action(None, 'markInvert'), 'cmd+alt+a')
editMenu.addItem("Mark Selected", Action(None, 'markSelected'), 'ctrl+cmd+a')
editMenu.addSeparator()
editMenu.addItem("Cut", Action(None, 'cut:'), 'cmd+x')
editMenu.addItem("Copy", Action(None, 'copy:'), 'cmd+c')
editMenu.addItem("Paste", Action(None, 'paste:'), 'cmd+v')
editMenu.addSeparator()
editMenu.addItem("Filter Results...", Action(None, 'focusOnFilterField'), 'cmd+alt+f')
actionMenu.addItem("Start Duplicate Scan", Action(owner, 'startScanning'), 'cmd+d')
actionMenu.addSeparator()
actionMenu.addItem("Send Marked to Trash...", Action(None, 'trashMarked'), 'cmd+t')
actionMenu.addItem("Move Marked to...", Action(None, 'moveMarked'), 'cmd+m')
actionMenu.addItem("Copy Marked to...", Action(None, 'copyMarked'), 'cmd+alt+m')
actionMenu.addItem("Remove Marked from Results", Action(None, 'removeMarked'), 'cmd+r')
actionMenu.addItem("Re-Prioritize Results...", Action(None, 'reprioritizeResults'))
actionMenu.addSeparator()
actionMenu.addItem("Remove Selected from Results", Action(None, 'removeSelected'), 'cmd+backspace')
actionMenu.addItem("Add Selected to Ignore List", Action(None, 'ignoreSelected'), 'cmd+g')
actionMenu.addItem("Make Selected into Reference", Action(None, 'switchSelected'), 'cmd+arrowup')
actionMenu.addSeparator()
actionMenu.addItem("Open Selected with Default Application", Action(None, 'openSelected'), 'cmd+return')
actionMenu.addItem("Reveal Selected in Finder", Action(None, 'revealSelected'), 'cmd+alt+return')
actionMenu.addItem("Invoke Custom Command", Action(None, 'invokeCustomCommand'), 'cmd+shift+c')
actionMenu.addItem("Rename Selected", Action(None, 'renameSelected'), 'enter')
modeMenu.addItem("Show Dupes Only", Action(None, 'togglePowerMarker'), 'cmd+1')
modeMenu.addItem("Show Delta Values", Action(None, 'toggleDelta'), 'cmd+2')
windowMenu.addItem("Results Window", Action(owner, 'showResultWindow'))
windowMenu.addItem("Folder Selection Window", Action(owner, 'showDirectoryWindow'))
windowMenu.addItem("Ignore List", Action(owner, 'showIgnoreList'))
windowMenu.addItem("Details Panel", Action(None, 'toggleDetailsPanel'), 'cmd+i')
windowMenu.addItem("Quick Look", Action(None, 'toggleQuicklookPanel'), 'cmd+l')
windowMenu.addSeparator()
windowMenu.addItem("Minimize", Action(None, 'performMinimize:'))
windowMenu.addItem("Zoom", Action(None, 'performZoom:'))
windowMenu.addItem("Close Window", Action(None, 'performClose:'), 'cmd+w')
windowMenu.addSeparator()
windowMenu.addItem("Bring All to Front", Action(None, 'arrangeInFront:'))
helpMenu.addItem("dupeGuru Help", Action(owner, 'openHelp'), 'cmd+?')
helpMenu.addItem("dupeGuru Website", Action(owner, 'openWebsite'))

View File

@ -1,173 +0,0 @@
appmode = args.get('appmode', 'standard')
dialogHeights = {
'standard': 325,
'music': 345,
'picture': 255,
}
result = Window(410, dialogHeights[appmode], "dupeGuru Preferences")
tabView = TabView(result)
basicTab = tabView.addTab("Basic")
advancedTab = tabView.addTab("Advanced")
thresholdSlider = Slider(basicTab.view, 1, 100, 80)
thresholdLabel = Label(basicTab.view, "Filter hardness:")
moreResultsLabel = Label(basicTab.view, "More results")
fewerResultsLabel = Label(basicTab.view, "Fewer results")
thresholdValueLabel = Label(basicTab.view, "")
fontSizeCombo = Combobox(basicTab.view, ["11", "12", "13", "14", "18", "24"])
fontSizeLabel = Label(basicTab.view, "Font Size:")
if appmode in ('standard', 'music'):
wordWeightingBox = Checkbox(basicTab.view, "Word weighting")
matchSimilarWordsBox = Checkbox(basicTab.view, "Match similar words")
elif appmode == 'picture':
matchDifferentDimensionsBox = Checkbox(basicTab.view, "Match pictures of different dimensions")
mixKindBox = Checkbox(basicTab.view, "Can mix file kind")
removeEmptyFoldersBox = Checkbox(basicTab.view, "Remove empty folders on delete or move")
checkForUpdatesBox = Checkbox(basicTab.view, "Automatically check for updates")
if appmode == 'standard':
ignoreSmallFilesBox = Checkbox(basicTab.view, "Ignore files smaller than:")
smallFilesThresholdText = TextField(basicTab.view, "")
smallFilesThresholdSuffixLabel = Label(basicTab.view, "KB")
elif appmode == 'music':
tagsToScanLabel = Label(basicTab.view, "Tags to scan:")
trackBox = Checkbox(basicTab.view, "Track")
artistBox = Checkbox(basicTab.view, "Artist")
albumBox = Checkbox(basicTab.view, "Album")
titleBox = Checkbox(basicTab.view, "Title")
genreBox = Checkbox(basicTab.view, "Genre")
yearBox = Checkbox(basicTab.view, "Year")
tagBoxes = [trackBox, artistBox, albumBox, titleBox, genreBox, yearBox]
regexpCheckbox = Checkbox(advancedTab.view, "Use regular expressions when filtering")
ignoreHardlinksBox = Checkbox(advancedTab.view, "Ignore duplicates hardlinking to the same file")
debugModeCheckbox = Checkbox(advancedTab.view, "Debug mode (restart required)")
customCommandLabel = Label(advancedTab.view, "Custom command (arguments: %d for dupe, %r for ref):")
customCommandText = TextField(advancedTab.view, "")
copyMoveLabel = Label(advancedTab.view, "Copy and Move:")
copyMovePopup = Popup(advancedTab.view, ["Right in destination", "Recreate relative path", "Recreate absolute path"])
resetToDefaultsButton = Button(result, "Reset To Defaults")
thresholdSlider.bind('value', defaults, 'values.minMatchPercentage')
thresholdValueLabel.bind('value', defaults, 'values.minMatchPercentage')
fontSizeCombo.bind('value', defaults, 'values.TableFontSize')
mixKindBox.bind('value', defaults, 'values.mixFileKind')
removeEmptyFoldersBox.bind('value', defaults, 'values.removeEmptyFolders')
checkForUpdatesBox.bind('value', defaults, 'values.SUEnableAutomaticChecks')
regexpCheckbox.bind('value', defaults, 'values.useRegexpFilter')
ignoreHardlinksBox.bind('value', defaults, 'values.ignoreHardlinkMatches')
debugModeCheckbox.bind('value', defaults, 'values.DebugMode')
customCommandText.bind('value', defaults, 'values.CustomCommand')
copyMovePopup.bind('selectedIndex', defaults, 'values.recreatePathType')
if appmode in ('standard', 'music'):
wordWeightingBox.bind('value', defaults, 'values.wordWeighting')
matchSimilarWordsBox.bind('value', defaults, 'values.matchSimilarWords')
disableWhenContentScan = [thresholdSlider, wordWeightingBox, matchSimilarWordsBox]
for control in disableWhenContentScan:
vtname = 'vtScanTypeMusicIsNotContent' if appmode == 'music' else 'vtScanTypeIsNotContent'
prefname = 'values.scanTypeMusic' if appmode == 'music' else 'values.scanTypeStandard'
control.bind('enabled', defaults, prefname, valueTransformer=vtname)
if appmode == 'standard':
ignoreSmallFilesBox.bind('value', defaults, 'values.ignoreSmallFiles')
smallFilesThresholdText.bind('value', defaults, 'values.smallFileThreshold')
elif appmode == 'music':
for box in tagBoxes:
box.bind('enabled', defaults, 'values.scanTypeMusic', valueTransformer='vtScanTypeIsTag')
trackBox.bind('value', defaults, 'values.scanTagTrack')
artistBox.bind('value', defaults, 'values.scanTagArtist')
albumBox.bind('value', defaults, 'values.scanTagAlbum')
titleBox.bind('value', defaults, 'values.scanTagTitle')
genreBox.bind('value', defaults, 'values.scanTagGenre')
yearBox.bind('value', defaults, 'values.scanTagYear')
elif appmode == 'picture':
matchDifferentDimensionsBox.bind('value', defaults, 'values.matchScaled')
thresholdSlider.bind('enabled', defaults, 'values.scanTypePicture', valueTransformer='vtScanTypeIsFuzzy')
result.canResize = False
result.canMinimize = False
thresholdValueLabel.formatter = NumberFormatter(NumberStyle.Decimal)
thresholdValueLabel.formatter.maximumFractionDigits = 0
allLabels = [thresholdValueLabel, moreResultsLabel, fewerResultsLabel,
thresholdLabel, fontSizeLabel, customCommandLabel, copyMoveLabel]
allCheckboxes = [mixKindBox, removeEmptyFoldersBox, checkForUpdatesBox, regexpCheckbox,
ignoreHardlinksBox, debugModeCheckbox]
if appmode == 'standard':
allLabels += [smallFilesThresholdSuffixLabel]
allCheckboxes += [ignoreSmallFilesBox, wordWeightingBox, matchSimilarWordsBox]
elif appmode == 'music':
allLabels += [tagsToScanLabel]
allCheckboxes += tagBoxes + [wordWeightingBox, matchSimilarWordsBox]
elif appmode == 'picture':
allCheckboxes += [matchDifferentDimensionsBox]
for label in allLabels:
label.controlSize = ControlSize.Small
fewerResultsLabel.alignment = TextAlignment.Right
for checkbox in allCheckboxes:
checkbox.font = thresholdValueLabel.font
resetToDefaultsButton.action = Action(defaults, 'revertToInitialValues:')
thresholdLabel.width = fontSizeLabel.width = 94
fontSizeCombo.width = 66
thresholdValueLabel.width = 25
resetToDefaultsButton.width = 136
if appmode == 'standard':
smallFilesThresholdText.width = 60
smallFilesThresholdSuffixLabel.width = 40
elif appmode == 'music':
for box in tagBoxes:
box.width = 70
tabView.packToCorner(Pack.UpperLeft)
tabView.fill(Pack.Right)
resetToDefaultsButton.packRelativeTo(tabView, Pack.Below, align=Pack.Right)
tabView.fill(Pack.Below, margin=14)
tabView.setAnchor(Pack.UpperLeft, growX=True, growY=True)
thresholdLayout = HLayout([thresholdLabel, thresholdSlider, thresholdValueLabel], filler=thresholdSlider)
thresholdLayout.packToCorner(Pack.UpperLeft)
thresholdLayout.fill(Pack.Right)
# We want to give the labels as much space as possible, and we only "know" how much is available
# after the slider's fill operation.
moreResultsLabel.width = fewerResultsLabel.width = thresholdSlider.width // 2
moreResultsLabel.packRelativeTo(thresholdSlider, Pack.Below, align=Pack.Left, margin=6)
fewerResultsLabel.packRelativeTo(thresholdSlider, Pack.Below, align=Pack.Right, margin=6)
fontSizeCombo.packRelativeTo(moreResultsLabel, Pack.Below)
fontSizeLabel.packRelativeTo(fontSizeCombo, Pack.Left)
if appmode == 'music':
tagsToScanLabel.packRelativeTo(fontSizeCombo, Pack.Below)
tagsToScanLabel.fill(Pack.Left)
tagsToScanLabel.fill(Pack.Right)
trackBox.packRelativeTo(tagsToScanLabel, Pack.Below)
trackBox.x += 10
artistBox.packRelativeTo(trackBox, Pack.Right)
albumBox.packRelativeTo(artistBox, Pack.Right)
titleBox.packRelativeTo(trackBox, Pack.Below)
genreBox.packRelativeTo(titleBox, Pack.Right)
yearBox.packRelativeTo(genreBox, Pack.Right)
viewToPackCheckboxesUnder = titleBox
else:
viewToPackCheckboxesUnder = fontSizeCombo
if appmode == 'standard':
checkboxesToLayout = [wordWeightingBox, matchSimilarWordsBox, mixKindBox, removeEmptyFoldersBox,
ignoreSmallFilesBox]
elif appmode == 'music':
checkboxesToLayout = [wordWeightingBox, matchSimilarWordsBox, mixKindBox, removeEmptyFoldersBox,
checkForUpdatesBox]
elif appmode == 'picture':
checkboxesToLayout = [matchDifferentDimensionsBox, mixKindBox, removeEmptyFoldersBox,
checkForUpdatesBox]
checkboxLayout = VLayout(checkboxesToLayout)
checkboxLayout.packRelativeTo(viewToPackCheckboxesUnder, Pack.Below)
checkboxLayout.fill(Pack.Left)
checkboxLayout.fill(Pack.Right)
if appmode == 'standard':
smallFilesThresholdText.packRelativeTo(ignoreSmallFilesBox, Pack.Below, margin=4)
checkForUpdatesBox.packRelativeTo(smallFilesThresholdText, Pack.Below, margin=4)
checkForUpdatesBox.fill(Pack.Right)
smallFilesThresholdText.x += 20
smallFilesThresholdSuffixLabel.packRelativeTo(smallFilesThresholdText, Pack.Right)
advancedLayout = VLayout(advancedTab.view.subviews[:])
advancedLayout.packToCorner(Pack.UpperLeft)
advancedLayout.fill(Pack.Right)

View File

@ -1,65 +0,0 @@
ownerclass = 'PrioritizeDialog'
ownerimport = 'PrioritizeDialog.h'
result = Window(610, 400, "Re-Prioritize duplicates")
promptLabel = Label(result, "Add criteria to the right box and click OK to send the dupes that "
"correspond the best to these criteria to their respective group's reference position. Read "
"the help file for more information.")
split = SplitView(result, 2, vertical=True)
categoryPopup = Popup(split.subviews[0])
criteriaTable = ListView(split.subviews[0])
prioritizationTable = ListView(split.subviews[1])
addButton = Button(split.subviews[1], NLSTR("-->"))
removeButton = Button(split.subviews[1], NLSTR("<--"))
okButton = Button(result, "Ok")
cancelButton = Button(result, "Cancel")
owner.categoryPopUpView = categoryPopup
owner.criteriaTableView = criteriaTable
owner.prioritizationTableView = prioritizationTable
result.canMinimize = False
result.canClose = False
result.minSize = Size(result.width, result.height)
addButton.action = Action(owner.model, 'addSelected')
removeButton.action = Action(owner.model, 'removeSelected')
okButton.action = Action(owner, 'ok')
cancelButton.action = Action(owner, 'cancel')
okButton.keyEquivalent = '\\r'
cancelButton.keyEquivalent = '\\e'
# For layouts to correctly work, subviews need to have the dimensions they'll approximately have
# at runtime.
split.subviews[0].width = 260
split.subviews[0].height = 260
split.subviews[1].width = 340
split.subviews[1].height = 260
promptLabel.height *= 3 # 3 lines
leftLayout = VLayout([categoryPopup, criteriaTable], filler=criteriaTable)
middleLayout = VLayout([addButton, removeButton], width=41)
buttonLayout = HLayout([None, cancelButton, okButton])
#pack split subview 0
leftLayout.fillAll()
#pack split subview 1
prioritizationTable.fillAll()
prioritizationTable.width -= 48
prioritizationTable.moveTo(Pack.Right)
middleLayout.moveNextTo(prioritizationTable, Pack.Left, align=Pack.Middle)
# Main layout
promptLabel.packToCorner(Pack.UpperLeft)
promptLabel.fill(Pack.Right)
split.moveNextTo(promptLabel, Pack.Below)
buttonLayout.moveNextTo(split, Pack.Below)
buttonLayout.fill(Pack.Right)
split.fill(Pack.LowerRight)
promptLabel.setAnchor(Pack.UpperLeft, growX=True)
prioritizationTable.setAnchor(Pack.UpperLeft, growX=True, growY=True)
categoryPopup.setAnchor(Pack.UpperLeft, growX=True)
criteriaTable.setAnchor(Pack.UpperLeft, growX=True, growY=True)
split.setAnchor(Pack.UpperLeft, growX=True, growY=True)
buttonLayout.setAnchor(Pack.Below)

View File

@ -1,35 +0,0 @@
ownerclass = 'ProblemDialog'
ownerimport = 'ProblemDialog.h'
result = Window(480, 310, "Problems!")
messageLabel = Label(result, "There were problems processing some (or all) of the files. The cause "
"of these problems are described in the table below. Those files were not removed from your "
"results.")
problemTable = TableView(result)
revealButton = Button(result, "Reveal")
closeButton = Button(result, "Close")
owner.problemTableView = problemTable
result.canMinimize = False
result.minSize = Size(300, 300)
closeButton.keyEquivalent = '\\r'
revealButton.action = Action(owner.model, 'revealSelected')
closeButton.action = Action(result, 'performClose:')
messageLabel.height *= 3 # 3 lines
revealButton.width = 150
closeButton.width = 98
messageLabel.packToCorner(Pack.UpperLeft)
messageLabel.fill(Pack.Right)
problemTable.packRelativeTo(messageLabel, Pack.Below)
problemTable.fill(Pack.Right)
revealButton.packRelativeTo(problemTable, Pack.Below)
closeButton.packRelativeTo(problemTable, Pack.Below, align=Pack.Right)
problemTable.fill(Pack.Below)
messageLabel.setAnchor(Pack.UpperLeft, growX=True)
problemTable.setAnchor(Pack.UpperLeft, growX=True, growY=True)
revealButton.setAnchor(Pack.LowerLeft)
closeButton.setAnchor(Pack.LowerRight)

View File

@ -1,97 +0,0 @@
ownerclass = 'ResultWindow'
ownerimport = 'ResultWindow.h'
result = Window(557, 400, "dupeGuru Results")
toolbar = result.createToolbar('ResultsToolbar')
table = TableView(result)
table.OBJC_CLASS = 'HSTableView'
statsLabel = Label(result, "")
contextMenu = Menu("")
#Setup toolbar items
toolbar.displayMode = const.NSToolbarDisplayModeIconOnly
directoriesToolItem = toolbar.addItem('Directories', "Directories", image='folder32')
actionToolItem = toolbar.addItem('Action', "Action")
filterToolItem = toolbar.addItem('Filter', "Filter")
optionsToolItem = toolbar.addItem('Options', "Options")
quicklookToolItem = toolbar.addItem('QuickLook', "Quick Look")
toolbar.defaultItems = [actionToolItem, optionsToolItem, quicklookToolItem, directoriesToolItem,
toolbar.flexibleSpace(), filterToolItem]
actionPopup = Popup(None)
actionPopup.pullsdown = True
actionPopup.bezelStyle = const.NSTexturedRoundedBezelStyle
actionPopup.arrowPosition = const.NSPopUpArrowAtBottom
item = actionPopup.menu.addItem("") # First item is invisible
item.hidden = True
item.image = 'NSActionTemplate'
actionPopup.width = 44
actionToolItem.view = actionPopup
filterField = SearchField(None, "Filter")
filterField.action = Action(owner, 'filter')
filterField.sendsWholeSearchString = True
filterToolItem.view = filterField
filterToolItem.minSize = Size(80, 22)
filterToolItem.maxSize = Size(300, 22)
quickLookButton = Button(None, "")
quickLookButton.bezelStyle = const.NSTexturedRoundedBezelStyle
quickLookButton.image = 'NSQuickLookTemplate'
quickLookButton.width = 44
quickLookButton.action = Action(owner, 'toggleQuicklookPanel')
quicklookToolItem.view = quickLookButton
optionsSegments = SegmentedControl(None)
optionsSegments.segmentStyle = const.NSSegmentStyleCapsule
optionsSegments.trackingMode = const.NSSegmentSwitchTrackingSelectAny
optionsSegments.font = Font(FontFamily.System, 11)
optionsSegments.addSegment("Details", 57)
optionsSegments.addSegment("Dupes Only", 82)
optionsSegments.addSegment("Delta", 48)
optionsSegments.action = Action(owner, 'changeOptions')
optionsToolItem.view = optionsSegments
# Popuplate menus
actionPopup.menu.addItem("Send Marked to Trash...", action=Action(owner, 'trashMarked'))
actionPopup.menu.addItem("Move Marked to...", action=Action(owner, 'moveMarked'))
actionPopup.menu.addItem("Copy Marked to...", action=Action(owner, 'copyMarked'))
actionPopup.menu.addItem("Remove Marked from Results", action=Action(owner, 'removeMarked'))
actionPopup.menu.addSeparator()
for menu in (actionPopup.menu, contextMenu):
menu.addItem("Remove Selected from Results", action=Action(owner, 'removeSelected'))
menu.addItem("Add Selected to Ignore List", action=Action(owner, 'ignoreSelected'))
menu.addItem("Make Selected into Reference", action=Action(owner, 'switchSelected'))
menu.addSeparator()
menu.addItem("Open Selected with Default Application", action=Action(owner, 'openSelected'))
menu.addItem("Reveal Selected in Finder", action=Action(owner, 'revealSelected'))
menu.addItem("Rename Selected", action=Action(owner, 'renameSelected'))
# Doing connections
owner.filterField = filterField
owner.matches = table
owner.optionsSwitch = optionsSegments
owner.optionsToolbarItem = optionsToolItem
owner.stats = statsLabel
table.bind('rowHeight', defaults, 'values.TableFontSize', valueTransformer='vtRowHeightOffset')
# Rest of the setup
result.minSize = Size(340, 340)
result.autosaveName = 'MainWindow'
statsLabel.alignment = TextAlignment.Center
table.alternatingRows = True
table.menu = contextMenu
table.allowsColumnReordering = True
table.allowsColumnResizing = True
table.allowsColumnSelection = False
table.allowsEmptySelection = False
table.allowsMultipleSelection = True
table.allowsTypeSelect = True
table.gridStyleMask = const.NSTableViewSolidHorizontalGridLineMask
table.setAnchor(Pack.UpperLeft, growX=True, growY=True)
statsLabel.setAnchor(Pack.LowerLeft, growX=True)
# Layout
# It's a little weird to pack with a margin of -1, but if I don't do that, I get too thick of a
# border on the upper side of the table.
table.packToCorner(Pack.UpperLeft, margin=-1)
table.fill(Pack.Right, margin=0)
statsLabel.packRelativeTo(table, Pack.Below, margin=6)
statsLabel.fill(Pack.Right, margin=0)
table.fill(Pack.Below, margin=5)

169
cocoa/waf vendored

File diff suppressed because one or more lines are too long

View File

@ -1,73 +0,0 @@
#!/usr/bin/env python
import sys
import os
import os.path as op
top = '.'
out = 'build'
def options(opt):
opt.load('compiler_c python')
def configure(conf):
# We use clang to compile our app
conf.env.CC = 'clang'
# WAF has a "pyembed" feature allowing us to automatically find Python and compile by linking
# to it. The problem is that because we made a copy of the Python library to mangle with its
# "install name", we don't actually want to link to our installed python, but to our mangled
# Python. The line below tells the "pyembed" WAF feature to look in ../build for Python.
conf.env.LIBPATH_PYEMBED = op.abspath('../build')
# I did a lot of fiddling-around, but I didn't find how to tell WAF the Python library name
# to look for without making the whole compilation process fail, so I just create a symlink
# with the name WAF is looking for.
versioned_dylib_path = '../build/libpython{}m.dylib'.format(sys.version[:3])
if not op.exists(versioned_dylib_path):
os.symlink('../build/Python', versioned_dylib_path)
# The rest is standard WAF code that you can find the the python and macapp demos.
conf.load('compiler_c python')
conf.check_python_version((3,4,0))
conf.check_python_headers()
conf.env.FRAMEWORK_COCOA = 'Cocoa'
conf.env.ARCH_COCOA = ['x86_64']
conf.env.MACOSX_DEPLOYMENT_TARGET = '10.8'
conf.env.CFLAGS = ['-F'+op.abspath('Sparkle/build/Release')]
conf.env.LINKFLAGS = ['-F'+op.abspath('Sparkle/build/Release')]
def build(ctx):
# What do we compile?
cocoalib_node = ctx.srcnode.find_dir('..').find_dir('cocoalib')
cocoalib_folders = ['controllers', 'views']
cocoalib_includes = [cocoalib_node] + [cocoalib_node.find_dir(folder) for folder in cocoalib_folders]
cocoalib_uses = ['NSEventAdditions', 'Dialogs', 'HSAboutBox', 'Utils',
'HSPyUtil', 'ProgressController', 'HSRecentFiles', 'HSQuicklook', 'ValueTransformers',
'NSImageAdditions', 'NSNotificationAdditions',
'views/HSTableView', 'views/HSOutlineView', 'views/NSIndexPathAdditions',
'views/NSTableViewAdditions',
'controllers/HSColumns', 'controllers/HSGUIController', 'controllers/HSTable',
'controllers/HSOutline', 'controllers/HSPopUpList', 'controllers/HSSelectableList',
'controllers/HSTextField', 'controllers/HSProgressWindow']
cocoalib_src = [cocoalib_node.find_node(usename + '.m') for usename in cocoalib_uses] + cocoalib_node.ant_glob('autogen/*.m')
project_folders = [ctx.srcnode, ctx.srcnode.find_dir('autogen')]
project_src = ctx.srcnode.ant_glob('autogen/*.m') + ctx.srcnode.ant_glob('*.m')
# Compile
ctx.program(
# "pyembed" takes care of the include and linking stuff to compile an app that embed Python.
features = 'c cprogram pyembed',
target = ctx.bldnode.make_node("dupeGuru"),
source = cocoalib_src + project_src,
includes = project_folders + cocoalib_includes,
use = 'COCOA',
# Because our python lib's install name is "@rpath/Python", we need to set the executable's
# rpath. Fortunately, WAF supports it and we just need to supply the "rpath" argument.
rpath = '@executable_path/../Frameworks',
framework = ['Sparkle', 'Quartz'],
)
from waflib import TaskGen
@TaskGen.extension('.m')
def m_hook(self, node):
"""Alias .m files to be compiled the same as .c files, gcc will do the right thing."""
return self.create_compiled_task('c', node)

@ -1 +0,0 @@
Subproject commit d059aa9b7910f76174090ccd449fe6ab92bb43f0

17
commitlint.config.js Normal file
View File

@ -0,0 +1,17 @@
const Configuration = {
/*
* Resolve and load @commitlint/config-conventional from node_modules.
* Referenced packages must be installed
*/
extends: ['@commitlint/config-conventional'],
/*
* Any rules defined here will override rules from @commitlint/config-conventional
*/
rules: {
'header-max-length': [2, 'always', 72],
'subject-case': [2, 'always', 'sentence-case'],
'scope-enum': [2, 'always'],
},
};
module.exports = Configuration;

View File

@ -1,3 +1,2 @@
__version__ = '4.0.1'
__appname__ = 'dupeGuru'
__version__ = "4.3.1"
__appname__ = "dupeGuru"

View File

@ -4,38 +4,42 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import cProfile
import datetime
import os
import os.path as op
import logging
import subprocess
import re
import shutil
from pathlib import Path
from send2trash import send2trash
from hscommon.jobprogress import job
from hscommon.notify import Broadcaster
from hscommon.path import Path
from hscommon.conflict import smart_move, smart_copy
from hscommon.gui.progress_window import ProgressWindow
from hscommon.util import delete_if_empty, first, escape, nonone, allsame
from hscommon.trans import tr
from hscommon import desktop
from . import se, me, pe
from .pe.photo import get_delta_dimensions
from .util import cmp_value, fix_surrogate_encoding
from . import directories, results, export, fs, prioritize
from .ignore import IgnoreList
from .scanner import ScanType
from .gui.deletion_options import DeletionOptions
from .gui.details_panel import DetailsPanel
from .gui.directory_tree import DirectoryTree
from .gui.ignore_list_dialog import IgnoreListDialog
from .gui.problem_dialog import ProblemDialog
from .gui.stats_label import StatsLabel
from core import se, me, pe
from core.pe.photo import get_delta_dimensions
from core.util import cmp_value, fix_surrogate_encoding
from core import directories, results, export, fs, prioritize
from core.ignore import IgnoreList
from core.exclude import ExcludeDict as ExcludeList
from core.scanner import ScanType
from core.gui.deletion_options import DeletionOptions
from core.gui.details_panel import DetailsPanel
from core.gui.directory_tree import DirectoryTree
from core.gui.ignore_list_dialog import IgnoreListDialog
from core.gui.exclude_list_dialog import ExcludeListDialogCore
from core.gui.problem_dialog import ProblemDialog
from core.gui.stats_label import StatsLabel
HAD_FIRST_LAUNCH_PREFERENCE = 'HadFirstLaunch'
DEBUG_MODE_PREFERENCE = 'DebugMode'
HAD_FIRST_LAUNCH_PREFERENCE = "HadFirstLaunch"
DEBUG_MODE_PREFERENCE = "DebugMode"
MSG_NO_MARKED_DUPES = tr("There are no marked duplicates. Nothing has been done.")
MSG_NO_SELECTED_DUPES = tr("There are no selected duplicates. Nothing has been done.")
@ -44,31 +48,36 @@ MSG_MANY_FILES_TO_OPEN = tr(
"files are opened with, doing so can create quite a mess. Continue?"
)
class DestType:
Direct = 0
Relative = 1
Absolute = 2
DIRECT = 0
RELATIVE = 1
ABSOLUTE = 2
class JobType:
Scan = 'job_scan'
Load = 'job_load'
Move = 'job_move'
Copy = 'job_copy'
Delete = 'job_delete'
SCAN = "job_scan"
LOAD = "job_load"
MOVE = "job_move"
COPY = "job_copy"
DELETE = "job_delete"
class AppMode:
Standard = 0
Music = 1
Picture = 2
STANDARD = 0
MUSIC = 1
PICTURE = 2
JOBID2TITLE = {
JobType.Scan: tr("Scanning for duplicates"),
JobType.Load: tr("Loading"),
JobType.Move: tr("Moving"),
JobType.Copy: tr("Copying"),
JobType.Delete: tr("Sending to Trash"),
JobType.SCAN: tr("Scanning for duplicates"),
JobType.LOAD: tr("Loading"),
JobType.MOVE: tr("Moving"),
JobType.COPY: tr("Copying"),
JobType.DELETE: tr("Sending to Trash"),
}
class DupeGuru(Broadcaster):
"""Holds everything together.
@ -100,7 +109,8 @@ class DupeGuru(Broadcaster):
Instance of :mod:`meta-gui <core.gui>` table listing the results from :attr:`results`
"""
#--- View interface
# --- View interface
# get_default(key_name)
# set_default(key_name, value)
# show_message(msg)
@ -116,35 +126,40 @@ class DupeGuru(Broadcaster):
NAME = PROMPT_NAME = "dupeGuru"
def __init__(self, view):
def __init__(self, view, portable=False):
if view.get_default(DEBUG_MODE_PREFERENCE):
logging.getLogger().setLevel(logging.DEBUG)
logging.debug("Debug mode enabled")
Broadcaster.__init__(self)
self.view = view
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.AppData, appname=self.NAME)
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.APPDATA, portable=portable)
if not op.exists(self.appdata):
os.makedirs(self.appdata)
self.app_mode = AppMode.Standard
self.app_mode = AppMode.STANDARD
self.discarded_file_count = 0
self.directories = directories.Directories()
self.exclude_list = ExcludeList()
hash_cache_file = op.join(self.appdata, "hash_cache.db")
fs.filesdb.connect(hash_cache_file)
self.directories = directories.Directories(self.exclude_list)
self.results = results.Results(self)
self.ignore_list = IgnoreList()
# In addition to "app-level" options, this dictionary also holds options that will be
# sent to the scanner. They don't have default values because those defaults values are
# defined in the scanner class.
self.options = {
'escape_filter_regexp': True,
'clean_empty_dirs': False,
'ignore_hardlink_matches': False,
'copymove_dest_type': DestType.Relative,
'cache_path': op.join(self.appdata, 'cached_pictures.db'),
"escape_filter_regexp": True,
"clean_empty_dirs": False,
"ignore_hardlink_matches": False,
"copymove_dest_type": DestType.RELATIVE,
"include_exists_check": True,
"rehash_ignore_mtime": False,
}
self.selected_dupes = []
self.details_panel = DetailsPanel(self)
self.directory_tree = DirectoryTree(self)
self.problem_dialog = ProblemDialog(self)
self.ignore_list_dialog = IgnoreListDialog(self)
self.exclude_list_dialog = ExcludeListDialogCore(self)
self.stats_label = StatsLabel(self)
self.result_table = None
self.deletion_options = DeletionOptions()
@ -153,35 +168,37 @@ class DupeGuru(Broadcaster):
for child in children:
child.connect()
#--- Private
# --- Private
def _recreate_result_table(self):
if self.result_table is not None:
self.result_table.disconnect()
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
self.result_table = pe.result_table.ResultTable(self)
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
self.result_table = me.result_table.ResultTable(self)
else:
self.result_table = se.result_table.ResultTable(self)
self.result_table.connect()
self.view.create_results_window()
def _get_picture_cache_path(self):
cache_name = "cached_pictures.db"
return op.join(self.appdata, cache_name)
def _get_dupe_sort_key(self, dupe, get_group, key, delta):
if self.app_mode in (AppMode.Music, AppMode.Picture):
if key == 'folder_path':
dupe_folder_path = getattr(dupe, 'display_folder_path', dupe.folder_path)
return str(dupe_folder_path).lower()
if self.app_mode == AppMode.Picture:
if delta and key == 'dimensions':
r = cmp_value(dupe, key)
ref_value = cmp_value(get_group().ref, key)
return get_delta_dimensions(r, ref_value)
if key == 'marked':
if self.app_mode in (AppMode.MUSIC, AppMode.PICTURE) and key == "folder_path":
dupe_folder_path = getattr(dupe, "display_folder_path", dupe.folder_path)
return str(dupe_folder_path).lower()
if self.app_mode == AppMode.PICTURE and delta and key == "dimensions":
r = cmp_value(dupe, key)
ref_value = cmp_value(get_group().ref, key)
return get_delta_dimensions(r, ref_value)
if key == "marked":
return self.results.is_marked(dupe)
if key == 'percentage':
if key == "percentage":
m = get_group().get_match_of(dupe)
return m.percentage
elif key == 'dupe_count':
elif key == "dupe_count":
return 0
else:
result = cmp_value(dupe, key)
@ -195,15 +212,14 @@ class DupeGuru(Broadcaster):
return result
def _get_group_sort_key(self, group, key):
if self.app_mode in (AppMode.Music, AppMode.Picture):
if key == 'folder_path':
dupe_folder_path = getattr(group.ref, 'display_folder_path', group.ref.folder_path)
return str(dupe_folder_path).lower()
if key == 'percentage':
if self.app_mode in (AppMode.MUSIC, AppMode.PICTURE) and key == "folder_path":
dupe_folder_path = getattr(group.ref, "display_folder_path", group.ref.folder_path)
return str(dupe_folder_path).lower()
if key == "percentage":
return group.percentage
if key == 'dupe_count':
if key == "dupe_count":
return len(group)
if key == 'marked':
if key == "marked":
return len([dupe for dupe in group.dupes if self.results.is_marked(dupe)])
return cmp_value(group.ref, key)
@ -226,17 +242,17 @@ class DupeGuru(Broadcaster):
else:
os.remove(str_path)
else:
send2trash(str_path) # Raises OSError when there's a problem
send2trash(str_path) # Raises OSError when there's a problem
if link_deleted:
group = self.results.get_group_of_duplicate(dupe)
ref = group.ref
linkfunc = os.link if use_hardlinks else os.symlink
linkfunc(str(ref.path), str_path)
self.clean_empty_dirs(dupe.path.parent())
self.clean_empty_dirs(dupe.path.parent)
def _create_file(self, path):
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
return fs.get_file(path, self.fileclasses + [fs.Folder])
return fs.get_file(path, self.fileclasses + [se.fs.Folder])
def _get_file(self, str_path):
path = Path(str_path)
@ -246,14 +262,11 @@ class DupeGuru(Broadcaster):
try:
f._read_all_info(attrnames=self.METADATA_TO_READ)
return f
except EnvironmentError:
except OSError:
return None
def _get_export_data(self):
columns = [
col for col in self.result_table.columns.ordered_columns
if col.visible and col.name != 'marked'
]
columns = [col for col in self.result_table._columns.ordered_columns if col.visible and col.name != "marked"]
colnames = [col.display for col in columns]
rows = []
for group_id, group in enumerate(self.results.groups):
@ -265,11 +278,8 @@ class DupeGuru(Broadcaster):
return colnames, rows
def _results_changed(self):
self.selected_dupes = [
d for d in self.selected_dupes
if self.results.get_group_of_duplicate(d) is not None
]
self.notify('results_changed')
self.selected_dupes = [d for d in self.selected_dupes if self.results.get_group_of_duplicate(d) is not None]
self.notify("results_changed")
def _start_job(self, jobid, func, args=()):
title = JOBID2TITLE[jobid]
@ -283,32 +293,36 @@ class DupeGuru(Broadcaster):
self.view.show_message(msg)
def _job_completed(self, jobid):
if jobid == JobType.Scan:
if jobid == JobType.SCAN:
self._results_changed()
fs.filesdb.commit()
if not self.results.groups:
self.view.show_message(tr("No duplicates found."))
else:
self.view.show_results_window()
if jobid in {JobType.Move, JobType.Delete}:
if jobid in {JobType.MOVE, JobType.DELETE}:
self._results_changed()
if jobid == JobType.Load:
if jobid == JobType.LOAD:
self._recreate_result_table()
self._results_changed()
self.view.show_results_window()
if jobid in {JobType.Copy, JobType.Move, JobType.Delete}:
if jobid in {JobType.COPY, JobType.MOVE, JobType.DELETE}:
if self.results.problems:
self.problem_dialog.refresh()
self.view.show_problem_dialog()
else:
msg = {
JobType.Copy: tr("All marked files were copied successfully."),
JobType.Move: tr("All marked files were moved successfully."),
JobType.Delete: tr("All marked files were successfully sent to Trash."),
}[jobid]
if jobid == JobType.COPY:
msg = tr("All marked files were copied successfully.")
elif jobid == JobType.MOVE:
msg = tr("All marked files were moved successfully.")
elif jobid == JobType.DELETE and self.deletion_options.direct:
msg = tr("All marked files were deleted successfully.")
else:
msg = tr("All marked files were successfully sent to Trash.")
self.view.show_message(msg)
def _job_error(self, jobid, err):
if jobid == JobType.Load:
if jobid == JobType.LOAD:
msg = tr("Could not load file: {}").format(err)
self.view.show_message(msg)
return False
@ -334,26 +348,26 @@ class DupeGuru(Broadcaster):
if dupes == self.selected_dupes:
return
self.selected_dupes = dupes
self.notify('dupes_selected')
self.notify("dupes_selected")
#--- Protected
# --- Protected
def _get_fileclasses(self):
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
return [pe.photo.PLAT_SPECIFIC_PHOTO_CLASS]
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
return [me.fs.MusicFile]
else:
return [se.fs.File]
def _prioritization_categories(self):
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
return pe.prioritize.all_categories()
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
return me.prioritize.all_categories()
else:
return prioritize.all_categories()
#--- Public
# --- Public
def add_directory(self, d):
"""Adds folder ``d`` to :attr:`directories`.
@ -363,15 +377,14 @@ class DupeGuru(Broadcaster):
"""
try:
self.directories.add_path(Path(d))
self.notify('directories_changed')
self.notify("directories_changed")
except directories.AlreadyThereError:
self.view.show_message(tr("'{}' already is in the list.").format(d))
except directories.InvalidPathError:
self.view.show_message(tr("'{}' does not exist.").format(d))
def add_selected_to_ignore_list(self):
"""Adds :attr:`selected_dupes` to :attr:`ignore_list`.
"""
"""Adds :attr:`selected_dupes` to :attr:`ignore_list`."""
dupes = self.without_ref(self.selected_dupes)
if not dupes:
self.view.show_message(MSG_NO_SELECTED_DUPES)
@ -383,59 +396,64 @@ class DupeGuru(Broadcaster):
g = self.results.get_group_of_duplicate(dupe)
for other in g:
if other is not dupe:
self.ignore_list.Ignore(str(other.path), str(dupe.path))
self.ignore_list.ignore(str(other.path), str(dupe.path))
self.remove_duplicates(dupes)
self.ignore_list_dialog.refresh()
def apply_filter(self, filter):
def apply_filter(self, result_filter):
"""Apply a filter ``filter`` to the results so that it shows only dupe groups that match it.
:param str filter: filter to apply
"""
self.results.apply_filter(None)
if self.options['escape_filter_regexp']:
filter = escape(filter, set('()[]\\.|+?^'))
filter = escape(filter, '*', '.')
self.results.apply_filter(filter)
if self.options["escape_filter_regexp"]:
result_filter = escape(result_filter, set("()[]\\.|+?^"))
result_filter = escape(result_filter, "*", ".")
self.results.apply_filter(result_filter)
self._results_changed()
def clean_empty_dirs(self, path):
if self.options['clean_empty_dirs']:
while delete_if_empty(path, ['.DS_Store']):
path = path.parent()
if self.options["clean_empty_dirs"]:
while delete_if_empty(path, [".DS_Store"]):
path = path.parent
def clear_picture_cache(self):
cache = pe.cache.Cache(self.options['cache_path'])
cache.clear()
cache.close()
try:
os.remove(self._get_picture_cache_path())
except FileNotFoundError:
pass # we don't care
def clear_hash_cache(self):
fs.filesdb.clear()
def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType):
source_path = dupe.path
location_path = first(p for p in self.directories if dupe.path in p)
location_path = first(p for p in self.directories if p in dupe.path.parents)
dest_path = Path(destination)
if dest_type in {DestType.Relative, DestType.Absolute}:
if dest_type in {DestType.RELATIVE, DestType.ABSOLUTE}:
# no filename, no windows drive letter
source_base = source_path.remove_drive_letter().parent()
if dest_type == DestType.Relative:
source_base = source_base[location_path:]
dest_path = dest_path[source_base]
source_base = source_path.relative_to(source_path.anchor).parent
if dest_type == DestType.RELATIVE:
source_base = source_base.relative_to(location_path.relative_to(location_path.anchor))
dest_path = dest_path.joinpath(source_base)
if not dest_path.exists():
dest_path.makedirs()
dest_path.mkdir(parents=True)
# Add filename to dest_path. For file move/copy, it's not required, but for folders, yes.
dest_path = dest_path[source_path.name]
dest_path = dest_path.joinpath(source_path.name)
logging.debug("Copy/Move operation from '%s' to '%s'", source_path, dest_path)
# Raises an EnvironmentError if there's a problem
if copy:
smart_copy(source_path, dest_path)
else:
smart_move(source_path, dest_path)
self.clean_empty_dirs(source_path.parent())
self.clean_empty_dirs(source_path.parent)
def copy_or_move_marked(self, copy):
"""Start an async move (or copy) job on marked duplicates.
:param bool copy: If True, duplicates will be copied instead of moved
"""
def do(j):
def op(dupe):
j.add_progress()
@ -447,28 +465,30 @@ class DupeGuru(Broadcaster):
if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES)
return
opname = tr("copy") if copy else tr("move")
prompt = tr("Select a directory to {} marked files to").format(opname)
destination = self.view.select_dest_folder(prompt)
destination = self.view.select_dest_folder(
tr("Select a directory to copy marked files to")
if copy
else tr("Select a directory to move marked files to")
)
if destination:
desttype = self.options['copymove_dest_type']
jobid = JobType.Copy if copy else JobType.Move
desttype = self.options["copymove_dest_type"]
jobid = JobType.COPY if copy else JobType.MOVE
self._start_job(jobid, do)
def delete_marked(self):
"""Start an async job to send marked duplicates to the trash.
"""
"""Start an async job to send marked duplicates to the trash."""
if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES)
return
if not self.deletion_options.show(self.results.mark_count):
return
args = [
self.deletion_options.link_deleted, self.deletion_options.use_hardlinks,
self.deletion_options.direct
self.deletion_options.link_deleted,
self.deletion_options.use_hardlinks,
self.deletion_options.direct,
]
logging.debug("Starting deletion job with args %r", args)
self._start_job(JobType.Delete, self._do_delete, args=args)
self._start_job(JobType.DELETE, self._do_delete, args=args)
def export_to_xhtml(self):
"""Export current results to XHTML.
@ -487,7 +507,7 @@ class DupeGuru(Broadcaster):
The columns and their order in the resulting CSV file is determined in the same way as in
:meth:`export_to_xhtml`.
"""
dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), 'csv')
dest_file = self.view.select_dest_file(tr("Select a destination for your exported CSV"), "csv")
if dest_file:
colnames, rows = self._get_export_data()
try:
@ -497,13 +517,14 @@ class DupeGuru(Broadcaster):
def get_display_info(self, dupe, group, delta=False):
def empty_data():
return {c.name: '---' for c in self.result_table.COLUMNS[1:]}
return {c.name: "---" for c in self.result_table.COLUMNS[1:]}
if (dupe is None) or (group is None):
return empty_data()
try:
return dupe.get_display_info(group, delta)
except Exception as e:
logging.warning("Exception on GetDisplayInfo for %s: %s", str(dupe.path), str(e))
logging.warning("Exception (type: %s) on GetDisplayInfo for %s: %s", type(e), str(dupe.path), str(e))
return empty_data()
def invoke_custom_command(self):
@ -513,28 +534,34 @@ class DupeGuru(Broadcaster):
is replaced with that dupe's ref file. If there's no selection, the command is not invoked.
If the dupe is a ref, ``%d`` and ``%r`` will be the same.
"""
cmd = self.view.get_default('CustomCommand')
cmd = self.view.get_default("CustomCommand")
if not cmd:
msg = tr("You have no custom command set up. Set it up in your preferences.")
self.view.show_message(msg)
return
if not self.selected_dupes:
return
dupe = self.selected_dupes[0]
group = self.results.get_group_of_duplicate(dupe)
ref = group.ref
cmd = cmd.replace('%d', str(dupe.path))
cmd = cmd.replace('%r', str(ref.path))
match = re.match(r'"([^"]+)"(.*)', cmd)
if match is not None:
# This code here is because subprocess. Popen doesn't seem to accept, under Windows,
# executable paths with spaces in it, *even* when they're enclosed in "". So this is
# a workaround to make the damn thing work.
exepath, args = match.groups()
path, exename = op.split(exepath)
subprocess.Popen(exename + args, shell=True, cwd=path)
else:
subprocess.Popen(cmd, shell=True)
dupes = self.selected_dupes
refs = [self.results.get_group_of_duplicate(dupe).ref for dupe in dupes]
for dupe, ref in zip(dupes, refs):
dupe_cmd = cmd.replace("%d", str(dupe.path))
dupe_cmd = dupe_cmd.replace("%r", str(ref.path))
match = re.match(r'"([^"]+)"(.*)', dupe_cmd)
if match is not None:
# This code here is because subprocess. Popen doesn't seem to accept, under Windows,
# executable paths with spaces in it, *even* when they're enclosed in "". So this is
# a workaround to make the damn thing work.
exepath, args = match.groups()
path, exename = op.split(exepath)
p = subprocess.Popen(
exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
output = p.stdout.read()
logging.info("Custom command %s %s: %s", exename, args, output)
else:
p = subprocess.Popen(dupe_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = p.stdout.read()
logging.info("Custom command %s: %s", dupe_cmd, output)
def load(self):
"""Load directory selection and ignore list from files in appdata.
@ -543,20 +570,31 @@ class DupeGuru(Broadcaster):
is persistent data, is the same as when the last session was closed (when :meth:`save` was
called).
"""
self.directories.load_from_file(op.join(self.appdata, 'last_directories.xml'))
self.notify('directories_changed')
p = op.join(self.appdata, 'ignore_list.xml')
self.directories.load_from_file(op.join(self.appdata, "last_directories.xml"))
self.notify("directories_changed")
p = op.join(self.appdata, "ignore_list.xml")
self.ignore_list.load_from_xml(p)
self.ignore_list_dialog.refresh()
p = op.join(self.appdata, "exclude_list.xml")
self.exclude_list.load_from_xml(p)
self.exclude_list_dialog.refresh()
def load_directories(self, filepath):
# Clear out previous entries
self.directories.__init__()
self.directories.load_from_file(filepath)
self.notify("directories_changed")
def load_from(self, filename):
"""Start an async job to load results from ``filename``.
:param str filename: path of the XML file (created with :meth:`save_as`) to load
"""
def do(j):
self.results.load_from_xml(filename, self._get_file, j)
self._start_job(JobType.Load, do)
self._start_job(JobType.LOAD, do)
def make_selected_reference(self):
"""Promote :attr:`selected_dupes` to reference position within their respective groups.
@ -569,9 +607,8 @@ class DupeGuru(Broadcaster):
changed_groups = set()
for dupe in dupes:
g = self.results.get_group_of_duplicate(dupe)
if g not in changed_groups:
if self.results.make_ref(dupe):
changed_groups.add(g)
if g not in changed_groups and self.results.make_ref(dupe):
changed_groups.add(g)
# It's not always obvious to users what this action does, so to make it a bit clearer,
# we change our selection to the ref of all changed groups. However, we also want to keep
# the files that were ref before and weren't changed by the action. In effect, what this
@ -580,35 +617,31 @@ class DupeGuru(Broadcaster):
if not self.result_table.power_marker:
if changed_groups:
self.selected_dupes = [
d for d in self.selected_dupes
if self.results.get_group_of_duplicate(d).ref is d
d for d in self.selected_dupes if self.results.get_group_of_duplicate(d).ref is d
]
self.notify('results_changed')
self.notify("results_changed")
else:
# If we're in "Dupes Only" mode (previously called Power Marker), things are a bit
# different. The refs are not shown in the table, and if our operation is successful,
# this means that there's no way to follow our dupe selection. Then, the best thing to
# do is to keep our selection index-wise (different dupe selection, but same index
# selection).
self.notify('results_changed_but_keep_selection')
self.notify("results_changed_but_keep_selection")
def mark_all(self):
"""Set all dupes in the results as marked.
"""
"""Set all dupes in the results as marked."""
self.results.mark_all()
self.notify('marking_changed')
self.notify("marking_changed")
def mark_none(self):
"""Set all dupes in the results as unmarked.
"""
"""Set all dupes in the results as unmarked."""
self.results.mark_none()
self.notify('marking_changed')
self.notify("marking_changed")
def mark_invert(self):
"""Invert the marked state of all dupes in the results.
"""
"""Invert the marked state of all dupes in the results."""
self.results.mark_invert()
self.notify('marking_changed')
self.notify("marking_changed")
def mark_dupe(self, dupe, marked):
"""Change marked status of ``dupe``.
@ -621,21 +654,18 @@ class DupeGuru(Broadcaster):
self.results.mark(dupe)
else:
self.results.unmark(dupe)
self.notify('marking_changed')
self.notify("marking_changed")
def open_selected(self):
"""Open :attr:`selected_dupes` with their associated application.
"""
if len(self.selected_dupes) > 10:
if not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN):
return
"""Open :attr:`selected_dupes` with their associated application."""
if len(self.selected_dupes) > 10 and not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN):
return
for dupe in self.selected_dupes:
desktop.open_path(dupe.path)
def purge_ignore_list(self):
"""Remove files that don't exist from :attr:`ignore_list`.
"""
self.ignore_list.Filter(lambda f, s: op.exists(f) and op.exists(s))
"""Remove files that don't exist from :attr:`ignore_list`."""
self.ignore_list.filter(lambda f, s: op.exists(f) and op.exists(s))
self.ignore_list_dialog.refresh()
def remove_directories(self, indexes):
@ -648,7 +678,7 @@ class DupeGuru(Broadcaster):
indexes = sorted(indexes, reverse=True)
for index in indexes:
del self.directories[index]
self.notify('directories_changed')
self.notify("directories_changed")
except IndexError:
pass
@ -661,11 +691,10 @@ class DupeGuru(Broadcaster):
:type duplicates: list of :class:`~core.fs.File`
"""
self.results.remove_duplicates(self.without_ref(duplicates))
self.notify('results_changed_but_keep_selection')
self.notify("results_changed_but_keep_selection")
def remove_marked(self):
"""Removed marked duplicates from the results (without touching the files themselves).
"""
"""Removed marked duplicates from the results (without touching the files themselves)."""
if not self.results.mark_count:
self.view.show_message(MSG_NO_MARKED_DUPES)
return
@ -676,8 +705,7 @@ class DupeGuru(Broadcaster):
self._results_changed()
def remove_selected(self):
"""Removed :attr:`selected_dupes` from the results (without touching the files themselves).
"""
"""Removed :attr:`selected_dupes` from the results (without touching the files themselves)."""
dupes = self.without_ref(self.selected_dupes)
if not dupes:
self.view.show_message(MSG_NO_SELECTED_DUPES)
@ -715,6 +743,8 @@ class DupeGuru(Broadcaster):
for group in self.results.groups:
if group.prioritize(key_func=sort_key):
count += 1
if count:
self.results.refresh_required = True
self._results_changed()
msg = tr("{} duplicate groups were changed by the re-prioritization.").format(count)
self.view.show_message(msg)
@ -726,10 +756,15 @@ class DupeGuru(Broadcaster):
def save(self):
if not op.exists(self.appdata):
os.makedirs(self.appdata)
self.directories.save_to_file(op.join(self.appdata, 'last_directories.xml'))
p = op.join(self.appdata, 'ignore_list.xml')
self.directories.save_to_file(op.join(self.appdata, "last_directories.xml"))
p = op.join(self.appdata, "ignore_list.xml")
self.ignore_list.save_to_xml(p)
self.notify('save_session')
p = op.join(self.appdata, "exclude_list.xml")
self.exclude_list.save_to_xml(p)
self.notify("save_session")
def close(self):
fs.filesdb.close()
def save_as(self, filename):
"""Save results in ``filename``.
@ -741,12 +776,23 @@ class DupeGuru(Broadcaster):
except OSError as e:
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
def start_scanning(self):
def save_directories_as(self, filename):
"""Save directories in ``filename``.
:param str filename: path of the file to save directories (as XML) to.
"""
try:
self.directories.save_to_file(filename)
except OSError as e:
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
def start_scanning(self, profile_scan=False):
"""Starts an async job to scan for duplicates.
Scans folders selected in :attr:`directories` and put the results in :attr:`results`
"""
scanner = self.SCANNER_CLASS()
fs.filesdb.ignore_mtime = self.options["rehash_ignore_mtime"] is True
if not self.directories.has_any_file():
self.view.show_message(tr("The selected directories contain no scannable file."))
return
@ -754,23 +800,31 @@ class DupeGuru(Broadcaster):
for k, v in self.options.items():
if hasattr(scanner, k):
setattr(scanner, k, v)
if self.app_mode == AppMode.PICTURE:
scanner.cache_path = self._get_picture_cache_path()
self.results.groups = []
self._recreate_result_table()
self._results_changed()
def do(j):
if profile_scan:
pr = cProfile.Profile()
pr.enable()
j.set_progress(0, tr("Collecting files to scan"))
if scanner.scan_type == ScanType.Folders:
if scanner.scan_type == ScanType.FOLDERS:
files = list(self.directories.get_folders(folderclass=se.fs.Folder, j=j))
else:
files = list(self.directories.get_files(fileclasses=self.fileclasses, j=j))
if self.options['ignore_hardlink_matches']:
if self.options["ignore_hardlink_matches"]:
files = self._remove_hardlink_dupes(files)
logging.info('Scanning %d files' % len(files))
logging.info("Scanning %d files" % len(files))
self.results.groups = scanner.get_dupe_groups(files, self.ignore_list, j)
self.discarded_file_count = scanner.discarded_file_count
if profile_scan:
pr.disable()
pr.dump_stats(op.join(self.appdata, f"{datetime.datetime.now():%Y-%m-%d_%H-%M-%S}.profile"))
self._start_job(JobType.Scan, do)
self._start_job(JobType.SCAN, do)
def toggle_selected_mark_state(self):
selected = self.without_ref(self.selected_dupes)
@ -782,11 +836,10 @@ class DupeGuru(Broadcaster):
markfunc = self.results.mark
for dupe in selected:
markfunc(dupe)
self.notify('marking_changed')
self.notify("marking_changed")
def without_ref(self, dupes):
"""Returns ``dupes`` with all reference elements removed.
"""
"""Returns ``dupes`` with all reference elements removed."""
return [dupe for dupe in dupes if self.results.get_group_of_duplicate(dupe).ref is not dupe]
def get_default(self, key, fallback_value=None):
@ -802,7 +855,7 @@ class DupeGuru(Broadcaster):
def set_default(self, key, value):
self.view.set_default(key, value)
#--- Properties
# --- Properties
@property
def stat_line(self):
result = self.results.stat_line
@ -816,22 +869,31 @@ class DupeGuru(Broadcaster):
@property
def SCANNER_CLASS(self):
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
return pe.scanner.ScannerPE
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
return me.scanner.ScannerME
else:
return se.scanner.ScannerSE
@property
def METADATA_TO_READ(self):
if self.app_mode == AppMode.Picture:
return ['size', 'mtime', 'dimensions', 'exif_timestamp']
elif self.app_mode == AppMode.Music:
if self.app_mode == AppMode.PICTURE:
return ["size", "mtime", "dimensions", "exif_timestamp"]
elif self.app_mode == AppMode.MUSIC:
return [
'size', 'mtime', 'duration', 'bitrate', 'samplerate', 'title', 'artist',
'album', 'genre', 'year', 'track', 'comment'
"size",
"mtime",
"duration",
"bitrate",
"samplerate",
"title",
"artist",
"album",
"genre",
"year",
"track",
"comment",
]
else:
return ['size', 'mtime']
return ["size", "mtime"]

View File

@ -1,27 +1,28 @@
# Created By: Virgil Dupras
# Created On: 2006/02/27
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
# Copyright 2017 Virgil Dupras
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import os
from xml.etree import ElementTree as ET
import logging
from pathlib import Path
from hscommon.jobprogress import job
from hscommon.path import Path
from hscommon.util import FileOrPath
from hscommon.trans import tr
from . import fs
from core import fs
__all__ = [
'Directories',
'DirectoryState',
'AlreadyThereError',
'InvalidPathError',
"Directories",
"DirectoryState",
"AlreadyThereError",
"InvalidPathError",
]
class DirectoryState:
"""Enum describing how a folder should be considered.
@ -29,16 +30,20 @@ class DirectoryState:
* DirectoryState.Reference: Scan files, but make sure never to delete any of them
* DirectoryState.Excluded: Don't scan this folder
"""
Normal = 0
Reference = 1
Excluded = 2
NORMAL = 0
REFERENCE = 1
EXCLUDED = 2
class AlreadyThereError(Exception):
"""The path being added is already in the directory list"""
class InvalidPathError(Exception):
"""The path being added is invalid"""
class Directories:
"""Holds user folder selection.
@ -48,15 +53,17 @@ class Directories:
Then, when the user starts the scan, :meth:`get_files` is called to retrieve all files (wrapped
in :mod:`core.fs`) that have to be scanned according to the chosen folders/states.
"""
#---Override
def __init__(self):
# ---Override
def __init__(self, exclude_list=None):
self._dirs = []
# {path: state}
self.states = {}
self._exclude_list = exclude_list
def __contains__(self, path):
for p in self._dirs:
if path in p:
if path == p or p in path.parents:
return True
return False
@ -69,54 +76,75 @@ class Directories:
def __len__(self):
return len(self._dirs)
#---Private
# ---Private
def _default_state_for_path(self, path):
# New logic with regex filters
if self._exclude_list is not None and self._exclude_list.mark_count > 0:
# We iterate even if we only have one item here
for denied_path_re in self._exclude_list.compiled:
if denied_path_re.match(str(path.name)):
return DirectoryState.EXCLUDED
return DirectoryState.NORMAL
# Override this in subclasses to specify the state of some special folders.
if path.name.startswith('.'): # hidden
return DirectoryState.Excluded
if path.name.startswith("."):
return DirectoryState.EXCLUDED
return DirectoryState.NORMAL
def _get_files(self, from_path, fileclasses, j):
j.check_if_cancelled()
state = self.get_state(from_path)
if state == DirectoryState.Excluded:
# Recursively get files from folders with lots of subfolder is expensive. However, there
# might be a subfolder in this path that is not excluded. What we want to do is to skim
# through self.states and see if we must continue, or we can stop right here to save time
if not any(p[:len(from_path)] == from_path for p in self.states):
return
try:
filepaths = set()
if state != DirectoryState.Excluded:
found_files = fs.get_files(from_path, fileclasses=fileclasses)
logging.debug("Collected %d files in folder %s", len(found_files), str(from_path))
for file in found_files:
file.is_ref = state == DirectoryState.Reference
filepaths.add(file.path)
yield file
# it's possible that a folder (bundle) gets into the file list. in that case, we don't
# want to recurse into it
subfolders = [p for p in from_path.listdir() if not p.islink() and p.isdir() and p not in filepaths]
for subfolder in subfolders:
for file in self._get_files(subfolder, fileclasses=fileclasses, j=j):
yield file
except (EnvironmentError, fs.InvalidPath):
with os.scandir(from_path) as iter:
root_path = Path(from_path)
state = self.get_state(root_path)
# if we have no un-excluded dirs under this directory skip going deeper
skip_dirs = state == DirectoryState.EXCLUDED and not any(
p.parts[: len(root_path.parts)] == root_path.parts for p in self.states
)
count = 0
for item in iter:
j.check_if_cancelled()
try:
if item.is_dir():
if skip_dirs:
continue
yield from self._get_files(item.path, fileclasses, j)
continue
elif state == DirectoryState.EXCLUDED:
continue
# File excluding or not
if (
self._exclude_list is None
or not self._exclude_list.mark_count
or not self._exclude_list.is_excluded(str(from_path), item.name)
):
file = fs.get_file(item, fileclasses=fileclasses)
if file:
file.is_ref = state == DirectoryState.REFERENCE
count += 1
yield file
except (OSError, fs.InvalidPath):
pass
logging.debug(
"Collected %d files in folder %s",
count,
str(root_path),
)
except OSError:
pass
def _get_folders(self, from_folder, j):
j.check_if_cancelled()
try:
for subfolder in from_folder.subfolders:
for folder in self._get_folders(subfolder, j):
yield folder
yield from self._get_folders(subfolder, j)
state = self.get_state(from_folder.path)
if state != DirectoryState.Excluded:
from_folder.is_ref = state == DirectoryState.Reference
if state != DirectoryState.EXCLUDED:
from_folder.is_ref = state == DirectoryState.REFERENCE
logging.debug("Yielding Folder %r state: %d", from_folder, state)
yield from_folder
except (EnvironmentError, fs.InvalidPath):
except (OSError, fs.InvalidPath):
pass
#---Public
# ---Public
def add_path(self, path):
"""Adds ``path`` to self, if not already there.
@ -131,7 +159,7 @@ class Directories:
raise AlreadyThereError()
if not path.exists():
raise InvalidPathError()
self._dirs = [p for p in self._dirs if p not in path]
self._dirs = [p for p in self._dirs if path not in p.parents]
self._dirs.append(path)
@staticmethod
@ -142,10 +170,10 @@ class Directories:
:rtype: list of Path
"""
try:
subpaths = [p for p in path.listdir() if p.isdir()]
subpaths = [p for p in path.glob("*") if p.is_dir()]
subpaths.sort(key=lambda x: x.name.lower())
return subpaths
except EnvironmentError:
except OSError:
return []
def get_files(self, fileclasses=None, j=job.nulljob):
@ -155,8 +183,12 @@ class Directories:
"""
if fileclasses is None:
fileclasses = [fs.File]
file_count = 0
for path in self._dirs:
for file in self._get_files(path, fileclasses=fileclasses, j=j):
file_count += 1
if not isinstance(j, job.NullJob):
j.set_progress(-1, tr("Collected {} files to scan").format(file_count))
yield file
def get_folders(self, folderclass=None, j=job.nulljob):
@ -166,9 +198,13 @@ class Directories:
"""
if folderclass is None:
folderclass = fs.Folder
folder_count = 0
for path in self._dirs:
from_folder = folderclass(path)
for folder in self._get_folders(from_folder, j):
folder_count += 1
if not isinstance(j, job.NullJob):
j.set_progress(-1, tr("Collected {} folders to scan").format(folder_count))
yield folder
def get_state(self, path):
@ -176,16 +212,20 @@ class Directories:
:rtype: :class:`DirectoryState`
"""
# direct match? easy result.
if path in self.states:
return self.states[path]
default_state = self._default_state_for_path(path)
if default_state is not None:
return default_state
parent = path.parent()
if parent in self:
return self.get_state(parent)
else:
return DirectoryState.Normal
state = self._default_state_for_path(path)
# Save non-default states in cache, necessary for _get_files()
if state != DirectoryState.NORMAL:
self.states[path] = state
return state
# find the longest parent path that is in states and return that state if found
# NOTE: path.parents is ordered longest to shortest
for parent_path in path.parents:
if parent_path in self.states:
return self.states[parent_path]
return state
def has_any_file(self):
"""Returns whether selected folders contain any file.
@ -209,21 +249,21 @@ class Directories:
root = ET.parse(infile).getroot()
except Exception:
return
for rdn in root.getiterator('root_directory'):
for rdn in root.iter("root_directory"):
attrib = rdn.attrib
if 'path' not in attrib:
if "path" not in attrib:
continue
path = attrib['path']
path = attrib["path"]
try:
self.add_path(Path(path))
except (AlreadyThereError, InvalidPathError):
pass
for sn in root.getiterator('state'):
for sn in root.iter("state"):
attrib = sn.attrib
if not ('path' in attrib and 'value' in attrib):
if not ("path" in attrib and "value" in attrib):
continue
path = attrib['path']
state = attrib['value']
path = attrib["path"]
state = attrib["value"]
self.states[Path(path)] = int(state)
def save_to_file(self, outfile):
@ -231,17 +271,17 @@ class Directories:
:param file outfile: path or file pointer to XML file to save to.
"""
with FileOrPath(outfile, 'wb') as fp:
root = ET.Element('directories')
with FileOrPath(outfile, "wb") as fp:
root = ET.Element("directories")
for root_path in self:
root_path_node = ET.SubElement(root, 'root_directory')
root_path_node.set('path', str(root_path))
root_path_node = ET.SubElement(root, "root_directory")
root_path_node.set("path", str(root_path))
for path, state in self.states.items():
state_node = ET.SubElement(root, 'state')
state_node.set('path', str(path))
state_node.set('value', str(state))
state_node = ET.SubElement(root, "state")
state_node.set("path", str(path))
state_node.set("value", str(state))
tree = ET.ElementTree(root)
tree.write(fp, encoding='utf-8')
tree.write(fp, encoding="utf-8")
def set_state(self, path, state):
"""Set the state of folder at ``path``.
@ -253,7 +293,6 @@ class Directories:
if self.get_state(path) == state:
return
for iter_path in list(self.states.keys()):
if path.is_parent_of(iter_path):
if path in iter_path.parents:
del self.states[iter_path]
self.states[path] = state

View File

@ -24,18 +24,33 @@ from hscommon.jobprogress import job
) = range(3)
JOB_REFRESH_RATE = 100
PROGRESS_MESSAGE = tr("%d matches found from %d groups")
def getwords(s):
# We decompose the string so that ascii letters with accents can be part of the word.
s = normalize('NFD', s)
s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", ' ').lower()
s = ''.join(c for c in s if c in string.ascii_letters + string.digits + string.whitespace)
return [_f for _f in s.split(' ') if _f] # remove empty elements
s = normalize("NFD", s)
s = multi_replace(s, "-_&+():;\\[]{}.,<>/?~!@#$*", " ").lower()
# logging.debug(f"DEBUG chars for: {s}\n"
# f"{[c for c in s if ord(c) != 32]}\n"
# f"{[ord(c) for c in s if ord(c) != 32]}")
# HACK We shouldn't ignore non-ascii characters altogether. Any Unicode char
# above common european characters that cannot be "sanitized" (ie. stripped
# of their accents, etc.) are preserved as is. The arbitrary limit is
# obtained from this one: ord("\u037e") GREEK QUESTION MARK
s = "".join(
c
for c in s
if (ord(c) <= 894 and c in string.ascii_letters + string.digits + string.whitespace) or ord(c) > 894
)
return [_f for _f in s.split(" ") if _f] # remove empty elements
def getfields(s):
fields = [getwords(field) for field in s.split(' - ')]
fields = [getwords(field) for field in s.split(" - ")]
return [_f for _f in fields if _f]
def unpack_fields(fields):
result = []
for field in fields:
@ -45,6 +60,7 @@ def unpack_fields(fields):
result.append(field)
return result
def compare(first, second, flags=()):
"""Returns the % of words that match between ``first`` and ``second``
@ -55,11 +71,11 @@ def compare(first, second, flags=()):
return 0
if any(isinstance(element, list) for element in first):
return compare_fields(first, second, flags)
second = second[:] #We must use a copy of second because we remove items from it
second = second[:] # We must use a copy of second because we remove items from it
match_similar = MATCH_SIMILAR_WORDS in flags
weight_words = WEIGHT_WORDS in flags
joined = first + second
total_count = (sum(len(word) for word in joined) if weight_words else len(joined))
total_count = sum(len(word) for word in joined) if weight_words else len(joined)
match_count = 0
in_order = True
for word in first:
@ -71,12 +87,13 @@ def compare(first, second, flags=()):
if second[0] != word:
in_order = False
second.remove(word)
match_count += (len(word) if weight_words else 1)
match_count += len(word) if weight_words else 1
result = round(((match_count * 2) / total_count) * 100)
if (result == 100) and (not in_order):
result = 99 # We cannot consider a match exact unless the ordering is the same
result = 99 # We cannot consider a match exact unless the ordering is the same
return result
def compare_fields(first, second, flags=()):
"""Returns the score for the lowest matching :ref:`fields`.
@ -87,23 +104,24 @@ def compare_fields(first, second, flags=()):
return 0
if NO_FIELD_ORDER in flags:
results = []
#We don't want to remove field directly in the list. We must work on a copy.
# We don't want to remove field directly in the list. We must work on a copy.
second = second[:]
for field1 in first:
max = 0
max_score = 0
matched_field = None
for field2 in second:
r = compare(field1, field2, flags)
if r > max:
max = r
if r > max_score:
max_score = r
matched_field = field2
results.append(max)
results.append(max_score)
if matched_field:
second.remove(matched_field)
else:
results = [compare(field1, field2, flags) for field1, field2 in zip(first, second)]
return min(results) if results else 0
def build_word_dict(objects, j=job.nulljob):
"""Returns a dict of objects mapped by their words.
@ -113,11 +131,12 @@ def build_word_dict(objects, j=job.nulljob):
The result will be a dict with words as keys, lists of objects as values.
"""
result = defaultdict(set)
for object in j.iter_with_progress(objects, 'Prepared %d/%d files', JOB_REFRESH_RATE):
for object in j.iter_with_progress(objects, "Prepared %d/%d files", JOB_REFRESH_RATE):
for word in unpack_fields(object.words):
result[word].add(object)
return result
def merge_similar_words(word_dict):
"""Take all keys in ``word_dict`` that are similar, and merge them together.
@ -126,7 +145,7 @@ def merge_similar_words(word_dict):
a word equal to the other.
"""
keys = list(word_dict.keys())
keys.sort(key=len)# we want the shortest word to stay
keys.sort(key=len) # we want the shortest word to stay
while keys:
key = keys.pop(0)
similars = difflib.get_close_matches(key, keys, 100, 0.8)
@ -138,6 +157,7 @@ def merge_similar_words(word_dict):
del word_dict[similar]
keys.remove(similar)
def reduce_common_words(word_dict, threshold):
"""Remove all objects from ``word_dict`` values where the object count >= ``threshold``
@ -146,7 +166,7 @@ def reduce_common_words(word_dict, threshold):
The exception to this removal are the objects where all the words of the object are common.
Because if we remove them, we will miss some duplicates!
"""
uncommon_words = set(word for word, objects in word_dict.items() if len(objects) < threshold)
uncommon_words = {word for word, objects in word_dict.items() if len(objects) < threshold}
for word, objects in list(word_dict.items()):
if len(objects) < threshold:
continue
@ -159,11 +179,13 @@ def reduce_common_words(word_dict, threshold):
else:
del word_dict[word]
# Writing docstrings in a namedtuple is tricky. From Python 3.3, it's possible to set __doc__, but
# some research allowed me to find a more elegant solution, which is what is done here. See
# http://stackoverflow.com/questions/1606436/adding-docstrings-to-namedtuples-in-python
class Match(namedtuple('Match', 'first second percentage')):
class Match(namedtuple("Match", "first second percentage")):
"""Represents a match between two :class:`~core.fs.File`.
Regarless of the matching method, when two files are determined to match, a Match pair is created,
@ -182,16 +204,24 @@ class Match(namedtuple('Match', 'first second percentage')):
their match level according to the scan method which found the match. int from 1 to 100. For
exact scan methods, such as Contents scans, this will always be 100.
"""
__slots__ = ()
def get_match(first, second, flags=()):
#it is assumed here that first and second both have a "words" attribute
# it is assumed here that first and second both have a "words" attribute
percentage = compare(first.words, second.words, flags)
return Match(first, second, percentage)
def getmatches(
objects, min_match_percentage=0, match_similar_words=False, weight_words=False,
no_field_order=False, j=job.nulljob):
objects,
min_match_percentage=0,
match_similar_words=False,
weight_words=False,
no_field_order=False,
j=job.nulljob,
):
"""Returns a list of :class:`Match` within ``objects`` after fuzzily matching their words.
:param objects: List of :class:`~core.fs.File` to match.
@ -206,7 +236,7 @@ def getmatches(
j = j.start_subjob(2)
sj = j.start_subjob(2)
for o in objects:
if not hasattr(o, 'words'):
if not hasattr(o, "words"):
o.words = getwords(o.name)
word_dict = build_word_dict(objects, sj)
reduce_common_words(word_dict, COMMON_WORD_THRESHOLD)
@ -219,10 +249,11 @@ def getmatches(
match_flags.append(MATCH_SIMILAR_WORDS)
if no_field_order:
match_flags.append(NO_FIELD_ORDER)
j.start_job(len(word_dict), tr("0 matches found"))
j.start_job(len(word_dict), PROGRESS_MESSAGE % (0, 0))
compared = defaultdict(set)
result = []
try:
word_count = 0
# This whole 'popping' thing is there to avoid taking too much memory at the same time.
while word_dict:
items = word_dict.popitem()[1]
@ -237,39 +268,54 @@ def getmatches(
result.append(m)
if len(result) >= LIMIT:
return result
j.add_progress(desc=tr("%d matches found") % len(result))
word_count += 1
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), word_count))
except MemoryError:
# This is the place where the memory usage is at its peak during the scan.
# Just continue the process with an incomplete list of matches.
del compared # This should give us enough room to call logging.
logging.warning('Memory Overflow. Matches: %d. Word dict: %d' % (len(result), len(word_dict)))
del compared # This should give us enough room to call logging.
logging.warning("Memory Overflow. Matches: %d. Word dict: %d" % (len(result), len(word_dict)))
return result
return result
def getmatches_by_contents(files, j=job.nulljob):
def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
"""Returns a list of :class:`Match` within ``files`` if their contents is the same.
:param bigsize: The size in bytes over which we consider files big enough to
justify taking samples of the file for hashing. If 0, compute digest as usual.
:param j: A :ref:`job progress instance <jobs>`.
"""
size2files = defaultdict(set)
for f in files:
if f.size:
size2files[f.size].add(f)
size2files[f.size].add(f)
del files
possible_matches = [files for files in size2files.values() if len(files) > 1]
del size2files
result = []
j.start_job(len(possible_matches), tr("0 matches found"))
j.start_job(len(possible_matches), PROGRESS_MESSAGE % (0, 0))
group_count = 0
for group in possible_matches:
for first, second in itertools.combinations(group, 2):
if first.is_ref and second.is_ref:
continue # Don't spend time comparing two ref pics together.
if first.md5partial == second.md5partial:
if first.md5 == second.md5:
result.append(Match(first, second, 100))
j.add_progress(desc=tr("%d matches found") % len(result))
continue # Don't spend time comparing two ref pics together.
if first.size == 0 and second.size == 0:
# skip hashing for zero length files
result.append(Match(first, second, 100))
continue
# if digests are the same (and not None) then files match
if first.digest_partial is not None and first.digest_partial == second.digest_partial:
if bigsize > 0 and first.size > bigsize:
if first.digest_samples is not None and first.digest_samples == second.digest_samples:
result.append(Match(first, second, 100))
else:
if first.digest is not None and first.digest == second.digest:
result.append(Match(first, second, 100))
group_count += 1
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count))
return result
class Group:
"""A group of :class:`~core.fs.File` that match together.
@ -297,7 +343,8 @@ class Group:
Average match percentage of match pairs containing :attr:`ref`.
"""
#---Override
# ---Override
def __init__(self):
self._clear()
@ -313,7 +360,7 @@ class Group:
def __len__(self):
return len(self.ordered)
#---Private
# ---Private
def _clear(self):
self._percentage = None
self._matches_for_ref = None
@ -328,7 +375,7 @@ class Group:
self._matches_for_ref = [match for match in self.matches if ref in match]
return self._matches_for_ref
#---Public
# ---Public
def add_match(self, match):
"""Adds ``match`` to internal match list and possibly add duplicates to the group.
@ -339,6 +386,7 @@ class Group:
:param tuple match: pair of :class:`~core.fs.File` to add
"""
def add_candidate(item, match):
matches = self.candidates[item]
matches.add(match)
@ -362,14 +410,13 @@ class Group:
You can call this after the duplicate scanning process to free a bit of memory.
"""
discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second]))
discarded = {m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second])}
self.matches -= discarded
self.candidates = defaultdict(set)
return discarded
def get_match_of(self, item):
"""Returns the match pair between ``item`` and :attr:`ref`.
"""
"""Returns the match pair between ``item`` and :attr:`ref`."""
if item is self.ref:
return
for m in self._get_matches_for_ref():
@ -385,8 +432,7 @@ class Group:
"""
# tie_breaker(ref, dupe) --> True if dupe should be ref
# Returns True if anything changed during prioritization.
master_key_func = lambda x: (-x.is_ref, key_func(x))
new_order = sorted(self.ordered, key=master_key_func)
new_order = sorted(self.ordered, key=lambda x: (-x.is_ref, key_func(x)))
changed = new_order != self.ordered
self.ordered = new_order
if tie_breaker is None:
@ -409,17 +455,16 @@ class Group:
self.unordered.remove(item)
self._percentage = None
self._matches_for_ref = None
if (len(self) > 1) and any(not getattr(item, 'is_ref', False) for item in self):
if (len(self) > 1) and any(not getattr(item, "is_ref", False) for item in self):
if discard_matches:
self.matches = set(m for m in self.matches if item not in m)
self.matches = {m for m in self.matches if item not in m}
else:
self._clear()
except ValueError:
pass
def switch_ref(self, with_dupe):
"""Make the :attr:`ref` dupe of the group switch position with ``with_dupe``.
"""
"""Make the :attr:`ref` dupe of the group switch position with ``with_dupe``."""
if self.ref.is_ref:
return False
try:
@ -485,7 +530,7 @@ def get_groups(matches):
del dupe2group
del matches
# should free enough memory to continue
logging.warning('Memory Overflow. Groups: {0}'.format(len(groups)))
logging.warning(f"Memory Overflow. Groups: {len(groups)}")
# Now that we have a group, we have to discard groups' matches and see if there're any "orphan"
# matches, that is, matches that were candidate in a group but that none of their 2 files were
# accepted in the group. With these orphan groups, it's safe to build additional groups
@ -493,9 +538,8 @@ def get_groups(matches):
orphan_matches = []
for group in groups:
orphan_matches += {
m for m in group.discard_matches()
if not any(obj in matched_files for obj in [m.first, m.second])
m for m in group.discard_matches() if not any(obj in matched_files for obj in [m.first, m.second])
}
if groups and orphan_matches:
groups += get_groups(orphan_matches) # no job, as it isn't supposed to take a long time
groups += get_groups(orphan_matches) # no job, as it isn't supposed to take a long time
return groups

513
core/exclude.py Normal file
View File

@ -0,0 +1,513 @@
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from core.markable import Markable
from xml.etree import ElementTree as ET
# TODO: perhaps use regex module for better Unicode support? https://pypi.org/project/regex/
# also https://pypi.org/project/re2/
# TODO update the Result list with newly added regexes if possible
import re
from os import sep
import logging
import functools
from hscommon.util import FileOrPath
from hscommon.plat import ISWINDOWS
import time
default_regexes = [
r"^thumbs\.db$", # Obsolete after WindowsXP
r"^desktop\.ini$", # Windows metadata
r"^\.DS_Store$", # MacOS metadata
r"^\.Trash\-.*", # Linux trash directories
r"^\$Recycle\.Bin$", # Windows
r"^\..*", # Hidden files on Unix-like
]
# These are too broad
forbidden_regexes = [r".*", r"\/.*", r".*\/.*", r".*\\\\.*", r".*\..*"]
def timer(func):
@functools.wraps(func)
def wrapper_timer(*args):
start = time.perf_counter_ns()
value = func(*args)
end = time.perf_counter_ns()
print(f"DEBUG: func {func.__name__!r} took {end - start} ns.")
return value
return wrapper_timer
def memoize(func):
func.cache = dict()
@functools.wraps(func)
def _memoize(*args):
if args not in func.cache:
func.cache[args] = func(*args)
return func.cache[args]
return _memoize
class AlreadyThereException(Exception):
"""Expression already in the list"""
def __init__(self, arg="Expression is already in excluded list."):
super().__init__(arg)
class ExcludeList(Markable):
"""A list of lists holding regular expression strings and the compiled re.Pattern"""
# Used to filter out directories and files that we would rather avoid scanning.
# The list() class allows us to preserve item order without too much hassle.
# The downside is we have to compare strings every time we look for an item in the list
# since we use regex strings as keys.
# If _use_union is True, the compiled regexes will be combined into one single
# Pattern instead of separate Patterns which may or may not give better
# performance compared to looping through each Pattern individually.
# ---Override
def __init__(self, union_regex=True):
Markable.__init__(self)
self._use_union = union_regex
# list([str regex, bool iscompilable, re.error exception, Pattern compiled], ...)
self._excluded = []
self._excluded_compiled = set()
self._dirty = True
def __iter__(self):
"""Iterate in order."""
for item in self._excluded:
regex = item[0]
yield self.is_marked(regex), regex
def __contains__(self, item):
return self.has_entry(item)
def __len__(self):
"""Returns the total number of regexes regardless of mark status."""
return len(self._excluded)
def __getitem__(self, key):
"""Returns the list item corresponding to key."""
for item in self._excluded:
if item[0] == key:
return item
raise KeyError(f"Key {key} is not in exclusion list.")
def __setitem__(self, key, value):
# TODO if necessary
pass
def __delitem__(self, key):
# TODO if necessary
pass
def get_compiled(self, key):
"""Returns the (precompiled) Pattern for key"""
return self.__getitem__(key)[3]
def is_markable(self, regex):
return self._is_markable(regex)
def _is_markable(self, regex):
"""Return the cached result of "compilable" property"""
for item in self._excluded:
if item[0] == regex:
return item[1]
return False # should not be necessary, the regex SHOULD be in there
def _did_mark(self, regex):
self._add_compiled(regex)
def _did_unmark(self, regex):
self._remove_compiled(regex)
def _add_compiled(self, regex):
self._dirty = True
if self._use_union:
return
for item in self._excluded:
# FIXME probably faster to just rebuild the set from the compiled instead of comparing strings
if item[0] == regex:
# no need to test if already present since it's a set()
self._excluded_compiled.add(item[3])
break
def _remove_compiled(self, regex):
self._dirty = True
if self._use_union:
return
for item in self._excluded_compiled:
if regex in item.pattern:
self._excluded_compiled.remove(item)
break
# @timer
@memoize
def _do_compile(self, expr):
return re.compile(expr)
# @timer
# @memoize # probably not worth memoizing this one if we memoize the above
def compile_re(self, regex):
compiled = None
try:
compiled = self._do_compile(regex)
except Exception as e:
return False, e, compiled
return True, None, compiled
def error(self, regex):
"""Return the compilation error Exception for regex.
It should have a "msg" attr."""
for item in self._excluded:
if item[0] == regex:
return item[2]
def build_compiled_caches(self, union=False):
if not union:
self._cached_compiled_files = [x for x in self._excluded_compiled if not has_sep(x.pattern)]
self._cached_compiled_paths = [x for x in self._excluded_compiled if has_sep(x.pattern)]
self._dirty = False
return
marked_count = [x for marked, x in self if marked]
# If there is no item, the compiled Pattern will be '' and match everything!
if not marked_count:
self._cached_compiled_union_all = []
self._cached_compiled_union_files = []
self._cached_compiled_union_paths = []
else:
# HACK returned as a tuple to get a free iterator and keep interface
# the same regardless of whether the client asked for union or not
self._cached_compiled_union_all = (re.compile("|".join(marked_count)),)
files_marked = [x for x in marked_count if not has_sep(x)]
if not files_marked:
self._cached_compiled_union_files = tuple()
else:
self._cached_compiled_union_files = (re.compile("|".join(files_marked)),)
paths_marked = [x for x in marked_count if has_sep(x)]
if not paths_marked:
self._cached_compiled_union_paths = tuple()
else:
self._cached_compiled_union_paths = (re.compile("|".join(paths_marked)),)
self._dirty = False
@property
def compiled(self):
"""Should be used by other classes to retrieve the up-to-date list of patterns."""
if self._use_union:
if self._dirty:
self.build_compiled_caches(self._use_union)
return self._cached_compiled_union_all
return self._excluded_compiled
@property
def compiled_files(self):
"""When matching against filenames only, we probably won't be seeing any
directory separator, so we filter out regexes with os.sep in them.
The interface should be expected to be a generator, even if it returns only
one item (one Pattern in the union case)."""
if self._dirty:
self.build_compiled_caches(self._use_union)
return self._cached_compiled_union_files if self._use_union else self._cached_compiled_files
@property
def compiled_paths(self):
"""Returns patterns with only separators in them, for more precise filtering."""
if self._dirty:
self.build_compiled_caches(self._use_union)
return self._cached_compiled_union_paths if self._use_union else self._cached_compiled_paths
# ---Public
def add(self, regex, forced=False):
"""This interface should throw exceptions if there is an error during
regex compilation"""
if self.has_entry(regex):
# This exception should never be ignored
raise AlreadyThereException()
if regex in forbidden_regexes:
raise ValueError("Forbidden (dangerous) expression.")
iscompilable, exception, compiled = self.compile_re(regex)
if not iscompilable and not forced:
# This exception can be ignored, but taken into account
# to avoid adding to compiled set
raise exception
else:
self._do_add(regex, iscompilable, exception, compiled)
def _do_add(self, regex, iscompilable, exception, compiled):
# We need to insert at the top
self._excluded.insert(0, [regex, iscompilable, exception, compiled])
@property
def marked_count(self):
"""Returns the number of marked regexes only."""
return len([x for marked, x in self if marked])
def has_entry(self, regex):
for item in self._excluded:
if regex == item[0]:
return True
return False
def is_excluded(self, dirname, filename):
"""Return True if the file or the absolute path to file is supposed to be
filtered out, False otherwise."""
matched = False
for expr in self.compiled_files:
if expr.fullmatch(filename):
matched = True
break
if not matched:
for expr in self.compiled_paths:
if expr.fullmatch(dirname + sep + filename):
matched = True
break
return matched
def remove(self, regex):
for item in self._excluded:
if item[0] == regex:
self._excluded.remove(item)
self._remove_compiled(regex)
def rename(self, regex, newregex):
if regex == newregex:
return
found = False
was_marked = False
is_compilable = False
for item in self._excluded:
if item[0] == regex:
found = True
was_marked = self.is_marked(regex)
is_compilable, exception, compiled = self.compile_re(newregex)
# We overwrite the found entry
self._excluded[self._excluded.index(item)] = [newregex, is_compilable, exception, compiled]
self._remove_compiled(regex)
break
if not found:
return
if is_compilable:
self._add_compiled(newregex)
if was_marked:
# Not marked by default when added, add it back
self.mark(newregex)
# def change_index(self, regex, new_index):
# """Internal list must be a list, not dict."""
# item = self._excluded.pop(regex)
# self._excluded.insert(new_index, item)
def restore_defaults(self):
for _, regex in self:
if regex not in default_regexes:
self.unmark(regex)
for default_regex in default_regexes:
if not self.has_entry(default_regex):
self.add(default_regex)
self.mark(default_regex)
def load_from_xml(self, infile):
"""Loads the ignore list from a XML created with save_to_xml.
infile can be a file object or a filename.
"""
try:
root = ET.parse(infile).getroot()
except Exception as e:
logging.warning(f"Error while loading {infile}: {e}")
self.restore_defaults()
return e
marked = set()
exclude_elems = (e for e in root if e.tag == "exclude")
for exclude_item in exclude_elems:
regex_string = exclude_item.get("regex")
if not regex_string:
continue
try:
# "forced" avoids compilation exceptions and adds anyway
self.add(regex_string, forced=True)
except AlreadyThereException:
logging.error(
f'Regex "{regex_string}" \
loaded from XML was already present in the list.'
)
continue
if exclude_item.get("marked") == "y":
marked.add(regex_string)
for item in marked:
self.mark(item)
def save_to_xml(self, outfile):
"""Create a XML file that can be used by load_from_xml.
outfile can be a file object or a filename."""
root = ET.Element("exclude_list")
# reversed in order to keep order of entries when reloading from xml later
for item in reversed(self._excluded):
exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", str(item[0]))
exclude_node.set("marked", ("y" if self.is_marked(item[0]) else "n"))
tree = ET.ElementTree(root)
with FileOrPath(outfile, "wb") as fp:
tree.write(fp, encoding="utf-8")
class ExcludeDict(ExcludeList):
"""Exclusion list holding a set of regular expressions as keys, the compiled
Pattern, compilation error and compilable boolean as values."""
# Implemntation around a dictionary instead of a list, which implies
# to keep the index of each string-key as its sub-element and keep it updated
# whenever insert/remove is done.
def __init__(self, union_regex=False):
Markable.__init__(self)
self._use_union = union_regex
# { "regex string":
# {
# "index": int,
# "compilable": bool,
# "error": str,
# "compiled": Pattern or None
# }
# }
self._excluded = {}
self._excluded_compiled = set()
self._dirty = True
def __iter__(self):
"""Iterate in order."""
for regex in ordered_keys(self._excluded):
yield self.is_marked(regex), regex
def __getitem__(self, key):
"""Returns the dict item correponding to key"""
return self._excluded.__getitem__(key)
def get_compiled(self, key):
"""Returns the compiled item for key"""
return self.__getitem__(key).get("compiled")
def is_markable(self, regex):
return self._is_markable(regex)
def _is_markable(self, regex):
"""Return the cached result of "compilable" property"""
exists = self._excluded.get(regex)
if exists:
return exists.get("compilable")
return False
def _add_compiled(self, regex):
self._dirty = True
if self._use_union:
return
try:
self._excluded_compiled.add(self._excluded.get(regex).get("compiled"))
except Exception as e:
logging.error(f"Exception while adding regex {regex} to compiled set: {e}")
return
def is_compilable(self, regex):
"""Returns the cached "compilable" value"""
return self._excluded[regex]["compilable"]
def error(self, regex):
"""Return the compilation error message for regex string"""
return self._excluded.get(regex).get("error")
# ---Public
def _do_add(self, regex, iscompilable, exception, compiled):
# We always insert at the top, so index should be 0
# and other indices should be pushed by one
for value in self._excluded.values():
value["index"] += 1
self._excluded[regex] = {"index": 0, "compilable": iscompilable, "error": exception, "compiled": compiled}
def has_entry(self, regex):
if regex in self._excluded.keys():
return True
return False
def remove(self, regex):
old_value = self._excluded.pop(regex)
# Bring down all indices which where above it
index = old_value["index"]
if index == len(self._excluded) - 1: # we start at 0...
# Old index was at the end, no need to update other indices
self._remove_compiled(regex)
return
for value in self._excluded.values():
if value.get("index") > old_value["index"]:
value["index"] -= 1
self._remove_compiled(regex)
def rename(self, regex, newregex):
if regex == newregex or regex not in self._excluded.keys():
return
was_marked = self.is_marked(regex)
previous = self._excluded.pop(regex)
iscompilable, error, compiled = self.compile_re(newregex)
self._excluded[newregex] = {
"index": previous.get("index"),
"compilable": iscompilable,
"error": error,
"compiled": compiled,
}
self._remove_compiled(regex)
if iscompilable:
self._add_compiled(newregex)
if was_marked:
self.mark(newregex)
def save_to_xml(self, outfile):
"""Create a XML file that can be used by load_from_xml.
outfile can be a file object or a filename.
"""
root = ET.Element("exclude_list")
# reversed in order to keep order of entries when reloading from xml later
reversed_list = []
for key in ordered_keys(self._excluded):
reversed_list.append(key)
for item in reversed(reversed_list):
exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", str(item))
exclude_node.set("marked", ("y" if self.is_marked(item) else "n"))
tree = ET.ElementTree(root)
with FileOrPath(outfile, "wb") as fp:
tree.write(fp, encoding="utf-8")
def ordered_keys(_dict):
"""Returns an iterator over the keys of dictionary sorted by "index" key"""
if not len(_dict):
return
list_of_items = []
for item in _dict.items():
list_of_items.append(item)
list_of_items.sort(key=lambda x: x[1].get("index"))
for item in list_of_items:
yield item[0]
if ISWINDOWS:
def has_sep(regexp):
return "\\" + sep in regexp
else:
def has_sep(regexp):
return sep in regexp

View File

@ -114,36 +114,38 @@ ROW_TEMPLATE = """
CELL_TEMPLATE = """<td>{value}</td>"""
def export_to_xhtml(colnames, rows):
# a row is a list of values with the first value being a flag indicating if the row should be indented
if rows:
assert len(rows[0]) == len(colnames) + 1 # + 1 is for the "indented" flag
colheaders = ''.join(COLHEADERS_TEMPLATE.format(name=name) for name in colnames)
assert len(rows[0]) == len(colnames) + 1 # + 1 is for the "indented" flag
colheaders = "".join(COLHEADERS_TEMPLATE.format(name=name) for name in colnames)
rendered_rows = []
previous_group_id = None
for row in rows:
# [2:] is to remove the indented flag + filename
if row[0] != previous_group_id:
# We've just changed dupe group, which means that this dupe is a ref. We don't indent it.
indented = ''
indented = ""
else:
indented = 'indented'
indented = "indented"
filename = row[1]
cells = ''.join(CELL_TEMPLATE.format(value=value) for value in row[2:])
cells = "".join(CELL_TEMPLATE.format(value=value) for value in row[2:])
rendered_rows.append(ROW_TEMPLATE.format(indented=indented, filename=filename, cells=cells))
previous_group_id = row[0]
rendered_rows = ''.join(rendered_rows)
rendered_rows = "".join(rendered_rows)
# The main template can't use format because the css code uses {}
content = MAIN_TEMPLATE.replace('$colheaders', colheaders).replace('$rows', rendered_rows)
content = MAIN_TEMPLATE.replace("$colheaders", colheaders).replace("$rows", rendered_rows)
folder = mkdtemp()
destpath = op.join(folder, 'export.htm')
fp = open(destpath, 'wt', encoding='utf-8')
destpath = op.join(folder, "export.htm")
fp = open(destpath, "wt", encoding="utf-8")
fp.write(content)
fp.close()
return destpath
def export_to_csv(dest, colnames, rows):
writer = csv.writer(open(dest, 'wt', encoding='utf-8'))
writer = csv.writer(open(dest, "wt", encoding="utf-8"))
writer.writerow(["Group ID"] + colnames)
for row in rows:
writer.writerow(row)

View File

@ -11,25 +11,54 @@
# resulting needless complexity and memory usage. It's been a while since I wanted to do that fork,
# and I'm doing it now.
import hashlib
import logging
import os
from math import floor
import logging
import sqlite3
from sys import platform
from threading import Lock
from typing import Any, AnyStr, Union, Callable
from pathlib import Path
from hscommon.util import nonone, get_file_ext
hasher: Callable
try:
import xxhash
hasher = xxhash.xxh128
except ImportError:
import hashlib
hasher = hashlib.md5
__all__ = [
'File',
'Folder',
'get_file',
'get_files',
'FSError',
'AlreadyExistsError',
'InvalidPath',
'InvalidDestinationError',
'OperationError',
"File",
"Folder",
"get_file",
"get_files",
"FSError",
"AlreadyExistsError",
"InvalidPath",
"InvalidDestinationError",
"OperationError",
]
NOT_SET = object()
# The goal here is to not run out of memory on really big files. However, the chunk
# size has to be large enough so that the python loop isn't too costly in terms of
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 MiB
# Minimum size below which partial hashing is not used
MIN_FILE_SIZE = 3 * CHUNK_SIZE # 3MiB, because we take 3 samples
# Partial hashing offset and size
PARTIAL_OFFSET_SIZE = (0x4000, 0x4000)
class FSError(Exception):
cls_message = "An error has occured on '{name}' in '{parent}'"
@ -40,8 +69,8 @@ class FSError(Exception):
elif isinstance(fsobject, File):
name = fsobject.name
else:
name = ''
parentname = str(parent) if parent is not None else ''
name = ""
parentname = str(parent) if parent is not None else ""
Exception.__init__(self, message.format(name=name, parent=parentname))
@ -49,40 +78,150 @@ class AlreadyExistsError(FSError):
"The directory or file name we're trying to add already exists"
cls_message = "'{name}' already exists in '{parent}'"
class InvalidPath(FSError):
"The path of self is invalid, and cannot be worked with."
cls_message = "'{name}' is invalid."
class InvalidDestinationError(FSError):
"""A copy/move operation has been called, but the destination is invalid."""
cls_message = "'{name}' is an invalid destination for this operation."
class OperationError(FSError):
"""A copy/move/delete operation has been called, but the checkup after the
operation shows that it didn't work."""
cls_message = "Operation on '{name}' failed."
class File:
"""Represents a file and holds metadata to be used for scanning.
class FilesDB:
schema_version = 1
schema_version_description = "Changed from md5 to xxhash if available."
create_table_query = """CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER,
entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"""
drop_table_query = "DROP TABLE IF EXISTS files;"
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
select_query_ignore_mtime = "SELECT {key} FROM files WHERE path=:path AND size=:size"
insert_query = """
INSERT INTO files (path, size, mtime_ns, entry_dt, {key})
VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value;
"""
INITIAL_INFO = {
'size': 0,
'mtime': 0,
'md5': '',
'md5partial': '',
}
ignore_mtime = False
def __init__(self):
self.conn = None
self.lock = None
def connect(self, path: Union[AnyStr, os.PathLike]) -> None:
if platform.startswith("gnu0"):
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
else:
self.conn = sqlite3.connect(path, check_same_thread=False)
self.lock = Lock()
self._check_upgrade()
def _check_upgrade(self) -> None:
with self.lock, self.conn as conn:
has_schema = conn.execute(
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
).fetchall()
version = None
if has_schema:
version = conn.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
else:
conn.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
if version != self.schema_version:
conn.execute(self.drop_table_query)
conn.execute(
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
{"version": self.schema_version, "description": self.schema_version_description},
)
conn.execute(self.create_table_query)
def clear(self) -> None:
with self.lock, self.conn as conn:
conn.execute(self.drop_table_query)
conn.execute(self.create_table_query)
def get(self, path: Path, key: str) -> Union[bytes, None]:
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.conn as conn:
if self.ignore_mtime:
cursor = conn.execute(
self.select_query_ignore_mtime.format(key=key), {"path": str(path), "size": size}
)
else:
cursor = conn.execute(
self.select_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns},
)
result = cursor.fetchone()
cursor.close()
if result:
return result[0]
except Exception as ex:
logging.warning(f"Couldn't get {key} for {path} w/{size}, {mtime_ns}: {ex}")
return None
def put(self, path: Path, key: str, value: Any) -> None:
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
try:
with self.lock, self.conn as conn:
conn.execute(
self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
)
except Exception as ex:
logging.warning(f"Couldn't put {key} for {path} w/{size}, {mtime_ns}: {ex}")
def commit(self) -> None:
with self.lock:
self.conn.commit()
def close(self) -> None:
with self.lock:
self.conn.close()
filesdb = FilesDB() # Singleton
class File:
"""Represents a file and holds metadata to be used for scanning."""
INITIAL_INFO = {"size": 0, "mtime": 0, "digest": b"", "digest_partial": b"", "digest_samples": b""}
# Slots for File make us save quite a bit of memory. In a memory test I've made with a lot of
# files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become
# even greater when we take into account read attributes (70%!). Yeah, it's worth it.
__slots__ = ('path', 'is_ref', 'words') + tuple(INITIAL_INFO.keys())
__slots__ = ("path", "unicode_path", "is_ref", "words") + tuple(INITIAL_INFO.keys())
def __init__(self, path):
self.path = path
for attrname in self.INITIAL_INFO:
setattr(self, attrname, NOT_SET)
if type(path) is os.DirEntry:
self.path = Path(path.path)
self.size = nonone(path.stat().st_size, 0)
self.mtime = nonone(path.stat().st_mtime, 0)
else:
self.path = path
if self.path:
self.unicode_path = str(self.path)
def __repr__(self):
return "<{} {}>".format(self.__class__.__name__, str(self.path))
return f"<{self.__class__.__name__} {str(self.path)}>"
def __getattribute__(self, attrname):
result = object.__getattribute__(self, attrname)
@ -96,43 +235,77 @@ class File:
result = self.INITIAL_INFO[attrname]
return result
#This offset is where we should start reading the file to get a partial md5
#For audio file, it should be where audio data starts
def _get_md5partial_offset_and_size(self):
return (0x4000, 0x4000) #16Kb
def _calc_digest(self):
# type: () -> bytes
with self.path.open("rb") as fp:
file_hash = hasher()
# The goal here is to not run out of memory on really big files. However, the chunk
# size has to be large enough so that the python loop isn't too costly in terms of
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 mb
filedata = fp.read(CHUNK_SIZE)
while filedata:
file_hash.update(filedata)
filedata = fp.read(CHUNK_SIZE)
return file_hash.digest()
def _calc_digest_partial(self):
# type: () -> bytes
with self.path.open("rb") as fp:
fp.seek(PARTIAL_OFFSET_SIZE[0])
partial_data = fp.read(PARTIAL_OFFSET_SIZE[1])
return hasher(partial_data).digest()
def _calc_digest_samples(self) -> bytes:
size = self.size
with self.path.open("rb") as fp:
# Chunk at 25% of the file
fp.seek(floor(size * 25 / 100), 0)
file_data = fp.read(CHUNK_SIZE)
file_hash = hasher(file_data)
# Chunk at 60% of the file
fp.seek(floor(size * 60 / 100), 0)
file_data = fp.read(CHUNK_SIZE)
file_hash.update(file_data)
# Last chunk of the file
fp.seek(-CHUNK_SIZE, 2)
file_data = fp.read(CHUNK_SIZE)
file_hash.update(file_data)
return file_hash.digest()
def _read_info(self, field):
if field in ('size', 'mtime'):
# print(f"_read_info({field}) for {self}")
if field in ("size", "mtime"):
stats = self.path.stat()
self.size = nonone(stats.st_size, 0)
self.mtime = nonone(stats.st_mtime, 0)
elif field == 'md5partial':
try:
fp = self.path.open('rb')
offset, size = self._get_md5partial_offset_and_size()
fp.seek(offset)
partialdata = fp.read(size)
md5 = hashlib.md5(partialdata)
self.md5partial = md5.digest()
fp.close()
except Exception:
pass
elif field == 'md5':
try:
fp = self.path.open('rb')
md5 = hashlib.md5()
# The goal here is to not run out of memory on really big files. However, the chunk
# size has to be large enough so that the python loop isn't too costly in terms of
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 mb
filedata = fp.read(CHUNK_SIZE)
while filedata:
md5.update(filedata)
filedata = fp.read(CHUNK_SIZE)
self.md5 = md5.digest()
fp.close()
except Exception:
pass
elif field == "digest_partial":
self.digest_partial = filesdb.get(self.path, "digest_partial")
if self.digest_partial is None:
# If file is smaller than partial requirements just use the full digest
if self.size < PARTIAL_OFFSET_SIZE[0] + PARTIAL_OFFSET_SIZE[1]:
self.digest_partial = self.digest
else:
self.digest_partial = self._calc_digest_partial()
filesdb.put(self.path, "digest_partial", self.digest_partial)
elif field == "digest":
self.digest = filesdb.get(self.path, "digest")
if self.digest is None:
self.digest = self._calc_digest()
filesdb.put(self.path, "digest", self.digest)
elif field == "digest_samples":
size = self.size
# Might as well hash such small files entirely.
if size <= MIN_FILE_SIZE:
self.digest_samples = self.digest
return
self.digest_samples = filesdb.get(self.path, "digest_samples")
if self.digest_samples is None:
self.digest_samples = self._calc_digest_samples()
filesdb.put(self.path, "digest_samples", self.digest_samples)
def _read_all_info(self, attrnames=None):
"""Cache all possible info.
@ -144,33 +317,39 @@ class File:
for attrname in attrnames:
getattr(self, attrname)
#--- Public
# --- Public
@classmethod
def can_handle(cls, path):
"""Returns whether this file wrapper class can handle ``path``.
"""
return not path.islink() and path.isfile()
"""Returns whether this file wrapper class can handle ``path``."""
return not path.is_symlink() and path.is_file()
def exists(self) -> bool:
"""Safely check if the underlying file exists, treat error as non-existent"""
try:
return self.path.exists()
except OSError as ex:
logging.warning(f"Checking {self.path} raised: {ex}")
return False
def rename(self, newname):
if newname == self.name:
return
destpath = self.path.parent()[newname]
destpath = self.path.parent.joinpath(newname)
if destpath.exists():
raise AlreadyExistsError(newname, self.path.parent())
raise AlreadyExistsError(newname, self.path.parent)
try:
self.path.rename(destpath)
except EnvironmentError:
except OSError:
raise OperationError(self)
if not destpath.exists():
raise OperationError(self)
self.path = destpath
def get_display_info(self, group, delta):
"""Returns a display-ready dict of dupe's data.
"""
"""Returns a display-ready dict of dupe's data."""
raise NotImplementedError()
#--- Properties
# --- Properties
@property
def extension(self):
return get_file_ext(self.name)
@ -181,18 +360,20 @@ class File:
@property
def folder_path(self):
return self.path.parent()
return self.path.parent
class Folder(File):
"""A wrapper around a folder path.
It has the size/md5 info of a File, but it's value are the sum of its subitems.
It has the size/digest info of a File, but its value is the sum of its subitems.
"""
__slots__ = File.__slots__ + ('_subfolders', )
__slots__ = File.__slots__ + ("_subfolders",)
def __init__(self, path):
File.__init__(self, path)
self.size = NOT_SET
self._subfolders = None
def _all_items(self):
@ -201,35 +382,37 @@ class Folder(File):
return folders + files
def _read_info(self, field):
if field in {'size', 'mtime'}:
# print(f"_read_info({field}) for Folder {self}")
if field in {"size", "mtime"}:
size = sum((f.size for f in self._all_items()), 0)
self.size = size
stats = self.path.stat()
self.mtime = nonone(stats.st_mtime, 0)
elif field in {'md5', 'md5partial'}:
elif field in {"digest", "digest_partial", "digest_samples"}:
# What's sensitive here is that we must make sure that subfiles'
# md5 are always added up in the same order, but we also want a
# different md5 if a file gets moved in a different subdirectory.
def get_dir_md5_concat():
# digest are always added up in the same order, but we also want a
# different digest if a file gets moved in a different subdirectory.
def get_dir_digest_concat():
items = self._all_items()
items.sort(key=lambda f: f.path)
md5s = [getattr(f, field) for f in items]
return b''.join(md5s)
digests = [getattr(f, field) for f in items]
return b"".join(digests)
md5 = hashlib.md5(get_dir_md5_concat())
digest = md5.digest()
digest = hasher(get_dir_digest_concat()).digest()
setattr(self, field, digest)
@property
def subfolders(self):
if self._subfolders is None:
subfolders = [p for p in self.path.listdir() if not p.islink() and p.isdir()]
with os.scandir(self.path) as iter:
subfolders = [p for p in iter if not p.is_symlink() and p.is_dir()]
self._subfolders = [self.__class__(p) for p in subfolders]
return self._subfolders
@classmethod
def can_handle(cls, path):
return not path.islink() and path.isdir()
return not path.is_symlink() and path.is_dir()
def get_file(path, fileclasses=[File]):
@ -244,6 +427,7 @@ def get_file(path, fileclasses=[File]):
if fileclass.can_handle(path):
return fileclass(path)
def get_files(path, fileclasses=[File]):
"""Returns a list of :class:`File` for each file contained in ``path``.
@ -253,10 +437,11 @@ def get_files(path, fileclasses=[File]):
assert all(issubclass(fileclass, File) for fileclass in fileclasses)
try:
result = []
for path in path.listdir():
file = get_file(path, fileclasses=fileclasses)
if file is not None:
result.append(file)
with os.scandir(path) as iter:
for item in iter:
file = get_file(item, fileclasses=fileclasses)
if file is not None:
result.append(file)
return result
except EnvironmentError:
except OSError:
raise InvalidPath(path)

View File

@ -13,4 +13,3 @@ blue, which is supposed to be orange, does the sorting logic, holds selection, e
.. _cross-toolkit: http://www.hardcoded.net/articles/cross-toolkit-software
"""

View File

@ -8,23 +8,28 @@
from hscommon.notify import Listener
class DupeGuruGUIObject(Listener):
def __init__(self, app):
Listener.__init__(self, app)
self.app = app
def directories_changed(self):
# Implemented in child classes
pass
def dupes_selected(self):
# Implemented in child classes
pass
def marking_changed(self):
# Implemented in child classes
pass
def results_changed(self):
# Implemented in child classes
pass
def results_changed_but_keep_selection(self):
# Implemented in child classes
pass

View File

@ -1,8 +1,8 @@
# Created On: 2012-05-30
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import os
@ -10,42 +10,44 @@ import os
from hscommon.gui.base import GUIObject
from hscommon.trans import tr
class DeletionOptionsView:
"""Expected interface for :class:`DeletionOptions`'s view.
*Not actually used in the code. For documentation purposes only.*
Our view presents the user with an appropriate way (probably a mix of checkboxes and radio
buttons) to set the different flags in :class:`DeletionOptions`. Note that
:attr:`DeletionOptions.use_hardlinks` is only relevant if :attr:`DeletionOptions.link_deleted`
is true. This is why we toggle the "enabled" state of that flag.
We expect the view to set :attr:`DeletionOptions.link_deleted` immediately as the user changes
its value because it will toggle :meth:`set_hardlink_option_enabled`
Other than the flags, there's also a prompt message which has a dynamic content, defined by
:meth:`update_msg`.
"""
def update_msg(self, msg: str):
"""Update the dialog's prompt with ``str``.
"""
"""Update the dialog's prompt with ``str``."""
def show(self):
"""Show the dialog in a modal fashion.
Returns whether the dialog was "accepted" (the user pressed OK).
"""
def set_hardlink_option_enabled(self, is_enabled: bool):
"""Enable or disable the widget controlling :attr:`DeletionOptions.use_hardlinks`.
"""
"""Enable or disable the widget controlling :attr:`DeletionOptions.use_hardlinks`."""
class DeletionOptions(GUIObject):
"""Present the user with deletion options before proceeding.
When the user activates "Send to trash", we present him with a couple of options that changes
the behavior of that deletion operation.
"""
def __init__(self):
GUIObject.__init__(self)
#: Whether symlinks or hardlinks are used when doing :attr:`link_deleted`.
@ -54,10 +56,10 @@ class DeletionOptions(GUIObject):
#: Delete dupes directly and don't send to trash.
#: *bool*. *get/set*
self.direct = False
def show(self, mark_count):
"""Prompt the user with a modal dialog offering our deletion options.
:param int mark_count: Number of dupes marked for deletion.
:rtype: bool
:returns: Whether the user accepted the dialog (we cancel deletion if false).
@ -69,10 +71,9 @@ class DeletionOptions(GUIObject):
msg = tr("You are sending {} file(s) to the Trash.").format(mark_count)
self.view.update_msg(msg)
return self.view.show()
def supports_links(self):
"""Returns whether our platform supports symlinks.
"""
"""Returns whether our platform supports symlinks."""
# When on a platform that doesn't implement it, calling os.symlink() (with the wrong number
# of arguments) raises NotImplementedError, which allows us to gracefully check for the
# feature.
@ -87,21 +88,19 @@ class DeletionOptions(GUIObject):
except TypeError:
# wrong number of arguments
return True
@property
def link_deleted(self):
"""Replace deleted dupes with symlinks (or hardlinks) to the dupe group reference.
*bool*. *get/set*
Whether the link is a symlink or hardlink is decided by :attr:`use_hardlinks`.
"""
return self._link_deleted
@link_deleted.setter
def link_deleted(self, value):
self._link_deleted = value
hardlinks_enabled = value and self.supports_links()
self.view.set_hardlink_option_enabled(hardlinks_enabled)

View File

@ -7,7 +7,8 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.gui.base import GUIObject
from .base import DupeGuruGUIObject
from core.gui.base import DupeGuruGUIObject
class DetailsPanel(GUIObject, DupeGuruGUIObject):
def __init__(self, app):
@ -19,7 +20,7 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject):
self._refresh()
self.view.refresh()
#--- Private
# --- Private
def _refresh(self):
if self.app.selected_dupes:
dupe = self.app.selected_dupes[0]
@ -31,18 +32,16 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject):
# we don't want the two sides of the table to display the stats for the same file
ref = group.ref if group is not None and group.ref is not dupe else None
data2 = self.app.get_display_info(ref, group, False)
columns = self.app.result_table.COLUMNS[1:] # first column is the 'marked' column
columns = self.app.result_table.COLUMNS[1:] # first column is the 'marked' column
self._table = [(c.display, data1[c.name], data2[c.name]) for c in columns]
#--- Public
# --- Public
def row_count(self):
return len(self._table)
def row(self, row_index):
return self._table[row_index]
#--- Event Handlers
# --- Event Handlers
def dupes_selected(self):
self._refresh()
self.view.refresh()
self._view_updated()

View File

@ -1,17 +1,18 @@
# Created By: Virgil Dupras
# Created On: 2010-02-06
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.gui.tree import Tree, Node
from ..directories import DirectoryState
from .base import DupeGuruGUIObject
from core.directories import DirectoryState
from core.gui.base import DupeGuruGUIObject
STATE_ORDER = [DirectoryState.NORMAL, DirectoryState.REFERENCE, DirectoryState.EXCLUDED]
STATE_ORDER = [DirectoryState.Normal, DirectoryState.Reference, DirectoryState.Excluded]
# Lazily loads children
class DirectoryNode(Node):
@ -21,29 +22,29 @@ class DirectoryNode(Node):
self._directory_path = path
self._loaded = False
self._state = STATE_ORDER.index(self._tree.app.directories.get_state(path))
def __len__(self):
if not self._loaded:
self._load()
return Node.__len__(self)
def _load(self):
self.clear()
subpaths = self._tree.app.directories.get_subfolders(self._directory_path)
for path in subpaths:
self.append(DirectoryNode(self._tree, path, path.name))
self._loaded = True
def update_all_states(self):
self._state = STATE_ORDER.index(self._tree.app.directories.get_state(self._directory_path))
for node in self:
node.update_all_states()
# The state propery is an index to the combobox
@property
def state(self):
return self._state
@state.setter
def state(self, value):
if value == self._state:
@ -52,29 +53,29 @@ class DirectoryNode(Node):
state = STATE_ORDER[value]
self._tree.app.directories.set_state(self._directory_path, state)
self._tree.update_all_states()
class DirectoryTree(Tree, DupeGuruGUIObject):
#--- model -> view calls:
# --- model -> view calls:
# refresh()
# refresh_states() # when only states label need to be refreshed
#
def __init__(self, app):
Tree.__init__(self)
DupeGuruGUIObject.__init__(self, app)
def _view_updated(self):
self._refresh()
self.view.refresh()
def _refresh(self):
self.clear()
for path in self.app.directories:
self.append(DirectoryNode(self, path, str(path)))
def add_directory(self, path):
self.app.add_directory(path)
def remove_selected(self):
selected_paths = self.selected_paths
if not selected_paths:
@ -85,23 +86,21 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
else:
# All selected nodes or on second-or-more level, exclude them.
nodes = self.selected_nodes
newstate = DirectoryState.Excluded
if all(node.state == DirectoryState.Excluded for node in nodes):
newstate = DirectoryState.Normal
newstate = DirectoryState.EXCLUDED
if all(node.state == DirectoryState.EXCLUDED for node in nodes):
newstate = DirectoryState.NORMAL
for node in nodes:
node.state = newstate
def select_all(self):
self.selected_nodes = list(self)
self.view.refresh()
def update_all_states(self):
for node in self:
node.update_all_states()
self.view.refresh_states()
#--- Event Handlers
# --- Event Handlers
def directories_changed(self):
self._refresh()
self.view.refresh()
self._view_updated()

View File

@ -0,0 +1,90 @@
# Created On: 2012/03/13
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from core.gui.exclude_list_table import ExcludeListTable
from core.exclude import has_sep
from os import sep
import logging
class ExcludeListDialogCore:
def __init__(self, app):
self.app = app
self.exclude_list = self.app.exclude_list # Markable from exclude.py
self.exclude_list_table = ExcludeListTable(self, app) # GUITable, this is the "model"
def restore_defaults(self):
self.exclude_list.restore_defaults()
self.refresh()
def refresh(self):
self.exclude_list_table.refresh()
def remove_selected(self):
for row in self.exclude_list_table.selected_rows:
self.exclude_list_table.remove(row)
self.exclude_list.remove(row.regex)
self.refresh()
def rename_selected(self, newregex):
"""Rename the selected regex to ``newregex``.
If there is more than one selected row, the first one is used.
:param str newregex: The regex to rename the row's regex to.
:return bool: true if success, false if error.
"""
try:
r = self.exclude_list_table.selected_rows[0]
self.exclude_list.rename(r.regex, newregex)
self.refresh()
return True
except Exception as e:
logging.warning(f"Error while renaming regex to {newregex}: {e}")
return False
def add(self, regex):
self.exclude_list.add(regex)
self.exclude_list.mark(regex)
self.exclude_list_table.add(regex)
def test_string(self, test_string):
"""Set the highlight property on each row when its regex matches the
test_string supplied. Return True if any row matched."""
matched = False
for row in self.exclude_list_table.rows:
compiled_regex = self.exclude_list.get_compiled(row.regex)
if self.is_match(test_string, compiled_regex):
row.highlight = True
matched = True
else:
row.highlight = False
return matched
def is_match(self, test_string, compiled_regex):
# This method is like an inverted version of ExcludeList.is_excluded()
if not compiled_regex:
return False
matched = False
# Test only the filename portion of the path
if not has_sep(compiled_regex.pattern) and sep in test_string:
filename = test_string.rsplit(sep, 1)[1]
if compiled_regex.fullmatch(filename):
matched = True
return matched
# Test the entire path + filename
if compiled_regex.fullmatch(test_string):
matched = True
return matched
def reset_rows_highlight(self):
for row in self.exclude_list_table.rows:
row.highlight = False
def show(self):
self.view.show()

View File

@ -0,0 +1,96 @@
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from core.gui.base import DupeGuruGUIObject
from hscommon.gui.table import GUITable, Row
from hscommon.gui.column import Column, Columns
from hscommon.trans import trget
tr = trget("ui")
class ExcludeListTable(GUITable, DupeGuruGUIObject):
COLUMNS = [Column("marked", ""), Column("regex", tr("Regular Expressions"))]
def __init__(self, exclude_list_dialog, app):
GUITable.__init__(self)
DupeGuruGUIObject.__init__(self, app)
self._columns = Columns(self)
self.dialog = exclude_list_dialog
def rename_selected(self, newname):
row = self.selected_row
if row is None:
return False
row._data = None
return self.dialog.rename_selected(newname)
# --- Virtual
def _do_add(self, regex):
"""(Virtual) Creates a new row, adds it in the table.
Returns ``(row, insert_index)``."""
# Return index 0 to insert at the top
return ExcludeListRow(self, self.dialog.exclude_list.is_marked(regex), regex), 0
def _do_delete(self):
self.dialog.exclude_list.remove(self.selected_row.regex)
# --- Override
def add(self, regex):
row, insert_index = self._do_add(regex)
self.insert(insert_index, row)
self.view.refresh()
def _fill(self):
for enabled, regex in self.dialog.exclude_list:
self.append(ExcludeListRow(self, enabled, regex))
def refresh(self, refresh_view=True):
"""Override to avoid keeping previous selection in case of multiple rows
selected previously."""
self.cancel_edits()
del self[:]
self._fill()
if refresh_view:
self.view.refresh()
class ExcludeListRow(Row):
def __init__(self, table, enabled, regex):
Row.__init__(self, table)
self._app = table.app
self._data = None
self.enabled = str(enabled)
self.regex = str(regex)
self.highlight = False
@property
def data(self):
if self._data is None:
self._data = {"marked": self.enabled, "regex": self.regex}
return self._data
@property
def markable(self):
return self._app.exclude_list.is_markable(self.regex)
@property
def marked(self):
return self._app.exclude_list.is_marked(self.regex)
@marked.setter
def marked(self, value):
if value:
self._app.exclude_list.mark(self.regex)
else:
self._app.exclude_list.unmark(self.regex)
@property
def error(self):
# This assumes error() returns an Exception()
message = self._app.exclude_list.error(self.regex)
if hasattr(message, "msg"):
return self._app.exclude_list.error(self.regex).msg
else:
return message # Exception object

View File

@ -6,24 +6,25 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.trans import tr
from .ignore_list_table import IgnoreListTable
from core.gui.ignore_list_table import IgnoreListTable
class IgnoreListDialog:
#--- View interface
# --- View interface
# show()
#
def __init__(self, app):
self.app = app
self.ignore_list = self.app.ignore_list
self.ignore_list_table = IgnoreListTable(self)
self.ignore_list_table = IgnoreListTable(self) # GUITable
def clear(self):
if not self.ignore_list:
return
msg = tr("Do you really want to remove all %d items from the ignore list?") % len(self.ignore_list)
if self.app.view.ask_yes_no(msg):
self.ignore_list.Clear()
self.ignore_list.clear()
self.refresh()
def refresh(self):
@ -36,4 +37,3 @@ class IgnoreListDialog:
def show(self):
self.view.show()

View File

@ -1,35 +1,36 @@
# Created By: Virgil Dupras
# Created On: 2012-03-13
# Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from hscommon.gui.table import GUITable, Row
from hscommon.gui.column import Column, Columns
from hscommon.trans import trget
coltr = trget('columns')
coltr = trget("columns")
class IgnoreListTable(GUITable):
COLUMNS = [
# the str concat below saves us needless localization.
Column('path1', coltr("File Path") + " 1"),
Column('path2', coltr("File Path") + " 2"),
Column("path1", coltr("File Path") + " 1"),
Column("path2", coltr("File Path") + " 2"),
]
def __init__(self, ignore_list_dialog):
GUITable.__init__(self)
self.columns = Columns(self)
self._columns = Columns(self)
self.view = None
self.dialog = ignore_list_dialog
#--- Override
# --- Override
def _fill(self):
for path1, path2 in self.dialog.ignore_list:
self.append(IgnoreListRow(self, path1, path2))
class IgnoreListRow(Row):
def __init__(self, table, path1, path2):
@ -38,4 +39,3 @@ class IgnoreListRow(Row):
self.path2_original = path2
self.path1 = str(path1)
self.path2 = str(path2)

Some files were not shown because too many files have changed in this diff Show More