.../Source/App/TimeFilter/TimeFilterValidator.swift
Line | Count | Source (jump to first uncovered line) |
1 | | // |
2 | | // TimeFilterValidator.swift |
3 | | // CutBox |
4 | | // |
5 | | // Created by Jason Milkins on 13/8/23. |
6 | | // Copyright © 2023 ocodo. All rights reserved. |
7 | | // |
8 | | |
9 | | import Foundation |
10 | | |
11 | | class TimeFilterValidator { |
12 | | private static let MINUTE: Int = 60 |
13 | | private static let HOUR: Int = 3600 |
14 | | private static let DAY: Int = 86400 |
15 | | private static let WEEK: Int = 604800 |
16 | | private static let YEAR: Int = 31536000 |
17 | | |
18 | | typealias TimeUnitLabels = [(name: String, plural: String)] |
19 | | |
20 | | private static let secondsToTimeFull: TimeUnitLabels = [ |
21 | | (name: "year", plural: "years"), |
22 | | (name: "week", plural: "weeks"), |
23 | | (name: "day", plural: "days"), |
24 | | (name: "hour", plural: "hours"), |
25 | | (name: "minute", plural: "minutes"), |
26 | | (name: "second", plural: "seconds") |
27 | | ] |
28 | | |
29 | | private static let secondsToTimeAbbreviated: TimeUnitLabels = [ |
30 | | (name: "yr", plural: "yrs"), |
31 | | (name: "wk", plural: "wks"), |
32 | | (name: "d", plural: "d"), |
33 | | (name: "hr", plural: "hrs"), |
34 | | (name: "min", plural: "mins"), |
35 | | (name: "s", plural: "s") |
36 | | ] |
37 | | |
38 | 12 | static func secondsToTime(seconds: Int, labels: TimeUnitLabels = secondsToTimeFull) -> String { |
39 | 12 | let secondsToComponents = [ |
40 | 12 | seconds / YEAR, |
41 | 12 | (seconds % YEAR) / WEEK, |
42 | 12 | (seconds % WEEK) / DAY, |
43 | 12 | (seconds % DAY) / HOUR, |
44 | 12 | (seconds % HOUR) / MINUTE, |
45 | 12 | seconds % MINUTE |
46 | 12 | ] |
47 | 12 | |
48 | 72 | let components: [String] = zip(secondsToComponents, labels).map { value, label in |
49 | 72 | switch value { |
50 | 72 | case 1: |
51 | 12 | return "\(Int(value)) \(label.name)" |
52 | 72 | case 0: |
53 | 46 | return "" |
54 | 72 | default: |
55 | 14 | return "\(Int(value)) \(label.plural)" |
56 | 72 | } |
57 | 72 | } |
58 | 12 | |
59 | 12 | if components.isEmpty { |
60 | 0 | return "0 seconds" |
61 | 12 | } |
62 | 12 | |
63 | 12 | var captured = "" |
64 | 45 | for component in components where !component.isEmpty { |
65 | 45 | if let index = components.firstIndex(of: component) { |
66 | 12 | if components.count > index + 1 { |
67 | 12 | let adjacent = components[index + 1] |
68 | 12 | if adjacent.isEmpty { |
69 | 5 | captured = component |
70 | 12 | } else { |
71 | 7 | captured = "\(component) \(adjacent)" |
72 | 12 | } |
73 | 12 | } else { |
74 | 0 | captured = component |
75 | 12 | } |
76 | 12 | break |
77 | 33 | } |
78 | 33 | } |
79 | 12 | |
80 | 12 | return captured |
81 | 12 | } |
82 | | |
83 | | private static let timeUnitsTable = [ |
84 | | (pattern: #"^(\d+(?:\.\d+)?) ?(m|minutes|min|mins|minute)$"#, factor: 60.0), |
85 | | (pattern: #"^(\d+(?:\.\d+)?) ?(h|hours|hr|hrs|hour)$"#, factor: 3600.0), |
86 | | (pattern: #"^(\d+(?:\.\d+)?) ?(d|days|day)$"#, factor: 86400.0), |
87 | | (pattern: #"^(\d+(?:\.\d+)?) ?(w|week|weeks|wk|wks)$"#, factor: 604800.0), |
88 | | (pattern: #"^(\d+(?:\.\d+)?) ?(y|year|years|yr|yrs)$"#, factor: 31536000.0), |
89 | | (pattern: #"^(\d+(?:\.\d+)?) ?(s|sec|secs|second|seconds)$"#, factor: 1.0) |
90 | | ] |
91 | | |
92 | 150 | private static func filterNums(_ string: String) -> Double? { |
93 | 812 | return Double(string.filter { $0 == "." || $0 >= "0" && $0 <= "9" }) |
94 | 150 | } |
95 | | |
96 | | /// Parse string to optional time interval. Any non-numeric chars will be |
97 | | /// filtered out after matching on seconds (or s,sec,secs), minutes (or |
98 | | /// m,min,mins), hours (or h,hr,hrs), days (or d,day). (case insensitive) |
99 | 150 | private static func parseToSeconds(_ time: String) -> Double? { |
100 | 150 | if let num = filterNums(time) { |
101 | 612 | return timeUnitsTable.compactMap { (unit: (pattern: String, factor: Double)) -> Double? in |
102 | 612 | if regexpMatch(time, unit.pattern, caseSensitive: false) { |
103 | 101 | return num * unit.factor |
104 | 511 | } |
105 | 511 | return nil |
106 | 612 | }.first |
107 | 102 | } |
108 | 48 | return nil |
109 | 150 | } |
110 | | |
111 | | /// Perform a regular expression (pattern) match on string, defaults to case sensitive. |
112 | | /// On match return true |
113 | 612 | private static func regexpMatch(_ string: String, _ pattern: String, caseSensitive: Bool = true) -> Bool { |
114 | 612 | let range = NSRange(location: 0, length: string.utf16.count) |
115 | 612 | if caseSensitive { |
116 | 0 | if let regex = try? NSRegularExpression(pattern: pattern) { |
117 | 0 | return regex.firstMatch(in: string, options: [], range: range) != nil |
118 | 0 | } |
119 | 612 | } else { |
120 | 612 | let regexOptions: NSRegularExpression.Options = [.caseInsensitive] |
121 | 612 | if let regex = try? NSRegularExpression(pattern: pattern, options: regexOptions) { |
122 | 612 | return regex.firstMatch(in: string, options: [], range: range) != nil |
123 | 612 | } |
124 | 0 | } |
125 | 0 | return false |
126 | 612 | } |
127 | | |
128 | | private let value: String |
129 | | let seconds: Double? |
130 | | |
131 | 150 | var isValid: Bool { |
132 | 150 | return self.seconds != nil |
133 | 150 | } |
134 | | |
135 | 153 | init(value: String) { |
136 | 153 | self.value = value |
137 | 153 | switch value { |
138 | 153 | case _ where "today" == value.localizedLowercase: |
139 | 1 | self.seconds = Double(Self.DAY) |
140 | 153 | case _ where "this week" == value.localizedLowercase: |
141 | 1 | self.seconds = Double(Self.WEEK) |
142 | 153 | case _ where "yesterday" == value.localizedLowercase: |
143 | 1 | self.seconds = 2 * Double(Self.DAY) |
144 | 153 | default: |
145 | 150 | self.seconds = Self.parseToSeconds(value) |
146 | 153 | } |
147 | 153 | } |
148 | | } |