An expansion list of labels guesses which subdomains exist; a '*' entry makes the guess unnecessary. parse_expansions now reports (labels, wildcard, rejected) instead of silently keeping junk, expand_entry keeps the base domain plus the configured labels and leaves '*.base' as a pattern when the wildcard is on, and the policy grows the 'allowed' masks the desktop resolver needs to serve exceptions.
364 lines
13 KiB
Python
364 lines
13 KiB
Python
"""The user-editable settings, as one validated tree.
|
|
|
|
Everything the UI can change lives here: timer lengths, which todo sources are
|
|
on, what the blocker blocks, when Telegram gets poked and in what tone. It is
|
|
stored as a single JSON row (see `app.models.SettingsRow`), which keeps
|
|
"add a new option" to a one-line change instead of a migration.
|
|
|
|
Defaults are deliberately a complete, working configuration: a fresh install is
|
|
a classic 25/5/15 pomodoro with no integrations and nothing blocked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import time
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Timer
|
|
# --------------------------------------------------------------------------
|
|
class TimerPreset(BaseModel):
|
|
"""A named, user-editable recipe of durations the UI can apply in one tap."""
|
|
|
|
name: str
|
|
work_minutes: int = Field(default=25, ge=1, le=240)
|
|
short_break_minutes: int = Field(default=5, ge=1, le=120)
|
|
long_break_minutes: int = Field(default=15, ge=1, le=240)
|
|
cycles_before_long_break: int = Field(default=4, ge=1, le=12)
|
|
|
|
|
|
def _default_timer_presets() -> list[TimerPreset]:
|
|
return [
|
|
TimerPreset(
|
|
name="Classic", work_minutes=45, short_break_minutes=15, long_break_minutes=30
|
|
),
|
|
TimerPreset(
|
|
name="Пара", work_minutes=85, short_break_minutes=10, long_break_minutes=10
|
|
),
|
|
]
|
|
|
|
|
|
class PomodoroSettings(BaseModel):
|
|
"""Lengths and automation of the timer itself. All durations in minutes."""
|
|
|
|
work_minutes: int = Field(default=25, ge=1, le=240)
|
|
short_break_minutes: int = Field(default=5, ge=1, le=120)
|
|
long_break_minutes: int = Field(default=15, ge=1, le=240)
|
|
cycles_before_long_break: int = Field(default=4, ge=1, le=12)
|
|
|
|
presets: list[TimerPreset] = Field(default_factory=_default_timer_presets)
|
|
"""Saved work/break combinations, applied to the fields above in one tap."""
|
|
|
|
auto_start_breaks: bool = True
|
|
"""Roll straight into the break when a work interval ends."""
|
|
|
|
auto_start_work: bool = False
|
|
"""Roll straight into the next work interval when a break ends."""
|
|
|
|
daily_goal_pomodoros: int = Field(default=8, ge=1, le=40)
|
|
"""The bar the daily report measures you against."""
|
|
|
|
strict_mode: bool = False
|
|
"""Abandoning a running work interval requires a reason, and is counted."""
|
|
|
|
allow_pause: bool = True
|
|
"""When false the only ways out of a work interval are finishing or abandoning."""
|
|
|
|
count_interruptions: bool = True
|
|
|
|
tick_sound: bool = False
|
|
alarm_sound: bool = True
|
|
alarm_volume: int = Field(default=70, ge=0, le=100)
|
|
|
|
timer_enabled: bool = True
|
|
"""When false the timer controls are hidden — the app becomes view-only."""
|
|
|
|
desktop_notifications: bool = True
|
|
keep_on_top_during_work: bool = False
|
|
"""Desktop app: float the window above everything while a work interval runs."""
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Todo sources
|
|
# --------------------------------------------------------------------------
|
|
class JiraSourceSettings(BaseModel):
|
|
"""Jira Cloud, authenticated with account email + API token (Basic auth)."""
|
|
|
|
enabled: bool = False
|
|
base_url: str = ""
|
|
email: str = ""
|
|
api_token: str = ""
|
|
|
|
extra_jql: str = ""
|
|
"""ANDed onto every query, e.g. `assignee = currentUser()`."""
|
|
|
|
start_date_field: str = "customfield_10015"
|
|
"""Jira's "Start date"; its id differs per site (see /rest/api/3/field)."""
|
|
|
|
include_all_open: bool = True
|
|
"""Fetch every open issue, not only the ones due today or overdue."""
|
|
|
|
max_issues: int = Field(default=200, ge=1, le=2000)
|
|
|
|
|
|
class GoogleTasksSourceSettings(BaseModel):
|
|
"""Google Tasks, via an OAuth refresh token (see jira-helper's README)."""
|
|
|
|
enabled: bool = False
|
|
client_id: str = ""
|
|
client_secret: str = ""
|
|
refresh_token: str = ""
|
|
lists: list[str] = Field(default_factory=list)
|
|
"""Titles or ids to limit to. Empty = every list in the account."""
|
|
|
|
include_undated: bool = True
|
|
"""Keep tasks with no due date (jira-helper drops them; here they are todos)."""
|
|
|
|
|
|
class TodoistSourceSettings(BaseModel):
|
|
"""Todoist, via a personal API token."""
|
|
|
|
enabled: bool = False
|
|
api_token: str = ""
|
|
projects: list[str] = Field(default_factory=list)
|
|
"""Names or ids to limit to. Empty = every project."""
|
|
|
|
filter_query: str = ""
|
|
"""Override the server-side filter. Empty = all open tasks."""
|
|
|
|
|
|
class LaundrySourceSettings(BaseModel):
|
|
"""Campus laundry bookings — not a todo, but a thing that owns your time."""
|
|
|
|
enabled: bool = False
|
|
base_url: str = "https://4ka.inbicst.ru"
|
|
session_cookie: str = ""
|
|
"""The `sessionid` cookie, or a whole `name=value; ...` cookie string."""
|
|
|
|
|
|
class SourcesSettings(BaseModel):
|
|
jira: JiraSourceSettings = Field(default_factory=JiraSourceSettings)
|
|
google_tasks: GoogleTasksSourceSettings = Field(
|
|
default_factory=GoogleTasksSourceSettings
|
|
)
|
|
todoist: TodoistSourceSettings = Field(default_factory=TodoistSourceSettings)
|
|
laundry: LaundrySourceSettings = Field(default_factory=LaundrySourceSettings)
|
|
|
|
refresh_interval_minutes: int = Field(default=15, ge=1, le=1440)
|
|
"""How often the background job re-pulls every source."""
|
|
|
|
request_timeout_seconds: int = Field(default=30, ge=5, le=120)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Blocker (enforced by the desktop app)
|
|
# --------------------------------------------------------------------------
|
|
class BlockWindow(BaseModel):
|
|
"""A recurring time window in which blocking is on regardless of the timer."""
|
|
|
|
label: str = ""
|
|
start: time = time(9, 0)
|
|
end: time = time(18, 0)
|
|
days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4])
|
|
"""ISO weekdays, Monday = 0 .. Sunday = 6."""
|
|
|
|
|
|
class BlockerSettings(BaseModel):
|
|
enabled: bool = False
|
|
|
|
mode: Literal["during_work", "always", "scheduled"] = "during_work"
|
|
"""`during_work` blocks only while a work interval runs — the useful default."""
|
|
|
|
block_during_breaks: bool = False
|
|
blocked_domains: list[str] = Field(default_factory=list)
|
|
"""Hostnames or masks.
|
|
|
|
`youtube.com` blocks the site (plus `www.` and `m.`); `*.youtube.com` blocks
|
|
its subdomains too, expanded with `subdomain_expansions` because a hosts file
|
|
has no wildcards of its own. Put `*` in that list and `*.youtube.com` means
|
|
every subdomain — the desktop app answers those at DNS level, the phone
|
|
matches them in the URL bar.
|
|
"""
|
|
|
|
allowed_domains: list[str] = Field(default_factory=list)
|
|
"""Exceptions, masks allowed: `music.youtube.com`, `*.docs.google.com`."""
|
|
|
|
subdomain_expansions: list[str] = Field(
|
|
default_factory=lambda: ["www", "m", "mobile", "app", "web", "i", "old", "new"]
|
|
)
|
|
"""What `*.example.com` turns into. Add the subdomains you actually visit.
|
|
|
|
A bare `*` means *every* subdomain instead: the hosts file still only gets the
|
|
labels listed here, while the rest is caught by the phone's URL-bar matcher and
|
|
by the desktop app's local resolver stub. Anything else that is not a DNS label
|
|
is ignored and reported as `invalid_expansions`.
|
|
"""
|
|
|
|
blocked_apps: list[str] = Field(default_factory=list)
|
|
"""Process names or masks: `steam`, `*discord*`, `telegram-*`."""
|
|
|
|
kill_blocked_apps: bool = True
|
|
"""Terminate a blocked app when it appears; otherwise only warn and log."""
|
|
|
|
schedule: list[BlockWindow] = Field(default_factory=list)
|
|
strict: bool = False
|
|
"""The desktop app refuses to switch blocking off while a work interval runs."""
|
|
|
|
redirect_ip: str = "127.0.0.1"
|
|
app_poll_seconds: int = Field(default=5, ge=1, le=60)
|
|
notify_on_block: bool = True
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Notifications and reports
|
|
# --------------------------------------------------------------------------
|
|
class QuietHours(BaseModel):
|
|
enabled: bool = False
|
|
start: time = time(23, 0)
|
|
end: time = time(8, 0)
|
|
|
|
|
|
class TelegramSettings(BaseModel):
|
|
"""Everything about the bot that nags, congratulates and reports."""
|
|
|
|
enabled: bool = False
|
|
bot_token: str = ""
|
|
chat_id: str = ""
|
|
|
|
commands_enabled: bool = True
|
|
"""Run a long-polling bot so /today, /report, /stats work in the chat."""
|
|
|
|
daily_report: bool = True
|
|
daily_report_time: time = time(21, 0)
|
|
|
|
morning_briefing: bool = True
|
|
"""A "here is your day" message with the todo list and yesterday's streak."""
|
|
|
|
morning_briefing_time: time = time(9, 0)
|
|
|
|
weekly_report: bool = True
|
|
weekly_report_weekday: int = Field(default=6, ge=0, le=6)
|
|
"""ISO weekday for the weekly wrap-up, Monday = 0 (default Sunday)."""
|
|
|
|
celebrate_every_n_pomodoros: int = Field(default=4, ge=0, le=50)
|
|
"""Cheer in the chat every N completed pomodoros today. 0 = never."""
|
|
|
|
nudge_enabled: bool = True
|
|
nudge_after_idle_minutes: int = Field(default=90, ge=10, le=600)
|
|
"""Poke you when you have not run a pomodoro for this long during the day."""
|
|
|
|
nudge_window_start: time = time(10, 0)
|
|
nudge_window_end: time = time(20, 0)
|
|
nudge_max_per_day: int = Field(default=4, ge=0, le=24)
|
|
|
|
goal_warning: bool = True
|
|
"""Warn in the evening when the daily goal is still out of reach."""
|
|
|
|
quiet_hours: QuietHours = Field(default_factory=QuietHours)
|
|
|
|
|
|
class SlackSettings(BaseModel):
|
|
enabled: bool = False
|
|
webhook_url: str = ""
|
|
|
|
|
|
class ReportSettings(BaseModel):
|
|
tone: Literal["friendly", "coach", "drill_sergeant", "dry"] = "friendly"
|
|
language: Literal["en", "ru"] = "en"
|
|
include_funny_stats: bool = True
|
|
include_task_list: bool = True
|
|
include_blocked_sites: bool = True
|
|
include_achievements: bool = True
|
|
include_comparison: bool = True
|
|
"""Compare today against your 7-day average."""
|
|
|
|
max_tasks_listed: int = Field(default=15, ge=1, le=100)
|
|
|
|
|
|
class NotificationsSettings(BaseModel):
|
|
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
|
|
slack: SlackSettings = Field(default_factory=SlackSettings)
|
|
report: ReportSettings = Field(default_factory=ReportSettings)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Web login
|
|
# --------------------------------------------------------------------------
|
|
class AuthSettings(BaseModel):
|
|
"""Gates the browser UI. The desktop app and scripts keep using `API_TOKEN`.
|
|
|
|
The web UI is closed the moment either credential is configured: a password
|
|
(`password_hash` set) or Telegram sign-in (`telegram_login_enabled` with an
|
|
`telegram_owner_id`). With neither set, the browser is open — the same
|
|
"friction-free on a laptop" default `API_TOKEN` already has.
|
|
"""
|
|
|
|
username: str = "timka"
|
|
password_hash: str = ""
|
|
"""`pbkdf2_sha256$<iterations>$<salt>$<hash>`, never the plaintext password."""
|
|
|
|
telegram_login_enabled: bool = False
|
|
telegram_owner_id: str = ""
|
|
"""The numeric Telegram user id allowed to sign in with the Login Widget."""
|
|
|
|
telegram_bot_username: str = ""
|
|
"""Without the `@`, e.g. `kadence_bot` — the widget needs it to render."""
|
|
|
|
session_days: int = Field(default=30, ge=1, le=365)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Root
|
|
# --------------------------------------------------------------------------
|
|
class AppearanceSettings(BaseModel):
|
|
theme: Literal["light", "dark", "system"] = "system"
|
|
accent: str = "#6750A4"
|
|
"""Material 3 seed colour; the whole palette is derived from it."""
|
|
|
|
compact: bool = False
|
|
|
|
|
|
class AppSettings(BaseModel):
|
|
"""The single settings document stored in the database."""
|
|
|
|
timezone: str = "Europe/Moscow"
|
|
week_starts_monday: bool = True
|
|
|
|
pomodoro: PomodoroSettings = Field(default_factory=PomodoroSettings)
|
|
sources: SourcesSettings = Field(default_factory=SourcesSettings)
|
|
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
|
|
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
|
|
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
|
auth: AuthSettings = Field(default_factory=AuthSettings)
|
|
|
|
@field_validator("timezone")
|
|
@classmethod
|
|
def _known_timezone(cls, value: str) -> str:
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
try:
|
|
ZoneInfo(value)
|
|
except (ZoneInfoNotFoundError, ValueError) as exc: # pragma: no cover
|
|
raise ValueError(f"unknown timezone: {value!r}") from exc
|
|
return value
|
|
|
|
|
|
# Keys whose values are never sent to a browser in full; the API replaces them
|
|
# with a masked placeholder and the UI sends the placeholder back unchanged.
|
|
SECRET_FIELDS = frozenset(
|
|
{
|
|
"api_token",
|
|
"client_secret",
|
|
"refresh_token",
|
|
"session_cookie",
|
|
"bot_token",
|
|
"webhook_url",
|
|
"password_hash",
|
|
}
|
|
)
|
|
|
|
SECRET_MASK = "••••••••"
|