Version 473
This commit is contained in:
parent
3be38d0594
commit
f2669f3f5b
|
@ -184,6 +184,7 @@ jobs:
|
|||
run: |
|
||||
move ${{ steps.setup_ffmpeg.outputs.ffmpeg-path }} hydrus\bin\
|
||||
move hydrus\static\build_files\windows\sqlite3.dll hydrus\
|
||||
move hydrus\static\build_files\windows\sqlite3.exe hydrus\db
|
||||
move hydrus\static\build_files\windows\client-win.spec client-win.spec
|
||||
move hydrus\static\build_files\windows\server-win.spec server-win.spec
|
||||
pyinstaller server-win.spec
|
||||
|
|
|
@ -8,6 +8,29 @@
|
|||
<div class="content">
|
||||
<h3 id="changelog"><a href="#changelog">changelog</a></h3>
|
||||
<ul>
|
||||
<li><h3 id="version_473"><a href="#version_473">version 473</a></h3></li>
|
||||
<ul>
|
||||
<li>misc:</li>
|
||||
<li>fixed the recent problem with drag and dropping thumbnails to a level below the top row of pages. sorry for the trouble!</li>
|
||||
<li>fixed a bug where the client would not load results sorting by 'import time' when the search file domain was a single deleted file domain</li>
|
||||
<li>fixed a list display bug in the edit page parser dialog when a subsidiary page parser has two complicated string-match based content parsers</li>
|
||||
<li>collections now sort by modified time, using the largest known modified time in their collection</li>
|
||||
<li>added sqlite3.exe console back into the windows build--sorry, it was missing since the github build changeover!</li>
|
||||
<li>added a note to the help about backing up when tight on space, which I will repeat here: the sqlite database files are very compressible (70GB->17GB on default 7zip settings!), so if you need more space on your backup drive, this is a good way to reclaim it</li>
|
||||
<li>.</li>
|
||||
<li>command palette:</li>
|
||||
<li>a user has written a cool 'command palette' for the program! it brings up a type-and-search interface to navigate to pages or menu entries.</li>
|
||||
<li>I have integrated his first version and set the default shortcut to Ctrl+P. users who update will get this shortcut if they have nothing else on Ctrl+P on 'main window' set. if you prefer Ctrl+K or anything else, you can change it under _file->shortcuts->the main window_</li>
|
||||
<li>regular users will get a page list they can search and select, advanced users will also get the (potentially dangerous) full scan of the menubar and current thumbnail right-click menu. I will be polishing this latter feature in future to filter out big maintenance jobs and show checkbox status and similar, so if you are advanced, please be careful for now</li>
|
||||
<li>try it out, and let me know how it goes. the underlying widget is neat, and I can change its behaviour and extend it significantly</li>
|
||||
<li>.</li>
|
||||
<li>(mostly advanced) deleted file improvements:</li>
|
||||
<li>files that have been deleted from a local file domain are now aware of their file deletion reason. this is visible in the right-click menu of thumb or media canvas</li>
|
||||
<li>the advanced file deletion dialog now initialises using this stored reason. if all pending deletees have the same existing reason stored, it will display it, and if they are all set but differ, this will be noted and an option to not alter them is also available. this will come up later in niche advanced situations with mutiple file services</li>
|
||||
<li>reversing a recent change, local file deletion reasons are no longer cleared on undelete or (re)import. they'll now hang around invisibly and initialise any future advanced file deletion dialog</li>
|
||||
<li>updated the thumbnail and canvas undelete mechanism to handle multiple services. now, if the files are deleted in more than one domain, you will be asked to multiple-select which you wish to undelete for. if there is only one eligible undelete service, the process remains unchanged--you'll just get a yes/no confirmation if the 'confirm trash' option is set</li>
|
||||
<li>misc multiple local file services code conversion work</li>
|
||||
</ul>
|
||||
<li><h3 id="version_472"><a href="#version_472">version 472</a></h3></li>
|
||||
<ul>
|
||||
<li>highlights:</li>
|
||||
|
@ -26,7 +49,6 @@
|
|||
<li>if the 'durable' temporary database exists on boot, it is now deleted and a fresh one created rather than trying to re-use the old one (which would not have any useful information anyway), and a note is made to log. one user recently had a problem where an existing corrupt temp dir was stopping boot, which this fixes</li>
|
||||
<li>.</li>
|
||||
<li>misc:</li>
|
||||
<li>updated the windows build to use a newer version of mpv, moving from roughly 2021-02 to 2022-01. this replaces mpv-1.dll with mpv-2.dll, and I set the installer to delete the old '1'. if you extract, you can delete the old '1' yourself, but things seems fine with both. in any case, let me know if you have any trouble!</li>
|
||||
<li>updated the windows build to use sqlite 3.37.2, the sqlite3 in the db dir is also updated</li>
|
||||
<li>the deleted files system now neatly cleans up old file deletion reasons on file import and file undelete</li>
|
||||
<li>cleared out some old thumbnail generation code, including deleting an old and now obselete optimisation where too-large thumbs were scaled down to make new thumbs rather than revisiting source. since our thumb situation is more complicated now, this is gone in favour of nicer quality thumbs and simpler code</li>
|
||||
|
|
|
@ -131,6 +131,7 @@
|
|||
<p>I recommend you always backup before you update, just in case there is a problem with my code that breaks your database. If that happens, please <a href="contact.html">contact me</a>, describing the problem, and revert to the functioning older version. I'll get on any problems like that immediately.</p>
|
||||
<h3 id="backing_up_small"><a href="#backing_up_small">backing up with not much space</a></h3>
|
||||
<p>If you decide not to maintain a backup because you cannot afford drive space for all your files, please please at least back up your actual database files. Use FreeFileSync or a similar program to back up the four 'client*.db' files in install_dir/db when the client is not running. Just make sure you have a copy of those files, and then if your main install becomes damaged, we will have a reference to either roll back to or manually restore data from. Even if you lose a bunch of media files in this case, with an intact database we'll be able to schedule recovery of anything with a URL.<p>
|
||||
<p>If you are really short on space, note also that the database files are very compressible. A very large database where the four files add up to 70GB can compress down to 17GB zip with 7zip on default settings. This obviously takes some additional time to do, but if you are really short on space it may be the only way it fits, and if your only backup drive is a slow USB stick, then you might actually save time from not having to transfer the other 53GB! Media files (jpegs, webms, etc...) are generally not very compressible, usually 5% at best, so it is usually not worth trying.</p>
|
||||
<p>It is best to have all four database files. It is generally easy and quick to fix problems if you have a backup of all four. If client.caches.db is missing, you can recover but it might take ten or more hours of CPU work to regenerate. If client.mappings.db is missing, you might be able to recover tags for your local files from a mirror in an intact client.caches.db. However, client.master.db and client.db are the most important. If you lose either of those, or they become too damaged to read and you have no backup, then your database is essentially dead and likely every single archive and view and tag and note and url record you made is lost. This has happened before, do not let it be you.</p>
|
||||
<p class="right"><a href="getting_started_files.html">Let's import some files! ----></a></p>
|
||||
</div>
|
||||
|
|
|
@ -141,6 +141,7 @@ SIMPLE_AUTOCOMPLETE_IF_EMPTY_MEDIA_NEXT = 133
|
|||
SIMPLE_MEDIA_SEEK_DELTA = 134
|
||||
SIMPLE_GLOBAL_PROFILE_MODE_FLIP = 135
|
||||
SIMPLE_GLOBAL_FORCE_ANIMATION_SCANBAR_SHOW = 136
|
||||
SIMPLE_OPEN_COMMAND_PALETTE = 137
|
||||
|
||||
simple_enum_to_str_lookup = {
|
||||
SIMPLE_ARCHIVE_DELETE_FILTER_BACK : 'archive/delete filter: back',
|
||||
|
@ -279,7 +280,8 @@ simple_enum_to_str_lookup = {
|
|||
SIMPLE_AUTOCOMPLETE_IF_EMPTY_MEDIA_NEXT : 'if input & results list are empty and in media viewer manage tags dialog, move to previous media',
|
||||
SIMPLE_MEDIA_SEEK_DELTA : 'seek media',
|
||||
SIMPLE_GLOBAL_PROFILE_MODE_FLIP : 'flip profile mode on/off',
|
||||
SIMPLE_GLOBAL_FORCE_ANIMATION_SCANBAR_SHOW : 'force the animation scanbar to show (flip on/off)'
|
||||
SIMPLE_GLOBAL_FORCE_ANIMATION_SCANBAR_SHOW : 'force the animation scanbar to show (flip on/off)',
|
||||
SIMPLE_OPEN_COMMAND_PALETTE : 'open the command palette'
|
||||
}
|
||||
|
||||
legacy_simple_str_to_enum_lookup = {
|
||||
|
|
|
@ -381,6 +381,7 @@ def GetDefaultShortcuts():
|
|||
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'W' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_CLOSE_PAGE ) )
|
||||
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'Y' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_REDO ) )
|
||||
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'Z' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_UNDO ) )
|
||||
main_gui.SetCommand( ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'P' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] ), CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_OPEN_COMMAND_PALETTE ) )
|
||||
|
||||
shortcuts.append( main_gui )
|
||||
|
||||
|
|
|
@ -7,6 +7,10 @@ from hydrus.core import HydrusSerialisable
|
|||
|
||||
from hydrus.client import ClientConstants as CC
|
||||
|
||||
def ValidLocalDomainsFilter( service_keys ):
|
||||
|
||||
return [ service_key for service_key in service_keys if HG.client_controller.services_manager.ServiceExists( service_key ) and HG.client_controller.services_manager.GetServiceType( service_key ) == HC.LOCAL_FILE_DOMAIN ]
|
||||
|
||||
class LocationContext( HydrusSerialisable.SerialisableBase ):
|
||||
|
||||
SERIALISABLE_TYPE = HydrusSerialisable.SERIALISABLE_TYPE_location_context
|
||||
|
|
|
@ -6783,6 +6783,8 @@ class DB( HydrusDB.HydrusDB ):
|
|||
|
||||
hash_ids_to_file_modified_timestamps = dict( self._Execute( 'SELECT hash_id, file_modified_timestamp FROM {} CROSS JOIN file_modified_timestamps USING ( hash_id );'.format( temp_table_name ) ) )
|
||||
|
||||
hash_ids_to_local_file_deletion_reasons = self.modules_files_storage.GetHashIdsToFileDeletionReasons( temp_table_name )
|
||||
|
||||
hash_ids_to_current_file_service_ids = { hash_id : [ file_service_id for ( file_service_id, timestamp ) in file_service_ids_and_timestamps ] for ( hash_id, file_service_ids_and_timestamps ) in hash_ids_to_current_file_service_ids_and_timestamps.items() }
|
||||
|
||||
hash_ids_to_tags_managers = self._GetForceRefreshTagsManagersWithTableHashIds( missing_hash_ids, temp_table_name, hash_ids_to_current_file_service_ids = hash_ids_to_current_file_service_ids )
|
||||
|
@ -6825,7 +6827,16 @@ class DB( HydrusDB.HydrusDB ):
|
|||
file_modified_timestamp = None
|
||||
|
||||
|
||||
locations_manager = ClientMediaManagers.LocationsManager( current_file_service_keys_to_timestamps, deleted_file_service_keys_to_timestamps, pending_file_service_keys, petitioned_file_service_keys, inbox, urls, service_keys_to_filenames, file_modified_timestamp = file_modified_timestamp )
|
||||
if hash_id in hash_ids_to_local_file_deletion_reasons:
|
||||
|
||||
local_file_deletion_reason = hash_ids_to_local_file_deletion_reasons[ hash_id ]
|
||||
|
||||
else:
|
||||
|
||||
local_file_deletion_reason = None
|
||||
|
||||
|
||||
locations_manager = ClientMediaManagers.LocationsManager( current_file_service_keys_to_timestamps, deleted_file_service_keys_to_timestamps, pending_file_service_keys, petitioned_file_service_keys, inbox, urls, service_keys_to_filenames, file_modified_timestamp = file_modified_timestamp, local_file_deletion_reason = local_file_deletion_reason )
|
||||
|
||||
#
|
||||
|
||||
|
@ -6897,7 +6908,7 @@ class DB( HydrusDB.HydrusDB ):
|
|||
return media_results[0]
|
||||
|
||||
|
||||
def _GetMediaResultsFromHashes( self, hashes: typing.Iterable[ bytes ], sorted: bytes = False ) -> typing.List[ ClientMediaResult.MediaResult ]:
|
||||
def _GetMediaResultsFromHashes( self, hashes: typing.Iterable[ bytes ], sorted: bool = False ) -> typing.List[ ClientMediaResult.MediaResult ]:
|
||||
|
||||
query_hash_ids = set( self.modules_hashes_local_cache.GetHashIds( hashes ) )
|
||||
|
||||
|
@ -8322,16 +8333,31 @@ class DB( HydrusDB.HydrusDB ):
|
|||
|
||||
#
|
||||
|
||||
file_import_options = file_import_job.GetFileImportOptions()
|
||||
|
||||
file_info_manager = ClientMediaManagers.FileInfoManager( hash_id, hash, size, mime, width, height, duration, num_frames, has_audio, num_words )
|
||||
|
||||
now = HydrusData.GetNow()
|
||||
|
||||
for destination_file_service_key in ( CC.LOCAL_FILE_SERVICE_KEY, ): # get this list from FIO, with fallback recovery
|
||||
destination_location_context = file_import_options.GetDestinationLocationContext()
|
||||
|
||||
destination_location_context.FixMissingServices( ClientLocation.ValidLocalDomainsFilter )
|
||||
|
||||
if not destination_location_context.IncludesCurrent():
|
||||
|
||||
service_ids = self.modules_services.GetServiceIds( ( HC.LOCAL_FILE_DOMAIN, ) )
|
||||
|
||||
service_id = min( service_ids )
|
||||
|
||||
service_key = self.modules_services.GetService( service_id ).GetServiceKey()
|
||||
|
||||
destination_location_context = ClientLocation.LocationContext( current_service_keys = ( service_key, ) )
|
||||
|
||||
|
||||
for destination_file_service_key in destination_location_context.current_service_keys:
|
||||
|
||||
destination_service_id = self.modules_services.GetServiceId( destination_file_service_key )
|
||||
|
||||
self.modules_files_storage.ClearFileDeletionReason( ( hash_id, ) )
|
||||
|
||||
self._AddFiles( destination_service_id, [ ( hash_id, now ) ] )
|
||||
|
||||
content_update = HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_ADD, ( file_info_manager, now ) )
|
||||
|
@ -8341,8 +8367,6 @@ class DB( HydrusDB.HydrusDB ):
|
|||
|
||||
#
|
||||
|
||||
file_import_options = file_import_job.GetFileImportOptions()
|
||||
|
||||
if file_import_options.AutomaticallyArchives():
|
||||
|
||||
if HG.file_import_report_mode:
|
||||
|
@ -9104,8 +9128,6 @@ class DB( HydrusDB.HydrusDB ):
|
|||
|
||||
elif action == HC.CONTENT_UPDATE_UNDELETE:
|
||||
|
||||
self.modules_files_storage.ClearFileDeletionReason( hash_ids )
|
||||
|
||||
self._UndeleteFiles( service_id, hash_ids )
|
||||
|
||||
elif action == HC.CONTENT_UPDATE_PEND:
|
||||
|
@ -10045,7 +10067,6 @@ class DB( HydrusDB.HydrusDB ):
|
|||
elif action == 'missing_thumbnail_hashes': result = self._GetRepositoryThumbnailHashesIDoNotHave( *args, **kwargs )
|
||||
elif action == 'num_deferred_file_deletes': result = self.modules_files_storage.GetDeferredPhysicalDeleteCounts()
|
||||
elif action == 'nums_pending': result = self._GetNumsPending( *args, **kwargs )
|
||||
elif action == 'trash_hashes': result = self._GetTrashHashes( *args, **kwargs )
|
||||
elif action == 'options': result = self._GetOptions( *args, **kwargs )
|
||||
elif action == 'pending': result = self._GetPending( *args, **kwargs )
|
||||
elif action == 'random_potential_duplicate_hashes': result = self._DuplicatesGetRandomPotentialDuplicateHashes( *args, **kwargs )
|
||||
|
@ -10072,6 +10093,7 @@ class DB( HydrusDB.HydrusDB ):
|
|||
elif action == 'tag_display_decorators': result = self.modules_tag_display.GetUIDecorators( *args, **kwargs )
|
||||
elif action == 'tag_siblings_and_parents_lookup': result = self.modules_tag_display.GetSiblingsAndParentsForTags( *args, **kwargs )
|
||||
elif action == 'tag_siblings_lookup': result = self.modules_tag_siblings.GetTagSiblingsForTags( *args, **kwargs )
|
||||
elif action == 'trash_hashes': result = self._GetTrashHashes( *args, **kwargs )
|
||||
elif action == 'potential_duplicates_count': result = self._DuplicatesGetPotentialDuplicatesCount( *args, **kwargs )
|
||||
elif action == 'url_statuses': result = self._GetURLStatuses( *args, **kwargs )
|
||||
elif action == 'vacuum_data': result = self.modules_db_maintenance.GetVacuumData( *args, **kwargs )
|
||||
|
@ -11642,7 +11664,7 @@ class DB( HydrusDB.HydrusDB ):
|
|||
|
||||
if sort_data == CC.SORT_FILES_BY_IMPORT_TIME:
|
||||
|
||||
if location_context.IsOneDomain():
|
||||
if location_context.IsOneDomain() and location_context.IncludesCurrent():
|
||||
|
||||
file_service_key = list( location_context.current_service_keys )[0]
|
||||
|
||||
|
@ -13596,6 +13618,36 @@ class DB( HydrusDB.HydrusDB ):
|
|||
|
||||
|
||||
|
||||
if version == 472:
|
||||
|
||||
try:
|
||||
|
||||
from hydrus.client.gui import ClientGUIShortcuts
|
||||
|
||||
main_gui = self.modules_serialisable.GetJSONDumpNamed( HydrusSerialisable.SERIALISABLE_TYPE_SHORTCUT_SET, dump_name = 'main_gui' )
|
||||
|
||||
palette_shortcut = ClientGUIShortcuts.Shortcut( ClientGUIShortcuts.SHORTCUT_TYPE_KEYBOARD_CHARACTER, ord( 'P' ), ClientGUIShortcuts.SHORTCUT_PRESS_TYPE_PRESS, [ ClientGUIShortcuts.SHORTCUT_MODIFIER_CTRL ] )
|
||||
palette_command = CAC.ApplicationCommand.STATICCreateSimpleCommand( CAC.SIMPLE_OPEN_COMMAND_PALETTE )
|
||||
|
||||
result = main_gui.GetCommand( palette_shortcut )
|
||||
|
||||
if result is None:
|
||||
|
||||
main_gui.SetCommand( palette_shortcut, palette_command )
|
||||
|
||||
self.modules_serialisable.SetJSONDump( main_gui )
|
||||
|
||||
|
||||
except Exception as e:
|
||||
|
||||
HydrusData.PrintException( e )
|
||||
|
||||
message = 'The new palette shortcut failed to set! This is not super important, but hydev would be interested in seeing the error that was printed to the log.'
|
||||
|
||||
self.pub_initial_message( message )
|
||||
|
||||
|
||||
|
||||
self._controller.frame_splash_status.SetTitleText( 'updated db to v{}'.format( HydrusData.ToHumanInt( version + 1 ) ) )
|
||||
|
||||
self._Execute( 'UPDATE version SET version = ?;', ( version + 1, ) )
|
||||
|
|
|
@ -857,6 +857,11 @@ class ClientDBFilesStorage( ClientDBModule.ClientDBModule ):
|
|||
return hash_ids_to_current_file_service_ids
|
||||
|
||||
|
||||
def GetHashIdsToFileDeletionReasons( self, hash_ids_table_name ):
|
||||
|
||||
return dict( self._Execute( 'SELECT hash_id, text FROM {} CROSS JOIN local_file_deletion_reasons USING ( hash_id ) CROSS JOIN texts ON ( reason_id = text_id );'.format( hash_ids_table_name ) ) )
|
||||
|
||||
|
||||
def GetHashIdsToServiceInfoDicts( self, temp_hash_ids_table_name ):
|
||||
|
||||
hash_ids_to_current_file_service_ids_and_timestamps = collections.defaultdict( list )
|
||||
|
|
|
@ -75,6 +75,8 @@ from hydrus.client.gui import ClientGUITags
|
|||
from hydrus.client.gui import ClientGUITime
|
||||
from hydrus.client.gui import ClientGUITopLevelWindows
|
||||
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
|
||||
from hydrus.client.gui import QLocator
|
||||
from hydrus.client.gui import ClientGUILocatorSearchProviders
|
||||
from hydrus.client.gui import QtPorting as QP
|
||||
from hydrus.client.gui.networking import ClientGUIHydrusNetwork
|
||||
from hydrus.client.gui.networking import ClientGUINetwork
|
||||
|
@ -583,6 +585,31 @@ class FrameGUI( ClientGUITopLevelWindows.MainFrameThatResizes ):
|
|||
|
||||
ClientGUIFunctions.UpdateAppDisplayName()
|
||||
|
||||
# locator setup
|
||||
|
||||
self._locator = QLocator.QLocator( self )
|
||||
|
||||
self._locator.setIconBasePath( HC.STATIC_DIR + os.path.sep )
|
||||
|
||||
# TODO: make configurable which providers + order
|
||||
self._locator.addProvider( ClientGUILocatorSearchProviders.CalculatorSearchProvider() )
|
||||
self._locator.addProvider( ClientGUILocatorSearchProviders.MainMenuSearchProvider() )
|
||||
self._locator.addProvider( ClientGUILocatorSearchProviders.MediaMenuSearchProvider() )
|
||||
self._locator.addProvider( ClientGUILocatorSearchProviders.PagesSearchProvider() )
|
||||
self._locator_widget = QLocator.QLocatorWidget( self,
|
||||
width = 800,
|
||||
resultHeight = 36,
|
||||
titleHeight = 36,
|
||||
primaryTextWidth = 430,
|
||||
secondaryTextWidth = 280,
|
||||
maxVisibleItemCount = 16
|
||||
)
|
||||
self._locator_widget.setDefaultStylingEnabled( False )
|
||||
self._locator_widget.setLocator( self._locator )
|
||||
self._locator_widget.setAlignment( QC.Qt.AlignCenter )
|
||||
self._locator_widget.setEscapeShortcuts( [ QG.QKeySequence( QC.Qt.Key_Escape ) ] )
|
||||
# self._locator_widget.setQueryTimeout( 100 ) # how much to wait before starting a search after user edit. default 0
|
||||
|
||||
|
||||
def _AboutWindow( self ):
|
||||
|
||||
|
@ -7257,6 +7284,10 @@ The password is cleartext here but obscured in the entry dialog. Enter a blank p
|
|||
|
||||
HG.client_controller.new_options.FlipBoolean( 'force_animation_scanbar_show' )
|
||||
|
||||
elif action == CAC.SIMPLE_OPEN_COMMAND_PALETTE:
|
||||
|
||||
self._locator_widget.start()
|
||||
|
||||
elif action == CAC.SIMPLE_SHOW_HIDE_SPLITTERS:
|
||||
|
||||
self._ShowHideSplitters()
|
||||
|
|
|
@ -0,0 +1,368 @@
|
|||
from hydrus.client.gui.QLocator import QAbstractLocatorSearchProvider, QCalculatorSearchProvider, QLocatorSearchResult
|
||||
from hydrus.core import HydrusGlobals as HG
|
||||
from qtpy import QtWidgets as QW
|
||||
from html import escape
|
||||
|
||||
|
||||
def highlight_result_text( result_text: str, query_text: str ):
|
||||
|
||||
result_text = escape( result_text )
|
||||
|
||||
if query_text:
|
||||
|
||||
result_text = result_text.replace( escape( query_text ), '<b>' + escape( query_text ) + '</b>' )
|
||||
|
||||
return result_text
|
||||
|
||||
|
||||
# Subclass for customizing icon paths
|
||||
class CalculatorSearchProvider( QCalculatorSearchProvider ):
|
||||
|
||||
def __init__( self, parent = None ):
|
||||
|
||||
super().__init__( parent )
|
||||
|
||||
|
||||
def titleIconPath( self ):
|
||||
|
||||
return str()
|
||||
|
||||
|
||||
def selectedIconPath( self ):
|
||||
|
||||
return str()
|
||||
|
||||
|
||||
def iconPath( self ):
|
||||
|
||||
return str()
|
||||
|
||||
|
||||
class PagesSearchProvider( QAbstractLocatorSearchProvider ):
|
||||
|
||||
def __init__( self, parent = None ):
|
||||
|
||||
super().__init__( parent )
|
||||
|
||||
self.result_id_counter = 0
|
||||
self.result_ids_to_pages = {}
|
||||
|
||||
|
||||
def title( self ):
|
||||
|
||||
return "Pages"
|
||||
|
||||
|
||||
# How many preallocated result widgets should be created (so that we don't have to recreate the entire result list on each search)
|
||||
# Should be larger than the average expected result count
|
||||
def suggestedReservedItemCount( self ):
|
||||
|
||||
return 32
|
||||
|
||||
|
||||
# Called when the user activates a result
|
||||
def resultSelected( self, resultID: int ):
|
||||
|
||||
page = self.result_ids_to_pages.get( resultID, None )
|
||||
|
||||
if page:
|
||||
|
||||
HG.client_controller.gui._notebook.ShowPage( page )
|
||||
|
||||
self.result_ids_to_pages = {}
|
||||
|
||||
|
||||
# Should generate a list of QLocatorSearchResults
|
||||
def processQuery( self, query: str, context, jobID: int ):
|
||||
|
||||
self.result_ids_to_pages = {}
|
||||
|
||||
if not HG.client_controller.gui or not HG.client_controller.gui._notebook:
|
||||
|
||||
return
|
||||
|
||||
|
||||
tab_widget = HG.client_controller.gui._notebook
|
||||
|
||||
# helper function to traverse tab tree and generate entries
|
||||
def get_child_tabs( tab_widget: QW.QTabWidget, parent_name: str ) -> list:
|
||||
|
||||
result = []
|
||||
|
||||
for i in range( tab_widget.count() ):
|
||||
|
||||
widget = tab_widget.widget(i)
|
||||
|
||||
if isinstance( widget, QW.QTabWidget ): # page of pages
|
||||
|
||||
result.extend( get_child_tabs( widget, widget.GetName() ) )
|
||||
|
||||
else:
|
||||
|
||||
selectable_media_page = widget
|
||||
|
||||
label = '{} - {}'.format( selectable_media_page.GetName(), selectable_media_page.GetPrettyStatus() )
|
||||
|
||||
if not query in label:
|
||||
|
||||
continue
|
||||
|
||||
primary_text = highlight_result_text( label, query )
|
||||
secondary_text = 'top level page' if not parent_name else "child of '" + escape( parent_name ) + "'"
|
||||
|
||||
result.append( QLocatorSearchResult( self.result_id_counter, 'thumbnails.png', 'thumbnails.png', True, [ primary_text, secondary_text ] ) )
|
||||
|
||||
self.result_ids_to_pages[ self.result_id_counter ] = selectable_media_page
|
||||
|
||||
self.result_id_counter += 1
|
||||
|
||||
return result
|
||||
|
||||
tab_data = get_child_tabs( tab_widget, '' )
|
||||
|
||||
if tab_data:
|
||||
|
||||
self.resultsAvailable.emit( jobID, tab_data )
|
||||
|
||||
|
||||
# When this is called, it means that the Locator/LocatorWidget is done with these jobs and no results will be activated either
|
||||
# So if any still-in-progress search can be stopped and any resources associated with these jobs can be freed
|
||||
def stopJobs( self, jobs: list ):
|
||||
|
||||
self.result_ids_to_pages = {}
|
||||
|
||||
|
||||
# Should the title item be visible in the result list
|
||||
def hideTitle( self ):
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def titleIconPath( self ):
|
||||
|
||||
return str() #TODO fill this in
|
||||
|
||||
|
||||
class MainMenuSearchProvider( QAbstractLocatorSearchProvider ):
|
||||
|
||||
def __init__( self, parent = None ):
|
||||
|
||||
super().__init__( parent )
|
||||
|
||||
self.result_id_counter = 0
|
||||
self.result_ids_to_actions = {}
|
||||
|
||||
|
||||
def title( self ):
|
||||
|
||||
return "Main Menu"
|
||||
|
||||
|
||||
def suggestedReservedItemCount( self ):
|
||||
|
||||
return 128
|
||||
|
||||
|
||||
def resultSelected( self, resultID: int ):
|
||||
|
||||
action = self.result_ids_to_actions.get( resultID, None )
|
||||
|
||||
if action:
|
||||
|
||||
action.trigger()
|
||||
|
||||
self.result_ids_to_actions = {}
|
||||
|
||||
|
||||
def processQuery( self, query: str, context, jobID: int ):
|
||||
|
||||
if not HG.client_controller.new_options.GetBoolean( 'advanced_mode' ):
|
||||
|
||||
return
|
||||
|
||||
|
||||
if len( query ) < 3:
|
||||
|
||||
return
|
||||
|
||||
self.result_ids_to_pages = {}
|
||||
|
||||
if not HG.client_controller.gui or not HG.client_controller.gui._menubar:
|
||||
|
||||
return
|
||||
|
||||
menubar = HG.client_controller.gui._menubar
|
||||
|
||||
# helper function to traverse menu and generate entries
|
||||
# TODO: need to filter out menu items not suitable for display in locator
|
||||
# (probably best to mark them when they are created and just check a property here)
|
||||
# TODO: need special icon or secondary text for toggle-able items to see toggle state
|
||||
def get_menu_items( menu: QW.QWidget, parent_name: str ) -> list:
|
||||
|
||||
result = []
|
||||
|
||||
for action in menu.actions():
|
||||
|
||||
actionText = action.text().replace( "&", "" )
|
||||
if action.menu():
|
||||
|
||||
new_parent_name = parent_name + " | " + actionText if parent_name else actionText
|
||||
|
||||
result.extend( get_menu_items( action.menu(), new_parent_name ) )
|
||||
|
||||
else:
|
||||
|
||||
if not query in action.text() and not query in actionText:
|
||||
|
||||
continue
|
||||
|
||||
primary_text = highlight_result_text( actionText, query )
|
||||
secondary_text = escape( parent_name )
|
||||
|
||||
result.append( QLocatorSearchResult( self.result_id_counter, 'lightning.png', 'lightning.png', True, [ primary_text, secondary_text ] ) )
|
||||
|
||||
self.result_ids_to_actions[ self.result_id_counter ] = action
|
||||
|
||||
self.result_id_counter += 1
|
||||
|
||||
return result
|
||||
|
||||
menu_data = get_menu_items( menubar, '' )
|
||||
|
||||
if menu_data:
|
||||
|
||||
self.resultsAvailable.emit( jobID, menu_data )
|
||||
|
||||
|
||||
def stopJobs( self, jobs ):
|
||||
|
||||
self.result_ids_to_actions = {}
|
||||
|
||||
|
||||
def hideTitle( self ):
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def titleIconPath( self ):
|
||||
|
||||
return str() #TODO fill this in
|
||||
|
||||
|
||||
class MediaMenuSearchProvider( QAbstractLocatorSearchProvider ):
|
||||
|
||||
def __init__( self, parent = None ):
|
||||
|
||||
super().__init__( parent )
|
||||
|
||||
self.result_id_counter = 0
|
||||
self.result_ids_to_actions = {}
|
||||
self.menu = None
|
||||
|
||||
|
||||
def title( self ):
|
||||
|
||||
return "Media"
|
||||
|
||||
|
||||
def suggestedReservedItemCount( self ):
|
||||
|
||||
return 64
|
||||
|
||||
|
||||
def resultSelected( self, resultID: int ):
|
||||
|
||||
action = self.result_ids_to_actions.get( resultID, None )
|
||||
|
||||
if action:
|
||||
|
||||
action.trigger()
|
||||
|
||||
self.result_ids_to_actions = {}
|
||||
self.menu = None
|
||||
|
||||
|
||||
def processQuery( self, query: str, context, jobID: int ):
|
||||
|
||||
if not HG.client_controller.new_options.GetBoolean( 'advanced_mode' ):
|
||||
|
||||
return
|
||||
|
||||
|
||||
if len( query ) < 3:
|
||||
|
||||
return
|
||||
|
||||
self.result_ids_to_pages = {}
|
||||
self.menu = None
|
||||
|
||||
if not HG.client_controller.gui or not HG.client_controller.gui._notebook:
|
||||
|
||||
return
|
||||
|
||||
media_page = HG.client_controller.gui._notebook.GetCurrentMediaPage()
|
||||
|
||||
if not media_page or not media_page._media_panel:
|
||||
|
||||
return
|
||||
|
||||
self.menu = media_page._media_panel.ShowMenu( True )
|
||||
|
||||
# helper function to traverse menu and generate entries
|
||||
# TODO: need to filter out menu items not suitable for display in locator
|
||||
# (probably best to mark them when they are created and just check a property here)
|
||||
# TODO: need special icon or secondary text for toggle-able items to see toggle state
|
||||
def get_menu_items( menu: QW.QWidget, parent_name: str ) -> list:
|
||||
|
||||
result = []
|
||||
|
||||
for action in menu.actions():
|
||||
|
||||
actionText = action.text().replace( "&", "" )
|
||||
if action.menu():
|
||||
|
||||
new_parent_name = parent_name + " | " + actionText if parent_name else actionText
|
||||
|
||||
result.extend( get_menu_items( action.menu(), new_parent_name ) )
|
||||
|
||||
else:
|
||||
|
||||
if not query in action.text() and not query in actionText:
|
||||
|
||||
continue
|
||||
|
||||
primary_text = highlight_result_text( actionText, query )
|
||||
secondary_text = escape( parent_name )
|
||||
|
||||
result.append( QLocatorSearchResult( self.result_id_counter, 'images.png', 'images.png', True, [ primary_text, secondary_text ] ) )
|
||||
|
||||
self.result_ids_to_actions[ self.result_id_counter ] = action
|
||||
|
||||
self.result_id_counter += 1
|
||||
|
||||
return result
|
||||
|
||||
menu_data = get_menu_items( self.menu, '' )
|
||||
|
||||
if menu_data:
|
||||
|
||||
self.resultsAvailable.emit( jobID, menu_data )
|
||||
|
||||
|
||||
def stopJobs( self, jobs ):
|
||||
|
||||
self.result_ids_to_actions = {}
|
||||
self.menu = {}
|
||||
|
||||
|
||||
def hideTitle( self ):
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def titleIconPath( self ):
|
||||
|
||||
return str() #TODO fill this in
|
||||
|
||||
# TODO: provider for page tab right click menu actions?
|
||||
|
|
@ -10,6 +10,7 @@ from hydrus.core import HydrusGlobals as HG
|
|||
|
||||
from hydrus.client import ClientApplicationCommand as CAC
|
||||
from hydrus.client import ClientConstants as CC
|
||||
from hydrus.client.gui import ClientGUIDialogsQuick
|
||||
from hydrus.client.gui import ClientGUIScrolledPanelsEdit
|
||||
from hydrus.client.gui import ClientGUITopLevelWindowsPanels
|
||||
from hydrus.client.media import ClientMedia
|
||||
|
@ -323,3 +324,74 @@ def UndeleteFiles( hashes ):
|
|||
|
||||
|
||||
|
||||
def UndeleteMedia( win, media ):
|
||||
|
||||
media_deleted_service_keys = HydrusData.MassUnion( ( m.GetLocationsManager().GetDeleted() for m in media ) )
|
||||
|
||||
local_file_services = HG.client_controller.services_manager.GetServices( ( HC.LOCAL_FILE_DOMAIN, ) )
|
||||
|
||||
undeletable_services = [ local_file_service for local_file_service in local_file_services if local_file_service.GetServiceKey() in media_deleted_service_keys ]
|
||||
|
||||
if len( undeletable_services ) > 0:
|
||||
|
||||
do_it = False
|
||||
|
||||
if len( undeletable_services ) > 1:
|
||||
|
||||
choice_tuples = []
|
||||
|
||||
for ( i, service ) in enumerate( undeletable_services ):
|
||||
|
||||
choice_tuples.append( ( service.GetName(), service, i == 0 ) )
|
||||
|
||||
|
||||
try:
|
||||
|
||||
undelete_services = ClientGUIDialogsQuick.SelectMultipleFromList( win, 'Undelete for?', choice_tuples )
|
||||
|
||||
do_it = True
|
||||
|
||||
except HydrusExceptions.CancelledException:
|
||||
|
||||
return
|
||||
|
||||
|
||||
else:
|
||||
|
||||
undelete_services = undeletable_services
|
||||
|
||||
if HC.options[ 'confirm_trash' ]:
|
||||
|
||||
result = ClientGUIDialogsQuick.GetYesNo( win, 'Undelete this file back to {}?'.format( undelete_services[0].GetName() ) )
|
||||
|
||||
if result == QW.QDialog.Accepted:
|
||||
|
||||
do_it = True
|
||||
|
||||
|
||||
else:
|
||||
|
||||
do_it = True
|
||||
|
||||
|
||||
|
||||
if do_it:
|
||||
|
||||
for chunk_of_media in HydrusData.SplitIteratorIntoChunks( media, 64 ):
|
||||
|
||||
service_keys_to_content_updates = collections.defaultdict( list )
|
||||
|
||||
for service in undelete_services:
|
||||
|
||||
service_key = service.GetServiceKey()
|
||||
|
||||
undeletee_hashes = [ m.GetHash() for m in chunk_of_media if service_key in m.GetLocationsManager().GetDeleted() ]
|
||||
|
||||
service_keys_to_content_updates[ service_key ] = [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_UNDELETE, undeletee_hashes ) ]
|
||||
|
||||
|
||||
HG.client_controller.Write( 'content_updates', service_keys_to_content_updates )
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -3087,7 +3087,7 @@ class EditPageParserPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
produces = page_parser.GetParsableContent()
|
||||
|
||||
produces = sorted( produces )
|
||||
produces = sorted( produces, key = lambda row: ( row[0], row[1] ) ) # ( name, content_type ), ignores potentially unsortable StringMatch etc.. in additional info in case of dupe
|
||||
|
||||
pretty_name = name
|
||||
pretty_formula = formula.ToPrettyString()
|
||||
|
|
|
@ -344,6 +344,9 @@ class EditDefaultTagImportOptionsPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
||||
|
||||
SPECIAL_CHOICE_CUSTOM = 0
|
||||
SPECIAL_CHOICE_NO_REASON = 1
|
||||
|
||||
def __init__( self, parent: QW.QWidget, media, default_reason, suggested_file_service_key = None ):
|
||||
|
||||
ClientGUIScrolledPanels.EditPanel.__init__( self, parent )
|
||||
|
@ -352,6 +355,8 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
self._question_is_already_resolved = len( self._media ) == 0
|
||||
|
||||
( self._all_files_have_existing_file_deletion_reasons, self._existing_shared_file_deletion_reason ) = self._GetExistingSharedFileDeletionReason()
|
||||
|
||||
self._simple_description = ClientGUICommon.BetterStaticText( self, label = 'init' )
|
||||
|
||||
self._permitted_action_choices = []
|
||||
|
@ -389,9 +394,20 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
self._reason_panel = ClientGUICommon.StaticBox( self, 'reason' )
|
||||
|
||||
existing_reason_was_in_list = False
|
||||
|
||||
permitted_reason_choices = []
|
||||
|
||||
permitted_reason_choices.append( ( default_reason, default_reason ) )
|
||||
if self._existing_shared_file_deletion_reason is not None and default_reason == self._existing_shared_file_deletion_reason:
|
||||
|
||||
existing_reason_was_in_list = True
|
||||
|
||||
permitted_reason_choices.append( ( 'keep existing reason: {}'.format( default_reason ), default_reason ) )
|
||||
|
||||
else:
|
||||
|
||||
permitted_reason_choices.append( ( default_reason, default_reason ) )
|
||||
|
||||
|
||||
if HG.client_controller.new_options.GetBoolean( 'remember_last_advanced_file_deletion_reason' ):
|
||||
|
||||
|
@ -413,7 +429,16 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
for ( i, s ) in enumerate( HG.client_controller.new_options.GetStringList( 'advanced_file_deletion_reasons' ) ):
|
||||
|
||||
permitted_reason_choices.append( ( s, s ) )
|
||||
if self._existing_shared_file_deletion_reason is not None and s == self._existing_shared_file_deletion_reason and not existing_reason_was_in_list:
|
||||
|
||||
existing_reason_was_in_list = True
|
||||
|
||||
permitted_reason_choices.append( ( 'keep existing reason: {}'.format( s ), s ) )
|
||||
|
||||
else:
|
||||
|
||||
permitted_reason_choices.append( ( s, s ) )
|
||||
|
||||
|
||||
if last_advanced_file_deletion_reason is not None and s == last_advanced_file_deletion_reason:
|
||||
|
||||
|
@ -421,7 +446,19 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
|
||||
|
||||
permitted_reason_choices.append( ( 'custom', None ) )
|
||||
if self._existing_shared_file_deletion_reason is not None and not existing_reason_was_in_list:
|
||||
|
||||
permitted_reason_choices.append( ( 'keep existing reason: {}'.format( self._existing_shared_file_deletion_reason ), self._existing_shared_file_deletion_reason ) )
|
||||
|
||||
|
||||
custom_index = len( permitted_reason_choices )
|
||||
|
||||
permitted_reason_choices.append( ( 'custom', self.SPECIAL_CHOICE_CUSTOM ) )
|
||||
|
||||
if self._all_files_have_existing_file_deletion_reasons and self._existing_shared_file_deletion_reason is None:
|
||||
|
||||
permitted_reason_choices.append( ( '(all files have existing file deletion reasons and they differ): do not alter them.', self.SPECIAL_CHOICE_NO_REASON ) )
|
||||
|
||||
|
||||
self._reason_radio = ClientGUICommon.BetterRadioBox( self._reason_panel, choices = permitted_reason_choices, vertical = True )
|
||||
|
||||
|
@ -429,7 +466,7 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
if selection_index is None:
|
||||
|
||||
selection_index = len( permitted_reason_choices ) - 1 # custom
|
||||
selection_index = custom_index
|
||||
|
||||
self._custom_reason.setText( last_advanced_file_deletion_reason )
|
||||
|
||||
|
@ -510,16 +547,51 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
return media
|
||||
|
||||
|
||||
def _GetExistingSharedFileDeletionReason( self ):
|
||||
|
||||
all_files_have_existing_file_deletion_reasons = True
|
||||
reasons = set()
|
||||
|
||||
for m in self._media:
|
||||
|
||||
lm = m.GetLocationsManager()
|
||||
|
||||
if not lm.HasLocalFileDeletionReason():
|
||||
|
||||
all_files_have_existing_file_deletion_reasons = False
|
||||
|
||||
return ( all_files_have_existing_file_deletion_reasons, None )
|
||||
|
||||
|
||||
reason = lm.GetLocalFileDeletionReason()
|
||||
|
||||
reasons.add( reason )
|
||||
|
||||
|
||||
shared_reason = None
|
||||
|
||||
if all_files_have_existing_file_deletion_reasons and len( reasons ) == 1:
|
||||
|
||||
shared_reason = list( reasons )[0]
|
||||
|
||||
|
||||
return ( all_files_have_existing_file_deletion_reasons, shared_reason )
|
||||
|
||||
|
||||
def _GetReason( self ):
|
||||
|
||||
if self._reason_panel.isEnabled():
|
||||
|
||||
reason = self._reason_radio.GetValue()
|
||||
|
||||
if reason is None:
|
||||
if reason == self.SPECIAL_CHOICE_CUSTOM:
|
||||
|
||||
reason = self._custom_reason.text()
|
||||
|
||||
elif reason == self.SPECIAL_CHOICE_NO_REASON:
|
||||
|
||||
reason = None
|
||||
|
||||
|
||||
else:
|
||||
|
||||
|
@ -655,7 +727,7 @@ class EditDeleteFilesPanel( ClientGUIScrolledPanels.EditPanel ):
|
|||
|
||||
reason = self._reason_radio.GetValue()
|
||||
|
||||
if reason is None:
|
||||
if reason == self.SPECIAL_CHOICE_CUSTOM:
|
||||
|
||||
self._custom_reason.setEnabled( True )
|
||||
|
||||
|
|
|
@ -223,7 +223,7 @@ SHORTCUTS_GLOBAL_ACTIONS = [ CAC.SIMPLE_GLOBAL_AUDIO_MUTE, CAC.SIMPLE_GLOBAL_AUD
|
|||
SHORTCUTS_MEDIA_ACTIONS = [ CAC.SIMPLE_MANAGE_FILE_TAGS, CAC.SIMPLE_MANAGE_FILE_RATINGS, CAC.SIMPLE_MANAGE_FILE_URLS, CAC.SIMPLE_MANAGE_FILE_NOTES, CAC.SIMPLE_ARCHIVE_FILE, CAC.SIMPLE_INBOX_FILE, CAC.SIMPLE_DELETE_FILE, CAC.SIMPLE_UNDELETE_FILE, CAC.SIMPLE_EXPORT_FILES, CAC.SIMPLE_EXPORT_FILES_QUICK_AUTO_EXPORT, CAC.SIMPLE_REMOVE_FILE_FROM_VIEW, CAC.SIMPLE_OPEN_FILE_IN_EXTERNAL_PROGRAM, CAC.SIMPLE_OPEN_SELECTION_IN_NEW_PAGE, CAC.SIMPLE_LAUNCH_THE_ARCHIVE_DELETE_FILTER, CAC.SIMPLE_COPY_BMP, CAC.SIMPLE_COPY_BMP_OR_FILE_IF_NOT_BMPABLE, CAC.SIMPLE_COPY_FILE, CAC.SIMPLE_COPY_PATH, CAC.SIMPLE_COPY_SHA256_HASH, CAC.SIMPLE_COPY_MD5_HASH, CAC.SIMPLE_COPY_SHA1_HASH, CAC.SIMPLE_COPY_SHA512_HASH, CAC.SIMPLE_GET_SIMILAR_TO_EXACT, CAC.SIMPLE_GET_SIMILAR_TO_VERY_SIMILAR, CAC.SIMPLE_GET_SIMILAR_TO_SIMILAR, CAC.SIMPLE_GET_SIMILAR_TO_SPECULATIVE, CAC.SIMPLE_DUPLICATE_MEDIA_SET_ALTERNATE, CAC.SIMPLE_DUPLICATE_MEDIA_SET_ALTERNATE_COLLECTIONS, CAC.SIMPLE_DUPLICATE_MEDIA_SET_CUSTOM, CAC.SIMPLE_DUPLICATE_MEDIA_SET_FOCUSED_BETTER, CAC.SIMPLE_DUPLICATE_MEDIA_SET_FOCUSED_KING, CAC.SIMPLE_DUPLICATE_MEDIA_SET_SAME_QUALITY, CAC.SIMPLE_DUPLICATE_MEDIA_SET_POTENTIAL, CAC.SIMPLE_OPEN_KNOWN_URL ]
|
||||
SHORTCUTS_MEDIA_VIEWER_ACTIONS = [ CAC.SIMPLE_PAUSE_MEDIA, CAC.SIMPLE_PAUSE_PLAY_MEDIA, CAC.SIMPLE_MEDIA_SEEK_DELTA, CAC.SIMPLE_MOVE_ANIMATION_TO_PREVIOUS_FRAME, CAC.SIMPLE_MOVE_ANIMATION_TO_NEXT_FRAME, CAC.SIMPLE_SWITCH_BETWEEN_FULLSCREEN_BORDERLESS_AND_REGULAR_FRAMED_WINDOW, CAC.SIMPLE_PAN_UP, CAC.SIMPLE_PAN_DOWN, CAC.SIMPLE_PAN_LEFT, CAC.SIMPLE_PAN_RIGHT, CAC.SIMPLE_PAN_TOP_EDGE, CAC.SIMPLE_PAN_BOTTOM_EDGE, CAC.SIMPLE_PAN_LEFT_EDGE, CAC.SIMPLE_PAN_RIGHT_EDGE, CAC.SIMPLE_PAN_VERTICAL_CENTER, CAC.SIMPLE_PAN_HORIZONTAL_CENTER, CAC.SIMPLE_ZOOM_IN, CAC.SIMPLE_ZOOM_OUT, CAC.SIMPLE_SWITCH_BETWEEN_100_PERCENT_AND_CANVAS_ZOOM, CAC.SIMPLE_FLIP_DARKMODE, CAC.SIMPLE_CLOSE_MEDIA_VIEWER ]
|
||||
SHORTCUTS_MEDIA_VIEWER_BROWSER_ACTIONS = [ CAC.SIMPLE_VIEW_NEXT, CAC.SIMPLE_VIEW_FIRST, CAC.SIMPLE_VIEW_LAST, CAC.SIMPLE_VIEW_PREVIOUS, CAC.SIMPLE_PAUSE_PLAY_SLIDESHOW, CAC.SIMPLE_SHOW_MENU, CAC.SIMPLE_CLOSE_MEDIA_VIEWER ]
|
||||
SHORTCUTS_MAIN_GUI_ACTIONS = [ CAC.SIMPLE_REFRESH, CAC.SIMPLE_REFRESH_ALL_PAGES, CAC.SIMPLE_REFRESH_PAGE_OF_PAGES_PAGES, CAC.SIMPLE_NEW_PAGE, CAC.SIMPLE_NEW_PAGE_OF_PAGES, CAC.SIMPLE_NEW_DUPLICATE_FILTER_PAGE, CAC.SIMPLE_NEW_GALLERY_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_URL_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_SIMPLE_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_WATCHER_DOWNLOADER_PAGE, CAC.SIMPLE_SET_MEDIA_FOCUS, CAC.SIMPLE_SHOW_HIDE_SPLITTERS, CAC.SIMPLE_SET_SEARCH_FOCUS, CAC.SIMPLE_UNCLOSE_PAGE, CAC.SIMPLE_CLOSE_PAGE, CAC.SIMPLE_REDO, CAC.SIMPLE_UNDO, CAC.SIMPLE_FLIP_DARKMODE, CAC.SIMPLE_RUN_ALL_EXPORT_FOLDERS, CAC.SIMPLE_CHECK_ALL_IMPORT_FOLDERS, CAC.SIMPLE_FLIP_DEBUG_FORCE_IDLE_MODE_DO_NOT_SET_THIS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FAVOURITE_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RELATED_TAGS, CAC.SIMPLE_REFRESH_RELATED_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FILE_LOOKUP_SCRIPT_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RECENT_TAGS, CAC.SIMPLE_FOCUS_MEDIA_VIEWER, CAC.SIMPLE_MOVE_PAGES_SELECTION_LEFT, CAC.SIMPLE_MOVE_PAGES_SELECTION_RIGHT, CAC.SIMPLE_MOVE_PAGES_SELECTION_HOME, CAC.SIMPLE_MOVE_PAGES_SELECTION_END ]
|
||||
SHORTCUTS_MAIN_GUI_ACTIONS = [ CAC.SIMPLE_REFRESH, CAC.SIMPLE_REFRESH_ALL_PAGES, CAC.SIMPLE_REFRESH_PAGE_OF_PAGES_PAGES, CAC.SIMPLE_NEW_PAGE, CAC.SIMPLE_NEW_PAGE_OF_PAGES, CAC.SIMPLE_NEW_DUPLICATE_FILTER_PAGE, CAC.SIMPLE_NEW_GALLERY_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_URL_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_SIMPLE_DOWNLOADER_PAGE, CAC.SIMPLE_NEW_WATCHER_DOWNLOADER_PAGE, CAC.SIMPLE_SET_MEDIA_FOCUS, CAC.SIMPLE_SHOW_HIDE_SPLITTERS, CAC.SIMPLE_SET_SEARCH_FOCUS, CAC.SIMPLE_UNCLOSE_PAGE, CAC.SIMPLE_CLOSE_PAGE, CAC.SIMPLE_REDO, CAC.SIMPLE_UNDO, CAC.SIMPLE_FLIP_DARKMODE, CAC.SIMPLE_RUN_ALL_EXPORT_FOLDERS, CAC.SIMPLE_CHECK_ALL_IMPORT_FOLDERS, CAC.SIMPLE_FLIP_DEBUG_FORCE_IDLE_MODE_DO_NOT_SET_THIS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FAVOURITE_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RELATED_TAGS, CAC.SIMPLE_REFRESH_RELATED_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_FILE_LOOKUP_SCRIPT_TAGS, CAC.SIMPLE_SHOW_AND_FOCUS_MANAGE_TAGS_RECENT_TAGS, CAC.SIMPLE_FOCUS_MEDIA_VIEWER, CAC.SIMPLE_MOVE_PAGES_SELECTION_LEFT, CAC.SIMPLE_MOVE_PAGES_SELECTION_RIGHT, CAC.SIMPLE_MOVE_PAGES_SELECTION_HOME, CAC.SIMPLE_MOVE_PAGES_SELECTION_END, CAC.SIMPLE_OPEN_COMMAND_PALETTE ]
|
||||
SHORTCUTS_TAGS_AUTOCOMPLETE_ACTIONS = [ CAC.SIMPLE_SYNCHRONISED_WAIT_SWITCH, CAC.SIMPLE_AUTOCOMPLETE_FORCE_FETCH, CAC.SIMPLE_AUTOCOMPLETE_IME_MODE ]
|
||||
SHORTCUTS_DUPLICATE_FILTER_ACTIONS = [ CAC.SIMPLE_DUPLICATE_FILTER_THIS_IS_BETTER_AND_DELETE_OTHER, CAC.SIMPLE_DUPLICATE_FILTER_THIS_IS_BETTER_BUT_KEEP_BOTH, CAC.SIMPLE_DUPLICATE_FILTER_EXACTLY_THE_SAME, CAC.SIMPLE_DUPLICATE_FILTER_ALTERNATES, CAC.SIMPLE_DUPLICATE_FILTER_FALSE_POSITIVE, CAC.SIMPLE_DUPLICATE_FILTER_CUSTOM_ACTION, CAC.SIMPLE_DUPLICATE_FILTER_SKIP, CAC.SIMPLE_DUPLICATE_FILTER_BACK, CAC.SIMPLE_CLOSE_MEDIA_VIEWER, CAC.SIMPLE_VIEW_NEXT ]
|
||||
SHORTCUTS_ARCHIVE_DELETE_FILTER_ACTIONS = [ CAC.SIMPLE_ARCHIVE_DELETE_FILTER_KEEP, CAC.SIMPLE_ARCHIVE_DELETE_FILTER_DELETE, CAC.SIMPLE_ARCHIVE_DELETE_FILTER_SKIP, CAC.SIMPLE_ARCHIVE_DELETE_FILTER_BACK, CAC.SIMPLE_CLOSE_MEDIA_VIEWER ]
|
||||
|
|
|
@ -0,0 +1,766 @@
|
|||
# QLocator
|
||||
# Copyright (C) 2020-2022 qcomixdev
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from qtpy import QtCore as QC
|
||||
from qtpy import QtWidgets as QW
|
||||
from qtpy import QtGui as QG
|
||||
import random
|
||||
import math
|
||||
import re
|
||||
|
||||
def elideRichText(richText: str, maxWidth: int, widget, elideFromLeft: bool):
|
||||
|
||||
doc = QG.QTextDocument()
|
||||
opt = QG.QTextOption()
|
||||
opt.setWrapMode(QG.QTextOption.NoWrap)
|
||||
doc.setDefaultTextOption(opt)
|
||||
doc.setDocumentMargin(0)
|
||||
doc.setHtml(richText)
|
||||
doc.adjustSize()
|
||||
|
||||
if doc.size().width() > maxWidth:
|
||||
cursor = QG.QTextCursor (doc)
|
||||
if elideFromLeft:
|
||||
cursor.movePosition(QG.QTextCursor.Start)
|
||||
else:
|
||||
cursor.movePosition(QG.QTextCursor.End)
|
||||
elidedPostfix = "…"
|
||||
metric = QG.QFontMetrics(widget.font())
|
||||
postfixWidth = metric.horizontalAdvance(elidedPostfix)
|
||||
|
||||
while doc.size().width() > maxWidth - postfixWidth:
|
||||
if elideFromLeft:
|
||||
cursor.deleteChar()
|
||||
else:
|
||||
cursor.deletePreviousChar()
|
||||
doc.adjustSize()
|
||||
|
||||
cursor.insertText(elidedPostfix)
|
||||
|
||||
return doc.toHtml()
|
||||
|
||||
return richText
|
||||
|
||||
class FocusEventFilter(QC.QObject):
|
||||
focused = QC.Signal()
|
||||
def __init__(self, parent = None):
|
||||
super().__init__(parent)
|
||||
|
||||
def eventFilter(self, object, event) -> bool:
|
||||
if event.type() == QC.QEvent.FocusIn:
|
||||
self.focused.emit()
|
||||
return False
|
||||
|
||||
class QLocatorSearchResult:
|
||||
def __init__(self, id: int, defaultIconPath: str, selectedIconPath: str, closeOnActivated: False, text: list):
|
||||
self.id = id
|
||||
self.defaultIconPath = defaultIconPath
|
||||
self.selectedIconPath = selectedIconPath
|
||||
self.closeOnActivated = closeOnActivated
|
||||
self.text = text
|
||||
|
||||
class QLocatorTitleWidget(QW.QWidget):
|
||||
def __init__(self, title: str, iconPath: str, height: int, shouldRemainHidden: bool, parent = None):
|
||||
super().__init__(parent)
|
||||
self.icon = QG.QIcon(iconPath)
|
||||
self.iconHeight = height - 2
|
||||
self.setLayout(QW.QHBoxLayout())
|
||||
self.iconLabel = QW.QLabel()
|
||||
self.iconLabel.setFixedHeight(self.iconHeight)
|
||||
self.iconLabel.setPixmap(self.icon.pixmap(self.iconHeight, self.iconHeight))
|
||||
self.titleLabel = QW.QLabel()
|
||||
self.titleLabel.setText(title)
|
||||
self.titleLabel.setTextFormat(QC.Qt.RichText)
|
||||
self.countLabel = QW.QLabel()
|
||||
self.layout().setContentsMargins(4, 1, 4, 1)
|
||||
self.layout().addWidget(self.iconLabel)
|
||||
self.layout().addWidget(self.titleLabel)
|
||||
self.layout().addStretch(1)
|
||||
self.layout().addWidget(self.countLabel)
|
||||
self.layout().setAlignment(self.countLabel, QC.Qt.AlignVCenter)
|
||||
self.layout().setAlignment(self.iconLabel, QC.Qt.AlignVCenter)
|
||||
self.layout().setAlignment(self.titleLabel, QC.Qt.AlignVCenter)
|
||||
titleFont = self.titleLabel.font()
|
||||
titleFont.setBold(True)
|
||||
self.titleLabel.setFont(titleFont)
|
||||
self.setFixedHeight(height)
|
||||
self.shouldRemainHidden = shouldRemainHidden
|
||||
|
||||
def updateData(self, count: int):
|
||||
self.countLabel.setText(str(count))
|
||||
|
||||
def paintEvent(self, event):
|
||||
opt = QW.QStyleOption()
|
||||
opt.initFrom(self)
|
||||
p = QG.QPainter(self)
|
||||
self.style().drawPrimitive(QW.QStyle.PE_Widget, opt, p, self)
|
||||
|
||||
class QLocatorResultWidget(QW.QWidget):
|
||||
up = QC.Signal()
|
||||
down = QC.Signal()
|
||||
activated = QC.Signal(int, int, bool)
|
||||
def __init__(self, keyEventTarget: QW.QWidget, height: int, primaryTextWidth: int, secondaryTextWidth: int, parent = None):
|
||||
super().__init__(parent)
|
||||
self.iconHeight = height - 2
|
||||
self.setObjectName("unselectedLocatorResult")
|
||||
self.keyEventTarget = keyEventTarget
|
||||
self.setLayout(QW.QHBoxLayout())
|
||||
self.iconLabel = QW.QLabel( self )
|
||||
self.iconLabel.setFixedHeight(self.iconHeight)
|
||||
self.mainTextLabel = QW.QLabel( self )
|
||||
self.primaryTextWidth = primaryTextWidth
|
||||
self.mainTextLabel.setMinimumWidth(primaryTextWidth)
|
||||
self.mainTextLabel.setTextFormat(QC.Qt.RichText)
|
||||
self.secondaryTextLabel = QW.QLabel( self )
|
||||
self.secondaryTextWidth = secondaryTextWidth
|
||||
self.secondaryTextLabel.setMaximumWidth(secondaryTextWidth)
|
||||
self.secondaryTextLabel.setTextFormat(QC.Qt.RichText)
|
||||
self.layout().setContentsMargins(4, 1, 4, 1)
|
||||
self.layout().addWidget(self.iconLabel)
|
||||
self.layout().addWidget(self.mainTextLabel)
|
||||
self.layout().addStretch(1)
|
||||
self.layout().addWidget(self.secondaryTextLabel)
|
||||
self.layout().setAlignment(self.mainTextLabel, QC.Qt.AlignVCenter)
|
||||
self.layout().setAlignment(self.iconLabel, QC.Qt.AlignVCenter)
|
||||
self.layout().setAlignment(self.secondaryTextLabel, QC.Qt.AlignVCenter)
|
||||
self.setFixedHeight(height)
|
||||
self.setSizePolicy(QW.QSizePolicy.Expanding, QW.QSizePolicy.Fixed)
|
||||
self.activateEnterShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Enter), self)
|
||||
self.activateEnterShortcut.setContext(QC.Qt.WidgetShortcut)
|
||||
self.activateReturnShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Return), self)
|
||||
self.activateReturnShortcut.setContext(QC.Qt.WidgetShortcut)
|
||||
self.upShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Up), self)
|
||||
self.upShortcut.setContext(QC.Qt.WidgetShortcut)
|
||||
self.downShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Down), self)
|
||||
self.downShortcut.setContext(QC.Qt.WidgetShortcut)
|
||||
|
||||
self.selectedPalette = self.palette()
|
||||
self.selectedPalette.setColor(QG.QPalette.Window, QG.QPalette().color(QG.QPalette.WindowText))
|
||||
self.selectedPalette.setColor(QG.QPalette.WindowText, QG.QPalette().color(QG.QPalette.Window))
|
||||
|
||||
self.id = -1
|
||||
self.providerIndex = -1
|
||||
self.closeOnActivated = False
|
||||
self.selected = False
|
||||
self.defaultStylingEnabled = True
|
||||
self.currentIcon = QG.QIcon()
|
||||
|
||||
def handleActivated():
|
||||
self.activated.emit(self.providerIndex, self.id, self.closeOnActivated)
|
||||
|
||||
self.activateEnterShortcut.activated.connect(handleActivated)
|
||||
self.activateReturnShortcut.activated.connect(handleActivated)
|
||||
|
||||
self.upShortcut.activated.connect(self.up)
|
||||
self.downShortcut.activated.connect(self.down)
|
||||
|
||||
def paintEvent(self, event):
|
||||
opt = QW.QStyleOption()
|
||||
opt.initFrom(self)
|
||||
p = QG.QPainter(self)
|
||||
self.style().drawPrimitive(QW.QStyle.PE_Widget, opt, p, self)
|
||||
|
||||
def updateData(self, providerIndex: int, data: QLocatorSearchResult):
|
||||
self.currentIcon = QG.QIcon()
|
||||
self.currentIcon.addFile(data.defaultIconPath, QC.QSize(), QG.QIcon.Normal)
|
||||
self.currentIcon.addFile(data.selectedIconPath, QC.QSize(), QG.QIcon.Selected)
|
||||
self.iconLabel.setPixmap(self.currentIcon.pixmap(self.iconHeight, self.iconHeight, QG.QIcon.Selected if self.selected else QG.QIcon.Normal))
|
||||
self.mainTextLabel.clear()
|
||||
self.secondaryTextLabel.clear()
|
||||
if len(data.text) > 0:
|
||||
self.mainTextLabel.setText(elideRichText(data.text[0], self.primaryTextWidth, self.mainTextLabel, False))
|
||||
if len(data.text) > 1:
|
||||
self.secondaryTextLabel.setText(elideRichText(data.text[1], self.secondaryTextWidth, self.secondaryTextLabel, True))
|
||||
self.id = data.id
|
||||
self.closeOnActivated = data.closeOnActivated
|
||||
self.providerIndex = providerIndex
|
||||
|
||||
def setDefaultStylingEnabled(self, enabled: bool):
|
||||
if self.defaultStylingEnabled and not enabled: self.setPalette(QG.QPalette())
|
||||
self.defaultStylingEnabled = enabled
|
||||
|
||||
def setSelected(self, selected: bool):
|
||||
self.selected = selected
|
||||
if selected:
|
||||
self.setObjectName("selectedLocatorResult")
|
||||
if self.defaultStylingEnabled: self.setPalette(self.selectedPalette)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.setFocus()
|
||||
else:
|
||||
self.setObjectName("unselectedLocatorResult")
|
||||
if self.defaultStylingEnabled: self.setPalette(QG.QPalette())
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.iconLabel.setPixmap(self.currentIcon.pixmap(self.iconHeight, self.iconHeight, QG.QIcon.Selected if self.selected else QG.QIcon.Normal))
|
||||
|
||||
def keyPressEvent(self, ev: QG.QKeyEvent):
|
||||
if ev.key() != QC.Qt.Key_Up and ev.key() != QC.Qt.Key_Down and ev.key() != QC.Qt.Key_Enter and ev.key() != QC.Qt.Key_Return:
|
||||
QW.QApplication.postEvent(self.keyEventTarget, QG.QKeyEvent(ev.type(), ev.key(), ev.modifiers(), ev.text(), ev.isAutoRepeat()))
|
||||
self.keyEventTarget.setFocus()
|
||||
else:
|
||||
super().keyPressEvent(self, ev)
|
||||
|
||||
class QAbstractLocatorSearchProvider(QC.QObject):
|
||||
resultsAvailable = QC.Signal(int, list)
|
||||
def __init__(self, parent = None):
|
||||
super().__init__(parent)
|
||||
|
||||
class QExampleSearchProvider(QAbstractLocatorSearchProvider):
|
||||
def __init__(self, parent = None):
|
||||
super().__init__(parent)
|
||||
|
||||
def title(self):
|
||||
return "Example search provider"
|
||||
|
||||
def suggestedReservedItemCount(self):
|
||||
return 32
|
||||
|
||||
def resultSelected(self, resultID: int):
|
||||
pass
|
||||
|
||||
def processQuery(self, query: str, context, jobID: int):
|
||||
resCount = random.randint(0, 50)
|
||||
results = []
|
||||
for i in range(resCount):
|
||||
randomStr = str()
|
||||
for j in range(5):
|
||||
randomStr += str(chr(97+random.randint(0, 26)))
|
||||
txt = []
|
||||
txt.append("Result <b>text</b> #" + str(i) + randomStr)
|
||||
txt.append("Secondary result text")
|
||||
results.append(QLocatorSearchResult(0, "icon.svg", "icon.svg", True, txt))
|
||||
self.resultsAvailable.emit(jobID, results)
|
||||
|
||||
def stopJobs(self, jobs):
|
||||
pass
|
||||
|
||||
def hideTitle(self):
|
||||
return False
|
||||
|
||||
def titleIconPath(self):
|
||||
return "icon.svg"
|
||||
|
||||
class QCalculatorSearchProvider(QAbstractLocatorSearchProvider):
|
||||
def __init__(self, parent = None):
|
||||
super().__init__(parent)
|
||||
self.safeEnv = {
|
||||
'ceil': math.ceil,
|
||||
'abs': abs,
|
||||
'floor': math.floor,
|
||||
'gcd': math.gcd,
|
||||
'exp': math.exp,
|
||||
'log': math.log,
|
||||
'log2': math.log2,
|
||||
'log10': math.log10,
|
||||
'pow': math.pow,
|
||||
'sqrt': math.sqrt,
|
||||
'acos': math.acos,
|
||||
'asin': math.asin,
|
||||
'atan': math.atan,
|
||||
'atan2': math.atan2,
|
||||
'cos': math.cos,
|
||||
'hypot': math.hypot,
|
||||
'sin': math.sin,
|
||||
'tan': math.tan,
|
||||
'degrees': math.degrees,
|
||||
'radians': math.radians,
|
||||
'acosh': math.acosh,
|
||||
'asinh': math.asinh,
|
||||
'atanh': math.atanh,
|
||||
'cosh': math.cosh,
|
||||
'sinh': math.sinh,
|
||||
'tanh': math.tanh,
|
||||
'erf': math.erf,
|
||||
'erfc': math.erfc,
|
||||
'gamma': math.gamma,
|
||||
'lgamma': math.lgamma,
|
||||
'pi': math.pi,
|
||||
'e': math.e,
|
||||
'inf': math.inf,
|
||||
'randint': random.randint,
|
||||
'random': random.random,
|
||||
'factorial': math.factorial
|
||||
}
|
||||
self.safePattern = re.compile("^("+"|".join(self.safeEnv.keys())+r"|[0-9.*+\-%/()]|\s" + ")+$")
|
||||
|
||||
def processQuery(self, query: str, context, jobID: int):
|
||||
try:
|
||||
if len(query.strip()) and self.safePattern.match(query):
|
||||
result = str(eval(query, {"__builtins__": {}}, self.safeEnv))
|
||||
try:
|
||||
int(result)
|
||||
except:
|
||||
result = str(float(result))
|
||||
self.resultsAvailable.emit(jobID, [QLocatorSearchResult(0, self.iconPath(), self.selectedIconPath(), False, [result,"Calculator"])])
|
||||
except:
|
||||
pass
|
||||
|
||||
def title(self):
|
||||
return str()
|
||||
|
||||
def suggestedReservedItemCount(self):
|
||||
return 1
|
||||
|
||||
def resultSelected(self, resultID: int):
|
||||
pass
|
||||
|
||||
def stopJobs(self, jobs):
|
||||
pass
|
||||
|
||||
def hideTitle(self):
|
||||
return True
|
||||
|
||||
def titleIconPath(self):
|
||||
return str()
|
||||
|
||||
def selectedIconPath(self):
|
||||
return str()
|
||||
|
||||
def iconPath(self):
|
||||
return str()
|
||||
|
||||
class QLocatorWidget(QW.QWidget):
|
||||
finished = QC.Signal()
|
||||
def __init__(self, parent = None, width: int = 600, resultHeight: int = 36, titleHeight: int = 36, primaryTextWidth: int = 320, secondaryTextWidth: int = 200, maxVisibleItemCount: int = 8):
|
||||
super().__init__(parent)
|
||||
self.alignment = QC.Qt.AlignCenter
|
||||
self.resultHeight = resultHeight
|
||||
self.titleHeight = titleHeight
|
||||
self.primaryTextWidth = primaryTextWidth
|
||||
self.locator = None
|
||||
self.secondaryTextWidth = secondaryTextWidth
|
||||
self.maxVisibleItemCount = maxVisibleItemCount
|
||||
self.reservedItemCounts = []
|
||||
self.visibleResultItemCounts = []
|
||||
self.currentJobIds = []
|
||||
self.titleItems = []
|
||||
self.resultItems = []
|
||||
self.escapeShortcuts = []
|
||||
self.selectedLayoutItemIndex = 0
|
||||
self.defaultStylingEnabled = True
|
||||
self.context = None
|
||||
self.lastQuery = str()
|
||||
self.setVisible(False)
|
||||
self.setLayout(QW.QVBoxLayout())
|
||||
self.searchEdit = QW.QLineEdit()
|
||||
self.resultList = QW.QScrollArea()
|
||||
self.resultLayout = QW.QVBoxLayout()
|
||||
self.resultList.setWidget(QW.QWidget())
|
||||
self.resultList.widget().setLayout(self.resultLayout)
|
||||
self.resultList.setWidgetResizable(True)
|
||||
self.resultLayout.setSizeConstraint(QW.QLayout.SetMinAndMaxSize)
|
||||
self.layout().addWidget(self.searchEdit)
|
||||
self.layout().addWidget(self.resultList)
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self.layout().setSpacing(0)
|
||||
self.resultLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.resultLayout.setSpacing(0)
|
||||
self.setFixedWidth(width)
|
||||
self.setWindowFlags(QC.Qt.FramelessWindowHint | QC.Qt.WindowStaysOnTopHint | QC.Qt.CustomizeWindowHint | QC.Qt.Popup)
|
||||
self.resultList.setSizeAdjustPolicy(QW.QAbstractScrollArea.AdjustToContents)
|
||||
self.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Maximum)
|
||||
self.setEscapeShortcuts([QC.Qt.Key_Escape])
|
||||
self.editorDownShortcut = QW.QShortcut(QG.QKeySequence(QC.Qt.Key_Down), self.searchEdit)
|
||||
self.editorDownShortcut.setContext(QC.Qt.WidgetShortcut)
|
||||
self.editorDownShortcut.activated.connect(self.handleEditorDown)
|
||||
|
||||
def handleTextEdited():
|
||||
for i in range(len(self.resultItems)):
|
||||
for it in self.resultItems[i]: self.setResultVisible(it, False)
|
||||
self.setResultVisible(self.titleItems[i], False)
|
||||
self.selectedLayoutItemIndex = 0
|
||||
self.updateResultListHeight()
|
||||
self.searchEdit.textEdited.connect(handleTextEdited)
|
||||
|
||||
def handleSearchFocused():
|
||||
if self.selectedLayoutItemIndex < self.resultLayout.count():
|
||||
widget = self.resultLayout.itemAt(self.selectedLayoutItemIndex).widget()
|
||||
if widget:
|
||||
if isinstance(widget, QLocatorResultWidget):
|
||||
widget.setSelected(False)
|
||||
self.selectedLayoutItemIndex = 0
|
||||
filter = FocusEventFilter()
|
||||
self.searchEdit.installEventFilter(filter)
|
||||
filter.focused.connect(handleSearchFocused)
|
||||
|
||||
self.queryTimer = QC.QTimer(self)
|
||||
self.queryTimer.setInterval(0)
|
||||
self.queryTimer.setSingleShot(True)
|
||||
|
||||
def handleQueryTimeout():
|
||||
if not self.locator: return
|
||||
self.currentJobIds = self.locator.query(self.lastQuery, self.context)
|
||||
self.queryTimer.timeout.connect(handleQueryTimeout)
|
||||
self.searchEdit.textEdited.connect(self.queryLocator)
|
||||
|
||||
self.updateResultListHeight()
|
||||
|
||||
def setAlignment( self, alignment ):
|
||||
if alignment == QC.Qt.AlignCenter:
|
||||
self.alignment = alignment
|
||||
self.updateAlignment()
|
||||
elif alignment == QC.Qt.AlignTop:
|
||||
self.alignment = alignment
|
||||
self.updateAlignment()
|
||||
|
||||
def updateAlignment( self ):
|
||||
widget = self
|
||||
while True:
|
||||
parent = widget.parentWidget()
|
||||
if not parent:
|
||||
break
|
||||
else:
|
||||
widget = parent
|
||||
|
||||
screenRect = QW.QApplication.primaryScreen().availableGeometry()
|
||||
if widget != self: # there is a parent
|
||||
screenRect = widget.geometry()
|
||||
|
||||
if self.alignment == QC.Qt.AlignCenter:
|
||||
centerRect = QW.QStyle.alignedRect(QC.Qt.LeftToRight, QC.Qt.AlignCenter, self.size(), screenRect)
|
||||
centerRect.setY(max(0, centerRect.y() - self.resultHeight * 4))
|
||||
self.setGeometry(centerRect)
|
||||
elif self.alignment == QC.Qt.AlignTop:
|
||||
rect = QW.QStyle.alignedRect(QC.Qt.LeftToRight, QC.Qt.AlignHCenter | QC.Qt.AlignTop, self.size(), screenRect)
|
||||
self.setGeometry(rect)
|
||||
|
||||
def paintEvent(self, event):
|
||||
opt = QW.QStyleOption()
|
||||
opt.initFrom(self)
|
||||
p = QG.QPainter(self)
|
||||
self.style().drawPrimitive(QW.QStyle.PE_Widget, opt, p, self)
|
||||
|
||||
def providerAdded(self, title: str, titleIconPath: str, suggestedReservedItemCount: int, hideTitle: bool):
|
||||
newTitleWidget = QLocatorTitleWidget(title, titleIconPath, self.titleHeight, hideTitle)
|
||||
self.visibleResultItemCounts.append(0)
|
||||
self.reservedItemCounts.append(suggestedReservedItemCount)
|
||||
self.titleItems.append(newTitleWidget)
|
||||
self.resultLayout.addWidget(newTitleWidget)
|
||||
newTitleWidget.setVisible(False)
|
||||
|
||||
self.resultItems.append([])
|
||||
for i in range(suggestedReservedItemCount):
|
||||
newWidget = QLocatorResultWidget(self.searchEdit, self.resultHeight, self.primaryTextWidth, self.secondaryTextWidth, self)
|
||||
self.setupResultWidget(newWidget)
|
||||
self.resultItems[-1].append(newWidget)
|
||||
self.resultLayout.addWidget(newWidget)
|
||||
newWidget.setVisible(False)
|
||||
|
||||
def setEscapeShortcuts(self, shortcuts):
|
||||
for escapeShortcut in self.escapeShortcuts:
|
||||
escapeShortcut.deleteLater()
|
||||
self.escapeShortcuts = []
|
||||
for shortcut in shortcuts:
|
||||
newShortcut = QW.QShortcut(QG.QKeySequence(shortcut), self)
|
||||
self.escapeShortcuts.append(newShortcut)
|
||||
newShortcut.activated.connect(self.finish)
|
||||
|
||||
def setLocator(self, locator):
|
||||
if self.locator:
|
||||
self.locator.providerAdded.disconnect(self.providerAdded)
|
||||
self.locator.resultsAvailable.disconnect(self.handleResultsAvailable)
|
||||
|
||||
self.reset()
|
||||
self.locator = locator
|
||||
|
||||
if self.locator:
|
||||
for provider in self.locator.providers:
|
||||
self.providerAdded(provider.title(), self.locator.iconBasePath + provider.titleIconPath(), provider.suggestedReservedItemCount(), provider.hideTitle())
|
||||
self.locator.providerAdded.connect(self.providerAdded)
|
||||
self.locator.resultsAvailable.connect(self.handleResultsAvailable)
|
||||
|
||||
def setDefaultStylingEnabled(self, enabled: bool) -> None:
|
||||
self.defaultStylingEnabled = enabled
|
||||
for i in range(len(self.resultItems)):
|
||||
for it in self.resultItems[i]: it.setDefaultStylingEnabled(enabled)
|
||||
|
||||
def __del__(self):
|
||||
self.queryTimer.stop()
|
||||
if self.locator:
|
||||
if self.currentJobIds: self.locator.stopJobs(self.currentJobIds)
|
||||
self.locator.providerAdded.disconnect(self.providerAdded)
|
||||
self.locator.resultsAvailable.disconnect(self.handleResultsAvailable)
|
||||
for it in self.titleItems:
|
||||
it.deleteLater()
|
||||
for i in range(len(self.resultItems)):
|
||||
for it in self.resultItems[i]: it.deleteLater()
|
||||
|
||||
def setContext(self, context):
|
||||
self.context = context
|
||||
|
||||
def setQueryTimeout(self, msec: int):
|
||||
self.queryTimer.setInterval(msec)
|
||||
|
||||
def start(self):
|
||||
self.clear()
|
||||
self.updateAlignment()
|
||||
self.show()
|
||||
self.searchEdit.setFocus()
|
||||
self.searchEdit.textEdited.emit("") # pylint: disable=E1101
|
||||
|
||||
def finish(self, doNotStopJobs: bool = False):
|
||||
self.queryTimer.stop()
|
||||
if self.locator and self.currentJobIds and not doNotStopJobs: self.locator.stopJobs(self.currentJobIds)
|
||||
self.clear()
|
||||
self.hide()
|
||||
self.finished.emit()
|
||||
|
||||
def reset(self):
|
||||
if self.locator and self.currentJobIds: self.locator.stopJobs(self.currentJobIds)
|
||||
self.currentJobIds.clear()
|
||||
self.reservedItemCounts.clear()
|
||||
self.visibleResultItemCounts.clear()
|
||||
for it in self.titleItems:
|
||||
it.deleteLater()
|
||||
self.titleItems = []
|
||||
for i in range(len(self.resultItems)):
|
||||
for it in self.resultItems[i]: it.deleteLater()
|
||||
self.resultItems = []
|
||||
if self.locator:
|
||||
for provider in self.locator.providers:
|
||||
self.providerAdded(provider.title(), self.locator.iconBasePath + provider.titleIconPath(), provider.suggestedReservedItemCount(), provider.hideTitle())
|
||||
|
||||
def handleResultsAvailable(self, providerIndex: int, jobID: int) -> None:
|
||||
data = self.locator.getResult(jobID)
|
||||
self.titleItems[providerIndex].updateData(len(data))
|
||||
if not self.titleItems[providerIndex].shouldRemainHidden:
|
||||
self.setResultVisible(self.titleItems[providerIndex], len(data) != 0)
|
||||
if len(data): self.resultList.setVisible(True)
|
||||
|
||||
if len(data) > len(self.resultItems[providerIndex]):
|
||||
titleItemIdx = 0
|
||||
for i in range(self.resultLayout.count()):
|
||||
if self.resultLayout.itemAt(i).widget() == self.titleItems[providerIndex]: break
|
||||
titleItemIdx += 1
|
||||
i = len(self.resultItems[providerIndex])
|
||||
while i < len(data):
|
||||
newWidget = QLocatorResultWidget(self.searchEdit, self.resultHeight, self.primaryTextWidth, self.secondaryTextWidth, self)
|
||||
self.setupResultWidget(newWidget)
|
||||
self.resultLayout.insertWidget(titleItemIdx + i + 1, newWidget)
|
||||
self.resultItems[providerIndex].append(newWidget)
|
||||
i += 1
|
||||
|
||||
for i in range(len(self.resultItems[providerIndex])):
|
||||
if i < len(data):
|
||||
self.resultItems[providerIndex][i].updateData(providerIndex, data[i])
|
||||
if not self.resultItems[providerIndex][i].isVisible():
|
||||
self.setResultVisible(self.resultItems[providerIndex][i], True)
|
||||
else:
|
||||
if self.resultItems[providerIndex][i].isVisible():
|
||||
self.setResultVisible(self.resultItems[providerIndex][i], False)
|
||||
|
||||
self.updateResultListHeight()
|
||||
|
||||
def queryLocator(self, query: str) -> None:
|
||||
if not self.locator: return
|
||||
if self.currentJobIds:
|
||||
self.locator.stopJobs(self.currentJobIds)
|
||||
self.lastQuery = query
|
||||
self.queryTimer.start()
|
||||
|
||||
def handleResultUp(self):
|
||||
resultWidget = self.sender()
|
||||
resultWidget.setSelected(False)
|
||||
i = self.selectedLayoutItemIndex - 1
|
||||
while i > 0:
|
||||
widget = self.resultLayout.itemAt(i).widget()
|
||||
if widget and widget.isVisible():
|
||||
if isinstance(widget, QLocatorResultWidget):
|
||||
self.selectedLayoutItemIndex = i
|
||||
widget.setSelected(True)
|
||||
self.resultList.ensureVisible(0, widget.pos().y(), 0, 0)
|
||||
return
|
||||
i = i - 1
|
||||
self.searchEdit.setFocus()
|
||||
self.resultList.ensureVisible(0, 0, 0, 0)
|
||||
|
||||
def handleResultDown(self):
|
||||
resultWidget = self.sender()
|
||||
i = self.selectedLayoutItemIndex + 1
|
||||
while i < self.resultLayout.count():
|
||||
widget = self.resultLayout.itemAt(i).widget()
|
||||
if widget and widget.isVisible():
|
||||
if isinstance(widget, QLocatorResultWidget):
|
||||
self.selectedLayoutItemIndex = i
|
||||
resultWidget.setSelected(False)
|
||||
widget.setSelected(True)
|
||||
self.resultList.ensureVisible(0, widget.pos().y() + widget.height(), 0, 0)
|
||||
break
|
||||
i = i + 1
|
||||
|
||||
def handleEditorDown(self):
|
||||
for i in range(self.resultLayout.count()):
|
||||
widget = self.resultLayout.itemAt(i).widget()
|
||||
if widget and widget.isVisible():
|
||||
if isinstance(widget, QLocatorResultWidget):
|
||||
self.selectedLayoutItemIndex = i
|
||||
widget.setSelected(True)
|
||||
break
|
||||
|
||||
def handleResultActivated(self, provider: int, id: int, closeOnSelected: bool):
|
||||
currJobIdsTmp = self.currentJobIds[:]
|
||||
if closeOnSelected: self.finish(True)
|
||||
if self.locator:
|
||||
self.locator.activateResult(provider, id)
|
||||
if closeOnSelected and currJobIdsTmp: self.locator.stopJobs(currJobIdsTmp)
|
||||
|
||||
def clear(self):
|
||||
self.searchEdit.clear()
|
||||
self.queryTimer.stop()
|
||||
self.lastQuery = str()
|
||||
self.context = None
|
||||
for i in range(len(self.titleItems)):
|
||||
self.titleItems[i].setVisible(False)
|
||||
for it in self.resultItems[i]:
|
||||
it.setVisible(False)
|
||||
it.setSelected(False)
|
||||
self.visibleResultItemCounts[i] = 0
|
||||
self.deleteAdditionalItems()
|
||||
self.updateResultListHeight()
|
||||
|
||||
def deleteAdditionalItems(self):
|
||||
titleIndex = 0
|
||||
for i in range(len(self.titleItems)):
|
||||
resultItemCount = len(self.resultItems[i])
|
||||
j = titleIndex
|
||||
while j < self.resultLayout.count():
|
||||
if self.resultLayout.itemAt(j).widget() == self.titleItems[i]: break
|
||||
titleIndex += 1
|
||||
j += 1
|
||||
k = self.reservedItemCounts[i]
|
||||
while k < resultItemCount:
|
||||
self.resultLayout.takeAt(titleIndex + k).widget().deleteLater()
|
||||
k += 1
|
||||
self.resultItems[i] = self.resultItems[i][:self.reservedItemCounts[i]]
|
||||
|
||||
def getVisibleHeight(self) -> int:
|
||||
itemCount = 0
|
||||
height = 0
|
||||
for i in range(len(self.titleItems)):
|
||||
if itemCount >= self.maxVisibleItemCount: break
|
||||
if self.visibleResultItemCounts[i] > 0:
|
||||
if not self.titleItems[i].shouldRemainHidden:
|
||||
itemCount += 1
|
||||
height += self.titleHeight
|
||||
if itemCount >= self.maxVisibleItemCount: break
|
||||
visibleItems = min(self.visibleResultItemCounts[i], self.maxVisibleItemCount - itemCount)
|
||||
itemCount += visibleItems
|
||||
height += self.resultHeight * visibleItems
|
||||
return height
|
||||
|
||||
def setResultVisible(self, widget: QW.QWidget, visible: bool):
|
||||
if widget.isVisible() and not visible:
|
||||
widget.setVisible(False)
|
||||
if isinstance(widget, QLocatorResultWidget):
|
||||
self.visibleResultItemCounts[widget.providerIndex] -= 1
|
||||
widget.setSelected(False)
|
||||
elif not widget.isVisible() and visible:
|
||||
if isinstance(widget, QLocatorResultWidget):
|
||||
self.visibleResultItemCounts[widget.providerIndex] += 1
|
||||
widget.setVisible(True)
|
||||
|
||||
def updateResultListHeight(self):
|
||||
pos = self.pos()
|
||||
visibleHeight = self.getVisibleHeight()
|
||||
if visibleHeight == 0:
|
||||
self.resultList.setVisible(False)
|
||||
else:
|
||||
self.resultList.setVisible(True)
|
||||
self.resultList.widget().setMaximumHeight(visibleHeight)
|
||||
if self.resultList.isVisible():
|
||||
self.setFixedHeight(self.searchEdit.sizeHint().height() + (self.resultList.height() - self.resultList.contentsRect().height()) + visibleHeight)
|
||||
else:
|
||||
self.setFixedHeight(self.searchEdit.sizeHint().height())
|
||||
self.move(pos)
|
||||
|
||||
def setupResultWidget(self, widget):
|
||||
widget.up.connect(self.handleResultUp)
|
||||
widget.down.connect(self.handleResultDown)
|
||||
widget.activated.connect(self.handleResultActivated)
|
||||
widget.setDefaultStylingEnabled(self.defaultStylingEnabled)
|
||||
|
||||
class QLocator(QC.QObject):
|
||||
iconBasePathChanged = QC.Signal(str)
|
||||
providerAdded = QC.Signal(str, str, int, bool)
|
||||
resultsAvailable = QC.Signal(int, int)
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.providers = []
|
||||
self.currentJobs = {}
|
||||
self.savedProviderData = {}
|
||||
self.jobIDCounter = 0
|
||||
self.iconBasePath = str()
|
||||
|
||||
def addProvider(self, provider) -> None:
|
||||
self.providers.append(provider)
|
||||
provider.setParent(self)
|
||||
provider.resultsAvailable.connect(self.handleItemUpdate)
|
||||
self.providerAdded.emit(provider.title(), self.iconBasePath + provider.titleIconPath(), provider.suggestedReservedItemCount(), provider.hideTitle())
|
||||
|
||||
def provider(self, idx: int):
|
||||
if idx >= 0 and idx <= len(self.providers):
|
||||
return self.providers[idx]
|
||||
return None
|
||||
|
||||
def getResult(self, jobID: int):
|
||||
return self.savedProviderData.pop(jobID, None)
|
||||
|
||||
def __del__(self):
|
||||
self.stopJobs()
|
||||
|
||||
def setIconBasePath(self, iconBasePath: str) -> None:
|
||||
if self.iconBasePath != iconBasePath:
|
||||
self.iconBasePath = iconBasePath
|
||||
self.iconBasePathChanged.emit(self.iconBasePath)
|
||||
|
||||
def query(self, queryText: str, context) -> list:
|
||||
jobIDs = []
|
||||
for i in range(len(self.providers)):
|
||||
self.currentJobs[self.jobIDCounter] = i
|
||||
jobIDs.append(self.jobIDCounter)
|
||||
self.providers[i].processQuery(queryText, context, self.jobIDCounter)
|
||||
self.jobIDCounter += 1
|
||||
return jobIDs
|
||||
|
||||
def activateResult(self, provider: int, id: int) -> None:
|
||||
if provider >= 0 and provider < len(self.providers):
|
||||
self.providers[provider].resultSelected(id)
|
||||
|
||||
def handleItemUpdate(self, jobID: int, data) -> None:
|
||||
if jobID in self.currentJobs:
|
||||
providerIndex = self.currentJobs[jobID]
|
||||
del self.currentJobs[jobID]
|
||||
self.savedProviderData[jobID] = data
|
||||
for dataItem in self.savedProviderData[jobID]:
|
||||
dataItem.defaultIconPath = self.iconBasePath + dataItem.defaultIconPath
|
||||
dataItem.selectedIconPath = self.iconBasePath + dataItem.selectedIconPath
|
||||
self.resultsAvailable.emit(providerIndex, jobID)
|
||||
|
||||
def stopJobs(self, ids = []) -> None:
|
||||
if not len(ids):
|
||||
self.currentJobs = {}
|
||||
for provider in self.providers:
|
||||
provider.stopJobs([])
|
||||
self.savedProviderData = {}
|
||||
else:
|
||||
for provider in self.providers:
|
||||
provider.stopJobs(ids)
|
||||
for id in ids:
|
||||
if id in self.currentJobs:
|
||||
del self.currentJobs[id]
|
||||
if id in self.savedProviderData:
|
||||
del self.savedProviderData[id]
|
|
@ -1221,51 +1221,7 @@ class Canvas( QW.QWidget ):
|
|||
return
|
||||
|
||||
|
||||
locations_manager = self._current_media.GetLocationsManager()
|
||||
|
||||
local_file_services = HG.client_controller.services_manager.GetServices( ( HC.LOCAL_FILE_DOMAIN, ) )
|
||||
|
||||
deleted_local_services = [ local_file_service for local_file_service in local_file_services if local_file_service.GetServiceKey() in locations_manager.GetDeleted() ]
|
||||
|
||||
if len( deleted_local_services ) > 0:
|
||||
|
||||
choice_tuples = []
|
||||
|
||||
for service in deleted_local_services:
|
||||
|
||||
choice_tuples.append( ( service.GetName(), service, service.GetName() ) )
|
||||
|
||||
|
||||
try:
|
||||
|
||||
undelete_service = ClientGUIDialogsQuick.SelectFromListButtons( self, 'Undelete for?', choice_tuples, message = 'Which service to undelete back to?' )
|
||||
|
||||
except HydrusExceptions.CancelledException:
|
||||
|
||||
return
|
||||
|
||||
|
||||
do_it = False
|
||||
|
||||
if len( choice_tuples ) > 1 or not HC.options[ 'confirm_trash' ]:
|
||||
|
||||
do_it = True
|
||||
|
||||
else:
|
||||
|
||||
result = ClientGUIDialogsQuick.GetYesNo( self, 'Undelete this file back to {}?'.format( undelete_service.GetName() ) )
|
||||
|
||||
if result == QW.QDialog.Accepted:
|
||||
|
||||
do_it = True
|
||||
|
||||
|
||||
|
||||
if do_it:
|
||||
|
||||
HG.client_controller.Write( 'content_updates', { undelete_service.GetServiceKey() : [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_UNDELETE, ( self._current_media.GetHash(), ) ) ] } )
|
||||
|
||||
|
||||
ClientGUIMediaActions.UndeleteMedia( self, ( self._current_media, ) )
|
||||
|
||||
|
||||
def _UpdateBackgroundColour( self ):
|
||||
|
@ -3743,7 +3699,7 @@ class CanvasMediaList( ClientMedia.ListeningMediaList, CanvasWithHovers ):
|
|||
|
||||
|
||||
|
||||
def CommitArchiveDelete( page_key, kept_hashes, deleted_hashes ):
|
||||
def CommitArchiveDelete( page_key: bytes, location_context: ClientLocation.LocationContext, kept_hashes: typing.Collection[ bytes ], deleted_hashes: typing.Collection[ bytes ] ):
|
||||
|
||||
if HC.options[ 'remove_filtered_files' ]:
|
||||
|
||||
|
@ -3765,7 +3721,19 @@ def CommitArchiveDelete( page_key, kept_hashes, deleted_hashes ):
|
|||
kept_hashes = list( kept_hashes )
|
||||
|
||||
|
||||
# we do a second set of removes to deal with late processing and a quick F5ing user
|
||||
location_context = location_context.Duplicate()
|
||||
|
||||
location_context.FixMissingServices( ClientLocation.ValidLocalDomainsFilter )
|
||||
|
||||
if location_context.IncludesCurrent():
|
||||
|
||||
deletee_file_service_keys = location_context.current_service_keys
|
||||
|
||||
else:
|
||||
|
||||
# if we are in a weird search domain, then just say 'delete from all local'
|
||||
deletee_file_service_keys = HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) )
|
||||
|
||||
|
||||
for block_of_deleted_hashes in HydrusData.SplitListIntoChunks( deleted_hashes, 64 ):
|
||||
|
||||
|
@ -3773,10 +3741,15 @@ def CommitArchiveDelete( page_key, kept_hashes, deleted_hashes ):
|
|||
|
||||
reason = 'Deleted in Archive/Delete filter.'
|
||||
|
||||
service_keys_to_content_updates[ CC.LOCAL_FILE_SERVICE_KEY ] = [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_DELETE, block_of_deleted_hashes, reason = reason ) ]
|
||||
for deletee_file_service_key in deletee_file_service_keys:
|
||||
|
||||
service_keys_to_content_updates[ deletee_file_service_key ] = [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_DELETE, block_of_deleted_hashes, reason = reason ) ]
|
||||
|
||||
|
||||
HG.client_controller.WriteSynchronous( 'content_updates', service_keys_to_content_updates )
|
||||
|
||||
# we do a second set of removes to deal with late processing and a quick F5ing user
|
||||
|
||||
if HC.options[ 'remove_filtered_files' ]:
|
||||
|
||||
HG.client_controller.pub( 'remove_media', page_key, block_of_deleted_hashes )
|
||||
|
@ -3880,7 +3853,7 @@ class CanvasMediaListFilterArchiveDelete( CanvasMediaList ):
|
|||
|
||||
self._current_media = self._GetFirst() # so the pubsub on close is better
|
||||
|
||||
HG.client_controller.CallToThread( CommitArchiveDelete, self._page_key, kept_hashes, deleted_hashes )
|
||||
HG.client_controller.CallToThread( CommitArchiveDelete, self._page_key, self._location_context, kept_hashes, deleted_hashes )
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -969,16 +969,23 @@ class CanvasHoverFrameTop( CanvasHoverFrame ):
|
|||
|
||||
locations_manager = self._current_media.GetLocationsManager()
|
||||
|
||||
if CC.LOCAL_FILE_SERVICE_KEY in locations_manager.GetCurrent():
|
||||
|
||||
self._trash_button.show()
|
||||
self._delete_button.hide()
|
||||
self._undelete_button.hide()
|
||||
|
||||
elif locations_manager.IsTrashed():
|
||||
if locations_manager.IsTrashed():
|
||||
|
||||
self._trash_button.hide()
|
||||
self._delete_button.show()
|
||||
|
||||
elif locations_manager.IsLocal():
|
||||
|
||||
self._trash_button.show()
|
||||
self._delete_button.hide()
|
||||
|
||||
|
||||
if set( locations_manager.GetDeleted() ).isdisjoint( HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) ) ):
|
||||
|
||||
self._undelete_button.hide()
|
||||
|
||||
else:
|
||||
|
||||
self._undelete_button.show()
|
||||
|
||||
|
||||
|
|
|
@ -1389,14 +1389,7 @@ class PagesNotebook( QP.TabWidgetWithDnD ):
|
|||
|
||||
else:
|
||||
|
||||
tab_index = ClientGUIFunctions.NotebookScreenToHitTest( self, screen_position )
|
||||
|
||||
if tab_index == -1:
|
||||
|
||||
return self
|
||||
|
||||
|
||||
on_child_notebook_somewhere = screen_position.y() > current_page.pos().y()
|
||||
on_child_notebook_somewhere = current_page.mapFromGlobal( screen_position ).y() > current_page.pos().y()
|
||||
|
||||
if on_child_notebook_somewhere:
|
||||
|
||||
|
|
|
@ -1733,42 +1733,9 @@ class MediaPanel( ClientMedia.ListeningMediaList, QW.QScrollArea ):
|
|||
|
||||
def _Undelete( self ):
|
||||
|
||||
hashes = self._GetSelectedHashes( has_location = CC.TRASH_SERVICE_KEY )
|
||||
media = self._GetSelectedFlatMedia()
|
||||
|
||||
num_to_undelete = len( hashes )
|
||||
|
||||
if num_to_undelete > 0:
|
||||
|
||||
do_it = False
|
||||
|
||||
if not HC.options[ 'confirm_trash' ]:
|
||||
|
||||
do_it = True
|
||||
|
||||
else:
|
||||
|
||||
if num_to_undelete == 1:
|
||||
|
||||
message = 'Are you sure you want to undelete this file?'
|
||||
|
||||
else:
|
||||
|
||||
message = 'Are you sure you want to undelete these ' + HydrusData.ToHumanInt( num_to_undelete ) + ' files?'
|
||||
|
||||
|
||||
result = ClientGUIDialogsQuick.GetYesNo( self, message )
|
||||
|
||||
if result == QW.QDialog.Accepted:
|
||||
|
||||
do_it = True
|
||||
|
||||
|
||||
|
||||
if do_it:
|
||||
|
||||
HG.client_controller.Write( 'content_updates', { CC.LOCAL_FILE_SERVICE_KEY : [ HydrusData.ContentUpdate( HC.CONTENT_TYPE_FILES, HC.CONTENT_UPDATE_UNDELETE, hashes ) ] } )
|
||||
|
||||
|
||||
ClientGUIMediaActions.UndeleteMedia( self, media )
|
||||
|
||||
|
||||
def _UpdateBackgroundColour( self ):
|
||||
|
@ -3261,7 +3228,7 @@ class MediaPanelThumbnails( MediaPanel ):
|
|||
self.ShowMenu()
|
||||
|
||||
|
||||
def ShowMenu( self ):
|
||||
def ShowMenu( self, do_not_show_just_return = False ):
|
||||
|
||||
new_options = HG.client_controller.new_options
|
||||
|
||||
|
@ -4204,9 +4171,14 @@ class MediaPanelThumbnails( MediaPanel ):
|
|||
|
||||
ClientGUIMenus.AppendMenu( menu, share_menu, 'share' )
|
||||
|
||||
if not do_not_show_just_return:
|
||||
|
||||
CGC.core().PopupMenu( self, menu )
|
||||
|
||||
CGC.core().PopupMenu( self, menu )
|
||||
|
||||
else:
|
||||
|
||||
return menu
|
||||
|
||||
|
||||
def MaintainPageCache( self ):
|
||||
|
||||
|
|
|
@ -2061,7 +2061,20 @@ class MediaCollection( MediaList, Media ):
|
|||
pending = HydrusData.MassUnion( [ locations_manager.GetPending() for locations_manager in all_locations_managers ] )
|
||||
petitioned = HydrusData.MassUnion( [ locations_manager.GetPetitioned() for locations_manager in all_locations_managers ] )
|
||||
|
||||
self._locations_manager = ClientMediaManagers.LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned )
|
||||
modified_times = { locations_manager.GetFileModifiedTimestamp() for locations_manager in all_locations_managers }
|
||||
|
||||
modified_times.discard( None )
|
||||
|
||||
if len( modified_times ) > 0:
|
||||
|
||||
modified_time = max( modified_times )
|
||||
|
||||
else:
|
||||
|
||||
modified_time = None
|
||||
|
||||
|
||||
self._locations_manager = ClientMediaManagers.LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned, file_modified_timestamp = modified_time )
|
||||
|
||||
|
||||
def _RecalcInternals( self ):
|
||||
|
@ -2485,11 +2498,13 @@ class MediaSingleton( Media ):
|
|||
|
||||
deleted_local_file_services = [ service for service in local_file_services if service.GetServiceKey() in deleted_service_keys ]
|
||||
|
||||
local_file_deletion_reason = locations_manager.GetLocalFileDeletionReason()
|
||||
|
||||
if CC.COMBINED_LOCAL_FILE_SERVICE_KEY in deleted_service_keys:
|
||||
|
||||
( timestamp, original_timestamp ) = locations_manager.GetDeletedTimestamps( CC.COMBINED_LOCAL_FILE_SERVICE_KEY )
|
||||
|
||||
lines.append( 'deleted from this client {}'.format( ClientData.TimestampToPrettyTimeDelta( timestamp ) ) )
|
||||
lines.append( 'deleted from this client {} ({})'.format( ClientData.TimestampToPrettyTimeDelta( timestamp ), local_file_deletion_reason ) )
|
||||
|
||||
elif len( deleted_local_file_services ) > 0:
|
||||
|
||||
|
@ -2497,7 +2512,19 @@ class MediaSingleton( Media ):
|
|||
|
||||
( timestamp, original_timestamp ) = locations_manager.GetDeletedTimestamps( local_file_service.GetServiceKey() )
|
||||
|
||||
lines.append( 'removed from {} {}'.format( local_file_service.GetName(), ClientData.TimestampToPrettyTimeDelta( timestamp ) ) )
|
||||
l = 'removed from {} {}'.format( local_file_service.GetName(), ClientData.TimestampToPrettyTimeDelta( timestamp ) )
|
||||
|
||||
if len( deleted_local_file_services ) == 1:
|
||||
|
||||
l = '{} ({})'.format( l, local_file_deletion_reason )
|
||||
|
||||
|
||||
lines.append( l )
|
||||
|
||||
|
||||
if len( deleted_local_file_services ) > 1:
|
||||
|
||||
lines.append( 'Deletion reason: {}'.format( local_file_deletion_reason ) )
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -269,7 +269,8 @@ class LocationsManager( object ):
|
|||
inbox: bool = False,
|
||||
urls: typing.Optional[ typing.Set[ str ] ] = None,
|
||||
service_keys_to_filenames: typing.Optional[ typing.Dict[ bytes, str ] ] = None,
|
||||
file_modified_timestamp: typing.Optional[ int ] = None
|
||||
file_modified_timestamp: typing.Optional[ int ] = None,
|
||||
local_file_deletion_reason: str = None
|
||||
):
|
||||
|
||||
self._current_to_timestamps = current_to_timestamps
|
||||
|
@ -297,6 +298,7 @@ class LocationsManager( object ):
|
|||
self._service_keys_to_filenames = service_keys_to_filenames
|
||||
|
||||
self._file_modified_timestamp = file_modified_timestamp
|
||||
self._local_file_deletion_reason = local_file_deletion_reason
|
||||
|
||||
|
||||
def DeletePending( self, service_key ):
|
||||
|
@ -314,7 +316,7 @@ class LocationsManager( object ):
|
|||
urls = set( self._urls )
|
||||
service_keys_to_filenames = dict( self._service_keys_to_filenames )
|
||||
|
||||
return LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned, self.inbox, urls, service_keys_to_filenames, self._file_modified_timestamp )
|
||||
return LocationsManager( current_to_timestamps, deleted_to_timestamps, pending, petitioned, self.inbox, urls, service_keys_to_filenames, self._file_modified_timestamp, self._local_file_deletion_reason )
|
||||
|
||||
|
||||
def GetCDPP( self ):
|
||||
|
@ -425,11 +427,28 @@ class LocationsManager( object ):
|
|||
|
||||
|
||||
|
||||
def GetLocalFileDeletionReason( self ) -> str:
|
||||
|
||||
if self._local_file_deletion_reason is None:
|
||||
|
||||
return 'Unknown deletion reason.'
|
||||
|
||||
else:
|
||||
|
||||
return self._local_file_deletion_reason
|
||||
|
||||
|
||||
|
||||
def GetURLs( self ):
|
||||
|
||||
return self._urls
|
||||
|
||||
|
||||
def HasLocalFileDeletionReason( self ) -> bool:
|
||||
|
||||
return self._local_file_deletion_reason is not None
|
||||
|
||||
|
||||
def IsDownloading( self ):
|
||||
|
||||
return CC.COMBINED_LOCAL_FILE_SERVICE_KEY in self._pending
|
||||
|
@ -466,7 +485,9 @@ class LocationsManager( object ):
|
|||
self._deleted.discard( service_key )
|
||||
|
||||
|
||||
if service_key == CC.LOCAL_FILE_SERVICE_KEY:
|
||||
local_service_keys = HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) )
|
||||
|
||||
if service_key in local_service_keys:
|
||||
|
||||
if CC.TRASH_SERVICE_KEY in self._current_to_timestamps:
|
||||
|
||||
|
@ -492,7 +513,7 @@ class LocationsManager( object ):
|
|||
self._pending.discard( service_key )
|
||||
|
||||
|
||||
def _DeleteFromService( self, service_key ):
|
||||
def _DeleteFromService( self, service_key: bytes, reason: typing.Optional[ str ] ):
|
||||
|
||||
if service_key in self._current_to_timestamps:
|
||||
|
||||
|
@ -517,7 +538,12 @@ class LocationsManager( object ):
|
|||
|
||||
local_service_keys = HG.client_controller.services_manager.GetServiceKeys( ( HC.LOCAL_FILE_DOMAIN, ) )
|
||||
|
||||
if service_key == CC.LOCAL_FILE_SERVICE_KEY:
|
||||
if service_key in local_service_keys:
|
||||
|
||||
if reason is not None:
|
||||
|
||||
self._local_file_deletion_reason = reason
|
||||
|
||||
|
||||
if self._current.isdisjoint( local_service_keys ):
|
||||
|
||||
|
@ -528,12 +554,12 @@ class LocationsManager( object ):
|
|||
|
||||
for local_service_key in list( self._current.intersection( local_service_keys ) ):
|
||||
|
||||
self._DeleteFromService( local_service_key )
|
||||
self._DeleteFromService( local_service_key, reason )
|
||||
|
||||
|
||||
if CC.TRASH_SERVICE_KEY in self._current:
|
||||
|
||||
self._DeleteFromService( CC.TRASH_SERVICE_KEY )
|
||||
self._DeleteFromService( CC.TRASH_SERVICE_KEY, reason )
|
||||
|
||||
|
||||
self.inbox = False
|
||||
|
@ -588,7 +614,16 @@ class LocationsManager( object ):
|
|||
|
||||
elif action == HC.CONTENT_UPDATE_DELETE:
|
||||
|
||||
self._DeleteFromService( service_key )
|
||||
if content_update.HasReason():
|
||||
|
||||
reason = content_update.GetReason()
|
||||
|
||||
else:
|
||||
|
||||
reason = None
|
||||
|
||||
|
||||
self._DeleteFromService( service_key, reason )
|
||||
|
||||
elif action == HC.CONTENT_UPDATE_UNDELETE:
|
||||
|
||||
|
|
|
@ -81,7 +81,7 @@ options = {}
|
|||
# Misc
|
||||
|
||||
NETWORK_VERSION = 20
|
||||
SOFTWARE_VERSION = 472
|
||||
SOFTWARE_VERSION = 473
|
||||
CLIENT_API_VERSION = 25
|
||||
|
||||
SERVER_THUMBNAIL_DIMENSIONS = ( 200, 200 )
|
||||
|
|
|
@ -2434,44 +2434,40 @@ class TestClientAPI( unittest.TestCase ):
|
|||
|
||||
for media_result in media_results:
|
||||
|
||||
metadata_row = {}
|
||||
|
||||
file_info_manager = media_result.GetFileInfoManager()
|
||||
|
||||
metadata_row[ 'file_id' ] = file_info_manager.hash_id
|
||||
metadata_row[ 'hash' ] = file_info_manager.hash.hex()
|
||||
metadata_row[ 'size' ] = file_info_manager.size
|
||||
metadata_row[ 'mime' ] = HC.mime_mimetype_string_lookup[ file_info_manager.mime ]
|
||||
metadata_row[ 'ext' ] = HC.mime_ext_lookup[ file_info_manager.mime ]
|
||||
metadata_row[ 'width' ] = file_info_manager.width
|
||||
metadata_row[ 'height' ] = file_info_manager.height
|
||||
metadata_row[ 'duration' ] = file_info_manager.duration
|
||||
metadata_row[ 'has_audio' ] = file_info_manager.has_audio
|
||||
metadata_row[ 'num_frames' ] = file_info_manager.num_frames
|
||||
metadata_row[ 'num_words' ] = file_info_manager.num_words
|
||||
|
||||
metadata_row[ 'file_services' ] = {
|
||||
'current' : {
|
||||
random_file_service_hex_current.hex() : {
|
||||
'time_imported' : current_import_timestamp
|
||||
metadata_row = {
|
||||
'file_id' : file_info_manager.hash_id,
|
||||
'hash' : file_info_manager.hash.hex(),
|
||||
'size' : file_info_manager.size,
|
||||
'mime' : HC.mime_mimetype_string_lookup[ file_info_manager.mime ],
|
||||
'ext' : HC.mime_ext_lookup[ file_info_manager.mime ],
|
||||
'width' : file_info_manager.width,
|
||||
'height' : file_info_manager.height,
|
||||
'duration' : file_info_manager.duration,
|
||||
'has_audio' : file_info_manager.has_audio,
|
||||
'num_frames' : file_info_manager.num_frames,
|
||||
'num_words' : file_info_manager.num_words,
|
||||
'file_services' : {
|
||||
'current' : {
|
||||
random_file_service_hex_current.hex() : {
|
||||
'time_imported' : current_import_timestamp
|
||||
}
|
||||
},
|
||||
'deleted' : {
|
||||
random_file_service_hex_deleted.hex() : {
|
||||
'time_deleted' : deleted_deleted_timestamp,
|
||||
'time_imported' : deleted_import_timestamp
|
||||
}
|
||||
}
|
||||
},
|
||||
'deleted' : {
|
||||
random_file_service_hex_deleted.hex() : {
|
||||
'time_deleted' : deleted_deleted_timestamp,
|
||||
'time_imported' : deleted_import_timestamp
|
||||
}
|
||||
}
|
||||
'time_modified' : file_modified_timestamp,
|
||||
'is_inbox' : False,
|
||||
'is_local' : False,
|
||||
'is_trashed' : False,
|
||||
'known_urls' : list( sorted_urls )
|
||||
}
|
||||
|
||||
metadata_row[ 'time_modified' ] = file_modified_timestamp
|
||||
|
||||
metadata_row[ 'is_inbox' ] = False
|
||||
metadata_row[ 'is_local' ] = False
|
||||
metadata_row[ 'is_trashed' ] = False
|
||||
|
||||
metadata_row[ 'known_urls' ] = list( sorted_urls )
|
||||
|
||||
tags_manager = media_result.GetTagsManager()
|
||||
|
||||
service_names_to_statuses_to_tags = {}
|
||||
|
|
Binary file not shown.
|
@ -64,6 +64,7 @@ jobs:
|
|||
run: |
|
||||
move ${{ steps.setup_ffmpeg.outputs.ffmpeg-path }} hydrus\bin\
|
||||
move hydrus\static\build_files\windows\sqlite3.dll hydrus\
|
||||
move hydrus\static\build_files\windows\sqlite3.exe hydrus\db
|
||||
move hydrus\static\build_files\windows\client-win.spec client-win.spec
|
||||
move hydrus\static\build_files\windows\server-win.spec server-win.spec
|
||||
pyinstaller server-win.spec
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 661 B |
Binary file not shown.
After Width: | Height: | Size: 634 B |
|
@ -120,6 +120,18 @@ QPushButton#HydrusOnOffButton[hydrus_on=false]
|
|||
color: #800000;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
This is the Command Palette (default Ctrl+P), and specifically the background colour of the item you currently have selected.
|
||||
|
||||
*/
|
||||
|
||||
QLocatorResultWidget#selectedLocatorResult
|
||||
{
|
||||
background-color: #006ffa
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
|
||||
Custom Controls
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 476 B |
Loading…
Reference in New Issue