CutBox CLI

Coverage Report

Created: 2024-03-12 03:43

.../Sources/CutBoxCLICore/CommandParams.swift
Line
Count
Source
1
import Foundation
2
3
107
func usageInfo() -> String {
4
107
    return """
5
107
    CutBox history CLI
6
107
    ==================
7
107
8
107
    Display items from CutBox history. Most recent items first.
9
107
10
107
        cutbox [options]
11
107
12
107
    Options:
13
107
    ========
14
107
15
107
    Search
16
107
    ------
17
107
18
107
        -f or --fuzzy <query>   Fuzzy match items (case insensitive)
19
107
        -r or --regex <query>   Regexp match items
20
107
        -i or --regexi <query>  Regexp match items (case insensitive)
21
107
        -e or --exact <string>  Exact substring match items (case sensitive)
22
107
23
107
    Filtering
24
107
    ---------
25
107
26
107
        -l or --limit <num>     Limit to num items
27
107
        -F or --favorites       Only list favorites
28
107
        -M or --missing-date    Only list items missing a date (copied pre CutBox v1.5.5)
29
107
30
107
    Filter by time units e.g. 7d, 1min, 5hr, 30s, 25sec, 3days, 2wks, 1.5hours, etc.
31
107
    Supports seconds, minutes, hours, days, weeks.
32
107
33
107
        --since <time>
34
107
        --before <time>
35
107
36
107
    Filter by ISO 8601 date e.g. 2023-06-05T09:21:59Z
37
107
38
107
        --since-date <date>
39
107
        --before-date <date>
40
107
41
107
    Info
42
107
    ----
43
107
44
107
        --version               Show the current version
45
107
        -h or --help            Show this help page
46
107
    """
47
107
}
48
49
/// Manages command parameters
50
///
51
/// After parsing these properties are available to other objects:
52
/// - query - search text/pattern
53
/// - searchMode - fuzzy, regex, regexi, exact
54
/// - beforeDate - limit search to items before date
55
/// - sinceDate - limit search to items since date
56
/// - favorites - limit search to favorites
57
/// - limit - limit search by num results printed
58
/// - missingDate - a check for items saved ≤ v1.5.8
59
///
60
/// The parser removes valid arguments, remaining arguments are
61
/// collected as errors.
62
class CommandParams {
63
    var query: String?
64
    var limit: Int?
65
    var beforeDate: TimeInterval?
66
    var sinceDate: TimeInterval?
67
    var searchMode: SearchMode?
68
    var missingDate: Bool = false
69
    var favorites: Bool = false
70
    var showTime: Bool = false
71
106
    var errors: [(String, String)] = []
72
    var arguments: [String]
73
    var infoFlags: Bool = false
74
75
    private var out: Output
76
77
    /// Initialize with `Output` & arguments.
78
    /// Parsing begins automatically at init.
79
106
    init(out: Output, arguments: [String]) {
80
106
        self.out = out
81
106
        self.arguments = arguments
82
106
        parse()
83
106
    }
84
85
    /// Check for a flag in arguments
86
1.25k
    func hasFlag(_ flag: String) -> Bool {
87
1.25k
        if self.arguments.contains(flag) {
88
7
            removeFlagFromArguments(flag)
89
7
            return true
90
1.25k
        }
91
1.25k
        return false
92
1.25k
    }
93
94
    /// Check for array of flags in arguments (variadic)
95
315
    func hasFlag(_ flags: String...) -> Bool {
96
315
        return hasFlag(flags)
97
315
    }
98
99
    /// Check for array of flags in arguments (array)
100
526
    func hasFlag(_ flags: [String]) -> Bool {
101
1.78k
        return flags.contains(where: hasFlag)
.../$s13CutBoxCLICore13CommandParamsC7hasFlagySbSaySSGFSbSScACcfu_
Line
Count
Source
101
526
        return flags.contains(where: hasFlag)
.../$s13CutBoxCLICore13CommandParamsC7hasFlagySbSaySSGFSbSScACcfu_SbSScfu0_
Line
Count
Source
101
1.25k
        return flags.contains(where: hasFlag)
102
526
    }
103
104
    /// Remove a flag from arguments
105
    ///
106
    /// Arguments remaining after parse are treated as errors.
107
7
    func removeFlagFromArguments(_ arg: String) {
108
7
        if let argIndex = self.arguments.firstIndex(of: arg) {
109
7
            self.arguments.remove(at: argIndex)
110
7
        }
111
7
    }
112
113
    /// Remove an argument and value by arg name
114
    ///
115
    /// Arguments remaining after parse are treated as errors.
116
25
    func removeOptionWithValueFromArguments<T>(_ arg: String, _ value: T?) {
117
25
        if let argIndex = self.arguments.firstIndex(of: arg) {
118
25
            self.arguments.remove(at: argIndex)
119
25
            if let value = value, let valueIndex = self.arguments
120
25
                .firstIndex(of: String(describing: value)) {
121
25
                self.arguments.remove(at: valueIndex)
122
25
            }
123
25
        }
124
25
    }
125
126
    /// Print all errors
127
7
    func printErrors() {
128
17
        errors.forEach(printError)
.../$s13CutBoxCLICore13CommandParamsC11printErrorsyyFySS_SSt_tcACcfu_
Line
Count
Source
128
7
        errors.forEach(printError)
.../$s13CutBoxCLICore13CommandParamsC11printErrorsyyFySS_SSt_tcACcfu_ySS_SSt_tcfu0_
Line
Count
Source
128
10
        errors.forEach(printError)
129
7
    }
130
131
    /// Print an error
132
10
    func printError(error: (String, String)) {
133
10
        out.error("Invalid argument: \(error.0) \(error.1)")
134
10
    }
135
136
    /// Check arguments for options (variadic)
137
    ///
138
    /// Parse option(s) (variadic) for their paired value and removed
139
    /// from arguments. The value of type T is returned when T is
140
    /// String, Int or Double.
141
    ///
142
    /// - Warning: **Fatal assertion** thrown if T is an unsupported type.
143
1.37k
    func hasOption<T>(_ options: String...) -> T? {
144
1.37k
        let args = self.arguments
145
1.37k
        guard let index = args.firstIndex(where: { options.contains($0) }),
146
1.37k
              let valueIndex = args.index(index, offsetBy: 1, limitedBy: args.endIndex),
147
1.37k
              !args[valueIndex].starts(with: "-") else {
148
1.34k
            return nil
149
1.34k
        }
150
26
151
26
        let value = args[valueIndex]
152
26
153
26
        switch T.self {
154
26
        case is String.Type:
155
20
            removeOptionWithValueFromArguments(args[index], value)
156
20
            return value as? T
157
26
        case is Int.Type:
158
4
            removeOptionWithValueFromArguments(args[index], value)
159
4
            return Int(value) as? T
160
26
        case is Double.Type:
161
1
            removeOptionWithValueFromArguments(args[index], value)
162
1
            return Double(value) as? T
163
26
        default:
164
1
            fatalError("hasOpt only supports T.Type of String?, Double? or Int? ")
165
26
        }
166
26
    }
167
168
    /// Collect an error, add it to errors.
169
16
    func collectError(_ option: String, _ value: Any, description: String = "") {
170
16
        errors.append((option, String(describing: value)))
171
16
    }
172
173
    /// Collect all errors from arguments.
174
    ///
175
    /// Note when this is called, all arguments supplied are considered errors.
176
107
    func collectErrors(_ arguments: [String]) {
177
107
        var currentOption: String?
178
107
        var currentValue: String = ""
179
107
180
107
        for arg in arguments {
181
18
            if arg.hasPrefix("-") {
182
12
                if let opt = currentOption {
183
6
                    collectError(opt, currentValue)
184
12
                }
185
12
                currentOption = arg
186
12
                currentValue = ""
187
18
            } else {
188
6
                currentValue += currentValue.isEmpty ? arg : " \(arg)"
189
18
            }
190
107
        }
191
107
192
107
        if let option = currentOption {
193
6
            collectError(option, currentValue)
194
107
        }
195
107
    }
196
197
    /// Calls timeOption using a given prefix, e.g. before, since.
198
    /// It will check for `$option...`: `--$prefix-date` and `--$prefix`
199
    ///
200
    /// Returns `TimeInterval?` from `timeOption($option)`
201
    ///
202
    /// See: `timeOption(_ options: String) -> TimeInterval?`
203
210
    func parseTimeOptions(_ prefix: String) -> TimeInterval? {
204
210
        let timeOptionSuffixes = ["", "-date"]
205
210
        return timeOptionSuffixes
206
420
            .map { "\(prefix)\($0)" }
207
420
            .compactMap { timeOption($0) }
208
210
            .first
209
210
    }
210
211
    /// Check for the presence of a time `option` in arguments
212
    /// When found retrieves the value as `TimeInterval`
213
    ///
214
    /// See: `parseToSeconds(_ time: String)`
215
    ///
216
    /// Return:`TimeInterval?` (typealias of `Double?`)
217
422
    func timeOption(_ option: String) -> TimeInterval? {
218
422
        if let value: String = hasOption(option) {
219
12
            switch option {
220
12
            case _ where NSString(string: option).contains("date"):
221
3
                if let date = iso8601().date(from: value) {
222
1
                    return date.timeIntervalSince1970
223
2
                } else {
224
2
                    collectError(option, value)
225
12
                }
226
12
            case _ where parseToSeconds(value) != nil:
227
7
                if let seconds = parseToSeconds(value) {
228
7
                    return Date().timeIntervalSince1970 - seconds
229
12
                }
230
12
            default:
231
2
                collectError(option, value)
232
12
            }
233
422
        }
234
422
        return nil
235
422
    }
236
237
    /// Collect numbers from a string
238
80
    func filterNums(_ string: String) -> Double? {
239
386
        return Double(string.filter { $0 == "." || $0 >= "0" && $0 <= "9" })
240
80
    }
241
242
    /// Provide a lookup table for time unit,
243
    /// pattern/name & factor (seconds per unit)
244
106
    let timeUnitsTable = [
245
106
        (pattern: "m|minutes|min|mins|minute", factor: 60.0),
246
106
        (pattern: "h|hours|hr|hrs|hour", factor: 60.0 * 60.0),
247
106
        (pattern: "d|days|day", factor: 24.0 * 60.0 * 60.0),
248
106
        (pattern: "w|week|weeks|wk|wks", factor: 7.0 * 24.0 * 60.0 * 60.0),
249
106
        (pattern: "s|sec|secs|second|seconds", factor: 1.0)
250
106
    ]
251
252
    /// Parse time string to seconds, the form of time strings are
253
    /// simple grammar of `"amount value"`
254
    ///
255
    /// - Example:
256
    /// ```
257
    /// parseToSeconds("1sec") == 1.0
258
    /// parseToSeconds("1s") == 1.0
259
    /// parseToSeconds("1min") == 60.0
260
    /// parseToSeconds("1hr") == 3600.0
261
    /// ```
262
    /// etc.
263
    ///
264
    /// - See: `timeUnitsTable` for possible unit names
265
80
    func parseToSeconds(_ time: String) -> Double? {
266
80
        if let seconds = filterNums(time) {
267
390
            return timeUnitsTable.compactMap { (unit: (pattern: String, factor: Double)) -> Double? in
268
390
                if regexpMatch(time, unit.pattern, caseSensitive: false) {
269
100
                    return seconds * unit.factor
270
290
                }
271
290
                return nil
272
390
            }.first
273
78
        }
274
2
        return nil
275
80
    }
276
277
    /// Parse a query option, setting searchMode and removing quotes if needed.
278
420
    private func parseQueryOption(_ options: String..., mode: SearchMode, removeQuotes: Bool) {
279
839
        for option in options {
280
839
            if let rawQuery: String = hasOption(option) {
281
7
                searchMode = mode
282
7
                query = removeQuotes ? rawQuery.replacingOccurrences(of: "\"", with: "") : rawQuery
283
7
                return
284
832
            }
285
832
        }
286
413
    }
287
288
    /// Parse all known arguments, collect any unparsed arguments as errors.
289
    /// It will call exit when parsing help and or version flags.
290
106
    func parse() {
291
106
        let flagAndInfoPairs: [([String], String)] = [
292
106
            (args: ["-h", "--help"], string: usageInfo()),
293
106
            (args: ["--version"], string: version)
294
106
        ]
295
106
296
211
        for (args, string) in flagAndInfoPairs where hasFlag(args) {
297
211
            out.print(string)
298
211
            self.infoFlags = true
299
211
            return
300
18.4E
        }
301
18.4E
302
18.4E
        beforeDate = parseTimeOptions("--before")
303
18.4E
        sinceDate = parseTimeOptions("--since")
304
18.4E
        limit = hasOption("-l", "--limit")
305
18.4E
        favorites = hasFlag("-F", "--favorites", "--favorite")
306
18.4E
        missingDate = hasFlag("-M", "--missing-date", "--missing-time")
307
18.4E
        showTime = hasFlag("-T", "--show-time", "--show-date")
308
18.4E
309
18.4E
        parseQueryOption("-i", "--regexi", mode: .regexi, removeQuotes: true)
310
18.4E
        parseQueryOption("-r", "--regex", mode: .regex, removeQuotes: true)
311
18.4E
        parseQueryOption("-e", "--exact", mode: .exact, removeQuotes: false)
312
18.4E
        parseQueryOption("-f", "--fuzzy", mode: .fuzzy, removeQuotes: false)
313
18.4E
314
18.4E
        collectErrors(self.arguments)
315
18.4E
    }
316
}