Upload files to "src"
This commit is contained in:
930
src/applet.py
Normal file
930
src/applet.py
Normal file
@@ -0,0 +1,930 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ThunderPing Email Monitor
|
||||
|
||||
A system tray applet that monitors Thunderbird email accounts and displays
|
||||
unread message counts with colored badges.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import warnings
|
||||
import logging
|
||||
import re
|
||||
import gettext
|
||||
import locale
|
||||
import time
|
||||
import cairo
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('Gdk', '3.0')
|
||||
gi.require_version('GdkPixbuf', '2.0')
|
||||
from gi.repository import Gtk, GLib, Gdk, GdkPixbuf
|
||||
|
||||
# Try to import AppIndicator3, fall back to StatusIcon if not available
|
||||
try:
|
||||
gi.require_version('AppIndicator3', '0.1')
|
||||
from gi.repository import AppIndicator3
|
||||
HAS_APP_INDICATOR = True
|
||||
APPINDICATOR_AVAILABLE = True
|
||||
except:
|
||||
HAS_APP_INDICATOR = False
|
||||
APPINDICATOR_AVAILABLE = False
|
||||
|
||||
# Set level to INFO for clean output. Change to DEBUG for details.
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Suppress deprecation warnings for StatusIcon (which we use intentionally for compatibility)
|
||||
warnings.filterwarnings("ignore", message=".*Gtk.StatusIcon.*is deprecated.*", category=DeprecationWarning)
|
||||
warnings.filterwarnings("ignore", message=".*Gtk.Widget.set_margin.*is deprecated.*", category=DeprecationWarning)
|
||||
|
||||
# Internationalization setup
|
||||
# Determine locale folder path based on structure
|
||||
script_dir = os.path.dirname(__file__)
|
||||
# If we are in src/, the locale folder is in the parent directory
|
||||
if os.path.basename(script_dir) == 'src':
|
||||
LOCALE_DIR = os.path.join(os.path.dirname(script_dir), 'locale')
|
||||
else:
|
||||
# If we are installed, the locale folder is in the same directory
|
||||
LOCALE_DIR = os.path.join(script_dir, 'locale')
|
||||
DOMAIN = 'thunderping'
|
||||
|
||||
# Detect system language
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, '')
|
||||
lang = locale.getlocale()[0]
|
||||
if lang:
|
||||
lang = lang.split('_')[0] # e.g.: 'it_IT' -> 'it'
|
||||
if lang not in ['it', 'en']:
|
||||
lang = 'en' # Fallback for unsupported languages
|
||||
except:
|
||||
lang = 'en'
|
||||
|
||||
# Initialize gettext
|
||||
try:
|
||||
translation = gettext.translation(DOMAIN, LOCALE_DIR, languages=[lang])
|
||||
translation.install()
|
||||
_ = translation.gettext
|
||||
except:
|
||||
# Fallback to English
|
||||
_ = lambda x: x
|
||||
|
||||
CONFIG_PATH = os.path.expanduser("~/.config/thunderping/config.json")
|
||||
AUTOSTART_PATH = os.path.expanduser("~/.config/autostart/thunderping.desktop")
|
||||
|
||||
class SettingsDialog:
|
||||
"""
|
||||
Settings dialog window for configuring ThunderPing.
|
||||
|
||||
Provides a tabbed interface for configuring general settings and email accounts.
|
||||
Handles saving/loading configuration and account management.
|
||||
"""
|
||||
def __init__(self, parent_app):
|
||||
"""
|
||||
Initialize the settings dialog.
|
||||
|
||||
Args:
|
||||
parent_app: Reference to the main ThunderPingApplet instance
|
||||
"""
|
||||
self.parent_app = parent_app
|
||||
self.config = parent_app.config.copy() # Local copy for modifications
|
||||
|
||||
# Create main window
|
||||
self.window = Gtk.Window()
|
||||
self.window.set_title(_("ThunderPing Settings"))
|
||||
self.window.set_default_size(600, 400)
|
||||
self.window.set_transient_for(None)
|
||||
self.window.set_modal(True)
|
||||
|
||||
# Notebook for tabs
|
||||
notebook = Gtk.Notebook()
|
||||
|
||||
# General tab
|
||||
general_page = self.create_general_page()
|
||||
notebook.append_page(general_page, Gtk.Label(label=_("General")))
|
||||
|
||||
# Accounts tab
|
||||
accounts_page = self.create_accounts_page()
|
||||
notebook.append_page(accounts_page, Gtk.Label(label=_("Accounts")))
|
||||
|
||||
# Main container
|
||||
vbox = Gtk.VBox(spacing=10)
|
||||
vbox.set_margin_left(10)
|
||||
vbox.set_margin_right(10)
|
||||
vbox.set_margin_top(10)
|
||||
vbox.set_margin_bottom(10)
|
||||
|
||||
vbox.pack_start(notebook, True, True, 0)
|
||||
|
||||
# Buttons
|
||||
button_box = Gtk.HBox(spacing=10)
|
||||
button_box.set_halign(Gtk.Align.END)
|
||||
|
||||
cancel_button = Gtk.Button(label=_("Cancel"))
|
||||
cancel_button.connect("clicked", self.on_cancel)
|
||||
button_box.pack_start(cancel_button, False, False, 0)
|
||||
|
||||
save_button = Gtk.Button(label=_("Save"))
|
||||
save_button.get_style_context().add_class("suggested-action")
|
||||
save_button.connect("clicked", self.on_save)
|
||||
button_box.pack_start(save_button, False, False, 0)
|
||||
|
||||
vbox.pack_start(button_box, False, False, 0)
|
||||
|
||||
self.window.add(vbox)
|
||||
self.window.connect("delete-event", self.on_cancel)
|
||||
|
||||
def create_general_page(self):
|
||||
grid = Gtk.Grid()
|
||||
grid.set_row_spacing(10)
|
||||
grid.set_column_spacing(10)
|
||||
grid.set_margin_left(20)
|
||||
grid.set_margin_right(20)
|
||||
grid.set_margin_top(20)
|
||||
grid.set_margin_bottom(20)
|
||||
|
||||
row = 0
|
||||
|
||||
# Update interval
|
||||
label = Gtk.Label(label=_("Refresh interval (seconds):"))
|
||||
label.set_halign(Gtk.Align.START)
|
||||
grid.attach(label, 0, row, 1, 1)
|
||||
|
||||
self.refresh_spin = Gtk.SpinButton.new_with_range(1, 60, 1)
|
||||
self.refresh_spin.set_value(self.config.get('general', {}).get('refresh_interval', 3))
|
||||
grid.attach(self.refresh_spin, 1, row, 1, 1)
|
||||
row += 1
|
||||
|
||||
# Tooltip font size (only for GTK StatusIcon)
|
||||
if not APPINDICATOR_AVAILABLE:
|
||||
label = Gtk.Label(label=_("Tooltip font size:"))
|
||||
label.set_halign(Gtk.Align.START)
|
||||
grid.attach(label, 0, row, 1, 1)
|
||||
|
||||
self.font_spin = Gtk.SpinButton.new_with_range(8, 24, 1)
|
||||
self.font_spin.set_value(self.config.get('general', {}).get('tooltip_font_size', 12000) // 1000) # Convert from Pango units to points
|
||||
grid.attach(self.font_spin, 1, row, 1, 1)
|
||||
row += 1
|
||||
else:
|
||||
# If AppIndicator is available, create a dummy font_spin to avoid errors
|
||||
self.font_spin = Gtk.SpinButton.new_with_range(8, 24, 1)
|
||||
self.font_spin.set_value(12)
|
||||
|
||||
# Automatic startup
|
||||
self.startup_check = Gtk.CheckButton(label=_("Start with system"))
|
||||
self.startup_check.set_active(self.config.get('general', {}).get('startup_with_system', False))
|
||||
grid.attach(self.startup_check, 0, row, 2, 1)
|
||||
row += 1
|
||||
|
||||
# Color section
|
||||
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
grid.attach(separator, 0, row, 2, 1)
|
||||
row += 1
|
||||
|
||||
# Title color section
|
||||
color_label = Gtk.Label(label=_("<b>Icon Colors</b>"))
|
||||
color_label.set_use_markup(True)
|
||||
color_label.set_halign(Gtk.Align.START)
|
||||
grid.attach(color_label, 0, row, 2, 1)
|
||||
row += 1
|
||||
|
||||
# Color for emails from multiple accounts
|
||||
label = Gtk.Label(label=_("Color for emails from multiple accounts:"))
|
||||
label.set_halign(Gtk.Align.START)
|
||||
grid.attach(label, 0, row, 1, 1)
|
||||
|
||||
self.multi_account_color = Gtk.ColorButton()
|
||||
default_color = self.config.get('general', {}).get('multi_account_color', '#FF6600')
|
||||
rgba = Gdk.RGBA()
|
||||
rgba.parse(default_color)
|
||||
self.multi_account_color.set_rgba(rgba)
|
||||
grid.attach(self.multi_account_color, 1, row, 1, 1)
|
||||
row += 1
|
||||
|
||||
# Default color for individual accounts
|
||||
label = Gtk.Label(label=_("Default color for single account:"))
|
||||
label.set_halign(Gtk.Align.START)
|
||||
grid.attach(label, 0, row, 1, 1)
|
||||
|
||||
self.single_account_color = Gtk.ColorButton()
|
||||
default_single_color = self.config.get('general', {}).get('single_account_color', '#0099FF')
|
||||
rgba = Gdk.RGBA()
|
||||
rgba.parse(default_single_color)
|
||||
self.single_account_color.set_rgba(rgba)
|
||||
grid.attach(self.single_account_color, 1, row, 1, 1)
|
||||
|
||||
return grid
|
||||
|
||||
def create_accounts_page(self):
|
||||
vbox = Gtk.VBox(spacing=10)
|
||||
vbox.set_margin_left(20)
|
||||
vbox.set_margin_right(20)
|
||||
vbox.set_margin_top(20)
|
||||
vbox.set_margin_bottom(20)
|
||||
|
||||
# Toolbar
|
||||
toolbar = Gtk.HBox(spacing=10)
|
||||
|
||||
add_button = Gtk.Button(label=_("+ Add Account"))
|
||||
add_button.connect("clicked", self.on_add_account)
|
||||
toolbar.pack_start(add_button, False, False, 0)
|
||||
|
||||
remove_button = Gtk.Button(label=_("🗑 Remove"))
|
||||
remove_button.connect("clicked", self.on_remove_account)
|
||||
toolbar.pack_start(remove_button, False, False, 0)
|
||||
|
||||
vbox.pack_start(toolbar, False, False, 0)
|
||||
|
||||
# Account list
|
||||
scrolled = Gtk.ScrolledWindow()
|
||||
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scrolled.set_min_content_height(250)
|
||||
|
||||
# TreeView for accounts
|
||||
self.account_store = Gtk.ListStore(bool, str, str, str, str, str) # enabled, name, displayName, msf_path, profile, color
|
||||
self.account_tree = Gtk.TreeView(model=self.account_store)
|
||||
|
||||
# Connect double click to open color picker
|
||||
self.account_tree.connect("row-activated", self.on_row_activated)
|
||||
|
||||
# Columns
|
||||
# Checkbox enabled
|
||||
renderer = Gtk.CellRendererToggle()
|
||||
renderer.connect("toggled", self.on_account_enabled_toggled)
|
||||
column = Gtk.TreeViewColumn(_("Enabled"), renderer, active=0)
|
||||
self.account_tree.append_column(column)
|
||||
|
||||
# Account name
|
||||
renderer = Gtk.CellRendererText()
|
||||
renderer.set_property("editable", True)
|
||||
renderer.connect("edited", self.on_account_name_edited)
|
||||
column = Gtk.TreeViewColumn(_("Name"), renderer, text=1)
|
||||
self.account_tree.append_column(column)
|
||||
|
||||
# Display Name
|
||||
renderer = Gtk.CellRendererText()
|
||||
renderer.set_property("editable", True)
|
||||
renderer.connect("edited", self.on_account_display_name_edited)
|
||||
column = Gtk.TreeViewColumn(_("Display Name"), renderer, text=2)
|
||||
self.account_tree.append_column(column)
|
||||
|
||||
# Color
|
||||
renderer = Gtk.CellRendererText()
|
||||
# Non editable - handled only via double click
|
||||
column = Gtk.TreeViewColumn(_("Color"), renderer, text=5)
|
||||
column.set_cell_data_func(renderer, self.color_cell_data_func)
|
||||
self.account_tree.append_column(column)
|
||||
|
||||
# MSF Path
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn(_("MSF File"), renderer, text=3)
|
||||
column.set_expand(True)
|
||||
self.account_tree.append_column(column)
|
||||
|
||||
# Profile
|
||||
renderer = Gtk.CellRendererText()
|
||||
renderer.set_property("editable", True)
|
||||
renderer.connect("edited", self.on_account_profile_edited)
|
||||
column = Gtk.TreeViewColumn(_("Profile"), renderer, text=4)
|
||||
self.account_tree.append_column(column)
|
||||
|
||||
scrolled.add(self.account_tree)
|
||||
vbox.pack_start(scrolled, True, True, 0)
|
||||
|
||||
# Populate the list
|
||||
self.populate_accounts()
|
||||
|
||||
return vbox
|
||||
|
||||
def on_row_activated(self, tree_view, path, column):
|
||||
"""Handle double click on row - opens color picker if it's the color column"""
|
||||
# Check if the clicked column is the color one (index 3)
|
||||
columns = tree_view.get_columns()
|
||||
column_index = columns.index(column)
|
||||
|
||||
if column_index == 3: # The color column is the fourth one (index 3)
|
||||
self.open_color_picker_for_row(path)
|
||||
|
||||
def open_color_picker_for_row(self, path):
|
||||
"""Open color picker for the specified row"""
|
||||
iter = self.account_store.get_iter(path)
|
||||
|
||||
# Open color picker
|
||||
dialog = Gtk.ColorChooserDialog(title=_("Choose color for account"), parent=self.window)
|
||||
|
||||
# Set current color
|
||||
current_color = self.account_store.get_value(iter, 5)
|
||||
if not current_color or current_color.strip() == '' or current_color == '#000000':
|
||||
# If no color exists, use default
|
||||
default_color = self.config.get('general', {}).get('single_account_color', '#0099FF')
|
||||
rgba = Gdk.RGBA()
|
||||
rgba.parse(default_color)
|
||||
else:
|
||||
rgba = Gdk.RGBA()
|
||||
rgba.parse(current_color)
|
||||
dialog.set_rgba(rgba)
|
||||
|
||||
response = dialog.run()
|
||||
if response == Gtk.ResponseType.OK:
|
||||
color = dialog.get_rgba()
|
||||
hex_color = "#%02X%02X%02X" % (
|
||||
int(color.red * 255),
|
||||
int(color.green * 255),
|
||||
int(color.blue * 255)
|
||||
)
|
||||
self.account_store.set_value(iter, 5, hex_color)
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
def populate_accounts(self):
|
||||
self.account_store.clear()
|
||||
accounts = self.config.get('accounts', [])
|
||||
for acc in accounts:
|
||||
# If color is #000000 or another invalid value, set it empty
|
||||
# to use the default
|
||||
color = acc.get('color', '')
|
||||
if color == '#000000' or color == '#FFFFFF':
|
||||
color = ''
|
||||
|
||||
self.account_store.append([
|
||||
acc.get('enabled', True),
|
||||
acc.get('name', ''),
|
||||
acc.get('displayName', ''),
|
||||
acc.get('msf_path', ''),
|
||||
acc.get('profile_name', 'Default'),
|
||||
color # Empty if invalid, to use default
|
||||
])
|
||||
|
||||
def color_cell_data_func(self, column, cell, model, iter, data):
|
||||
"""Render color cell with colored background"""
|
||||
color = model.get_value(iter, 5)
|
||||
try:
|
||||
# If color is empty or invalid (like #000000), use default from general settings
|
||||
if not color or color.strip() == '' or color == '#000000':
|
||||
default_color = self.config.get('general', {}).get('single_account_color', '#0099FF')
|
||||
cell.set_property("background", default_color)
|
||||
cell.set_property("text", _("(default: {})").format(default_color))
|
||||
else:
|
||||
# Set cell background color
|
||||
cell.set_property("background", color)
|
||||
cell.set_property("text", color)
|
||||
except:
|
||||
default_color = self.config.get('general', {}).get('single_account_color', '#0099FF')
|
||||
cell.set_property("background", default_color)
|
||||
cell.set_property("text", _("(default: {})").format(default_color))
|
||||
|
||||
def on_account_enabled_toggled(self, renderer, path):
|
||||
iter = self.account_store.get_iter(path)
|
||||
current = self.account_store.get_value(iter, 0)
|
||||
self.account_store.set_value(iter, 0, not current)
|
||||
def on_account_name_edited(self, renderer, path, new_text):
|
||||
iter = self.account_store.get_iter(path)
|
||||
self.account_store.set_value(iter, 1, new_text)
|
||||
|
||||
def on_account_display_name_edited(self, renderer, path, new_text):
|
||||
iter = self.account_store.get_iter(path)
|
||||
self.account_store.set_value(iter, 2, new_text)
|
||||
|
||||
def on_account_profile_edited(self, renderer, path, new_text):
|
||||
iter = self.account_store.get_iter(path)
|
||||
self.account_store.set_value(iter, 4, new_text)
|
||||
|
||||
def on_add_account(self, button):
|
||||
# Dialog box for selecting the MSF file
|
||||
dialog = Gtk.FileChooserDialog(
|
||||
title=_("Select MSF file for account"),
|
||||
parent=self.window,
|
||||
action=Gtk.FileChooserAction.OPEN
|
||||
)
|
||||
dialog.add_buttons(
|
||||
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_OPEN, Gtk.ResponseType.OK
|
||||
)
|
||||
|
||||
# Filter for .msf files
|
||||
filter_msf = Gtk.FileFilter()
|
||||
filter_msf.set_name("File MSF (*.msf)")
|
||||
filter_msf.add_pattern("*.msf")
|
||||
dialog.add_filter(filter_msf)
|
||||
|
||||
# Try opening it in the Thunderbird folder
|
||||
thunderbird_path = os.path.expanduser("~/.thunderbird")
|
||||
if os.path.exists(thunderbird_path):
|
||||
dialog.set_current_folder(thunderbird_path)
|
||||
|
||||
response = dialog.run()
|
||||
if response == Gtk.ResponseType.OK:
|
||||
msf_path = dialog.get_filename()
|
||||
# Extract account name from path
|
||||
account_name = os.path.basename(os.path.dirname(msf_path))
|
||||
|
||||
# Add to list
|
||||
self.account_store.append([
|
||||
True, # enabled
|
||||
account_name, # name
|
||||
account_name, # displayName
|
||||
msf_path, # msf_path
|
||||
"Default", # profile
|
||||
"" # empty color - will use default
|
||||
])
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
def on_remove_account(self, button):
|
||||
selection = self.account_tree.get_selection()
|
||||
model, iter = selection.get_selected()
|
||||
if iter:
|
||||
model.remove(iter)
|
||||
|
||||
def on_save(self, button):
|
||||
# Save general settings
|
||||
multi_rgba = self.multi_account_color.get_rgba()
|
||||
multi_hex = "#%02X%02X%02X" % (
|
||||
int(multi_rgba.red * 255),
|
||||
int(multi_rgba.green * 255),
|
||||
int(multi_rgba.blue * 255)
|
||||
)
|
||||
|
||||
single_rgba = self.single_account_color.get_rgba()
|
||||
single_hex = "#%02X%02X%02X" % (
|
||||
int(single_rgba.red * 255),
|
||||
int(single_rgba.green * 255),
|
||||
int(single_rgba.blue * 255)
|
||||
)
|
||||
|
||||
self.config['general'] = {
|
||||
'refresh_interval': int(self.refresh_spin.get_value()),
|
||||
'tooltip_font_size': int(self.font_spin.get_value()) * 1000, # Convert from points to Pango units
|
||||
'startup_with_system': self.startup_check.get_active(),
|
||||
'multi_account_color': multi_hex,
|
||||
'single_account_color': single_hex
|
||||
}
|
||||
|
||||
# Save accounts
|
||||
accounts = []
|
||||
iter = self.account_store.get_iter_first()
|
||||
while iter:
|
||||
account = {
|
||||
'enabled': self.account_store.get_value(iter, 0),
|
||||
'name': self.account_store.get_value(iter, 1),
|
||||
'displayName': self.account_store.get_value(iter, 2),
|
||||
'msf_path': self.account_store.get_value(iter, 3),
|
||||
'profile_name': self.account_store.get_value(iter, 4),
|
||||
'color': self.account_store.get_value(iter, 5)
|
||||
}
|
||||
accounts.append(account)
|
||||
iter = self.account_store.iter_next(iter)
|
||||
|
||||
self.config['accounts'] = accounts
|
||||
|
||||
# Save and apply
|
||||
self.parent_app.save_config(self.config)
|
||||
self.parent_app.load_config_and_refresh()
|
||||
|
||||
# Manage autostart
|
||||
self.parent_app.manage_autostart(self.config['general']['startup_with_system'])
|
||||
|
||||
self.window.destroy()
|
||||
|
||||
def on_cancel(self, *args):
|
||||
self.window.destroy()
|
||||
return True
|
||||
|
||||
def show(self):
|
||||
self.window.show_all()
|
||||
|
||||
class ThunderPingApplet:
|
||||
"""
|
||||
Main application class for ThunderPing.
|
||||
|
||||
Monitors Thunderbird email accounts for unread messages and displays
|
||||
a system tray icon with notifications. Supports both GTK StatusIcon
|
||||
and AppIndicator depending on system availability.
|
||||
"""
|
||||
def __init__(self):
|
||||
"""Initialize the ThunderPing applet."""
|
||||
# Load configuration
|
||||
self.config = self.load_config()
|
||||
|
||||
self.menu = Gtk.Menu()
|
||||
|
||||
# Store unread counts for each account
|
||||
self.unread_counts = {}
|
||||
|
||||
self.update_selected_accounts()
|
||||
|
||||
# Create menu items
|
||||
if APPINDICATOR_AVAILABLE:
|
||||
self.indicator = AppIndicator3.Indicator.new(
|
||||
"thunderping-indicator",
|
||||
"mail-read-symbolic",
|
||||
AppIndicator3.IndicatorCategory.APPLICATION_STATUS
|
||||
)
|
||||
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
||||
self.indicator.set_menu(self.menu)
|
||||
else:
|
||||
self.status_icon = Gtk.StatusIcon()
|
||||
self.status_icon.set_from_icon_name("mail-read-symbolic")
|
||||
self.status_icon.set_tooltip_text("ThunderPing")
|
||||
self.status_icon.set_visible(True)
|
||||
self.status_icon.connect("popup-menu", self.show_menu)
|
||||
self.status_icon.connect("activate", self.on_left_click)
|
||||
self.status_icon.connect("query-tooltip", self.on_query_tooltip)
|
||||
self.status_icon.set_has_tooltip(True)
|
||||
|
||||
self.update_menu()
|
||||
|
||||
# Use refresh interval from configuration
|
||||
refresh_interval = self.config.get('general', {}).get('refresh_interval', 3)
|
||||
GLib.timeout_add_seconds(refresh_interval, self.refresh)
|
||||
self.refresh()
|
||||
|
||||
self._last_total_unread = None
|
||||
self._last_icon_path = None
|
||||
|
||||
def update_selected_accounts(self):
|
||||
"""Update the list of selected accounts"""
|
||||
accounts = self.config.get('accounts', [])
|
||||
self.selected_accounts = [acc['name'] for acc in accounts if acc.get('enabled', True)]
|
||||
|
||||
def on_query_tooltip(self, widget, x, y, keyboard_mode, tooltip):
|
||||
"""Handle custom tooltip with adjustable font size"""
|
||||
if hasattr(self, 'current_tooltip_text'):
|
||||
# Get font size from configuration
|
||||
font_size = self.config.get('general', {}).get('tooltip_font_size', 12000)
|
||||
markup_text = f'<span font_size="{font_size}">{self.current_tooltip_text}</span>'
|
||||
tooltip.set_markup(markup_text)
|
||||
return True
|
||||
return False
|
||||
|
||||
def show_menu(self, icon, button, time):
|
||||
"""Show menu when right-clicking on icon"""
|
||||
self.menu.popup(None, None, None, None, button, time)
|
||||
|
||||
def open_settings(self, *args):
|
||||
"""Open settings window"""
|
||||
settings = SettingsDialog(self)
|
||||
settings.show()
|
||||
|
||||
def load_config_and_refresh(self):
|
||||
"""Reload configuration and update everything"""
|
||||
self.config = self.load_config()
|
||||
self.update_selected_accounts()
|
||||
self.update_menu()
|
||||
self.refresh()
|
||||
|
||||
def hex_to_rgb(self, hex_color):
|
||||
"""Convert hex color to normalized RGB (0-1)"""
|
||||
try:
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r = int(hex_color[0:2], 16) / 255.0
|
||||
g = int(hex_color[2:4], 16) / 255.0
|
||||
b = int(hex_color[4:6], 16) / 255.0
|
||||
return r, g, b
|
||||
except:
|
||||
# Orange default in case of error
|
||||
return 1.0, 0.6, 0.0
|
||||
|
||||
def is_color_light(self, hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
b = int(hex_color[4:6], 16)
|
||||
luminance = 0.299*r + 0.587*g + 0.114*b
|
||||
return luminance > 186
|
||||
|
||||
def create_icon_with_number(self, count, counts=None):
|
||||
"""Create a colored icon based on the number of emails and the configured colors. If AppIndicator is available, draw the number next to the icon with the account color."""
|
||||
try:
|
||||
size = 22
|
||||
badge_width = 18 if APPINDICATOR_AVAILABLE and count > 0 else 0
|
||||
total_width = size + badge_width
|
||||
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, total_width, size)
|
||||
ctx = cairo.Context(surface)
|
||||
ctx.set_source_rgba(0, 0, 0, 0)
|
||||
ctx.paint()
|
||||
|
||||
# Load system base icon
|
||||
icon_theme = Gtk.IconTheme.get_default()
|
||||
base_icon = None
|
||||
icon_names = ["mail-unread-symbolic", "mail-read-symbolic", "mail-symbolic"]
|
||||
for icon_name in icon_names:
|
||||
try:
|
||||
base_icon = icon_theme.load_icon(icon_name, size-2, Gtk.IconLookupFlags.FORCE_SIZE)
|
||||
if base_icon:
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
# Color the icon
|
||||
if base_icon:
|
||||
if count > 0:
|
||||
color_hex = self.determine_icon_color(counts)
|
||||
r, g, b = self.hex_to_rgb(color_hex)
|
||||
ctx.set_source_rgba(b, g, r, 1.0)
|
||||
else:
|
||||
ctx.set_source_rgba(0.6, 0.6, 0.6, 1.0)
|
||||
ctx.rectangle(1, 1, size-2, size-2)
|
||||
ctx.fill()
|
||||
Gdk.cairo_set_source_pixbuf(ctx, base_icon, 1, 1)
|
||||
ctx.set_operator(cairo.OPERATOR_DEST_IN)
|
||||
ctx.paint()
|
||||
ctx.set_operator(cairo.OPERATOR_OVER)
|
||||
else:
|
||||
ctx.set_source_rgba(0.6, 0.6, 0.6, 1.0)
|
||||
ctx.rectangle(3, 5, 16, 12)
|
||||
ctx.fill()
|
||||
|
||||
# If AppIndicator is available and there are unread emails, draw the badge next to the icon
|
||||
if APPINDICATOR_AVAILABLE and count > 0:
|
||||
badge_x = size
|
||||
badge_h = size - 4
|
||||
badge_y = 2
|
||||
# Use the same color as the icon
|
||||
if count > 0:
|
||||
color_hex = self.determine_icon_color(counts)
|
||||
else:
|
||||
color_hex = '#666666'
|
||||
r, g, b = self.hex_to_rgb(color_hex)
|
||||
ctx.set_source_rgba(b, g, r, 1.0)
|
||||
ctx.arc(badge_x + badge_width/2, badge_y + badge_h/2, badge_h/2, 0, 2*3.14)
|
||||
ctx.fill()
|
||||
text_color = (1, 1, 1, 1) if not self.is_color_light(color_hex) else (0, 0, 0, 1)
|
||||
ctx.set_source_rgba(*text_color)
|
||||
font_size = 13
|
||||
ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
|
||||
ctx.set_font_size(font_size)
|
||||
text = str(count)
|
||||
xb, yb, w, h, xa, ya = ctx.text_extents(text)
|
||||
ctx.move_to(badge_x + (badge_width - w)/2, badge_y + badge_h/2 - h/2 - yb)
|
||||
ctx.show_text(text)
|
||||
|
||||
buf = surface.get_data()
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_data(
|
||||
buf, GdkPixbuf.Colorspace.RGB, True, 8, total_width, size, total_width * 4
|
||||
)
|
||||
return pixbuf
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating icon: {e}")
|
||||
return None
|
||||
|
||||
def determine_icon_color(self, counts):
|
||||
"""Determine the icon color based on account counts"""
|
||||
if not counts:
|
||||
return self.config.get('general', {}).get('single_account_color', '#0099FF')
|
||||
|
||||
# Count how many accounts have unread emails
|
||||
accounts_with_emails = [acc_name for acc_name, count in counts.items() if count > 0]
|
||||
|
||||
if len(accounts_with_emails) == 0:
|
||||
return '#666666' # Gray for no emails
|
||||
elif len(accounts_with_emails) == 1:
|
||||
account_name = accounts_with_emails[0]
|
||||
accounts = self.config.get('accounts', [])
|
||||
for acc in accounts:
|
||||
if acc['name'] == account_name:
|
||||
account_color = acc.get('color', '')
|
||||
# If account color is empty or invalid, use default
|
||||
if account_color and account_color.strip() and account_color != '#000000':
|
||||
return account_color
|
||||
else:
|
||||
return self.config.get('general', {}).get('single_account_color', '#0099FF')
|
||||
return self.config.get('general', {}).get('single_account_color', '#0099FF')
|
||||
else:
|
||||
# Multiple accounts - use multi-account color
|
||||
return self.config.get('general', {}).get('multi_account_color', '#FF6600')
|
||||
|
||||
def on_left_click(self, icon):
|
||||
"""Handle left click on icon"""
|
||||
# Toggle functionality removed
|
||||
pass
|
||||
|
||||
def manage_autostart(self, enable):
|
||||
"""Manage application autostart"""
|
||||
try:
|
||||
if enable:
|
||||
# Create autostart file
|
||||
os.makedirs(os.path.dirname(AUTOSTART_PATH), exist_ok=True)
|
||||
|
||||
# Get absolute path of current script
|
||||
script_path = os.path.abspath(__file__)
|
||||
|
||||
desktop_content = f"""[Desktop Entry]
|
||||
Type=Application
|
||||
Name=ThunderPing
|
||||
Comment=Monitor Thunderbird unread emails
|
||||
Exec=python3 "{script_path}"
|
||||
Icon=mail-unread
|
||||
Hidden=false
|
||||
NoDisplay=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
StartupNotify=false
|
||||
"""
|
||||
|
||||
with open(AUTOSTART_PATH, 'w') as f:
|
||||
f.write(desktop_content)
|
||||
|
||||
logging.info(f"Autostart enabled: {AUTOSTART_PATH}")
|
||||
else:
|
||||
# Remove autostart file if it exists
|
||||
if os.path.exists(AUTOSTART_PATH):
|
||||
os.remove(AUTOSTART_PATH)
|
||||
logging.info("Autostart disabled")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error managing autostart: {e}")
|
||||
|
||||
def load_config(self):
|
||||
"""
|
||||
Load configuration from JSON file.
|
||||
|
||||
Returns:
|
||||
dict: Configuration dictionary with general settings and accounts
|
||||
"""
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
# Create default configuration
|
||||
default_config = {
|
||||
"general": {
|
||||
"refresh_interval": 3,
|
||||
"tooltip_font_size": 12000,
|
||||
"startup_with_system": False,
|
||||
"multi_account_color": "#FF6600", # Orange for multiple accounts
|
||||
"single_account_color": "#0099FF" # Blue for single account
|
||||
},
|
||||
"accounts": []
|
||||
}
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
self.save_config(default_config)
|
||||
return default_config
|
||||
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
return config
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logging.error(f"Error loading configuration file: {e}")
|
||||
return {
|
||||
"general": {
|
||||
"refresh_interval": 3,
|
||||
"tooltip_font_size": 12000,
|
||||
"startup_with_system": False,
|
||||
"multi_account_color": "#FF6600",
|
||||
"single_account_color": "#0099FF"
|
||||
},
|
||||
"accounts": []
|
||||
}
|
||||
|
||||
def save_config(self, config):
|
||||
"""Save configuration to JSON file"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
except IOError as e:
|
||||
logging.error(f"Unable to save configuration: {e}")
|
||||
|
||||
def _parse_mork_content(self, mork_content: str) -> int:
|
||||
"""Extract unread email count from Mork content"""
|
||||
try:
|
||||
dict_pattern = re.compile(r'\((\w+)=([^)]+)\)')
|
||||
definitions = {name: code for code, name in dict_pattern.findall(mork_content)}
|
||||
|
||||
key_name = "numNewMsgs"
|
||||
unread_code = definitions.get(key_name)
|
||||
|
||||
if not unread_code:
|
||||
logging.debug(f"Key '{key_name}' not found in dictionary.")
|
||||
return 0
|
||||
|
||||
pattern = re.compile(fr'\(\^({re.escape(unread_code)})=([\da-fA-F]+)\)')
|
||||
matches = pattern.findall(mork_content)
|
||||
|
||||
if matches:
|
||||
last_match_value = matches[-1][1]
|
||||
unread_count = int(last_match_value, 16)
|
||||
logging.info(f"SUCCESS! Found final count: {unread_count}")
|
||||
return unread_count
|
||||
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_unread_counts(self) -> dict:
|
||||
"""Get unread email counts for all enabled accounts"""
|
||||
counts = {}
|
||||
accounts = self.config.get('accounts', [])
|
||||
|
||||
for acc in accounts:
|
||||
if acc['name'] not in self.selected_accounts:
|
||||
continue
|
||||
|
||||
msf_path = acc.get('msf_path')
|
||||
if not msf_path or not os.path.exists(msf_path):
|
||||
counts[acc['name']] = 0
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(msf_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
mork_content = f.read()
|
||||
counts[acc['name']] = self._parse_mork_content(mork_content)
|
||||
except Exception:
|
||||
counts[acc['name']] = 0
|
||||
|
||||
return counts
|
||||
|
||||
def _update_indicator_ui(self, total, counts):
|
||||
custom_icon = self.create_icon_with_number(total, counts)
|
||||
if APPINDICATOR_AVAILABLE:
|
||||
import tempfile
|
||||
import os
|
||||
import time
|
||||
tmp_icon_path = os.path.join(tempfile.gettempdir(), f"thunderping_icon_{total}_{int(time.time())}.png")
|
||||
# Delete the previous one
|
||||
if self._last_icon_path and os.path.exists(self._last_icon_path):
|
||||
os.remove(self._last_icon_path)
|
||||
if custom_icon:
|
||||
custom_icon.savev(tmp_icon_path, "png", [], [])
|
||||
self.indicator.set_icon_full(tmp_icon_path, "ThunderPing")
|
||||
self._last_icon_path = tmp_icon_path
|
||||
else:
|
||||
if total > 0:
|
||||
self.indicator.set_icon("mail-unread-symbolic")
|
||||
else:
|
||||
self.indicator.set_icon("mail-read-symbolic")
|
||||
self._last_total_unread = total
|
||||
else:
|
||||
if custom_icon:
|
||||
self.status_icon.set_from_pixbuf(custom_icon)
|
||||
else:
|
||||
if total > 0:
|
||||
self.status_icon.set_from_icon_name("mail-unread-symbolic")
|
||||
else:
|
||||
self.status_icon.set_from_icon_name("mail-read-symbolic")
|
||||
# Tooltip
|
||||
if total > 0:
|
||||
tooltip_text = _("📧 New emails: {}" ).format(total)
|
||||
if len(counts) > 1:
|
||||
details = []
|
||||
accounts = self.config.get('accounts', [])
|
||||
for acc_name, count in counts.items():
|
||||
if count > 0:
|
||||
display_name = acc_name
|
||||
for acc in accounts:
|
||||
if acc['name'] == acc_name:
|
||||
display_name = acc.get('displayName', acc_name)
|
||||
break
|
||||
details.append(f" • {display_name}: {count}")
|
||||
if details:
|
||||
tooltip_text += "\n" + "\n".join(details)
|
||||
self.current_tooltip_text = tooltip_text
|
||||
else:
|
||||
self.current_tooltip_text = _("📭 No new emails")
|
||||
if APPINDICATOR_AVAILABLE:
|
||||
self.indicator.set_title(self.current_tooltip_text)
|
||||
else:
|
||||
self.status_icon.set_tooltip_text(self.current_tooltip_text)
|
||||
return False
|
||||
|
||||
def refresh(self):
|
||||
"""Update email counts and interface"""
|
||||
self.unread_counts = self.get_unread_counts()
|
||||
logging.info(f"Updated counts: {self.unread_counts}")
|
||||
|
||||
total = sum(self.unread_counts.values())
|
||||
logging.info(f"Total unread emails: {total}")
|
||||
|
||||
# Interface update is delegated to GLib
|
||||
GLib.idle_add(self._update_indicator_ui, total, self.unread_counts)
|
||||
|
||||
return True # Keeps the timer active
|
||||
|
||||
def update_menu(self):
|
||||
"""Update context menu"""
|
||||
for child in self.menu.get_children():
|
||||
self.menu.remove(child)
|
||||
|
||||
# Settings Item
|
||||
settings_item = Gtk.MenuItem(label=_("⚙ Settings"))
|
||||
settings_item.connect("activate", self.open_settings)
|
||||
self.menu.append(settings_item)
|
||||
|
||||
self.menu.append(Gtk.SeparatorMenuItem())
|
||||
|
||||
# Quit Item
|
||||
quit_item = Gtk.MenuItem(label=_("Exit"))
|
||||
quit_item.connect("activate", self.quit)
|
||||
self.menu.append(quit_item)
|
||||
|
||||
self.menu.show_all()
|
||||
|
||||
def quit(self, *args):
|
||||
"""Close the application"""
|
||||
Gtk.main_quit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ThunderPingApplet()
|
||||
Gtk.main()
|
||||
130
src/generate_config.py
Normal file
130
src/generate_config.py
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ThunderPing Configuration Generator
|
||||
|
||||
This script automatically generates a configuration file for ThunderPing
|
||||
by scanning Thunderbird profiles and finding email accounts.
|
||||
"""
|
||||
import os
|
||||
import configparser
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
|
||||
# Set up basic logging to see what the script is doing
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
|
||||
HOME = os.path.expanduser("~")
|
||||
THUNDERBIRD_DIR = os.path.join(HOME, ".thunderbird")
|
||||
PROFILES_INI = os.path.join(THUNDERBIRD_DIR, "profiles.ini")
|
||||
CONFIG_PATH = os.path.expanduser("~/.config/thunderping/config.json")
|
||||
|
||||
def find_account_name_from_directory(profile_dir, account_directory):
|
||||
"""
|
||||
Find the account name in prefs.js based on the folder path.
|
||||
|
||||
Args:
|
||||
profile_dir (str): Path to the Thunderbird profile directory
|
||||
account_directory (str): Directory name of the email account
|
||||
|
||||
Returns:
|
||||
str or None: Account display name if found, None otherwise
|
||||
"""
|
||||
prefs_file = os.path.join(profile_dir, "prefs.js")
|
||||
if not os.path.exists(prefs_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(prefs_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
prefs_content = f.read()
|
||||
|
||||
# Step 1: Find the internal server ID (e.g. "server5") using the folder path.
|
||||
# Need to "quote" the path for regular expression, especially on Windows.
|
||||
server_id = None
|
||||
pattern_server = re.compile(r'user_pref\("mail\.server\.(server\d+)\.directory",\s*"{}"\)'.format(re.escape(account_directory)))
|
||||
match_server = pattern_server.search(prefs_content)
|
||||
|
||||
if match_server:
|
||||
server_id = match_server.group(1)
|
||||
logging.debug(f"Found server ID '{server_id}' for folder '{os.path.basename(account_directory)}'")
|
||||
else:
|
||||
logging.debug(f"No server ID found for folder '{os.path.basename(account_directory)}'")
|
||||
return None
|
||||
|
||||
# Step 2: Use the server ID to find the associated "name" (display name).
|
||||
pattern_name = re.compile(r'user_pref\("mail\.server\.{}\.name",\s*"([^"]+)"\)'.format(re.escape(server_id)))
|
||||
match_name = pattern_name.search(prefs_content)
|
||||
|
||||
if match_name:
|
||||
account_name = match_name.group(1)
|
||||
logging.info(f"-> Found account name: {account_name}")
|
||||
return account_name
|
||||
else:
|
||||
logging.debug(f"No 'name' found for server ID '{server_id}'")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading {prefs_file}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_config():
|
||||
"""
|
||||
Main function to create the configuration file.
|
||||
Scans Thunderbird profiles and generates ThunderPing configuration.
|
||||
"""
|
||||
if not os.path.exists(PROFILES_INI):
|
||||
logging.error(f"File not found: {PROFILES_INI}. Cannot create configuration.")
|
||||
return
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(PROFILES_INI)
|
||||
accounts = []
|
||||
|
||||
logging.info(f"Sections found in profiles.ini: {config.sections()}")
|
||||
for section in config.sections():
|
||||
if section.lower().startswith("profile"):
|
||||
logging.info(f"Analyzing profile section: {section}")
|
||||
path = config.get(section, "Path")
|
||||
is_relative = config.get(section, "IsRelative", fallback="0") == "1"
|
||||
profile_name = config.get(section, "Name", fallback=section)
|
||||
|
||||
profile_dir = os.path.join(THUNDERBIRD_DIR, path) if is_relative else path
|
||||
logging.info(f"Profile '{profile_name}' found at: {profile_dir}")
|
||||
|
||||
for base in ["Mail", "ImapMail"]:
|
||||
search_path = os.path.join(profile_dir, base, "*", "INBOX.msf")
|
||||
logging.info(f"Scanning in: {search_path}")
|
||||
for msf_path in glob.glob(search_path):
|
||||
account_directory = os.path.dirname(msf_path)
|
||||
account_folder_name = os.path.basename(account_directory)
|
||||
logging.info(f"Found server folder: {account_folder_name}")
|
||||
|
||||
# Search for account name using the full folder path.
|
||||
display_name = find_account_name_from_directory(profile_dir, account_directory)
|
||||
|
||||
accounts.append({
|
||||
"profile_name": profile_name,
|
||||
"name": account_folder_name,
|
||||
"displayName": display_name or account_folder_name,
|
||||
"msf_path": msf_path,
|
||||
"color": "#000000",
|
||||
"enabled": True
|
||||
})
|
||||
|
||||
if not accounts:
|
||||
logging.warning("No INBOX.msf files found. An empty config.json will be created.")
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
config_data = {"accounts": accounts}
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
logging.info(f"Created configuration file at '{CONFIG_PATH}' with {len(accounts)} accounts.")
|
||||
except IOError as e:
|
||||
logging.error(f"Cannot write configuration file: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_config()
|
||||
Reference in New Issue
Block a user