180 lines
5.4 KiB
Python
180 lines
5.4 KiB
Python
"""Configuration system for Mail email reader using Pydantic."""
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Literal, Optional
|
|
|
|
import toml
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TaskBackendConfig(BaseModel):
|
|
"""Configuration for task management backend."""
|
|
|
|
backend: Literal["taskwarrior", "dstask"] = "taskwarrior"
|
|
taskwarrior_path: str = "task"
|
|
dstask_path: str = Field(
|
|
default_factory=lambda: str(Path.home() / ".local" / "bin" / "dstask")
|
|
)
|
|
|
|
|
|
class EnvelopeDisplayConfig(BaseModel):
|
|
"""Configuration for envelope list item rendering."""
|
|
|
|
# Sender display
|
|
max_sender_length: int = 25
|
|
|
|
# Date/time display
|
|
date_format: str = "%m/%d"
|
|
time_format: str = "%H:%M"
|
|
show_date: bool = True
|
|
show_time: bool = True
|
|
|
|
# Grouping
|
|
group_by: Literal["relative", "absolute"] = "relative"
|
|
# relative: "Today", "Yesterday", "This Week", etc.
|
|
# absolute: "December 2025", "November 2025", etc.
|
|
|
|
# Layout
|
|
lines: Literal[2, 3] = 2
|
|
# 2: sender/date on line 1, subject on line 2
|
|
# 3: sender/date on line 1, subject on line 2, preview on line 3
|
|
|
|
show_checkbox: bool = True
|
|
show_preview: bool = False # Only used when lines=3
|
|
|
|
# NerdFont icons for status
|
|
icon_unread: str = "\uf0e0" # nf-fa-envelope (filled)
|
|
icon_read: str = "\uf2b6" # nf-fa-envelope_open (open)
|
|
icon_flagged: str = "\uf024" # nf-fa-flag
|
|
icon_attachment: str = "\uf0c6" # nf-fa-paperclip
|
|
|
|
|
|
class KeybindingsConfig(BaseModel):
|
|
"""Keybinding customization."""
|
|
|
|
next_message: str = "j"
|
|
prev_message: str = "k"
|
|
delete: str = "#"
|
|
archive: str = "e"
|
|
open_by_id: str = "o"
|
|
quit: str = "q"
|
|
toggle_header: str = "h"
|
|
create_task: str = "t"
|
|
reload: str = "%"
|
|
toggle_sort: str = "s"
|
|
toggle_selection: str = "space"
|
|
clear_selection: str = "escape"
|
|
scroll_page_down: str = "pagedown"
|
|
scroll_page_down: str = "space"
|
|
scroll_page_up: str = "b"
|
|
toggle_main_content: str = "w"
|
|
open_links: str = "l"
|
|
toggle_view_mode: str = "m"
|
|
|
|
|
|
class ContentDisplayConfig(BaseModel):
|
|
"""Configuration for message content display."""
|
|
|
|
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
|
|
default_view_mode: Literal["markdown", "html"] = "markdown"
|
|
|
|
|
|
class LinkPanelConfig(BaseModel):
|
|
"""Configuration for the link panel."""
|
|
|
|
# Whether to close the panel after opening a link
|
|
close_on_open: bool = False
|
|
|
|
|
|
class MailOperationsConfig(BaseModel):
|
|
"""Configuration for mail operations."""
|
|
|
|
# Folder to move messages to when archiving
|
|
archive_folder: str = "Archive"
|
|
|
|
|
|
class ThemeConfig(BaseModel):
|
|
"""Theme/appearance settings."""
|
|
|
|
theme_name: str = "monokai"
|
|
|
|
|
|
class MailAppConfig(BaseModel):
|
|
"""Main configuration for Mail email reader."""
|
|
|
|
task: TaskBackendConfig = Field(default_factory=TaskBackendConfig)
|
|
envelope_display: EnvelopeDisplayConfig = Field(
|
|
default_factory=EnvelopeDisplayConfig
|
|
)
|
|
content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig)
|
|
link_panel: LinkPanelConfig = Field(default_factory=LinkPanelConfig)
|
|
mail: MailOperationsConfig = Field(default_factory=MailOperationsConfig)
|
|
keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig)
|
|
theme: ThemeConfig = Field(default_factory=ThemeConfig)
|
|
|
|
@classmethod
|
|
def get_config_path(cls) -> Path:
|
|
"""Get the path to the config file."""
|
|
# Check environment variable first
|
|
env_path = os.getenv("LUK_MAIL_CONFIG")
|
|
if env_path:
|
|
return Path(env_path)
|
|
|
|
# Default to ~/.config/luk/mail.toml
|
|
return Path.home() / ".config" / "luk" / "mail.toml"
|
|
|
|
@classmethod
|
|
def load(cls, config_path: Optional[Path] = None) -> "MailAppConfig":
|
|
"""Load config from TOML file with defaults for missing values."""
|
|
if config_path is None:
|
|
config_path = cls.get_config_path()
|
|
|
|
if config_path.exists():
|
|
try:
|
|
with open(config_path, "r") as f:
|
|
data = toml.load(f)
|
|
logger.info(f"Loaded config from {config_path}")
|
|
return cls.model_validate(data)
|
|
except Exception as e:
|
|
logger.warning(f"Error loading config from {config_path}: {e}")
|
|
logger.warning("Using default configuration")
|
|
return cls()
|
|
else:
|
|
logger.info(f"No config file at {config_path}, using defaults")
|
|
return cls()
|
|
|
|
def save(self, config_path: Optional[Path] = None) -> None:
|
|
"""Save current config to TOML file."""
|
|
if config_path is None:
|
|
config_path = self.get_config_path()
|
|
|
|
# Ensure parent directory exists
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(config_path, "w") as f:
|
|
toml.dump(self.model_dump(), f)
|
|
logger.info(f"Saved config to {config_path}")
|
|
|
|
|
|
# Global config instance (lazy-loaded)
|
|
_config: Optional[MailAppConfig] = None
|
|
|
|
|
|
def get_config() -> MailAppConfig:
|
|
"""Get the global config instance, loading it if necessary."""
|
|
global _config
|
|
if _config is None:
|
|
_config = MailAppConfig.load()
|
|
return _config
|
|
|
|
|
|
def reload_config() -> MailAppConfig:
|
|
"""Force reload of the config from disk."""
|
|
global _config
|
|
_config = MailAppConfig.load()
|
|
return _config
|