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..3d5c31f --- /dev/null +++ b/docs/testing/external-change-detection-manual-tests.md @@ -0,0 +1,283 @@ +## 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 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" +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) + +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" +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 b7599e4..7fbcca2 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 # 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 + 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,42 @@ 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) + # 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 + 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 +341,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 +363,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 +377,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..8ec7ef5 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 @@ -610,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 @@ -770,6 +780,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 +985,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 +1159,213 @@ 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 _reload_notes_from_external_change(self): + """Reload notes from disk after external change detected""" + 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() + + 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") + 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) + + # 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() + + # 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() + + # Handle response + user_chose_reload = (response == Gtk.ResponseType.OK) + + 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 + 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()) + + return user_chose_reload + + 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 + + # Check if there were unsaved changes (saved before timer was cancelled) + has_unsaved = file_handler.had_pending_changes + + # Check preference for auto-reload + auto_reload = self.settings.get_boolean('auto-reload-external-changes') + + # 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 + + # UNSAFE SCENARIO or preference disabled: Show dialog + self._show_external_change_dialog(has_unsaved) + + # 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 + + # Check preference for auto-reload + auto_reload = self.settings.get_boolean('auto-reload-external-changes') + + # SAFE SCENARIO: Auto-reload if enabled and no unsaved changes + if auto_reload and not has_unsaved: + self._reload_notes_from_external_change() + return + + # 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""" + 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 +1384,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() 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. + + +