From 726ff534a4bdf9e79b2955d46b40f8d98cb858a5 Mon Sep 17 00:00:00 2001 From: John Dalbey Date: Tue, 20 Jan 2026 17:16:09 -0800 Subject: [PATCH 1/4] Implement external file change detection feature and provide manual test cases. --- .../external-change-detection-manual-tests.md | 259 ++++++++++++++++++ usr/lib/sticky/common.py | 95 +++++++ usr/lib/sticky/sticky.py | 234 +++++++++++++++- 3 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 docs/testing/external-change-detection-manual-tests.md diff --git a/docs/testing/external-change-detection-manual-tests.md b/docs/testing/external-change-detection-manual-tests.md new file mode 100644 index 0000000..0431caf --- /dev/null +++ b/docs/testing/external-change-detection-manual-tests.md @@ -0,0 +1,259 @@ +## Feature: External file change detection + +### Manual Testing Scenarios + +Test 1a: External change with no unsaved changes +START application +CREATE a new note in color yellow with text "demo note" +EXTERNALLY modify notes.json, (e.g., from text editor) changing color to red. +VERIFY dialog appears: The notes have been changed on disk. Do you want to reload all notes? Reload/Cancel +CLICK "Reload" +VERIFY UI the note color is now red. + +Test 1b: Manager thumbnails refresh after reload +START application +OPEN notes manager +EXTERNALLY modify notes.json (change note text to "Thumbnail Test") +VERIFY External Change dialog appears +CLICK "Reload" +VERIFY manager window thumbnails are refreshed and show "Thumbnail Test" + +Test 2: App's own save doesn't trigger dialog +START application +MAKE an edit to a note +WAIT 3 seconds for auto-save +VERIFY no dialog appears (ignore_next_change flag works) + +Test 3: Backup files don't trigger dialog +START application +CHOOSE "Back up" from the 3-dot menu. +VERIFY backup-*.json is created +VERIFY no dialog appears (monitoring only notes.json) + +Test 4a: User cancels reload, doesn't lose external changes +START application +EXTERNALLY modify notes.json: change note color to green. +VERIFY dialog appears. +CLICK "Cancel". +VERIFY UI remains unchanged. +CHANGE note text to "Happy Days" +WAIT 3 seconds. +A dialog should appear: notes.json has been modified since you last opened it. If you save now, those external changes will be lost. Do you want to save anyway? Don't Save/Backup and Save/Save Anyway. +CLICK "Save Anyway" +VERIFY notes.json has note text "Happy Days" + +Test 4b: +Repeat 4a but choose "Don't Save". +Verify the UI is unchanged and the notes.json file is unchanged. + +Test 4c: Backup and Save feature +Repeat 4a but choose "Backup and Save" +VERIFY notes.json has note text "Happy Days" +VERIFY a backup file has been created without text "Happy Days" + +Test 5a: External change with unsaved changes (race condition) +START application +OPEN notes.json in external editor. Enter a note text of "Quick Fox" but don't save it yet. +MOVE the note, changing its position on the screen, (triggers 3-second save timer) +QUICKLY, within 3 seconds: save the EXTERNALLY modified notes.json. +VERIFY dialog appears with conflict message: The notes have been changed on disk, but you have unsaved changes. What would you like to do? Keep my changes/Reload from disk. +CLICK "Reload from disk" +Verify note has text "Quick Fox" and note is restored to original position. + +Test 5b: +Repeat 6a except CLICK "Keep my changes" +VERIFY a second dialog appears with file modified message: The notes.json file has been modified since you last opened it. If you save now, those external changes will be lost. Do you want to save anyway? Don't Save/Backup and Save/Save Anyway. +CLICK "Save Anyway" +VERIFY UI keeps edited version with text "Lazy Dog" +VERIFY external changes are overwritten ("Quick Fox" is deleted). + +Test 5c: +Repeat 5b except instead of 'Save Anyway' CLICK "Backup and Save" +VERIFY backup-*.json file is created containing "Quick Fox" +VERIFY notes.json contains the moved note position (user's changes) +VERIFY external edit "External Edit" is NOT in notes.json + +Test 5d: +Repeat 5b except instead of 'Save Anyway' CLICK "Don't Save" +VERIFY notes.json is UNCHANGED (still has "Quick Fox") +VERIFY UI still shows moved position (changes in memory) +MOVE note again, wait 3 seconds +VERIFY File Modified dialog appears again + +Test 6: Verify 'dirty flag' operation +OBSERVE last modified date on notes.json file. +START application +QUIT application +VERIFY last modified date on notes.json file is unchanged. + +OBSERVE last modified date on notes.json file. +START application +Change the color of a note and within 3 seconds, QUIT application. +VERIFY last modified date on notes.json file is changed to reflect the time of Quit. + +OBSERVE last modified date on notes.json file. +START application +Change the color of a note and wait 3 seconds for save operation. +VERIFY last modified date on notes.json file is changed to the time of the save. +QUIT application, wait 3 seconds. +VERIFY last modified date on notes.json file is unchanged. + +Test 7a: Confirmation dialog doesn't appear if notes are hidden, only when visible. +START application +VERIFY Tray icon is displayed (Preference > General > Tray icon) +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json, (e.g., from text editor) changing color to red. +VERIFY confirmation dialog does NOT appear. +Click the tray icon to show the notes. +VERIFY confirmation dialog appears. +CHOOSE "reload" in the dialog. +VERIFY external modification is reflected in the notes. + +Test 7b: Deferred dialog with after two modifications +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json, save, then make a second modification and save. +VERIFY confirmation dialog does NOT appear. +Click the tray icon to show the notes. +VERIFY a single confirmation dialog appears. +CHOOSE "reload" in the dialog. +VERIFY both external modifications are reflected in the notes. + +Test 7c: Deferred dialog with "Cancel" choice +START application +VERIFY Tray icon is displayed +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json (change color to purple) +VERIFY confirmation dialog does NOT appear. +Click the tray icon to show the notes. +VERIFY External Change dialog appears. +CLICK "Cancel" (keep current in-memory state) +CHANGE note text to "Fat cat"), wait 3 seconds for save +VERIFY File Modified dialog appears (because external changes still pending) +CLICK "Save Anyway" +VERIFY notes.json contains "Fat cat" and does NOT have purple color + +Test 8a: Creating notes through note manager. +START application +VERIFY Tray icon is displayed (Preference > General > Tray icon) +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json. +VERIFY confirmation dialog doesn't appear. +In notes manager, create a new note. Wait 3 seconds. +VERIFY File Modified dialog appears. + +Test 8b: Opening manager triggers deferred dialog +START application +VERIFY Tray icon is displayed +CLOSE notes manager (if open) +Click the tray icon to hide the notes +EXTERNALLY modify notes.json (change color to orange) +VERIFY confirmation dialog does NOT appear. +OPEN notes manager (right-click on tray icon) +VERIFY External Change dialog appears immediately (before manager window opens) +CLICK "Reload" +VERIFY notes have orange color +VERIFY manager window opens and shows updated thumbnails + +Test 9a: Verify focus is returned to notes after dialog closes (both dialogs). +START application +CREATE a new note in color yellow with text "demo note", wait 3 seconds +EXTERNALLY modify notes.json, changing color to red +VERIFY dialog appears with title "External Change Detected" and buttons "Cancel" / "Reload" +CLICK "Reload from Disk" +VERIFY the note color is now red +CLICK the tray icon +VERIFY the notes are hidden on the FIRST click (focus is properly restored after dialog) + +Test 9b: +Repeat 9a, except hide notes before external change. + +Test 9c: +Repeat 9a, except CLICK "Cancel" +EDIT the note text to "Groovy Note"), wait 3 seconds +VERIFY File Modified dialog appears +CLICK "Save Anyway" +CLICK the tray icon +VERIFY notes are hidden on the FIRST click (focus properly restored) + +Test 10: "Save anyway" cancels external pending changes +START application +VERIFY Tray icon is displayed (Preference > General > Tray icon) +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json. +VERIFY confirmation dialog doesn't appear. +In notes manager, create a new note. Wait 3 seconds. +VERIFY File Modified dialog appears. +CLICK "Save Anyway" (overwriting pending external changes) +CLICK tray icon (even though notes are already visible) +Verify no confirmation dialog is presented. + +Test 11: Quit with dirty changes and deferred external change +START application +VERIFY Tray icon is displayed +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json (add "Slick note") +VERIFY confirmation dialog does NOT appear. +Click the tray icon to show the notes. +VERIFY External Change dialog appears. +CLICK "Cancel" +EDIT a note (change text to "About to quit") +IMMEDIATELY (within 1-2 seconds, before save timer fires) QUIT application +VERIFY File Modified dialog appears before application quits +CLICK "Don't Save" +OBSERVE the application is terminated. +RESTART application +VERIFY notes now contain the external modification "Slick note", (not "About to quit") + +Test 12: "Backup and Save" clears pending external changes +START application +VERIFY Tray icon is displayed +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json (change color to cyan) +VERIFY confirmation dialog does NOT appear. +In notes manager, create a new note. Wait 3 seconds. +VERIFY File Modified dialog appears. +CLICK "Backup and Save" +VERIFY backup-*.json is created with cyan color +VERIFY notes.json contains the new note. +Click the tray icon to hide notes. +Click the tray icon to show notes again. +VERIFY no External Change dialog appears (pending external change was cleared) + +Test 13: Multiple external changes while dialog is open (notes visible) +START application +EXTERNALLY modify notes.json (change color to blue) +VERIFY External Change dialog appears +DO NOT respond to the dialog yet (leave it open) +EXTERNALLY modify notes.json again (change title to "HELLO") +CLICK "Reload" on the first dialog +VERIFY notes have blue color and title "HELLO" (both external changes applied) +OBSERVE a second External Change dialog appears immediately +CLICK "Reload" on the second dialog +VERIFY notes unchanged. +NOTE: Sequential dialogs are expected behavior due to GTK modal dialog blocking + +Test 14: First launch scenario +COPY notes.json to a backup location. +RENAME notes.json to notes.json.bak +START application (simulating first launch) +CREATE a new note +WAIT 3 seconds +VERIFY no false dialogs appear +VERIFY notes.json is created correctly +QUIT application. +RESTORE original notes.json (delete test file) + +Test 15: Obscure edge case anomaly +START application +VERIFY Tray icon is displayed (Preference > General > Tray icon) +Click the tray icon to hide the notes. +EXTERNALLY modify notes.json. +VERIFY confirmation dialog doesn't appear. +In notes manager, create a new note. Wait 3 seconds. +VERIFY File Modified dialog appears. +Click "Don't Save" +Hidden note reappears and notes.json is unchanged. +Make a second external change to the notes.json file. +Verify confirmation prompt does not appear. +(Because we haven't actually clicked the tray icon to make notes appear ... they showed up as a byproduct of other actions) + diff --git a/usr/lib/sticky/common.py b/usr/lib/sticky/common.py index b7599e4..d1e3e2a 100644 --- a/usr/lib/sticky/common.py +++ b/usr/lib/sticky/common.py @@ -36,6 +36,16 @@ def lists_changed(self): def saved(self): pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, return_type=bool, + accumulator=GObject.signal_accumulator_true_handled) + def external_change_detected(self): + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, return_type=bool, + accumulator=GObject.signal_accumulator_true_handled) + def file_modified_before_save(self): + pass + def __init__(self, settings, window): super(FileHandler, self).__init__() @@ -45,6 +55,14 @@ def __init__(self, settings, window): self.backup_timer_id = 0 self.notes_lists = {} + # File monitoring variables + self.monitor = None + self.ignore_next_change = False + self.had_pending_changes = False + self.file_mtime = None # Track file modification time for conflict detection + self.dirty = False # Track whether notes have been modified since last save/load + self.has_pending_external_change = False # Track deferred external changes while notes hidden + if os.path.exists(CONFIG_PATH): self.load_notes() @@ -52,12 +70,23 @@ def __init__(self, settings, window): self.settings.connect('changed::backup-interval', self.check_backup) self.check_backup() + # Setup file monitoring for external changes + self.setup_file_monitor() + def load_notes(self, *args): with open(CONFIG_PATH, 'r') as file: info = json.loads(file.read()) self.notes_lists = info + # Track the file modification time for conflict detection + actual_path = os.path.realpath(CONFIG_PATH) + if os.path.exists(actual_path): + self.file_mtime = os.path.getmtime(actual_path) + + # Clear dirty flag - we've just loaded from disk + self.dirty = False + def get_note_list(self, group_name): return self.notes_lists[group_name] @@ -66,6 +95,7 @@ def get_note_group_names(self): def update_note_list(self, notes_list, group_name): self.notes_lists[group_name] = notes_list + self.dirty = True self.queue_save() @@ -78,16 +108,43 @@ def queue_save(self): self.save_timer_id = GLib.timeout_add_seconds(SAVE_DELAY, self.save_note_list) def save_to_file(self, file_path): + # Mark that we're about to write, so we ignore the monitor event + self.ignore_next_change = True + with open(file_path, 'w+') as file: file.write(json.dumps(self.notes_lists, indent=4)) def save_note_list(self): self.save_timer_id = 0 + # Skip save if nothing has changed + if not self.dirty: + return + if not os.path.exists(CONFIG_DIR): os.makedirs(CONFIG_DIR) + # Check if file was modified since we last read it + actual_path = os.path.realpath(CONFIG_PATH) + if os.path.exists(actual_path) and self.file_mtime is not None: + current_mtime = os.path.getmtime(actual_path) + if current_mtime != self.file_mtime: + # File was modified externally since we loaded it + # Signal the app to ask user what to do + if self.emit('file-modified-before-save'): + # Signal handler returned True, meaning "don't save" + return + # Otherwise, handler returned False or wasn't connected, proceed with save + self.save_to_file(CONFIG_PATH) + + # Update the modification time after saving + if os.path.exists(actual_path): + self.file_mtime = os.path.getmtime(actual_path) + + # Clear dirty flag after successful save + self.dirty = False + self.emit('saved') def check_backup(self, *args): @@ -115,6 +172,40 @@ def check_backup(self, *args): else: self.backup_timer_id = GLib.timeout_add_seconds(next_backup - now, self.save_backup) + def setup_file_monitor(self): + """Setup file monitoring for external changes to notes.json""" + try: + # Resolve symlinks to monitor the actual file + actual_path = os.path.realpath(CONFIG_PATH) + file = Gio.File.new_for_path(actual_path) + self.monitor = file.monitor_file(Gio.FileMonitorFlags.NONE, None) + self.monitor.connect('changed', self.on_file_changed) + except Exception as e: + # If monitoring fails, log but don't crash the app + print(f"Warning: Could not setup file monitoring: {e}") + + def on_file_changed(self, monitor, file, other_file, event_type): + """Called when notes.json is modified externally""" + # Only respond to the final change event + if event_type != Gio.FileMonitorEvent.CHANGES_DONE_HINT: + return + + # Ignore changes from our own writes + if self.ignore_next_change: + self.ignore_next_change = False + return + + # Check if there are pending changes BEFORE canceling the timer + self.had_pending_changes = (self.save_timer_id > 0) + + # Cancel any pending save to prevent overwriting external changes + if self.save_timer_id > 0: + GLib.source_remove(self.save_timer_id) + self.save_timer_id = 0 + + # Notify the application of external changes + self.emit('external-change-detected') + def save_backup(self, *args): self.backup_timer_id = 0 @@ -248,6 +339,7 @@ def load_notes_from_path(self, path, window): # should really be added to load_notes() as well self.notes_lists = info + self.dirty = True # Content has changed self.save_note_list() self.emit('lists-changed') @@ -269,6 +361,7 @@ def new_group(self, group_name): return False self.notes_lists[group_name] = [] + self.dirty = True self.save_note_list() self.emit('lists-changed') @@ -282,12 +375,14 @@ def remove_group(self, group_name): if group_name not in self.notes_lists: raise ValueError('invalid group name %s' % group_name) del self.notes_lists[group_name] + self.dirty = True self.save_note_list() self.emit('lists-changed') def change_group_name(self, old_group, new_group): self.notes_lists[new_group] = self.notes_lists.pop(old_group) + self.dirty = True self.save_note_list() self.emit('group-name-changed', old_group, new_group) diff --git a/usr/lib/sticky/sticky.py b/usr/lib/sticky/sticky.py index 60b01b4..cc5efad 100755 --- a/usr/lib/sticky/sticky.py +++ b/usr/lib/sticky/sticky.py @@ -2,7 +2,9 @@ import json import os +import shutil import sys +import time import gi gi.require_version('Gdk', '3.0') @@ -15,7 +17,7 @@ from note_buffer import NoteBuffer from manager import NotesManager -from common import FileHandler, HoverBox, prompt, confirm +from common import FileHandler, HoverBox, prompt, confirm, CONFIG_DIR, CONFIG_PATH from util import gnote_to_internal_format import gettext @@ -770,6 +772,8 @@ def do_activate(self): self.group_update_id = self.file_handler.connect('group-changed', self.on_group_changed) self.file_handler.connect('group-name-changed', self.on_group_name_changed) self.file_handler.connect('saved', self.on_save) + self.file_handler.connect('external-change-detected', self.on_external_change_detected) + self.file_handler.connect('file-modified-before-save', self.on_file_modified_before_save) if self.settings.get_boolean('show-in-tray'): self.create_status_icon() @@ -973,6 +977,11 @@ def activate_notes(self, time): self.hide_notes() return + # Check for pending external changes before showing notes + if self.file_handler.has_pending_external_change: + self.file_handler.has_pending_external_change = False + self.show_deferred_external_change_dialog() + self.dummy_window.present_with_time(time) for note in self.notes: @@ -1142,6 +1151,224 @@ def on_group_name_changed(self, f, old_name, new_name): def on_save(self, *args): self.get_dbus_connection().emit_signal(None, DBUS_PATH, APPLICATION_ID, 'NotesChanged', None) + def on_external_change_detected(self, file_handler): + """Handle external changes to notes.json file""" + # If notes are hidden, defer the dialog until they're shown + if self.notes_hidden: + self.file_handler.has_pending_external_change = True + return + + # Check if there were unsaved changes (saved before timer was cancelled) + has_unsaved = file_handler.had_pending_changes + + # Customize dialog message based on whether there are unsaved changes + if has_unsaved: + title = _("External Changes Detected") + message = _("The notes have been changed on disk, but you have unsaved changes.\nWhat would you like to do?") + reload_text = _("Reload from disk") + cancel_text = _("Keep my changes") + else: + title = _("External Changes Detected") + message = _("The notes have been changed on disk. Do you want to reload all notes?") + reload_text = _("Reload") + cancel_text = _("Cancel") + + # Create custom dialog with proper button labels + dialog = Gtk.Dialog(title=title, transient_for=self.dummy_window, + window_position=Gtk.WindowPosition.CENTER_ON_PARENT) + dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL) + dialog.add_button(reload_text, Gtk.ResponseType.OK) + dialog.set_default_response(Gtk.ResponseType.OK) + + # Apply orange background to make dialog stand out on desktop + css_provider = Gtk.CssProvider() + css_provider.load_from_data(b""" + dialog { + background-color: #ffa939; + } + dialog label { + color: #000000; + font-weight: bold; + } + """) + style_context = dialog.get_style_context() + style_context.add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + content = dialog.get_content_area() + content.props.margin_left = 20 + content.props.margin_right = 20 + + content.pack_start(Gtk.Label(label=message), False, False, 10) + content.show_all() + + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.OK: + # User chose to reload from disk + self.file_handler.load_notes() + self.load_notes() + + # Notify manager window to refresh thumbnails (if it's open) + if self.manager: + self.manager.generate_previews() + + # Note: We do NOT emit DBus 'NotesChanged' signal here + # because we're responding to external changes, not making them + else: + # User chose to keep their changes + # If they had unsaved changes, save them now to overwrite external changes + if has_unsaved: + self.file_handler.save_note_list() + + # Restore focus to notes after dialog closes + # (dialog steals focus, we need to return it so tray icon works correctly) + for note in self.notes: + note.restore(Gtk.get_current_event_time()) + + # Reset flag for next time + self.file_handler.had_pending_changes = False + + def show_deferred_external_change_dialog(self): + """Handle deferred external changes when notes become visible""" + # Check if there are unsaved changes (via dirty bit) + has_unsaved = self.file_handler.dirty + + # Customize dialog message based on whether there are unsaved changes + if has_unsaved: + title = _("External Changes Detected") + message = _("The notes have been changed on disk, but you have unsaved changes.\nWhat would you like to do?") + reload_text = _("Reload from disk") + cancel_text = _("Keep my changes") + else: + title = _("External Changes Detected") + message = _("The notes have been changed on disk. Do you want to reload all notes?") + reload_text = _("Reload") + cancel_text = _("Cancel") + + # Create custom dialog with proper button labels + dialog = Gtk.Dialog(title=title, transient_for=self.dummy_window, + window_position=Gtk.WindowPosition.CENTER_ON_PARENT) + dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL) + dialog.add_button(reload_text, Gtk.ResponseType.OK) + dialog.set_default_response(Gtk.ResponseType.OK) + + # Apply orange background to make dialog stand out on desktop + css_provider = Gtk.CssProvider() + css_provider.load_from_data(b""" + dialog { + background-color: #ffa939; + } + dialog label { + color: #000000; + font-weight: bold; + } + """) + style_context = dialog.get_style_context() + style_context.add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + content = dialog.get_content_area() + content.props.margin_left = 20 + content.props.margin_right = 20 + + content.pack_start(Gtk.Label(label=message), False, False, 10) + content.show_all() + + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.OK: + # User chose to reload from disk + self.file_handler.load_notes() + self.load_notes() + + # Notify manager window to refresh thumbnails (if it's open) + if self.manager: + self.manager.generate_previews() + + # Note: We do NOT emit DBus 'NotesChanged' signal here + # because we're responding to external changes, not making them + else: + # User chose to keep their changes + # If they had unsaved changes, save them now to overwrite external changes + if has_unsaved: + self.file_handler.save_note_list() + + # Restore focus to notes after dialog closes + # (dialog steals focus, we need to return it so tray icon works correctly) + for note in self.notes: + note.restore(Gtk.get_current_event_time()) + + def on_file_modified_before_save(self, file_handler): + """Handle file modified since last read - warn before overwriting""" + title = _("File Modified") + message = _("The notes.json file has been modified since you last opened it.\nIf you save now, those external changes will be lost.\n\nDo you want to save anyway?") + save_text = _("Save Anyway") + backup_save_text = _("Backup and Save") + cancel_text = _("Don't Save") + + # Create custom dialog with proper button labels + dialog = Gtk.Dialog(title=title, transient_for=self.dummy_window, + window_position=Gtk.WindowPosition.CENTER_ON_PARENT) + dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL) + dialog.add_button(backup_save_text, Gtk.ResponseType.APPLY) # Middle option + dialog.add_button(save_text, Gtk.ResponseType.OK) + dialog.set_default_response(Gtk.ResponseType.CANCEL) # Default to safe option + + # Apply orange background to make dialog stand out on desktop + css_provider = Gtk.CssProvider() + css_provider.load_from_data(b""" + dialog { + background-color: #ffa939; + } + dialog label { + color: #000000; + font-weight: bold; + } + """) + style_context = dialog.get_style_context() + style_context.add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + content = dialog.get_content_area() + content.props.margin_left = 20 + content.props.margin_right = 20 + + content.pack_start(Gtk.Label(label=message), False, False, 10) + content.show_all() + + response = dialog.run() + dialog.destroy() + + # Handle response and perform actions + if response == Gtk.ResponseType.APPLY: + # User chose "Backup and Save" - backup current file then proceed with save + try: + # Resolve symlinks to get the actual file path + actual_path = os.path.realpath(CONFIG_PATH) + if os.path.exists(actual_path): + # Create backup with timestamp + timestamp = int(time.time()) + backup_path = os.path.join(CONFIG_DIR, 'backup-%d.json' % timestamp) + shutil.copy2(actual_path, backup_path) + print(f"External changes backed up to: {backup_path}") + except Exception as e: + print(f"Warning: Could not create backup: {e}") + # Continue with save anyway + + # Restore focus to notes after dialog closes + # (dialog steals focus, we need to return it so tray icon works correctly) + for note in self.notes: + note.restore(Gtk.get_current_event_time()) + + # Return appropriate value based on user choice + if response == Gtk.ResponseType.OK or response == Gtk.ResponseType.APPLY: + # User chose to save - clear any pending external change flag + # because we're about to overwrite those changes + self.file_handler.has_pending_external_change = False + return False # Proceed with save + else: + return True # Cancel save + def change_visible_note_group(self, group=None): default = self.settings.get_string('active-group') if group is None: @@ -1160,6 +1387,11 @@ def change_visible_note_group(self, group=None): self.load_notes() def open_manager(self, *args, time=0): + # Check for pending external changes before opening manager + if self.file_handler.has_pending_external_change: + self.file_handler.has_pending_external_change = False + self.show_deferred_external_change_dialog() + if self.manager: if time == 0: time = Gtk.get_current_event_time() From 559cfa813177563f3e89f8c9bcc003feef78eb38 Mon Sep 17 00:00:00 2001 From: John Dalbey Date: Wed, 21 Jan 2026 12:46:44 -0800 Subject: [PATCH 2/4] Add comments to variable declarations. --- usr/lib/sticky/common.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/usr/lib/sticky/common.py b/usr/lib/sticky/common.py index d1e3e2a..7f82726 100644 --- a/usr/lib/sticky/common.py +++ b/usr/lib/sticky/common.py @@ -56,9 +56,9 @@ def __init__(self, settings, window): self.notes_lists = {} # File monitoring variables - self.monitor = None - self.ignore_next_change = False - self.had_pending_changes = False + self.monitor = None # the GFileMonitor object used to watch for external changes + self.ignore_next_change = False # Flag to prevent the app from reacting to its own file writes + self.had_pending_changes = False # tracks active save timer (used in detecting race condition) self.file_mtime = None # Track file modification time for conflict detection self.dirty = False # Track whether notes have been modified since last save/load self.has_pending_external_change = False # Track deferred external changes while notes hidden @@ -178,7 +178,9 @@ def setup_file_monitor(self): # Resolve symlinks to monitor the actual file actual_path = os.path.realpath(CONFIG_PATH) file = Gio.File.new_for_path(actual_path) + # Create the monitor object self.monitor = file.monitor_file(Gio.FileMonitorFlags.NONE, None) + # Establish the callback self.monitor.connect('changed', self.on_file_changed) except Exception as e: # If monitoring fails, log but don't crash the app From 8819d2df32bd805839def16b7c8c42a2f2e021a9 Mon Sep 17 00:00:00 2001 From: John Dalbey Date: Sat, 24 Jan 2026 09:21:17 -0800 Subject: [PATCH 3/4] Add a new preference setting "Automatically reload external changes". It is False by default. However, we add a checkbox to the confirmation prompt "Always reload". So the confirmation prompt appears by default, but at any time the user can check the checkbox and it will stop prompting and do auto-reload. If the user later wants the prompt back they can go to Preferences and turn off the setting. --- .../external-change-detection-manual-tests.md | 24 +++ usr/lib/sticky/common.py | 6 +- usr/lib/sticky/sticky.py | 159 +++++++++--------- .../glib-2.0/schemas/org.x.sticky.gschema.xml | 8 + 4 files changed, 113 insertions(+), 84 deletions(-) diff --git a/docs/testing/external-change-detection-manual-tests.md b/docs/testing/external-change-detection-manual-tests.md index 0431caf..d3a73c6 100644 --- a/docs/testing/external-change-detection-manual-tests.md +++ b/docs/testing/external-change-detection-manual-tests.md @@ -257,3 +257,27 @@ Make a second external change to the notes.json file. Verify confirmation prompt does not appear. (Because we haven't actually clicked the tray icon to make notes appear ... they showed up as a byproduct of other actions) +Test 16: Always auto-reload Checkbox +START application +VERIFY Preference > General > Auto reload is OFF. +CREATE a new note in color yellow with text "demo note" +EXTERNALLY modify notes.json, changing color to red. +VERIFY confirmation prompt appears with checkbox unchecked "Always reload (unless turned off in Preferences)" +CLICK the checkbox to turn on this preference. +CLICK "Reload" +VERIFY UI the note color is now red. +VERIFY Preference setting is now ON. +EXTERNALLY modify notes.json, changing color to blue. +VERIFY note color changes to blue WITHOUT confirmation prompt. +HIDE the notes, modify notes.json to change color to green. +REVEAL the notes, VERIFY the note is green (no prompt displayed) +MODIFY Preference setting to OFF. +EXTERNALLY modify notes.json, changing color to teal. +VERIFY confirmation prompt appears with checkbox unchecked "Always reload (unless turned off in Preferences)" +CLICK "Reload" +VERIFY UI the note color is now teal. + +Test 17: Auto-reload doesn't happen in race condition. +VERIFY auto-reload preference is ON. +Repeat test 5 for race condition and verify confirmation prompt appears even though auto-reload is on. + diff --git a/usr/lib/sticky/common.py b/usr/lib/sticky/common.py index 7f82726..7fbcca2 100644 --- a/usr/lib/sticky/common.py +++ b/usr/lib/sticky/common.py @@ -56,9 +56,9 @@ def __init__(self, settings, window): self.notes_lists = {} # File monitoring variables - self.monitor = None # the GFileMonitor object used to watch for external changes - self.ignore_next_change = False # Flag to prevent the app from reacting to its own file writes - self.had_pending_changes = False # tracks active save timer (used in detecting race condition) + self.monitor = None # the GFileMonitor object used to watch for external changes + self.ignore_next_change = False # Flag to prevent the app from reacting to its own file writes + self.had_pending_changes = False # tracks active save timer (used in detecting race condition) self.file_mtime = None # Track file modification time for conflict detection self.dirty = False # Track whether notes have been modified since last save/load self.has_pending_external_change = False # Track deferred external changes while notes hidden diff --git a/usr/lib/sticky/sticky.py b/usr/lib/sticky/sticky.py index cc5efad..8ec7ef5 100755 --- a/usr/lib/sticky/sticky.py +++ b/usr/lib/sticky/sticky.py @@ -612,6 +612,14 @@ def __init__(self, app): page.pack_start(GSettingsSwitch(_("Show in taskbar"), SCHEMA, 'show-in-taskbar'), False, False, 0) page.pack_start(GSettingsSwitch(_("Tray icon"), SCHEMA, 'show-in-tray'), False, False, 0) page.pack_start(GSettingsSwitch(_("Show the main window automatically"), SCHEMA, 'show-manager', dep_key=SCHEMA+'/show-in-tray'), False, False, 0) + + # Get description from schema for tooltip + settings = Gio.Settings.new(SCHEMA) + schema = settings.get_property('settings-schema') + key = schema.get_key('auto-reload-external-changes') + auto_reload_tooltip = key.get_description() + + page.pack_start(GSettingsSwitch(_("Automatically reload external changes"), SCHEMA, 'auto-reload-external-changes', tooltip=auto_reload_tooltip), False, False, 0) self.add_page(page, 'general', _("General")) # note related settings @@ -1151,16 +1159,24 @@ def on_group_name_changed(self, f, old_name, new_name): def on_save(self, *args): self.get_dbus_connection().emit_signal(None, DBUS_PATH, APPLICATION_ID, 'NotesChanged', None) - def on_external_change_detected(self, file_handler): - """Handle external changes to notes.json file""" - # If notes are hidden, defer the dialog until they're shown - if self.notes_hidden: - self.file_handler.has_pending_external_change = True - return + def _reload_notes_from_external_change(self): + """Reload notes from disk after external change detected""" + self.file_handler.load_notes() + self.load_notes() - # Check if there were unsaved changes (saved before timer was cancelled) - has_unsaved = file_handler.had_pending_changes + # Notify manager window to refresh thumbnails (if it's open) + if self.manager: + self.manager.generate_previews() + + def _show_external_change_dialog(self, has_unsaved): + """Show external change dialog and handle user response + + Args: + has_unsaved: Whether there are unsaved local changes + Returns: + True if user chose to reload, False otherwise + """ # Customize dialog message based on whether there are unsaved changes if has_unsaved: title = _("External Changes Detected") @@ -1199,22 +1215,29 @@ def on_external_change_detected(self, file_handler): content.props.margin_right = 20 content.pack_start(Gtk.Label(label=message), False, False, 10) + + # Add checkbox to enable auto-reload (only when preference is currently disabled and scenario is safe) + auto_reload_checkbox = None + if not has_unsaved and not self.settings.get_boolean('auto-reload-external-changes'): + auto_reload_checkbox = Gtk.CheckButton.new_with_label(_("Always reload (unless turned off in Preferences)")) + content.pack_start(auto_reload_checkbox, False, False, 5) + content.show_all() response = dialog.run() - dialog.destroy() - if response == Gtk.ResponseType.OK: - # User chose to reload from disk - self.file_handler.load_notes() - self.load_notes() + # If user checked the box, enable auto-reload in settings + if auto_reload_checkbox is not None and auto_reload_checkbox.get_active(): + self.settings.set_boolean('auto-reload-external-changes', True) + + dialog.destroy() - # Notify manager window to refresh thumbnails (if it's open) - if self.manager: - self.manager.generate_previews() + # Handle response + user_chose_reload = (response == Gtk.ResponseType.OK) - # Note: We do NOT emit DBus 'NotesChanged' signal here - # because we're responding to external changes, not making them + if user_chose_reload: + # User chose to reload from disk + self._reload_notes_from_external_change() else: # User chose to keep their changes # If they had unsaved changes, save them now to overwrite external changes @@ -1226,78 +1249,52 @@ def on_external_change_detected(self, file_handler): for note in self.notes: note.restore(Gtk.get_current_event_time()) - # Reset flag for next time - self.file_handler.had_pending_changes = False - - def show_deferred_external_change_dialog(self): - """Handle deferred external changes when notes become visible""" - # Check if there are unsaved changes (via dirty bit) - has_unsaved = self.file_handler.dirty - - # Customize dialog message based on whether there are unsaved changes - if has_unsaved: - title = _("External Changes Detected") - message = _("The notes have been changed on disk, but you have unsaved changes.\nWhat would you like to do?") - reload_text = _("Reload from disk") - cancel_text = _("Keep my changes") - else: - title = _("External Changes Detected") - message = _("The notes have been changed on disk. Do you want to reload all notes?") - reload_text = _("Reload") - cancel_text = _("Cancel") + return user_chose_reload - # Create custom dialog with proper button labels - dialog = Gtk.Dialog(title=title, transient_for=self.dummy_window, - window_position=Gtk.WindowPosition.CENTER_ON_PARENT) - dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL) - dialog.add_button(reload_text, Gtk.ResponseType.OK) - dialog.set_default_response(Gtk.ResponseType.OK) + def on_external_change_detected(self, file_handler): + """Handle external changes to notes.json file""" + """ Note: Edits less than 1 second old may be lost if external changes arrive during that time. + There's a delay between when the note is changed in the UI and when update_note_list() + actually gets called to register the change. Fixing this defect is a bit tricky and the + likelihood of occurrence is very small so I'm not going to bother repairing it. + """ + # If notes are hidden, defer the dialog until they're shown + if self.notes_hidden: + self.file_handler.has_pending_external_change = True + return - # Apply orange background to make dialog stand out on desktop - css_provider = Gtk.CssProvider() - css_provider.load_from_data(b""" - dialog { - background-color: #ffa939; - } - dialog label { - color: #000000; - font-weight: bold; - } - """) - style_context = dialog.get_style_context() - style_context.add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + # Check if there were unsaved changes (saved before timer was cancelled) + has_unsaved = file_handler.had_pending_changes - content = dialog.get_content_area() - content.props.margin_left = 20 - content.props.margin_right = 20 + # Check preference for auto-reload + auto_reload = self.settings.get_boolean('auto-reload-external-changes') - content.pack_start(Gtk.Label(label=message), False, False, 10) - content.show_all() + # SAFE SCENARIO: Auto-reload if enabled and no unsaved changes + if auto_reload and not has_unsaved: + self._reload_notes_from_external_change() + self.file_handler.had_pending_changes = False + return - response = dialog.run() - dialog.destroy() + # UNSAFE SCENARIO or preference disabled: Show dialog + self._show_external_change_dialog(has_unsaved) - if response == Gtk.ResponseType.OK: - # User chose to reload from disk - self.file_handler.load_notes() - self.load_notes() + # Reset flag for next time + self.file_handler.had_pending_changes = False + def show_deferred_external_change_dialog(self): + """Handle deferred external changes when notes become visible""" + # Check if there are unsaved changes (via dirty bit) + has_unsaved = self.file_handler.dirty - # Notify manager window to refresh thumbnails (if it's open) - if self.manager: - self.manager.generate_previews() + # Check preference for auto-reload + auto_reload = self.settings.get_boolean('auto-reload-external-changes') - # Note: We do NOT emit DBus 'NotesChanged' signal here - # because we're responding to external changes, not making them - else: - # User chose to keep their changes - # If they had unsaved changes, save them now to overwrite external changes - if has_unsaved: - self.file_handler.save_note_list() + # SAFE SCENARIO: Auto-reload if enabled and no unsaved changes + if auto_reload and not has_unsaved: + self._reload_notes_from_external_change() + return - # Restore focus to notes after dialog closes - # (dialog steals focus, we need to return it so tray icon works correctly) - for note in self.notes: - note.restore(Gtk.get_current_event_time()) + # UNSAFE SCENARIO or preference disabled: Show dialog + self._show_external_change_dialog(has_unsaved) def on_file_modified_before_save(self, file_handler): """Handle file modified since last read - warn before overwriting""" diff --git a/usr/share/glib-2.0/schemas/org.x.sticky.gschema.xml b/usr/share/glib-2.0/schemas/org.x.sticky.gschema.xml index 73fb2eb..845ede0 100644 --- a/usr/share/glib-2.0/schemas/org.x.sticky.gschema.xml +++ b/usr/share/glib-2.0/schemas/org.x.sticky.gschema.xml @@ -209,6 +209,14 @@ + + false + Automatically reload external changes + + When enabled, automatically reload notes when external changes are detected, without prompting. Only applies when there are no unsaved changes. Note: Edits less than 1 second old may be lost if external changes arrive during that time. + + + From 1abff9f4d48651d0cf925b3f4bec82ba4e0d766e Mon Sep 17 00:00:00 2001 From: John Dalbey Date: Sat, 24 Jan 2026 12:13:01 -0800 Subject: [PATCH 4/4] Fix typos in test cases. --- docs/testing/external-change-detection-manual-tests.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing/external-change-detection-manual-tests.md b/docs/testing/external-change-detection-manual-tests.md index d3a73c6..3d5c31f 100644 --- a/docs/testing/external-change-detection-manual-tests.md +++ b/docs/testing/external-change-detection-manual-tests.md @@ -61,7 +61,7 @@ CLICK "Reload from disk" Verify note has text "Quick Fox" and note is restored to original position. Test 5b: -Repeat 6a except CLICK "Keep my changes" +Repeat 5a except CLICK "Keep my changes" VERIFY a second dialog appears with file modified message: The notes.json file has been modified since you last opened it. If you save now, those external changes will be lost. Do you want to save anyway? Don't Save/Backup and Save/Save Anyway. CLICK "Save Anyway" VERIFY UI keeps edited version with text "Lazy Dog" @@ -257,7 +257,7 @@ Make a second external change to the notes.json file. Verify confirmation prompt does not appear. (Because we haven't actually clicked the tray icon to make notes appear ... they showed up as a byproduct of other actions) -Test 16: Always auto-reload Checkbox +Test 16: Checkbox for "Always auto-reload" START application VERIFY Preference > General > Auto reload is OFF. CREATE a new note in color yellow with text "demo note"