1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-03-12 03:31:37 +00:00

Compare commits

...

200 Commits

Author SHA1 Message Date
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
fd0adc77b3 Update Readme notes for system setup 2021-01-06 12:22:15 -06:00
6a03e1e399 Update URLs 2021-01-05 23:21:44 -06:00
ae51842007 Update README.md 2021-01-05 23:04:42 -06:00
ab6acd9e88 Merge pull request #733 from glubsy/dev
Increment version to 4.1.0
2021-01-05 22:48:21 -06:00
6a2c1eb293 Fix flake8 issues introduced in package.py 2020-12-30 20:04:14 -06:00
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
701e6d4bb2 Merge pull request #755 from glubsy/packaging
Fix Debian packaging issues
2020-12-30 14:41:34 -06:00
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
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
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
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
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
e822a67b38 Force correct python environment for tox on windows 2020-12-28 21:18:16 -06:00
c30c3400d4 Fix typo in .travis.yml 2020-12-28 21:07:49 -06:00
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
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
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
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
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
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
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
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
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
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
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
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
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
4f252480d3 Fix pywin32 dependency 2020-06-30 00:52:04 -05:00
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
ee2671a5f3 More Test and Flake8 Cleanup
- Allow flake8 to check more files as well.
2020-06-27 01:08:12 -05:00
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
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
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
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
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
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
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
1c00331bc2 Remove Old Issue Template 2020-06-15 23:28:31 -05:00
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
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
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
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
94 changed files with 4789 additions and 495 deletions

View File

@@ -1,24 +0,0 @@
# Instructions
1. Provide a short descriptive title for the issue. A good example is 'Results window appears off screen.', a non-optimal example is 'Problem with App'.
2. Please fill out either the 'Bug / Issue' or the 'Feature Request' section. Replace values in ` `.
3. Delete these instructions and the unused sections.
# Bug / issue Report
System Information:
- DupeGuru Version: `version`
- Operating System: `Windows/Linux/OSX` `distribution` `version`
If using the source distribution and building yourself also provide (otherwise remove):
- Python Version: `version ex. 3.6.6` `32/64bit`
- Complier: `gcc/llvm/msvc` `version`
## Description
`Provide a detailed description of the issue to help reproduce it. If it happens after a specific sequence of events provide them here.`
## Debug Log
```
If reporting an error provide the debug log and/or the error message information. If the debug log is short < 40 lines you can provide it here, otherwise attach the text file to this issue.
```
# Feature Requests
`Provide a detailed description of the feature.`

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.

4
.gitignore vendored
View File

@@ -22,4 +22,6 @@ cocoa/autogen
*.pyd *.pyd
*.exe *.exe
*.spec *.spec
.vscode

View File

@@ -12,18 +12,16 @@ matrix:
dist: "xenial" dist: "xenial"
python: "3.7" python: "3.7"
- os: "linux" - os: "linux"
dist: "xenial" dist: "focal"
python: "3.8" python: "3.8"
- os: "linux"
dist: "focal"
python: "3.9"
- os: "windows" - os: "windows"
language: shell language: shell
python: "3.7" python: "3.8"
env: "PATH=/c/python37:/c/python37/Scripts:$PATH" env: "PATH=/c/python38:/c/python38/Scripts:$PATH"
before_install: before_install:
- choco install python --version=3.7.6 - choco install python --version=3.8.6
- choco install make - cp /c/python38/python.exe /c/python38/python3.exe
- cp /c/python37/python.exe /c/python37/python3.exe script: tox -e py38
before_script:
- pip3 install -r requirements-windows.txt
- python3 build.py
script:
- tox -e WINDOWS

View File

@@ -1,6 +1,8 @@
To know who contributed to dupeGuru, you can look at the commit log, but not all contributions 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. 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 * Jérôme Cantin, Main icon
* Gregor Tätzner, German localization * Gregor Tätzner, German localization
* Frank Weber, German localization * Frank Weber, German localization

View File

