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