diff --git a/help/changelog.html b/help/changelog.html
index eeb6be52..84ff34b5 100755
--- a/help/changelog.html
+++ b/help/changelog.html
@@ -8,6 +8,40 @@
changelog
+
+ version 275
+ - if you hold shift down while dropping a page tab, the client will not 'chase' that page to show it (try it out!)
+ - the gui will be more snappy about dealing with drag-and-drop drops (of types file import, page tab rearrange, and url drop), returning the mouse to normal state instantly on drop and dealing with the event in a subsequent action
+ - dropping url text on the client will specifically inform the source program not to delete the dragged text (that the operation was copy, not move), if it is interested in hearing that
+ - page drag-and-drops should transition a little less flickerily
+ - all file import status objects can now track 'source time', typically to represent upload time
+ - file imports now populate 'source time' based on the earliest of creation/modified time! (this will be used later to parse as a tag)
+ - thread watchers now populate 'source time' based on post time!
+ - finished a watcher options object for the new thread checker system
+ - wrote a panel to edit watcher options
+ - converted the thread object to the new watcher system
+ - thread watchers now have two pause buttons--one for the file queue, one for the checker
+ - compressed thread watcher ui layout
+ - converted the thread left-panel ui and options->downloading page to reflect the new watcher system
+ - improved the watcher options to generate better timings for fresh threads
+ - cleaned up some thread watcher check time code
+ - the total/selected mime summary on the status bar is a little prettier and will now report by individual mime sometimes
+ - generating the total/select mime summaries are now faster on pages with >1,000 files (it'll just use 'file')
+ - a 'refresh' action on an import page now triggers a sort event
+ - added 'flip_darkmode' shortcut command to 'main_gui' and 'media_viewer' shortcut sets
+ - added copy_file/path/hash/bmp actions to the 'media' shortcut set, removed the hardcoded ctrl+c for copy_file. I added ctrl+c to the defaults, but existing users will have to re-add it manually if they want it!
+ - simplified some page ui update code
+ - import pages will no longer update their left-panel ui (which uses a bit of cpu) when they are not in view
+ - polished some new string and url matching code the domain engine will be using
+ - wrote a panel to edit string match objects
+ - wrote a new panel to handle simple ordered lists of data in a better way
+ - wrote most of a panel to edit url match objects
+ - misc domain manager work
+ - fixed an issue with the old listctrl where object name de-duplication was sometimes not permitting (1)-type names to be cleaned up
+ - fixed some inelegant time duration->text conversions
+ - improved complete process shutdown reliability when some downloads are waiting on bandwidth on shutdown
+ - did some misc listctrl update work
+
version 274
- the help menu now has an easy on/off check entry for the darkmode colourset
diff --git a/include/ClientConstants.py b/include/ClientConstants.py
index 03be6ae1..4267858b 100755
--- a/include/ClientConstants.py
+++ b/include/ClientConstants.py
@@ -333,10 +333,10 @@ SHORTCUTS_RESERVED_NAMES = [ 'archive_delete_filter', 'duplicate_filter', 'media
# shortcut commands
-SHORTCUTS_MEDIA_ACTIONS = [ 'manage_file_tags', 'manage_file_ratings', 'archive_file', 'inbox_file', 'delete_file', 'remove_file_from_view', 'open_file_in_external_program', 'launch_the_archive_delete_filter' ]
-SHORTCUTS_MEDIA_VIEWER_ACTIONS = [ 'move_animation_to_previous_frame', 'move_animation_to_next_frame', 'switch_between_fullscreen_borderless_and_regular_framed_window', 'pan_up', 'pan_down', 'pan_left', 'pan_right', 'zoom_in', 'zoom_out', 'switch_between_100_percent_and_canvas_zoom' ]
+SHORTCUTS_MEDIA_ACTIONS = [ 'manage_file_tags', 'manage_file_ratings', 'archive_file', 'inbox_file', 'delete_file', 'remove_file_from_view', 'open_file_in_external_program', 'launch_the_archive_delete_filter', 'copy_bmp', 'copy_file', 'copy_path', 'copy_sha256_hash' ]
+SHORTCUTS_MEDIA_VIEWER_ACTIONS = [ 'move_animation_to_previous_frame', 'move_animation_to_next_frame', 'switch_between_fullscreen_borderless_and_regular_framed_window', 'pan_up', 'pan_down', 'pan_left', 'pan_right', 'zoom_in', 'zoom_out', 'switch_between_100_percent_and_canvas_zoom', 'flip_darkmode' ]
SHORTCUTS_MEDIA_VIEWER_BROWSER_ACTIONS = [ 'view_next', 'view_first', 'view_last', 'view_previous' ]
-SHORTCUTS_MAIN_GUI_ACTIONS = [ 'refresh', 'new_page', 'synchronised_wait_switch', 'set_media_focus', 'show_hide_splitters', 'set_search_focus', 'unclose_page', 'close_page', 'redo', 'undo' ]
+SHORTCUTS_MAIN_GUI_ACTIONS = [ 'refresh', 'new_page', 'synchronised_wait_switch', 'set_media_focus', 'show_hide_splitters', 'set_search_focus', 'unclose_page', 'close_page', 'redo', 'undo', 'flip_darkmode' ]
SHORTCUTS_DUPLICATE_FILTER_ACTIONS = [ 'duplicate_filter_this_is_better', 'duplicate_filter_exactly_the_same', 'duplicate_filter_alternates', 'duplicate_filter_not_dupes', 'duplicate_filter_custom_action', 'duplicate_filter_skip', 'duplicate_filter_back' ]
SHORTCUTS_ARCHIVE_DELETE_FILTER_ACTIONS = [ 'archive_delete_filter_keep', 'archive_delete_filter_delete', 'archive_delete_filter_skip', 'archive_delete_filter_back' ]
diff --git a/include/ClientData.py b/include/ClientData.py
index 308ed1e3..2693c44c 100644
--- a/include/ClientData.py
+++ b/include/ClientData.py
@@ -1038,6 +1038,12 @@ class ClientOptions( HydrusSerialisable.SerialisableBase ):
#
+ self._dictionary[ 'misc' ] = HydrusSerialisable.SerialisableDictionary()
+
+ self._dictionary[ 'misc' ][ 'default_thread_watcher_options' ] = WatcherOptions( intended_files_per_check = 8, never_faster_than = 300, never_slower_than = 86400, death_file_velocity = ( 1, 86400 ) )
+
+ #
+
self._dictionary[ 'suggested_tags' ] = HydrusSerialisable.SerialisableDictionary()
self._dictionary[ 'suggested_tags' ][ 'favourites' ] = {}
@@ -1332,6 +1338,14 @@ class ClientOptions( HydrusSerialisable.SerialisableBase ):
+ def GetDefaultThreadWatcherOptions( self ):
+
+ with self._lock:
+
+ return self._dictionary[ 'misc' ][ 'default_thread_watcher_options' ]
+
+
+
def GetDefaultSort( self ):
with self._lock:
@@ -1585,6 +1599,14 @@ class ClientOptions( HydrusSerialisable.SerialisableBase ):
+ def SetDefaultThreadWatcherOptions( self, watcher_options ):
+
+ with self._lock:
+
+ self._dictionary[ 'misc' ][ 'default_thread_watcher_options' ] = watcher_options
+
+
+
def SetDefaultSort( self, media_sort ):
with self._lock:
@@ -2690,3 +2712,139 @@ class TagCensor( HydrusSerialisable.SerialisableBase ):
HydrusSerialisable.SERIALISABLE_TYPES_TO_OBJECT_TYPES[ HydrusSerialisable.SERIALISABLE_TYPE_TAG_CENSOR ] = TagCensor
+
+class WatcherOptions( HydrusSerialisable.SerialisableBase ):
+
+ SERIALISABLE_TYPE = HydrusSerialisable.SERIALISABLE_TYPE_WATCHER_OPTIONS
+ SERIALISABLE_VERSION = 1
+
+ def __init__( self, intended_files_per_check = 8, never_faster_than = 300, never_slower_than = 86400, death_file_velocity = ( 1, 86400 ) ):
+
+ HydrusSerialisable.SerialisableBase.__init__( self )
+
+ self._intended_files_per_check = intended_files_per_check
+ self._never_faster_than = never_faster_than
+ self._never_slower_than = never_slower_than
+ self._death_file_velocity = death_file_velocity
+
+
+ def _GetCurrentFilesVelocity( self, seed_cache, last_check_time ):
+
+ ( death_files_found, death_time_delta ) = self._death_file_velocity
+
+ since = last_check_time - death_time_delta
+
+ current_files_found = seed_cache.GetNumNewFilesSince( since )
+
+ if len( seed_cache ) == 0:
+
+ current_time_delta = death_time_delta
+
+ else:
+
+ # when a thread is only 30mins old (i.e. first file was posted 30 mins ago), we don't want to calculate based on a longer delete time delta
+ # we want next check to be like 30mins from now, not 12 hours
+ # so we'll say "5 files in 30 mins" rather than "5 files in 24 hours"
+
+ earliest_file_time = seed_cache.GetEarliestTimestamp()
+
+ early_time_delta = max( last_check_time - earliest_file_time, 30 )
+
+ current_time_delta = min( early_time_delta, death_time_delta )
+
+
+ return ( current_files_found, current_time_delta )
+
+
+ def _GetSerialisableInfo( self ):
+
+ return ( self._intended_files_per_check, self._never_faster_than, self._never_slower_than, self._death_file_velocity )
+
+
+ def _InitialiseFromSerialisableInfo( self, serialisable_info ):
+
+ ( self._intended_files_per_check, self._never_faster_than, self._never_slower_than, self._death_file_velocity ) = serialisable_info
+
+
+ def GetNextCheckTime( self, seed_cache, last_check_time ):
+
+ if len( seed_cache ) == 0:
+
+ if last_check_time == 0:
+
+ return 0 # haven't checked yet, so should check immediately
+
+ else:
+
+ return HydrusData.GetNow() + self._never_slower_than
+
+
+ else:
+
+ ( current_files_found, current_time_delta ) = self._GetCurrentFilesVelocity( seed_cache, last_check_time )
+
+ if current_files_found == 0:
+
+ check_period = self._never_slower_than
+
+ else:
+
+ approx_time_per_file = current_time_delta / current_files_found
+
+ ideal_check_period = self._intended_files_per_check * approx_time_per_file
+
+ check_period = min( max( self._never_faster_than, ideal_check_period ), self._never_slower_than )
+
+
+ return last_check_time + check_period
+
+
+
+ def GetPrettyCurrentVelocity( self, seed_cache, last_check_time ):
+
+ if len( seed_cache ) == 0:
+
+ if last_check_time == 0:
+
+ pretty_current_velocity = 'no files yet'
+
+ else:
+
+ pretty_current_velocity = 'no files, unable to determine velocity'
+
+
+ else:
+
+ ( current_files_found, current_time_delta ) = self._GetCurrentFilesVelocity( seed_cache, last_check_time )
+
+ pretty_current_velocity = 'at last check, found ' + HydrusData.ConvertIntToPrettyString( current_files_found ) + ' files in previous ' + HydrusData.ConvertTimeDeltaToPrettyString( current_time_delta )
+
+
+ return pretty_current_velocity
+
+
+ def IsDead( self, seed_cache, last_check_time ):
+
+ if len( seed_cache ) == 0 and last_check_time == 0:
+
+ return False
+
+ else:
+
+ ( current_files_found, current_time_delta ) = self._GetCurrentFilesVelocity( seed_cache, last_check_time )
+
+ ( death_files_found, deleted_time_delta ) = self._death_file_velocity
+
+ current_file_velocity_float = current_files_found / float( current_time_delta )
+ death_file_velocity_float = death_files_found / float( deleted_time_delta )
+
+ return current_file_velocity_float < death_file_velocity_float
+
+
+
+ def ToTuple( self ):
+
+ return ( self._intended_files_per_check, self._never_faster_than, self._never_slower_than, self._death_file_velocity )
+
+
+HydrusSerialisable.SERIALISABLE_TYPES_TO_OBJECT_TYPES[ HydrusSerialisable.SERIALISABLE_TYPE_WATCHER_OPTIONS ] = WatcherOptions
diff --git a/include/ClientDefaults.py b/include/ClientDefaults.py
index 9b574922..173a15bc 100644
--- a/include/ClientDefaults.py
+++ b/include/ClientDefaults.py
@@ -628,6 +628,8 @@ def GetDefaultShortcuts():
media.SetCommand( ClientData.Shortcut( CC.SHORTCUT_TYPE_KEYBOARD, wx.WXK_F12, [] ), ClientData.ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'launch_the_archive_delete_filter' ) )
+ media.SetCommand( ClientData.Shortcut( CC.SHORTCUT_TYPE_KEYBOARD, ord( 'C' ), [ CC.SHORTCUT_MODIFIER_CTRL ] ), ClientData.ApplicationCommand( CC.APPLICATION_COMMAND_TYPE_SIMPLE, 'copy_file' ) )
+
shortcuts.append( media )
main_gui = ClientData.Shortcuts( 'main_gui' )
diff --git a/include/ClientDownloading.py b/include/ClientDownloading.py
index 45ecc045..5690f6b6 100644
--- a/include/ClientDownloading.py
+++ b/include/ClientDownloading.py
@@ -278,7 +278,7 @@ def Parse4chanPostScreen( html ):
except: return ( 'error', 'unknown error' )
-def ParseImageboardFileURLFromPost( thread_url, post ):
+def ParseImageboardFileURLFromPost( thread_url, post, source_timestamp ):
url_filename = str( post[ 'tim' ] )
url_ext = post[ 'ext' ]
@@ -295,7 +295,7 @@ def ParseImageboardFileURLFromPost( thread_url, post ):
file_md5_base64 = None
- return ( file_url, file_md5_base64, file_original_filename )
+ return ( file_url, file_md5_base64, file_original_filename, source_timestamp )
def ParseImageboardFileURLsFromJSON( thread_url, raw_json ):
@@ -312,7 +312,16 @@ def ParseImageboardFileURLsFromJSON( thread_url, raw_json ):
continue
- file_infos.append( ParseImageboardFileURLFromPost( thread_url, post ) )
+ if 'time' in post:
+
+ source_timestamp = post[ 'time' ]
+
+ else:
+
+ source_timestamp = HydrusData.GetNow()
+
+
+ file_infos.append( ParseImageboardFileURLFromPost( thread_url, post, source_timestamp ) )
if 'extra_files' in post:
@@ -323,7 +332,7 @@ def ParseImageboardFileURLsFromJSON( thread_url, raw_json ):
continue
- file_infos.append( ParseImageboardFileURLFromPost( thread_url, extra_file ) )
+ file_infos.append( ParseImageboardFileURLFromPost( thread_url, extra_file, source_timestamp ) )
diff --git a/include/ClientDragDrop.py b/include/ClientDragDrop.py
index 96fc7541..2cea1d3d 100644
--- a/include/ClientDragDrop.py
+++ b/include/ClientDragDrop.py
@@ -38,13 +38,17 @@ class FileDropTarget( wx.PyDropTarget ):
paths = self._file_data_object.GetFilenames()
- wx.CallAfter( self._filenames_callable, paths )
+ wx.CallAfter( self._filenames_callable, paths ) # callafter to terminate dnd event now
+
+ result = wx.DragNone
elif received_format_type in ( wx.DF_TEXT, wx.DF_UNICODETEXT ) and self._url_callable is not None:
text = self._text_data_object.GetText()
- self._url_callable( text )
+ wx.CallAfter( self._url_callable, text ) # callafter to terminate dnd event now
+
+ result = wx.DragCopy
else:
@@ -59,14 +63,16 @@ class FileDropTarget( wx.PyDropTarget ):
if format_id == 'application/hydrus-media':
- pass
+ result = wx.DragCancel
if format_id == 'application/hydrus-page-tab' and self._page_callable is not None:
page_key = self._hydrus_page_tab_data_object.GetData()
- self._page_callable( page_key )
+ wx.CallAfter( self._page_callable, page_key ) # callafter to terminate dnd event now
+
+ result = wx.DragMove
@@ -74,8 +80,4 @@ class FileDropTarget( wx.PyDropTarget ):
return result
- def OnDragOver( self, x, y, result ):
-
- return wx.DragCopy
-
-
+ # setting OnDragOver to return copy gives Linux trouble with page tab drops with shift held down
diff --git a/include/ClientGUI.py b/include/ClientGUI.py
index 2192bf27..61e7cc8a 100755
--- a/include/ClientGUI.py
+++ b/include/ClientGUI.py
@@ -834,22 +834,6 @@ class FrameGUI( ClientGUITopLevelWindows.FrameThatResizes ):
- def _FlipDarkmode( self ):
-
- current_colourset = self._new_options.GetString( 'current_colourset' )
-
- if current_colourset == 'darkmode':
-
- new_colourset = 'default'
-
- elif current_colourset == 'default':
-
- new_colourset = 'darkmode'
-
-
- self._new_options.SetString( 'current_colourset', new_colourset )
-
-
def _GenerateMenuInfo( self, name ):
menu = wx.Menu()
@@ -1419,7 +1403,7 @@ class FrameGUI( ClientGUITopLevelWindows.FrameThatResizes ):
currently_darkmode = self._new_options.GetString( 'current_colourset' ) == 'darkmode'
- ClientGUIMenus.AppendMenuCheckItem( self, menu, 'darkmode', 'Set the \'darkmode\' colourset on and off.', currently_darkmode, self._FlipDarkmode )
+ ClientGUIMenus.AppendMenuCheckItem( self, menu, 'darkmode', 'Set the \'darkmode\' colourset on and off.', currently_darkmode, self.FlipDarkmode )
check_manager = ClientGUICommon.CheckboxManagerOptions( 'advanced_mode' )
@@ -1954,6 +1938,10 @@ class FrameGUI( ClientGUITopLevelWindows.FrameThatResizes ):
self._UnclosePage()
+ elif action == 'flip_darkmode':
+
+ self.FlipDarkmode()
+
elif action == 'show_hide_splitters':
self._ShowHideSplitters()
@@ -2972,6 +2960,22 @@ The password is cleartext here but obscured in the entry dialog. Enter a blank p
return True
+ def FlipDarkmode( self ):
+
+ current_colourset = self._new_options.GetString( 'current_colourset' )
+
+ if current_colourset == 'darkmode':
+
+ new_colourset = 'default'
+
+ elif current_colourset == 'default':
+
+ new_colourset = 'darkmode'
+
+
+ self._new_options.SetString( 'current_colourset', new_colourset )
+
+
def FlushOutPredicates( self, predicates ):
good_predicates = []
diff --git a/include/ClientGUICanvas.py b/include/ClientGUICanvas.py
index 493d01cb..09354185 100755
--- a/include/ClientGUICanvas.py
+++ b/include/ClientGUICanvas.py
@@ -1055,6 +1055,10 @@ class CanvasFrame( ClientGUITopLevelWindows.FrameThatResizes ):
self.FullscreenSwitch()
+ elif action == 'flip_darkmode':
+
+ HG.client_controller.gui.FlipDarkmode()
+
else:
command_processed = False
@@ -1683,6 +1687,22 @@ class Canvas( wx.Window ):
self._Archive()
+ elif action == 'copy_bmp':
+
+ self._CopyBMPToClipboard()
+
+ elif action == 'copy_file':
+
+ self._CopyFileToClipboard()
+
+ elif action == 'copy_path':
+
+ self._CopyPathToClipboard()
+
+ elif action == 'copy_sha256_hash':
+
+ self._CopyHashToClipboard( 'sha256' )
+
elif action == 'delete_file':
self._Delete()
@@ -3591,7 +3611,6 @@ class CanvasFilterDuplicates( CanvasWithHovers ):
if modifier == wx.ACCEL_NORMAL and key in CC.DELETE_KEYS: self._Delete()
elif modifier == wx.ACCEL_SHIFT and key in CC.DELETE_KEYS: self._Undelete()
- elif modifier == wx.ACCEL_CTRL and key == ord( 'C' ): self._CopyFileToClipboard()
else:
CanvasWithHovers.EventCharHook( self, event )
@@ -4229,8 +4248,7 @@ class CanvasMediaListFilterArchiveDelete( CanvasMediaList ):
( modifier, key ) = ClientData.ConvertKeyEventToSimpleTuple( event )
- if modifier == wx.ACCEL_CTRL and key == ord( 'C' ): self._CopyFileToClipboard()
- elif modifier == wx.ACCEL_NORMAL and key in ( wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER, wx.WXK_ESCAPE ): self._Close()
+ if modifier == wx.ACCEL_NORMAL and key in ( wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER, wx.WXK_ESCAPE ): self._Close()
else:
CanvasMediaList.EventCharHook( self, event )
@@ -4579,7 +4597,6 @@ class CanvasMediaListBrowser( CanvasMediaListNavigable ):
elif modifier == wx.ACCEL_SHIFT and key in CC.DELETE_KEYS: self._Undelete()
elif modifier == wx.ACCEL_NORMAL and key in ( wx.WXK_SPACE, wx.WXK_NUMPAD_SPACE ): wx.CallAfter( self._PausePlaySlideshow )
elif modifier == wx.ACCEL_NORMAL and key in ( wx.WXK_RETURN, wx.WXK_NUMPAD_ENTER, wx.WXK_ESCAPE ): self._Close()
- elif modifier == wx.ACCEL_CTRL and key == ord( 'C' ): self._CopyFileToClipboard()
else:
CanvasMediaListNavigable.EventCharHook( self, event )
diff --git a/include/ClientGUICommon.py b/include/ClientGUICommon.py
index 019d9a32..5c210ac3 100755
--- a/include/ClientGUICommon.py
+++ b/include/ClientGUICommon.py
@@ -623,6 +623,61 @@ class CheckboxCollect( wx.combo.ComboCtrl ):
+class CheckboxManager( object ):
+
+ def GetCurrentValue( self ):
+
+ raise NotImplementedError()
+
+
+ def Invert( self ):
+
+ raise NotImplementedError()
+
+
+class CheckboxManagerCalls( CheckboxManager ):
+
+ def __init__( self, invert_call, value_call ):
+
+ CheckboxManager.__init__( self )
+
+ self._invert_call = invert_call
+ self._value_call = value_call
+
+
+ def GetCurrentValue( self ):
+
+ return self._value_call()
+
+
+ def Invert( self ):
+
+ self._invert_call()
+
+
+class CheckboxManagerOptions( CheckboxManager ):
+
+ def __init__( self, boolean_name ):
+
+ CheckboxManager.__init__( self )
+
+ self._boolean_name = boolean_name
+
+
+ def GetCurrentValue( self ):
+
+ new_options = HG.client_controller.GetNewOptions()
+
+ return new_options.GetBoolean( self._boolean_name )
+
+
+ def Invert( self ):
+
+ new_options = HG.client_controller.GetNewOptions()
+
+ new_options.InvertBoolean( self._boolean_name )
+
+
class ChoiceSort( wx.Panel ):
def __init__( self, parent, management_controller = None ):
@@ -1360,61 +1415,6 @@ class ListBook( wx.Panel ):
-class CheckboxManager( object ):
-
- def GetCurrentValue( self ):
-
- raise NotImplementedError()
-
-
- def Invert( self ):
-
- raise NotImplementedError()
-
-
-class CheckboxManagerCalls( CheckboxManager ):
-
- def __init__( self, invert_call, value_call ):
-
- CheckboxManager.__init__( self )
-
- self._invert_call = invert_call
- self._value_call = value_call
-
-
- def GetCurrentValue( self ):
-
- return self._value_call()
-
-
- def Invert( self ):
-
- self._invert_call()
-
-
-class CheckboxManagerOptions( CheckboxManager ):
-
- def __init__( self, boolean_name ):
-
- CheckboxManager.__init__( self )
-
- self._boolean_name = boolean_name
-
-
- def GetCurrentValue( self ):
-
- new_options = HG.client_controller.GetNewOptions()
-
- return new_options.GetBoolean( self._boolean_name )
-
-
- def Invert( self ):
-
- new_options = HG.client_controller.GetNewOptions()
-
- new_options.InvertBoolean( self._boolean_name )
-
-
class MenuBitmapButton( BetterBitmapButton ):
def __init__( self, parent, bitmap, menu_items ):
@@ -1554,8 +1554,13 @@ class NoneableSpinCtrl( wx.Panel ):
def Bind( self, event_type, callback ):
self._checkbox.Bind( wx.EVT_CHECKBOX, callback )
+
self._one.Bind( wx.EVT_SPINCTRL, callback )
- if self._num_dimensions == 2: self._two.Bind( wx.EVT_SPINCTRL, callback )
+
+ if self._num_dimensions == 2:
+
+ self._two.Bind( wx.EVT_SPINCTRL, callback )
+
def EventCheckBox( self, event ):
@@ -2539,6 +2544,50 @@ class StaticBoxSorterForListBoxTags( StaticBox ):
self._tags_box.SetTagsByMedia( media, force_reload = force_reload )
+class RadioBox( StaticBox ):
+
+ def __init__( self, parent, title, choice_pairs, initial_index = None ):
+
+ StaticBox.__init__( self, parent, title )
+
+ self._indices_to_radio_buttons = {}
+ self._radio_buttons_to_data = {}
+
+ first_button = True
+
+ for ( index, ( text, data ) ) in enumerate( choice_pairs ):
+
+ if first_button:
+
+ style = wx.RB_GROUP
+
+ first_button = False
+
+ else: style = 0
+
+ radio_button = wx.RadioButton( self, label = text, style = style )
+
+ self.AddF( radio_button, CC.FLAGS_EXPAND_PERPENDICULAR )
+
+ self._indices_to_radio_buttons[ index ] = radio_button
+ self._radio_buttons_to_data[ radio_button ] = data
+
+
+ if initial_index is not None and initial_index in self._indices_to_radio_buttons: self._indices_to_radio_buttons[ initial_index ].SetValue( True )
+
+
+ def GetSelectedClientData( self ):
+
+ for radio_button in self._radio_buttons_to_data.keys():
+
+ if radio_button.GetValue() == True: return self._radio_buttons_to_data[ radio_button ]
+
+
+
+ def SetSelection( self, index ): self._indices_to_radio_buttons[ index ].SetValue( True )
+
+ def SetString( self, index, text ): self._indices_to_radio_buttons[ index ].SetLabelText( text )
+
class TextAndGauge( wx.Panel ):
def __init__( self, parent ):
@@ -2727,7 +2776,7 @@ class TimeDeltaButton( wx.Button ):
class TimeDeltaCtrl( wx.Panel ):
- def __init__( self, parent, min = 1, days = False, hours = False, minutes = False, seconds = False, monthly_allowed = False ):
+ def __init__( self, parent, min = 1, days = False, hours = False, minutes = False, seconds = False, monthly_allowed = False, monthly_label = 'monthly' ):
wx.Panel.__init__( self, parent )
@@ -2782,7 +2831,7 @@ class TimeDeltaCtrl( wx.Panel ):
self._monthly.Bind( wx.EVT_CHECKBOX, self.EventChange )
hbox.AddF( self._monthly, CC.FLAGS_VCENTER )
- hbox.AddF( BetterStaticText( self, 'monthly' ), CC.FLAGS_VCENTER )
+ hbox.AddF( BetterStaticText( self, monthly_label ), CC.FLAGS_VCENTER )
self.SetSizer( hbox )
@@ -2937,47 +2986,60 @@ class TimeDeltaCtrl( wx.Panel ):
self._UpdateEnables()
-class RadioBox( StaticBox ):
+class VelocityCtrl( wx.Panel ):
- def __init__( self, parent, title, choice_pairs, initial_index = None ):
+ def __init__( self, parent, min_time_delta = 60, days = False, hours = False, minutes = False, seconds = False, per_phrase = 'per', unit = None ):
- StaticBox.__init__( self, parent, title )
+ wx.Panel.__init__( self, parent )
- self._indices_to_radio_buttons = {}
- self._radio_buttons_to_data = {}
+ self._num = wx.SpinCtrl( self, min = 0, max = 1000, size = ( 60, -1 ) )
- first_button = True
+ self._times = TimeDeltaCtrl( self, min = min_time_delta, days = days, hours = hours, minutes = minutes, seconds = seconds )
- for ( index, ( text, data ) ) in enumerate( choice_pairs ):
+ #
+
+ hbox = wx.BoxSizer( wx.HORIZONTAL )
+
+ hbox.AddF( self._num, CC.FLAGS_VCENTER )
+
+ mid_text = per_phrase
+
+ if unit is not None:
- if first_button:
-
- style = wx.RB_GROUP
-
- first_button = False
-
- else: style = 0
-
- radio_button = wx.RadioButton( self, label = text, style = style )
-
- self.AddF( radio_button, CC.FLAGS_EXPAND_PERPENDICULAR )
-
- self._indices_to_radio_buttons[ index ] = radio_button
- self._radio_buttons_to_data[ radio_button ] = data
+ mid_text = unit + ' ' + mid_text
- if initial_index is not None and initial_index in self._indices_to_radio_buttons: self._indices_to_radio_buttons[ initial_index ].SetValue( True )
+ hbox.AddF( BetterStaticText( self, mid_text ), CC.FLAGS_VCENTER )
+
+ hbox.AddF( self._times, CC.FLAGS_VCENTER )
+
+ self.SetSizer( hbox )
- def GetSelectedClientData( self ):
+ def GetValue( self ):
- for radio_button in self._radio_buttons_to_data.keys():
+ num = self._num.GetValue()
+ time_delta = self._times.GetValue()
+
+ return ( num, time_delta )
+
+
+ def SetToolTipString( self, text ):
+
+ wx.Panel.SetToolTipString( self, text )
+
+ for c in self.GetChildren():
- if radio_button.GetValue() == True: return self._radio_buttons_to_data[ radio_button ]
+ c.SetToolTipString( text )
- def SetSelection( self, index ): self._indices_to_radio_buttons[ index ].SetValue( True )
-
- def SetString( self, index, text ): self._indices_to_radio_buttons[ index ].SetLabelText( text )
+ def SetValue( self, velocity ):
+
+ ( num, time_delta ) = velocity
+
+ self._num.SetValue( num )
+
+ self._times.SetValue( time_delta )
+
diff --git a/include/ClientGUIListBoxes.py b/include/ClientGUIListBoxes.py
index 57f34e34..f8787f5f 100644
--- a/include/ClientGUIListBoxes.py
+++ b/include/ClientGUIListBoxes.py
@@ -14,6 +14,225 @@ import HydrusTags
import os
import wx
+class QueueListBox( wx.Panel ):
+
+ def __init__( self, parent, data_to_pretty_callable, add_callable, edit_callable ):
+
+ self._data_to_pretty_callable = data_to_pretty_callable
+ self._add_callable = add_callable
+ self._edit_callable = edit_callable
+
+ wx.Panel.__init__( self, parent )
+
+ self._listbox = wx.ListBox( self, style = wx.LB_MULTIPLE )
+
+ self._up_button = ClientGUICommon.BetterButton( self, u'\u2191', self._Up )
+
+ self._delete_button = ClientGUICommon.BetterButton( self, 'X', self._Delete )
+
+ self._down_button = ClientGUICommon.BetterButton( self, u'\u2193', self._Down )
+
+ self._add_button = ClientGUICommon.BetterButton( self, 'add', self._Add )
+ self._edit_button = ClientGUICommon.BetterButton( self, 'edit', self._Edit )
+
+ #
+
+ vbox = wx.BoxSizer( wx.VERTICAL )
+
+ buttons_vbox = wx.BoxSizer( wx.VERTICAL )
+
+ buttons_vbox.AddF( self._up_button, CC.FLAGS_VCENTER )
+ buttons_vbox.AddF( self._delete_button, CC.FLAGS_VCENTER )
+ buttons_vbox.AddF( self._down_button, CC.FLAGS_VCENTER )
+
+ hbox = wx.BoxSizer( wx.HORIZONTAL )
+
+ hbox.AddF( self._listbox, CC.FLAGS_EXPAND_BOTH_WAYS )
+ hbox.AddF( buttons_vbox, CC.FLAGS_VCENTER )
+
+ buttons_hbox = wx.BoxSizer( wx.HORIZONTAL )
+
+ buttons_hbox.AddF( self._add_button, CC.FLAGS_EXPAND_BOTH_WAYS )
+ buttons_hbox.AddF( self._edit_button, CC.FLAGS_EXPAND_BOTH_WAYS )
+
+ vbox.AddF( hbox, CC.FLAGS_EXPAND_BOTH_WAYS )
+ vbox.AddF( buttons_hbox, CC.FLAGS_EXPAND_PERPENDICULAR )
+
+ self.SetSizer( vbox )
+
+ #
+
+ self._listbox.Bind( wx.EVT_LISTBOX, self.EventSelection )
+
+
+ def _Add( self ):
+
+ ( result, data ) = self._add_callable()
+
+ if result:
+
+ self._AddData( data )
+
+
+
+ def _AddData( self, data ):
+
+ pretty_data = self._data_to_pretty_callable( data )
+
+ self._listbox.Append( pretty_data, data )
+
+
+ def _Delete( self ):
+
+ indices = self._listbox.GetSelections()
+
+ indices.sort( reverse = True )
+
+ if len( indices ) == 0:
+
+ return
+
+
+ import ClientGUIDialogs
+
+ with ClientGUIDialogs.DialogYesNo( self, 'Remove all selected?' ) as dlg_yn:
+
+ if dlg_yn.ShowModal() == wx.ID_YES:
+
+ for i in indices:
+
+ self._listbox.Delete( i )
+
+
+
+
+
+ def _Down( self ):
+
+ indices = self._listbox.GetSelections()
+
+ indices.sort( reverse = True )
+
+ for i in indices:
+
+ if i > 0:
+
+ if not self._listbox.IsSelected( i + 1 ): # is the one below not selected?
+
+ self._SwapRows( i, i + 1 )
+
+
+
+
+
+ def _Edit( self ):
+
+ for i in range( self._listbox.GetCount() ):
+
+ if not self._listbox.IsSelected( i ):
+
+ continue
+
+
+ data = self._listbox.GetClientData( i )
+
+ ( result, new_data ) = self._edit_callable( data )
+
+ if result:
+
+ self._listbox.Delete( i )
+
+ pretty_new_data = self._data_to_pretty_callable( new_data )
+
+ self._listbox.Set( pretty_new_data, i, new_data )
+
+ else:
+
+ break
+
+
+
+
+ def _SwapRows( self, index_a, index_b ):
+
+ data_a = self._listbox.GetClientData( index_a )
+ data_b = self._listbox.GetClientData( index_b )
+
+ pretty_data_a = self._data_to_pretty_callable( data_a )
+ pretty_data_b = self._data_to_pretty_callable( data_b )
+
+ self._listbox.Delete( index_a )
+ self._listbox.Insert( pretty_data_b, index_a, data_b )
+
+ self._listbox.Delete( index_b )
+ self._listbox.Insert( pretty_data_a, index_b, data_a )
+
+
+ def _Up( self ):
+
+ indices = self._listbox.GetSelections()
+
+ for i in indices:
+
+ if i > 0:
+
+ if not self._listbox.IsSelected( i - 1 ): # is the one above not selected?
+
+ self._SwapRows( i, i - 1 )
+
+
+
+
+
+ def AddDatas( self, datas ):
+
+ for data in datas:
+
+ self._AddData( data )
+
+
+
+ def Bind( self, event, handler ):
+
+ self._listbox.Bind( event, handler )
+
+
+ def EventSelection( self, event ):
+
+ if self._listbox.GetSelection() == wx.NOT_FOUND:
+
+ self._up_button.Disable()
+ self._delete_button.Disable()
+ self._down_button.Disable()
+
+ self._edit_button.Disable()
+
+ else:
+
+ self._up_button.Enable()
+ self._delete_button.Enable()
+ self._down_button.Enable()
+
+ self._edit_button.Enable()
+
+
+ event.Skip()
+
+
+ def GetData( self, only_selected = False ):
+
+ datas = []
+
+ for i in range( self._listbox.GetCount() ):
+
+ data = self._listbox.GetClientData( i )
+
+ datas.append( data )
+
+
+ return datas
+
+
class ListBox( wx.ScrolledWindow ):
TEXT_X_PADDING = 3
@@ -914,7 +1133,7 @@ class ListBoxTags( ListBox ):
ClientGUIMenus.AppendMenuItem( self, menu, 'add parents to ' + text, 'Add a parent to this tag.', self._ProcessMenuTagEvent, 'parent' )
- ClientGUIMenus.AppendMenuItem( self, menu, 'add parents to ' + text, 'Add a sibling to this tag.', self._ProcessMenuTagEvent, 'sibling' )
+ ClientGUIMenus.AppendMenuItem( self, menu, 'add siblings to ' + text, 'Add a sibling to this tag.', self._ProcessMenuTagEvent, 'sibling' )
diff --git a/include/ClientGUIListCtrl.py b/include/ClientGUIListCtrl.py
index 90139f77..e6d703eb 100644
--- a/include/ClientGUIListCtrl.py
+++ b/include/ClientGUIListCtrl.py
@@ -376,7 +376,7 @@ class SaneListCtrlForSingleObject( SaneListCtrl ):
name = obj.GetName()
- current_names = { obj.GetName() for obj in self.GetObjects() }
+ current_names = { o.GetName() for o in self.GetObjects() if o is not obj }
if name in current_names:
@@ -492,7 +492,7 @@ class SaneListCtrlPanel( wx.Panel ):
self._listctrl.Bind( wx.EVT_LIST_DELETE_ITEM, self.EventContentChanged )
self._listctrl.Bind( wx.EVT_LIST_DELETE_ALL_ITEMS, self.EventContentChanged )
-
+
class BetterListCtrl( wx.ListCtrl, ListCtrlAutoWidthMixin ):
def __init__( self, parent, name, height_num_chars, sizing_column_initial_width_num_chars, columns, data_to_tuples_func, delete_key_callback = None, activation_callback = None ):
@@ -881,3 +881,92 @@ class BetterListCtrl( wx.ListCtrl, ListCtrlAutoWidthMixin ):
+
+class BetterListCtrlPanel( wx.Panel ):
+
+ def __init__( self, parent ):
+
+ wx.Panel.__init__( self, parent )
+
+ self._vbox = wx.BoxSizer( wx.VERTICAL )
+
+ self._buttonbox = wx.BoxSizer( wx.HORIZONTAL )
+
+ self._listctrl = None
+
+ self._button_infos = []
+
+
+ def _HasSelected( self ):
+
+ return self._listctrl.HasSelected()
+
+
+ def _UpdateButtons( self ):
+
+ for ( button, enabled_check_func ) in self._button_infos:
+
+ if enabled_check_func():
+
+ button.Enable()
+
+ else:
+
+ button.Disable()
+
+
+
+
+ def AddButton( self, label, clicked_func, enabled_only_on_selection = False, enabled_check_func = None ):
+
+ button = ClientGUICommon.BetterButton( self, label, clicked_func )
+
+ self._buttonbox.AddF( button, CC.FLAGS_VCENTER )
+
+ if enabled_only_on_selection:
+
+ enabled_check_func = self._HasSelected
+
+
+ if enabled_check_func is not None:
+
+ self._button_infos.append( ( button, enabled_check_func ) )
+
+
+
+ def AddWindow( self, window ):
+
+ self._buttonbox.AddF( window, CC.FLAGS_VCENTER )
+
+
+ def EventContentChanged( self, event ):
+
+ self._UpdateButtons()
+
+ event.Skip()
+
+
+ def EventSelectionChanged( self, event ):
+
+ self._UpdateButtons()
+
+ event.Skip()
+
+
+ def SetListCtrl( self, listctrl ):
+
+ self._listctrl = listctrl
+
+ self._vbox.AddF( self._listctrl, CC.FLAGS_EXPAND_SIZER_BOTH_WAYS )
+ self._vbox.AddF( self._buttonbox, CC.FLAGS_BUTTON_SIZER )
+
+ self.SetSizer( self._vbox )
+
+ self._listctrl.Bind( wx.EVT_LIST_ITEM_SELECTED, self.EventSelectionChanged )
+ self._listctrl.Bind( wx.EVT_LIST_ITEM_DESELECTED, self.EventSelectionChanged )
+
+ self._listctrl.Bind( wx.EVT_LIST_INSERT_ITEM, self.EventContentChanged )
+ self._listctrl.Bind( wx.EVT_LIST_DELETE_ITEM, self.EventContentChanged )
+ self._listctrl.Bind( wx.EVT_LIST_DELETE_ALL_ITEMS, self.EventContentChanged )
+
+
diff --git a/include/ClientGUIManagement.py b/include/ClientGUIManagement.py
index bcc51e22..9df63cc6 100755
--- a/include/ClientGUIManagement.py
+++ b/include/ClientGUIManagement.py
@@ -766,6 +766,16 @@ class ManagementPanel( wx.lib.scrolledpanel.ScrolledPanel ):
pass
+ def PageHidden( self ):
+
+ pass
+
+
+ def PageShown( self ):
+
+ pass
+
+
def SetSearchFocus( self, page_key ):
pass
@@ -1330,12 +1340,38 @@ class ManagementPanelImporter( ManagementPanel ):
self._import_update_timer.Start( 250, wx.TIMER_CONTINUOUS )
+ self._controller.sub( self, 'RefreshSort', 'refresh_query' )
+
def _UpdateStatus( self ):
raise NotImplementedError()
+ def PageHidden( self ):
+
+ ManagementPanel.PageHidden( self )
+
+ self._import_update_timer.Stop()
+
+
+ def PageShown( self ):
+
+ ManagementPanel.PageShown( self )
+
+ self._UpdateStatus()
+
+ self._import_update_timer.Start()
+
+
+ def RefreshSort( self, page_key ):
+
+ if page_key == self._page_key:
+
+ self._sort_by.BroadcastSort()
+
+
+
def TIMEREventImportUpdate( self, event ):
if self._controller.gui.IsCurrentPage( self._page_key ):
@@ -2213,12 +2249,12 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
self._options_panel = wx.Panel( self._thread_watcher_panel )
- self._pause_button = wx.BitmapButton( self._options_panel, bitmap = CC.GlobalBMPs.pause )
- self._pause_button.Bind( wx.EVT_BUTTON, self.EventPause )
-
#
- imports_panel = ClientGUICommon.StaticBox( self._options_panel, 'imports' )
+ imports_panel = ClientGUICommon.StaticBox( self._options_panel, 'file imports' )
+
+ self._files_pause_button = wx.BitmapButton( imports_panel, bitmap = CC.GlobalBMPs.pause )
+ self._files_pause_button.Bind( wx.EVT_BUTTON, self.EventPauseFiles )
self._current_action = ClientGUICommon.BetterStaticText( imports_panel )
self._seed_cache_control = ClientGUISeedCache.SeedCacheStatusControl( imports_panel, self._controller )
@@ -2228,52 +2264,58 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
checker_panel = ClientGUICommon.StaticBox( self._options_panel, 'checker' )
+ self._file_velocity_status = ClientGUICommon.BetterStaticText( checker_panel )
+
+ self._thread_pause_button = wx.BitmapButton( checker_panel, bitmap = CC.GlobalBMPs.pause )
+ self._thread_pause_button.Bind( wx.EVT_BUTTON, self.EventPauseThread )
+
self._watcher_status = ClientGUICommon.BetterStaticText( checker_panel )
- self._thread_download_control = ClientGUIControls.NetworkJobControl( checker_panel )
-
- ( times_to_check, check_period ) = HC.options[ 'thread_checker_timings' ]
-
- self._thread_times_to_check = wx.SpinCtrl( checker_panel, size = ( 60, -1 ), min = 0, max = 65536 )
- self._thread_times_to_check.SetValue( times_to_check )
- self._thread_times_to_check.Bind( wx.EVT_SPINCTRL, self.EventTimesToCheck )
-
- self._thread_check_period = ClientGUICommon.TimeDeltaButton( checker_panel, min = 30, days = True, hours = True, minutes = True, seconds = True )
- self._thread_check_period.SetValue( check_period )
- self._thread_check_period.Bind( ClientGUICommon.EVT_TIME_DELTA, self.EventCheckPeriod )
self._thread_check_now_button = wx.Button( checker_panel, label = 'check now' )
self._thread_check_now_button.Bind( wx.EVT_BUTTON, self.EventCheckNow )
+ self._watcher_options_button = ClientGUICommon.BetterButton( checker_panel, 'edit check timings', self._EditWatcherOptions )
+
+ self._thread_download_control = ClientGUIControls.NetworkJobControl( checker_panel )
+
#
self._thread_watcher_import = self._management_controller.GetVariable( 'thread_watcher_import' )
- ( thread_url, file_import_options, tag_import_options, times_to_check, check_period ) = self._thread_watcher_import.GetOptions()
+ ( thread_url, file_import_options, tag_import_options ) = self._thread_watcher_import.GetOptions()
self._file_import_options = ClientGUIImport.FileImportOptionsButton( self._thread_watcher_panel, file_import_options, self._thread_watcher_import.SetFileImportOptions )
self._tag_import_options = ClientGUICollapsible.CollapsibleOptionsTags( self._thread_watcher_panel, namespaces = [ 'filename' ] )
#
- imports_panel.AddF( self._current_action, CC.FLAGS_EXPAND_PERPENDICULAR )
+ hbox = wx.BoxSizer( wx.HORIZONTAL )
+
+ hbox.AddF( self._current_action, CC.FLAGS_VCENTER_EXPAND_DEPTH_ONLY )
+
+ hbox.AddF( self._files_pause_button, CC.FLAGS_LONE_BUTTON )
+
+ imports_panel.AddF( hbox, CC.FLAGS_EXPAND_SIZER_PERPENDICULAR )
imports_panel.AddF( self._seed_cache_control, CC.FLAGS_EXPAND_PERPENDICULAR )
imports_panel.AddF( self._file_download_control, CC.FLAGS_EXPAND_PERPENDICULAR )
- hbox_1 = wx.WrapSizer( wx.HORIZONTAL )
+ #
- hbox_1.AddF( ClientGUICommon.BetterStaticText( checker_panel, label = 'checking ' ), CC.FLAGS_VCENTER )
- hbox_1.AddF( self._thread_times_to_check, CC.FLAGS_VCENTER )
- hbox_1.AddF( ClientGUICommon.BetterStaticText( checker_panel, label = ' more times, every ' ), CC.FLAGS_VCENTER )
- hbox_1.AddF( self._thread_check_period, CC.FLAGS_VCENTER )
- hbox_1.AddF( self._thread_check_now_button, CC.FLAGS_VCENTER )
+ gridbox = wx.FlexGridSizer( 0, 2 )
- checker_panel.AddF( self._watcher_status, CC.FLAGS_EXPAND_PERPENDICULAR )
+ gridbox.AddGrowableCol( 0, 1 )
+
+ gridbox.AddF( self._file_velocity_status, CC.FLAGS_VCENTER_EXPAND_DEPTH_ONLY )
+ gridbox.AddF( self._thread_pause_button, CC.FLAGS_LONE_BUTTON )
+ gridbox.AddF( self._watcher_status, CC.FLAGS_VCENTER_EXPAND_DEPTH_ONLY )
+ gridbox.AddF( self._thread_check_now_button, CC.FLAGS_VCENTER )
+
+ checker_panel.AddF( gridbox, CC.FLAGS_EXPAND_SIZER_PERPENDICULAR )
+ checker_panel.AddF( self._watcher_options_button, CC.FLAGS_EXPAND_PERPENDICULAR )
checker_panel.AddF( self._thread_download_control, CC.FLAGS_EXPAND_PERPENDICULAR )
- checker_panel.AddF( hbox_1, CC.FLAGS_EXPAND_SIZER_PERPENDICULAR )
vbox = wx.BoxSizer( wx.VERTICAL )
- vbox.AddF( self._pause_button, CC.FLAGS_LONE_BUTTON )
vbox.AddF( imports_panel, CC.FLAGS_EXPAND_PERPENDICULAR )
vbox.AddF( checker_panel, CC.FLAGS_EXPAND_PERPENDICULAR )
@@ -2302,8 +2344,6 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
self.Bind( wx.EVT_MENU, self.EventMenu )
- self._controller.sub( self, 'DecrementTimesToCheck', 'decrement_times_to_check' )
-
seed_cache = self._thread_watcher_import.GetSeedCache()
self._seed_cache_control.SetSeedCache( seed_cache )
@@ -2315,12 +2355,30 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
self._tag_import_options.SetOptions( tag_import_options )
- self._thread_times_to_check.SetValue( times_to_check )
- self._thread_check_period.SetValue( check_period )
-
self._UpdateStatus()
+ def _EditWatcherOptions( self ):
+
+ watcher_options = self._thread_watcher_import.GetWatcherOptions()
+
+ with ClientGUITopLevelWindows.DialogEdit( self._watcher_options_button, 'edit check timings' ) as dlg:
+
+ panel = ClientGUIScrolledPanelsEdit.EditWatcherOptions( dlg, watcher_options )
+
+ dlg.SetPanel( panel )
+
+ if dlg.ShowModal() == wx.ID_OK:
+
+ new_watcher_options = panel.GetValue()
+
+ self._thread_watcher_import.SetWatcherOptions( new_watcher_options )
+
+ self._UpdateStatus()
+
+
+
+
def _UpdateStatus( self ):
if self._thread_watcher_import.HasThread():
@@ -2344,37 +2402,63 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
- ( current_action, watcher_status, check_now, paused ) = self._thread_watcher_import.GetStatus()
+ ( current_action, files_paused, file_velocity_status, next_check_time, watcher_status, check_now, thread_paused ) = self._thread_watcher_import.GetStatus()
- self._current_action.SetLabelText( current_action )
-
- if paused:
+ if files_paused:
- if self._thread_times_to_check.GetValue() > 0 or check_now:
+ if current_action == '':
- watcher_status = 'paused'
+ current_action = 'paused'
+
+ else:
+
+ current_action = 'pausing, ' + current_action
- if self._pause_button.GetBitmap() != CC.GlobalBMPs.play:
+ if self._files_pause_button.GetBitmap() != CC.GlobalBMPs.play:
- self._pause_button.SetBitmap( CC.GlobalBMPs.play )
+ self._files_pause_button.SetBitmap( CC.GlobalBMPs.play )
else:
- if self._pause_button.GetBitmap() != CC.GlobalBMPs.pause:
+ if self._files_pause_button.GetBitmap() != CC.GlobalBMPs.pause:
- self._pause_button.SetBitmap( CC.GlobalBMPs.pause )
+ self._files_pause_button.SetBitmap( CC.GlobalBMPs.pause )
+
+
+
+ self._current_action.SetLabelText( current_action )
+
+ self._file_velocity_status.SetLabelText( file_velocity_status )
+
+ if thread_paused:
+
+ if watcher_status == '':
+
+ watcher_status = 'paused'
+
+
+ if self._thread_pause_button.GetBitmap() != CC.GlobalBMPs.play:
+
+ self._thread_pause_button.SetBitmap( CC.GlobalBMPs.play )
+
+
+ else:
+
+ if watcher_status == '':
+
+ watcher_status = 'next check ' + HydrusData.ConvertTimestampToPrettyPending( next_check_time )
+
+
+ if self._thread_pause_button.GetBitmap() != CC.GlobalBMPs.pause:
+
+ self._thread_pause_button.SetBitmap( CC.GlobalBMPs.pause )
self._watcher_status.SetLabelText( watcher_status )
- if current_action == '' and paused:
-
- current_action = 'paused'
-
-
if check_now:
self._thread_check_now_button.Disable()
@@ -2385,18 +2469,6 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
- def DecrementTimesToCheck( self, page_key ):
-
- if page_key == self._page_key:
-
- current_value = self._thread_times_to_check.GetValue()
-
- new_value = max( 0, current_value - 1 )
-
- self._thread_times_to_check.SetValue( new_value )
-
-
-
def EventCheckNow( self, event ):
self._thread_watcher_import.CheckNow()
@@ -2404,11 +2476,18 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
self._UpdateStatus()
- def EventCheckPeriod( self, event ):
+ def EventPauseFiles( self, event ):
- check_period = self._thread_check_period.GetValue()
+ self._thread_watcher_import.PausePlayFiles()
- self._thread_watcher_import.SetCheckPeriod( check_period )
+ self._UpdateStatus()
+
+
+ def EventPauseThread( self, event ):
+
+ self._thread_watcher_import.PausePlayThread()
+
+ self._UpdateStatus()
def EventKeyDown( self, event ):
@@ -2468,20 +2547,6 @@ class ManagementPanelImporterThreadWatcher( ManagementPanelImporter ):
- def EventPause( self, event ):
-
- self._thread_watcher_import.PausePlay()
-
- self._UpdateStatus()
-
-
- def EventTimesToCheck( self, event ):
-
- times_to_check = self._thread_times_to_check.GetValue()
-
- self._thread_watcher_import.SetTimesToCheck( times_to_check )
-
-
def SetSearchFocus( self, page_key ):
if page_key == self._page_key and self._thread_input.IsEditable():
diff --git a/include/ClientGUIMedia.py b/include/ClientGUIMedia.py
index e96d9e9b..f22dfbd6 100755
--- a/include/ClientGUIMedia.py
+++ b/include/ClientGUIMedia.py
@@ -118,8 +118,6 @@ class MediaPanel( ClientMedia.ListeningMediaList, wx.ScrolledWindow ):
HG.client_controller.sub( self, 'AddMediaResults', 'add_media_results' )
HG.client_controller.sub( self, 'SetFocussedMedia', 'set_focus' )
- HG.client_controller.sub( self, 'PageHidden', 'page_hidden' )
- HG.client_controller.sub( self, 'PageShown', 'page_shown' )
HG.client_controller.sub( self, 'Collect', 'collect_media' )
HG.client_controller.sub( self, 'Sort', 'sort_media' )
HG.client_controller.sub( self, 'FileDumped', 'file_dumped' )
@@ -547,34 +545,7 @@ class MediaPanel( ClientMedia.ListeningMediaList, wx.ScrolledWindow ):
num_selected = self._GetNumSelected()
- ( sorted_mime_classes, selected_mime_classes ) = self._GetSortedSelectedMimeClasses()
-
- if sorted_mime_classes == set( [ 'image' ] ):
-
- num_files_descriptor = 'image'
-
- elif sorted_mime_classes == set( [ 'video' ] ):
-
- num_files_descriptor = 'video'
-
- else:
-
- num_files_descriptor = 'file'
-
-
- if selected_mime_classes == set( [ 'image' ] ):
-
- selected_files_descriptor = 'image'
-
- elif selected_mime_classes == set( [ 'video' ] ):
-
- selected_files_descriptor = 'video'
-
- else:
-
- selected_files_descriptor = 'file'
-
-
+ ( num_files_descriptor, selected_files_descriptor ) = self._GetSortedSelectedMimeDescriptors()
if num_files == 1:
@@ -585,28 +556,21 @@ class MediaPanel( ClientMedia.ListeningMediaList, wx.ScrolledWindow ):
num_files_string = HydrusData.ConvertIntToPrettyString( num_files ) + ' ' + num_files_descriptor + 's'
- if selected_files_descriptor == num_files_descriptor:
-
- selected_files_string = HydrusData.ConvertIntToPrettyString( num_selected )
-
- else:
-
- if num_selected == 1:
-
- selected_files_string = '1 ' + selected_files_descriptor
-
- else:
-
- selected_files_string = HydrusData.ConvertIntToPrettyString( num_selected ) + ' ' + selected_files_descriptor + 's'
-
-
-
s = num_files_string # 23 files
if num_selected > 0:
s += ' - '
+ if num_selected == 1 or selected_files_descriptor == num_files_descriptor:
+
+ selected_files_string = HydrusData.ConvertIntToPrettyString( num_selected )
+
+ else:
+
+ selected_files_string = HydrusData.ConvertIntToPrettyString( num_selected ) + ' ' + selected_files_descriptor + 's'
+
+
if num_selected == 1: # 23 files - 1 video selected, file_info
( selected_media, ) = self._selected_media
@@ -617,9 +581,18 @@ class MediaPanel( ClientMedia.ListeningMediaList, wx.ScrolledWindow ):
num_inbox = sum( ( media.GetNumInbox() for media in self._selected_media ) )
- if num_inbox == num_selected: inbox_phrase = 'all in inbox, '
- elif num_inbox == 0: inbox_phrase = 'all archived, '
- else: inbox_phrase = HydrusData.ConvertIntToPrettyString( num_inbox ) + ' in inbox and ' + HydrusData.ConvertIntToPrettyString( num_selected - num_inbox ) + ' archived, '
+ if num_inbox == num_selected:
+
+ inbox_phrase = 'all in inbox, '
+
+ elif num_inbox == 0:
+
+ inbox_phrase = 'all archived, '
+
+ else:
+
+ inbox_phrase = HydrusData.ConvertIntToPrettyString( num_inbox ) + ' in inbox and ' + HydrusData.ConvertIntToPrettyString( num_selected - num_inbox ) + ' archived, '
+
pretty_total_size = self._GetPrettyTotalSelectedSize()
@@ -706,57 +679,63 @@ class MediaPanel( ClientMedia.ListeningMediaList, wx.ScrolledWindow ):
- def _GetSortedSelectedMimeClasses( self ):
+ def _GetSortedSelectedMimeDescriptors( self ):
- sorted_mimes = set()
-
- for media in self._sorted_media:
+ def GetDescriptor( classes ):
- mime = media.GetMime()
+ if len( classes ) == 0:
+
+ return 'file'
+
- if mime in HC.IMAGES:
+ if len( classes ) == 1:
- sorted_mimes.add( 'image' )
+ ( mime, ) = classes
- elif mime in HC.VIDEO:
+ return HC.mime_string_lookup[ mime ]
- sorted_mimes.add( 'video' )
+
+ if len( classes.difference( HC.IMAGES ) ) == 0:
- elif mime in HC.AUDIO:
+ return 'image'
- sorted_mimes.add( 'audio' )
+ elif len( classes.difference( HC.VIDEO ) ) == 0:
+
+ return 'video'
+
+ elif len( classes.difference( HC.AUDIO ) ) == 0:
+
+ return 'audio file'
else:
- sorted_mimes.add( 'misc' )
+ return 'file'
- selected_mimes = set()
-
- for media in self._selected_media:
+ if len( self._sorted_media ) > 1000:
- mime = media.GetMime()
+ sorted_mime_descriptor = 'file'
- if mime in HC.IMAGES:
-
- selected_mimes.add( 'image' )
-
- elif mime in HC.VIDEO:
-
- selected_mimes.add( 'video' )
-
- elif mime in HC.AUDIO:
-
- selected_mimes.add( 'audio' )
-
- else:
-
- selected_mimes.add( 'misc' )
-
+ else:
+
+ sorted_mimes = { media.GetMime() for media in self._sorted_media }
+
+ sorted_mime_descriptor = GetDescriptor( sorted_mimes )
- return ( sorted_mimes, selected_mimes )
+ if len( self._selected_media ) > 1000:
+
+ selected_mime_descriptor = 'file'
+
+ else:
+
+ selected_mimes = { media.GetMime() for media in self._selected_media }
+
+ selected_mime_descriptor = GetDescriptor( selected_mimes )
+
+
+ return ( sorted_mime_descriptor, selected_mime_descriptor )
def _HitMedia( self, media, ctrl, shift ):
@@ -1031,7 +1010,23 @@ class MediaPanel( ClientMedia.ListeningMediaList, wx.ScrolledWindow ):
action = data
- if action == 'manage_file_ratings':
+ if action == 'copy_bmp':
+
+ self._CopyBMPToClipboard()
+
+ elif action == 'copy_file':
+
+ self._CopyFilesToClipboard()
+
+ elif action == 'copy_path':
+
+ self._CopyPathsToClipboard()
+
+ elif action == 'copy_sha256_hash':
+
+ self._CopyHashesToClipboard( 'sha256' )
+
+ elif action == 'manage_file_ratings':
self._ManageRatings()
@@ -1586,22 +1581,16 @@ class MediaPanel( ClientMedia.ListeningMediaList, wx.ScrolledWindow ):
- def PageHidden( self, page_key ):
+ def PageHidden( self ):
- if page_key == self._page_key:
-
- HG.client_controller.pub( 'preview_changed', self._page_key, None )
-
+ HG.client_controller.pub( 'preview_changed', self._page_key, None )
- def PageShown( self, page_key ):
+ def PageShown( self ):
- if page_key == self._page_key:
-
- HG.client_controller.pub( 'preview_changed', self._page_key, self._focussed_media )
-
- self._PublishSelectionChange()
-
+ HG.client_controller.pub( 'preview_changed', self._page_key, self._focussed_media )
+
+ self._PublishSelectionChange()
def ProcessContentUpdates( self, service_keys_to_content_updates ):
@@ -3594,7 +3583,6 @@ class MediaPanelThumbnails( MediaPanel ):
( wx.ACCEL_SHIFT, wx.WXK_RIGHT, ClientCaches.MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_right' ) ),
( wx.ACCEL_SHIFT, wx.WXK_NUMPAD_RIGHT, ClientCaches.MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'key_shift_right' ) ),
( wx.ACCEL_CTRL, ord( 'A' ), ClientCaches.MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'select', 'all' ) ),
- ( wx.ACCEL_CTRL, ord( 'c' ), ClientCaches.MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'copy_files' ) ),
( wx.ACCEL_CTRL, wx.WXK_SPACE, ClientCaches.MENU_EVENT_ID_TO_ACTION_CACHE.GetPermanentId( 'ctrl-space' ) )
]
diff --git a/include/ClientGUIPages.py b/include/ClientGUIPages.py
index 46439a0b..6f482709 100755
--- a/include/ClientGUIPages.py
+++ b/include/ClientGUIPages.py
@@ -576,12 +576,14 @@ class Page( wx.SplitterWindow ):
def PageHidden( self ):
- self._controller.pub( 'page_hidden', self._page_key )
+ self._management_panel.PageHidden()
+ self._media_panel.PageHidden()
def PageShown( self ):
- self._controller.pub( 'page_shown', self._page_key )
+ self._management_panel.PageShown()
+ self._media_panel.PageShown()
def PrepareToHide( self ):
@@ -1998,12 +2000,20 @@ class PagesNotebook( wx.Notebook ):
page.Reparent( dest_notebook )
+ self._controller.pub( 'refresh_page_name', source_notebook.GetPageKey() )
+
- dest_notebook.InsertPage( insertion_tab_index, page, page.GetName() )
+ shift_down = wx.GetKeyState( wx.WXK_SHIFT )
- self.ShowPage( page )
+ follow_dropped_page = not shift_down
+
+ dest_notebook.InsertPage( insertion_tab_index, page, page.GetName(), select = follow_dropped_page )
+
+ if follow_dropped_page:
+
+ self.ShowPage( page )
+
- self._controller.pub( 'refresh_page_name', source_notebook.GetPageKey() )
self._controller.pub( 'refresh_page_name', page.GetPageKey() )
diff --git a/include/ClientGUIScrolledPanelsEdit.py b/include/ClientGUIScrolledPanelsEdit.py
index d00b692e..d5f14d9b 100644
--- a/include/ClientGUIScrolledPanelsEdit.py
+++ b/include/ClientGUIScrolledPanelsEdit.py
@@ -15,9 +15,12 @@ import ClientGUIMenus
import ClientGUIScrolledPanels
import ClientGUISeedCache
import ClientGUITopLevelWindows
+import ClientNetworkingDomain
+import ClientParsing
import ClientTags
import HydrusConstants as HC
import HydrusData
+import HydrusExceptions
import HydrusGlobals as HG
import HydrusNetwork
import HydrusSerialisable
@@ -161,6 +164,47 @@ class EditBandwidthRulesPanel( ClientGUIScrolledPanels.EditPanel ):
return self._bandwidth_rules_ctrl.GetValue()
+class EditChooseMultiple( ClientGUIScrolledPanels.EditPanel ):
+
+ def __init__( self, parent, choice_tuples ):
+
+ ClientGUIScrolledPanels.EditPanel.__init__( self, parent )
+
+ self._checkboxes = wx.CheckListBox( self )
+
+ self._checkboxes.SetMinSize( ( 320, 420 ) )
+
+ for ( i, ( label, data, selected ) ) in enumerate( choice_tuples ):
+
+ self._checkboxes.Append( label, data )
+
+ if selected:
+
+ self._checkboxes.Check( i )
+
+
+
+ #
+
+ vbox = wx.BoxSizer( wx.VERTICAL )
+
+ vbox.AddF( self._checkboxes, CC.FLAGS_EXPAND_BOTH_WAYS )
+
+ self.SetSizer( vbox )
+
+
+ def GetValue( self ):
+
+ datas = []
+
+ for index in self._checkboxes.GetChecked():
+
+ datas.append( self._checkboxes.GetClientData( index ) )
+
+
+ return datas
+
+
class EditDuplicateActionOptionsPanel( ClientGUIScrolledPanels.EditPanel ):
def __init__( self, parent, duplicate_action, duplicate_action_options ):
@@ -1190,45 +1234,183 @@ class EditServersideService( ClientGUIScrolledPanels.EditPanel ):
-class EditChooseMultiple( ClientGUIScrolledPanels.EditPanel ):
+class EditStringMatchPanel( ClientGUIScrolledPanels.EditPanel ):
- def __init__( self, parent, choice_tuples ):
+ def __init__( self, parent, string_match ):
ClientGUIScrolledPanels.EditPanel.__init__( self, parent )
- self._checkboxes = wx.CheckListBox( self )
+ self._match_type = ClientGUICommon.BetterChoice( self )
- self._checkboxes.SetMinSize( ( 320, 420 ) )
+ self._match_type.Append( ClientParsing.STRING_MATCH_ANY, 'any characters' )
+ self._match_type.Append( ClientParsing.STRING_MATCH_FIXED, 'fixed characters' )
+ self._match_type.Append( ClientParsing.STRING_MATCH_FLEXIBLE, 'character set' )
+ self._match_type.Append( ClientParsing.STRING_MATCH_REGEX, 'regex' )
- for ( i, ( label, data, selected ) ) in enumerate( choice_tuples ):
-
- self._checkboxes.Append( label, data )
-
- if selected:
-
- self._checkboxes.Check( i )
-
-
+ self._match_value_text_input = wx.TextCtrl( self )
+
+ self._match_value_flexible_input = ClientGUICommon.BetterChoice( self )
+
+ self._match_value_flexible_input.Append( ClientParsing.ALPHA, 'alphabetic characters (a-zA-Z)' )
+ self._match_value_flexible_input.Append( ClientParsing.ALPHANUMERIC, 'alphanumeric characters (a-zA-Z0-9)' )
+ self._match_value_flexible_input.Append( ClientParsing.NUMERIC, 'numeric characters (0-9)' )
+
+ self._min_chars = ClientGUICommon.NoneableSpinCtrl( self, min = 1, max = 65535, unit = 'characters', none_phrase = 'no limit' )
+ self._max_chars = ClientGUICommon.NoneableSpinCtrl( self, min = 1, max = 65535, unit = 'characters', none_phrase = 'no limit' )
+
+ self._example_string = wx.TextCtrl( self )
+
+ self._example_string_matches = ClientGUICommon.BetterStaticText( self )
#
+ ( match_type, match_value, min_chars, max_chars, example_string ) = string_match.ToTuple()
+
+ self._match_type.SetClientData( match_type )
+
+ if match_type == ClientParsing.STRING_MATCH_FLEXIBLE:
+
+ self._match_value_flexible_input.SetClientData( match_value )
+
+ else:
+
+ self._match_value_flexible_input.SetClientData( ClientParsing.ALPHA )
+
+ self._match_value_text_input.SetValue( match_value )
+
+
+ self._UpdateControls()
+
+ #
+
+ rows = []
+
+ rows.append( ( 'match type: ', self._match_type ) )
+ rows.append( ( 'match text: ', self._match_value_text_input ) )
+ rows.append( ( 'match value (character set): ', self._match_value_flexible_input ) )
+ rows.append( ( 'minumum allowed number of characters: ', self._min_chars ) )
+ rows.append( ( 'maximum allowed number of characters: ', self._max_chars ) )
+ rows.append( ( 'example string: ', self._example_string ) )
+
+ gridbox = ClientGUICommon.WrapInGrid( self, rows )
+
vbox = wx.BoxSizer( wx.VERTICAL )
- vbox.AddF( self._checkboxes, CC.FLAGS_EXPAND_BOTH_WAYS )
+ vbox.AddF( gridbox, CC.FLAGS_EXPAND_PERPENDICULAR )
+ vbox.AddF( self._example_string_matches, CC.FLAGS_EXPAND_PERPENDICULAR )
self.SetSizer( vbox )
+ #
+
+ self._match_type.Bind( wx.EVT_CHOICE, self.EventUpdate )
+ self._match_value_text_input.Bind( wx.EVT_TEXT, self.EventUpdate )
+ self._match_value_flexible_input.Bind( wx.EVT_CHOICE, self.EventUpdate )
+ self._min_chars.Bind( wx.EVT_SPINCTRL, self.EventUpdate )
+ self._max_chars.Bind( wx.EVT_SPINCTRL, self.EventUpdate )
+ self._example_string.Bind( wx.EVT_TEXT, self.EventUpdate )
+
+
+ def _GetValue( self ):
+
+ match_type = self._match_type.GetChoice()
+
+ if match_type == ClientParsing.STRING_MATCH_ANY:
+
+ match_value = ''
+
+ elif match_type == ClientParsing.STRING_MATCH_FLEXIBLE:
+
+ match_value = self._match_value_flexible_input.GetChoice()
+
+ else:
+
+ match_value = self._match_value_text_input.GetValue()
+
+
+ min_chars = self._min_chars.GetValue()
+ max_chars = self._max_chars.GetValue()
+
+ example_string = self._example_string.GetValue()
+
+ string_match = ClientParsing.StringMatch( match_type = match_type, match_value = match_value, min_chars = min_chars, max_chars = max_chars, example_string = example_string )
+
+ return string_match
+
+
+ def _UpdateControls( self ):
+
+ match_type = self._match_type.GetChoice()
+
+ if match_type == ClientParsing.STRING_MATCH_ANY:
+
+ self._match_value_text_input.Disable()
+ self._match_value_flexible_input.Disable()
+
+ elif match_type == ClientParsing.STRING_MATCH_FLEXIBLE:
+
+ self._match_value_text_input.Disable()
+ self._match_value_flexible_input.Enable()
+
+ else:
+
+ self._match_value_text_input.Enable()
+ self._match_value_flexible_input.Disable()
+
+
+ if match_type == ClientParsing.STRING_MATCH_FIXED:
+
+ self._min_chars.SetValue( None )
+ self._max_chars.SetValue( None )
+
+ self._min_chars.Disable()
+ self._max_chars.Disable()
+
+ self._example_string.SetValue( self._match_value_text_input.GetValue() )
+
+ self._example_string_matches.SetLabelText( '' )
+
+ else:
+
+ self._min_chars.Enable()
+ self._max_chars.Enable()
+
+ string_match = self._GetValue()
+
+ ( result, reason ) = string_match.Test( self._example_string.GetValue() )
+
+ if result:
+
+ self._example_string_matches.SetLabelText( 'Example matches ok!' )
+ self._example_string_matches.SetForegroundColour( ( 0, 128, 0 ) )
+
+ else:
+
+ self._example_string_matches.SetLabelText( 'Example does not match - ' + reason )
+ self._example_string_matches.SetForegroundColour( ( 128, 0, 0 ) )
+
+
+
+
+ def EventUpdate( self, event ):
+
+ self._UpdateControls()
+
def GetValue( self ):
- datas = []
+ string_match = self._GetValue()
- for index in self._checkboxes.GetChecked():
+ ( result, reason ) = string_match.Test( self._example_string.GetValue() )
+
+ if not result:
- datas.append( self._checkboxes.GetClientData( index ) )
+ wx.MessageBox( 'Please enter an example text that matches the given rules!' )
+
+ raise HydrusExceptions.VetoException()
- return datas
+ return string_match
class EditSubscriptionPanel( ClientGUIScrolledPanels.EditPanel ):
@@ -1673,7 +1855,6 @@ class EditSubscriptionPanel( ClientGUIScrolledPanels.EditPanel ):
self._UpdateSeedInfo()
-
class EditTagCensorPanel( ClientGUIScrolledPanels.EditPanel ):
def __init__( self, parent, tag_censor ):
@@ -2123,3 +2304,310 @@ class EditTagImportOptions( ClientGUIScrolledPanels.EditPanel ):
return tag_import_options
+class EditURLMatch( ClientGUIScrolledPanels.EditPanel ):
+
+ def __init__( self, parent, url_match ):
+
+ ClientGUIScrolledPanels.EditPanel.__init__( self, parent )
+
+ self._name = wx.TextCtrl( self )
+
+ self._preferred_scheme = ClientGUICommon.BetterChoice( self )
+
+ self._preferred_scheme.Append( 'http', 'http' )
+ self._preferred_scheme.Append( 'https', 'https' )
+
+ self._netloc = wx.TextCtrl( self )
+
+ self._subdomain_is_important = wx.CheckBox( self )
+
+ #
+
+ path_components_panel = ClientGUICommon.StaticBox( self, 'path components' )
+
+ self._path_components = ClientGUIListBoxes.QueueListBox( path_components_panel, self._ConvertPathComponentToString, self._AddPathComponent, self._EditPathComponent )
+
+ #
+
+ parameters_panel = ClientGUICommon.StaticBox( self, 'parameters' )
+
+ self._parameters = ClientGUIListCtrl.BetterListCtrl( parameters_panel, 'url_match_path_components', 5, 20, [ ( 'key', 14 ), ( 'value', -1 ) ], self._ConvertParameterToListCtrlTuples, delete_key_callback = self._DeleteParameters, activation_callback = self._EditParameters )
+
+ # parameter buttons, do it with a new wrapper panel class
+
+ #
+
+ self._example_url = wx.TextCtrl( self )
+
+ self._example_url_matches = ClientGUICommon.BetterStaticText( self )
+
+ #
+
+ name = url_match.GetName()
+
+ self._name.SetValue( name )
+
+ ( preferred_scheme, netloc, subdomain_is_important, path_components, parameters, example_url ) = url_match.ToTuple()
+
+ self._preferred_scheme.SelectClientData( preferred_scheme )
+
+ self._netloc.SetValue( netloc )
+
+ self._subdomain_is_important.SetValue( subdomain_is_important )
+
+ self._path_components.AddDatas( path_components )
+ 00
+ self._parameters.AddDatas( parameters.items() )
+
+ self._example_url.SetValue( example_url )
+
+ self._UpdateControls()
+
+ #
+
+ rows = []
+
+ rows.append( ( 'name: ', self._name ) )
+ rows.append( ( 'preferred scheme: ', self._preferred_scheme ) )
+ rows.append( ( 'network location: ', self._netloc ) )
+ rows.append( ( 'keep subdomains?: ', self._subdomain_is_important ) )
+
+ gridbox_1 = ClientGUICommon.WrapInGrid( self, rows )
+
+ rows = []
+
+ rows.append( ( 'example url: ', self._example_url ) )
+
+ gridbox_2 = ClientGUICommon.WrapInGrid( self, rows )
+
+ vbox = wx.BoxSizer( wx.VERTICAL )
+
+ vbox.AddF( gridbox_1, CC.FLAGS_EXPAND_PERPENDICULAR )
+ vbox.AddF( path_components_panel, CC.FLAGS_EXPAND_BOTH_WAYS )
+ vbox.AddF( parameters_panel, CC.FLAGS_EXPAND_BOTH_WAYS )
+ vbox.AddF( self._example_url_matches, CC.FLAGS_EXPAND_PERPENDICULAR )
+ vbox.AddF( gridbox_2, CC.FLAGS_EXPAND_PERPENDICULAR )
+
+ self.SetSizer( vbox )
+
+ #
+
+ self._preferred_scheme.Bind( wx.EVT_CHOICE, self.EventUpdate )
+ self._netloc.Bind( wx.EVT_TEXT, self.EventUpdate )
+ self._subdomain_is_important.Bind( wx.EVT_CHECKBOX, self.EventUpdate )
+ self._example_url.Bind( wx.EVT_TEXT, self.EventUpdate )
+
+
+ def _AddParameters( self ):
+
+ # throw up a dialog to take key text
+ # warn on key conflict I guess
+
+ # throw up a string match dialog to take value
+
+ # add it
+
+ pass
+
+
+ def _AddPathComponent( self ):
+
+ string_match = ClientParsing.StringMatch()
+
+ return self._EditPathComponent( string_match )
+
+
+ def _ConvertParameterToListCtrlTuples( self, data ):
+
+ ( key, string_match ) = data
+
+ pretty_key = key
+ pretty_string_match = string_match.ToUnicode()
+
+ sort_key = pretty_key
+ sort_string_match = pretty_string_match
+
+ display_tuple = ( pretty_key, pretty_string_match )
+ sort_tuple = ( sort_key, sort_string_match )
+
+ return ( display_tuple, sort_tuple )
+
+
+ def _ConvertPathComponentToString( self, path_component ):
+
+ return path_component.ToUnicode()
+
+
+ def _DeleteParameters( self ):
+
+ # ask for certain, then do it
+
+ pass
+
+
+ def _EditParameters( self ):
+
+ # for each in list, throw up dialog for key, value
+ # delete and readd
+ # break on cancel, etc...
+ # sort at the end
+
+ pass
+
+
+ def _EditPathComponent( self, string_match ):
+
+ with ClientGUITopLevelWindows.DialogEdit( self, 'edit path component' ) as dlg:
+
+ panel = EditStringMatchPanel( dlg, string_match )
+
+ dlg.SetPanel( panel )
+
+ if dlg.Showmodal() == wx.ID_OK:
+
+ new_string_match = panel.GetValue()
+
+ return ( True, new_string_match )
+
+ else:
+
+ return ( False, None )
+
+
+
+
+ def _GetValue( self ):
+
+ name = self._name.GetValue()
+ preferred_scheme = self._preferred_scheme.GetChoice()
+ netloc = self._netloc.GetValue()
+ subdomain_is_important = self._subdomain_is_important.GetValue()
+ path_components = self._path_components.GetData()
+ parameters = self._parameters.GetData()
+ example_url = self._example_url.GetValue()
+
+ url_match = ClientNetworkingDomain.URLMatch( name, preferred_scheme = preferred_scheme, netloc = netloc, subdomain_is_important = subdomain_is_important, path_components = path_components, parameters = parameters, example_url = example_url )
+
+ return url_match
+
+
+ def _UpdateControls( self ):
+
+ url_match = self._GetValue()
+
+ ( result, reason ) = url_match.Test( self._example_url.GetValue() )
+
+ if result:
+
+ self._example_url_matches.SetLabelText( 'Example matches ok!' )
+ self._example_url_matches.SetForegroundColour( ( 0, 128, 0 ) )
+
+ else:
+
+ self._example_url_matches.SetLabelText( 'Example does not match - ' + reason )
+ self._example_url_matches.SetForegroundColour( ( 128, 0, 0 ) )
+
+
+
+ def EventUpdate( self, event ):
+
+ self._UpdateControls()
+
+
+ def GetValue( self ):
+
+ url_match = self._GetValue()
+
+ ( result, reason ) = url_match.Test( self._example_url.GetValue() )
+
+ if not result:
+
+ wx.MessageBox( 'Please enter an example url that matches the given rules!' )
+
+ raise HydrusExceptions.VetoException()
+
+
+ return url_match
+
+
+class EditWatcherOptions( ClientGUIScrolledPanels.EditPanel ):
+
+ def __init__( self, parent, watcher_options ):
+
+ ClientGUIScrolledPanels.EditPanel.__init__( self, parent )
+
+ help_button = ClientGUICommon.BetterBitmapButton( self, CC.GlobalBMPs.help, self._ShowHelp )
+ help_button.SetToolTipString( 'Show help regarding these checker options.' )
+
+ # add statictext or whatever that will update on any updates above to say 'given velocity of blah and last check at blah, next check in 5 mins'
+ # or indeed this could just take the seed cache and last check of the caller, if there is one
+ # this would be more useful to the user, to know 'right, on ok, it'll refresh in 30 mins'
+
+ self._intended_files_per_check = wx.SpinCtrl( self, min = 1, max = 1000 )
+
+ self._never_faster_than = ClientGUICommon.TimeDeltaCtrl( self, min = 30, days = True, hours = True, minutes = True, seconds = True )
+
+ self._never_slower_than = ClientGUICommon.TimeDeltaCtrl( self, min = 600, days = True, hours = True, minutes = True )
+
+ self._death_file_velocity = ClientGUICommon.VelocityCtrl( self, min_time_delta = 60, days = True, hours = True, minutes = True, per_phrase = 'in', unit = 'files' )
+
+ #
+
+ ( intended_files_per_check, never_faster_than, never_slower_than, death_file_velocity ) = watcher_options.ToTuple()
+
+ self._intended_files_per_check.SetValue( intended_files_per_check )
+ self._never_faster_than.SetValue( never_faster_than )
+ self._never_slower_than.SetValue( never_slower_than )
+ self._death_file_velocity.SetValue( death_file_velocity )
+
+ #
+
+ rows = []
+
+ rows.append( ( 'intended new files per check: ', self._intended_files_per_check ) )
+ rows.append( ( 'stop checking if new files found falls below: ', self._death_file_velocity ) )
+ rows.append( ( 'never check faster than once per: ', self._never_faster_than ) )
+ rows.append( ( 'never check slower than once per: ', self._never_slower_than ) )
+
+ gridbox = ClientGUICommon.WrapInGrid( self, rows )
+
+ vbox = wx.BoxSizer( wx.VERTICAL )
+
+ help_hbox = wx.BoxSizer( wx.HORIZONTAL )
+
+ st = ClientGUICommon.BetterStaticText( self, 'help for this panel -->' )
+
+ st.SetForegroundColour( wx.Colour( 0, 0, 255 ) )
+
+ help_hbox.AddF( st, CC.FLAGS_VCENTER )
+ help_hbox.AddF( help_button, CC.FLAGS_VCENTER )
+
+ vbox.AddF( help_hbox, CC.FLAGS_LONE_BUTTON )
+ vbox.AddF( gridbox, CC.FLAGS_EXPAND_SIZER_PERPENDICULAR )
+
+ self.SetSizer( vbox )
+
+
+ def _ShowHelp( self ):
+
+ help = 'PROTIP: Do not change anything here unless you understand what it means!'
+ help += os.linesep * 2
+ help += 'After its initialisation check, the checker times future checks so that it will probably find the same specified number of new files each time. When files are being posted frequently, it will check more often. When things are slow, it will slow down as well.'
+ help += os.linesep * 2
+ help += 'For instance, if it were set to try for 5 new files with every check, and at the last check it knew that the last 24 hours had produced 10 new files, it would check again 12 hours later. When that check was done and any new files found, it would then recalculate and repeat the process.'
+ help += os.linesep * 2
+ help += 'If the \'file velocity\' drops below a certain amount, the checker considers the source of files dead and will stop checking. If it falls into this state but you think there might have been a rush of new files, hit the \'check now\' button in an attempt to revive the checker. If there are new files, it will start checking again until they drop off once more.'
+
+ wx.MessageBox( help )
+
+
+ def GetValue( self ):
+
+ intended_files_per_check = self._intended_files_per_check.GetValue()
+ never_faster_than = self._never_faster_than.GetValue()
+ never_slower_than = self._never_slower_than.GetValue()
+ death_file_velocity = self._death_file_velocity.GetValue()
+
+ return ClientData.WatcherOptions( intended_files_per_check, never_faster_than, never_slower_than, death_file_velocity )
+
+
diff --git a/include/ClientGUIScrolledPanelsManagement.py b/include/ClientGUIScrolledPanelsManagement.py
index d3685335..a77dc20a 100644
--- a/include/ClientGUIScrolledPanelsManagement.py
+++ b/include/ClientGUIScrolledPanelsManagement.py
@@ -1865,11 +1865,9 @@ class ManageOptionsPanel( ClientGUIScrolledPanels.ManagePanel ):
thread_checker = ClientGUICommon.StaticBox( self, 'thread checker' )
- self._thread_times_to_check = wx.SpinCtrl( thread_checker, min = 0, max = 65536 )
- self._thread_times_to_check.SetToolTipString( 'how many times the thread checker will check' )
+ watcher_options = self._new_options.GetDefaultThreadWatcherOptions()
- self._thread_check_period = ClientGUICommon.TimeDeltaButton( thread_checker, min = 30, days = True, hours = True, minutes = True, seconds = True )
- self._thread_check_period.SetToolTipString( 'how long the checker will wait between checks' )
+ self._thread_watcher_options = ClientGUIScrolledPanelsEdit.EditWatcherOptions( thread_checker, watcher_options )
#
@@ -1877,12 +1875,6 @@ class ManageOptionsPanel( ClientGUIScrolledPanels.ManagePanel ):
self._gallery_file_limit.SetValue( HC.options[ 'gallery_file_limit' ] )
- ( times_to_check, check_period ) = HC.options[ 'thread_checker_timings' ]
-
- self._thread_times_to_check.SetValue( times_to_check )
-
- self._thread_check_period.SetValue( check_period )
-
#
rows = []
@@ -1899,14 +1891,7 @@ class ManageOptionsPanel( ClientGUIScrolledPanels.ManagePanel ):
#
- rows = []
-
- rows.append( ( 'default number of times to check: ', self._thread_times_to_check ) )
- rows.append( ( 'default wait between checks: ', self._thread_check_period ) )
-
- gridbox = ClientGUICommon.WrapInGrid( thread_checker, rows )
-
- thread_checker.AddF( gridbox, CC.FLAGS_EXPAND_SIZER_BOTH_WAYS )
+ thread_checker.AddF( self._thread_watcher_options, CC.FLAGS_EXPAND_PERPENDICULAR )
#
@@ -1923,7 +1908,8 @@ class ManageOptionsPanel( ClientGUIScrolledPanels.ManagePanel ):
self._new_options.SetBoolean( 'verify_regular_https', self._verify_regular_https.GetValue() )
HC.options[ 'gallery_file_limit' ] = self._gallery_file_limit.GetValue()
- HC.options[ 'thread_checker_timings' ] = ( self._thread_times_to_check.GetValue(), self._thread_check_period.GetValue() )
+
+ self._new_options.SetDefaultThreadWatcherOptions( self._thread_watcher_options.GetValue() )
@@ -6244,6 +6230,72 @@ class ManageURLsPanel( ClientGUIScrolledPanels.ManagePanel ):
+class ManageURLMatchesPanel( ClientGUIScrolledPanels.ManagePanel ):
+
+ def __init__( self, parent ):
+
+ ClientGUIScrolledPanels.ManagePanel.__init__( self, parent )
+
+ self._url_matches = ClientGUIListCtrl.BetterListCtrl( self, 'manage_url_matches', 15, 30, [ ( 'name', 20 ), ( 'example url', -1 ) ], self._ConvertURLMatchToListCtrlTuples, delete_key_callback = self._Delete, activation_callback = self._Edit )
+
+ # add, edit, delete buttons
+ # it would be nice to wrap this up in a panel rather than writing it out manually
+
+ #
+
+ # load them from the db, populate the listctrl
+
+ self._original_names = set()
+ # set self._original_names
+
+ #
+
+ vbox = wx.BoxSizer( wx.VERTICAL )
+
+ vbox.AddF( self._url_matches, CC.FLAGS_EXPAND_BOTH_WAYS )
+
+ self.SetSizer( vbox )
+
+
+ def _Add( self ):
+
+ pass
+
+
+ def _ConvertURLMatchToListCtrlTuples( self, url_match ):
+
+ name = url_match.GetName()
+ example_url = url_match.GetExampleURL()
+
+ display_tuple = ( name, example_url )
+ sort_tuple = display_tuple
+
+ return ( display_tuple, sort_tuple )
+
+
+ def _Delete( self ):
+
+ pass
+
+
+ def _Edit( self ):
+
+ pass
+
+
+ def CommitChanges( self ):
+
+ datas = self._url_matches.GetData()
+
+ datas_names = { data.GetName() for data in datas }
+
+ to_delete = [ name for name in self._original_names if name not in datas_names ]
+
+ # save datas to db
+
+ # delete names from db
+
+
class RepairFileSystemPanel( ClientGUIScrolledPanels.ManagePanel ):
def __init__( self, parent, missing_locations ):
@@ -6409,3 +6461,4 @@ class RepairFileSystemPanel( ClientGUIScrolledPanels.ManagePanel ):
HG.client_controller.WriteSynchronous( 'repair_client_files', correct_rows )
+
diff --git a/include/ClientGUISeedCache.py b/include/ClientGUISeedCache.py
index 62f2ea52..780f64a3 100644
--- a/include/ClientGUISeedCache.py
+++ b/include/ClientGUISeedCache.py
@@ -24,7 +24,7 @@ class EditSeedCachePanel( ClientGUIScrolledPanels.EditPanel ):
# add index control row here, hide it if needed and hook into showing/hiding and postsizechangedevent on seed add/remove
- columns = [ ( '#', 3 ), ( 'source', -1 ), ( 'status', 12 ), ( 'added', 20 ), ( 'last modified', 20 ), ( 'note', 30 ) ]
+ columns = [ ( '#', 3 ), ( 'source', -1 ), ( 'status', 12 ), ( 'added', 23 ), ( 'last modified', 23 ), ( 'source time', 23 ), ( 'note', 20 ) ]
self._list_ctrl = ClientGUIListCtrl.BetterListCtrl( self, 'seed_cache', 30, 30, columns, self._ConvertSeedToListCtrlTuples )
@@ -59,16 +59,26 @@ class EditSeedCachePanel( ClientGUIScrolledPanels.EditPanel ):
sort_tuple = self._seed_cache.GetSeedInfo( seed )
- ( seed_index, seed, status, added_timestamp, last_modified_timestamp, note ) = sort_tuple
+ ( seed_index, seed, status, added_timestamp, last_modified_timestamp, source_timestamp, note ) = sort_tuple
pretty_seed_index = HydrusData.ConvertIntToPrettyString( seed_index )
pretty_seed = HydrusData.ToUnicode( seed )
pretty_status = CC.status_string_lookup[ status ]
pretty_added = HydrusData.ConvertTimestampToPrettyAgo( added_timestamp ) + ' ago'
pretty_modified = HydrusData.ConvertTimestampToPrettyAgo( last_modified_timestamp ) + ' ago'
+
+ if source_timestamp is None:
+
+ pretty_source_time = 'unknown'
+
+ else:
+
+ pretty_source_time = HydrusData.ConvertTimestampToHumanPrettyTime( source_timestamp )
+
+
pretty_note = note.split( os.linesep )[0]
- display_tuple = ( pretty_seed_index, pretty_seed, pretty_status, pretty_added, pretty_modified, pretty_note )
+ display_tuple = ( pretty_seed_index, pretty_seed, pretty_status, pretty_added, pretty_modified, pretty_source_time, pretty_note )
return ( display_tuple, sort_tuple )
@@ -79,7 +89,7 @@ class EditSeedCachePanel( ClientGUIScrolledPanels.EditPanel ):
for seed in self._list_ctrl.GetData( only_selected = True ):
- ( seed_index, seed, status, added_timestamp, last_modified_timestamp, note ) = self._seed_cache.GetSeedInfo( seed )
+ ( seed_index, seed, status, added_timestamp, last_modified_timestamp, source_timestamp, note ) = self._seed_cache.GetSeedInfo( seed )
if note != '':
diff --git a/include/ClientImporting.py b/include/ClientImporting.py
index fc60efa3..dbc66750 100644
--- a/include/ClientImporting.py
+++ b/include/ClientImporting.py
@@ -1171,6 +1171,22 @@ class HDDImport( HydrusSerialisable.SerialisableBase ):
self._paths_cache.AddSeeds( paths )
+ for path in paths:
+
+ try:
+
+ s = os.stat( path )
+
+ source_time = min( s.st_mtime, s.st_ctime )
+
+ self._paths_cache.UpdateSeedSourceTime( path, source_time )
+
+ except:
+
+ pass
+
+
+
self._file_import_options = file_import_options
self._paths_to_tags = paths_to_tags
@@ -2438,7 +2454,7 @@ HydrusSerialisable.SERIALISABLE_TYPES_TO_OBJECT_TYPES[ HydrusSerialisable.SERIAL
class SeedCache( HydrusSerialisable.SerialisableBase ):
SERIALISABLE_TYPE = HydrusSerialisable.SERIALISABLE_TYPE_SEED_CACHE
- SERIALISABLE_VERSION = 5
+ SERIALISABLE_VERSION = 6
def __init__( self ):
@@ -2496,20 +2512,6 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
self._dirty = False
- def _GetSeedTuple( self, seed ):
-
- seed_index = self._seeds_to_indices[ seed ]
-
- seed_info = self._seeds_to_info[ seed ]
-
- status = seed_info[ 'status' ]
- added_timestamp = seed_info[ 'added_timestamp' ]
- last_modified_timestamp = seed_info[ 'last_modified_timestamp' ]
- note = seed_info[ 'note' ]
-
- return ( seed_index, seed, status, added_timestamp, last_modified_timestamp, note )
-
-
def _GetSerialisableInfo( self ):
with self._lock:
@@ -2527,6 +2529,20 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
+ def _GetSourceTimestamp( self, seed ):
+
+ seed_info = self._seeds_to_info[ seed ]
+
+ source_timestamp = seed_info[ 'source_timestamp' ]
+
+ if source_timestamp is None:
+
+ source_timestamp = seed_info[ 'added_timestamp' ] # decent fallback compromise
+
+
+ return source_timestamp
+
+
def _InitialiseFromSerialisableInfo( self, serialisable_info ):
with self._lock:
@@ -2680,6 +2696,20 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
return ( 5, new_serialisable_info )
+ if version == 5:
+
+ new_serialisable_info = []
+
+ for ( seed, seed_info ) in old_serialisable_info:
+
+ seed_info[ 'source_timestamp' ] = None
+
+ new_serialisable_info.append( ( seed, seed_info ) )
+
+
+ return ( 6, new_serialisable_info )
+
+
def AddSeeds( self, seeds ):
@@ -2719,6 +2749,7 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
seed_info[ 'status' ] = CC.STATUS_UNKNOWN
seed_info[ 'added_timestamp' ] = now
seed_info[ 'last_modified_timestamp' ] = now
+ seed_info[ 'source_timestamp' ] = None
seed_info[ 'note' ] = ''
self._seeds_to_info[ seed ] = seed_info
@@ -2774,6 +2805,16 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
HG.client_controller.pub( 'seed_cache_seeds_updated', self._seed_cache_key, ( seed, ) )
+ def GetEarliestTimestamp( self ):
+
+ with self._lock:
+
+ earliest_timestamp = min( ( self._GetSourceTimestamp( seed ) for seed in self._seeds_ordered ) )
+
+
+ return earliest_timestamp
+
+
def GetNextSeed( self, status ):
with self._lock:
@@ -2792,6 +2833,26 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
return None
+ def GetNumNewFilesSince( self, since ):
+
+ num_files = 0
+
+ with self._lock:
+
+ for seed in self._seeds_ordered:
+
+ source_timestamp = self._GetSourceTimestamp( seed )
+
+ if source_timestamp > since:
+
+ num_files += 1
+
+
+
+
+ return num_files
+
+
def GetSeedCacheKey( self ):
return self._seed_cache_key
@@ -2855,7 +2916,17 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
with self._lock:
- return self._GetSeedTuple( seed )
+ seed_index = self._seeds_to_indices[ seed ]
+
+ seed_info = self._seeds_to_info[ seed ]
+
+ status = seed_info[ 'status' ]
+ added_timestamp = seed_info[ 'added_timestamp' ]
+ last_modified_timestamp = seed_info[ 'last_modified_timestamp' ]
+ source_timestamp = seed_info[ 'source_timestamp' ]
+ note = seed_info[ 'note' ]
+
+ return ( seed_index, seed, status, added_timestamp, last_modified_timestamp, source_timestamp, note )
@@ -2948,6 +3019,18 @@ class SeedCache( HydrusSerialisable.SerialisableBase ):
HG.client_controller.pub( 'seed_cache_seeds_updated', self._seed_cache_key, seeds_to_delete )
+ def UpdateSeedSourceTime( self, seed, source_timestamp ):
+
+ # this is ugly--this should all be moved to the seed when it becomes a cleverer object, rather than jimmying it through the cache
+
+ with self._lock:
+
+ seed_info = self._seeds_to_info[ seed ]
+
+ seed_info[ 'source_timestamp' ] = source_timestamp
+
+
+
def UpdateSeedStatus( self, seed, status, note = '', exception = None ):
with self._lock:
@@ -3892,7 +3975,7 @@ HydrusSerialisable.SERIALISABLE_TYPES_TO_OBJECT_TYPES[ HydrusSerialisable.SERIAL
class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
SERIALISABLE_TYPE = HydrusSerialisable.SERIALISABLE_TYPE_THREAD_WATCHER_IMPORT
- SERIALISABLE_VERSION = 1
+ SERIALISABLE_VERSION = 2
MIN_CHECK_PERIOD = 30
@@ -3906,17 +3989,16 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
tag_import_options = new_options.GetDefaultTagImportOptions( ClientDownloading.GalleryIdentifier( HC.SITE_TYPE_THREAD_WATCHER ) )
- ( times_to_check, check_period ) = HC.options[ 'thread_checker_timings' ]
-
self._thread_url = ''
self._urls_cache = SeedCache()
self._urls_to_filenames = {}
self._urls_to_md5_base64 = {}
+ self._watcher_options = new_options.GetDefaultThreadWatcherOptions()
self._file_import_options = file_import_options
self._tag_import_options = tag_import_options
- self._times_to_check = times_to_check
- self._check_period = check_period
- self._last_time_checked = 0
+ self._last_check_time = 0
+
+ self._next_check_time = None
self._download_control_file_set = None
self._download_control_file_clear = None
@@ -3924,10 +4006,12 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
self._download_control_thread_clear = None
self._check_now = False
- self._paused = False
+ self._files_paused = False
+ self._thread_paused = False
- self._watcher_status = ''
+ self._file_velocity_status = ''
self._current_action = ''
+ self._watcher_status = ''
self._thread_key = HydrusData.GenerateKey()
@@ -3937,24 +4021,210 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
self._new_thread_event = threading.Event()
+ def _CheckThread( self, page_key ):
+
+ error_occurred = False
+ watcher_status_should_stick = True
+
+ with self._lock:
+
+ self._watcher_status = 'checking thread'
+
+
+ try:
+
+ json_url = ClientDownloading.GetImageboardThreadJSONURL( self._thread_url )
+
+ network_job = ClientNetworking.NetworkJobThreadWatcher( self._thread_key, 'GET', json_url )
+
+ network_job.OverrideBandwidth()
+
+ HG.client_controller.network_engine.AddJob( network_job )
+
+ with self._lock:
+
+ if self._download_control_thread_set is not None:
+
+ wx.CallAfter( self._download_control_thread_set, network_job )
+
+
+
+ try:
+
+ network_job.WaitUntilDone()
+
+ finally:
+
+ if self._download_control_thread_clear is not None:
+
+ wx.CallAfter( self._download_control_thread_clear )
+
+
+
+ raw_json = network_job.GetContent()
+
+ file_infos = ClientDownloading.ParseImageboardFileURLsFromJSON( self._thread_url, raw_json )
+
+ new_urls = []
+ new_urls_set = set()
+
+ file_urls_to_source_timestamps = {}
+
+ for ( file_url, file_md5_base64, file_original_filename, source_timestamp ) in file_infos:
+
+ if not self._urls_cache.HasSeed( file_url ) and not file_url in new_urls_set:
+
+ new_urls.append( file_url )
+ new_urls_set.add( file_url )
+
+ self._urls_to_filenames[ file_url ] = file_original_filename
+
+ if file_md5_base64 is not None:
+
+ self._urls_to_md5_base64[ file_url ] = file_md5_base64
+
+
+ file_urls_to_source_timestamps[ file_url ] = source_timestamp
+
+
+
+ self._urls_cache.AddSeeds( new_urls )
+
+ for ( file_url, source_timestamp ) in file_urls_to_source_timestamps.items():
+
+ self._urls_cache.UpdateSeedSourceTime( file_url, source_timestamp )
+
+
+ num_new = len( new_urls )
+
+ watcher_status = 'thread checked OK - ' + HydrusData.ConvertIntToPrettyString( num_new ) + ' new urls'
+ watcher_status_should_stick = False
+
+ if num_new > 0:
+
+ self._new_files_event.set()
+
+
+ except HydrusExceptions.NotFoundException:
+
+ error_occurred = True
+
+ watcher_status = 'thread 404'
+
+ except Exception as e:
+
+ error_occurred = True
+
+ watcher_status = HydrusData.ToUnicode( e )
+
+ HydrusData.PrintException( e )
+
+
+ with self._lock:
+
+ if self._check_now:
+
+ self._check_now = False
+
+
+ self._watcher_status = watcher_status
+
+ self._last_check_time = HydrusData.GetNow()
+
+ self._UpdateFileVelocityStatus()
+
+ self._UpdateNextCheckTime()
+
+ if error_occurred:
+
+ self._thread_paused = True
+
+
+
+ if error_occurred:
+
+ time.sleep( 5 )
+
+
+ if not watcher_status_should_stick:
+
+ time.sleep( 5 )
+
+ with self._lock:
+
+ self._watcher_status = ''
+
+
+
+
def _GetSerialisableInfo( self ):
serialisable_url_cache = self._urls_cache.GetSerialisableTuple()
+ serialisable_watcher_options = self._watcher_options.GetSerialisableTuple()
serialisable_file_options = self._file_import_options.GetSerialisableTuple()
serialisable_tag_options = self._tag_import_options.GetSerialisableTuple()
- return ( self._thread_url, serialisable_url_cache, self._urls_to_filenames, self._urls_to_md5_base64, serialisable_file_options, serialisable_tag_options, self._times_to_check, self._check_period, self._last_time_checked, self._paused )
+ return ( self._thread_url, serialisable_url_cache, self._urls_to_filenames, self._urls_to_md5_base64, serialisable_watcher_options, serialisable_file_options, serialisable_tag_options, self._last_check_time, self._files_paused, self._thread_paused )
+
+
+ def _HasThread( self ):
+
+ return self._thread_url != ''
def _InitialiseFromSerialisableInfo( self, serialisable_info ):
- ( self._thread_url, serialisable_url_cache, self._urls_to_filenames, self._urls_to_md5_base64, serialisable_file_options, serialisable_tag_options, self._times_to_check, self._check_period, self._last_time_checked, self._paused ) = serialisable_info
+ ( self._thread_url, serialisable_url_cache, self._urls_to_filenames, self._urls_to_md5_base64, serialisable_watcher_options, serialisable_file_options, serialisable_tag_options, self._last_check_time, self._files_paused, self._thread_paused ) = serialisable_info
self._urls_cache = HydrusSerialisable.CreateFromSerialisableTuple( serialisable_url_cache )
+ self._watcher_options = HydrusSerialisable.CreateFromSerialisableTuple( serialisable_watcher_options )
self._file_import_options = HydrusSerialisable.CreateFromSerialisableTuple( serialisable_file_options )
self._tag_import_options = HydrusSerialisable.CreateFromSerialisableTuple( serialisable_tag_options )
+ def _UpdateFileVelocityStatus( self ):
+
+ self._file_velocity_status = self._watcher_options.GetPrettyCurrentVelocity( self._urls_cache, self._last_check_time )
+
+
+ def _UpdateNextCheckTime( self ):
+
+ if self._check_now:
+
+ self._next_check_time = self._last_check_time + self.MIN_CHECK_PERIOD
+
+ else:
+
+ if self._watcher_options.IsDead( self._urls_cache, self._last_check_time ):
+
+ self._watcher_status = 'thread is dead'
+
+ self._thread_paused = True
+
+
+ self._next_check_time = self._watcher_options.GetNextCheckTime( self._urls_cache, self._last_check_time )
+
+
+
+ def _UpdateSerialisableInfo( self, version, old_serialisable_info ):
+
+ if version == 1:
+
+ ( thread_url, serialisable_url_cache, urls_to_filenames, urls_to_md5_base64, serialisable_file_options, serialisable_tag_options, times_to_check, check_period, last_check_time, paused ) = old_serialisable_info
+
+ watcher_options = ClientData.WatcherOptions( intended_files_per_check = 8, never_faster_than = 300, never_slower_than = 86400, death_file_velocity = ( 1, 86400 ) )
+
+ serialisable_watcher_options = watcher_options.GetSerialisableTuple()
+
+ files_paused = paused
+ thread_paused = paused
+
+ new_serialisable_info = ( thread_url, serialisable_url_cache, urls_to_filenames, urls_to_md5_base64, serialisable_watcher_options, serialisable_file_options, serialisable_tag_options, last_check_time, files_paused, thread_paused )
+
+ return ( 2, new_serialisable_info )
+
+
+
def _WorkOnFiles( self, page_key ):
file_url = self._urls_cache.GetNextSeed( CC.STATUS_UNKNOWN )
@@ -4139,168 +4409,11 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
return True
- def _WorkOnThread( self, page_key ):
-
- error_occurred = False
-
- with self._lock:
-
- p1 = self._check_now and HydrusData.TimeHasPassed( self._last_time_checked + self.MIN_CHECK_PERIOD )
- p2 = self._times_to_check > 0 and HydrusData.TimeHasPassed( self._last_time_checked + self._check_period )
-
-
- if p1 or p2:
-
- with self._lock:
-
- self._watcher_status = 'checking thread'
-
-
- try:
-
- json_url = ClientDownloading.GetImageboardThreadJSONURL( self._thread_url )
-
- network_job = ClientNetworking.NetworkJobThreadWatcher( self._thread_key, 'GET', json_url )
-
- network_job.OverrideBandwidth()
-
- HG.client_controller.network_engine.AddJob( network_job )
-
- with self._lock:
-
- if self._download_control_thread_set is not None:
-
- wx.CallAfter( self._download_control_thread_set, network_job )
-
-
-
- try:
-
- network_job.WaitUntilDone()
-
- finally:
-
- if self._download_control_thread_clear is not None:
-
- wx.CallAfter( self._download_control_thread_clear )
-
-
-
- raw_json = network_job.GetContent()
-
- file_infos = ClientDownloading.ParseImageboardFileURLsFromJSON( self._thread_url, raw_json )
-
- new_urls = []
- new_urls_set = set()
-
- for ( file_url, file_md5_base64, file_original_filename ) in file_infos:
-
- if not self._urls_cache.HasSeed( file_url ) and not file_url in new_urls_set:
-
- new_urls.append( file_url )
- new_urls_set.add( file_url )
-
- self._urls_to_filenames[ file_url ] = file_original_filename
-
- if file_md5_base64 is not None:
-
- self._urls_to_md5_base64[ file_url ] = file_md5_base64
-
-
-
-
- self._urls_cache.AddSeeds( new_urls )
-
- num_new = len( new_urls )
-
- watcher_status = 'thread checked OK - ' + HydrusData.ConvertIntToPrettyString( num_new ) + ' new urls'
-
- if num_new > 0:
-
- self._new_files_event.set()
-
-
- except HydrusExceptions.NotFoundException:
-
- error_occurred = True
-
- watcher_status = 'thread 404'
-
- with self._lock:
-
- for i in range( self._times_to_check ):
-
- HG.client_controller.pub( 'decrement_times_to_check', page_key )
-
-
- self._times_to_check = 0
-
-
- except Exception as e:
-
- error_occurred = True
-
- watcher_status = HydrusData.ToUnicode( e )
-
- HydrusData.PrintException( e )
-
-
- with self._lock:
-
- if self._check_now:
-
- self._check_now = False
-
- else:
-
- self._times_to_check -= 1
-
- HG.client_controller.pub( 'decrement_times_to_check', page_key )
-
-
- self._last_time_checked = HydrusData.GetNow()
-
-
- else:
-
- with self._lock:
-
- if self._check_now or self._times_to_check > 0:
-
- if self._check_now:
-
- delay = self.MIN_CHECK_PERIOD
-
- else:
-
- delay = self._check_period
-
-
- watcher_status = 'checking again ' + HydrusData.ConvertTimestampToPrettyPending( self._last_time_checked + delay )
-
- else:
-
- watcher_status = 'checking finished'
-
-
-
-
- with self._lock:
-
- self._watcher_status = watcher_status
-
-
- if error_occurred:
-
- time.sleep( 5 )
-
-
-
def _THREADWorkOnFiles( self, page_key ):
while not ( HG.view_shutdown or HG.client_controller.PageCompletelyDestroyed( page_key ) ):
- if self._paused or HG.client_controller.PageClosedButNotDestroyed( page_key ):
+ if self._files_paused or HG.client_controller.PageClosedButNotDestroyed( page_key ):
self._new_files_event.wait( 5 )
@@ -4344,7 +4457,15 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
while not ( HG.view_shutdown or HG.client_controller.PageCompletelyDestroyed( page_key ) ):
- if self._paused or HG.client_controller.PageClosedButNotDestroyed( page_key ):
+ with self._lock:
+
+ able_to_check = self._HasThread() and not self._thread_paused
+ check_due = HydrusData.TimeHasPassed( self._next_check_time )
+
+ time_to_check = able_to_check and check_due
+
+
+ if not time_to_check or HG.client_controller.PageClosedButNotDestroyed( page_key ):
self._new_thread_event.wait( 5 )
@@ -4352,14 +4473,9 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
try:
- if self._thread_url != '':
-
- self._WorkOnThread( page_key )
-
+ self._CheckThread( page_key )
- time.sleep( 1 )
-
- # 1s wait here regardless so the text countdown status updates every sec
+ time.sleep( 5 )
HG.client_controller.WaitUntilPubSubsEmpty()
@@ -4381,6 +4497,10 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
self._check_now = True
+ self._thread_paused = False
+
+ self._UpdateNextCheckTime()
+
self._new_thread_event.set()
@@ -4391,7 +4511,7 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
finished = not self._urls_cache.WorkToDo()
- return not finished and not self._paused
+ return not finished and not self._files_paused
@@ -4404,7 +4524,7 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
with self._lock:
- return ( self._thread_url, self._file_import_options, self._tag_import_options, self._times_to_check, self._check_period )
+ return ( self._thread_url, self._file_import_options, self._tag_import_options )
@@ -4412,9 +4532,15 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
with self._lock:
- finished = not self._urls_cache.WorkToDo()
+ return ( self._current_action, self._files_paused, self._file_velocity_status, self._next_check_time, self._watcher_status, self._check_now, self._thread_paused )
- return ( self._current_action, self._watcher_status, self._check_now, self._paused )
+
+
+ def GetWatcherOptions( self ):
+
+ with self._lock:
+
+ return self._watcher_options
@@ -4422,26 +4548,34 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
with self._lock:
- return self._thread_url != ''
+ return self._HasThread()
- def PausePlay( self ):
+ def PausePlayFiles( self ):
with self._lock:
- self._paused = not self._paused
+ self._files_paused = not self._files_paused
self._new_files_event.set()
- self._new_thread_event.set()
- def SetCheckPeriod( self, check_period ):
+ def PausePlayThread( self ):
with self._lock:
- self._check_period = max( self.MIN_CHECK_PERIOD, check_period )
+ if self._thread_paused and self._watcher_options.IsDead( self._urls_cache, self._last_check_time ):
+
+ self._watcher_status = 'thread is dead--hit check now to try to revive'
+
+ else:
+
+ self._thread_paused = not self._thread_paused
+
+ self._new_thread_event.set()
+
@@ -4489,11 +4623,17 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
- def SetTimesToCheck( self, times_to_check ):
+ def SetWatcherOptions( self, watcher_options ):
with self._lock:
- self._times_to_check = times_to_check
+ self._watcher_options = watcher_options
+
+ self._thread_paused = False
+
+ self._UpdateNextCheckTime()
+
+ self._UpdateFileVelocityStatus()
self._new_thread_event.set()
@@ -4501,6 +4641,10 @@ class ThreadWatcherImport( HydrusSerialisable.SerialisableBase ):
def Start( self, page_key ):
+ self._UpdateNextCheckTime()
+
+ self._UpdateFileVelocityStatus()
+
HG.client_controller.CallToThreadLongRunning( self._THREADWorkOnThread, page_key )
HG.client_controller.CallToThreadLongRunning( self._THREADWorkOnFiles, page_key )
diff --git a/include/ClientNetworking.py b/include/ClientNetworking.py
index 1d1276d4..b9bb6b7b 100644
--- a/include/ClientNetworking.py
+++ b/include/ClientNetworking.py
@@ -2472,7 +2472,15 @@ class NetworkJob( object ):
def WaitUntilDone( self ):
- self._is_done_event.wait()
+ while True:
+
+ self._is_done_event.wait( 5 )
+
+ if self.IsDone():
+
+ break
+
+
with self._lock:
diff --git a/include/ClientNetworkingDomain.py b/include/ClientNetworkingDomain.py
index 8b3c8d81..358c4614 100644
--- a/include/ClientNetworkingDomain.py
+++ b/include/ClientNetworkingDomain.py
@@ -1,27 +1,63 @@
import ClientConstants as CC
+import ClientParsing
import HydrusConstants as HC
import HydrusGlobals as HG
import HydrusData
import HydrusExceptions
+import HydrusSerialisable
import threading
import urlparse
# this should do network_contexts->user-agent as well, with some kind of approval system in place
+ # approval needs a new queue in the network engine. this will eventually test downloader validity and so on. failable at that stage
# user-agent info should be exportable/importable on the ui as well
-# eventually extend this to do urlmatch->downloader, I think.
+# eventually extend this to do urlmatch->downloader_key, I think.
# hence we'll be able to do some kind of dnd_url->new thread watcher page
+# hide urls on media viewer based on domain
+# decide whether we want to add this to the dirtyobjects loop, and it which case, if anything is appropriate to store in the db separately
+ # hence making this a serialisableobject itself.
class DomainManager( object ):
def __init__( self, controller ):
self._controller = controller
self._domains_to_url_matches = {}
+ self._network_contexts_to_custom_headers = {} # user-agent here
+ # ( header_key, header_value, approved, approval_reason )
+ # approved is True for user created, None for imported and defaults
self._lock = threading.Lock()
self._Initialise()
+ def _GetURLMatch( self, url ):
+
+ domain = 'blah' # get top urldomain
+
+ if domain in self._domains_to_url_matches:
+
+ url_matches = self._domains_to_url_matches[ domain ]
+
+ # it would be nice to somehow sort these based on descending complexity
+ # maybe by length of example url
+ # in this way, url matches can have overlapping desmaign
+ # e.g. 'post url' vs 'post url, manga subpage'
+
+ for url_match in url_matches:
+
+ ( result_bool, result_reason ) = url_match.Test( url )
+
+ if result_bool:
+
+ return url_match
+
+
+
+
+ return None
+
+
def _Initialise( self ):
self._domains_to_url_matches = {}
@@ -32,28 +68,70 @@ class DomainManager( object ):
pass
- def GetURLMatch( self, url ):
+ def CanApprove( self, network_contexts ):
+
+ # if user selected false for any approval, return false
+ # network job presumably throws a ValidationError at this point, which will cause any larger queue to pause.
+
+ pass
+
+
+ def DoApproval( self, network_contexts ):
+
+ # if false on validity check, it presents the user with a yes/no popup with the approval_reason and waits
+
+ pass
+
+
+ def GetCustomHeaders( self, network_contexts ):
with self._lock:
- domain = 'blah' # get top urldomain
+ pass
- if domain in self._domains_to_url_matches:
+ # good order is global = least powerful, which I _think_ is how these come.
+
+
+
+ def GetDownloader( self, url ):
+
+ with self._lock:
+
+ # this might be better as getdownloaderkey, but we'll see how it shakes out
+ # might also be worth being a getifhasdownloader
+
+ # match the url to a url_match, then lookup that in a 'this downloader can handle this url_match type' dict that we'll manage
+
+ pass
+
+
+
+ def NeedsApproval( self, network_contexts ):
+
+ # this is called by the network engine in the new approval queue
+ # if a job needs approval, it goes to a single step like the login one and waits, possibly failing.
+ # checks for 'approved is None' on all ncs
+
+ pass
+
+
+ def NormaliseURL( self, url ):
+
+ # call this before an entry into a seed cache or the db
+ # use it in the dialog to review mass db-level changes
+
+ with self._lock:
+
+ url_match = self._GetURLMatch( url )
+
+ if url_match is None:
- url_matches = self._domains_to_url_matches[ domain ]
-
- for url_match in url_matches:
-
- ( result_bool, result_reason ) = url_match.Test( url )
-
- if result_bool:
-
- return url_match
-
-
+ return url
- return None
+ normalised_url = url_match.Normalise( url )
+
+ return normalised_url
@@ -69,21 +147,41 @@ class DomainManager( object ):
# __hash__ for name? not sure
# maybe all serialisable should return __hash__ of ( type, name ) if they don't already
# that might lead to problems elsewhere, so careful
-class URLMatch( object ):
+class URLMatch( HydrusSerialisable.SerialisableBaseNamed ):
- def __init__( self ):
+ SERIALISABLE_TYPE = HydrusSerialisable.SERIALISABLE_TYPE_URL_MATCH
+ SERIALISABLE_VERSION = 1
+
+ def __init__( self, name, preferred_scheme = 'https', netloc = 'hostname.com', subdomain_is_important = False, path_components = None, parameters = None, example_url = 'https://hostname.com' ):
+
+ if path_components is None:
+
+ path_components = HydrusSerialisable.SerialisableList()
+
+ path_components.append( ClientParsing.StringMatch( match_type = ClientParsing.STRING_MATCH_FIXED, match_value = 'post', example_string = 'post' ) )
+ path_components.append( ClientParsing.StringMatch( match_type = ClientParsing.STRING_MATCH_FIXED, match_value = 'page.php', example_string = 'page.php' ) )
+
+
+ if parameters is None:
+
+ parameters = HydrusSerialisable.SerialisableDictionary()
+
+ parameters[ 's' ] = ClientParsing.StringMatch( match_type = ClientParsing.STRING_MATCH_FIXED, match_value = 'view', example_string = 'view' )
+ parameters[ 'id' ] = ClientParsing.StringMatch( match_type = ClientParsing.STRING_MATCH_FLEXIBLE, match_value = ClientParsing.NUMERIC, example_string = '123456' )
+
# an edit dialog panel for this that has example url and testing of current values
# a parent panel or something that lists all current urls in the db that match and how they will be clipped, is this ok? kind of thing.
- self._preferred_scheme = None
- self._netloc = None
- self._subdomain_is_important = False
- self._path_components = None
- self._parameters = {}
+ HydrusSerialisable.SerialisableBaseNamed.__init__( self, name )
- self._name = None
- self._example_url = None
+ self._preferred_scheme = 'https'
+ self._netloc = 'hostname.com'
+ self._subdomain_is_important = False
+ self._path_components = HydrusSerialisable.SerialisableList()
+ self._parameters = HydrusSerialisable.SerialisableDictionary()
+
+ self._example_url = 'https://hostname.com/post/page.php?id=123456&s=view'
def _ClipNetLoc( self, netloc ):
@@ -105,6 +203,22 @@ class URLMatch( object ):
return netloc
+ def _GetSerialisableInfo( self ):
+
+ serialisable_path_components = self._path_components.GetSerialisableTuple()
+ serialisable_parameters = self._parameters.GetSerialisableTuple()
+
+ return ( self._preferred_scheme, self._netloc, self._subdomain_is_important, serialisable_path_components, serialisable_parameters, self._example_url )
+
+
+ def _InitialiseFromSerialisableInfo( self, serialisable_info ):
+
+ ( self._preferred_scheme, self._netloc, self._subdomain_is_important, serialisable_path_components, serialisable_parameters, self._example_url ) = serialisable_info
+
+ self._path_components = HydrusSerialisable.CreateFromSerialisableTuple( serialisable_path_components )
+ self._parameters = HydrusSerialisable.CreateFromSerialisableTuple( serialisable_parameters )
+
+
def _ClipPath( self, path ):
# /post/show/1326143/akunim-anthro-armband-armwear-clothed-clothing-fem
@@ -151,7 +265,7 @@ class URLMatch( object ):
return query
- def Clip( self, url ):
+ def Normalise( self, url ):
p = urlparse.urlparse( url )
@@ -225,3 +339,5 @@ class URLMatch( object ):
return ( True, 'good' )
+HydrusSerialisable.SERIALISABLE_TYPES_TO_OBJECT_TYPES[ HydrusSerialisable.SERIALISABLE_TYPE_URLS_IMPORT ] = URLMatch
+
diff --git a/include/ClientParsing.py b/include/ClientParsing.py
index 0cb38a27..6a5fdf5c 100644
--- a/include/ClientParsing.py
+++ b/include/ClientParsing.py
@@ -1026,19 +1026,35 @@ STRING_MATCH_ANY = 3
ALPHA = 0
ALPHANUMERIC = 1
NUMERIC = 2
-# make it serialisable as well
-class StringMatch( object ):
+
+class StringMatch( HydrusSerialisable.SerialisableBase ):
- def __init__( self, match_type = STRING_MATCH_FIXED, match_value = 'post' ):
+ SERIALISABLE_TYPE = HydrusSerialisable.SERIALISABLE_TYPE_STRING_MATCH
+ SERIALISABLE_VERSION = 1
+
+ def __init__( self, match_type = STRING_MATCH_FIXED, match_value = 'post', min_chars = None, max_chars = None, example_string = 'post' ):
+ HydrusSerialisable.SerialisableBase.__init__( self )
# make a gui control that accepts one of these. displays expected input on the right and colours red/green (and does isvalid) based on current input
# think about replacing the veto stuff above with this.
self._match_type = match_type
self._match_value = match_value
- self._min_chars = None
- self._max_chars = None
+ self._min_chars = min_chars
+ self._max_chars = max_chars
+
+ self._example_string = example_string
+
+
+ def _GetSerialisableInfo( self ):
+
+ return ( self._match_type, self._match_value, self._min_chars, self._max_chars, self._example_string )
+
+
+ def _InitialiseFromSerialisableInfo( self, serialisable_info ):
+
+ ( self._match_type, self._match_value, self._min_chars, self._max_chars, self._example_string ) = serialisable_info
def SetMaxChars( self, max_chars ):
@@ -1059,12 +1075,12 @@ class StringMatch( object ):
if self._min_chars is not None and text_len < self._min_chars:
- return ( False, presentation_text + ' had too few characters' )
+ return ( False, presentation_text + ' had fewer than ' + HydrusData.ConvertIntToPrettyString( self._min_chars ) + ' characters' )
if self._max_chars is not None and text_len > self._max_chars:
- return ( False, presentation_text + ' had too many characters' )
+ return ( False, presentation_text + ' had more than ' + HydrusData.ConvertIntToPrettyString( self._min_chars ) + ' characters' )
if self._match_type == STRING_MATCH_FIXED:
@@ -1105,7 +1121,7 @@ class StringMatch( object ):
fail_reason = ' did not match "' + r + '"'
- if re.search( r, text ) is None:
+ if re.search( r, text, flags = re.UNICODE ) is None:
return ( False, presentation_text + fail_reason )
@@ -1120,3 +1136,64 @@ class StringMatch( object ):
+ def ToUnicode( self ):
+
+ result = ''
+
+ if self._min_chars is not None:
+
+ if self._max_chars is not None:
+
+ result += 'between ' + HydrusData.ToUnicode( self._min_chars ) + ' and ' + HydrusData.ToUnicode( self._max_chars ) + ' '
+
+ else:
+
+ result += 'at least ' + HydrusData.ToUnicode( self._min_chars ) + ' '
+
+
+ else:
+
+ result += 'at most ' + HydrusData.ToUnicode( self._max_chars ) + ' '
+
+
+ show_example = True
+
+ if self._match_type == STRING_MATCH_ANY:
+
+ result += 'characters'
+
+ elif self._match_type == STRING_MATCH_FIXED:
+
+ result += self._match_value
+
+ show_example = False
+
+ elif self._match_type == STRING_MATCH_FLEXIBLE:
+
+ if self._match_value == ALPHA:
+
+ result += 'alphabetical characters'
+
+ elif self._match_value == ALPHANUMERIC:
+
+ result += 'alphanumeric characters'
+
+ elif self._match_value == NUMERIC:
+
+ result += 'numeric characters'
+
+
+ elif self._match_type == STRING_MATCH_REGEX:
+
+ result += 'characters, matching regex "' + self._match_value + '"'
+
+
+ if show_example:
+
+ result += ', such as ' + self._example_string
+
+
+ return result
+
+
+HydrusSerialisable.SERIALISABLE_TYPES_TO_OBJECT_TYPES[ HydrusSerialisable.SERIALISABLE_TYPE_STRING_MATCH ] = StringMatch
diff --git a/include/HydrusConstants.py b/include/HydrusConstants.py
index 7b4c29d6..afc9123c 100755
--- a/include/HydrusConstants.py
+++ b/include/HydrusConstants.py
@@ -49,7 +49,7 @@ options = {}
# Misc
NETWORK_VERSION = 18
-SOFTWARE_VERSION = 274
+SOFTWARE_VERSION = 275
UNSCALED_THUMBNAIL_DIMENSIONS = ( 200, 200 )
diff --git a/include/HydrusData.py b/include/HydrusData.py
index d14d7b8f..72c48514 100644
--- a/include/HydrusData.py
+++ b/include/HydrusData.py
@@ -230,7 +230,7 @@ def ConvertTimeDeltaToPrettyString( seconds ):
return 'per month'
- if seconds > 60:
+ if seconds >= 60:
seconds = int( seconds )
@@ -277,7 +277,14 @@ def ConvertTimeDeltaToPrettyString( seconds ):
minutes = seconds / 60
seconds = seconds % 60
- result = '%d' % minutes + ' minutes'
+ if minutes == 1:
+
+ result = '1 minute'
+
+ else:
+
+ result = '%d' % minutes + ' minutes'
+
if seconds > 0:
diff --git a/include/HydrusSerialisable.py b/include/HydrusSerialisable.py
index ced00c25..ea464c3c 100644
--- a/include/HydrusSerialisable.py
+++ b/include/HydrusSerialisable.py
@@ -52,6 +52,9 @@ SERIALISABLE_TYPE_NETWORK_SESSION_MANAGER = 46
SERIALISABLE_TYPE_NETWORK_CONTEXT = 47
SERIALISABLE_TYPE_NETWORK_LOGIN_MANAGER = 48
SERIALISABLE_TYPE_MEDIA_SORT = 49
+SERIALISABLE_TYPE_URL_MATCH = 50
+SERIALISABLE_TYPE_STRING_MATCH = 51
+SERIALISABLE_TYPE_WATCHER_OPTIONS = 52
SERIALISABLE_TYPES_TO_OBJECT_TYPES = {}
diff --git a/include/TestClientData.py b/include/TestClientData.py
new file mode 100644
index 00000000..faf5bdd3
--- /dev/null
+++ b/include/TestClientData.py
@@ -0,0 +1,151 @@
+import ClientData
+import ClientImporting
+import os
+import unittest
+
+class TestData( unittest.TestCase ):
+
+ def test_watcher_options( self ):
+
+ regular_watcher_options = ClientData.WatcherOptions( intended_files_per_check = 5, never_faster_than = 30, never_slower_than = 86400, death_file_velocity = ( 1, 86400 ) )
+ fast_watcher_options = ClientData.WatcherOptions( intended_files_per_check = 2, never_faster_than = 30, never_slower_than = 86400, death_file_velocity = ( 1, 86400 ) )
+ slow_watcher_options = ClientData.WatcherOptions( intended_files_per_check = 10, never_faster_than = 30, never_slower_than = 86400, death_file_velocity = ( 1, 86400 ) )
+ callous_watcher_options = ClientData.WatcherOptions( intended_files_per_check = 5, never_faster_than = 30, never_slower_than = 86400, death_file_velocity = ( 1, 60 ) )
+
+ empty_seed_cache = ClientImporting.SeedCache()
+
+ seed_cache = ClientImporting.SeedCache()
+
+ last_check_time = 10000000
+
+ one_day_before = last_check_time - 86400
+
+ for i in range( 50 ):
+
+ seed = os.urandom( 16 ).encode( 'hex' )
+
+ seed_cache.AddSeeds( ( seed, ) )
+
+ seed_cache.UpdateSeedSourceTime( seed, one_day_before - 10 )
+
+
+ for i in range( 50 ):
+
+ seed = os.urandom( 16 ).encode( 'hex' )
+
+ seed_cache.AddSeeds( ( seed, ) )
+
+ seed_cache.UpdateSeedSourceTime( seed, one_day_before + 10 )
+
+
+ bare_seed_cache = ClientImporting.SeedCache()
+
+ bare_seed_cache.AddSeeds( ( 'early', ) )
+ bare_seed_cache.AddSeeds( ( 'in_time_delta', ) )
+
+ bare_seed_cache.UpdateSeedSourceTime( 'early', one_day_before - 10 )
+ bare_seed_cache.UpdateSeedSourceTime( 'in_time_delta', one_day_before + 10 )
+
+ busy_seed_cache = ClientImporting.SeedCache()
+
+ busy_seed_cache.AddSeeds( ( 'early', ) )
+
+ busy_seed_cache.UpdateSeedSourceTime( 'early', one_day_before - 10 )
+
+ for i in range( 8640 ):
+
+ seed = os.urandom( 16 ).encode( 'hex' )
+
+ busy_seed_cache.AddSeeds( ( seed, ) )
+
+ busy_seed_cache.UpdateSeedSourceTime( seed, one_day_before + ( ( i + 1 ) * 10 ) - 1 )
+
+
+ new_thread_seed_cache = ClientImporting.SeedCache()
+
+ for i in range( 10 ):
+
+ seed = os.urandom( 16 ).encode( 'hex' )
+
+ new_thread_seed_cache.AddSeeds( ( seed, ) )
+
+ new_thread_seed_cache.UpdateSeedSourceTime( seed, last_check_time - 600 )
+
+
+ # empty
+ # should say ok if last_check_time is 0, so it can initialise
+ # otherwise sperg out safely
+
+ self.assertFalse( regular_watcher_options.IsDead( empty_seed_cache, 0 ) )
+
+ self.assertEqual( regular_watcher_options.GetPrettyCurrentVelocity( empty_seed_cache, 0 ), 'no files yet' )
+
+ self.assertEqual( regular_watcher_options.GetNextCheckTime( empty_seed_cache, 0 ), 0 )
+
+ self.assertTrue( regular_watcher_options.IsDead( empty_seed_cache, last_check_time ) )
+
+ self.assertEqual( regular_watcher_options.GetPrettyCurrentVelocity( empty_seed_cache, last_check_time ), 'no files, unable to determine velocity' )
+
+ # regular
+ # current velocity should be 50 files per day for the day ones and 0 files per min for the callous minute one
+
+ self.assertFalse( regular_watcher_options.IsDead( seed_cache, last_check_time ) )
+ self.assertFalse( fast_watcher_options.IsDead( seed_cache, last_check_time ) )
+ self.assertFalse( slow_watcher_options.IsDead( seed_cache, last_check_time ) )
+ self.assertTrue( callous_watcher_options.IsDead( seed_cache, last_check_time ) )
+
+ self.assertEqual( regular_watcher_options.GetPrettyCurrentVelocity( seed_cache, last_check_time ), u'at last check, found 50 files in previous 1 day' )
+ self.assertEqual( fast_watcher_options.GetPrettyCurrentVelocity( seed_cache, last_check_time ), u'at last check, found 50 files in previous 1 day' )
+ self.assertEqual( slow_watcher_options.GetPrettyCurrentVelocity( seed_cache, last_check_time ), u'at last check, found 50 files in previous 1 day' )
+ self.assertEqual( callous_watcher_options.GetPrettyCurrentVelocity( seed_cache, last_check_time ), u'at last check, found 0 files in previous 1 minute' )
+
+ self.assertEqual( regular_watcher_options.GetNextCheckTime( seed_cache, last_check_time ), last_check_time + 8640 )
+ self.assertEqual( fast_watcher_options.GetNextCheckTime( seed_cache, last_check_time ), last_check_time + 3456 )
+ self.assertEqual( slow_watcher_options.GetNextCheckTime( seed_cache, last_check_time ), last_check_time + 17280 )
+
+ # bare
+ # 1 files per day
+
+ self.assertFalse( regular_watcher_options.IsDead( bare_seed_cache, last_check_time ) )
+ self.assertTrue( callous_watcher_options.IsDead( bare_seed_cache, last_check_time ) )
+
+ self.assertEqual( regular_watcher_options.GetPrettyCurrentVelocity( bare_seed_cache, last_check_time ), u'at last check, found 1 files in previous 1 day' )
+
+ self.assertEqual( regular_watcher_options.GetNextCheckTime( bare_seed_cache, last_check_time ), last_check_time + 86400 )
+ self.assertEqual( fast_watcher_options.GetNextCheckTime( bare_seed_cache, last_check_time ), last_check_time + 86400 )
+ self.assertEqual( slow_watcher_options.GetNextCheckTime( bare_seed_cache, last_check_time ), last_check_time + 86400 )
+
+ # busy
+ # 8640 files per day, 6 files per minute
+
+ self.assertFalse( regular_watcher_options.IsDead( busy_seed_cache, last_check_time ) )
+ self.assertFalse( fast_watcher_options.IsDead( busy_seed_cache, last_check_time ) )
+ self.assertFalse( slow_watcher_options.IsDead( busy_seed_cache, last_check_time ) )
+ self.assertFalse( callous_watcher_options.IsDead( busy_seed_cache, last_check_time ) )
+
+ self.assertEqual( regular_watcher_options.GetPrettyCurrentVelocity( busy_seed_cache, last_check_time ), u'at last check, found 8,640 files in previous 1 day' )
+ self.assertEqual( callous_watcher_options.GetPrettyCurrentVelocity( busy_seed_cache, last_check_time ), u'at last check, found 6 files in previous 1 minute' )
+
+ self.assertEqual( regular_watcher_options.GetNextCheckTime( busy_seed_cache, last_check_time ), last_check_time + 50 )
+ self.assertEqual( fast_watcher_options.GetNextCheckTime( busy_seed_cache, last_check_time ), last_check_time + 30 )
+ self.assertEqual( slow_watcher_options.GetNextCheckTime( busy_seed_cache, last_check_time ), last_check_time + 100 )
+ self.assertEqual( callous_watcher_options.GetNextCheckTime( busy_seed_cache, last_check_time ), last_check_time + 50 )
+
+ # new thread
+ # only had files from ten mins ago, so timings are different
+
+ self.assertFalse( regular_watcher_options.IsDead( new_thread_seed_cache, last_check_time ) )
+ self.assertFalse( fast_watcher_options.IsDead( new_thread_seed_cache, last_check_time ) )
+ self.assertFalse( slow_watcher_options.IsDead( new_thread_seed_cache, last_check_time ) )
+ self.assertTrue( callous_watcher_options.IsDead( new_thread_seed_cache, last_check_time ) )
+
+ self.assertEqual( regular_watcher_options.GetPrettyCurrentVelocity( new_thread_seed_cache, last_check_time ), u'at last check, found 10 files in previous 10 minutes' )
+ self.assertEqual( fast_watcher_options.GetPrettyCurrentVelocity( new_thread_seed_cache, last_check_time ), u'at last check, found 10 files in previous 10 minutes' )
+ self.assertEqual( slow_watcher_options.GetPrettyCurrentVelocity( new_thread_seed_cache, last_check_time ), u'at last check, found 10 files in previous 10 minutes' )
+ self.assertEqual( callous_watcher_options.GetPrettyCurrentVelocity( new_thread_seed_cache, last_check_time ), u'at last check, found 0 files in previous 1 minute' )
+
+ self.assertEqual( regular_watcher_options.GetNextCheckTime( new_thread_seed_cache, last_check_time ), last_check_time + 300 )
+ self.assertEqual( fast_watcher_options.GetNextCheckTime( new_thread_seed_cache, last_check_time ), last_check_time + 120 )
+ self.assertEqual( slow_watcher_options.GetNextCheckTime( new_thread_seed_cache, last_check_time ), last_check_time + 600 )
+
+
diff --git a/test.py b/test.py
index eec5f456..a0ae1cf2 100644
--- a/test.py
+++ b/test.py
@@ -17,6 +17,7 @@ from include import HydrusTags
from include import HydrusThreading
from include import TestClientConstants
from include import TestClientDaemons
+from include import TestClientData
from include import TestClientListBoxes
from include import TestClientNetworking
from include import TestConstants
@@ -312,6 +313,7 @@ class Controller( object ):
if run_all or only_run == 'data':
suites.append( unittest.TestLoader().loadTestsFromModule( TestClientConstants ) )
+ suites.append( unittest.TestLoader().loadTestsFromModule( TestClientData ) )
suites.append( unittest.TestLoader().loadTestsFromModule( TestFunctions ) )
suites.append( unittest.TestLoader().loadTestsFromModule( TestHydrusSerialisable ) )
suites.append( unittest.TestLoader().loadTestsFromModule( TestHydrusSessions ) )