diff --git a/indicator-stickynotes.py b/indicator-stickynotes.py index 1258add..296f5bc 100755 --- a/indicator-stickynotes.py +++ b/indicator-stickynotes.py @@ -25,9 +25,13 @@ import gi gi.require_version('Gtk', '3.0') gi.require_version('GtkSource', '3.0') -gi.require_version('AppIndicator3', '0.1') +try: + gi.require_version('AyatanaAppIndicator3', '0.1') + from gi.repository import AyatanaAppIndicator3 as appindicator +except (ValueError, ImportError): + gi.require_version('AppIndicator3', '0.1') + from gi.repository import AppIndicator3 as appindicator from gi.repository import Gtk, Gdk -from gi.repository import AppIndicator3 as appindicator import os.path import locale @@ -86,12 +90,12 @@ def __init__(self, args = None): # Delete/modify the following file when distributing as a package self.ind.set_icon_theme_path(os.path.abspath(os.path.join( os.path.dirname(__file__), 'Icons'))) - self.ind.set_icon("indicator-stickynotes-mono") + self.ind.set_icon_full("indicator-stickynotes-mono", "Sticky Notes") self.ind.set_status(appindicator.IndicatorStatus.ACTIVE) self.ind.set_title(_("Sticky Notes")) # Create Menu self.menu = Gtk.Menu() - self.mNewNote = Gtk.MenuItem(_("New Note")) + self.mNewNote = Gtk.MenuItem(label=_("New Note")) self.menu.append(self.mNewNote) self.mNewNote.connect("activate", self.new_note, None) self.mNewNote.show() @@ -100,12 +104,12 @@ def __init__(self, args = None): self.menu.append(s) s.show() - self.mShowAll = Gtk.MenuItem(_("Show All")) + self.mShowAll = Gtk.MenuItem(label=_("Show All")) self.menu.append(self.mShowAll) self.mShowAll.connect("activate", self.showall, None) self.mShowAll.show() - self.mHideAll = Gtk.MenuItem(_("Hide All")) + self.mHideAll = Gtk.MenuItem(label=_("Hide All")) self.menu.append(self.mHideAll) self.mHideAll.connect("activate", self.hideall, None) self.mHideAll.show() @@ -114,12 +118,12 @@ def __init__(self, args = None): self.menu.append(s) s.show() - self.mLockAll = Gtk.MenuItem(_("Lock All")) + self.mLockAll = Gtk.MenuItem(label=_("Lock All")) self.menu.append(self.mLockAll) self.mLockAll.connect("activate", self.lockall, None) self.mLockAll.show() - self.mUnlockAll = Gtk.MenuItem(_("Unlock All")) + self.mUnlockAll = Gtk.MenuItem(label=_("Unlock All")) self.menu.append(self.mUnlockAll) self.mUnlockAll.connect("activate", self.unlockall, None) self.mUnlockAll.show() @@ -128,12 +132,12 @@ def __init__(self, args = None): self.menu.append(s) s.show() - self.mExport = Gtk.MenuItem(_("Export Data")) + self.mExport = Gtk.MenuItem(label=_("Export Data")) self.menu.append(self.mExport) self.mExport.connect("activate", self.export_datafile, None) self.mExport.show() - self.mImport = Gtk.MenuItem(_("Import Data")) + self.mImport = Gtk.MenuItem(label=_("Import Data")) self.menu.append(self.mImport) self.mImport.connect("activate", self.import_datafile, None) self.mImport.show() @@ -142,12 +146,21 @@ def __init__(self, args = None): self.menu.append(s) s.show() - self.mAbout = Gtk.MenuItem(_("About")) + self.mArchive = Gtk.MenuItem(label=_("Archive")) + self.menu.append(self.mArchive) + self.mArchive.connect("activate", self.show_archive, None) + self.mArchive.show() + + s = Gtk.SeparatorMenuItem.new() + self.menu.append(s) + s.show() + + self.mAbout = Gtk.MenuItem(label=_("About")) self.menu.append(self.mAbout) self.mAbout.connect("activate", self.show_about, None) self.mAbout.show() - self.mSettings = Gtk.MenuItem(_("Settings")) + self.mSettings = Gtk.MenuItem(label=_("Settings")) self.menu.append(self.mSettings) self.mSettings.connect("activate", self.show_settings, None) self.mSettings.show() @@ -156,7 +169,7 @@ def __init__(self, args = None): self.menu.append(s) s.show() - self.mQuit = Gtk.MenuItem(_("Quit")) + self.mQuit = Gtk.MenuItem(label=_("Quit")) self.menu.append(self.mQuit) self.mQuit.connect("activate", Gtk.main_quit, None) self.mQuit.show() @@ -249,6 +262,10 @@ def show_about(self, *args): def show_settings(self, *args): wSettings = SettingsDialog(self.nset) + def show_archive(self, *args): + from stickynotes.gui import ArchiveDialog + ArchiveDialog(self.nset) + def save(self): self.nset.save() diff --git a/stickynotes/backend.py b/stickynotes/backend.py index 0aab569..2eed189 100644 --- a/stickynotes/backend.py +++ b/stickynotes/backend.py @@ -15,12 +15,12 @@ # You should have received a copy of the GNU General Public License along with # indicator-stickynotes. If not, see . -from datetime import datetime +from datetime import datetime, timedelta import uuid import json from os.path import expanduser -from stickynotes.info import FALLBACK_PROPERTIES +from stickynotes.info import FALLBACK_PROPERTIES, DEFAULT_TRASH_RETENTION_DAYS, DEFAULT_CONFIRM_DELETE class Note: def __init__(self, content=None, gui_class=None, noteset=None, @@ -60,9 +60,9 @@ def update(self,body=None): self.last_modified = datetime.now() def delete(self): - self.noteset.notes.remove(self) + """Move note to archive instead of permanent deletion""" + self.noteset.archive_note(self) self.noteset.save() - del self def show(self, *args, **kwargs): # If GUI has not been created, create it now @@ -90,6 +90,7 @@ def cat_prop(self, prop): class NoteSet: def __init__(self, gui_class, data_file, indicator): self.notes = [] + self.archived_notes = [] # Archive for deleted notes self.properties = {} self.categories = {} self.gui_class = gui_class @@ -104,13 +105,26 @@ def loads(self, snoteset): """Loads notes into their respective objects""" notes = self._loads_updater(json.loads(snoteset)) self.properties = notes.get("properties", {}) + # Set default values for new properties + if "trash_retention_days" not in self.properties: + self.properties["trash_retention_days"] = DEFAULT_TRASH_RETENTION_DAYS + if "confirm_delete" not in self.properties: + self.properties["confirm_delete"] = DEFAULT_CONFIRM_DELETE self.categories = notes.get("categories", {}) self.notes = [Note(note, gui_class=self.gui_class, noteset=self) for note in notes.get("notes",[])] + # Load archived notes + self.archived_notes = notes.get("archived_notes", []) + # Clean up old archived notes + self.cleanup_old_archived_notes() def dumps(self): - return json.dumps({"notes":[x.extract() for x in self.notes], - "properties": self.properties, "categories": self.categories}) + return json.dumps({ + "notes": [x.extract() for x in self.notes], + "archived_notes": self.archived_notes, + "properties": self.properties, + "categories": self.categories + }) def save(self, path=''): output = self.dumps() @@ -177,6 +191,70 @@ def hideall(self, *args): for note in self.notes: note.hide(*args) self.properties["all_visible"] = False + def archive_note(self, note): + """Move note to archive instead of permanent deletion""" + # Remove from active notes + if note in self.notes: + self.notes.remove(note) + + # Extract note data and add deletion timestamp + archived_data = note.extract() + archived_data["deleted_at"] = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + + # Add to archived notes + self.archived_notes.append(archived_data) + + # Hide GUI if exists + if note.gui: + note.gui.winMain.destroy() + + def cleanup_old_archived_notes(self): + """Remove archived notes older than retention period""" + retention_days = self.properties.get("trash_retention_days", DEFAULT_TRASH_RETENTION_DAYS) + if retention_days <= 0: + return # Keep notes forever if retention is 0 or negative + + cutoff_date = datetime.now() - timedelta(days=retention_days) + + # Filter out old archived notes + self.archived_notes = [ + note for note in self.archived_notes + if datetime.strptime(note.get("deleted_at", "2000-01-01T00:00:00"), + "%Y-%m-%dT%H:%M:%S") > cutoff_date + ] + + def restore_note(self, archived_note_uuid): + """Restore a note from archive""" + # Find the archived note + archived_note = None + for note in self.archived_notes: + if note.get("uuid") == archived_note_uuid: + archived_note = note + break + + if not archived_note: + return None + + # Remove from archive + self.archived_notes.remove(archived_note) + + # Remove deleted_at timestamp + if "deleted_at" in archived_note: + del archived_note["deleted_at"] + + # Create new note from archived data + restored_note = Note(archived_note, gui_class=self.gui_class, noteset=self) + self.notes.append(restored_note) + + # Save changes + self.save() + + return restored_note + + def get_archived_notes(self): + """Get list of archived notes with their metadata""" + return self.archived_notes + def get_category_property(self, cat, prop): """Get a property of a category or the default""" diff --git a/stickynotes/gui.py b/stickynotes/gui.py index ccdabaa..ddc439e 100755 --- a/stickynotes/gui.py +++ b/stickynotes/gui.py @@ -272,19 +272,24 @@ def add(self, *args): return False def delete(self, *args): - winConfirm = Gtk.MessageDialog(self.winMain, None, - Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, - _("Are you sure you want to delete this note?")) - winConfirm.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, - Gtk.STOCK_DELETE, Gtk.ResponseType.ACCEPT) - confirm = winConfirm.run() - winConfirm.destroy() - if confirm == Gtk.ResponseType.ACCEPT: - self.note.delete() - self.winMain.destroy() - return False - else: - return True + """Delete note (move to archive) with optional confirmation dialog""" + confirm_delete = self.noteset.properties.get("confirm_delete", False) + + if confirm_delete: + winConfirm = Gtk.MessageDialog(self.winMain, None, + Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, + _("Are you sure you want to delete this note?")) + winConfirm.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, + Gtk.STOCK_DELETE, Gtk.ResponseType.ACCEPT) + confirm = winConfirm.run() + winConfirm.destroy() + if confirm != Gtk.ResponseType.ACCEPT: + return True + + # Delete note (moves to archive) + self.note.delete() + self.winMain.destroy() + return False def popup_menu(self, button, *args): """Pops up the note's menu""" @@ -444,6 +449,10 @@ def __init__(self, noteset): widgets = ["wSettings", "boxCategories"] for w in widgets: setattr(self, w, self.builder.get_object(w)) + + # Add archive settings + self.add_archive_settings() + for c in self.noteset.categories: self.add_category_widgets(c) ret = self.wSettings.run() @@ -470,7 +479,194 @@ def delete_category(self, cat): note.gui.populate_menu() note.gui.update_style() note.gui.update_font() + + def add_archive_settings(self): + """Add archive configuration widgets""" + # Create a frame for archive settings + frame = Gtk.Frame(label=_("Archive Settings")) + frame.set_margin_top(10) + frame.set_margin_bottom(10) + frame.set_margin_start(10) + frame.set_margin_end(10) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + vbox.set_margin_top(10) + vbox.set_margin_bottom(10) + vbox.set_margin_start(10) + vbox.set_margin_end(10) + + # Retention days setting + hbox1 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + label1 = Gtk.Label(label=_("Delete archived notes after (days):")) + label1.set_xalign(0) + self.spin_retention = Gtk.SpinButton() + self.spin_retention.set_range(0, 365) + self.spin_retention.set_increments(1, 7) + self.spin_retention.set_value( + self.noteset.properties.get("trash_retention_days", 30)) + self.spin_retention.connect("value-changed", self.on_retention_changed) + hbox1.pack_start(label1, True, True, 0) + hbox1.pack_start(self.spin_retention, False, False, 0) + + label_hint = Gtk.Label() + label_hint.set_markup("" + _("Set to 0 to keep notes forever") + "") + label_hint.set_xalign(0) + + # Confirm delete setting + self.check_confirm = Gtk.CheckButton(label=_("Confirm before deleting notes")) + self.check_confirm.set_active( + self.noteset.properties.get("confirm_delete", False)) + self.check_confirm.connect("toggled", self.on_confirm_changed) + + # View archive button + btn_view_archive = Gtk.Button(label=_("View Archive")) + btn_view_archive.connect("clicked", self.show_archive) + + vbox.pack_start(hbox1, False, False, 0) + vbox.pack_start(label_hint, False, False, 0) + vbox.pack_start(self.check_confirm, False, False, 0) + vbox.pack_start(btn_view_archive, False, False, 0) + + frame.add(vbox) + + # Insert at the top of the content area + content_area = self.wSettings.get_content_area() + content_area.pack_start(frame, False, False, 0) + content_area.reorder_child(frame, 0) + frame.show_all() + + def on_retention_changed(self, spinbutton): + """Update retention days setting""" + self.noteset.properties["trash_retention_days"] = int(spinbutton.get_value()) + self.noteset.save() + + def on_confirm_changed(self, checkbutton): + """Update confirm delete setting""" + self.noteset.properties["confirm_delete"] = checkbutton.get_active() + self.noteset.save() + + def show_archive(self, *args): + """Show the archive dialog""" + ArchiveDialog(self.noteset) def refresh_category_titles(self): for cid, catsettings in self.categories.items(): catsettings.refresh_title() + +class ArchiveDialog: + """Dialog to view and restore archived notes""" + def __init__(self, noteset): + self.noteset = noteset + self.path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + + # Create dialog + self.wArchive = Gtk.Dialog(_("Archived Notes"), None, + Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT) + self.wArchive.set_default_size(600, 400) + + # Create scrolled window with list + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + + # Create list store: uuid, body preview, deleted date, full body + self.liststore = Gtk.ListStore(str, str, str, str) + self.treeview = Gtk.TreeView(model=self.liststore) + + # Add columns + renderer_text = Gtk.CellRendererText() + column_preview = Gtk.TreeViewColumn(_("Note Preview"), renderer_text, text=1) + column_preview.set_expand(True) + self.treeview.append_column(column_preview) + + column_date = Gtk.TreeViewColumn(_("Deleted"), renderer_text, text=2) + column_date.set_min_width(150) + self.treeview.append_column(column_date) + + scroll.add(self.treeview) + + # Populate list + self.populate_list() + + # Add buttons + content_area = self.wArchive.get_content_area() + content_area.pack_start(scroll, True, True, 0) + + self.wArchive.add_button(_("Close"), Gtk.ResponseType.CLOSE) + self.wArchive.add_button(_("Restore"), Gtk.ResponseType.ACCEPT) + self.wArchive.add_button(_("Delete Permanently"), Gtk.ResponseType.REJECT) + + self.wArchive.show_all() + + # Handle response + while True: + response = self.wArchive.run() + if response == Gtk.ResponseType.ACCEPT: + self.restore_selected() + elif response == Gtk.ResponseType.REJECT: + self.delete_selected() + else: + break + + self.wArchive.destroy() + + def populate_list(self): + """Populate the list with archived notes""" + self.liststore.clear() + archived = self.noteset.get_archived_notes() + + for note in archived: + body = note.get("body", "") + # Create preview (first 50 chars) + preview = body[:50].replace("\n", " ") + if len(body) > 50: + preview += "..." + + deleted_at = note.get("deleted_at", "") + if deleted_at: + try: + dt = datetime.strptime(deleted_at, "%Y-%m-%dT%H:%M:%S") + deleted_str = dt.strftime("%Y-%m-%d %H:%M") + except: + deleted_str = deleted_at + else: + deleted_str = _("Unknown") + + self.liststore.append([note.get("uuid", ""), preview, deleted_str, body]) + + def restore_selected(self): + """Restore the selected note""" + selection = self.treeview.get_selection() + model, treeiter = selection.get_selected() + + if treeiter: + uuid = model[treeiter][0] + restored = self.noteset.restore_note(uuid) + if restored: + restored.show() + self.populate_list() + + def delete_selected(self): + """Permanently delete the selected note""" + selection = self.treeview.get_selection() + model, treeiter = selection.get_selected() + + if treeiter: + uuid = model[treeiter][0] + # Confirm permanent deletion + winConfirm = Gtk.MessageDialog(self.wArchive, None, + Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, + _("Permanently delete this note? This cannot be undone!")) + winConfirm.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, + Gtk.STOCK_DELETE, Gtk.ResponseType.ACCEPT) + confirm = winConfirm.run() + winConfirm.destroy() + + if confirm == Gtk.ResponseType.ACCEPT: + # Remove from archived notes + self.noteset.archived_notes = [ + n for n in self.noteset.archived_notes + if n.get("uuid") != uuid + ] + self.noteset.save() + self.populate_list() + diff --git a/stickynotes/info.py b/stickynotes/info.py index 99f9384..1e036b9 100644 --- a/stickynotes/info.py +++ b/stickynotes/info.py @@ -9,3 +9,7 @@ "textcolor": [32./255, 32./255, 32./255], "font": "", "shadow": 60} + +# Archive settings (default values) +DEFAULT_TRASH_RETENTION_DAYS = 30 +DEFAULT_CONFIRM_DELETE = False