krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Agenda/AgendaItemText.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgCore/Agenda/AgendaItemText.swift history · blame · raw

61 lines · 3303 bytes

 1import Foundation
 2
 3/// The parts of an item's heading text the agenda rows show apart: the title alone, the
 4/// priority letter and the statistics cookie.
 5extension AgendaItem {
 6    static let priorityCookie = try! NSRegularExpression(pattern: "^\\[#([A-Z]|[0-9]+)\\][ \\t]*")
 7    static let statisticsCookie = try! NSRegularExpression(pattern: "\\[([0-9]*(?:%|/[0-9]*))\\]")
 8    static let anyTimestamp = try! NSRegularExpression(
 9        pattern: "<%%\\([^>\\n]*>|<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>(?:--<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>)?|\\[[0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]\\n]*\\](?:--\\[[0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]\\n]*\\])?"
10    )
11
12    /// `text` after the TODO keyword.
13    private var afterKeyword: String {
14        var t = Substring(text.trimmingCharacters(in: .whitespaces))
15        if let todo, t.hasPrefix(todo), t.dropFirst(todo.count).first.map({ $0 == " " || $0 == "\t" }) ?? true {
16            t = t.dropFirst(todo.count)
17        }
18        return t.trimmingCharacters(in: .whitespaces)
19    }
20
21    /// The heading's title without TODO keyword, priority cookie, statistics cookies,
22    /// timestamps or tags, links as their descriptions, runs of spaces made one. The text itself
23    /// when nothing is left.
24    public var title: String {
25        var t = afterKeyword
26        t = Self.priorityCookie.stringByReplacingMatches(in: t, range: NSRange(location: 0, length: (t as NSString).length), withTemplate: "")
27        t = Self.statisticsCookie.stringByReplacingMatches(in: t, range: NSRange(location: 0, length: (t as NSString).length), withTemplate: "")
28        t = Self.anyTimestamp.stringByReplacingMatches(in: t, range: NSRange(location: 0, length: (t as NSString).length), withTemplate: "")
29        t = Links.displayFormat(t).replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
30            .trimmingCharacters(in: .whitespaces)
31        return t.isEmpty ? text.trimmingCharacters(in: .whitespaces) : t
32    }
33
34    /// The letter or number of the priority cookie (`[#A]`), when the heading has one.
35    public var priorityLetter: String? {
36        let t = afterKeyword
37        guard let m = Self.priorityCookie.firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) else { return nil }
38        return (t as NSString).substring(with: m.range(at: 1))
39    }
40
41    /// The first statistics cookie's value: `1/3` or `50%`.
42    public var progress: String? {
43        let t = afterKeyword
44        guard let m = Self.statisticsCookie.firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) else { return nil }
45        return (t as NSString).substring(with: m.range(at: 1))
46    }
47}
48
49/// Ages and distances in days, compactly: under 14 days in days (`3d`), under 60 in whole
50/// weeks (`2w`), under a year in 30-day months (`4mo`, at most 11), then years of 365 days with
51/// any remaining months (`1y`, `1y 4mo`). Values round down; the sign is ignored.
52public enum AgendaAge {
53    public static func compact(_ days: Int) -> String {
54        let d = abs(days)
55        if d < 14 { return "\(d)d" }
56        if d < 60 { return "\(d / 7)w" }
57        if d < 365 { return "\(min(11, d / 30))mo" }
58        let months = min(11, (d % 365) / 30)
59        return months == 0 ? "\(d / 365)y" : "\(d / 365)y \(months)mo"
60    }
61}