Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Elsewhen.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -317,6 +319,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
43BDCD4C2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel */ = {isa = PBXFileReference; lastKnownFileType = file.mlmodel; path = DateTimePartTaggerModel.mlmodel; sourceTree = "<group>"; };
5E01E2F128621E8300782A37 /* NewTimeZoneGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTimeZoneGroupView.swift; sourceTree = "<group>"; };
5E05D0EF289479E600C46543 /* CustomTimeFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimeFormat.swift; sourceTree = "<group>"; };
5E07E846273475E300D9350E /* rainbow@3x~ipad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "rainbow@3x~ipad.png"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -567,6 +570,14 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
43BDCD4D2C49ED2900C909A5 /* ML Models */ = {
isa = PBXGroup;
children = (
43BDCD4C2C49ED2900C909A5 /* DateTimePartTaggerModel.mlmodel */,
);
path = "ML Models";
sourceTree = "<group>";
};
5E07E8452734740300D9350E /* Icons */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -653,8 +664,9 @@
6378399B26EBF4750055A10D /* Discord Helper IntentsUI */,
6346AD5B26F3BF1300E406D0 /* MacElsewhen */,
6346AD6C26F3BF1400E406D0 /* MacElsewhenTests */,
6346AD7626F3BF1400E406D0 /* MacElsewhenUITests */,
6378398D26EBF4750055A10D /* Frameworks */,
43BDCD4D2C49ED2900C909A5 /* ML Models */,
6346AD7626F3BF1400E406D0 /* MacElsewhenUITests */,
5EB23B9A26E406B600203153 /* Products */,
);
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
205 changes: 205 additions & 0 deletions Elsewhen/Elements/DateTimeZoneSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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..<input.endIndex, unit: .word,
scheme: customTagScheme, options: .omitWhitespace) { tag, tokenRange in
// Make sure the tag exists
guard let tag = tag else {
return false
}

// Grab the text associated with the tag
let text = input[tokenRange]

// Check which kind of tag it is
switch tag.rawValue {
case "INTERVAL":
intervals.append(String(text))
case "UNIT":
units.append(String(text))
case "DIRECTION":
switch text.lowercased() {
case "ago":
direction = false
default:
direction = true
}
default:
break
}

// Gotta return something in this block
return true
}

// Show error if no date or time detected
if intervals.isEmpty || units.isEmpty {
self.showError(.noDateDetected)
return
}

// We're going to add the detected dates/times to the current time
var newDate = Date.now

for (i,interval) in intervals.enumerated() {
guard let parsed = Int(interval) else {
continue
}

// Make sure the interval has an associated unit
if units.count > 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
Expand Down Expand Up @@ -62,6 +197,12 @@ struct DateTimeZoneSheet: View {
.datePickerStyle(.compact)
.labelsHidden()
#endif
Button(action: {
naturalTextInput = ""
showingTextInput = true
}) {
Image(systemName: "keyboard")
}
Button(action: {
withAnimation {
selectedDate = Date()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
}
Binary file added ML Models/DateTimePartTaggerModel.mlmodel
Binary file not shown.