Skip to content

Commit c114453

Browse files
authored
Reminders: Add FTS (#138)
* wip * wip * fix tests * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Fix
1 parent a38c595 commit c114453

File tree

11 files changed

+449
-232
lines changed

11 files changed

+449
-232
lines changed

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ jobs:
4242
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
4343
- name: xcodebuild ${{ matrix.scheme }}
4444
run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw
45+
- name: Output test failures
46+
run: |
47+
xcrun xcresulttool get test-results tests --path TestResults.xcresult --format json | jq -r '
48+
.testNodes[]
49+
| .. | objects | select(.nodeType=="Test Case")
50+
| . as $tc
51+
| ($tc.children // [])[]
52+
| select(.nodeType=="Failure Message")
53+
| "✘ Test \($tc.name) recorded an issue at \(.name)\n"
54+
'
55+
if: failure()
4556

4657
# NB: GRDB 7.6.1 does not currently build on Linux.
4758
# linux:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ DerivedData/
66
.swiftpm
77
.netrc
88
*.sqlite
9+
*.xcresult

Examples/Reminders/ReminderRow.swift

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ struct ReminderRow: View {
88
let reminder: Reminder
99
let remindersList: RemindersList
1010
let showCompleted: Bool
11-
let tags: [String]
11+
let tags: String
12+
let title: String?
1213

1314
@State var editReminder: Reminder.Draft?
1415
@State var isCompleted: Bool
@@ -22,7 +23,8 @@ struct ReminderRow: View {
2223
reminder: Reminder,
2324
remindersList: RemindersList,
2425
showCompleted: Bool,
25-
tags: [String]
26+
tags: String,
27+
title: String? = nil
2628
) {
2729
self.color = color
2830
self.isPastDue = isPastDue
@@ -31,6 +33,7 @@ struct ReminderRow: View {
3133
self.remindersList = remindersList
3234
self.showCompleted = showCompleted
3335
self.tags = tags
36+
self.title = title
3437
self.isCompleted = reminder.isCompleted
3538
}
3639

@@ -44,10 +47,10 @@ struct ReminderRow: View {
4447
.padding([.trailing], 5)
4548
}
4649
VStack(alignment: .leading) {
47-
title(for: reminder)
50+
title(for: reminder, title: title)
4851

4952
if !notes.isEmpty {
50-
Text(notes)
53+
highlight(notes)
5154
.font(.subheadline)
5255
.foregroundStyle(.gray)
5356
.lineLimit(2)
@@ -145,28 +148,34 @@ struct ReminderRow: View {
145148
}
146149

147150
private var subtitleText: Text {
148-
let tagsText = tags.reduce(Text(reminder.dueDate == nil ? "" : " ")) { result, tag in
149-
result + Text("#\(tag) ")
150-
}
151-
return
152-
(dueText
153-
+ tagsText
154-
.foregroundStyle(.gray)
155-
.bold())
156-
.font(.callout)
151+
Text(
152+
"""
153+
\(dueText)\(reminder.dueDate == nil ? "" : " ")\(highlight(tags).foregroundStyle(.gray))
154+
"""
155+
)
156+
.font(.callout)
157157
}
158158

159-
private func title(for reminder: Reminder) -> some View {
160-
return HStack(alignment: .firstTextBaseline) {
159+
@ViewBuilder
160+
private func title(for reminder: Reminder, title: String?) -> some View {
161+
HStack(alignment: .firstTextBaseline) {
161162
if let priority = reminder.priority {
162163
Text(String(repeating: "!", count: priority.rawValue))
163164
.foregroundStyle(isCompleted ? .gray : remindersList.color)
164165
}
165-
Text(reminder.title)
166+
highlight(title ?? reminder.title)
166167
.foregroundStyle(isCompleted ? .gray : .primary)
167168
}
168169
.font(.title3)
169170
}
171+
172+
func highlight(_ text: String) -> Text {
173+
if let attributedText = try? AttributedString(markdown: text) {
174+
Text(attributedText)
175+
} else {
176+
Text(text)
177+
}
178+
}
170179
}
171180

172181
struct ReminderRowPreview: PreviewProvider {
@@ -190,7 +199,7 @@ struct ReminderRowPreview: PreviewProvider {
190199
reminder: reminder,
191200
remindersList: remindersList,
192201
showCompleted: true,
193-
tags: ["point-free", "adulting"]
202+
tags: "#point-free #adulting"
194203
)
195204
}
196205
}

Examples/Reminders/RemindersDetail.swift

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class RemindersDetailModel: HashableObject {
4646
let ids = Array(ids.enumerated())
4747
let (first, rest) = (ids.first!, ids.dropFirst())
4848
$0.position =
49-
rest
49+
rest
5050
.reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in
5151
cases.when(id.element, then: id.offset)
5252
}
@@ -58,22 +58,21 @@ class RemindersDetailModel: HashableObject {
5858
$ordering.withLock { $0 = .manual }
5959
await updateQuery()
6060
}
61-
61+
6262
private func updateQuery() async {
6363
await withErrorReporting {
6464
try await $reminderRows.load(remindersQuery, animation: .default)
6565
}
6666
}
6767

6868
private var remindersQuery: some StructuredQueriesCore.Statement<Row> {
69-
let query =
7069
Reminder
7170
.where {
7271
if !showCompleted {
7372
!$0.isCompleted
7473
}
7574
}
76-
.order { $0.isCompleted }
75+
.order(by: \.isCompleted)
7776
.order {
7877
switch ordering {
7978
case .dueDate: $0.dueDate.asc(nulls: .last)
@@ -95,16 +94,16 @@ class RemindersDetailModel: HashableObject {
9594
}
9695
}
9796
.join(RemindersList.all) { $0.remindersListID.eq($3.id) }
97+
.join(ReminderText.all) { $0.rowid.eq($4.rowid) }
9898
.select {
9999
Row.Columns(
100100
reminder: $0,
101101
remindersList: $3,
102102
isPastDue: $0.isPastDue,
103-
notes: $0.inlineNotes.substr(0, 200),
104-
tags: #sql("\($2.jsonTitles)")
103+
notes: $4.notes.substr(0, 200),
104+
tags: $4.tags
105105
)
106106
}
107-
return query
108107
}
109108

110109
enum Ordering: String, CaseIterable {
@@ -141,8 +140,7 @@ class RemindersDetailModel: HashableObject {
141140
let remindersList: RemindersList
142141
let isPastDue: Bool
143142
let notes: String
144-
@Column(as: [String].JSONRepresentation.self)
145-
let tags: [String]
143+
let tags: String
146144
}
147145
}
148146

@@ -191,7 +189,7 @@ struct RemindersDetailView: View {
191189
reminder: Reminder.Draft(remindersListID: remindersList.id),
192190
remindersList: remindersList
193191
)
194-
.navigationTitle("New Reminder")
192+
.navigationTitle("New Reminder")
195193
}
196194
}
197195
}

Examples/Reminders/RemindersLists.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,9 @@ struct RemindersListsView: View {
173173

174174
var body: some View {
175175
List {
176-
if model.searchRemindersModel.searchText.isEmpty {
176+
if model.searchRemindersModel.isSearching {
177+
SearchRemindersView(model: model.searchRemindersModel)
178+
} else {
177179
Section {
178180
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) {
179181
GridRow {
@@ -269,8 +271,6 @@ struct RemindersListsView: View {
269271
.padding([.leading, .trailing], 4)
270272
}
271273
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
272-
} else {
273-
SearchRemindersView(model: model.searchRemindersModel)
274274
}
275275
}
276276
.onAppear {
@@ -328,7 +328,17 @@ struct RemindersListsView: View {
328328
}
329329
.presentationDetents([.medium])
330330
}
331-
.searchable(text: $model.searchRemindersModel.searchText)
331+
.searchable(
332+
text: $model.searchRemindersModel.searchText,
333+
tokens: $model.searchRemindersModel.searchTokens
334+
) { token in
335+
switch token.kind {
336+
case .near:
337+
Text(token.rawValue)
338+
case .tag:
339+
Text("#\(token.rawValue)")
340+
}
341+
}
332342
.navigationDestination(item: $model.destination.detail) { detailModel in
333343
RemindersDetailView(model: detailModel)
334344
}

Examples/Reminders/Schema.swift

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,6 @@ enum Priority: Int, Codable, QueryBindable {
4949

5050
extension Reminder {
5151
static let incomplete = Self.where { !$0.isCompleted }
52-
static func searching(_ text: String) -> Where<Reminder> {
53-
Self.where {
54-
$0.title.collate(.nocase).contains(text)
55-
|| $0.notes.collate(.nocase).contains(text)
56-
}
57-
}
5852
static let withTags = group(by: \.id)
5953
.leftJoin(ReminderTag.all) { $0.id.eq($1.reminderID) }
6054
.leftJoin(Tag.all) { $1.tagID.eq($2.primaryKey) }
@@ -72,9 +66,6 @@ extension Reminder.TableColumns {
7266
var isScheduled: some QueryExpression<Bool> {
7367
!isCompleted && dueDate.isNot(nil)
7468
}
75-
var inlineNotes: some QueryExpression<String> {
76-
notes.replace("\n", " ")
77-
}
7869
}
7970

8071
extension Tag {
@@ -83,19 +74,21 @@ extension Tag {
8374
.leftJoin(Reminder.all) { $1.reminderID.eq($2.id) }
8475
}
8576

86-
extension Tag.TableColumns {
87-
var jsonTitles: some QueryExpression<[String].JSONRepresentation> {
88-
self.title.jsonGroupArray(filter: self.title.isNot(nil))
89-
}
90-
}
91-
9277
@Table("remindersTags")
9378
struct ReminderTag: Hashable, Identifiable {
9479
let id: UUID
9580
var reminderID: Reminder.ID
9681
var tagID: Tag.ID
9782
}
9883

84+
@Table @Selection
85+
struct ReminderText: StructuredQueries.FTS5 {
86+
let rowid: Int
87+
let title: String
88+
let notes: String
89+
let tags: String
90+
}
91+
9992
func appDatabase() throws -> any DatabaseWriter {
10093
@Dependency(\.context) var context
10194
let database: any DatabaseWriter
@@ -173,6 +166,18 @@ func appDatabase() throws -> any DatabaseWriter {
173166
"""
174167
)
175168
.execute(db)
169+
try #sql(
170+
"""
171+
CREATE VIRTUAL TABLE "reminderTexts" USING fts5(
172+
"reminderID" UNINDEXED,
173+
"title",
174+
"notes",
175+
"tags",
176+
tokenize = 'trigram'
177+
)
178+
"""
179+
)
180+
.execute(db)
176181
}
177182

178183
try migrator.migrate(database)
@@ -188,12 +193,14 @@ func appDatabase() throws -> any DatabaseWriter {
188193
.update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} }
189194
})
190195
.execute(db)
196+
191197
try Reminder.createTemporaryTrigger(after: .insert { new in
192198
Reminder
193199
.find(new.id)
194200
.update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} }
195201
})
196202
.execute(db)
203+
197204
try RemindersList.createTemporaryTrigger(after: .delete { _ in
198205
RemindersList.insert {
199206
RemindersList.Draft(
@@ -205,6 +212,61 @@ func appDatabase() throws -> any DatabaseWriter {
205212
!RemindersList.exists()
206213
})
207214
.execute(db)
215+
216+
try Reminder.createTemporaryTrigger(after: .insert { new in
217+
ReminderText.insert {
218+
ReminderText.Columns(
219+
rowid: new.rowid,
220+
title: new.title,
221+
notes: new.notes.replace("\n", " "),
222+
tags: ""
223+
)
224+
}
225+
})
226+
.execute(db)
227+
228+
try Reminder.createTemporaryTrigger(after: .update {
229+
($0.title, $0.notes)
230+
} forEachRow: { _, new in
231+
ReminderText
232+
.where { $0.rowid.eq(new.rowid) }
233+
.update {
234+
$0.title = new.title
235+
$0.notes = new.notes.replace("\n", " ")
236+
}
237+
})
238+
.execute(db)
239+
240+
try Reminder.createTemporaryTrigger(after: .delete { old in
241+
ReminderText
242+
.where { $0.rowid.eq(old.rowid) }
243+
.delete()
244+
})
245+
.execute(db)
246+
247+
func updateReminderTextTags(
248+
for reminderID: some QueryExpression<Reminder.ID>
249+
) -> UpdateOf<ReminderText> {
250+
ReminderText
251+
.where { $0.rowid.eq(Reminder.find(reminderID).select(\.rowid)) }
252+
.update {
253+
$0.tags = ReminderTag
254+
.order(by: \.tagID)
255+
.where { $0.reminderID.eq(reminderID) }
256+
.join(Tag.all) { $0.tagID.eq($1.primaryKey) }
257+
.select { ("#" + $1.title).groupConcat(" ") ?? "" }
258+
}
259+
}
260+
261+
try ReminderTag.createTemporaryTrigger(after: .insert { new in
262+
updateReminderTextTags(for: new.reminderID)
263+
})
264+
.execute(db)
265+
266+
try ReminderTag.createTemporaryTrigger(after: .delete { old in
267+
updateReminderTextTags(for: old.reminderID)
268+
})
269+
.execute(db)
208270
}
209271

210272
return database

0 commit comments

Comments
 (0)