Sources/OrgApp/AgendaViews.swift
95 lines · 3740 bytes
1import Foundation
2import OrgCore
3
4/// What the agenda window shows.
5public enum AgendaMode: Hashable, Sendable {
6 /// `org-agenda-list`; nil span and start use the settings.
7 case agenda(span: Int?, startOffset: Int?)
8 /// `org-todo-list`, optionally for `KWD1|KWD2`.
9 case todo(keywords: String?)
10 /// `org-tags-view`.
11 case match(String, todoOnly: Bool)
12 /// `org-search-view`.
13 case search(String, todoOnly: Bool)
14}
15
16/// A named view: the built-in ones and those in `views.toml`.
17public struct AgendaViewDefinition: Hashable, Sendable, Identifiable {
18 public let name: String
19 public let mode: AgendaMode
20 public var id: String { name }
21
22 public init(name: String, mode: AgendaMode) {
23 self.name = name
24 self.mode = mode
25 }
26}
27
28/// Reads `views.toml`:
29///
30/// ```toml
31/// [[view]]
32/// name = "Work"
33/// type = "match" # agenda, todo, match, todo-match, search or todo-search
34/// match = "+work-boss"
35/// ```
36///
37/// `agenda` views take `span` and `start` (days from today), `todo` views `keywords`
38/// (`WAIT|HOLD`); a `search` view's `match` is its search string.
39@MainActor
40public enum AgendaViewLoader {
41 public static var userFile: URL { UserConfig.file("views.toml") }
42
43 public static let builtIn = [
44 AgendaViewDefinition(name: "Agenda", mode: .agenda(span: nil, startOffset: nil)),
45 AgendaViewDefinition(name: "TODO List", mode: .todo(keywords: nil)),
46 ]
47
48 /// The built-in views followed by the user's, and the problems found in the file.
49 public static func load(userFile: URL = userFile) -> (views: [AgendaViewDefinition], problems: [String]) {
50 guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (builtIn, []) }
51 let tables: [TOML.Table]
52 do {
53 tables = try TOML.parse(text)
54 } catch {
55 return (builtIn, ["views.toml: \(error)"])
56 }
57 var views = builtIn
58 var problems: [String] = []
59 for table in tables where !(table.name.isEmpty && table.values.isEmpty) {
60 guard table.name == "view", table.isArrayElement else {
61 problems.append("views.toml line \(table.line): expected [[view]]")
62 continue
63 }
64 guard let name = table.values["name"]?.string else {
65 problems.append("views.toml line \(table.line): a view needs a name")
66 continue
67 }
68 let match = table.values["match"]?.string
69 let mode: AgendaMode
70 switch table.values["type"]?.string ?? "agenda" {
71 case "agenda":
72 mode = .agenda(span: table.values["span"]?.integer, startOffset: table.values["start"]?.integer)
73 case "todo":
74 mode = .todo(keywords: table.values["keywords"]?.string)
75 case "match", "todo-match":
76 guard let match else {
77 problems.append("views.toml line \(table.line): \(name) needs a match")
78 continue
79 }
80 mode = .match(match, todoOnly: table.values["type"]?.string == "todo-match")
81 case "search", "todo-search":
82 guard let match else {
83 problems.append("views.toml line \(table.line): \(name) needs a match")
84 continue
85 }
86 mode = .search(match, todoOnly: table.values["type"]?.string == "todo-search")
87 case let other:
88 problems.append("views.toml line \(table.line): unknown type \(other)")
89 continue
90 }
91 views.append(AgendaViewDefinition(name: name, mode: mode))
92 }
93 return (views, problems)
94 }
95}