.../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 | | } |