#!/usr/bin/env python3 import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, GLib, Gio, Gdk import os import datetime import sqlite3 from pathlib import Path class JotApp(Gtk.Application): def __init__(self): super().__init__(application_id="com.example.jot", flags=Gio.ApplicationFlags.FLAGS_NONE) # Set up data structures self.dbPath = self.get_db_path() self.conn = None self.init_database() # Initialize settings self.expirationDays = self.get_setting("expirationDays", 0) self.confirmDelete = self.get_setting("confirmDelete", 1) def do_activate(self): # Create main window when app is activated self.window = Gtk.ApplicationWindow(application=self, title="I38 Notes") self.window.set_default_size(500, 400) # Connect the delete-event signal (for window close button) self.window.connect("delete-event", self.on_window_close) # Set up keyboard shortcuts self.setup_actions() # Build the main interface self.build_ui() # Check for expired notes self.check_expirations() self.window.show_all() def on_window_close(self, window, event): """Handle window close event""" # Close the database connection before quitting if self.conn: self.conn.close() self.quit() return True def get_db_path(self): """Get path to the SQLite database""" configHome = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config')) configDir = os.path.join(configHome, 'stormux', 'I38') os.makedirs(configDir, exist_ok=True) return os.path.join(configDir, 'notes.sqlite') def init_database(self): """Initialize the SQLite database""" try: self.conn = sqlite3.connect(self.dbPath) cursor = self.conn.cursor() # Create notes table if it doesn't exist cursor.execute(''' CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires TIMESTAMP NULL, locked BOOLEAN DEFAULT 0 ) ''') # Create settings table if it doesn't exist cursor.execute(''' CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) ''') self.conn.commit() # Initialize default settings if they don't exist self.init_default_settings() # Check if we need to migrate from old format self.migrate_if_needed() except sqlite3.Error as e: print(f"Database error: {e}") def init_default_settings(self): """Initialize default settings if they don't exist""" defaultSettings = { "expirationDays": "0", "confirmDelete": "1" # 1 = enabled, 0 = disabled } cursor = self.conn.cursor() for key, value in defaultSettings.items(): cursor.execute( "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (key, value) ) self.conn.commit() def migrate_if_needed(self): """Check if we need to migrate from old format""" # Check for old config directory oldConfigHome = os.environ.get('XDG_CONFIG_HOME', os.path.join(os.path.expanduser('~'), '.config')) oldConfigDir = os.path.join(oldConfigHome, 'jot') oldNotesFile = os.path.join(oldConfigDir, 'notes') if os.path.exists(oldNotesFile): try: # Check if we already have notes in the database cursor = self.conn.cursor() cursor.execute("SELECT COUNT(*) FROM notes") count = cursor.fetchone()[0] # Only migrate if database is empty if count == 0: with open(oldNotesFile, 'r') as f: for line in f: parts = line.strip().split(': ', 1) if len(parts) == 2: noteNum, noteText = parts cursor.execute( "INSERT INTO notes (text, locked) VALUES (?, ?)", (noteText, 0) ) self.conn.commit() print(f"Migrated notes from {oldNotesFile}") except Exception as e: print(f"Migration error: {e}") def get_setting(self, key, default=None): """Get a setting from the database""" try: cursor = self.conn.cursor() cursor.execute("SELECT value FROM settings WHERE key = ?", (key,)) result = cursor.fetchone() if result: return result[0] else: # Set default if not exists self.save_setting(key, default) return default except sqlite3.Error: return default def save_setting(self, key, value): """Save a setting to the database""" try: cursor = self.conn.cursor() cursor.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value) ) self.conn.commit() except sqlite3.Error as e: print(f"Settings error: {e}") def check_expirations(self): """Check and remove expired notes""" try: cursor = self.conn.cursor() now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Find expired notes that aren't locked cursor.execute( "SELECT id, text FROM notes WHERE expires IS NOT NULL AND expires < ? AND locked = 0", (now,) ) expired = cursor.fetchall() if expired: # Delete expired notes cursor.execute( "DELETE FROM notes WHERE expires IS NOT NULL AND expires < ? AND locked = 0", (now,) ) self.conn.commit() expiredCount = len(expired) if expiredCount > 0: self.show_status_message(f"Removed {expiredCount} expired notes") # Refresh notes list self.populate_notes() except sqlite3.Error as e: print(f"Expiration check error: {e}") def build_ui(self): """Build the main user interface with tabs""" # Create notebook (tabbed interface) self.notebook = Gtk.Notebook() self.notebook.set_tab_pos(Gtk.PositionType.TOP) # Make tabs keyboard navigable self.notebook.set_can_focus(True) self.window.add(self.notebook) # Build notes tab notesTab = self.build_notes_tab() notesTabLabel = Gtk.Label(label="Notes") self.notebook.append_page(notesTab, notesTabLabel) self.notebook.set_tab_reorderable(notesTab, False) # Build settings tab settingsTab = self.build_settings_tab() settingsTabLabel = Gtk.Label(label="Settings") self.notebook.append_page(settingsTab, settingsTabLabel) self.notebook.set_tab_reorderable(settingsTab, False) # Connect tab change signal self.notebook.connect("switch-page", self.on_tab_switched) def build_notes_tab(self): """Build the notes tab""" vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) vbox.set_border_width(10) # Notes list with scrolling scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) # Create list store and view self.notesStore = Gtk.ListStore(int, str, bool, str) # id, text, locked, expiry self.notesView = Gtk.TreeView(model=self.notesStore) self.notesView.set_activate_on_single_click(False) self.notesView.connect("row-activated", self.on_row_activated) # Improve keyboard navigation in the tree view self.notesView.set_can_focus(True) self.notesView.set_headers_clickable(True) self.notesView.set_enable_search(True) self.notesView.set_search_column(1) # Search by note text # Add columns with renderers self.add_columns() # Populate the list self.populate_notes() scrolled.add(self.notesView) vbox.pack_start(scrolled, True, True, 0) # Action buttons actionBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) # Copy button copyButton = Gtk.Button.new_with_label("Copy to Clipboard") copyButton.connect("clicked", self.on_copy_clicked) copyButton.set_can_focus(True) actionBox.pack_start(copyButton, False, False, 0) # Delete button deleteButton = Gtk.Button.new_with_label("Delete Note") deleteButton.connect("clicked", self.on_delete_button_clicked) deleteButton.set_can_focus(True) actionBox.pack_start(deleteButton, False, False, 0) vbox.pack_start(actionBox, False, False, 0) # Entry for adding new notes entryBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self.newNoteEntry = Gtk.Entry() self.newNoteEntry.set_placeholder_text("Type a new note and press Enter") self.newNoteEntry.connect("activate", self.on_entry_activate) self.newNoteEntry.set_can_focus(True) entryBox.pack_start(self.newNoteEntry, True, True, 0) # Add button addButton = Gtk.Button.new_with_label("Add Note") addButton.connect("clicked", self.on_add_clicked) addButton.set_can_focus(True) entryBox.pack_start(addButton, False, False, 0) vbox.pack_start(entryBox, False, False, 0) # Status bar self.statusbar = Gtk.Statusbar() self.statusbarCtx = self.statusbar.get_context_id("jot") vbox.pack_start(self.statusbar, False, False, 0) return vbox def build_settings_tab(self): """Build the settings tab""" vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) vbox.set_border_width(20) # Create a frame for expiration settings expiryFrame = Gtk.Frame(label="Note Expiration") vbox.pack_start(expiryFrame, False, False, 0) # Container for frame content expiryBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) expiryBox.set_border_width(10) expiryFrame.add(expiryBox) # Note expiration setting # First radio button for "Never expire" self.neverExpireRadio = Gtk.RadioButton.new_with_label_from_widget(None, "Never expire notes") self.neverExpireRadio.set_can_focus(True) expiryBox.pack_start(self.neverExpireRadio, False, False, 0) # Container for expiration days selection expireDaysBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) # Radio button for custom expiration self.customExpireRadio = Gtk.RadioButton.new_with_label_from_widget( self.neverExpireRadio, "Expire notes after" ) self.customExpireRadio.set_can_focus(True) expireDaysBox.pack_start(self.customExpireRadio, False, False, 0) # Spin button for days adjustment = Gtk.Adjustment( value=max(1, int(self.expirationDays)) if int(self.expirationDays) > 0 else 7, lower=1, upper=30, step_increment=1 ) self.daysSpinButton = Gtk.SpinButton() self.daysSpinButton.set_adjustment(adjustment) self.daysSpinButton.set_can_focus(True) expireDaysBox.pack_start(self.daysSpinButton, False, False, 0) # Label for "days" daysLabel = Gtk.Label(label="days") expireDaysBox.pack_start(daysLabel, False, False, 0) expiryBox.pack_start(expireDaysBox, False, False, 0) # Create a frame for confirmation settings confirmFrame = Gtk.Frame(label="Confirmations") vbox.pack_start(confirmFrame, False, False, 10) # Container for confirmation settings confirmBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) confirmBox.set_border_width(10) confirmFrame.add(confirmBox) # Delete confirmation checkbox self.confirmDeleteCheck = Gtk.CheckButton(label="Confirm before deleting notes") self.confirmDeleteCheck.set_active(bool(int(self.confirmDelete))) self.confirmDeleteCheck.set_can_focus(True) confirmBox.pack_start(self.confirmDeleteCheck, False, False, 0) # Set the active radio button based on current setting if int(self.expirationDays) > 0: self.customExpireRadio.set_active(True) else: self.neverExpireRadio.set_active(True) # Connect signals self.neverExpireRadio.connect("toggled", self.on_expiry_radio_toggled) self.customExpireRadio.connect("toggled", self.on_expiry_radio_toggled) # Enable/disable the spin button based on the selected radio self.on_expiry_radio_toggled(None) # Save button saveButton = Gtk.Button.new_with_label("Save Settings") saveButton.connect("clicked", self.on_save_settings) saveButton.set_can_focus(True) vbox.pack_start(saveButton, False, False, 10) return vbox def add_columns(self): """Add columns to the TreeView""" # ID Column renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn("ID", renderer, text=0) column.set_sort_column_id(0) self.notesView.append_column(column) # Note Text Column renderer = Gtk.CellRendererText() renderer.set_property("ellipsize", True) column = Gtk.TreeViewColumn("Note", renderer, text=1) column.set_expand(True) self.notesView.append_column(column) # Locked Column renderer = Gtk.CellRendererToggle() renderer.connect("toggled", self.on_locked_toggled) column = Gtk.TreeViewColumn("Locked", renderer, active=2) self.notesView.append_column(column) # Expiration Column renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn("Expires", renderer, text=3) self.notesView.append_column(column) def populate_notes(self): """Populate the list store with notes from the database""" self.notesStore.clear() try: cursor = self.conn.cursor() cursor.execute( "SELECT id, text, locked, expires FROM notes ORDER BY id" ) notes = cursor.fetchall() for note in notes: noteId, text, locked, expires = note # Format expiry date if it exists expiryText = "" if expires: try: expiryDate = datetime.datetime.strptime(expires, "%Y-%m-%d %H:%M:%S") expiryText = expiryDate.strftime("%Y-%m-%d") except: expiryText = "Invalid date" self.notesStore.append([ noteId, # Database ID text, # Note text bool(locked), # Locked status expiryText # Expiry date ]) except sqlite3.Error as e: print(f"Error populating notes: {e}") def setup_actions(self): """Set up keyboard shortcuts""" # Delete selected note deleteAction = Gio.SimpleAction.new("delete", None) deleteAction.connect("activate", self.on_delete_clicked) self.add_action(deleteAction) self.set_accels_for_action("app.delete", ["Delete"]) # Toggle lock on selected note lockAction = Gio.SimpleAction.new("lock", None) lockAction.connect("activate", self.on_lock_toggled) self.add_action(lockAction) self.set_accels_for_action("app.lock", ["l"]) # Copy note to clipboard copyAction = Gio.SimpleAction.new("copy", None) copyAction.connect("activate", self.on_copy_clicked) self.add_action(copyAction) self.set_accels_for_action("app.copy", ["c"]) # Switch to notes tab notesTabAction = Gio.SimpleAction.new("notes_tab", None) notesTabAction.connect("activate", lambda a, p: self.notebook.set_current_page(0)) self.add_action(notesTabAction) self.set_accels_for_action("app.notes_tab", ["1"]) # Switch to settings tab settingsTabAction = Gio.SimpleAction.new("settings_tab", None) settingsTabAction.connect("activate", lambda a, p: self.notebook.set_current_page(1)) self.add_action(settingsTabAction) self.set_accels_for_action("app.settings_tab", ["2"]) # Quit application quitAction = Gio.SimpleAction.new("quit", None) quitAction.connect("activate", lambda a, p: self.quit()) self.add_action(quitAction) self.set_accels_for_action("app.quit", ["Escape"]) def show_status_message(self, message): """Show a message in the statusbar""" self.statusbar.push(self.statusbarCtx, message) # Auto-remove after 5 seconds GLib.timeout_add_seconds(5, self.statusbar.pop, self.statusbarCtx) def on_tab_switched(self, notebook, page, page_num): """Handler for tab switching""" # Reset status bar on tab switch self.statusbar.pop(self.statusbarCtx) # Set focus appropriately if page_num == 0: # Notes tab self.newNoteEntry.grab_focus() elif page_num == 1: # Settings tab if self.neverExpireRadio.get_active(): self.neverExpireRadio.grab_focus() else: self.customExpireRadio.grab_focus() def on_expiry_radio_toggled(self, widget): """Handler for expiry radio button toggles""" # Enable/disable spin button based on which radio is active self.daysSpinButton.set_sensitive(self.customExpireRadio.get_active()) def on_save_settings(self, button): """Handler for Save Settings button""" if self.neverExpireRadio.get_active(): self.expirationDays = 0 else: self.expirationDays = self.daysSpinButton.get_value_as_int() # Get delete confirmation setting self.confirmDelete = 1 if self.confirmDeleteCheck.get_active() else 0 # Save to database self.save_setting("expirationDays", self.expirationDays) self.save_setting("confirmDelete", self.confirmDelete) # Apply expiration to notes if needed if self.expirationDays > 0: try: cursor = self.conn.cursor() now = datetime.datetime.now() expiryDate = now + datetime.timedelta(days=self.expirationDays) expiryStr = expiryDate.strftime("%Y-%m-%d %H:%M:%S") # Set expiration for notes that aren't locked and don't have expiration cursor.execute( "UPDATE notes SET expires = ? WHERE locked = 0 AND expires IS NULL", (expiryStr,) ) self.conn.commit() # Refresh the notes list self.populate_notes() except sqlite3.Error as e: print(f"Error updating expirations: {e}") self.show_status_message("Settings saved") # Switch back to notes tab self.notebook.set_current_page(0) def on_entry_activate(self, entry): """Handle Enter key in the entry field""" self.add_new_note(entry.get_text()) entry.set_text("") def on_add_clicked(self, button): """Handle Add Note button click""" self.add_new_note(self.newNoteEntry.get_text()) self.newNoteEntry.set_text("") def add_new_note(self, text): """Add a new note to the database""" if not text.strip(): self.show_status_message("Note text cannot be empty") return try: cursor = self.conn.cursor() # Set expiration if enabled expires = None if int(self.expirationDays) > 0: expiryDate = datetime.datetime.now() + datetime.timedelta(days=int(self.expirationDays)) expires = expiryDate.strftime("%Y-%m-%d %H:%M:%S") # Insert the new note cursor.execute( "INSERT INTO notes (text, expires, locked) VALUES (?, ?, ?)", (text, expires, 0) ) self.conn.commit() # Refresh the notes list self.populate_notes() self.show_status_message("Note added") except sqlite3.Error as e: self.show_status_message(f"Error adding note: {e}") def on_row_activated(self, view, path, column): """Handle double-click on a note - edit the note""" model = view.get_model() noteId = model[path][0] # Get the note from the database try: cursor = self.conn.cursor() cursor.execute("SELECT text, locked, expires FROM notes WHERE id = ?", (noteId,)) note = cursor.fetchone() if note: self.edit_note_dialog(noteId, note) except sqlite3.Error as e: self.show_status_message(f"Error retrieving note: {e}") def on_locked_toggled(self, renderer, path): """Handle toggling the locked state from the view""" model = self.notesView.get_model() noteId = model[path][0] currentLocked = model[path][2] newLocked = not currentLocked try: cursor = self.conn.cursor() # Update locked status cursor.execute( "UPDATE notes SET locked = ? WHERE id = ?", (1 if newLocked else 0, noteId) ) # If unlocking and expiration is enabled, set expiration if not newLocked and int(self.expirationDays) > 0: expiryDate = datetime.datetime.now() + datetime.timedelta(days=int(self.expirationDays)) expiryStr = expiryDate.strftime("%Y-%m-%d %H:%M:%S") cursor.execute( "UPDATE notes SET expires = ? WHERE id = ?", (expiryStr, noteId) ) # Update the expiry text in the model model[path][3] = expiryDate.strftime("%Y-%m-%d") self.show_status_message("Note unlocked - expiration set") elif newLocked: self.show_status_message("Note locked - will not expire") else: self.show_status_message("Note unlocked") # Update the model model[path][2] = newLocked self.conn.commit() except sqlite3.Error as e: self.show_status_message(f"Error updating note: {e}") def on_lock_toggled(self, action, parameter): """Handle keyboard shortcut to toggle lock""" selection = self.notesView.get_selection() model, treeiter = selection.get_selected() if treeiter: noteId = model[treeiter][0] currentLocked = model[treeiter][2] # Simulate clicking the toggle path = model.get_path(treeiter) self.on_locked_toggled(None, path) def on_copy_clicked(self, action=None, parameter=None): """Copy the selected note to clipboard""" selection = self.notesView.get_selection() model, treeiter = selection.get_selected() if treeiter: noteText = model[treeiter][1] # Get the clipboard clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.set_text(noteText, -1) self.show_status_message("Note copied to clipboard") else: self.show_status_message("No note selected") def on_delete_button_clicked(self, button): """Handle Delete button click""" selection = self.notesView.get_selection() model, treeiter = selection.get_selected() if treeiter: noteId = model[treeiter][0] noteText = model[treeiter][1] if int(self.confirmDelete): self.confirm_delete_note(noteId, noteText) else: self.delete_note(noteId) def on_delete_clicked(self, action, parameter): """Handle Delete key to remove a note""" selection = self.notesView.get_selection() model, treeiter = selection.get_selected() if treeiter: noteId = model[treeiter][0] noteText = model[treeiter][1] if int(self.confirmDelete): self.confirm_delete_note(noteId, noteText) else: self.delete_note(noteId) def delete_note(self, noteId): """Delete a note by ID without confirmation""" try: cursor = self.conn.cursor() cursor.execute("DELETE FROM notes WHERE id = ?", (noteId,)) self.conn.commit() self.populate_notes() self.show_status_message("Note deleted") except sqlite3.Error as e: self.show_status_message(f"Error deleting note: {e}") def confirm_delete_note(self, noteId, noteText): """Show confirmation dialog before deleting a note""" if len(noteText) > 30: noteText = noteText[:30] + "..." dialog = Gtk.MessageDialog( transient_for=self.window, flags=0, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, text=f"Delete note: {noteText}?" ) dialog.set_default_response(Gtk.ResponseType.NO) response = dialog.run() if response == Gtk.ResponseType.YES: self.delete_note(noteId) dialog.destroy() def edit_note_dialog(self, noteId, note): """Show dialog to edit a note""" text, locked, expires = note dialog = Gtk.Dialog( title="Edit Note", parent=self.window, flags=0, buttons=( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK ) ) dialog.set_default_size(400, 200) # Make the dialog accessible dialog.set_role("dialog") dialog.set_property("has-tooltip", True) # Create a text view for the note entryBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) entryBox.set_border_width(10) label = Gtk.Label(label="Edit note text:") label.set_halign(Gtk.Align.START) entryBox.pack_start(label, False, False, 0) # Scrolled window for text view scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled.set_shadow_type(Gtk.ShadowType.IN) # Text view for multi-line editing textBuffer = Gtk.TextBuffer() textBuffer.set_text(text) textView = Gtk.TextView.new_with_buffer(textBuffer) textView.set_wrap_mode(Gtk.WrapMode.WORD) textView.set_can_focus(True) scrolled.add(textView) entryBox.pack_start(scrolled, True, True, 0) # Add lock checkbox lockCheck = Gtk.CheckButton(label="Lock note (prevent expiration)") lockCheck.set_active(bool(locked)) lockCheck.set_can_focus(True) entryBox.pack_start(lockCheck, False, False, 0) # Show expiration date if it exists if expires and not locked: try: expiryDate = datetime.datetime.strptime(expires, "%Y-%m-%d %H:%M:%S") expiryLabel = Gtk.Label(label=f"Expires on: {expiryDate.strftime('%Y-%m-%d')}") expiryLabel.set_halign(Gtk.Align.START) entryBox.pack_start(expiryLabel, False, False, 0) except: pass dialog.get_content_area().add(entryBox) dialog.set_default_response(Gtk.ResponseType.OK) dialog.show_all() # Set focus to text view textView.grab_focus() response = dialog.run() if response == Gtk.ResponseType.OK: # Get the text from the buffer start, end = textBuffer.get_bounds() newText = textBuffer.get_text(start, end, False) newLocked = lockCheck.get_active() try: cursor = self.conn.cursor() # Update the note cursor.execute( "UPDATE notes SET text = ?, locked = ? WHERE id = ?", (newText, 1 if newLocked else 0, noteId) ) # Update expiration if needed if not newLocked and int(self.expirationDays) > 0: expiryDate = datetime.datetime.now() + datetime.timedelta(days=int(self.expirationDays)) expiryStr = expiryDate.strftime("%Y-%m-%d %H:%M:%S") cursor.execute( "UPDATE notes SET expires = ? WHERE id = ?", (expiryStr, noteId) ) self.conn.commit() self.populate_notes() self.show_status_message("Note updated") except sqlite3.Error as e: self.show_status_message(f"Error updating note: {e}") dialog.destroy() def main(): app = JotApp() return app.run(None) if __name__ == "__main__": main()