Upload files to "src"

This commit is contained in:
2025-09-14 17:44:47 +02:00
parent ce0ac83569
commit c634a551f1
2 changed files with 1060 additions and 0 deletions

930
src/applet.py Normal file
View 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
View 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()