213 lines
6.1 KiB
Nim
213 lines
6.1 KiB
Nim
import cliutils, docopt, json, logging, os, nre, random, sequtils,
|
|
times, timeutils
|
|
|
|
from posix import SIGTERM, SIGHUP, signal, kill, Pid
|
|
import strutils except toUpper
|
|
from unicode import toUpper
|
|
|
|
type
|
|
ParseState = enum BeforeHeading, ReadingPlans, AfterPlans
|
|
PlanItem* = tuple[time: TimeInfo, note: string]
|
|
DPConfig = tuple[planDir, dateFmt, pidfile, logfile, errfile: string,
|
|
notificationSecs: int]
|
|
|
|
const VERSION = "0.3.3"
|
|
const NOTE_TITLE = "Daily Notifier v" & VERSION
|
|
const timeFmt = "HH:mm"
|
|
|
|
var args: Table[string, Value]
|
|
var cfg: DPConfig
|
|
let appName = getAppFilename()
|
|
let soundsDir: string = appName[0..(appName.len-15)] & "sounds/navi"
|
|
let soundFiles = seqUtils.toSeq(walkFiles(soundsDir & "/*"))
|
|
randomize()
|
|
|
|
proc parseDailyPlan(filename: string): seq[PlanItem] =
|
|
|
|
debug "Parsing daily plan file: " & filename
|
|
|
|
result = @[]
|
|
|
|
var parseState = BeforeHeading
|
|
let planItemRe = re"\s*(\d{4})\s+(.*)"
|
|
const timeFmt = "HHmm"
|
|
|
|
for line in lines filename:
|
|
if line.strip.len == 0: continue
|
|
case parseState
|
|
|
|
of BeforeHeading:
|
|
if line.strip.startsWith("## Timeline"): parseState = ReadingPlans
|
|
|
|
of ReadingPlans:
|
|
let match = line.find(planItemRe)
|
|
if match.isSome(): result.add((
|
|
time: parse(match.get().captures[0], timeFmt),
|
|
note: match.get().captures[1]))
|
|
else: parseState = AfterPlans
|
|
|
|
of AfterPlans: break
|
|
else: break
|
|
|
|
debug "Found " & $result.len & " items."
|
|
|
|
proc doAndLog(cmd: string): void =
|
|
debug "Executing '" & cmd & "'"
|
|
discard execShellCmd(cmd)
|
|
|
|
proc notifyDailyPlanItem(item: PlanItem): void =
|
|
|
|
let desc = item.time.format(timeFmt) & " - " & item.note
|
|
debug "Notifying: " & desc
|
|
|
|
let soundFile = soundFiles[random(soundFiles.len)]
|
|
case hostOS
|
|
of "macosx":
|
|
doAndLog "osascript -e 'display notification \"" &
|
|
desc & "\" with title \"" & NOTE_TITLE & "\"'"
|
|
doAndLog "ogg123 \"" & soundFile & "\""
|
|
|
|
of "linux":
|
|
doAndLog "notify-send '" & NOTE_TITLE & "' '" & desc & "'"
|
|
doAndLog "paplay \"" & soundFile & "\""
|
|
|
|
else: quit("Unsupported host OS: '" & hostOS & "'.")
|
|
|
|
proc loadConfig(s: cint): void {. exportc, noconv .} =
|
|
|
|
# Find and parse the .dailynotificationrc file
|
|
let rcLocations = @[
|
|
if args["--config"]: $args["--config"] else:"",
|
|
".dailynotificationrc", $getEnv("DAILY_NOTIFICATION_RC"),
|
|
$getEnv("HOME") & "/.dailynotificationrc"]
|
|
|
|
var rcFilename: string =
|
|
foldl(rcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
|
|
|
|
debug "Reloading config file from " & rcFilename
|
|
|
|
var jsonCfg: JsonNode
|
|
try: jsonCfg = parseFile(rcFilename)
|
|
except: jsonCfg = newJObject()
|
|
|
|
var ccfg = CombinedConfig(docopt: args, json: jsonCfg)
|
|
|
|
cfg = (
|
|
ccfg.getVal("plans-directory", "plans"),
|
|
ccfg.getVal("date-format", "yyyy-MM-dd"),
|
|
ccfg.getVal("pidfile", "/tmp/daily-plans.pid"),
|
|
ccfg.getVal("logfile", "/tmp/daily-plans.log"),
|
|
ccfg.getVal("errfile", "/tmp/daily-plans.error.log"),
|
|
parseInt(ccfg.getVal("notification-seconds", "600"))
|
|
)
|
|
|
|
if not existsDir(cfg.planDir):
|
|
quit("daily_notifier: plan directory does not exist: '" & cfg.planDir & "'", 1)
|
|
|
|
proc mainLoop(args: Table[string, Value]): void =
|
|
|
|
debug "Started daemon main loop."
|
|
loadConfig(0)
|
|
signal(SIGHUP, loadConfig)
|
|
var curDay: TimeInfo = getLocalTime(fromSeconds(0))
|
|
var todaysFile = "nofile"
|
|
var lastModTime = fromSeconds(0)
|
|
var todaysItems: seq[PlanItem] = @[]
|
|
|
|
while true:
|
|
|
|
# Check the date
|
|
let now = getLocalTime(getTime())
|
|
let today = startOfDay(now)
|
|
|
|
# If we need to change day, look for a new file.
|
|
if today != curDay:
|
|
curDay = today
|
|
todaysFile = cfg.planDir & "/" & today.format(cfg.dateFmt) & "-plan.md"
|
|
todaysItems = @[]
|
|
lastModTime = fromSeconds(0)
|
|
|
|
# Check for our plan file.
|
|
if fileExists(todaysFile):
|
|
let fileModTime = getLastModificationTime(todaysFile)
|
|
|
|
# Check if the file has been modified or we have no itmes
|
|
if fileModTime > lastModTime or todaysItems.len == 0:
|
|
lastModTime = fileModTime
|
|
todaysItems = parseDailyPlan(todaysFile)
|
|
|
|
# Check to see if any items are happening soon.
|
|
let cutoff = now + seconds(cfg.notificationSecs)
|
|
let needsNotification = todaysItems.filterIt(it.time > now and it.time < cutoff)
|
|
|
|
todaysItems.keepItIf(not needsNotification.contains(it))
|
|
|
|
for item in needsNotification:
|
|
notifyDailyPlanItem(item)
|
|
|
|
# Sleep for a while
|
|
sleep(30 * 1000)
|
|
|
|
when isMainModule:
|
|
|
|
let doc = """
|
|
Usage:
|
|
daily_notifier start [options]
|
|
daily_notifier reconfigure [options]
|
|
daily_notifier stop
|
|
|
|
Options:
|
|
|
|
-d --plans-directory <dir> Directory to search for plan files.
|
|
|
|
-f --date-format <fmt>
|
|
|
|
Date pattern for identifying plan files. This is used in conjunction
|
|
with plan directory to idenfity files that should be parsed as plan files.
|
|
|
|
-c --config <cfgFile> Use <cfgFile> as the source of configuration for
|
|
daily-notification.
|
|
|
|
-p --pidfile <pidfile> Daemon PID filename
|
|
-L --logfile <logfile> Log file.
|
|
-E --errfile <errfile> Error file.
|
|
-h --help Print this usage information.
|
|
|
|
-s --notification-seconds <sec>
|
|
-v --verbose Enable verbose output.
|
|
|
|
Notification period for plan items.
|
|
|
|
"""
|
|
|
|
args = docopt(doc, version = "daily_notifier " & VERSION)
|
|
|
|
if args["--help"]:
|
|
echo doc
|
|
quit()
|
|
|
|
loadConfig(0)
|
|
|
|
addHandler(newConsoleLogger(
|
|
if args["--verbose"]: lvlAll
|
|
else: lvlInfo))
|
|
|
|
if args["start"]:
|
|
# Start our daemon process (if needed)
|
|
info "daily_notifier: Starting daemon."
|
|
let childPid = daemonize(cfg.pidfile, "/dev/null", cfg.logfile, cfg.errfile, proc(): void = mainLoop(args))
|
|
if childPid == 0: quit(QuitSuccess) # We are the child... don't need to do anything else.
|
|
info "daily_notifier: Started, pid: " & $childPid
|
|
|
|
elif args["stop"] or args["reconfigure"]:
|
|
|
|
if not fileExists(cfg.pidfile):
|
|
info "daily_notifier: not running"
|
|
quit(QuitSuccess)
|
|
|
|
let pid: Pid = cast[Pid] (parseInt(readFile(cfg.pidfile).strip))
|
|
info "daily_notifier: Killing process " & $pid
|
|
|
|
if args["stop"]: discard kill(pid, SIGTERM)
|
|
else: discard kill(pid, SIGHUP)
|