@@ -1,7 +1,7 @@
PYTHON ?= python3 PYTHON ?= python3
PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)") PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)")
PYRCC5 ?= pyrcc5 PYRCC5 ?= pyrcc5
REQ_MINOR_VERSION = 4 REQ_MINOR_VERSION = 6
PREFIX ?= /usr/local PREFIX ?= /usr/local
# Window compatability via Msys2 # Window compatability via Msys2
@@ -15,7 +15,7 @@ ifeq ($(shell ${PYTHON} -c "import platform; print(platform.system())"), Windows
VENV_OPTIONS = VENV_OPTIONS =
else else
BIN = bin BIN = bin
SO = cpython-3$(PYTHON_VERSION_MINOR)*.so SO = *.so
VENV_OPTIONS = --system-site-packages VENV_OPTIONS = --system-site-packages
endif endif
@@ -43,16 +43,16 @@ mofiles = $(patsubst %.po,%.mo,$(pofiles))
vpath %.po $(localedirs) vpath %.po $(localedirs)
vpath %.mo $(localedirs) vpath %.mo $(localedirs)
all : | env i18n modules qt/dg_rc.py all: | env i18n modules qt/dg_rc.py
@echo "Build complete! You can run dupeGuru with 'make run'" @echo "Build complete! You can run dupeGuru with 'make run'"
run: run:
$(VENV_PYTHON) run.py $(VENV_PYTHON) run.py
pyc: pyc: | env
${PYTHON} -m compileall ${packages} ${VENV_PYTHON} -m compileall ${packages}
reqs : reqs:
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -gt $(REQ_MINOR_VERSION); echo $$?),0) ifneq ($(shell test $(PYTHON_VERSION_MINOR) -gt $(REQ_MINOR_VERSION); echo $$?),0)
$(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.") $(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.")
endif endif
@@ -63,7 +63,7 @@ endif
@${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \ @${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \
{ echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; } { echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
env : | reqs env: | reqs
ifndef NO_VENV ifndef NO_VENV
@echo "Creating our virtualenv" @echo "Creating our virtualenv"
${PYTHON} -m venv env ${PYTHON} -m venv env
@@ -73,40 +73,26 @@ ifndef NO_VENV
${PYTHON} -m venv --upgrade ${VENV_OPTIONS} env ${PYTHON} -m venv --upgrade ${VENV_OPTIONS} env
endif endif
build/help : | env build/help: | env
$(VENV_PYTHON) build.py --doc $(VENV_PYTHON) build.py --doc
qt/dg_rc.py : qt/dg.qrc qt/dg_rc.py: qt/dg.qrc
$(PYRCC5) qt/dg.qrc > qt/dg_rc.py $(PYRCC5) qt/dg.qrc > qt/dg_rc.py
i18n: $(mofiles) i18n: $(mofiles)
%.mo : %.po %.mo: %.po
msgfmt -o $@ $< msgfmt -o $@ $<
core/pe/_block.$(SO) : core/pe/modules/block.c core/pe/modules/common.c modules: | env
$(PYTHON) hscommon/build_ext.py $^ _block $(VENV_PYTHON) build.py --modules
mv _block.$(SO) core/pe
core/pe/_cache.$(SO) : core/pe/modules/cache.c core/pe/modules/common.c mergepot: | env
$(PYTHON) hscommon/build_ext.py $^ _cache
mv _cache.$(SO) core/pe
qt/pe/_block_qt.$(SO) : qt/pe/modules/block.c
$(PYTHON) hscommon/build_ext.py $^ _block_qt
mv _block_qt.$(SO) qt/pe
modules : core/pe/_block.$(SO) core/pe/_cache.$(SO) qt/pe/_block_qt.$(SO)
mergepot :
$(VENV_PYTHON) build.py --mergepot $(VENV_PYTHON) build.py --mergepot
normpo : normpo: | env
$(VENV_PYTHON) build.py --normpo $(VENV_PYTHON) build.py --normpo
srcpkg :
./scripts/srcpkg.sh
install: all pyc install: all pyc
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
cp -rf ${packages} locale ${DESTDIR}${PREFIX}/share/dupeguru cp -rf ${packages} locale ${DESTDIR}${PREFIX}/share/dupeguru
@@ -123,7 +109,7 @@ installdocs: build/help
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
cp -rf build/help ${DESTDIR}${PREFIX}/share/dupeguru cp -rf build/help ${DESTDIR}${PREFIX}/share/dupeguru
uninstall : uninstall:
rm -rf "${DESTDIR}${PREFIX}/share/dupeguru" rm -rf "${DESTDIR}${PREFIX}/share/dupeguru"
rm -f "${DESTDIR}${PREFIX}/bin/dupeguru" rm -f "${DESTDIR}${PREFIX}/bin/dupeguru"
rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop" rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop"
@@ -134,4 +120,4 @@ clean:
-rm locale/*/LC_MESSAGES/*.mo -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

View File

@@ -1,19 +1,21 @@
# dupeGuru # dupeGuru
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in [dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
a system. It's written mostly in Python 3 and has the peculiarity of using a system. It is 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 [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. is written in Objective-C and uses Cocoa. On Linux, it is written in Python and uses Qt5.
The Cocoa UI of dupeGuru is hosted in a separate repo: https://github.com/hsoft/dupeguru-cocoa The Cocoa UI of dupeGuru is hosted in a separate repo: https://github.com/arsenetar/dupeguru-cocoa
## Current status ## Current status
Development has been slow this past year, however very close to getting all the different 4.0.4 releases posted. Most of the work this past year (2019) has been towards packaging the application and issues related to that. 2020: various bug fixes and small UI improvements have been added. Packaging for MacOS is still a problem.
Still looking for additional help especially with regards to: Still looking for additional help especially with regards to:
- OSX maintenance (reproducing bugs & cocoa version) * OSX maintenance: reproducing bugs & cocoa version, building package with Cocoa UI.
- Linux maintenance (reproducing bugs) * Linux maintenance: reproducing bugs, maintaining PPA repository, Debian package.
* Translations: updating missing strings.
* Documentation: keeping it up-to-date.
## Contents of this folder ## Contents of this folder
@@ -31,26 +33,57 @@ This folder contains the source for dupeGuru. Its documentation is in `help`, bu
## How to build dupeGuru from source ## How to build dupeGuru from source
### Windows ### Windows & macOS specific additional instructions
For windows instructions see the [Windows Instructions](Windows.md). For windows instructions see the [Windows Instructions](Windows.md).
### Prerequisites For macos instructions (qt version) see the [macOS Instructions](macos.md).
* [Python 3.5+][python] ### Prerequisites
* [Python 3.6+][python]
* PyQt5 * PyQt5
### make ### System Setup
When running in a linux based environment the following system packages or equivalents are needed to build:
* python3-pyqt5
* python3-wheel (for hsaudiotag3k)
* python3-venv (only if using a virtual environment)
* python3-dev
* build-essential
dupeGuru is built with "make": To create packages the following are also needed:
* python3-setuptools
* debhelper
$ make ### Building with Make
$ make run dupeGuru comes with a makefile that can be used to build and run:
### Generate Debian/Ubuntu package $ make && make run
$ bash -c "python3 -m venv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt && python3 build.py --clean && python3 package.py" ### Building without Make
### Running tests $ cd <dupeGuru directory>
$ python3 -m venv --system-site-packages ./env
$ source ./env/bin/activate
$ pip install -r requirements.txt
$ python build.py
$ python run.py
### 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:
$ 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"
## Running tests
The complete test suite is run 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`. don't even need to set up a virtualenv. Just `cd` into the root project folder and run `tox`.

View File

@@ -2,26 +2,26 @@
### Prerequisites ### Prerequisites
- [Python 3.5+][python] - [Python 3.6+][python]
- [Visual Studio 2017][vs] or [Visual Studio Build Tools 2017][vsBuildTools] with the Windows 10 SDK - [Visual Studio 2019][vs] or [Visual Studio Build Tools 2019][vsBuildTools] with the Windows 10 SDK
- [nsis][nsis] (for installer creation) - [nsis][nsis] (for installer creation)
- [msys2][msys2] (for using makefile method) - [msys2][msys2] (for using makefile method)
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]. 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.7): 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.7 -m pip install --upgrade setuptools $ 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] 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) ### With build.py (preferred)
To build with a different python version 3.5 vs 3.7 or 32 bit vs 64 bit specify that version instead of -3.7 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each vritual environment. To build with a different python version 3.6 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> $ cd <dupeGuru directory>
$ py -3.7 -m venv .\env $ py -3.8 -m venv .\env
$ .\env\Scripts\activate $ .\env\Scripts\activate
$ pip install -r requirements.txt -r requirements-windows.txt $ pip install -r requirements.txt
$ python build.py $ python build.py
$ python run.py $ python run.py
@@ -34,23 +34,21 @@ It is possible to build dupeGuru with the makefile on windows using a compatable
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. 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> $ cd <dupeGuru directory>
$ make PYTHON='py -3.7' $ make PYTHON='py -3.8'
$ make run $ make run
NOTE: Install PyQt5 & cx-Freeze with requirements-windows.txt into the venv before running the packaging scripts in the section below.
### Generate Windows Installer Packages ### 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. Run the following in the respective virtual environment. 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 $ python package.py
### Running tests ### Running tests
The complete test suite can be run with tox just like on linux. 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/ [python]: http://www.python.org/
[nsis]: http://nsis.sourceforge.net/Main_Page [nsis]: http://nsis.sourceforge.net/Main_Page
[vs]: https://www.visualstudio.com/downloads/#visual-studio-community-2017 [vs]: https://www.visualstudio.com/downloads/#visual-studio-community-2019
[vsBuildTools]: https://www.visualstudio.com/downloads/#build-tools-for-visual-studio-2017 [vsBuildTools]: https://www.visualstudio.com/downloads/#build-tools-for-visual-studio-2019
[win10sdk]: https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk [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 [KB2999226]: https://support.microsoft.com/en-us/help/2999226/update-for-universal-c-runtime-in-windows
[pythonWindowsCompilers]: https://wiki.python.org/moin/WindowsCompilers [pythonWindowsCompilers]: https://wiki.python.org/moin/WindowsCompilers

View File

@@ -54,6 +54,12 @@ def parse_args():
dest="normpo", dest="normpo",
help="Normalize all PO files (do this before commit).", help="Normalize all PO files (do this before commit).",
) )
parser.add_option(
"--modules",
action="store_true",
dest="modules",
help="Build the python modules.",
)
(options, args) = parser.parse_args() (options, args) = parser.parse_args()
return options return options
@@ -182,6 +188,8 @@ def main():
build_mergepot() build_mergepot()
elif options.normpo: elif options.normpo:
build_normpo() build_normpo()
elif options.modules:
build_pe_modules()
else: else:
build_normal() build_normal()

View File

@@ -1,2 +1,2 @@
__version__ = "4.0.4" __version__ = "4.1.0"
__appname__ = "dupeGuru" __appname__ = "dupeGuru"

View File

@@ -26,11 +26,13 @@ from .pe.photo import get_delta_dimensions
from .util import cmp_value, fix_surrogate_encoding from .util import cmp_value, fix_surrogate_encoding
from . import directories, results, export, fs, prioritize from . import directories, results, export, fs, prioritize
from .ignore import IgnoreList from .ignore import IgnoreList
from .exclude import ExcludeDict as ExcludeList
from .scanner import ScanType from .scanner import ScanType
from .gui.deletion_options import DeletionOptions from .gui.deletion_options import DeletionOptions
from .gui.details_panel import DetailsPanel from .gui.details_panel import DetailsPanel
from .gui.directory_tree import DirectoryTree from .gui.directory_tree import DirectoryTree
from .gui.ignore_list_dialog import IgnoreListDialog from .gui.ignore_list_dialog import IgnoreListDialog
from .gui.exclude_list_dialog import ExcludeListDialogCore
from .gui.problem_dialog import ProblemDialog from .gui.problem_dialog import ProblemDialog
from .gui.stats_label import StatsLabel from .gui.stats_label import StatsLabel
@@ -137,7 +139,8 @@ class DupeGuru(Broadcaster):
os.makedirs(self.appdata) os.makedirs(self.appdata)
self.app_mode = AppMode.Standard self.app_mode = AppMode.Standard
self.discarded_file_count = 0 self.discarded_file_count = 0
self.directories = directories.Directories() self.exclude_list = ExcludeList()
self.directories = directories.Directories(self.exclude_list)
self.results = results.Results(self) self.results = results.Results(self)
self.ignore_list = IgnoreList() self.ignore_list = IgnoreList()
# In addition to "app-level" options, this dictionary also holds options that will be # In addition to "app-level" options, this dictionary also holds options that will be
@@ -155,6 +158,7 @@ class DupeGuru(Broadcaster):
self.directory_tree = DirectoryTree(self) self.directory_tree = DirectoryTree(self)
self.problem_dialog = ProblemDialog(self) self.problem_dialog = ProblemDialog(self)
self.ignore_list_dialog = IgnoreListDialog(self) self.ignore_list_dialog = IgnoreListDialog(self)
self.exclude_list_dialog = ExcludeListDialogCore(self)
self.stats_label = StatsLabel(self) self.stats_label = StatsLabel(self)
self.result_table = None self.result_table = None
self.deletion_options = DeletionOptions() self.deletion_options = DeletionOptions()
@@ -259,7 +263,7 @@ class DupeGuru(Broadcaster):
def _create_file(self, path): def _create_file(self, path):
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths. # 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): def _get_file(self, str_path):
path = Path(str_path) path = Path(str_path)
@@ -539,8 +543,8 @@ class DupeGuru(Broadcaster):
return dupe.get_display_info(group, delta) return dupe.get_display_info(group, delta)
except Exception as e: except Exception as e:
logging.warning( logging.warning(
"Exception on GetDisplayInfo for %s: %s", str(dupe.path), str(e) "Exception (type: %s) on GetDisplayInfo for %s: %s",
) type(e), str(dupe.path), str(e))
return empty_data() return empty_data()
def invoke_custom_command(self): def invoke_custom_command(self):
@@ -587,6 +591,15 @@ class DupeGuru(Broadcaster):
p = op.join(self.appdata, "ignore_list.xml") p = op.join(self.appdata, "ignore_list.xml")
self.ignore_list.load_from_xml(p) self.ignore_list.load_from_xml(p)
self.ignore_list_dialog.refresh() 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): def load_from(self, filename):
"""Start an async job to load results from ``filename``. """Start an async job to load results from ``filename``.
@@ -773,6 +786,8 @@ class DupeGuru(Broadcaster):
self.directories.save_to_file(op.join(self.appdata, "last_directories.xml")) self.directories.save_to_file(op.join(self.appdata, "last_directories.xml"))
p = op.join(self.appdata, "ignore_list.xml") p = op.join(self.appdata, "ignore_list.xml")
self.ignore_list.save_to_xml(p) self.ignore_list.save_to_xml(p)
p = op.join(self.appdata, "exclude_list.xml")
self.exclude_list.save_to_xml(p)
self.notify("save_session") self.notify("save_session")
def save_as(self, filename): def save_as(self, filename):
@@ -785,6 +800,16 @@ class DupeGuru(Broadcaster):
except OSError as e: except OSError as e:
self.view.show_message(tr("Couldn't write to file: {}").format(str(e))) self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
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): def start_scanning(self):
"""Starts an async job to scan for duplicates. """Starts an async job to scan for duplicates.

View File

@@ -54,10 +54,11 @@ class Directories:
""" """
# ---Override # ---Override
def __init__(self): def __init__(self, exclude_list=None):
self._dirs = [] self._dirs = []
# {path: state} # {path: state}
self.states = {} self.states = {}
self._exclude_list = exclude_list
def __contains__(self, path): def __contains__(self, path):
for p in self._dirs: for p in self._dirs:
@@ -76,39 +77,62 @@ class Directories:
# ---Private # ---Private
def _default_state_for_path(self, path): 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 # We still use the old logic to force state on hidden dirs
# Override this in subclasses to specify the state of some special folders. # Override this in subclasses to specify the state of some special folders.
if path.name.startswith("."): # hidden if path.name.startswith("."):
return DirectoryState.Excluded return DirectoryState.Excluded
def _get_files(self, from_path, fileclasses, j): def _get_files(self, from_path, fileclasses, j):
for root, dirs, files in os.walk(str(from_path)): for root, dirs, files in os.walk(str(from_path)):
j.check_if_cancelled() j.check_if_cancelled()
root = Path(root) rootPath = Path(root)
state = self.get_state(root) state = self.get_state(rootPath)
if state == DirectoryState.Excluded: if state == DirectoryState.Excluded:
# Recursively get files from folders with lots of subfolder is expensive. However, there # 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 # 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 # through self.states and see if we must continue, or we can stop right here to save time
if not any(p[: len(root)] == root for p in self.states): if not any(p[: len(rootPath)] == rootPath for p in self.states):
del dirs[:] del dirs[:]
try: try:
if state != DirectoryState.Excluded: if state != DirectoryState.Excluded:
found_files = [ # Old logic
fs.get_file(root + f, fileclasses=fileclasses) for f in files if self._exclude_list is None or not self._exclude_list.mark_count:
] found_files = [fs.get_file(rootPath + f, fileclasses=fileclasses) for f in files]
else:
found_files = []
# print(f"len of files: {len(files)} {files}")
for f in files:
found = False
for expr in self._exclude_list.compiled_files:
if expr.match(f):
found = True
break
if not found:
for expr in self._exclude_list.compiled_paths:
if expr.match(root + os.sep + f):
found = True
break
if not found:
found_files.append(fs.get_file(rootPath + f, fileclasses=fileclasses))
found_files = [f for f in found_files if f is not None] found_files = [f for f in found_files if f is not None]
# In some cases, directories can be considered as files by dupeGuru, which is # In some cases, directories can be considered as files by dupeGuru, which is
# why we have this line below. In fact, there only one case: Bundle files under # why we have this line below. In fact, there only one case: Bundle files under
# OS X... In other situations, this forloop will do nothing. # OS X... In other situations, this forloop will do nothing.
for d in dirs[:]: for d in dirs[:]:
f = fs.get_file(root + d, fileclasses=fileclasses) f = fs.get_file(rootPath + d, fileclasses=fileclasses)
if f is not None: if f is not None:
found_files.append(f) found_files.append(f)
dirs.remove(d) dirs.remove(d)
logging.debug( logging.debug(
"Collected %d files in folder %s", "Collected %d files in folder %s",
len(found_files), len(found_files),
str(from_path), str(rootPath),
) )
for file in found_files: for file in found_files:
file.is_ref = state == DirectoryState.Reference file.is_ref = state == DirectoryState.Reference
@@ -194,8 +218,14 @@ class Directories:
if path in self.states: if path in self.states:
return self.states[path] return self.states[path]
state = self._default_state_for_path(path) or DirectoryState.Normal state = self._default_state_for_path(path) or DirectoryState.Normal
# Save non-default states in cache, necessary for _get_files()
if state != DirectoryState.Normal:
self.states[path] = state
return state
prevlen = 0 prevlen = 0
# we loop through the states to find the longest matching prefix # we loop through the states to find the longest matching prefix
# if the parent has a state in cache, return that state
for p, s in self.states.items(): for p, s in self.states.items():
if p.is_parent_of(path) and len(p) > prevlen: if p.is_parent_of(path) and len(p) > prevlen:
prevlen = len(p) prevlen = len(p)
@@ -224,7 +254,7 @@ class Directories:
root = ET.parse(infile).getroot() root = ET.parse(infile).getroot()
except Exception: except Exception:
return return
for rdn in root.getiterator("root_directory"): for rdn in root.iter("root_directory"):
attrib = rdn.attrib attrib = rdn.attrib
if "path" not in attrib: if "path" not in attrib:
continue continue
@@ -233,7 +263,7 @@ class Directories:
self.add_path(Path(path)) self.add_path(Path(path))
except (AlreadyThereError, InvalidPathError): except (AlreadyThereError, InvalidPathError):
pass pass
for sn in root.getiterator("state"): for sn in root.iter("state"):
attrib = sn.attrib attrib = sn.attrib
if not ("path" in attrib and "value" in attrib): if not ("path" in attrib and "value" in attrib):
continue continue

499
core/exclude.py Normal file
View File

@@ -0,0 +1,499 @@
# 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 .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.isExcluded(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):
try:
return re.compile(expr)
except Exception as e:
raise(e)
# @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)]
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)),)
@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(True)
self._dirty = False
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(True if self._use_union else False)
self._dirty = False
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(True if self._use_union else False)
self._dirty = False
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.isExcluded(regex):
# This exception should never be ignored
raise AlreadyThereException()
if regex in forbidden_regexes:
raise Exception("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 isExcluded(self, regex):
for item in self._excluded:
if regex == item[0]:
return True
return False
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 and 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.isExcluded(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[regex]["compiled"])
except Exception as e:
logging.warning(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 isExcluded(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["index"],
"compilable": iscompilable,
"error": error,
"compiled": compiled
}
self._remove_compiled(regex)
if was_marked and iscompilable:
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(x):
return '\\' + sep in x
else:
def has_sep(x):
return sep in x

View File

@@ -0,0 +1,71 @@
# 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 hscommon.trans import tr
from .exclude_list_table import ExcludeListTable
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):
"""Renames the selected regex to ``newregex``.
If there's more than one selected row, the first one is used.
:param str newregex: The regex to rename the row's regex to.
"""
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):
try:
self.exclude_list.add(regex)
except Exception as e:
raise(e)
self.exclude_list.mark(regex)
self.exclude_list_table.add(regex)
def test_string(self, test_string):
"""Sets property on row to highlight if its regex matches test_string supplied."""
matched = False
for row in self.exclude_list_table.rows:
compiled_regex = self.exclude_list.get_compiled(row.regex)
if compiled_regex and compiled_regex.match(test_string):
matched = True
row.highlight = True
else:
row.highlight = False
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,98 @@
# 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 .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.dalog.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

@@ -17,7 +17,7 @@ class IgnoreListDialog:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.ignore_list = self.app.ignore_list self.ignore_list = self.app.ignore_list
self.ignore_list_table = IgnoreListTable(self) self.ignore_list_table = IgnoreListTable(self) # GUITable
def clear(self): def clear(self):
if not self.ignore_list: if not self.ignore_list:

View File

@@ -72,13 +72,15 @@ class PrioritizeDialog(GUIObject):
# Add selected criteria in criteria_list to prioritization_list. # Add selected criteria in criteria_list to prioritization_list.
if self.criteria_list.selected_index is None: if self.criteria_list.selected_index is None:
return return
crit = self.criteria[self.criteria_list.selected_index] for i in self.criteria_list.selected_indexes:
self.prioritizations.append(crit) crit = self.criteria[i]
del crit self.prioritizations.append(crit)
del crit
self.prioritization_list[:] = [crit.display for crit in self.prioritizations] self.prioritization_list[:] = [crit.display for crit in self.prioritizations]
def remove_selected(self): def remove_selected(self):
self.prioritization_list.remove_selected() self.prioritization_list.remove_selected()
self.prioritization_list.select([])
def perform_reprioritization(self): def perform_reprioritization(self):
self.app.reprioritize_groups(self._sort_key) self.app.reprioritize_groups(self._sort_key)

View File

@@ -254,6 +254,7 @@ def getmatches(pictures, cache_path, threshold, match_scaled=False, j=job.nulljo
ref.dimensions # pre-read dimensions for display in results ref.dimensions # pre-read dimensions for display in results
other.dimensions other.dimensions
result.append(get_match(ref, other, percentage)) result.append(get_match(ref, other, percentage))
pool.join()
return result return result

View File

@@ -241,13 +241,13 @@ class Results(Markable):
self.apply_filter(None) self.apply_filter(None)
root = ET.parse(infile).getroot() root = ET.parse(infile).getroot()
group_elems = list(root.getiterator("group")) group_elems = list(root.iter("group"))
groups = [] groups = []
marked = set() marked = set()
for group_elem in j.iter_with_progress(group_elems, every=100): for group_elem in j.iter_with_progress(group_elems, every=100):
group = engine.Group() group = engine.Group()
dupes = [] dupes = []
for file_elem in group_elem.getiterator("file"): for file_elem in group_elem.iter("file"):
path = file_elem.get("path") path = file_elem.get("path")
words = file_elem.get("words", "") words = file_elem.get("words", "")
if not path: if not path:
@@ -260,7 +260,7 @@ class Results(Markable):
dupes.append(file) dupes.append(file)
if file_elem.get("marked") == "y": if file_elem.get("marked") == "y":
marked.add(file) marked.add(file)
for match_elem in group_elem.getiterator("match"): for match_elem in group_elem.iter("match"):
try: try:
attrs = match_elem.attrib attrs = match_elem.attrib
first_file = dupes[int(attrs["first"])] first_file = dupes[int(attrs["first"])]

View File

@@ -8,7 +8,7 @@ import os
import os.path as op import os.path as op
import logging import logging
from pytest import mark import pytest
from hscommon.path import Path from hscommon.path import Path
import hscommon.conflict import hscommon.conflict
import hscommon.util import hscommon.util
@@ -109,7 +109,7 @@ class TestCaseDupeGuru:
add_fake_files_to_directories(app.directories, [f1, f2]) add_fake_files_to_directories(app.directories, [f1, f2])
app.start_scanning() # no exception app.start_scanning() # no exception
@mark.skipif("not hasattr(os, 'link')") @pytest.mark.skipif("not hasattr(os, 'link')")
def test_ignore_hardlink_matches(self, tmpdir): def test_ignore_hardlink_matches(self, tmpdir):
# If the ignore_hardlink_matches option is set, don't match files hardlinking to the same # If the ignore_hardlink_matches option is set, don't match files hardlinking to the same
# inode. # inode.
@@ -133,8 +133,9 @@ class TestCaseDupeGuru:
class TestCaseDupeGuru_clean_empty_dirs: class TestCaseDupeGuru_clean_empty_dirs:
def pytest_funcarg__do_setup(self, request): @pytest.fixture
monkeypatch = request.getfuncargvalue("monkeypatch") def do_setup(self, request):
monkeypatch = request.getfixturevalue("monkeypatch")
monkeypatch.setattr( monkeypatch.setattr(
hscommon.util, hscommon.util,
"delete_if_empty", "delete_if_empty",
@@ -175,7 +176,8 @@ class TestCaseDupeGuru_clean_empty_dirs:
class TestCaseDupeGuruWithResults: class TestCaseDupeGuruWithResults:
def pytest_funcarg__do_setup(self, request): @pytest.fixture
def do_setup(self, request):
app = TestApp() app = TestApp()
self.app = app.app self.app = app.app
self.objects, self.matches, self.groups = GetTestGroups() self.objects, self.matches, self.groups = GetTestGroups()
@@ -184,7 +186,7 @@ class TestCaseDupeGuruWithResults:
self.dtree = app.dtree self.dtree = app.dtree
self.rtable = app.rtable self.rtable = app.rtable
self.rtable.refresh() self.rtable.refresh()
tmpdir = request.getfuncargvalue("tmpdir") tmpdir = request.getfixturevalue("tmpdir")
tmppath = Path(str(tmpdir)) tmppath = Path(str(tmpdir))
tmppath["foo"].mkdir() tmppath["foo"].mkdir()
tmppath["bar"].mkdir() tmppath["bar"].mkdir()
@@ -430,8 +432,9 @@ class TestCaseDupeGuruWithResults:
class TestCaseDupeGuru_renameSelected: class TestCaseDupeGuru_renameSelected:
def pytest_funcarg__do_setup(self, request): @pytest.fixture
tmpdir = request.getfuncargvalue("tmpdir") def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")
p = Path(str(tmpdir)) p = Path(str(tmpdir))
fp = open(str(p["foo bar 1"]), mode="w") fp = open(str(p["foo bar 1"]), mode="w")
fp.close() fp.close()
@@ -493,8 +496,9 @@ class TestCaseDupeGuru_renameSelected:
class TestAppWithDirectoriesInTree: class TestAppWithDirectoriesInTree:
def pytest_funcarg__do_setup(self, request): @pytest.fixture
tmpdir = request.getfuncargvalue("tmpdir") def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")
p = Path(str(tmpdir)) p = Path(str(tmpdir))
p["sub1"].mkdir() p["sub1"].mkdir()
p["sub2"].mkdir() p["sub2"].mkdir()

View File

@@ -147,6 +147,8 @@ def GetTestGroups():
class TestApp(TestAppBase): class TestApp(TestAppBase):
__test__ = False
def __init__(self): def __init__(self):
def link_gui(gui): def link_gui(gui):
gui.view = self.make_logger() gui.view = self.make_logger()

View File

@@ -1 +1 @@
from hscommon.testutil import pytest_funcarg__app # noqa from hscommon.testutil import app # noqa

View File

@@ -12,6 +12,7 @@ import shutil
from pytest import raises from pytest import raises
from hscommon.path import Path from hscommon.path import Path
from hscommon.testutil import eq_ from hscommon.testutil import eq_
from hscommon.plat import ISWINDOWS
from ..fs import File from ..fs import File
from ..directories import ( from ..directories import (
@@ -20,6 +21,7 @@ from ..directories import (
AlreadyThereError, AlreadyThereError,
InvalidPathError, InvalidPathError,
) )
from ..exclude import ExcludeList, ExcludeDict
def create_fake_fs(rootpath): def create_fake_fs(rootpath):
@@ -341,3 +343,200 @@ def test_default_path_state_override(tmpdir):
d.set_state(p1["foobar"], DirectoryState.Normal) d.set_state(p1["foobar"], DirectoryState.Normal)
eq_(d.get_state(p1["foobar"]), DirectoryState.Normal) eq_(d.get_state(p1["foobar"]), DirectoryState.Normal)
eq_(len(list(d.get_files())), 2) eq_(len(list(d.get_files())), 2)
class TestExcludeList():
def setup_method(self, method):
self.d = Directories(exclude_list=ExcludeList(union_regex=False))
def get_files_and_expect_num_result(self, num_result):
"""Calls get_files(), get the filenames only, print for debugging.
num_result is how many files are expected as a result."""
print(f"EXCLUDED REGEX: paths {self.d._exclude_list.compiled_paths} \
files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled}")
files = list(self.d.get_files())
files = [file.name for file in files]
print(f"FINAL FILES {files}")
eq_(len(files), num_result)
return files
def test_exclude_recycle_bin_by_default(self, tmpdir):
regex = r"^.*Recycle\.Bin$"
self.d._exclude_list.add(regex)
self.d._exclude_list.mark(regex)
p1 = Path(str(tmpdir))
p1["$Recycle.Bin"].mkdir()
p1["$Recycle.Bin"]["subdir"].mkdir()
self.d.add_path(p1)
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
# By default, subdirs should be excluded too, but this can be overriden separately
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
def test_exclude_refined(self, tmpdir):
regex1 = r"^\$Recycle\.Bin$"
self.d._exclude_list.add(regex1)
self.d._exclude_list.mark(regex1)
p1 = Path(str(tmpdir))
p1["$Recycle.Bin"].mkdir()
p1["$Recycle.Bin"]["somefile.png"].open("w").close()
p1["$Recycle.Bin"]["some_unwanted_file.jpg"].open("w").close()
p1["$Recycle.Bin"]["subdir"].mkdir()
p1["$Recycle.Bin"]["subdir"]["somesubdirfile.png"].open("w").close()
p1["$Recycle.Bin"]["subdir"]["unwanted_subdirfile.gif"].open("w").close()
p1["$Recycle.Bin"]["subdar"].mkdir()
p1["$Recycle.Bin"]["subdar"]["somesubdarfile.jpeg"].open("w").close()
p1["$Recycle.Bin"]["subdar"]["unwanted_subdarfile.png"].open("w").close()
self.d.add_path(p1["$Recycle.Bin"])
# Filter should set the default state to Excluded
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
# The subdir should inherit its parent state
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Excluded)
# Override a child path's state
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
# Parent should keep its default state, and the other child too
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Excluded)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
# only the 2 files directly under the Normal directory
files = self.get_files_and_expect_num_result(2)
assert "somefile.png" not in files
assert "some_unwanted_file.jpg" not in files
assert "somesubdarfile.jpeg" not in files
assert "unwanted_subdarfile.png" not in files
assert "somesubdirfile.png" in files
assert "unwanted_subdirfile.gif" in files
# Overriding the parent should enable all children
self.d.set_state(p1["$Recycle.Bin"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Normal)
# all files there
files = self.get_files_and_expect_num_result(6)
assert "somefile.png" in files
assert "some_unwanted_file.jpg" in files
# This should still filter out files under directory, despite the Normal state
regex2 = r".*unwanted.*"
self.d._exclude_list.add(regex2)
self.d._exclude_list.mark(regex2)
files = self.get_files_and_expect_num_result(3)
assert "somefile.png" in files
assert "some_unwanted_file.jpg" not in files
assert "unwanted_subdirfile.gif" not in files
assert "unwanted_subdarfile.png" not in files
if ISWINDOWS:
regex3 = r".*Recycle\.Bin\\.*unwanted.*subdirfile.*"
else:
regex3 = r".*Recycle\.Bin\/.*unwanted.*subdirfile.*"
self.d._exclude_list.rename(regex2, regex3)
assert self.d._exclude_list.error(regex3) is None
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
# Directory shouldn't change its state here, unless explicitely done by user
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
files = self.get_files_and_expect_num_result(5)
assert "unwanted_subdirfile.gif" not in files
assert "unwanted_subdarfile.png" in files
# using end of line character should only filter the directory, or file ending with subdir
regex4 = r".*subdir$"
self.d._exclude_list.rename(regex3, regex4)
assert self.d._exclude_list.error(regex4) is None
p1["$Recycle.Bin"]["subdar"]["file_ending_with_subdir"].open("w").close()
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
files = self.get_files_and_expect_num_result(4)
assert "file_ending_with_subdir" not in files
assert "somesubdarfile.jpeg" in files
assert "somesubdirfile.png" not in files
assert "unwanted_subdirfile.gif" not in files
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
files = self.get_files_and_expect_num_result(6)
assert "file_ending_with_subdir" not in files
assert "somesubdirfile.png" in files
assert "unwanted_subdirfile.gif" in files
regex5 = r".*subdir.*"
self.d._exclude_list.rename(regex4, regex5)
# Files containing substring should be filtered
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
# The path should not match, only the filename, the "subdir" in the directory name shouldn't matter
p1["$Recycle.Bin"]["subdir"]["file_which_shouldnt_match"].open("w").close()
files = self.get_files_and_expect_num_result(5)
assert "somesubdirfile.png" not in files
assert "unwanted_subdirfile.gif" not in files
assert "file_ending_with_subdir" not in files
assert "file_which_shouldnt_match" in files
def test_japanese_unicode(self, tmpdir):
p1 = Path(str(tmpdir))
p1["$Recycle.Bin"].mkdir()
p1["$Recycle.Bin"]["somerecycledfile.png"].open("w").close()
p1["$Recycle.Bin"]["some_unwanted_file.jpg"].open("w").close()
p1["$Recycle.Bin"]["subdir"].mkdir()
p1["$Recycle.Bin"]["subdir"]["過去白濁物語~]_カラー.jpg"].open("w").close()
p1["$Recycle.Bin"]["思叫物語"].mkdir()
p1["$Recycle.Bin"]["思叫物語"]["なししろ会う前"].open("w").close()
p1["$Recycle.Bin"]["思叫物語"]["堂~ロ"].open("w").close()
self.d.add_path(p1["$Recycle.Bin"])
regex3 = r".*物語.*"
self.d._exclude_list.add(regex3)
self.d._exclude_list.mark(regex3)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
eq_(self.d.get_state(p1["$Recycle.Bin"]["思叫物語"]), DirectoryState.Excluded)
files = self.get_files_and_expect_num_result(2)
assert "過去白濁物語~]_カラー.jpg" not in files
assert "なししろ会う前" not in files
assert "堂~ロ" not in files
# using end of line character should only filter that directory, not affecting its files
regex4 = r".*物語$"
self.d._exclude_list.rename(regex3, regex4)
assert self.d._exclude_list.error(regex4) is None
self.d.set_state(p1["$Recycle.Bin"]["思叫物語"], DirectoryState.Normal)
files = self.get_files_and_expect_num_result(5)
assert "過去白濁物語~]_カラー.jpg" in files
assert "なししろ会う前" in files
assert "堂~ロ" in files
def test_get_state_returns_excluded_for_hidden_directories_and_files(self, tmpdir):
# This regex only work for files, not paths
regex = r"^\..*$"
self.d._exclude_list.add(regex)
self.d._exclude_list.mark(regex)
p1 = Path(str(tmpdir))
p1["foobar"].mkdir()
p1["foobar"][".hidden_file.txt"].open("w").close()
p1["foobar"][".hidden_dir"].mkdir()
p1["foobar"][".hidden_dir"]["foobar.jpg"].open("w").close()
p1["foobar"][".hidden_dir"][".hidden_subfile.png"].open("w").close()
self.d.add_path(p1["foobar"])
# It should not inherit its parent's state originally
eq_(self.d.get_state(p1["foobar"][".hidden_dir"]), DirectoryState.Excluded)
self.d.set_state(p1["foobar"][".hidden_dir"], DirectoryState.Normal)
# The files should still be filtered
files = self.get_files_and_expect_num_result(1)
eq_(len(self.d._exclude_list.compiled_paths), 0)
eq_(len(self.d._exclude_list.compiled_files), 1)
assert ".hidden_file.txt" not in files
assert ".hidden_subfile.png" not in files
assert "foobar.jpg" in files
class TestExcludeDict(TestExcludeList):
def setup_method(self, method):
self.d = Directories(exclude_list=ExcludeDict(union_regex=False))
class TestExcludeListunion(TestExcludeList):
def setup_method(self, method):
self.d = Directories(exclude_list=ExcludeList(union_regex=True))
class TestExcludeDictunion(TestExcludeList):
def setup_method(self, method):
self.d = Directories(exclude_list=ExcludeDict(union_regex=True))

282
core/tests/exclude_test.py Normal file
View File

@@ -0,0 +1,282 @@
# 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
import io
# import os.path as op
from xml.etree import ElementTree as ET
# from pytest import raises
from hscommon.testutil import eq_
from hscommon.plat import ISWINDOWS
from .base import DupeGuru
from ..exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
from re import error
# Two slightly different implementations here, one around a list of lists,
# and another around a dictionary.
class TestCaseListXMLLoading:
def setup_method(self, method):
self.exclude_list = ExcludeList()
def test_load_non_existant_file(self):
# Loads the pre-defined regexes
self.exclude_list.load_from_xml("non_existant.xml")
eq_(len(default_regexes), len(self.exclude_list))
# they should also be marked by default
eq_(len(default_regexes), self.exclude_list.marked_count)
def test_save_to_xml(self):
f = io.BytesIO()
self.exclude_list.save_to_xml(f)
f.seek(0)
doc = ET.parse(f)
root = doc.getroot()
eq_("exclude_list", root.tag)
def test_save_and_load(self, tmpdir):
e1 = ExcludeList()
e2 = ExcludeList()
eq_(len(e1), 0)
e1.add(r"one")
e1.mark(r"one")
e1.add(r"two")
tmpxml = str(tmpdir.join("exclude_testunit.xml"))
e1.save_to_xml(tmpxml)
e2.load_from_xml(tmpxml)
# We should have the default regexes
assert r"one" in e2
assert r"two" in e2
eq_(len(e2), 2)
eq_(e2.marked_count, 1)
def test_load_xml_with_garbage_and_missing_elements(self):
root = ET.Element("foobar") # The root element shouldn't matter
exclude_node = ET.SubElement(root, "bogus")
exclude_node.set("regex", "None")
exclude_node.set("marked", "y")
exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", "one")
# marked field invalid
exclude_node.set("markedddd", "y")
exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", "two")
# missing marked field
exclude_node = ET.SubElement(root, "exclude")
exclude_node.set("regex", "three")
exclude_node.set("markedddd", "pazjbjepo")
f = io.BytesIO()
tree = ET.ElementTree(root)
tree.write(f, encoding="utf-8")
f.seek(0)
self.exclude_list.load_from_xml(f)
print(f"{[x for x in self.exclude_list]}")
# only the two "exclude" nodes should be added,
eq_(3, len(self.exclude_list))
# None should be marked
eq_(0, self.exclude_list.marked_count)
class TestCaseDictXMLLoading(TestCaseListXMLLoading):
def setup_method(self, method):
self.exclude_list = ExcludeDict()
class TestCaseListEmpty:
def setup_method(self, method):
self.app = DupeGuru()
self.app.exclude_list = ExcludeList(union_regex=False)
self.exclude_list = self.app.exclude_list
def test_add_mark_and_remove_regex(self):
regex1 = r"one"
regex2 = r"two"
self.exclude_list.add(regex1)
assert(regex1 in self.exclude_list)
self.exclude_list.add(regex2)
self.exclude_list.mark(regex1)
self.exclude_list.mark(regex2)
eq_(len(self.exclude_list), 2)
eq_(len(self.exclude_list.compiled), 2)
compiled_files = [x for x in self.exclude_list.compiled_files]
eq_(len(compiled_files), 2)
self.exclude_list.remove(regex2)
assert(regex2 not in self.exclude_list)
eq_(len(self.exclude_list), 1)
def test_add_duplicate(self):
self.exclude_list.add(r"one")
eq_(1 , len(self.exclude_list))
try:
self.exclude_list.add(r"one")
except Exception:
pass
eq_(1 , len(self.exclude_list))
def test_add_not_compilable(self):
# Trying to add a non-valid regex should not work and raise exception
regex = r"one))"
try:
self.exclude_list.add(regex)
except Exception as e:
# Make sure we raise a re.error so that the interface can process it
eq_(type(e), error)
added = self.exclude_list.mark(regex)
eq_(added, False)
eq_(len(self.exclude_list), 0)
eq_(len(self.exclude_list.compiled), 0)
compiled_files = [x for x in self.exclude_list.compiled_files]
eq_(len(compiled_files), 0)
def test_force_add_not_compilable(self):
"""Used when loading from XML for example"""
regex = r"one))"
try:
self.exclude_list.add(regex, forced=True)
except Exception as e:
# Should not get an exception here unless it's a duplicate regex
raise e
marked = self.exclude_list.mark(regex)
eq_(marked, False) # can't be marked since not compilable
eq_(len(self.exclude_list), 1)
eq_(len(self.exclude_list.compiled), 0)
compiled_files = [x for x in self.exclude_list.compiled_files]
eq_(len(compiled_files), 0)
# adding a duplicate
regex = r"one))"
try:
self.exclude_list.add(regex, forced=True)
except Exception as e:
# we should have this exception, and it shouldn't be added
assert type(e) is AlreadyThereException
eq_(len(self.exclude_list), 1)
eq_(len(self.exclude_list.compiled), 0)
def test_rename_regex(self):
regex = r"one"
self.exclude_list.add(regex)
self.exclude_list.mark(regex)
regex_renamed = r"one))"
# Not compilable, can't be marked
self.exclude_list.rename(regex, regex_renamed)
assert regex not in self.exclude_list
assert regex_renamed in self.exclude_list
eq_(self.exclude_list.is_marked(regex_renamed), False)
self.exclude_list.mark(regex_renamed)
eq_(self.exclude_list.is_marked(regex_renamed), False)
regex_renamed_compilable = r"two"
self.exclude_list.rename(regex_renamed, regex_renamed_compilable)
assert regex_renamed_compilable in self.exclude_list
eq_(self.exclude_list.is_marked(regex_renamed), False)
self.exclude_list.mark(regex_renamed_compilable)
eq_(self.exclude_list.is_marked(regex_renamed_compilable), True)
eq_(len(self.exclude_list), 1)
# Should still be marked after rename
regex_compilable = r"three"
self.exclude_list.rename(regex_renamed_compilable, regex_compilable)
eq_(self.exclude_list.is_marked(regex_compilable), True)
def test_restore_default(self):
"""Only unmark previously added regexes and mark the pre-defined ones"""
regex = r"one"
self.exclude_list.add(regex)
self.exclude_list.mark(regex)
self.exclude_list.restore_defaults()
eq_(len(default_regexes), self.exclude_list.marked_count)
# added regex shouldn't be marked
eq_(self.exclude_list.is_marked(regex), False)
# added regex shouldn't be in compiled list either
compiled = [x for x in self.exclude_list.compiled]
assert regex not in compiled
# Only default regexes marked and in compiled list
for re in default_regexes:
assert self.exclude_list.is_marked(re)
found = False
for compiled_re in compiled:
if compiled_re.pattern == re:
found = True
if not found:
raise(Exception(f"Default RE {re} not found in compiled list."))
continue
eq_(len(default_regexes), len(self.exclude_list.compiled))
class TestCaseDictEmpty(TestCaseListEmpty):
"""Same, but with dictionary implementation"""
def setup_method(self, method):
self.app = DupeGuru()
self.app.exclude_list = ExcludeDict(union_regex=False)
self.exclude_list = self.app.exclude_list
def split_union(pattern_object):
"""Returns list of strings for each union pattern"""
return [x for x in pattern_object.pattern.split("|")]
class TestCaseCompiledList():
"""Test consistency between union or and separate versions."""
def setup_method(self, method):
self.e_separate = ExcludeList(union_regex=False)
self.e_separate.restore_defaults()
self.e_union = ExcludeList(union_regex=True)
self.e_union.restore_defaults()
def test_same_number_of_expressions(self):
# We only get one union Pattern item in a tuple, which is made of however many parts
eq_(len(split_union(self.e_union.compiled[0])), len(default_regexes))
# We get as many as there are marked items
eq_(len(self.e_separate.compiled), len(default_regexes))
exprs = split_union(self.e_union.compiled[0])
# We should have the same number and the same expressions
eq_(len(exprs), len(self.e_separate.compiled))
for expr in self.e_separate.compiled:
assert expr.pattern in exprs
def test_compiled_files(self):
# is path separator checked properly to yield the output
if ISWINDOWS:
regex1 = r"test\\one\\sub"
else:
regex1 = r"test/one/sub"
self.e_separate.add(regex1)
self.e_separate.mark(regex1)
self.e_union.add(regex1)
self.e_union.mark(regex1)
separate_compiled_dirs = self.e_separate.compiled
separate_compiled_files = [x for x in self.e_separate.compiled_files]
# HACK we need to call compiled property FIRST to generate the cache
union_compiled_dirs = self.e_union.compiled
# print(f"type: {type(self.e_union.compiled_files[0])}")
# A generator returning only one item... ugh
union_compiled_files = [x for x in self.e_union.compiled_files][0]
print(f"compiled files: {union_compiled_files}")
# Separate should give several plus the one added
eq_(len(separate_compiled_dirs), len(default_regexes) + 1)
# regex1 shouldn't be in the "files" version
eq_(len(separate_compiled_files), len(default_regexes))
# Only one Pattern returned, which when split should be however many + 1
eq_(len(split_union(union_compiled_dirs[0])), len(default_regexes) + 1)
# regex1 shouldn't be here either
eq_(len(split_union(union_compiled_files)), len(default_regexes))
class TestCaseCompiledDict(TestCaseCompiledList):
"""Test the dictionary version"""
def setup_method(self, method):
self.e_separate = ExcludeDict(union_regex=False)
self.e_separate.restore_defaults()
self.e_union = ExcludeDict(union_regex=True)
self.e_union.restore_defaults()

View File

@@ -414,12 +414,12 @@ class TestCaseResultsMarkings:
f.seek(0) f.seek(0)
doc = ET.parse(f) doc = ET.parse(f)
root = doc.getroot() root = doc.getroot()
g1, g2 = root.getiterator("group") g1, g2 = root.iter("group")
d1, d2, d3 = g1.getiterator("file") d1, d2, d3 = g1.iter("file")
eq_("n", d1.get("marked")) eq_("n", d1.get("marked"))
eq_("n", d2.get("marked")) eq_("n", d2.get("marked"))
eq_("y", d3.get("marked")) eq_("y", d3.get("marked"))
d1, d2 = g2.getiterator("file") d1, d2 = g2.iter("file")
eq_("n", d1.get("marked")) eq_("n", d1.get("marked"))
eq_("y", d2.get("marked")) eq_("y", d2.get("marked"))

View File

@@ -4,6 +4,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
import pytest
from hscommon.jobprogress import job from hscommon.jobprogress import job
from hscommon.path import Path from hscommon.path import Path
from hscommon.testutil import eq_ from hscommon.testutil import eq_
@@ -33,10 +35,11 @@ class NamedObject:
no = NamedObject no = NamedObject
def pytest_funcarg__fake_fileexists(request): @pytest.fixture
def fake_fileexists(request):
# This is a hack to avoid invalidating all previous tests since the scanner started to test # This is a hack to avoid invalidating all previous tests since the scanner started to test
# for file existence before doing the match grouping. # for file existence before doing the match grouping.
monkeypatch = request.getfuncargvalue("monkeypatch") monkeypatch = request.getfixturevalue("monkeypatch")
monkeypatch.setattr(Path, "exists", lambda _: True) monkeypatch.setattr(Path, "exists", lambda _: True)

View File

@@ -1,3 +1,29 @@
=== 4.1.0 (2020-12-29)
* Use tabs instead of separate windows (#688)
* Show the shortcut for "mark selected" in results dialog (#656, #641)
* Add image comparison features to details dialog (#683)
* Add the ability to use regex based exclusion filters (#705)
* Change reference row background color, and allow user to adjust the color (#701)
* Save / Load directories as XML (#706)
* Workaround for EXIF IFD type mismatch in parsing function (#630, #698)
* Progress dialog stuck at "Verified X/X matches" (#693, #694)
* Fix word wrap in ignore list dialog (#687)
* Fix issue with result window action on creation (#685)
* Colorize details table differences, allow moving rows (#682)
* Fix loading Result of 'Scan Type: Folders' shows only '---' in every table cell (#677, #676)
* Fix issue with details and results dialog row trimming (#655, #654)
* Add option to enable/disable bold font (#646, #314)
* Use relative icon path for themes to override more easily (#746)
* Fix issues with Python 3.8 compatibility (#665)
* Fix flake8 issues (#672)
* Update to use newer pytest and expand flake8 checking, cleanup various Deprecation Warnings
* Add warnings to packaging script when files are not built (#691)
* Use relative icon path for themes to override more easily (#746)
* Update Packaging for Ubuntu (#593)
* Minor Build Updates (#627, #575, #628, #614)
* Update CI builds and add windows CI (#572, #669)
=== 4.0.4 (2019-05-13) === 4.0.4 (2019-05-13)
* Update qt/platform.py to support other Unix style OSes (#444) * Update qt/platform.py to support other Unix style OSes (#444)

View File

@@ -295,7 +295,7 @@ def build_debian_changelog(
return [s.strip() for s in result if s.strip()] return [s.strip() for s in result if s.strip()]
ENTRY_MODEL = ( ENTRY_MODEL = (
"{pkg} ({version}-1) {distribution}; urgency=low\n\n{changes}\n " "{pkg} ({version}) {distribution}; urgency=low\n\n{changes}\n "
"-- Virgil Dupras <hsoft@hardcoded.net> {date}\n\n" "-- Virgil Dupras <hsoft@hardcoded.net> {date}\n\n"
) )
CHANGE_MODEL = " * {description}\n" CHANGE_MODEL = " * {description}\n"
@@ -557,7 +557,7 @@ def fix_qt_resource_file(path):
with open(path, "rb") as fp: with open(path, "rb") as fp:
contents = fp.read() contents = fp.read()
lines = contents.split(b"\n") lines = contents.split(b"\n")
lines = [l for l in lines if not l.startswith(b"#")] lines = [line for line in lines if not line.startswith(b"#")]
with open(path, "wb") as fp: with open(path, "wb") as fp:
fp.write(b"\n".join(lines)) fp.write(b"\n".join(lines))

View File

@@ -6,7 +6,7 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from collections import Sequence, MutableSequence from collections.abc import Sequence, MutableSequence
from .base import GUIObject from .base import GUIObject

View File

@@ -6,7 +6,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from collections import MutableSequence, namedtuple from collections.abc import MutableSequence
from collections import namedtuple
from .base import GUIObject from .base import GUIObject
from .selectable_list import Selectable from .selectable_list import Selectable

View File

@@ -4,7 +4,7 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from collections import MutableSequence from collections.abc import MutableSequence
from .base import GUIObject from .base import GUIObject

View File

@@ -257,6 +257,6 @@ def log_io_error(func):
msg = 'Error "{0}" during operation "{1}" on "{2}": "{3}"' msg = 'Error "{0}" during operation "{1}" on "{2}": "{3}"'
classname = e.__class__.__name__ classname = e.__class__.__name__
funcname = func.__name__ funcname = func.__name__
logging.warn(msg.format(classname, funcname, str(path), str(e))) logging.warning(msg.format(classname, funcname, str(path), str(e)))
return wrapper return wrapper

View File

@@ -6,7 +6,15 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from ..conflict import * import pytest
from ..conflict import (
get_conflicted_name,
get_unconflicted_name,
is_conflicted,
smart_copy,
smart_move,
)
from ..path import Path from ..path import Path
from ..testutil import eq_ from ..testutil import eq_
@@ -59,8 +67,9 @@ class TestCase_IsConflicted:
class TestCase_move_copy: class TestCase_move_copy:
def pytest_funcarg__do_setup(self, request): @pytest.fixture
tmpdir = request.getfuncargvalue("tmpdir") def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")
self.path = Path(str(tmpdir)) self.path = Path(str(tmpdir))
self.path["foo"].open("w").close() self.path["foo"].open("w").close()
self.path["bar"].open("w").close() self.path["bar"].open("w").close()

View File

@@ -28,8 +28,8 @@ class HelloRepeater(Repeater):
def create_pair(): def create_pair():
b = Broadcaster() b = Broadcaster()
l = HelloListener(b) listener = HelloListener(b)
return b, l return b, listener
def test_disconnect_during_notification(): def test_disconnect_during_notification():
@@ -60,53 +60,53 @@ def test_disconnect_during_notification():
def test_disconnect(): def test_disconnect():
# After a disconnect, the listener doesn't hear anything. # After a disconnect, the listener doesn't hear anything.
b, l = create_pair() b, listener = create_pair()
l.connect() listener.connect()
l.disconnect() listener.disconnect()
b.notify("hello") b.notify("hello")
eq_(l.hello_count, 0) eq_(listener.hello_count, 0)
def test_disconnect_when_not_connected(): def test_disconnect_when_not_connected():
# When disconnecting an already disconnected listener, nothing happens. # When disconnecting an already disconnected listener, nothing happens.
b, l = create_pair() b, listener = create_pair()
l.disconnect() listener.disconnect()
def test_not_connected_on_init(): def test_not_connected_on_init():
# A listener is not initialized connected. # A listener is not initialized connected.
b, l = create_pair() b, listener = create_pair()
b.notify("hello") b.notify("hello")
eq_(l.hello_count, 0) eq_(listener.hello_count, 0)
def test_notify(): def test_notify():
# The listener listens to the broadcaster. # The listener listens to the broadcaster.
b, l = create_pair() b, listener = create_pair()
l.connect() listener.connect()
b.notify("hello") b.notify("hello")
eq_(l.hello_count, 1) eq_(listener.hello_count, 1)
def test_reconnect(): def test_reconnect():
# It's possible to reconnect a listener after disconnection. # It's possible to reconnect a listener after disconnection.
b, l = create_pair() b, listener = create_pair()
l.connect() listener.connect()
l.disconnect() listener.disconnect()
l.connect() listener.connect()
b.notify("hello") b.notify("hello")
eq_(l.hello_count, 1) eq_(listener.hello_count, 1)
def test_repeater(): def test_repeater():
b = Broadcaster() b = Broadcaster()
r = HelloRepeater(b) r = HelloRepeater(b)
l = HelloListener(r) listener = HelloListener(r)
r.connect() r.connect()
l.connect() listener.connect()
b.notify("hello") b.notify("hello")
eq_(r.hello_count, 1) eq_(r.hello_count, 1)
eq_(l.hello_count, 1) eq_(listener.hello_count, 1)
def test_repeater_with_repeated_notifications(): def test_repeater_with_repeated_notifications():
@@ -124,15 +124,15 @@ def test_repeater_with_repeated_notifications():
b = Broadcaster() b = Broadcaster()
r = MyRepeater(b) r = MyRepeater(b)
l = HelloListener(r) listener = HelloListener(r)
r.connect() r.connect()
l.connect() listener.connect()
b.notify("hello") b.notify("hello")
b.notify( b.notify(
"foo" "foo"
) # if the repeater repeated this notif, we'd get a crash on HelloListener ) # if the repeater repeated this notif, we'd get a crash on HelloListener
eq_(r.hello_count, 1) eq_(r.hello_count, 1)
eq_(l.hello_count, 1) eq_(listener.hello_count, 1)
eq_(r.foo_count, 1) eq_(r.foo_count, 1)
@@ -140,18 +140,18 @@ def test_repeater_doesnt_try_to_dispatch_to_self_if_it_cant():
# if a repeater doesn't handle a particular message, it doesn't crash and simply repeats it. # if a repeater doesn't handle a particular message, it doesn't crash and simply repeats it.
b = Broadcaster() b = Broadcaster()
r = Repeater(b) # doesnt handle hello r = Repeater(b) # doesnt handle hello
l = HelloListener(r) listener = HelloListener(r)
r.connect() r.connect()
l.connect() listener.connect()
b.notify("hello") # no crash b.notify("hello") # no crash
eq_(l.hello_count, 1) eq_(listener.hello_count, 1)
def test_bind_messages(): def test_bind_messages():
b, l = create_pair() b, listener = create_pair()
l.bind_messages({"foo", "bar"}, l.hello) listener.bind_messages({"foo", "bar"}, listener.hello)
l.connect() listener.connect()
b.notify("foo") b.notify("foo")
b.notify("bar") b.notify("bar")
b.notify("hello") # Normal dispatching still work b.notify("hello") # Normal dispatching still work
eq_(l.hello_count, 3) eq_(listener.hello_count, 3)

View File

@@ -9,14 +9,15 @@
import sys import sys
import os import os
from pytest import raises, mark import pytest
from ..path import Path, pathify from ..path import Path, pathify
from ..testutil import eq_ from ..testutil import eq_
def pytest_funcarg__force_ossep(request): @pytest.fixture
monkeypatch = request.getfuncargvalue("monkeypatch") def force_ossep(request):
monkeypatch = request.getfixturevalue("monkeypatch")
monkeypatch.setattr(os, "sep", "/") monkeypatch.setattr(os, "sep", "/")
@@ -50,7 +51,7 @@ def test_init_with_tuple_and_list(force_ossep):
def test_init_with_invalid_value(force_ossep): def test_init_with_invalid_value(force_ossep):
try: try:
path = Path(42) path = Path(42) # noqa: F841
assert False assert False
except TypeError: except TypeError:
pass pass
@@ -142,8 +143,8 @@ def test_path_slice(force_ossep):
eq_((), foobar[:foobar]) eq_((), foobar[:foobar])
abcd = Path("a/b/c/d") abcd = Path("a/b/c/d")
a = Path("a") a = Path("a")
b = Path("b") b = Path("b") # noqa: #F841
c = Path("c") c = Path("c") # noqa: #F841
d = Path("d") d = Path("d")
z = Path("z") z = Path("z")
eq_("b/c", abcd[a:d]) eq_("b/c", abcd[a:d])
@@ -236,12 +237,12 @@ def test_getitem_path(force_ossep):
eq_(p[Path("baz/bleh")], Path("/foo/bar/baz/bleh")) eq_(p[Path("baz/bleh")], Path("/foo/bar/baz/bleh"))
@mark.xfail(reason="pytest's capture mechanism is flaky, I have to investigate") @pytest.mark.xfail(reason="pytest's capture mechanism is flaky, I have to investigate")
def test_log_unicode_errors(force_ossep, monkeypatch, capsys): def test_log_unicode_errors(force_ossep, monkeypatch, capsys):
# When an there's a UnicodeDecodeError on path creation, log it so it can be possible # When an there's a UnicodeDecodeError on path creation, log it so it can be possible
# to debug the cause of it. # to debug the cause of it.
monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "ascii") monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "ascii")
with raises(UnicodeDecodeError): with pytest.raises(UnicodeDecodeError):
Path(["", b"foo\xe9"]) Path(["", b"foo\xe9"])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert repr(b"foo\xe9") in err assert repr(b"foo\xe9") in err

View File

@@ -95,7 +95,7 @@ def test_make_sure_theres_no_messup_between_queries():
threads.append(t) threads.append(t)
while threads: while threads:
time.sleep(0.1) time.sleep(0.1)
threads = [t for t in threads if t.isAlive()] threads = [t for t in threads if t.is_alive()]
def test_query_after_close(): def test_query_after_close():

View File

@@ -11,6 +11,8 @@ from ..gui.table import Table, GUITable, Row
class TestRow(Row): class TestRow(Row):
__test__ = False
def __init__(self, table, index, is_new=False): def __init__(self, table, index, is_new=False):
Row.__init__(self, table) Row.__init__(self, table)
self.is_new = is_new self.is_new = is_new
@@ -28,6 +30,8 @@ class TestRow(Row):
class TestGUITable(GUITable): class TestGUITable(GUITable):
__test__ = False
def __init__(self, rowcount, viewclass=CallLogger): def __init__(self, rowcount, viewclass=CallLogger):
GUITable.__init__(self) GUITable.__init__(self)
self.view = viewclass() self.view = viewclass()

View File

@@ -12,7 +12,31 @@ from pytest import raises
from ..testutil import eq_ from ..testutil import eq_
from ..path import Path from ..path import Path
from ..util import * from ..util import (
nonone,
tryint,
minmax,
first,
flatten,
dedupe,
stripfalse,
extract,
allsame,
trailiter,
format_time,
format_time_decimal,
format_size,
remove_invalid_xml,
multi_replace,
delete_if_empty,
open_if_filename,
FileOrPath,
iterconsume,
escape,
get_file_ext,
rem_file_ext,
pluralize,
)
def test_nonone(): def test_nonone():
@@ -214,42 +238,46 @@ def test_multi_replace():
# --- Files # --- Files
# These test cases needed https://github.com/hsoft/pytest-monkeyplus/ which appears to not be compatible with latest
# pytest, looking at where this is used only appears to be in hscommon.localize_all_stringfiles at top level.
# Right now this repo does not seem to utilize any of that functionality so going to leave these tests out for now.
# TODO decide if fixing these tests is worth it or not.
class TestCase_modified_after: # class TestCase_modified_after:
def test_first_is_modified_after(self, monkeyplus): # def test_first_is_modified_after(self, monkeyplus):
monkeyplus.patch_osstat("first", st_mtime=42) # monkeyplus.patch_osstat("first", st_mtime=42)
monkeyplus.patch_osstat("second", st_mtime=41) # monkeyplus.patch_osstat("second", st_mtime=41)
assert modified_after("first", "second") # assert modified_after("first", "second")
def test_second_is_modified_after(self, monkeyplus): # def test_second_is_modified_after(self, monkeyplus):
monkeyplus.patch_osstat("first", st_mtime=42) # monkeyplus.patch_osstat("first", st_mtime=42)
monkeyplus.patch_osstat("second", st_mtime=43) # monkeyplus.patch_osstat("second", st_mtime=43)
assert not modified_after("first", "second") # assert not modified_after("first", "second")
def test_same_mtime(self, monkeyplus): # def test_same_mtime(self, monkeyplus):
monkeyplus.patch_osstat("first", st_mtime=42) # monkeyplus.patch_osstat("first", st_mtime=42)
monkeyplus.patch_osstat("second", st_mtime=42) # monkeyplus.patch_osstat("second", st_mtime=42)
assert not modified_after("first", "second") # assert not modified_after("first", "second")
def test_first_file_does_not_exist(self, monkeyplus): # def test_first_file_does_not_exist(self, monkeyplus):
# when the first file doesn't exist, we return False # # when the first file doesn't exist, we return False
monkeyplus.patch_osstat("second", st_mtime=42) # monkeyplus.patch_osstat("second", st_mtime=42)
assert not modified_after("does_not_exist", "second") # no crash # assert not modified_after("does_not_exist", "second") # no crash
def test_second_file_does_not_exist(self, monkeyplus): # def test_second_file_does_not_exist(self, monkeyplus):
# when the second file doesn't exist, we return True # # when the second file doesn't exist, we return True
monkeyplus.patch_osstat("first", st_mtime=42) # monkeyplus.patch_osstat("first", st_mtime=42)
assert modified_after("first", "does_not_exist") # no crash # assert modified_after("first", "does_not_exist") # no crash
def test_first_file_is_none(self, monkeyplus): # def test_first_file_is_none(self, monkeyplus):
# when the first file is None, we return False # # when the first file is None, we return False
monkeyplus.patch_osstat("second", st_mtime=42) # monkeyplus.patch_osstat("second", st_mtime=42)
assert not modified_after(None, "second") # no crash # assert not modified_after(None, "second") # no crash
def test_second_file_is_none(self, monkeyplus): # def test_second_file_is_none(self, monkeyplus):
# when the second file is None, we return True # # when the second file is None, we return True
monkeyplus.patch_osstat("first", st_mtime=42) # monkeyplus.patch_osstat("first", st_mtime=42)
assert modified_after("first", None) # no crash # assert modified_after("first", None) # no crash
class TestCase_delete_if_empty: class TestCase_delete_if_empty:

View File

@@ -6,6 +6,8 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
import pytest
import threading import threading
import py.path import py.path
@@ -148,7 +150,7 @@ class TestApp:
return gui return gui
# To use @with_app, you have to import pytest_funcarg__app in your conftest.py file. # To use @with_app, you have to import app in your conftest.py file.
def with_app(setupfunc): def with_app(setupfunc):
def decorator(func): def decorator(func):
func.setupfunc = setupfunc func.setupfunc = setupfunc
@@ -157,7 +159,8 @@ def with_app(setupfunc):
return decorator return decorator
def pytest_funcarg__app(request): @pytest.fixture
def app(request):
setupfunc = request.function.setupfunc setupfunc = request.function.setupfunc
if hasattr(setupfunc, "__code__"): if hasattr(setupfunc, "__code__"):
argnames = setupfunc.__code__.co_varnames[: setupfunc.__code__.co_argcount] argnames = setupfunc.__code__.co_varnames[: setupfunc.__code__.co_argcount]

BIN
images/dialog-error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
images/dupeguru.icns Executable file

Binary file not shown.

BIN
images/exchange.icns Normal file

Binary file not shown.

BIN
images/exchange.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
images/exchange.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

BIN
images/exchange_purple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/old_zoom_in.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/old_zoom_out.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

53
macos.md Normal file
View File

@@ -0,0 +1,53 @@
## How to build dupeGuru for macos
### Prerequisites
- [Python 3.6+][python]
- [Xcode 12.3][xcode] or just Xcode command line tools (older versions can be used if not interested in arm macs)
- [Homebrew][homebrew]
- [qt5](https://www.qt.io/)
#### Prerequisite setup
1. Install Xcode if desired
2. Install [Homebrew][homebrew], if not on the path after install (arm based Macs) create `~/.zshrc`
with `export PATH="/opt/homebrew/bin:$PATH"`. Will need to reload terminal or source the file to take
affect.
3. Install qt5 with `brew`. If you are using a version of macos without system python 3.6+ then you will
also need to install that via brew or with pyenv.
$ brew install qt5
NOTE: Using `brew` to install qt5 is to allow pyqt5 to build without a native wheel
available. If you are using an intel based mac you can probably skip this step.
4. May need to launch a new terminal to have everything working.
### With build.py
OSX comes with a version of python 3 by default in newer versions of OSX. To produce universal
builds either the 3.8 version shipped in macos or 3.9.1 or newer needs to be used. If needing to
build pyqt5 from source then the first line below is needed, else it may be omitted. (Path shown is
for an arm mac.)
$ export PATH="/opt/homebrew/opt/qt/bin:$PATH"
$ cd <dupeGuru directory>
$ python3 -m venv ./env
$ source ./env/bin/activate
$ pip install -r requirements.txt
$ python build.py
$ python run.py
### Generate OSX Packages
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
This will produce a dupeGuru.app in the dist folder.
### 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/
[homebrew]: https://brew.sh/
[xcode]: https://developer.apple.com/xcode/

View File

@@ -12,6 +12,7 @@ import shutil
import json import json
from argparse import ArgumentParser from argparse import ArgumentParser
import platform import platform
import distro
import re import re
from hscommon.build import ( from hscommon.build import (
@@ -42,6 +43,15 @@ def copy_files_to_package(destpath, packages, with_so):
shutil.copy("run.py", op.join(destpath, "run.py")) shutil.copy("run.py", op.join(destpath, "run.py"))
extra_ignores = ["*.so"] if not with_so else None extra_ignores = ["*.so"] if not with_so else None
copy_packages(packages, destpath, extra_ignores=extra_ignores) copy_packages(packages, destpath, extra_ignores=extra_ignores)
# include locale files if they are built otherwise exit as it will break
# the localization
if not op.exists("build/locale"):
print('Locale files are missing. Have you run "build.py --loc"? Exiting...')
return
# include help files if they are built otherwise exit as they should be included?
if not op.exists("build/help"):
print('Help files are missing. Have you run "build.py --doc"? Exiting...')
return
shutil.copytree(op.join("build", "help"), op.join(destpath, "help")) shutil.copytree(op.join("build", "help"), op.join(destpath, "help"))
shutil.copytree(op.join("build", "locale"), op.join(destpath, "locale")) shutil.copytree(op.join("build", "locale"), op.join(destpath, "locale"))
compileall.compile_dir(destpath) compileall.compile_dir(destpath)
@@ -90,7 +100,7 @@ def package_debian_distribution(distribution):
) )
shutil.copy(op.join("images", "dgse_logo_128.png"), srcpath) shutil.copy(op.join("images", "dgse_logo_128.png"), srcpath)
os.chdir(destpath) os.chdir(destpath)
cmd = "dpkg-buildpackage -S -us -uc" cmd = "dpkg-buildpackage -F -us -uc"
os.system(cmd) os.system(cmd)
os.chdir("../..") os.chdir("../..")
@@ -151,11 +161,11 @@ def package_windows():
# include locale files if they are built otherwise exit as it will break # include locale files if they are built otherwise exit as it will break
# the localization # the localization
if not op.exists("build/locale"): if not op.exists("build/locale"):
print("Locale files not built, exiting...") print('Locale files are missing. Have you run "build.py --loc"? Exiting...')
return return
# include help files if they are built otherwise exit as they should be included? # include help files if they are built otherwise exit as they should be included?
if not op.exists("build/help"): if not op.exists("build/help"):
print("Help files not built, exiting...") print('Help files are missing. Have you run "build.py --doc"? Exiting...')
return return
# create version information file from template # create version information file from template
try: try:
@@ -201,6 +211,33 @@ def package_windows():
print_and_do(cmd.format(version_array[0], version_array[1], version_array[2], bits)) print_and_do(cmd.format(version_array[0], version_array[1], version_array[2], bits))
def package_macos():
# include locale files if they are built otherwise exit as it will break
# the localization
if not op.exists("build/locale"):
print('Locale files are missing. Have you run "build.py --loc"? Exiting...')
return
# include help files if they are built otherwise exit as they should be included?
if not op.exists("build/help"):
print('Help files are missing. Have you run "build.py --doc"? Exiting...')
return
# run pyinstaller from here:
import PyInstaller.__main__
PyInstaller.__main__.run(
[
"--name=dupeguru",
"--windowed",
"--noconfirm",
"--icon=images/dupeguru.icns",
"--osx-bundle-identifier=com.hardcoded-software.dupeguru",
"--add-data=build/locale:locale",
"--add-data=build/help:help",
"run.py",
]
)
def main(): def main():
args = parse_args() args = parse_args()
if args.src_pkg: if args.src_pkg:
@@ -210,9 +247,11 @@ def main():
print("Packaging dupeGuru with UI qt") print("Packaging dupeGuru with UI qt")
if sys.platform == "win32": if sys.platform == "win32":
package_windows() package_windows()
elif sys.platform == "darwin":
package_macos()
else: else:
if not args.arch_pkg: if not args.arch_pkg:
distname, _, _ = platform.dist() distname = distro.id()
else: else:
distname = "arch" distname = "arch"
if distname == "arch": if distname == "arch":

View File

@@ -3,5 +3,5 @@
"longname": "dupeGuru", "longname": "dupeGuru",
"execname": "dupeguru", "execname": "dupeguru",
"arch": "any", "arch": "any",
"iconpath": "/usr/share/dupeguru/dgse_logo_128.png" "iconpath": "dupeguru"
} }

View File

@@ -8,4 +8,6 @@ all:
chmod +x src/run.py chmod +x src/run.py
cp -R src/ "$(CURDIR)/debian/{pkgname}/usr/share/{execname}" cp -R src/ "$(CURDIR)/debian/{pkgname}/usr/share/{execname}"
cp "$(CURDIR)/debian/{execname}.desktop" "$(CURDIR)/debian/{pkgname}/usr/share/applications" cp "$(CURDIR)/debian/{execname}.desktop" "$(CURDIR)/debian/{pkgname}/usr/share/applications"
mkdir -p "$(CURDIR)/debian/{pkgname}/usr/share/pixmaps"
ln -s "/usr/share/{execname}/dgse_logo_128.png" "$(CURDIR)/debian/{pkgname}/usr/share/pixmaps/{execname}.png"
ln -s "/usr/share/{execname}/run.py" "$(CURDIR)/debian/{pkgname}/usr/bin/{execname}" ln -s "/usr/share/{execname}/run.py" "$(CURDIR)/debian/{pkgname}/usr/bin/{execname}"

351
pkg/debian/changelog Normal file
View File

@@ -0,0 +1,351 @@
dupeguru (4.0.4-1) unstable; urgency=low
* Update qt/platform.py to support other Unix style OSes (#444)
* Fix font size scaling issue in properties dialog [qt] (#504)
* Updates to support Python 3.7
* Fix issue with result window appearing partially off-screen [qt] (#521)
* Fix translation error for Simplified Chinese
* Updates to language files for German (#479)
* Fix error with multiple close calls to the progress window [qt] (#460, #449)
* Add Travis CI Builds
* Un-recurse methods get_files() and get_state() to improve stability (#421)
* Updates to language files for Italian (#445, #446, #447, #448)
* Fix issue with cache_shelve (#402, #439)
* Updated Windows packaging and builds (#438, #456, #461, #491, #474, #490, #565)
* Handle OS termination signals (#425)
* Make documentation installation optional
* Move cocoa UI to dupeguru-cocoa [cocoa]
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 13 May 2019 00:00:00 +0000
dupeguru (4.0.3-1) unstable; urgency=low
* Add new picture cache backend: shelve
* Make shelve picture cache backend the active one on MacOS to fix #394 more elegantly. [cocoa]
* Remove Sparkle (auto-updates) due to technical limitations. [cocoa]
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 24 Nov 2016 00:00:00 +0000
dupeguru (4.0.2-1) unstable; urgency=low
* Fix systematic crash in Picture Mode under MacOS Sierra. (#394)
* No change for Linux. Just keeping version in sync.
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 09 Oct 2016 00:00:00 +0000
dupeguru (4.0.1-1) unstable; urgency=low
* Add Greek localization, by Gabriel Koutilellis. (#382)
* Fix localization base path. [qt] (#378)
* Fix broken load results dialog. [qt]
* Fix crash on load results. [cocoa] (#380)
* Save preferences more predictably. [qt] (#379)
* Fix picture mode's fuzzy block scanner threshold. (#387)
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 24 Aug 2016 00:00:00 +0000
dupeguru (4.0.0-1) unstable; urgency=low
* Merge Standard, Music and Picture editions in the same application!
* Improve documentation. (#294)
* Add Polish, Korean, Spanish and Dutch localizations.
* qt: Fix wrong use_regexp option propagation to core. (#295)
* qt: Fix progress window mistakenly showing up on startup. (#357)
* Bump Python requirement to v3.4.
* Bump OS X requirement to 10.8
* Drop Windows support, maybe temporarily. `Details <https://www.hardcoded.net/archive2015#2015-11-01>`_
* cocoa: Drop iPhoto, Aperture and iTunes support. Was unmaintained and obsolete.
* Drop "Audio Contents" scan type. Was confusing and seldom useful.
* Change license to GPLv3
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 01 Jul 2016 00:00:00 +0000
dupeguru (3.9.1-1) unstable; urgency=low
* Fixed ``AttributeError: 'ComboboxModel' object has no attribute 'reset'``. [Linux, Windows] (#254)
* Fixed ``PermissionError`` on saving results. (#266)
* Fixed a build problem introduced by Sphinx 1.2.3.
* Updated German localisation, by Frank Weber.
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 17 Oct 2014 00:00:00 +0000
dupeguru (3.9.0-1) unstable; urgency=low
* This is mostly a dependencies upgrade.
* Upgraded to Python 3.3.
* Upgraded to Qt 5.
* Minimum Windows version is now Windows 7 64bit.
* Minimum Ubuntu version is now 14.04.
* Minimum OS X version is now 10.7 (Lion).
* ... But with a couple of little improvements.
* Improved documentation.
* Overwrite subfolders' state when setting states in folder dialog (#248)
* The error report dialog now brings the user to Github issues.
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 19 Apr 2014 00:00:00 +0000
dupeguru (3.8.0-1) unstable; urgency=low
* Disable symlink/hardlink deletion option when not relevant. (#247)
* Make Cmd+A select all folders in the Folder Selection dialog. [Mac] (#228)
* Make non-numeric delta comparison case insensitive. (#239)
* Fix surrogate-related UnicodeEncodeError on CSV export. (#210)
* Fixed crash on Dupe Count sorting with Delta + Dupes Only. (#238)
* Improved documentation.
* Important internal refactorings.
* Dropped Ubuntu 12.04 and 12.10 support.
* Removed the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 07 Dec 2013 00:00:00 +0000
dupeguru (3.7.1-1) unstable; urgency=low
* Fixed folder scan type, which was broken in v3.7.0.
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 19 Aug 2013 00:00:00 +0000
dupeguru (3.7.0-1) unstable; urgency=low
* Improved delta values to support non-numerical values. (#213)
* Improved the Re-Prioritize dialog's UI. (#224)
* Added hardlink/symlink support on Windows Vista+. (#220)
* Dropped 32bit support on Mac OS X.
* Added Vietnamese localization by Phan Anh.
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 17 Aug 2013 00:00:00 +0000
dupeguru (3.6.1-1) unstable; urgency=low
* Improved "Make Selection Reference" to make it clearer. (#222)
* Improved "Open Selected" to allow opening more than one file at once. (#142)
* Fixed a few typos here and there. (#216 #225)
* Tweaked the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
* Added Arch Linux packaging
* Added a 64-bit build for Windows.
* Improved Russian localization by Kyrill Detinov.
* Improved Brazilian localization by Victor Figueiredo.
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 28 Apr 2013 00:00:00 +0000
dupeguru (3.6.0-1) unstable; urgency=low
* Added "Export to CSV". (#189)
* Added "Replace with symlinks" to complement "Replace with hardlinks". [Mac, Linux] (#194)
* dupeGuru now tells how many duplicates were affected after each re-prioritization operation. (#204)
* Added Longest/Shortest filename criteria in the re-prioritize dialog. (#198)
* Fixed result table cells which mistakenly became writable in v3.5.0. [Mac] (#203)
* Fixed "Rename Selected" which was broken since v3.5.0. [Mac] (#202)
* Fixed a bug where "Reset to Defaults" in the Columns menu wouldn't refresh menu items' marked state.
* Added Brazilian localization by Victor Figueiredo.
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 08 Aug 2012 00:00:00 +0000
dupeguru (3.5.0-1) unstable; urgency=low
* Added a Deletion Options panel.
* Greatly improved memory usage for big scans.
* Added a keybinding for the filter field. (#182) [Mac]
* Upgraded minimum requirements for Ubuntu to 12.04.
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 01 Jun 2012 00:00:00 +0000
dupeguru (3.4.1-1) unstable; urgency=low
* Fixed the "Folders" scan type. [Mac]
* Fixed localization issues. [Windows, Linux]
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 14 Apr 2012 00:00:00 +0000
dupeguru (3.4.0-1) unstable; urgency=low
* Improved results window UI. [Windows, Linux]
* Added a dialog to edit the Ignore List.
* Added the ability to sort results by "marked" status.
* Fixed "Open with default application". (#190)
* Fixed a bug where there would be a false reporting of discarded matches. (#195)
* Fixed various localization glitches.
* Fixed hard crashes on crash reporting. (#196)
* Fixed bug where the details panel would show up at inconvenient places in the screen. [Windows, Linux]
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 29 Mar 2012 00:00:00 +0000
dupeguru (3.3.3-1) unstable; urgency=low
* Fixed crash on adding some folders. [Mac OS X]
* Added Ukrainian localization by Yuri Petrashko.
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 01 Feb 2012 00:00:00 +0000
dupeguru (3.3.2-1) unstable; urgency=low
* Fixed random hard crashes (yeah, again). [Mac OS X]
* Fixed crash on Export to HTML. [Windows, Linux]
* Added Armenian localization by Hrant Ohanyan.
* Added Russian localization by Igor Pavlov.
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 16 Jan 2012 00:00:00 +0000
dupeguru (3.3.1-1) unstable; urgency=low
* Fixed a couple of nasty crashes.
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 02 Dec 2011 00:00:00 +0000
dupeguru (3.3.0-1) unstable; urgency=low
* Added multiple-selection in folder selection dialog for a more efficient folder removal. (#179)
* Fixed a crash in the prioritize dialog. (#178)
* Fixed a bug where mass marking with a filter would mark more than filtered duplicates. (#181)
* Fixed random hard crashes. [Mac OS X] (#183 #184)
* Added Czech localization by Aleš Nehyba.
* Added Italian localization by Paolo Rossi.
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 30 Nov 2011 00:00:00 +0000
dupeguru (3.2.1-1) unstable; urgency=low
* Fixed a couple of broken action bindings from v3.2.0.
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 02 Oct 2011 00:00:00 +0000
dupeguru (3.2.0-1) unstable; urgency=low
* Added duplicate re-prioritization dialog. (#138)
* Added font size preference for duplicate table. (#82)
* Added Quicklook support. [Mac OS X] (#21)
* Improved behavior of Mark Selected. (#139)
* Improved filename sorting. (#169)
* Added Chinese (Simplified) localization by Eric Dee.
* Tweaked the fairware system.
* Upgraded minimum requirements to OS X 10.6 and Ubuntu 11.04.
-- Virgil Dupras <hsoft@hardcoded.net> Tue, 27 Sep 2011 00:00:00 +0000
dupeguru (3.1.2-1) unstable; urgency=low
* Fixed a bug preventing the Folders scan from working. (#172)
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 25 Aug 2011 00:00:00 +0000
dupeguru (3.1.1-1) unstable; urgency=low
* Added German localization by Gregor Tätzner.
* Improved OS X Lion compatibility. [Mac OS X]
* Made the file collection phase cancellable. (#168)
* Fixed glitch in folder window upon selecting a folder state. [Windows, Linux] (#165)
* Fixed a text coloring glitch in the results. (#156)
* Fixed glitch in the sorting feature of the Folder column. (#161)
* Make sure that saved results have the ".dupeguru" extension. [Linux] (#157)
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 24 Aug 2011 00:00:00 +0000
dupeguru (3.1.0-1) unstable; urgency=low
* Added the "Folders" scan type. (#89)
* Fixed a couple of crashes. (#140 #149)
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 16 Apr 2011 00:00:00 +0000
dupeguru (3.0.2-1) unstable; urgency=low
* Fixed crash after removing marked dupes. (#140)
* Fixed crash on error handling. [Windows] (#144)
* Fixed crash on copy/move. [Windows] (#148)
* Fixed crash when launching dupeGuru from a very long folder name. [Mac OS X] (#119)
* Fixed a refresh bug in directory panel. (#153)
* Improved reliability of the "Send to Trash" operation. [Linux]
* Tweaked Fairware reminders.
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 16 Mar 2011 00:00:00 +0000
dupeguru (3.0.1-1) unstable; urgency=low
* Restored the context menu which had been broken in 3.0.0. [Mac OS X] (#133)
* Fixed a bug where an "unsaved results" warning would be issued on quit even with empty results. (#134)
* Removed focus from the cancel button in the progress dialog to avoid accidental cancellations. [Mac OS X] (#135)
* Folders added through drag and drop are added to the recent folders list. (#136)
* Added a debugging mode. (#132)
* Fixed french localization glitches.
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 27 Jan 2011 00:00:00 +0000
dupeguru (3.0.0-1) unstable; urgency=low
* Re-designed the UI. (#129)
* Internationalized dupeGuru and localized it to french. (#32)
* Changed the format of the help file. (#130)
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 24 Jan 2011 00:00:00 +0000
dupeguru (2.12.3-1) unstable; urgency=low
* Fixed bug causing results to be corrupted after a scan cancellation. (#120)
* Fixed crash when fetching Fairware unpaid hours. (#121)
* Fixed crash when replacing files with hardlinks. (#122)
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 01 Jan 2011 00:00:00 +0000
dupeguru (2.12.2-1) unstable; urgency=low
* Fixed delta column colors which were broken since 2.12.0.
* Fixed column sorting crash. (#108)
* Fixed occasional crash during scan. (#106)
-- Virgil Dupras <hsoft@hardcoded.net> Tue, 05 Oct 2010 00:00:00 +0000
dupeguru (2.12.1-1) unstable; urgency=low
* Re-licensed dupeGuru to BSD and made it [Fairware](http://open.hardcoded.net/about/).
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 30 Sep 2010 00:00:00 +0000
dupeguru (2.12.0-1) unstable; urgency=low
* Improved UI with a little revamp.
* Added the possibility to place hardlinks to references after having deleted duplicates. [Mac OS X, Linux] (#91)
* Added an option to ignore duplicates hardlinking to the same file. [Mac OS X, Linux] (#92)
* Added multiple selection in the "Add Directory" dialog. [Mac OS X] (#105)
* Fixed a bug preventing drag & drop from working in the Directories panel. [Windows, Linux]
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 26 Sep 2010 00:00:00 +0000
dupeguru (2.11.1-1) unstable; urgency=low
* Fixed HTML exporting which was broken in 2.11.0.
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 26 Aug 2010 00:00:00 +0000
dupeguru (2.11.0-1) unstable; urgency=low
* Added the ability to save results (and reload them) at arbitrary locations.
* Improved the way reference files in dupe groups are chosen. (#15)
* Remember size/position of all windows between launches. (#102)
* Fixed a bug sometimes preventing dupeGuru from reloading previous results.
* Fixed a bug sometimes causing the progress dialog to be stuck there. [Mac OS X] (#103)
* Removed the Creation Date column, which wasn't displaying the correct value anyway. (#101)
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 18 Aug 2010 00:00:00 +0000
dupeguru (2.10.1-1) unstable; urgency=low
* Fixed a couple of crashes. (#95, #97, #100)
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 15 Jul 2010 00:00:00 +0000
dupeguru (2.10.0-1) unstable; urgency=low
* Improved error messages when files can't be sent to trash, moved or copied.
* Added a custom command invocation action. (#12)
* Filters are now applied on whole paths. (#4)
-- Virgil Dupras <hsoft@hardcoded.net> Tue, 13 Apr 2010 00:00:00 +0000
dupeguru (2.9.2-1) unstable; urgency=low
* dupeGuru is now 64-bit on Mac OS X!
* Fixed a crash upon quitting when support folder is not present. (#83)
* Fixed a crash during sorting. (#85)
* Fixed selection glitches, especially while renaming. (#93)
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 10 Feb 2010 00:00:00 +0000

View File

@@ -3,5 +3,5 @@
"longname": "dupeGuru", "longname": "dupeGuru",
"execname": "dupeguru", "execname": "dupeguru",
"arch": "any", "arch": "any",
"iconpath": "/usr/share/dupeguru/dgse_logo_128.png" "iconpath": "dupeguru"
} }

View File

@@ -1 +1 @@
3.0 (native) 3.0 (native)

171
qt/app.py
View File

@@ -7,7 +7,7 @@
import sys import sys
import os.path as op import os.path as op
from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox
@@ -27,6 +27,7 @@ from .result_window import ResultWindow
from .directories_dialog import DirectoriesDialog from .directories_dialog import DirectoriesDialog
from .problem_dialog import ProblemDialog from .problem_dialog import ProblemDialog
from .ignore_list_dialog import IgnoreListDialog from .ignore_list_dialog import IgnoreListDialog
from .exclude_list_dialog import ExcludeListDialog
from .deletion_options import DeletionOptions from .deletion_options import DeletionOptions
from .se.details_dialog import DetailsDialog as DetailsDialogStandard from .se.details_dialog import DetailsDialog as DetailsDialogStandard
from .me.details_dialog import DetailsDialog as DetailsDialogMusic from .me.details_dialog import DetailsDialog as DetailsDialogMusic
@@ -35,6 +36,7 @@ from .se.preferences_dialog import PreferencesDialog as PreferencesDialogStandar
from .me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic from .me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic
from .pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture from .pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture
from .pe.photo import File as PlatSpecificPhoto from .pe.photo import File as PlatSpecificPhoto
from .tabbed_window import TabBarWindow, TabWindow
tr = trget("ui") tr = trget("ui")
@@ -47,6 +49,9 @@ class DupeGuru(QObject):
super().__init__(**kwargs) super().__init__(**kwargs)
self.prefs = Preferences() self.prefs = Preferences()
self.prefs.load() self.prefs.load()
# Enable tabs instead of separate floating windows for each dialog
# Could be passed as an argument to this class if we wanted
self.use_tabs = True
self.model = DupeGuruModel(view=self) self.model = DupeGuruModel(view=self)
self._setup() self._setup()
@@ -54,27 +59,61 @@ class DupeGuru(QObject):
def _setup(self): def _setup(self):
core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto
self._setupActions() self._setupActions()
self.details_dialog = None
self._update_options() self._update_options()
self.recentResults = Recent(self, "recentResults") self.recentResults = Recent(self, "recentResults")
self.recentResults.mustOpenItem.connect(self.model.load_from) self.recentResults.mustOpenItem.connect(self.model.load_from)
self.resultWindow = None self.resultWindow = None
self.details_dialog = None if self.use_tabs:
self.directories_dialog = DirectoriesDialog(self) self.main_window = (
self.progress_window = ProgressWindow( TabBarWindow(self)
self.directories_dialog, self.model.progress_window if not self.prefs.tabs_default_pos
) else TabWindow(self)
self.problemDialog = ProblemDialog( )
parent=self.directories_dialog, model=self.model.problem_dialog parent_window = self.main_window
) self.directories_dialog = self.main_window.createPage(
self.ignoreListDialog = IgnoreListDialog( "DirectoriesDialog", app=self
parent=self.directories_dialog, model=self.model.ignore_list_dialog )
) self.main_window.addTab(
self.deletionOptions = DeletionOptions( self.directories_dialog, "Directories", switch=False
parent=self.directories_dialog, model=self.model.deletion_options )
) self.actionDirectoriesWindow.setEnabled(False)
self.about_box = AboutBox(self.directories_dialog, self) else: # floating windows only
self.main_window = None
self.directories_dialog = DirectoriesDialog(self)
parent_window = self.directories_dialog
self.directories_dialog.show() self.progress_window = ProgressWindow(parent_window, self.model.progress_window)
self.problemDialog = ProblemDialog(
parent=parent_window, model=self.model.problem_dialog
)
if self.use_tabs:
self.ignoreListDialog = self.main_window.createPage(
"IgnoreListDialog",
parent=self.main_window,
model=self.model.ignore_list_dialog,
)
self.excludeListDialog = self.main_window.createPage(
"ExcludeListDialog",
app=self,
parent=self.main_window,
model=self.model.exclude_list_dialog,
)
else:
self.ignoreListDialog = IgnoreListDialog(
parent=parent_window, model=self.model.ignore_list_dialog
)
self.excludeDialog = ExcludeListDialog(
app=self, parent=parent_window, model=self.model.exclude_list_dialog
)
self.deletionOptions = DeletionOptions(
parent=parent_window, model=self.model.deletion_options
)
self.about_box = AboutBox(parent_window, self)
parent_window.show()
self.model.load() self.model.load()
self.SIGTERM.connect(self.handleSIGTERM) self.SIGTERM.connect(self.handleSIGTERM)
@@ -98,6 +137,13 @@ class DupeGuru(QObject):
self.preferencesTriggered, self.preferencesTriggered,
), ),
("actionIgnoreList", "", "", tr("Ignore List"), self.ignoreListTriggered), ("actionIgnoreList", "", "", tr("Ignore List"), self.ignoreListTriggered),
(
"actionDirectoriesWindow",
"",
"",
tr("Directories"),
self.showDirectoriesWindow,
),
( (
"actionClearPictureCache", "actionClearPictureCache",
"Ctrl+Shift+P", "Ctrl+Shift+P",
@@ -105,6 +151,13 @@ class DupeGuru(QObject):
tr("Clear Picture Cache"), tr("Clear Picture Cache"),
self.clearPictureCacheTriggered, self.clearPictureCacheTriggered,
), ),
(
"actionExcludeList",
"",
"",
tr("Exclusion Filters"),
self.excludeListTriggered,
),
("actionShowHelp", "F1", "", tr("dupeGuru Help"), self.showHelpTriggered), ("actionShowHelp", "F1", "", tr("dupeGuru Help"), self.showHelpTriggered),
("actionAbout", "", "", tr("About dupeGuru"), self.showAboutBoxTriggered), ("actionAbout", "", "", tr("About dupeGuru"), self.showAboutBoxTriggered),
( (
@@ -152,6 +205,9 @@ class DupeGuru(QObject):
self.model.options["match_scaled"] = self.prefs.match_scaled self.model.options["match_scaled"] = self.prefs.match_scaled
self.model.options["picture_cache_type"] = self.prefs.picture_cache_type self.model.options["picture_cache_type"] = self.prefs.picture_cache_type
if self.details_dialog:
self.details_dialog.update_options()
# --- Private # --- Private
def _get_details_dialog_class(self): def _get_details_dialog_class(self):
if self.model.app_mode == AppMode.Picture: if self.model.app_mode == AppMode.Picture:
@@ -187,11 +243,27 @@ class DupeGuru(QObject):
def show_details(self): def show_details(self):
if self.details_dialog is not None: if self.details_dialog is not None:
self.details_dialog.show() if not self.details_dialog.isVisible():
self.details_dialog.show()
else:
self.details_dialog.hide()
def showResultsWindow(self): def showResultsWindow(self):
if self.resultWindow is not None: if self.resultWindow is not None:
self.resultWindow.show() if self.use_tabs:
if self.main_window.indexOfWidget(self.resultWindow) < 0:
self.main_window.addTab(self.resultWindow, "Results", switch=True)
return
self.main_window.showTab(self.resultWindow)
else:
self.resultWindow.show()
def showDirectoriesWindow(self):
if self.directories_dialog is not None:
if self.use_tabs:
self.main_window.showTab(self.directories_dialog)
else:
self.directories_dialog.show()
def shutdown(self): def shutdown(self):
self.willSavePrefs.emit() self.willSavePrefs.emit()
@@ -212,7 +284,11 @@ class DupeGuru(QObject):
"scanning have accented letters, you'll probably get a crash. It is advised that " "scanning have accented letters, you'll probably get a crash. It is advised that "
"you set your system locale properly." "you set your system locale properly."
) )
QMessageBox.warning(self.directories_dialog, "Wrong Locale", msg) QMessageBox.warning(
self.main_window if self.main_window else self.directories_dialog,
"Wrong Locale",
msg,
)
def clearPictureCacheTriggered(self): def clearPictureCacheTriggered(self):
title = tr("Clear Picture Cache") title = tr("Clear Picture Cache")
@@ -223,7 +299,27 @@ class DupeGuru(QObject):
QMessageBox.information(active, title, tr("Picture cache cleared.")) QMessageBox.information(active, title, tr("Picture cache cleared."))
def ignoreListTriggered(self): def ignoreListTriggered(self):
self.model.ignore_list_dialog.show() if self.use_tabs:
self.showTriggeredTabbedDialog(self.ignoreListDialog, "Ignore List")
else: # floating windows
self.model.ignore_list_dialog.show()
def excludeListTriggered(self):
if self.use_tabs:
self.showTriggeredTabbedDialog(self.excludeListDialog, "Exclusion Filters")
else: # floating windows
self.model.exclude_list_dialog.show()
def showTriggeredTabbedDialog(self, dialog, desc_string):
"""Add tab for dialog, name the tab with desc_string, then show it."""
index = self.main_window.indexOfWidget(dialog)
# Create the tab if it doesn't exist already
if (
index < 0
): # or (not dialog.isVisible() and not self.main_window.isTabVisible(index)):
index = self.main_window.addTab(dialog, desc_string, switch=True)
# Show the tab for that widget
self.main_window.setCurrentIndex(index)
def openDebugLogTriggered(self): def openDebugLogTriggered(self):
debugLogPath = op.join(self.model.appdata, "debug.log") debugLogPath = op.join(self.model.appdata, "debug.log")
@@ -231,7 +327,7 @@ class DupeGuru(QObject):
def preferencesTriggered(self): def preferencesTriggered(self):
preferences_dialog = self._get_preferences_dialog_class()( preferences_dialog = self._get_preferences_dialog_class()(
self.directories_dialog, self self.main_window if self.main_window else self.directories_dialog, self
) )
preferences_dialog.load() preferences_dialog.load()
result = preferences_dialog.exec() result = preferences_dialog.exec()
@@ -242,7 +338,13 @@ class DupeGuru(QObject):
preferences_dialog.setParent(None) preferences_dialog.setParent(None)
def quitTriggered(self): def quitTriggered(self):
self.directories_dialog.close() if self.details_dialog is not None:
self.details_dialog.close()
if self.main_window:
self.main_window.close()
else:
self.directories_dialog.close()
def showAboutBoxTriggered(self): def showAboutBoxTriggered(self):
self.about_box.show() self.about_box.show()
@@ -253,7 +355,7 @@ class DupeGuru(QObject):
if op.exists(help_path): if op.exists(help_path):
url = QUrl.fromLocalFile(help_path) url = QUrl.fromLocalFile(help_path)
else: else:
url = QUrl("https://www.hardcoded.net/dupeguru/help/en/") url = QUrl("https://dupeguru.voltaicideas.net/help/en/")
QDesktopServices.openUrl(url) QDesktopServices.openUrl(url)
def handleSIGTERM(self): def handleSIGTERM(self):
@@ -274,15 +376,28 @@ class DupeGuru(QObject):
return self.confirm("", prompt) return self.confirm("", prompt)
def create_results_window(self): def create_results_window(self):
"""Creates resultWindow and details_dialog depending on the selected ``app_mode``. """Creates resultWindow and details_dialog depending on the selected ``app_mode``."""
"""
if self.details_dialog is not None: if self.details_dialog is not None:
# The object is not deleted entirely, avoid saving its geometry in the future
# self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs)
# or simply delete it on close which is probably cleaner:
self.details_dialog.setAttribute(Qt.WA_DeleteOnClose)
self.details_dialog.close() self.details_dialog.close()
# if we don't do the following, Qt will crash when we recreate the Results dialog
self.details_dialog.setParent(None) self.details_dialog.setParent(None)
if self.resultWindow is not None: if self.resultWindow is not None:
self.resultWindow.close() self.resultWindow.close()
self.resultWindow.setParent(None) # This is better for tabs, as it takes care of duplicate items in menu bar
self.resultWindow = ResultWindow(self.directories_dialog, self) self.resultWindow.deleteLater() if self.use_tabs else self.resultWindow.setParent(
None
)
if self.use_tabs:
self.resultWindow = self.main_window.createPage(
"ResultWindow", parent=self.main_window, app=self
)
else: # We don't use a tab widget, regular floating QMainWindow
self.resultWindow = ResultWindow(self.directories_dialog, self)
self.directories_dialog._updateActionsState()
self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self)
def show_results_window(self): def show_results_window(self):

View File

@@ -7,34 +7,63 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog from PyQt5.QtWidgets import QDockWidget, QWidget
from .details_table import DetailsModel from .details_table import DetailsModel
from hscommon.plat import ISLINUX
class DetailsDialog(QDialog): class DetailsDialog(QDockWidget):
def __init__(self, parent, app, **kwargs): def __init__(self, parent, app, **kwargs):
super().__init__(parent, Qt.Tool, **kwargs) super().__init__(parent, Qt.Tool, **kwargs)
self.parent = parent
self.app = app self.app = app
self.model = app.model.details_panel self.model = app.model.details_panel
self.setAllowedAreas(Qt.AllDockWidgetAreas)
self._setupUi() self._setupUi()
# To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog # To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog
# has been shown. If it has, we know that our geometry should be saved. # has been shown. If it has, we know that our geometry should be saved.
self._shown_once = False self._shown_once = False
self.app.prefs.restoreGeometry("DetailsWindowRect", self) self._wasDocked, area = self.app.prefs.restoreGeometry("DetailsWindowRect", self)
self.tableModel = DetailsModel(self.model) self.tableModel = DetailsModel(self.model, app)
# tableView is defined in subclasses # tableView is defined in subclasses
self.tableView.setModel(self.tableModel) self.tableView.setModel(self.tableModel)
self.model.view = self self.model.view = self
self.app.willSavePrefs.connect(self.appWillSavePrefs) self.app.willSavePrefs.connect(self.appWillSavePrefs)
# self.setAttribute(Qt.WA_DeleteOnClose)
parent.addDockWidget(
area if self._wasDocked else Qt.BottomDockWidgetArea, self)
def _setupUi(self): # Virtual def _setupUi(self): # Virtual
pass pass
def show(self): def show(self):
if not self._shown_once and self._wasDocked:
self.setFloating(False)
self._shown_once = True self._shown_once = True
super().show() super().show()
self.update_options()
def update_options(self):
# This disables the title bar (if we had not set one before already)
# essentially making it a simple floating window, not dockable anymore
if not self.app.prefs.details_dialog_titlebar_enabled:
if not self.titleBarWidget(): # default title bar
self.setTitleBarWidget(QWidget()) # disables title bar
# Windows (and MacOS?) users cannot move a floating window which
# has not native decoration so we force it to dock for now
if not ISLINUX:
self.setFloating(False)
elif self.titleBarWidget() is not None: # title bar is disabled
self.setTitleBarWidget(None) # resets to the default title bar
elif not self.titleBarWidget() and not self.app.prefs.details_dialog_titlebar_enabled:
self.setTitleBarWidget(QWidget())
features = self.features()
if self.app.prefs.details_dialog_vertical_titlebar:
self.setFeatures(features | QDockWidget.DockWidgetVerticalTitleBar)
elif features & QDockWidget.DockWidgetVerticalTitleBar:
self.setFeatures(features ^ QDockWidget.DockWidgetVerticalTitleBar)
# --- Events # --- Events
def appWillSavePrefs(self): def appWillSavePrefs(self):

View File

@@ -8,18 +8,20 @@
from PyQt5.QtCore import Qt, QAbstractTableModel from PyQt5.QtCore import Qt, QAbstractTableModel
from PyQt5.QtWidgets import QHeaderView, QTableView from PyQt5.QtWidgets import QHeaderView, QTableView
from PyQt5.QtGui import QFont, QBrush
from hscommon.trans import trget from hscommon.trans import trget
tr = trget("ui") tr = trget("ui")
HEADER = [tr("Attribute"), tr("Selected"), tr("Reference")] HEADER = [tr("Selected"), tr("Reference")]
class DetailsModel(QAbstractTableModel): class DetailsModel(QAbstractTableModel):
def __init__(self, model, **kwargs): def __init__(self, model, app, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.model = model self.model = model
self.prefs = app.prefs
def columnCount(self, parent): def columnCount(self, parent):
return len(HEADER) return len(HEADER)
@@ -27,11 +29,27 @@ class DetailsModel(QAbstractTableModel):
def data(self, index, role): def data(self, index, role):
if not index.isValid(): if not index.isValid():
return None return None
if role != Qt.DisplayRole: # Skip first value "Attribute"
return None column = index.column() + 1
column = index.column()
row = index.row() row = index.row()
return self.model.row(row)[column]
ignored_fields = ["Dupe Count"]
if (self.model.row(row)[0] in ignored_fields
or self.model.row(row)[1] == "---"
or self.model.row(row)[2] == "---"):
if role != Qt.DisplayRole:
return None
return self.model.row(row)[column]
if role == Qt.DisplayRole:
return self.model.row(row)[column]
if role == Qt.ForegroundRole and self.model.row(row)[1] != self.model.row(row)[2]:
return QBrush(self.prefs.details_table_delta_foreground_color)
if role == Qt.FontRole and self.model.row(row)[1] != self.model.row(row)[2]:
font = QFont(self.model.view.font()) # or simply QFont()
font.setBold(True)
return font
return None # QVariant()
def headerData(self, section, orientation, role): def headerData(self, section, orientation, role):
if ( if (
@@ -40,6 +58,13 @@ class DetailsModel(QAbstractTableModel):
and section < len(HEADER) and section < len(HEADER)
): ):
return HEADER[section] return HEADER[section]
elif (
orientation == Qt.Vertical
and role == Qt.DisplayRole
and section < self.model.row_count()
):
# Read "Attribute" cell for horizontal header
return self.model.row(section)[0]
return None return None
def rowCount(self, parent): def rowCount(self, parent):
@@ -51,18 +76,22 @@ class DetailsTable(QTableView):
QTableView.__init__(self, *args) QTableView.__init__(self, *args)
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
self.setSelectionBehavior(QTableView.SelectRows) self.setSelectionBehavior(QTableView.SelectRows)
self.setSelectionMode(QTableView.NoSelection)
self.setShowGrid(False) self.setShowGrid(False)
self.setWordWrap(False)
self.setCornerButtonEnabled(False)
def setModel(self, model): def setModel(self, model):
QTableView.setModel(self, model) QTableView.setModel(self, model)
# The model needs to be set to set header stuff # The model needs to be set to set header stuff
hheader = self.horizontalHeader() hheader = self.horizontalHeader()
hheader.setHighlightSections(False) hheader.setHighlightSections(False)
hheader.setStretchLastSection(False) hheader.setSectionResizeMode(0, QHeaderView.Stretch)
hheader.resizeSection(0, 100)
hheader.setSectionResizeMode(0, QHeaderView.Fixed)
hheader.setSectionResizeMode(1, QHeaderView.Stretch) hheader.setSectionResizeMode(1, QHeaderView.Stretch)
hheader.setSectionResizeMode(2, QHeaderView.Stretch)
vheader = self.verticalHeader() vheader = self.verticalHeader()
vheader.setVisible(False) vheader.setVisible(True)
vheader.setDefaultSectionSize(18) vheader.setDefaultSectionSize(18)
# hardcoded value above is not ideal, perhaps resize to contents first?
# vheader.setSectionResizeMode(QHeaderView.ResizeToContents)
vheader.setSectionResizeMode(QHeaderView.Fixed)
vheader.setSectionsMovable(True)

View File

@@ -5,5 +5,11 @@
<file alias="plus">../images/plus_8.png</file> <file alias="plus">../images/plus_8.png</file>
<file alias="minus">../images/minus_8.png</file> <file alias="minus">../images/minus_8.png</file>
<file alias="search_clear_13">../qtlib/images/search_clear_13.png</file> <file alias="search_clear_13">../qtlib/images/search_clear_13.png</file>
<file alias="exchange">../images/exchange_purple_upscaled.png</file>
<file alias="zoom_in">../images/old_zoom_in.png</file>
<file alias="zoom_out">../images/old_zoom_out.png</file>
<file alias="zoom_original">../images/old_zoom_original.png</file>
<file alias="zoom_best_fit">../images/old_zoom_best_fit.png</file>
<file alias="error">../images/dialog-error.png</file>
</qresource> </qresource>
</RCC> </RCC>

View File

@@ -40,6 +40,7 @@ class DirectoriesDialog(QMainWindow):
def __init__(self, app, **kwargs): def __init__(self, app, **kwargs):
super().__init__(None, **kwargs) super().__init__(None, **kwargs)
self.app = app self.app = app
self.specific_actions = set()
self.lastAddedFolder = platform.INITIAL_FOLDER_IN_DIALOGS self.lastAddedFolder = platform.INITIAL_FOLDER_IN_DIALOGS
self.recentFolders = Recent(self.app, "recentFolders") self.recentFolders = Recent(self.app, "recentFolders")
self._setupUi() self._setupUi()
@@ -87,42 +88,66 @@ class DirectoriesDialog(QMainWindow):
"actionShowResultsWindow", "actionShowResultsWindow",
"", "",
"", "",
tr("Results Window"), tr("Scan Results"),
self.app.showResultsWindow, self.app.showResultsWindow,
), ),
("actionAddFolder", "", "", tr("Add Folder..."), self.addFolderTriggered), ("actionAddFolder", "", "", tr("Add Folder..."), self.addFolderTriggered),
("actionLoadDirectories", "", "", tr("Load Directories..."), self.loadDirectoriesTriggered),
("actionSaveDirectories", "", "", tr("Save Directories..."), self.saveDirectoriesTriggered),
] ]
createActions(ACTIONS, self) createActions(ACTIONS, self)
if self.app.use_tabs:
# Keep track of actions which should only be accessible from this window
self.specific_actions.add(self.actionLoadDirectories)
self.specific_actions.add(self.actionSaveDirectories)
def _setupMenu(self): def _setupMenu(self):
self.menubar = QMenuBar(self) if not self.app.use_tabs:
self.menubar.setGeometry(QRect(0, 0, 42, 22)) # we are our own QMainWindow, we need our own menu bar
self.menuFile = QMenu(self.menubar) self.menubar = QMenuBar(self)
self.menuFile.setTitle(tr("File")) self.menubar.setGeometry(QRect(0, 0, 42, 22))
self.menuView = QMenu(self.menubar) self.menuFile = QMenu(self.menubar)
self.menuView.setTitle(tr("View")) self.menuFile.setTitle(tr("File"))
self.menuHelp = QMenu(self.menubar) self.menuView = QMenu(self.menubar)
self.menuHelp.setTitle(tr("Help")) self.menuView.setTitle(tr("View"))
self.menuHelp = QMenu(self.menubar)
self.menuHelp.setTitle(tr("Help"))
self.setMenuBar(self.menubar)
menubar = self.menubar
else:
# we are part of a tab widget, we populate its window's menubar instead
self.menuFile = self.app.main_window.menuFile
self.menuView = self.app.main_window.menuView
self.menuHelp = self.app.main_window.menuHelp
menubar = self.app.main_window.menubar
self.menuLoadRecent = QMenu(self.menuFile) self.menuLoadRecent = QMenu(self.menuFile)
self.menuLoadRecent.setTitle(tr("Load Recent Results")) self.menuLoadRecent.setTitle(tr("Load Recent Results"))
self.setMenuBar(self.menubar)
self.menuFile.addAction(self.actionLoadResults) self.menuFile.addAction(self.actionLoadResults)
self.menuFile.addAction(self.menuLoadRecent.menuAction()) self.menuFile.addAction(self.menuLoadRecent.menuAction())
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.app.actionClearPictureCache) self.menuFile.addAction(self.app.actionClearPictureCache)
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.actionLoadDirectories)
self.menuFile.addAction(self.actionSaveDirectories)
self.menuFile.addSeparator()
self.menuFile.addAction(self.app.actionQuit) self.menuFile.addAction(self.app.actionQuit)
self.menuView.addAction(self.app.actionPreferences)
self.menuView.addAction(self.app.actionDirectoriesWindow)
self.menuView.addAction(self.actionShowResultsWindow) self.menuView.addAction(self.actionShowResultsWindow)
self.menuView.addAction(self.app.actionIgnoreList) self.menuView.addAction(self.app.actionIgnoreList)
self.menuView.addAction(self.app.actionExcludeList)
self.menuView.addSeparator()
self.menuView.addAction(self.app.actionPreferences)
self.menuHelp.addAction(self.app.actionShowHelp) self.menuHelp.addAction(self.app.actionShowHelp)
self.menuHelp.addAction(self.app.actionOpenDebugLog) self.menuHelp.addAction(self.app.actionOpenDebugLog)
self.menuHelp.addAction(self.app.actionAbout) self.menuHelp.addAction(self.app.actionAbout)
self.menubar.addAction(self.menuFile.menuAction()) menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuView.menuAction()) menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuHelp.menuAction()) menubar.addAction(self.menuHelp.menuAction())
# Recent folders menu # Recent folders menu
self.menuRecentFolders = QMenu() self.menuRecentFolders = QMenu()
@@ -139,6 +164,8 @@ class DirectoriesDialog(QMainWindow):
self.resize(420, 338) self.resize(420, 338)
self.centralwidget = QWidget(self) self.centralwidget = QWidget(self)
self.verticalLayout = QVBoxLayout(self.centralwidget) self.verticalLayout = QVBoxLayout(self.centralwidget)
self.verticalLayout.setContentsMargins(4, 0, 4, 0)
self.verticalLayout.setSpacing(0)
hl = QHBoxLayout() hl = QHBoxLayout()
label = QLabel(tr("Application Mode:"), self) label = QLabel(tr("Application Mode:"), self)
label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
@@ -306,9 +333,25 @@ class DirectoriesDialog(QMainWindow):
self.app.model.load_from(destination) self.app.model.load_from(destination)
self.app.recentResults.insertItem(destination) self.app.recentResults.insertItem(destination)
def loadDirectoriesTriggered(self):
title = tr("Select a directories file to load")
files = ";;".join([tr("dupeGuru Results (*.dupegurudirs)"), tr("All Files (*.*)")])
destination = QFileDialog.getOpenFileName(self, title, "", files)[0]
if destination:
self.app.model.load_directories(destination)
def removeFolderButtonClicked(self): def removeFolderButtonClicked(self):
self.directoriesModel.model.remove_selected() self.directoriesModel.model.remove_selected()
def saveDirectoriesTriggered(self):
title = tr("Select a file to save your directories to")
files = tr("dupeGuru Directories (*.dupegurudirs)")
destination, chosen_filter = QFileDialog.getSaveFileName(self, title, "", files)
if destination:
if not destination.endswith(".dupegurudirs"):
destination = "{}.dupegurudirs".format(destination)
self.app.model.save_directories_as(destination)
def scanButtonClicked(self): def scanButtonClicked(self):
if self.app.model.results.is_modified: if self.app.model.results.is_modified:
title = tr("Start a new scan") title = tr("Start a new scan")

165
qt/exclude_list_dialog.py Normal file
View File

@@ -0,0 +1,165 @@
# 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 re
from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtWidgets import (
QPushButton, QLineEdit, QVBoxLayout, QGridLayout, QDialog,
QTableView, QAbstractItemView, QSpacerItem, QSizePolicy, QHeaderView
)
from .exclude_list_table import ExcludeListTable
from core.exclude import AlreadyThereException
from hscommon.trans import trget
tr = trget("ui")
class ExcludeListDialog(QDialog):
def __init__(self, app, parent, model, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs)
self.app = app
self.specific_actions = frozenset()
self._setupUI()
self.model = model # ExcludeListDialogCore
self.model.view = self
self.table = ExcludeListTable(app, view=self.tableView) # Qt ExcludeListTable
self._row_matched = False # test if at least one row matched our test string
self._input_styled = False
self.buttonAdd.clicked.connect(self.addStringFromLineEdit)
self.buttonRemove.clicked.connect(self.removeSelected)
self.buttonRestore.clicked.connect(self.restoreDefaults)
self.buttonClose.clicked.connect(self.accept)
self.buttonHelp.clicked.connect(self.display_help_message)
self.buttonTestString.clicked.connect(self.onTestStringButtonClicked)
self.inputLine.textEdited.connect(self.reset_input_style)
self.testLine.textEdited.connect(self.reset_input_style)
self.testLine.textEdited.connect(self.reset_table_style)
def _setupUI(self):
layout = QVBoxLayout(self)
gridlayout = QGridLayout()
self.buttonAdd = QPushButton(tr("Add"))
self.buttonRemove = QPushButton(tr("Remove Selected"))
self.buttonRestore = QPushButton(tr("Restore defaults"))
self.buttonTestString = QPushButton(tr("Test string"))
self.buttonClose = QPushButton(tr("Close"))
self.buttonHelp = QPushButton(tr("Help"))
self.inputLine = QLineEdit()
self.testLine = QLineEdit()
self.tableView = QTableView()
triggers = (
QAbstractItemView.DoubleClicked
| QAbstractItemView.EditKeyPressed
| QAbstractItemView.SelectedClicked
)
self.tableView.setEditTriggers(triggers)
self.tableView.setSelectionMode(QTableView.ExtendedSelection)
self.tableView.setSelectionBehavior(QTableView.SelectRows)
self.tableView.setShowGrid(False)
vheader = self.tableView.verticalHeader()
vheader.setSectionsMovable(True)
vheader.setVisible(False)
hheader = self.tableView.horizontalHeader()
hheader.setSectionsMovable(False)
hheader.setSectionResizeMode(QHeaderView.Fixed)
hheader.setStretchLastSection(True)
hheader.setHighlightSections(False)
hheader.setVisible(True)
gridlayout.addWidget(self.inputLine, 0, 0)
gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonHelp, 3, 1, Qt.AlignLeft)
gridlayout.addWidget(self.buttonClose, 4, 1)
gridlayout.addWidget(self.tableView, 1, 0, 6, 1)
gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 4, 1)
gridlayout.addWidget(self.buttonTestString, 6, 1)
gridlayout.addWidget(self.testLine, 6, 0)
layout.addLayout(gridlayout)
self.inputLine.setPlaceholderText(tr("Type a python regular expression here..."))
self.inputLine.setFocus()
self.testLine.setPlaceholderText(tr("Type a file system path or filename here..."))
self.testLine.setClearButtonEnabled(True)
# --- model --> view
def show(self):
super().show()
self.inputLine.setFocus()
@pyqtSlot()
def addStringFromLineEdit(self):
text = self.inputLine.text()
if not text:
return
try:
self.model.add(text)
except AlreadyThereException:
self.app.show_message("Expression already in the list.")
return
except Exception as e:
self.app.show_message(f"Expression is invalid: {e}")
return
self.inputLine.clear()
def removeSelected(self):
self.model.remove_selected()
def restoreDefaults(self):
self.model.restore_defaults()
def onTestStringButtonClicked(self):
input_text = self.testLine.text()
if not input_text:
self.reset_input_style()
return
# if at least one row matched, we know whether table is highlighted or not
self._row_matched = self.model.test_string(input_text)
self.table.refresh()
input_regex = self.inputLine.text()
if not input_regex:
self.reset_input_style()
return
try:
compiled = re.compile(input_regex)
except re.error:
self.reset_input_style()
return
match = compiled.match(input_text)
if match:
self._input_styled = True
self.inputLine.setStyleSheet("background-color: rgb(10, 200, 10);")
else:
self.reset_input_style()
def reset_input_style(self):
"""Reset regex input line background"""
if self._input_styled:
self._input_styled = False
self.inputLine.setStyleSheet(self.styleSheet())
def reset_table_style(self):
if self._row_matched:
self._row_matched = False
self.model.reset_rows_highlight()
self.table.refresh()
def display_help_message(self):
self.app.show_message(tr("""\
These (case sensitive) python regular expressions will filter out files during scans.<br>\
Directores will also have their <strong>default state</strong> set to Excluded \
in the Directories tab if their name happen to match one of the regular expressions.<br>\
For each file collected two tests are perfomed on each of them to determine whether or not to filter them out:<br>\
<li>1. Regular expressions with no path separator in them will be compared to the file name only.</li>
<li>2. Regular expressions with no path separator in them will be compared to the full path to the file.</li><br>
Example: if you want to filter out .PNG files from the "My Pictures" directory only:<br>\
<code>.*My\\sPictures\\\\.*\\.png</code><br><br>\
You can test the regular expression with the test string feature by pasting a fake path in it:<br>\
<code>C:\\\\User\\My Pictures\\test.png</code><br><br>
Matching regular expressions will be highlighted.<br>\
If there is at least one highlight, the path tested will be ignored during scans.<br><br>\
Directories and files starting with a period '.' are filtered out by default.<br><br>"""))

77
qt/exclude_list_table.py Normal file
View File

@@ -0,0 +1,77 @@
# 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 PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QFontMetrics, QIcon, QColor
from qtlib.column import Column
from qtlib.table import Table
from hscommon.trans import trget
tr = trget("ui")
class ExcludeListTable(Table):
"""Model for exclude list"""
COLUMNS = [
Column("marked", defaultWidth=15),
Column("regex", defaultWidth=230)
]
def __init__(self, app, view, **kwargs):
model = app.model.exclude_list_dialog.exclude_list_table # pointer to GUITable
super().__init__(model, view, **kwargs)
font = view.font()
font.setPointSize(app.prefs.tableFontSize)
view.setFont(font)
fm = QFontMetrics(font)
view.verticalHeader().setDefaultSectionSize(fm.height() + 2)
# app.willSavePrefs.connect(self.appWillSavePrefs)
def _getData(self, row, column, role):
if column.name == "marked":
if role == Qt.CheckStateRole and row.markable:
return Qt.Checked if row.marked else Qt.Unchecked
if role == Qt.ToolTipRole and not row.markable:
return tr("Compilation error: ") + row.get_cell_value("error")
if role == Qt.DecorationRole and not row.markable:
return QIcon.fromTheme("dialog-error", QIcon(":/error"))
return None
if role == Qt.DisplayRole:
return row.data[column.name]
elif role == Qt.FontRole:
return QFont(self.view.font())
elif role == Qt.BackgroundRole and column.name == "regex":
if row.highlight:
return QColor(10, 200, 10) # green
elif role == Qt.EditRole:
if column.name == "regex":
return row.data[column.name]
return None
def _getFlags(self, row, column):
flags = Qt.ItemIsEnabled
if column.name == "marked":
if row.markable:
flags |= Qt.ItemIsUserCheckable
elif column.name == "regex":
flags |= Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
return flags
def _setData(self, row, column, value, role):
if role == Qt.CheckStateRole:
if column.name == "marked":
row.marked = bool(value)
return True
elif role == Qt.EditRole:
if column.name == "regex":
return self.model.rename_selected(value)
return False
# def sort(self, column, order):
# column = self.model.COLUMNS[column]
# self.model.sort(column.name, order == Qt.AscendingOrder)
# # --- Events
# def appWillSavePrefs(self):
# self.model.columns.save_columns()

View File

@@ -26,6 +26,7 @@ class IgnoreListDialog(QDialog):
def __init__(self, parent, model, **kwargs): def __init__(self, parent, model, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
super().__init__(parent, flags, **kwargs) super().__init__(parent, flags, **kwargs)
self.specific_actions = frozenset()
self._setupUi() self._setupUi()
self.model = model self.model = model
self.model.view = self self.model.view = self
@@ -39,6 +40,7 @@ class IgnoreListDialog(QDialog):
self.setWindowTitle(tr("Ignore List")) self.setWindowTitle(tr("Ignore List"))
self.resize(540, 330) self.resize(540, 330)
self.verticalLayout = QVBoxLayout(self) self.verticalLayout = QVBoxLayout(self)
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.tableView = QTableView() self.tableView = QTableView()
self.tableView.setEditTriggers(QAbstractItemView.NoEditTriggers) self.tableView.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tableView.setSelectionMode(QAbstractItemView.ExtendedSelection) self.tableView.setSelectionMode(QAbstractItemView.ExtendedSelection)
@@ -48,6 +50,7 @@ class IgnoreListDialog(QDialog):
self.tableView.verticalHeader().setDefaultSectionSize(18) self.tableView.verticalHeader().setDefaultSectionSize(18)
self.tableView.verticalHeader().setHighlightSections(False) self.tableView.verticalHeader().setHighlightSections(False)
self.tableView.verticalHeader().setVisible(False) self.tableView.verticalHeader().setVisible(False)
self.tableView.setWordWrap(False)
self.verticalLayout.addWidget(self.tableView) self.verticalLayout.addWidget(self.tableView)
self.removeSelectedButton = QPushButton(tr("Remove Selected")) self.removeSelectedButton = QPushButton(tr("Remove Selected"))
self.clearButton = QPushButton(tr("Clear")) self.clearButton = QPushButton(tr("Clear"))

View File

@@ -10,6 +10,8 @@ from qtlib.table import Table
class IgnoreListTable(Table): class IgnoreListTable(Table):
""" Ignore list model"""
COLUMNS = [ COLUMNS = [
Column("path1", defaultWidth=230), Column("path1", defaultWidth=230),
Column("path2", defaultWidth=230), Column("path2", defaultWidth=230),

View File

@@ -5,7 +5,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import QSize from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView from PyQt5.QtWidgets import QAbstractItemView
from hscommon.trans import trget from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_dialog import DetailsDialog as DetailsDialogBase
@@ -19,11 +19,8 @@ class DetailsDialog(DetailsDialogBase):
self.setWindowTitle(tr("Details")) self.setWindowTitle(tr("Details"))
self.resize(502, 295) self.resize(502, 295)
self.setMinimumSize(QSize(250, 250)) self.setMinimumSize(QSize(250, 250))
self.verticalLayout = QVBoxLayout(self)
self.verticalLayout.setSpacing(0)
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.tableView = DetailsTable(self) self.tableView = DetailsTable(self)
self.tableView.setAlternatingRowColors(True) self.tableView.setAlternatingRowColors(True)
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tableView.setShowGrid(False) self.tableView.setShowGrid(False)
self.verticalLayout.addWidget(self.tableView) self.setWidget(self.tableView)

View File

@@ -76,7 +76,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.debugModeBox) self.widgetsVLayout.addWidget(self.debugModeBox)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs, setchecked): def _load(self, prefs, setchecked, section):
setchecked(self.tagTrackBox, prefs.scan_tag_track) setchecked(self.tagTrackBox, prefs.scan_tag_track)
setchecked(self.tagArtistBox, prefs.scan_tag_artist) setchecked(self.tagArtistBox, prefs.scan_tag_artist)
setchecked(self.tagAlbumBox, prefs.scan_tag_album) setchecked(self.tagAlbumBox, prefs.scan_tag_album)

View File

@@ -4,115 +4,142 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, QSize from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QVBoxLayout, QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame)
QAbstractItemView, from PyQt5.QtGui import QResizeEvent
QHBoxLayout,
QLabel,
QSizePolicy,
)
from hscommon.trans import trget from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_dialog import DetailsDialog as DetailsDialogBase
from ..details_table import DetailsTable from ..details_table import DetailsTable
from .image_viewer import (
ViewerToolBar, ScrollAreaImageViewer, ScrollAreaController)
tr = trget("ui") tr = trget("ui")
class DetailsDialog(DetailsDialogBase): class DetailsDialog(DetailsDialogBase):
def __init__(self, parent, app): def __init__(self, parent, app):
DetailsDialogBase.__init__(self, parent, app) self.vController = None
self.selectedPixmap = None self.app = app
self.referencePixmap = None super().__init__(parent, app)
def _setupUi(self): def _setupUi(self):
self.setWindowTitle(tr("Details")) self.setWindowTitle(tr("Details"))
self.resize(502, 295) self.resize(502, 502)
self.setMinimumSize(QSize(250, 250)) self.setMinimumSize(QSize(250, 250))
self.verticalLayout = QVBoxLayout(self) self.splitter = QSplitter(Qt.Vertical)
self.verticalLayout.setSpacing(0) self.topFrame = EmittingFrame()
self.verticalLayout.setContentsMargins(0, 0, 0, 0) self.topFrame.setFrameShape(QFrame.StyledPanel)
self.horizontalLayout = QHBoxLayout() self.horizontalLayout = QGridLayout()
self.horizontalLayout.setSpacing(4) # Minimum width for the toolbar in the middle:
self.selectedImage = QLabel(self) self.horizontalLayout.setColumnMinimumWidth(1, 10)
sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) self.horizontalLayout.setContentsMargins(0, 0, 0, 0)
sizePolicy.setHorizontalStretch(0) self.horizontalLayout.setColumnStretch(0, 32)
sizePolicy.setVerticalStretch(0) # Smaller value for the toolbar in the middle to avoid excessive resize
sizePolicy.setHeightForWidth( self.horizontalLayout.setColumnStretch(1, 2)
self.selectedImage.sizePolicy().hasHeightForWidth() self.horizontalLayout.setColumnStretch(2, 32)
) # This avoids toolbar getting incorrectly partially hidden when window resizes
self.selectedImage.setSizePolicy(sizePolicy) self.horizontalLayout.setRowStretch(0, 1)
self.selectedImage.setScaledContents(False) self.horizontalLayout.setRowStretch(1, 24)
self.selectedImage.setAlignment(Qt.AlignCenter) self.horizontalLayout.setRowStretch(2, 1)
self.horizontalLayout.addWidget(self.selectedImage) self.horizontalLayout.setSpacing(1) # probably not important
self.referenceImage = QLabel(self)
sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage")
sizePolicy.setHorizontalStretch(0) self.horizontalLayout.addWidget(self.selectedImageViewer, 0, 0, 3, 1)
sizePolicy.setVerticalStretch(0) # Use a specific type of controller depending on the underlying viewer type
sizePolicy.setHeightForWidth( self.vController = ScrollAreaController(self)
self.referenceImage.sizePolicy().hasHeightForWidth()
) self.verticalToolBar = ViewerToolBar(self, self.vController)
self.referenceImage.setSizePolicy(sizePolicy) self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical))
self.referenceImage.setAlignment(Qt.AlignCenter) self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter)
self.horizontalLayout.addWidget(self.referenceImage)
self.verticalLayout.addLayout(self.horizontalLayout) self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage")
self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1)
self.topFrame.setLayout(self.horizontalLayout)
self.splitter.addWidget(self.topFrame)
self.splitter.setStretchFactor(0, 8)
self.tableView = DetailsTable(self) self.tableView = DetailsTable(self)
sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.tableView.sizePolicy().hasHeightForWidth())
self.tableView.setSizePolicy(sizePolicy) self.tableView.setSizePolicy(sizePolicy)
self.tableView.setMinimumSize(QSize(0, 188)) # self.tableView.setMinimumSize(QSize(0, 190))
self.tableView.setMaximumSize(QSize(16777215, 190)) # self.tableView.setMaximumSize(QSize(16777215, 190))
self.tableView.setAlternatingRowColors(True) self.tableView.setAlternatingRowColors(True)
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tableView.setShowGrid(False) self.tableView.setShowGrid(False)
self.verticalLayout.addWidget(self.tableView) self.splitter.addWidget(self.tableView)
self.splitter.setStretchFactor(1, 1)
# Late population needed here for connections to the toolbar
self.vController.setupViewers(
self.selectedImageViewer, self.referenceImageViewer)
# self.setCentralWidget(self.splitter) # only as QMainWindow
self.setWidget(self.splitter) # only as QDockWidget
self.topFrame.resized.connect(self.resizeEvent)
def _update(self): def _update(self):
if self.vController is None: # Not yet constructed!
return
if not self.app.model.selected_dupes: if not self.app.model.selected_dupes:
# No item from the model, disable and clear everything.
self.vController.resetViewersState()
return return
dupe = self.app.model.selected_dupes[0] dupe = self.app.model.selected_dupes[0]
group = self.app.model.results.get_group_of_duplicate(dupe) group = self.app.model.results.get_group_of_duplicate(dupe)
ref = group.ref ref = group.ref
self.selectedPixmap = QPixmap(str(dupe.path)) self.vController.updateView(ref, dupe, group)
if ref is dupe:
self.referencePixmap = None
else:
self.referencePixmap = QPixmap(str(ref.path))
self._updateImages()
def _updateImages(self):
if self.selectedPixmap is not None:
target_size = self.selectedImage.size()
scaledPixmap = self.selectedPixmap.scaled(
target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.selectedImage.setPixmap(scaledPixmap)
else:
self.selectedImage.setPixmap(QPixmap())
if self.referencePixmap is not None:
target_size = self.referenceImage.size()
scaledPixmap = self.referencePixmap.scaled(
target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.referenceImage.setPixmap(scaledPixmap)
else:
self.referenceImage.setPixmap(QPixmap())
# --- Override # --- Override
@pyqtSlot(QResizeEvent)
def resizeEvent(self, event): def resizeEvent(self, event):
self._updateImages() self.ensure_same_sizes()
if self.vController is None or not self.vController.bestFit:
return
# Only update the scaled down pixmaps
self.vController.updateBothImages()
def show(self): def show(self):
# Give the splitter a maximum height to reach. This is assuming that
# all rows below their headers have the same height
self.tableView.setMaximumHeight(
self.tableView.rowHeight(1)
* self.tableModel.model.row_count()
+ self.tableView.verticalHeader().sectionSize(0)
# looks like the handle is taken into account by the splitter
+ self.splitter.handle(1).size().height())
DetailsDialogBase.show(self) DetailsDialogBase.show(self)
self.ensure_same_sizes()
self._update() self._update()
def ensure_same_sizes(self):
# HACK This ensures same size while shrinking.
# ReferenceViewer might be 1 pixel shorter in width
# due to the toolbar in the middle keeping the same width,
# so resizing in the GridLayout's engine leads to not enough space
# left for the panel on the right.
# This work as a QMainWindow, but doesn't work as a QDockWidget:
# resize can only grow. Might need some custom sizeHint somewhere...
# self.horizontalLayout.setColumnMinimumWidth(
# 0, self.selectedImageViewer.size().width())
# self.horizontalLayout.setColumnMinimumWidth(
# 2, self.selectedImageViewer.size().width())
# This works when expanding but it's ugly:
if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width():
self.selectedImageViewer.resize(self.referenceImageViewer.size())
# model --> view # model --> view
def refresh(self): def refresh(self):
DetailsDialogBase.refresh(self) DetailsDialogBase.refresh(self)
if self.isVisible(): if self.isVisible():
self._update() self._update()
class EmittingFrame(QFrame):
"""Emits a signal whenever is resized"""
resized = pyqtSignal(QResizeEvent)
def resizeEvent(self, event):
self.resized.emit(event)

1370
qt/pe/image_viewer.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,15 @@ class File(PhotoBase):
def _plat_get_blocks(self, block_count_per_side, orientation): def _plat_get_blocks(self, block_count_per_side, orientation):
image = QImage(str(self.path)) image = QImage(str(self.path))
image = image.convertToFormat(QImage.Format_RGB888) image = image.convertToFormat(QImage.Format_RGB888)
if type(orientation) == str:
logging.warning("Orientation for file '%s' was a str '%s', not an int.",
str(self.path), orientation)
try:
orientation = int(orientation)
except Exception as e:
logging.exception("Skipping transformation because could not \
convert str to int. %s", e)
return getblocks(image, block_count_per_side)
# MYSTERY TO SOLVE: For reasons I cannot explain, orientations 5 and 7 don't work for # MYSTERY TO SOLVE: For reasons I cannot explain, orientations 5 and 7 don't work for
# duplicate scanning. The transforms seems to work fine (if I try to save the image after # duplicate scanning. The transforms seems to work fine (if I try to save the image after
# the transform, we see that the image has been correctly flipped and rotated), but the # the transform, we see that the image has been correctly flipped and rotated), but the

View File

@@ -4,8 +4,10 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtWidgets import QLabel from PyQt5.QtWidgets import QFormLayout
from PyQt5.QtCore import Qt
from hscommon.trans import trget from hscommon.trans import trget
from hscommon.plat import ISLINUX
from qtlib.radio_box import RadioBox from qtlib.radio_box import RadioBox
from core.scanner import ScanType from core.scanner import ScanType
from core.app import AppMode from core.app import AppMode
@@ -40,12 +42,35 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches) self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches)
self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)")) self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)"))
self.widgetsVLayout.addWidget(self.debugModeBox) self.widgetsVLayout.addWidget(self.debugModeBox)
self.widgetsVLayout.addWidget(QLabel(tr("Picture cache mode:")))
self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False) self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False)
self.widgetsVLayout.addWidget(self.cacheTypeRadio) cache_form = QFormLayout()
cache_form.setLabelAlignment(Qt.AlignLeft)
cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio)
self.widgetsVLayout.addLayout(cache_form)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs, setchecked): def _setupDisplayPage(self):
super()._setupDisplayPage()
self._setupAddCheckbox("details_dialog_override_theme_icons",
tr("Override theme icons in viewer toolbar"))
self.details_dialog_override_theme_icons.setToolTip(
tr("Use our own internal icons instead of those provided by the theme engine"))
# Prevent changing this on platforms where themes are unpredictable
self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True)
# Insert this right after the vertical title bar option
index = self.details_groupbox_layout.indexOf(self.details_dialog_vertical_titlebar)
self.details_groupbox_layout.insertWidget(
index + 1, self.details_dialog_override_theme_icons)
self._setupAddCheckbox("details_dialog_viewers_show_scrollbars",
tr("Show scrollbars in image viewers"))
self.details_dialog_viewers_show_scrollbars.setToolTip(
tr("When the image displayed doesn't fit the viewport, \
show scrollbars to span the view around"))
self.details_groupbox_layout.insertWidget(
index + 2, self.details_dialog_viewers_show_scrollbars)
def _load(self, prefs, setchecked, section):
setchecked(self.matchScaledBox, prefs.match_scaled) setchecked(self.matchScaledBox, prefs.match_scaled)
self.cacheTypeRadio.selected_index = ( self.cacheTypeRadio.selected_index = (
1 if prefs.picture_cache_type == "shelve" else 0 1 if prefs.picture_cache_type == "shelve" else 0
@@ -55,9 +80,17 @@ class PreferencesDialog(PreferencesDialogBase):
scan_type = prefs.get_scan_type(AppMode.Picture) scan_type = prefs.get_scan_type(AppMode.Picture)
fuzzy_scan = scan_type == ScanType.FuzzyBlock fuzzy_scan = scan_type == ScanType.FuzzyBlock
self.filterHardnessSlider.setEnabled(fuzzy_scan) self.filterHardnessSlider.setEnabled(fuzzy_scan)
setchecked(self.details_dialog_override_theme_icons,
prefs.details_dialog_override_theme_icons)
setchecked(self.details_dialog_viewers_show_scrollbars,
prefs.details_dialog_viewers_show_scrollbars)
def _save(self, prefs, ischecked): def _save(self, prefs, ischecked):
prefs.match_scaled = ischecked(self.matchScaledBox) prefs.match_scaled = ischecked(self.matchScaledBox)
prefs.picture_cache_type = ( prefs.picture_cache_type = (
"shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite" "shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite"
) )
prefs.details_dialog_override_theme_icons =\
ischecked(self.details_dialog_override_theme_icons)
prefs.details_dialog_viewers_show_scrollbars =\
ischecked(self.details_dialog_viewers_show_scrollbars)

View File

@@ -5,8 +5,11 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor
from hscommon import trans from hscommon import trans
from hscommon.plat import ISLINUX
from core.app import AppMode from core.app import AppMode
from core.scanner import ScanType from core.scanner import ScanType
from qtlib.preferences import Preferences as PreferencesBase from qtlib.preferences import Preferences as PreferencesBase
@@ -30,17 +33,42 @@ class Preferences(PreferencesBase):
self.language = trans.installed_lang self.language = trans.installed_lang
self.tableFontSize = get("TableFontSize", self.tableFontSize) self.tableFontSize = get("TableFontSize", self.tableFontSize)
self.reference_bold_font = get('ReferenceBoldFont', self.reference_bold_font) self.reference_bold_font = get("ReferenceBoldFont", self.reference_bold_font)
self.details_dialog_titlebar_enabled = get("DetailsDialogTitleBarEnabled",
self.details_dialog_titlebar_enabled)
self.details_dialog_vertical_titlebar = get("DetailsDialogVerticalTitleBar",
self.details_dialog_vertical_titlebar)
# On Windows and MacOS, use internal icons by default
self.details_dialog_override_theme_icons =\
get("DetailsDialogOverrideThemeIcons",
self.details_dialog_override_theme_icons) if ISLINUX else True
self.details_table_delta_foreground_color =\
get("DetailsTableDeltaForegroundColor", self.details_table_delta_foreground_color)
self.details_dialog_viewers_show_scrollbars =\
get("DetailsDialogViewersShowScrollbars", self.details_dialog_viewers_show_scrollbars)
self.result_table_ref_foreground_color =\
get("ResultTableRefForegroundColor", self.result_table_ref_foreground_color)
self.result_table_ref_background_color =\
get("ResultTableRefBackgroundColor", self.result_table_ref_background_color)
self.result_table_delta_foreground_color =\
get("ResultTableDeltaForegroundColor", self.result_table_delta_foreground_color)
self.resultWindowIsMaximized = get( self.resultWindowIsMaximized = get(
"ResultWindowIsMaximized", self.resultWindowIsMaximized "ResultWindowIsMaximized", self.resultWindowIsMaximized
) )
self.resultWindowRect = self.get_rect("ResultWindowRect", self.resultWindowRect) self.resultWindowRect = self.get_rect("ResultWindowRect", self.resultWindowRect)
self.mainWindowIsMaximized = get(
"MainWindowIsMaximized", self.mainWindowIsMaximized
)
self.mainWindowRect = self.get_rect("MainWindowRect", self.mainWindowRect)
self.directoriesWindowRect = self.get_rect( self.directoriesWindowRect = self.get_rect(
"DirectoriesWindowRect", self.directoriesWindowRect "DirectoriesWindowRect", self.directoriesWindowRect
) )
self.recentResults = get("RecentResults", self.recentResults) self.recentResults = get("RecentResults", self.recentResults)
self.recentFolders = get("RecentFolders", self.recentFolders) self.recentFolders = get("RecentFolders", self.recentFolders)
self.tabs_default_pos = get("TabsDefaultPosition", self.tabs_default_pos)
self.word_weighting = get("WordWeighting", self.word_weighting) self.word_weighting = get("WordWeighting", self.word_weighting)
self.match_similar = get("MatchSimilar", self.match_similar) self.match_similar = get("MatchSimilar", self.match_similar)
self.ignore_small_files = get("IgnoreSmallFiles", self.ignore_small_files) self.ignore_small_files = get("IgnoreSmallFiles", self.ignore_small_files)
@@ -67,12 +95,24 @@ class Preferences(PreferencesBase):
self.tableFontSize = QApplication.font().pointSize() self.tableFontSize = QApplication.font().pointSize()
self.reference_bold_font = True self.reference_bold_font = True
self.details_dialog_titlebar_enabled = True
self.details_dialog_vertical_titlebar = True
self.details_table_delta_foreground_color = QColor(250, 20, 20) # red
# By default use internal icons on platforms other than Linux for now
self.details_dialog_override_theme_icons = False if not ISLINUX else True
self.details_dialog_viewers_show_scrollbars = True
self.result_table_ref_foreground_color = QColor(Qt.blue)
self.result_table_ref_background_color = QColor(Qt.darkGray)
self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange
self.resultWindowIsMaximized = False self.resultWindowIsMaximized = False
self.resultWindowRect = None self.resultWindowRect = None
self.directoriesWindowRect = None self.directoriesWindowRect = None
self.mainWindowRect = None
self.mainWindowIsMaximized = False
self.recentResults = [] self.recentResults = []
self.recentFolders = [] self.recentFolders = []
self.tabs_default_pos = True
self.word_weighting = True self.word_weighting = True
self.match_similar = False self.match_similar = False
self.ignore_small_files = True self.ignore_small_files = True
@@ -99,13 +139,24 @@ class Preferences(PreferencesBase):
set_("Language", self.language) set_("Language", self.language)
set_("TableFontSize", self.tableFontSize) set_("TableFontSize", self.tableFontSize)
set_('ReferenceBoldFont', self.reference_bold_font) set_("ReferenceBoldFont", self.reference_bold_font)
set_("DetailsDialogTitleBarEnabled", self.details_dialog_titlebar_enabled)
set_("DetailsDialogVerticalTitleBar", self.details_dialog_vertical_titlebar)
set_("DetailsDialogOverrideThemeIcons", self.details_dialog_override_theme_icons)
set_("DetailsDialogViewersShowScrollbars", self.details_dialog_viewers_show_scrollbars)
set_("DetailsTableDeltaForegroundColor", self.details_table_delta_foreground_color)
set_("ResultTableRefForegroundColor", self.result_table_ref_foreground_color)
set_("ResultTableRefBackgroundColor", self.result_table_ref_background_color)
set_("ResultTableDeltaForegroundColor", self.result_table_delta_foreground_color)
set_("ResultWindowIsMaximized", self.resultWindowIsMaximized) set_("ResultWindowIsMaximized", self.resultWindowIsMaximized)
set_("MainWindowIsMaximized", self.mainWindowIsMaximized)
self.set_rect("ResultWindowRect", self.resultWindowRect) self.set_rect("ResultWindowRect", self.resultWindowRect)
self.set_rect("MainWindowRect", self.mainWindowRect)
self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect) self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect)
set_("RecentResults", self.recentResults) set_("RecentResults", self.recentResults)
set_("RecentFolders", self.recentFolders) set_("RecentFolders", self.recentFolders)
set_("TabsDefaultPosition", self.tabs_default_pos)
set_("WordWeighting", self.word_weighting) set_("WordWeighting", self.word_weighting)
set_("MatchSimilar", self.match_similar) set_("MatchSimilar", self.match_similar)
set_("IgnoreSmallFiles", self.ignore_small_files) set_("IgnoreSmallFiles", self.ignore_small_files)

View File

@@ -4,12 +4,13 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, QSize from PyQt5.QtCore import Qt, QSize, pyqtSlot
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QVBoxLayout, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QGridLayout,
QLabel, QLabel,
QComboBox, QComboBox,
QSlider, QSlider,
@@ -20,11 +21,20 @@ from PyQt5.QtWidgets import (
QMessageBox, QMessageBox,
QSpinBox, QSpinBox,
QLayout, QLayout,
QTabWidget,
QWidget,
QColorDialog,
QPushButton,
QGroupBox,
QFormLayout,
) )
from PyQt5.QtGui import QPixmap, QIcon
from hscommon.trans import trget from hscommon.trans import trget
from hscommon.plat import ISLINUX
from qtlib.util import horizontalWrap from qtlib.util import horizontalWrap
from qtlib.preferences import get_langnames from qtlib.preferences import get_langnames
from enum import Flag, auto
from .preferences import Preferences from .preferences import Preferences
@@ -50,6 +60,13 @@ SUPPORTED_LANGUAGES = [
] ]
class Sections(Flag):
"""Filter blocks of preferences when reset or loaded"""
GENERAL = auto()
DISPLAY = auto()
ALL = GENERAL | DISPLAY
class PreferencesDialogBase(QDialog): class PreferencesDialogBase(QDialog):
def __init__(self, parent, app, **kwargs): def __init__(self, parent, app, **kwargs):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
@@ -111,21 +128,6 @@ class PreferencesDialogBase(QDialog):
def _setupBottomPart(self): def _setupBottomPart(self):
# The bottom part of the pref panel is always the same in all editions. # The bottom part of the pref panel is always the same in all editions.
self.fontSizeLabel = QLabel(tr("Font size:"))
self.fontSizeSpinBox = QSpinBox()
self.fontSizeSpinBox.setMinimum(5)
self.widgetsVLayout.addLayout(
horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None])
)
self._setupAddCheckbox("reference_bold_font", tr("Bold font for reference."))
self.widgetsVLayout.addWidget(self.reference_bold_font)
self.languageLabel = QLabel(tr("Language:"), self)
self.languageComboBox = QComboBox(self)
for lang in self.supportedLanguages:
self.languageComboBox.addItem(get_langnames()[lang])
self.widgetsVLayout.addLayout(
horizontalWrap([self.languageLabel, self.languageComboBox, None])
)
self.copyMoveLabel = QLabel(self) self.copyMoveLabel = QLabel(self)
self.copyMoveLabel.setText(tr("Copy and Move:")) self.copyMoveLabel.setText(tr("Copy and Move:"))
self.widgetsVLayout.addWidget(self.copyMoveLabel) self.widgetsVLayout.addWidget(self.copyMoveLabel)
@@ -142,6 +144,81 @@ class PreferencesDialogBase(QDialog):
self.customCommandEdit = QLineEdit(self) self.customCommandEdit = QLineEdit(self)
self.widgetsVLayout.addWidget(self.customCommandEdit) self.widgetsVLayout.addWidget(self.customCommandEdit)
def _setupDisplayPage(self):
self.ui_groupbox = QGroupBox("&General Interface")
layout = QVBoxLayout()
self.languageLabel = QLabel(tr("Language:"), self)
self.languageComboBox = QComboBox(self)
for lang in self.supportedLanguages:
self.languageComboBox.addItem(get_langnames()[lang])
layout.addLayout(horizontalWrap([self.languageLabel, self.languageComboBox, None]))
self._setupAddCheckbox("tabs_default_pos",
tr("Use default position for tab bar (requires restart)"))
self.tabs_default_pos.setToolTip(
tr("Place the tab bar below the main menu instead of next to it\n\
On MacOS, the tab bar will fill up the window's width instead."))
layout.addWidget(self.tabs_default_pos)
self.ui_groupbox.setLayout(layout)
self.displayVLayout.addWidget(self.ui_groupbox)
gridlayout = QGridLayout()
gridlayout.setColumnStretch(2, 2)
formlayout = QFormLayout()
result_groupbox = QGroupBox("&Result Table")
self.fontSizeSpinBox = QSpinBox()
self.fontSizeSpinBox.setMinimum(5)
formlayout.addRow(tr("Font size:"), self.fontSizeSpinBox)
self._setupAddCheckbox("reference_bold_font",
tr("Use bold font for references"))
formlayout.addRow(self.reference_bold_font)
self.result_table_ref_foreground_color = ColorPickerButton(self)
formlayout.addRow(tr("Reference foreground color:"),
self.result_table_ref_foreground_color)
self.result_table_ref_background_color = ColorPickerButton(self)
formlayout.addRow(tr("Reference background color:"),
self.result_table_ref_background_color)
self.result_table_delta_foreground_color = ColorPickerButton(self)
formlayout.addRow(tr("Delta foreground color:"),
self.result_table_delta_foreground_color)
formlayout.setLabelAlignment(Qt.AlignLeft)
# Keep same vertical spacing as parent layout for consistency
formlayout.setVerticalSpacing(self.displayVLayout.spacing())
gridlayout.addLayout(formlayout, 0, 0)
result_groupbox.setLayout(gridlayout)
self.displayVLayout.addWidget(result_groupbox)
details_groupbox = QGroupBox("&Details Window")
self.details_groupbox_layout = QVBoxLayout()
self._setupAddCheckbox("details_dialog_titlebar_enabled",
tr("Show the title bar and can be docked"))
self.details_dialog_titlebar_enabled.setToolTip(
tr("While the title bar is hidden, \
use the modifier key to drag the floating window around") if ISLINUX else
tr("The title bar can only be disabled while the window is docked"))
self.details_groupbox_layout.addWidget(self.details_dialog_titlebar_enabled)
self._setupAddCheckbox("details_dialog_vertical_titlebar",
tr("Vertical title bar"))
self.details_dialog_vertical_titlebar.setToolTip(
tr("Change the title bar from horizontal on top, to vertical on the left side"))
self.details_groupbox_layout.addWidget(self.details_dialog_vertical_titlebar)
self.details_dialog_vertical_titlebar.setEnabled(
self.details_dialog_titlebar_enabled.isChecked())
self.details_dialog_titlebar_enabled.stateChanged.connect(
self.details_dialog_vertical_titlebar.setEnabled)
gridlayout = QGridLayout()
formlayout = QFormLayout()
self.details_table_delta_foreground_color = ColorPickerButton(self)
# Padding on the right side and space between label and widget to keep it somewhat consistent across themes
gridlayout.setColumnStretch(1, 1)
formlayout.setHorizontalSpacing(50)
formlayout.addRow(tr("Delta foreground color:"), self.details_table_delta_foreground_color)
gridlayout.addLayout(formlayout, 0, 0)
self.details_groupbox_layout.addLayout(gridlayout)
details_groupbox.setLayout(self.details_groupbox_layout)
self.displayVLayout.addWidget(details_groupbox)
def _setupAddCheckbox(self, name, label, parent=None): def _setupAddCheckbox(self, name, label, parent=None):
if parent is None: if parent is None:
parent = self parent = self
@@ -158,19 +235,32 @@ class PreferencesDialogBase(QDialog):
self.setSizeGripEnabled(False) self.setSizeGripEnabled(False)
self.setModal(True) self.setModal(True)
self.mainVLayout = QVBoxLayout(self) self.mainVLayout = QVBoxLayout(self)
self.tabwidget = QTabWidget()
self.page_general = QWidget()
self.page_display = QWidget()
self.widgetsVLayout = QVBoxLayout() self.widgetsVLayout = QVBoxLayout()
self.page_general.setLayout(self.widgetsVLayout)
self.displayVLayout = QVBoxLayout()
self.displayVLayout.setSpacing(5) # arbitrary value, might conflict with style
self.page_display.setLayout(self.displayVLayout)
self._setupPreferenceWidgets() self._setupPreferenceWidgets()
self.mainVLayout.addLayout(self.widgetsVLayout) self._setupDisplayPage()
# self.mainVLayout.addLayout(self.widgetsVLayout)
self.buttonBox = QDialogButtonBox(self) self.buttonBox = QDialogButtonBox(self)
self.buttonBox.setStandardButtons( self.buttonBox.setStandardButtons(
QDialogButtonBox.Cancel QDialogButtonBox.Cancel
| QDialogButtonBox.Ok | QDialogButtonBox.Ok
| QDialogButtonBox.RestoreDefaults | QDialogButtonBox.RestoreDefaults
) )
self.mainVLayout.addWidget(self.tabwidget)
self.mainVLayout.addWidget(self.buttonBox) self.mainVLayout.addWidget(self.buttonBox)
self.layout().setSizeConstraint(QLayout.SetFixedSize) self.layout().setSizeConstraint(QLayout.SetFixedSize)
self.tabwidget.addTab(self.page_general, "General")
self.tabwidget.addTab(self.page_display, "Display")
self.displayVLayout.addStretch(0)
self.widgetsVLayout.addStretch(0)
def _load(self, prefs, setchecked): def _load(self, prefs, setchecked, section):
# Edition-specific # Edition-specific
pass pass
@@ -178,27 +268,42 @@ class PreferencesDialogBase(QDialog):
# Edition-specific # Edition-specific
pass pass
def load(self, prefs=None): def load(self, prefs=None, section=Sections.ALL):
if prefs is None: if prefs is None:
prefs = self.app.prefs prefs = self.app.prefs
self.filterHardnessSlider.setValue(prefs.filter_hardness)
self.filterHardnessLabel.setNum(prefs.filter_hardness)
setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked) setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked)
setchecked(self.mixFileKindBox, prefs.mix_file_kind) if section & Sections.GENERAL:
setchecked(self.useRegexpBox, prefs.use_regexp) self.filterHardnessSlider.setValue(prefs.filter_hardness)
setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders) self.filterHardnessLabel.setNum(prefs.filter_hardness)
setchecked(self.ignoreHardlinkMatches, prefs.ignore_hardlink_matches) setchecked(self.mixFileKindBox, prefs.mix_file_kind)
setchecked(self.debugModeBox, prefs.debug_mode) setchecked(self.useRegexpBox, prefs.use_regexp)
setchecked(self.reference_bold_font, prefs.reference_bold_font) setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders)
self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) setchecked(self.ignoreHardlinkMatches, prefs.ignore_hardlink_matches)
self.customCommandEdit.setText(prefs.custom_command) setchecked(self.debugModeBox, prefs.debug_mode)
self.fontSizeSpinBox.setValue(prefs.tableFontSize) self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type)
try: self.customCommandEdit.setText(prefs.custom_command)
langindex = self.supportedLanguages.index(self.app.prefs.language) if section & Sections.DISPLAY:
except ValueError: setchecked(self.reference_bold_font, prefs.reference_bold_font)
langindex = 0 setchecked(self.tabs_default_pos, prefs.tabs_default_pos)
self.languageComboBox.setCurrentIndex(langindex) setchecked(self.details_dialog_titlebar_enabled,
self._load(prefs, setchecked) prefs.details_dialog_titlebar_enabled)
setchecked(self.details_dialog_vertical_titlebar,
prefs.details_dialog_vertical_titlebar)
self.fontSizeSpinBox.setValue(prefs.tableFontSize)
self.details_table_delta_foreground_color.setColor(
prefs.details_table_delta_foreground_color)
self.result_table_ref_foreground_color.setColor(
prefs.result_table_ref_foreground_color)
self.result_table_ref_background_color.setColor(
prefs.result_table_ref_background_color)
self.result_table_delta_foreground_color.setColor(
prefs.result_table_delta_foreground_color)
try:
langindex = self.supportedLanguages.index(self.app.prefs.language)
except ValueError:
langindex = 0
self.languageComboBox.setCurrentIndex(langindex)
self._load(prefs, setchecked, section)
def save(self): def save(self):
prefs = self.app.prefs prefs = self.app.prefs
@@ -210,9 +315,16 @@ class PreferencesDialogBase(QDialog):
prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches) prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches)
prefs.debug_mode = ischecked(self.debugModeBox) prefs.debug_mode = ischecked(self.debugModeBox)
prefs.reference_bold_font = ischecked(self.reference_bold_font) prefs.reference_bold_font = ischecked(self.reference_bold_font)
prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled)
prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar)
prefs.details_table_delta_foreground_color = self.details_table_delta_foreground_color.color
prefs.result_table_ref_foreground_color = self.result_table_ref_foreground_color.color
prefs.result_table_ref_background_color = self.result_table_ref_background_color.color
prefs.result_table_delta_foreground_color = self.result_table_delta_foreground_color.color
prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex()
prefs.custom_command = str(self.customCommandEdit.text()) prefs.custom_command = str(self.customCommandEdit.text())
prefs.tableFontSize = self.fontSizeSpinBox.value() prefs.tableFontSize = self.fontSizeSpinBox.value()
prefs.tabs_default_pos = ischecked(self.tabs_default_pos)
lang = self.supportedLanguages[self.languageComboBox.currentIndex()] lang = self.supportedLanguages[self.languageComboBox.currentIndex()]
oldlang = self.app.prefs.language oldlang = self.app.prefs.language
if oldlang not in self.supportedLanguages: if oldlang not in self.supportedLanguages:
@@ -226,11 +338,45 @@ class PreferencesDialogBase(QDialog):
self.app.prefs.language = lang self.app.prefs.language = lang
self._save(prefs, ischecked) self._save(prefs, ischecked)
def resetToDefaults(self): def resetToDefaults(self, section_to_update):
self.load(Preferences()) self.load(Preferences(), section_to_update)
# --- Events # --- Events
def buttonClicked(self, button): def buttonClicked(self, button):
role = self.buttonBox.buttonRole(button) role = self.buttonBox.buttonRole(button)
if role == QDialogButtonBox.ResetRole: if role == QDialogButtonBox.ResetRole:
self.resetToDefaults() current_tab = self.tabwidget.currentWidget()
section_to_update = Sections.ALL
if current_tab is self.page_general:
section_to_update = Sections.GENERAL
if current_tab is self.page_display:
section_to_update = Sections.DISPLAY
self.resetToDefaults(section_to_update)
class ColorPickerButton(QPushButton):
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
self.color = None
self.clicked.connect(self.onClicked)
@pyqtSlot()
def onClicked(self):
color = QColorDialog.getColor(
self.color if self.color is not None else Qt.white,
self.parent)
self.setColor(color)
def setColor(self, color):
size = QSize(16, 16)
px = QPixmap(size)
if color is None:
size.width = 0
size.height = 0
elif not color.isValid():
return
else:
self.color = color
px.fill(color)
self.setIcon(QIcon(px))

View File

@@ -47,9 +47,16 @@ class PrioritizationList(ListviewModel):
# to know where the drop took place. # to know where the drop took place.
if parentIndex.isValid(): if parentIndex.isValid():
return False return False
# "When row and column are -1 it means that the dropped data should be considered as
# dropped directly on parent."
# Moving items to row -1 would put them before the last item. Fix the row to drop the
# dragged items after the last item.
if row < 0:
row = len(self.model) - 1
strMimeData = bytes(mimeData.data(MIME_INDEXES)).decode() strMimeData = bytes(mimeData.data(MIME_INDEXES)).decode()
indexes = list(map(int, strMimeData.split(","))) indexes = list(map(int, strMimeData.split(",")))
self.model.move_indexes(indexes, row) self.model.move_indexes(indexes, row)
self.view.selectionModel().clearSelection()
return True return True
def mimeData(self, indexes): def mimeData(self, indexes):
@@ -84,7 +91,9 @@ class PrioritizeDialog(QDialog):
self.model.view = self self.model.view = self
self.addCriteriaButton.clicked.connect(self.model.add_selected) self.addCriteriaButton.clicked.connect(self.model.add_selected)
self.criteriaListView.doubleClicked.connect(self.model.add_selected)
self.removeCriteriaButton.clicked.connect(self.model.remove_selected) self.removeCriteriaButton.clicked.connect(self.model.remove_selected)
self.prioritizationListView.doubleClicked.connect(self.model.remove_selected)
self.buttonBox.accepted.connect(self.accept) self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject) self.buttonBox.rejected.connect(self.reject)
@@ -102,6 +111,7 @@ class PrioritizeDialog(QDialog):
self.promptLabel.setWordWrap(True) self.promptLabel.setWordWrap(True)
self.categoryCombobox = QComboBox() self.categoryCombobox = QComboBox()
self.criteriaListView = QListView() self.criteriaListView = QListView()
self.criteriaListView.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.addCriteriaButton = QPushButton( self.addCriteriaButton = QPushButton(
self.style().standardIcon(QStyle.SP_ArrowRight), "" self.style().standardIcon(QStyle.SP_ArrowRight), ""
) )
@@ -113,6 +123,7 @@ class PrioritizeDialog(QDialog):
self.prioritizationListView.setDragEnabled(True) self.prioritizationListView.setDragEnabled(True)
self.prioritizationListView.setDragDropMode(QAbstractItemView.InternalMove) self.prioritizationListView.setDragDropMode(QAbstractItemView.InternalMove)
self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectRows) self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.prioritizationListView.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.buttonBox = QDialogButtonBox() self.buttonBox = QDialogButtonBox()
self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel | QDialogButtonBox.Ok)

View File

@@ -42,6 +42,7 @@ class ResultWindow(QMainWindow):
def __init__(self, parent, app, **kwargs): def __init__(self, parent, app, **kwargs):
super().__init__(parent, **kwargs) super().__init__(parent, **kwargs)
self.app = app self.app = app
self.specific_actions = set()
self._setupUi() self._setupUi()
if app.model.app_mode == AppMode.Picture: if app.model.app_mode == AppMode.Picture:
MODEL_CLASS = ResultsModelPicture MODEL_CLASS = ResultsModelPicture
@@ -169,7 +170,7 @@ class ResultWindow(QMainWindow):
), ),
( (
"actionMarkSelected", "actionMarkSelected",
"", Qt.Key_Space,
"", "",
tr("Mark Selected"), tr("Mark Selected"),
self.markSelectedTriggered, self.markSelectedTriggered,
@@ -207,22 +208,39 @@ class ResultWindow(QMainWindow):
self.actionDelta.setCheckable(True) self.actionDelta.setCheckable(True)
self.actionPowerMarker.setCheckable(True) self.actionPowerMarker.setCheckable(True)
if self.app.main_window: # We use tab widgets in this case
# Keep track of actions which should only be accessible from this class
for action, _, _, _, _ in ACTIONS:
self.specific_actions.add(getattr(self, action))
def _setupMenu(self): def _setupMenu(self):
self.menubar = QMenuBar() if not self.app.use_tabs:
self.menubar.setGeometry(QRect(0, 0, 630, 22)) # we are our own QMainWindow, we need our own menu bar
self.menuFile = QMenu(self.menubar) self.menubar = QMenuBar() # self.menuBar() works as well here
self.menuFile.setTitle(tr("File")) self.menubar.setGeometry(QRect(0, 0, 630, 22))
self.menuMark = QMenu(self.menubar) self.menuFile = QMenu(self.menubar)
self.menuMark.setTitle(tr("Mark")) self.menuFile.setTitle(tr("File"))
self.menuActions = QMenu(self.menubar) self.menuMark = QMenu(self.menubar)
self.menuActions.setTitle(tr("Actions")) self.menuMark.setTitle(tr("Mark"))
self.menuColumns = QMenu(self.menubar) self.menuActions = QMenu(self.menubar)
self.menuColumns.setTitle(tr("Columns")) self.menuActions.setTitle(tr("Actions"))
self.menuView = QMenu(self.menubar) self.menuColumns = QMenu(self.menubar)
self.menuView.setTitle(tr("View")) self.menuColumns.setTitle(tr("Columns"))
self.menuHelp = QMenu(self.menubar) self.menuView = QMenu(self.menubar)
self.menuHelp.setTitle(tr("Help")) self.menuView.setTitle(tr("View"))
self.setMenuBar(self.menubar) self.menuHelp = QMenu(self.menubar)
self.menuHelp.setTitle(tr("Help"))
self.setMenuBar(self.menubar)
menubar = self.menubar
else:
# we are part of a tab widget, we populate its window's menubar instead
self.menuFile = self.app.main_window.menuFile
self.menuMark = self.app.main_window.menuMark
self.menuActions = self.app.main_window.menuActions
self.menuColumns = self.app.main_window.menuColumns
self.menuView = self.app.main_window.menuView
self.menuHelp = self.app.main_window.menuHelp
menubar = self.app.main_window.menubar
self.menuActions.addAction(self.actionDeleteMarked) self.menuActions.addAction(self.actionDeleteMarked)
self.menuActions.addAction(self.actionMoveMarked) self.menuActions.addAction(self.actionMoveMarked)
@@ -242,12 +260,18 @@ class ResultWindow(QMainWindow):
self.menuMark.addAction(self.actionMarkNone) self.menuMark.addAction(self.actionMarkNone)
self.menuMark.addAction(self.actionInvertMarking) self.menuMark.addAction(self.actionInvertMarking)
self.menuMark.addAction(self.actionMarkSelected) self.menuMark.addAction(self.actionMarkSelected)
self.menuView.addAction(self.actionDetails)
self.menuView.addSeparator()
self.menuView.addAction(self.actionPowerMarker) self.menuView.addAction(self.actionPowerMarker)
self.menuView.addAction(self.actionDelta) self.menuView.addAction(self.actionDelta)
self.menuView.addSeparator() self.menuView.addSeparator()
self.menuView.addAction(self.actionDetails) if not self.app.use_tabs:
self.menuView.addAction(self.app.actionIgnoreList) self.menuView.addAction(self.app.actionIgnoreList)
# This also pushes back the options entry to the bottom of the menu
self.menuView.addSeparator()
self.menuView.addAction(self.app.actionPreferences) self.menuView.addAction(self.app.actionPreferences)
self.menuHelp.addAction(self.app.actionShowHelp) self.menuHelp.addAction(self.app.actionShowHelp)
self.menuHelp.addAction(self.app.actionOpenDebugLog) self.menuHelp.addAction(self.app.actionOpenDebugLog)
self.menuHelp.addAction(self.app.actionAbout) self.menuHelp.addAction(self.app.actionAbout)
@@ -257,15 +281,19 @@ class ResultWindow(QMainWindow):
self.menuFile.addSeparator() self.menuFile.addSeparator()
self.menuFile.addAction(self.app.actionQuit) self.menuFile.addAction(self.app.actionQuit)
self.menubar.addAction(self.menuFile.menuAction()) menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuMark.menuAction()) menubar.addAction(self.menuMark.menuAction())
self.menubar.addAction(self.menuActions.menuAction()) menubar.addAction(self.menuActions.menuAction())
self.menubar.addAction(self.menuColumns.menuAction()) menubar.addAction(self.menuColumns.menuAction())
self.menubar.addAction(self.menuView.menuAction()) menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuHelp.menuAction()) menubar.addAction(self.menuHelp.menuAction())
# Columns menu # Columns menu
menu = self.menuColumns menu = self.menuColumns
# Avoid adding duplicate actions in tab widget menu in case we recreated
# the Result Window instance.
if menu.actions():
menu.clear()
self._column_actions = [] self._column_actions = []
for index, (display, visible) in enumerate( for index, (display, visible) in enumerate(
self.app.model.result_table.columns.menu_items() self.app.model.result_table.columns.menu_items()
@@ -280,7 +308,7 @@ class ResultWindow(QMainWindow):
action.item_index = -1 action.item_index = -1
# Action menu # Action menu
actionMenu = QMenu(tr("Actions"), self.menubar) actionMenu = QMenu(tr("Actions"), menubar)
actionMenu.addAction(self.actionDeleteMarked) actionMenu.addAction(self.actionDeleteMarked)
actionMenu.addAction(self.actionMoveMarked) actionMenu.addAction(self.actionMoveMarked)
actionMenu.addAction(self.actionCopyMarked) actionMenu.addAction(self.actionCopyMarked)
@@ -327,6 +355,7 @@ class ResultWindow(QMainWindow):
self.resultsView.setSelectionMode(QAbstractItemView.ExtendedSelection) self.resultsView.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.resultsView.setSelectionBehavior(QAbstractItemView.SelectRows) self.resultsView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.resultsView.setSortingEnabled(True) self.resultsView.setSortingEnabled(True)
self.resultsView.setWordWrap(False)
self.resultsView.verticalHeader().setVisible(False) self.resultsView.verticalHeader().setVisible(False)
h = self.resultsView.horizontalHeader() h = self.resultsView.horizontalHeader()
h.setHighlightSections(False) h.setHighlightSections(False)

View File

@@ -7,7 +7,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex
from PyQt5.QtGui import QBrush, QFont, QFontMetrics, QColor from PyQt5.QtGui import QBrush, QFont, QFontMetrics
from PyQt5.QtWidgets import QTableView from PyQt5.QtWidgets import QTableView
from qtlib.table import Table from qtlib.table import Table
@@ -20,7 +20,7 @@ class ResultsModel(Table):
view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
font = view.font() font = view.font()
font.setPointSize(app.prefs.tableFontSize) font.setPointSize(app.prefs.tableFontSize)
self.view.setFont(font) view.setFont(font)
fm = QFontMetrics(font) fm = QFontMetrics(font)
view.verticalHeader().setDefaultSectionSize(fm.height() + 2) view.verticalHeader().setDefaultSectionSize(fm.height() + 2)
@@ -29,6 +29,8 @@ class ResultsModel(Table):
def _getData(self, row, column, role): def _getData(self, row, column, role):
if column.name == "marked": if column.name == "marked":
if role == Qt.BackgroundRole and row.isref:
return QBrush(self.prefs.result_table_ref_background_color)
if role == Qt.CheckStateRole and row.markable: if role == Qt.CheckStateRole and row.markable:
return Qt.Checked if row.marked else Qt.Unchecked return Qt.Checked if row.marked else Qt.Unchecked
return None return None
@@ -37,9 +39,12 @@ class ResultsModel(Table):
return data[column.name] return data[column.name]
elif role == Qt.ForegroundRole: elif role == Qt.ForegroundRole:
if row.isref: if row.isref:
return QBrush(Qt.blue) return QBrush(self.prefs.result_table_ref_foreground_color)
elif row.is_cell_delta(column.name): elif row.is_cell_delta(column.name):
return QBrush(QColor(255, 142, 40)) # orange return QBrush(self.prefs.result_table_delta_foreground_color)
elif role == Qt.BackgroundRole:
if row.isref:
return QBrush(self.prefs.result_table_ref_background_color)
elif role == Qt.FontRole: elif role == Qt.FontRole:
font = QFont(self.view.font()) font = QFont(self.view.font())
if self.prefs.reference_bold_font: if self.prefs.reference_bold_font:

View File

@@ -5,7 +5,7 @@
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import QSize from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView from PyQt5.QtWidgets import QAbstractItemView
from hscommon.trans import trget from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_dialog import DetailsDialog as DetailsDialogBase
@@ -19,11 +19,8 @@ class DetailsDialog(DetailsDialogBase):
self.setWindowTitle(tr("Details")) self.setWindowTitle(tr("Details"))
self.resize(502, 186) self.resize(502, 186)
self.setMinimumSize(QSize(200, 0)) self.setMinimumSize(QSize(200, 0))
self.verticalLayout = QVBoxLayout(self)
self.verticalLayout.setSpacing(0)
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.tableView = DetailsTable(self) self.tableView = DetailsTable(self)
self.tableView.setAlternatingRowColors(True) self.tableView.setAlternatingRowColors(True)
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tableView.setShowGrid(False) self.tableView.setShowGrid(False)
self.verticalLayout.addWidget(self.tableView) self.setWidget(self.tableView)

View File

@@ -85,7 +85,7 @@ class PreferencesDialog(PreferencesDialogBase):
self.widgetsVLayout.addWidget(self.widget) self.widgetsVLayout.addWidget(self.widget)
self._setupBottomPart() self._setupBottomPart()
def _load(self, prefs, setchecked): def _load(self, prefs, setchecked, section):
setchecked(self.matchSimilarBox, prefs.match_similar) setchecked(self.matchSimilarBox, prefs.match_similar)
setchecked(self.wordWeightingBox, prefs.word_weighting) setchecked(self.wordWeightingBox, prefs.word_weighting)
setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files) setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files)

367
qt/tabbed_window.py Normal file
View File

@@ -0,0 +1,367 @@
# 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 PyQt5.QtCore import QRect, pyqtSlot, Qt
from PyQt5.QtWidgets import (
QWidget,
QVBoxLayout,
QHBoxLayout,
QMainWindow,
QTabWidget,
QMenu,
QTabBar,
QStackedWidget,
)
from hscommon.trans import trget
from qtlib.util import moveToScreenCenter, createActions
from .directories_dialog import DirectoriesDialog
from .result_window import ResultWindow
from .ignore_list_dialog import IgnoreListDialog
from .exclude_list_dialog import ExcludeListDialog
tr = trget("ui")
class TabWindow(QMainWindow):
def __init__(self, app, **kwargs):
super().__init__(None, **kwargs)
self.app = app
self.pages = {} # This is currently not used anywhere
self.menubar = None
self.menuList = set()
self.last_index = -1
self.previous_widget_actions = set()
self._setupUi()
self.app.willSavePrefs.connect(self.appWillSavePrefs)
def _setupActions(self):
# (name, shortcut, icon, desc, func)
ACTIONS = [
(
"actionToggleTabs",
"",
"",
tr("Show tab bar"),
self.toggleTabBar,
),
]
createActions(ACTIONS, self)
self.actionToggleTabs.setCheckable(True)
self.actionToggleTabs.setChecked(True)
def _setupUi(self):
self.setWindowTitle(self.app.NAME)
self.resize(640, 480)
self.tabWidget = QTabWidget()
# self.tabWidget.setTabPosition(QTabWidget.South)
self.tabWidget.setContentsMargins(0, 0, 0, 0)
# self.tabWidget.setTabBarAutoHide(True)
# This gets rid of the annoying margin around the TabWidget:
self.tabWidget.setDocumentMode(True)
self._setupActions()
self._setupMenu()
# This should be the same as self.centralWidget.setLayout(self.verticalLayout)
self.verticalLayout = QVBoxLayout(self.tabWidget)
# self.verticalLayout.addWidget(self.tabWidget)
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.tabWidget.setTabsClosable(True)
self.setCentralWidget(self.tabWidget) # only for QMainWindow
self.tabWidget.currentChanged.connect(self.updateMenuBar)
self.tabWidget.tabCloseRequested.connect(self.onTabCloseRequested)
self.updateMenuBar(self.tabWidget.currentIndex())
self.restoreGeometry()
def restoreGeometry(self):
if self.app.prefs.mainWindowRect is not None:
self.setGeometry(self.app.prefs.mainWindowRect)
else:
moveToScreenCenter(self)
def _setupMenu(self):
"""Setup the menubar boiler plates which will be filled by the underlying
tab's widgets whenever they are instantiated."""
self.menubar = self.menuBar() # QMainWindow, similar to just QMenuBar() here
# self.setMenuBar(self.menubar) # already set if QMainWindow class
self.menubar.setGeometry(QRect(0, 0, 100, 22))
self.menuFile = QMenu(self.menubar)
self.menuFile.setTitle(tr("File"))
self.menuMark = QMenu(self.menubar)
self.menuMark.setTitle(tr("Mark"))
self.menuActions = QMenu(self.menubar)
self.menuActions.setTitle(tr("Actions"))
self.menuColumns = QMenu(self.menubar)
self.menuColumns.setTitle(tr("Columns"))
self.menuView = QMenu(self.menubar)
self.menuView.setTitle(tr("View"))
self.menuHelp = QMenu(self.menubar)
self.menuHelp.setTitle(tr("Help"))
self.menuView.addAction(self.actionToggleTabs)
self.menuView.addSeparator()
self.menuList.add(self.menuFile)
self.menuList.add(self.menuMark)
self.menuList.add(self.menuActions)
self.menuList.add(self.menuColumns)
self.menuList.add(self.menuView)
self.menuList.add(self.menuHelp)
@pyqtSlot(int)
def updateMenuBar(self, page_index=-1):
if page_index < 0:
return
current_index = self.getCurrentIndex()
active_widget = self.getWidgetAtIndex(current_index)
if self.last_index < 0:
self.last_index = current_index
self.previous_widget_actions = active_widget.specific_actions
return
page_type = type(active_widget).__name__
for menu in self.menuList:
if menu is self.menuColumns or menu is self.menuActions or menu is self.menuMark:
if not isinstance(active_widget, ResultWindow):
menu.setEnabled(False)
continue
else:
menu.setEnabled(True)
for action in menu.actions():
if action not in active_widget.specific_actions:
if action in self.previous_widget_actions:
action.setEnabled(False)
continue
action.setEnabled(True)
self.app.directories_dialog.actionShowResultsWindow.setEnabled(
False if page_type == "ResultWindow"
else self.app.resultWindow is not None)
self.app.actionIgnoreList.setEnabled(
True if self.app.ignoreListDialog is not None
and not page_type == "IgnoreListDialog" else False)
self.app.actionDirectoriesWindow.setEnabled(
False if page_type == "DirectoriesDialog" else True)
self.app.actionExcludeList.setEnabled(
True if self.app.excludeListDialog is not None
and not page_type == "ExcludeListDialog" else False)
self.previous_widget_actions = active_widget.specific_actions
self.last_index = current_index
def createPage(self, cls, **kwargs):
app = kwargs.get("app", self.app)
page = None
if cls == "DirectoriesDialog":
page = DirectoriesDialog(app)
elif cls == "ResultWindow":
parent = kwargs.get("parent", self)
page = ResultWindow(parent, app)
elif cls == "IgnoreListDialog":
parent = kwargs.get("parent", self)
model = kwargs.get("model")
page = IgnoreListDialog(parent, model)
page.accepted.connect(self.onDialogAccepted)
elif cls == "ExcludeListDialog":
app = kwargs.get("app", app)
parent = kwargs.get("parent", self)
model = kwargs.get("model")
page = ExcludeListDialog(app, parent, model)
page.accepted.connect(self.onDialogAccepted)
self.pages[cls] = page # Not used, might remove
return page
def addTab(self, page, title, switch=False):
# Warning: this supposedly takes ownership of the page
index = self.tabWidget.addTab(page, title)
# index = self.tabWidget.insertTab(-1, page, title)
if isinstance(page, DirectoriesDialog):
self.tabWidget.tabBar().setTabButton(
index, QTabBar.RightSide, None)
if switch:
self.setCurrentIndex(index)
return index
def showTab(self, page):
index = self.indexOfWidget(page)
self.setCurrentIndex(index)
def indexOfWidget(self, widget):
return self.tabWidget.indexOf(widget)
def setCurrentIndex(self, index):
return self.tabWidget.setCurrentIndex(index)
def removeTab(self, index):
return self.tabWidget.removeTab(index)
def isTabVisible(self, index):
return self.tabWidget.isTabVisible(index)
def getCurrentIndex(self):
return self.tabWidget.currentIndex()
def getWidgetAtIndex(self, index):
return self.tabWidget.widget(index)
def getCount(self):
return self.tabWidget.count()
# --- Events
def appWillSavePrefs(self):
# Right now this is useless since the first spawned dialog inside the
# QTabWidget will assign its geometry after restoring it
prefs = self.app.prefs
prefs.mainWindowIsMaximized = self.isMaximized()
prefs.mainWindowRect = self.geometry()
def closeEvent(self, close_event):
# Force closing of our tabbed widgets in reverse order so that the
# directories dialog (which usually is at index 0) will be called last
for index in range(self.getCount() - 1, -1, -1):
self.getWidgetAtIndex(index).closeEvent(close_event)
self.appWillSavePrefs()
@pyqtSlot(int)
def onTabCloseRequested(self, index):
current_widget = self.getWidgetAtIndex(index)
if isinstance(current_widget, DirectoriesDialog):
# if we close this one, the application quits. Force user to use the
# menu or shortcut. But this is useless if we don't have a button
# set up to make a close request anyway. This check could be removed.
return
self.removeTab(index)
@pyqtSlot()
def onDialogAccepted(self):
"""Remove tabbed dialog when Accepted/Done (close button clicked)."""
widget = self.sender()
index = self.indexOfWidget(widget)
if index > -1:
self.removeTab(index)
@pyqtSlot()
def toggleTabBar(self):
value = self.sender().isChecked()
self.actionToggleTabs.setChecked(value)
self.tabWidget.tabBar().setVisible(value)
class TabBarWindow(TabWindow):
"""Implementation which uses a separate QTabBar and QStackedWidget.
The Tab bar is placed next to the menu bar to save real estate."""
def __init__(self, app, **kwargs):
super().__init__(app, **kwargs)
def _setupUi(self):
self.setWindowTitle(self.app.NAME)
self.resize(640, 480)
self.tabBar = QTabBar()
self.verticalLayout = QVBoxLayout()
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self._setupActions()
self._setupMenu()
self.centralWidget = QWidget(self)
self.setCentralWidget(self.centralWidget)
self.stackedWidget = QStackedWidget()
self.centralWidget.setLayout(self.verticalLayout)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.addWidget(self.menubar, 0, Qt.AlignTop)
self.horizontalLayout.addWidget(self.tabBar, 0, Qt.AlignTop)
self.verticalLayout.addLayout(self.horizontalLayout)
self.verticalLayout.addWidget(self.stackedWidget)
self.tabBar.currentChanged.connect(self.showTabIndex)
self.tabBar.tabCloseRequested.connect(self.onTabCloseRequested)
self.stackedWidget.currentChanged.connect(self.updateMenuBar)
self.stackedWidget.widgetRemoved.connect(self.onRemovedWidget)
self.tabBar.setTabsClosable(True)
self.restoreGeometry()
def addTab(self, page, title, switch=True):
stack_index = self.stackedWidget.addWidget(page)
self.tabBar.insertTab(stack_index, title)
if isinstance(page, DirectoriesDialog):
self.tabBar.setTabButton(
stack_index, QTabBar.RightSide, None)
if switch: # switch to the added tab immediately upon creation
self.setTabIndex(stack_index)
return stack_index
@pyqtSlot(int)
def showTabIndex(self, index):
# The tab bar's indices should be aligned with the stackwidget's
if index >= 0 and index <= self.stackedWidget.count():
self.stackedWidget.setCurrentIndex(index)
def indexOfWidget(self, widget):
# Warning: this may return -1 if widget is not a child of stackedwidget
return self.stackedWidget.indexOf(widget)
def setCurrentIndex(self, tab_index):
self.setTabIndex(tab_index)
# The signal will handle switching the stackwidget's widget
# self.stackedWidget.setCurrentWidget(self.stackedWidget.widget(tab_index))
def setCurrentWidget(self, widget):
"""Sets the current Tab on TabBar for this widget."""
self.tabBar.setCurrentIndex(self.indexOfWidget(widget))
@pyqtSlot(int)
def setTabIndex(self, index):
if index is None:
return
self.tabBar.setCurrentIndex(index)
@pyqtSlot(int)
def onRemovedWidget(self, index):
self.removeTab(index)
@pyqtSlot(int)
def removeTab(self, index):
"""Remove the tab, but not the widget (it should already be removed)"""
return self.tabBar.removeTab(index)
@pyqtSlot(int)
def removeWidget(self, widget):
return self.stackedWidget.removeWidget(widget)
def isTabVisible(self, index):
return self.tabBar.isTabVisible(index)
def getCurrentIndex(self):
return self.stackedWidget.currentIndex()
def getWidgetAtIndex(self, index):
return self.stackedWidget.widget(index)
def getCount(self):
return self.stackedWidget.count()
@pyqtSlot()
def toggleTabBar(self):
value = self.sender().isChecked()
self.actionToggleTabs.setChecked(value)
self.tabBar.setVisible(value)
@pyqtSlot(int)
def onTabCloseRequested(self, index):
target_widget = self.getWidgetAtIndex(index)
if isinstance(target_widget, DirectoriesDialog):
# On MacOS, the tab has a close button even though we explicitely
# set it to None in order to hide it. This should prevent
# the "Directories" tab from closing by mistake.
return
# target_widget.close() # seems unnecessary
# Removing the widget should trigger tab removal via the signal
self.removeWidget(self.getWidgetAtIndex(index))
@pyqtSlot()
def onDialogAccepted(self):
"""Remove tabbed dialog when Accepted/Done (close button clicked)."""
widget = self.sender()
self.removeWidget(widget)

View File

@@ -42,7 +42,7 @@ class AboutBox(QDialog):
self.setWindowTitle( self.setWindowTitle(
tr("About {}").format(QCoreApplication.instance().applicationName()) tr("About {}").format(QCoreApplication.instance().applicationName())
) )
self.resize(400, 190) self.resize(400, 290)
sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0) sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0) sizePolicy.setVerticalStretch(0)

View File

@@ -58,9 +58,10 @@ class ErrorReportDialog(QDialog):
self.verticalLayout.addWidget(self.errorTextEdit) self.verticalLayout.addWidget(self.errorTextEdit)
msg = tr( msg = tr(
"Error reports should be reported as Github issues. You can copy the error traceback " "Error reports should be reported as Github issues. You can copy the error traceback "
"above and paste it in a new issue (bonus point if you run a search to make sure the " "above and paste it in a new issue.\n\nPlease make sure to run a search for any already "
"issue doesn't already exist). What usually really helps is if you add a description " "existing issues beforehand. Also make sure to test the very latest version available from the repository, "
"of how you got the error. Thanks!" "since the bug you are experiencing might have already been patched.\n\n"
"What usually really helps is if you add a description of how you got the error. Thanks!"
"\n\n" "\n\n"
"Although the application should continue to run after this error, it may be in an " "Although the application should continue to run after this error, it may be in an "
"unstable state, so it is recommended that you restart the application." "unstable state, so it is recommended that you restart the application."

View File

@@ -6,10 +6,14 @@
# which should be included with this package. The terms are also available at # which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html # http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt, QSettings, QRect, QObject, pyqtSignal from PyQt5.QtCore import Qt, QSettings, QRect, QObject, pyqtSignal, QStandardPaths
from PyQt5.QtWidgets import QDockWidget
from hscommon.trans import trget from hscommon.trans import trget
from hscommon.util import tryint from hscommon.util import tryint
from hscommon.plat import ISWINDOWS
from os import path as op
tr = trget("qtlib") tr = trget("qtlib")
@@ -73,7 +77,18 @@ class Preferences(QObject):
def __init__(self): def __init__(self):
QObject.__init__(self) QObject.__init__(self)
self.reset() self.reset()
self._settings = QSettings() # On windows use an ini file in the AppDataLocation instead of registry if possible as it
# makes it easier for a user to clear it out when there are issues.
if ISWINDOWS:
Locations = QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)
if Locations:
self._settings = QSettings(
op.join(Locations[0], "settings.ini"), QSettings.IniFormat
)
else:
self._settings = QSettings()
else:
self._settings = QSettings()
def _load_values(self, settings, get): def _load_values(self, settings, get):
pass pass
@@ -120,19 +135,27 @@ class Preferences(QObject):
self._settings.setValue(name, normalize_for_serialization(value)) self._settings.setValue(name, normalize_for_serialization(value))
def saveGeometry(self, name, widget): def saveGeometry(self, name, widget):
# We save geometry under a 5-sized int array: first item is a flag for whether the widget # We save geometry under a 7-sized int array: first item is a flag
# is maximized and the other 4 are (x, y, w, h). # for whether the widget is maximized, second item is a flag for whether
# the widget is docked, third item is a Qt::DockWidgetArea enum value,
# and the other 4 are (x, y, w, h).
m = 1 if widget.isMaximized() else 0 m = 1 if widget.isMaximized() else 0
d = 1 if isinstance(widget, QDockWidget) and not widget.isFloating() else 0
area = widget.parent.dockWidgetArea(widget) if d else 0
r = widget.geometry() r = widget.geometry()
rectAsList = [r.x(), r.y(), r.width(), r.height()] rectAsList = [r.x(), r.y(), r.width(), r.height()]
self.set_value(name, [m] + rectAsList) self.set_value(name, [m, d, area] + rectAsList)
def restoreGeometry(self, name, widget): def restoreGeometry(self, name, widget):
geometry = self.get_value(name) geometry = self.get_value(name)
if geometry and len(geometry) == 5: if geometry and len(geometry) == 7:
m, x, y, w, h = geometry m, d, area, x, y, w, h = geometry
if m: if m:
widget.setWindowState(Qt.WindowMaximized) widget.setWindowState(Qt.WindowMaximized)
else: else:
r = QRect(x, y, w, h) r = QRect(x, y, w, h)
widget.setGeometry(r) widget.setGeometry(r)
if isinstance(widget, QDockWidget):
# Inform of the previous dock state and the area used
return bool(d), area
return False, 0

View File

@@ -1,5 +1,5 @@
pytest>=2.0.0,<3.0 pytest>=5,<6
pytest-monkeyplus>=1.0.0
flake8 flake8
tox-travis tox-travis
black black
pyinstaller>=4.0,<5.0; sys_platform != 'linux'

View File

@@ -1,3 +0,0 @@
PyQt5 >=5.4,<6.0
pypiwin32>=200
pyinstaller>=3.4,<4.0

View File

@@ -1,4 +1,7 @@
Send2Trash>=1.3.0 Send2Trash>=1.3.0
sphinx>=1.2.2 sphinx>=1.2.2
polib>=1.0.4 polib>=1.0.4
hsaudiotag3k>=1.1.3 hsaudiotag3k>=1.1.3*
distro>=1.5.0
PyQt5 >=5.4,<6.0; sys_platform != 'linux'
pywin32>=200; sys_platform == 'win32'

2
run.py
View File

@@ -16,7 +16,7 @@ from PyQt5.QtWidgets import QApplication
from hscommon.trans import install_gettext_trans_under_qt from hscommon.trans import install_gettext_trans_under_qt
from qtlib.error_report_dialog import install_excepthook from qtlib.error_report_dialog import install_excepthook
from qtlib.util import setupQtLogging from qtlib.util import setupQtLogging
from qt import dg_rc from qt import dg_rc # noqa: F401
from qt.platform import BASE_PATH from qt.platform import BASE_PATH
from core import __version__, __appname__ from core import __version__, __appname__

View File

@@ -1,21 +0,0 @@
#!/bin/bash
echo "Creating git archive"
version=`python -c "from hscommon.build import get_module_version; print(get_module_version('core'))"`
dest="dupeguru-src-${version}.tar"
git archive -o ${dest} HEAD
# Now, we need to include submodules
submodules="cocoalib"
for submodule in $submodules; do
echo "Adding submodule ${submodule} to archive"
archive_name="${submodule}.tar"
git -C ${submodule} archive -o ../${archive_name} --prefix ${submodule}/ HEAD
tar -A ${archive_name} -f ${dest}
rm ${archive_name}
done
xz ${dest}
echo "Built source package ${dest}.xz"

View File

@@ -48,9 +48,9 @@ SetCompressor /SOLID lzma
!define APPLICENSE "LICENSE" ; License is not in build directory !define APPLICENSE "LICENSE" ; License is not in build directory
!define APPICON "images\dgse_logo.ico" ; nor is the icon !define APPICON "images\dgse_logo.ico" ; nor is the icon
!define DISTDIR "dist" !define DISTDIR "dist"
!define HELPURL "http://www.hardcoded.net/support/" !define HELPURL "https://github.com/arsenetar/dupeguru/issues"
!define UPDATEURL "http://www.hardcoded.net/dupeguru/" !define UPDATEURL "https://dupeguru.voltaicideas.net/"
!define ABOUTURL "http://www.hardcoded.net/dupeguru/" !define ABOUTURL "https://dupeguru.voltaicideas.net/"
; Static Defines ; Static Defines
!define UNINSTALLREGBASE "Software\Microsoft\Windows\CurrentVersion\Uninstall" !define UNINSTALLREGBASE "Software\Microsoft\Windows\CurrentVersion\Uninstall"

15
tox.ini
View File

@@ -1,28 +1,21 @@
[tox] [tox]
envlist = py36,py37,py38 envlist = py36,py37,py38,py39
skipsdist = True skipsdist = True
skip_missing_interpreters = True skip_missing_interpreters = True
[testenv] [testenv]
whitelist_externals =
make
setenv = setenv =
PYTHON="{envpython}" PYTHON="{envpython}"
commands = commands =
make modules python build.py --modules
flake8 flake8
py.test core hscommon {posargs:py.test core hscommon}
deps = deps =
-r{toxinidir}/requirements.txt -r{toxinidir}/requirements.txt
-r{toxinidir}/requirements-extra.txt -r{toxinidir}/requirements-extra.txt
[testenv:WINDOWS]
deps =
{[testenv]deps}
-r{toxinidir}/requirements-windows.txt
[flake8] [flake8]
exclude = .tox,env,build,hscommon/tests,cocoalib,cocoa,help,./qt/dg_rc.py,qt/run_template.py,cocoa/run_template.py,./run.py,./pkg exclude = .tox,env,build,cocoalib,cocoa,help,./qt/dg_rc.py,cocoa/run_template.py,./pkg
max-line-length = 120 max-line-length = 120
ignore = E731,E203,E501,W503 ignore = E731,E203,E501,W503