diff --git a/Elsewhen.xcodeproj/project.pbxproj b/Elsewhen.xcodeproj/project.pbxproj index a4d3020..c76de6d 100644 --- a/Elsewhen.xcodeproj/project.pbxproj +++ b/Elsewhen.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 43BDCD4E2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel in Sources */ = {isa = PBXBuildFile; fileRef = 43BDCD4C2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel */; }; + 43BDCD4F2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel in Sources */ = {isa = PBXBuildFile; fileRef = 43BDCD4C2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel */; }; 5E01E2F228621E8300782A37 /* NewTimeZoneGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E01E2F128621E8300782A37 /* NewTimeZoneGroupView.swift */; }; 5E05D0F0289479E600C46543 /* CustomTimeFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E05D0EF289479E600C46543 /* CustomTimeFormat.swift */; }; 5E05D0F128947B2200C46543 /* CustomTimeFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E05D0EF289479E600C46543 /* CustomTimeFormat.swift */; }; @@ -317,6 +319,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 43BDCD4C2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel */ = {isa = PBXFileReference; lastKnownFileType = file.mlmodel; path = DateTimePartTaggerModel.mlmodel; sourceTree = ""; }; 5E01E2F128621E8300782A37 /* NewTimeZoneGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTimeZoneGroupView.swift; sourceTree = ""; }; 5E05D0EF289479E600C46543 /* CustomTimeFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimeFormat.swift; sourceTree = ""; }; 5E07E846273475E300D9350E /* rainbow@3x~ipad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "rainbow@3x~ipad.png"; sourceTree = ""; }; @@ -567,6 +570,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 43BDCD4D2C49ED2900C909A5 /* ML Models */ = { + isa = PBXGroup; + children = ( + 43BDCD4C2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel */, + ); + path = "ML Models"; + sourceTree = ""; + }; 5E07E8452734740300D9350E /* Icons */ = { isa = PBXGroup; children = ( @@ -653,8 +664,9 @@ 6378399B26EBF4750055A10D /* Discord Helper IntentsUI */, 6346AD5B26F3BF1300E406D0 /* MacElsewhen */, 6346AD6C26F3BF1400E406D0 /* MacElsewhenTests */, - 6346AD7626F3BF1400E406D0 /* MacElsewhenUITests */, 6378398D26EBF4750055A10D /* Frameworks */, + 43BDCD4D2C49ED2900C909A5 /* ML Models */, + 6346AD7626F3BF1400E406D0 /* MacElsewhenUITests */, 5EB23B9A26E406B600203153 /* Products */, ); sourceTree = ""; @@ -1360,6 +1372,7 @@ 63A90DD026F4295F00CACCB9 /* Bundle.swift in Sources */, 637839CB26ED11360055A10D /* TimeZone+ItemProvider.swift in Sources */, 5E5F42B827163E510022C7D1 /* Orientation.swift in Sources */, + 43BDCD4E2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel in Sources */, 63BEF696271EE275000CAC95 /* NotRepresentativeWarning.swift in Sources */, 63C51BA726FA32D400145662 /* FavouriteIndicator.swift in Sources */, 5E9BD45126EEA11900383233 /* UserDefaults.swift in Sources */, @@ -1447,6 +1460,7 @@ 632F38DF274B882C00F0C064 /* StatusItemTabViewController.swift in Sources */, 63B5EF6D2720E62900AA576E /* Selectable.swift in Sources */, 632F38C62748341800F0C064 /* PreferencesViewController.swift in Sources */, + 43BDCD4F2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel in Sources */, 63A90DE826F4D49E00CACCB9 /* MenuItemContents.swift in Sources */, 63BEF68C271EB9A1000CAC95 /* SearchBar.swift in Sources */, 5E48E3C328636CDE00F3613C /* FormatChoiceButton.swift in Sources */, diff --git a/Elsewhen/Elements/DateTimeZoneSheet.swift b/Elsewhen/Elements/DateTimeZoneSheet.swift index b4fcb05..1ce623c 100644 --- a/Elsewhen/Elements/DateTimeZoneSheet.swift +++ b/Elsewhen/Elements/DateTimeZoneSheet.swift @@ -6,9 +6,25 @@ // import SwiftUI +import CoreML +import NaturalLanguage struct DateTimeZoneSheet: View { + enum ErrorMessage { + case noDateDetected + case none + + var message: String { + switch self { + case .noDateDetected: + return "No date or time was detected in the input." + default: + return "An error occurred" + } + } + } + // MARK: Init arguments @Binding var selectedDate: Date @@ -20,6 +36,125 @@ struct DateTimeZoneSheet: View { // MARK: State @State private var showTimeZoneChoiceSheet: Bool = false + + + // MARK: Natural Language + @State private var naturalTextInput: String = "" + @State private var dataDetector: NSDataDetector! + @State private var relativeTimeClassifier: DateTimePartTaggerModel! + @State private var showingTextInput = false + + func parseRelativeTimeInput(input: String) { + do { + // Honestly I don't understand the NLModel and NLTagger blocks. + // This is what Apple's documentation said to do + let customModel = try NLModel(mlModel: relativeTimeClassifier.model) + let customTagScheme = NLTagScheme("RelativeDate") + + let tagger = NLTagger(tagSchemes: [.nameType, customTagScheme]) + tagger.string = input + tagger.setModels([customModel], forTagScheme: customTagScheme) + + var intervals: [String] = [] + var units: [String] = [] + // true = forward, false = backward + var direction = true + + // Iterate through detected tags + // * UNIT (i.e. 3 *minutes*) + // * INTERVAL (i.e. *3* minutes) + // * DIRECTION (i.e. "from now", "ago") + tagger.enumerateTags(in: input.startIndex.. i { + let unit = units[i] + + // Convert the unit string into a calendar component + guard let component = Calendar.Component.fromString(unit) else { + continue + } + + // Add the converted component * interval to the accumulated time + newDate = Calendar.current.date(byAdding: component, + value: parsed * (direction ? 1 : -1), + to: newDate) ?? newDate + } else { + break + } + } + + selectedDate = newDate + + } catch { + print(error) + self.showError(.none) + } + } + + func processNaturalTextInput(newValue: String) { + let matches = dataDetector.matches(in: newValue, range: NSMakeRange(0, newValue.count)) + // First check for dates + if let match = matches.first, let date = match.date { + selectedDate = date + } else { + // If no dates detected, check for relative time + parseRelativeTimeInput(input: newValue) + } + } + + // MARK: Error + @State var showingErrorAlert = false + @State var errorMessage: ErrorMessage = .none + + func showError(_ message: ErrorMessage) { + self.errorMessage = message + self.showingErrorAlert = true + } + #if os(macOS) @State private var isPresentingDatePopover: Bool = false @State private var showTimeZoneChoicePopover: Bool = false @@ -62,6 +197,12 @@ struct DateTimeZoneSheet: View { .datePickerStyle(.compact) .labelsHidden() #endif + Button(action: { + naturalTextInput = "" + showingTextInput = true + }) { + Image(systemName: "keyboard") + } Button(action: { withAnimation { selectedDate = Date() @@ -120,6 +261,38 @@ struct DateTimeZoneSheet: View { } .padding([.horizontal, .bottom]) .padding(.top, 10) + .onAppear { + do { + // initialize data detector and ML model + dataDetector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.date.rawValue) + relativeTimeClassifier = try DateTimePartTaggerModel(configuration: MLModelConfiguration()) + } catch { + print(error) + self.showError(.none) + } + } + .alert("Error", isPresented: $showingErrorAlert, actions: { + Button(action: { + errorMessage = .none + }, label: { + Text("OK") + }) + }, message: { + Text(errorMessage.message) + }) + .alert("Enter Time", isPresented: $showingTextInput, actions: { + Button(action: { + withAnimation { + processNaturalTextInput(newValue: naturalTextInput) + } + }, label: { + Text("OK") + }) + Button(role: .cancel, action: {}, label: { + Text("Cancel") + }) + TextField("Type A Time", text: $naturalTextInput) + }) #if os(iOS) .sheet(isPresented: $showTimeZoneChoiceSheet) { NavigationView { @@ -147,3 +320,35 @@ struct DateTimeZoneSheet_Previews: PreviewProvider { DateTimeZoneSheet(selectedDate: .constant(Date()), selectedTimeZone: .constant(nil), selectedTimeZones: .constant([]), selectedTimeZoneGroup: .constant(nil), multipleTimeZones: false) } } + +extension Calendar.Component { + static func fromString(_ string: String) -> Calendar.Component? { + guard let first = string.lowercased().first else { + return nil + } + + // We're checking the first letter of the unit string to determine its type + switch first { + case "s": + return .second + case "m": + // "m" can be Minute or Month so check the second letter + let start = string.index(string.startIndex, offsetBy: 1) + guard string.count >= 2 else { + return .minute + } + let second = string.lowercased()[start...start] + return (second == "o") ? .month : .minute + case "h": + return .hour + case "d": + return .day + case "w": + return .weekOfYear + case "y": + return .year + default: + return nil + } + } +} diff --git a/ML Models/DateTimePartTaggerModel.mlmodel b/ML Models/DateTimePartTaggerModel.mlmodel new file mode 100644 index 0000000..2302259 Binary files /dev/null and b/ML Models/DateTimePartTaggerModel.mlmodel differ