Merge pull request #20 from tommybananas/fix/history-filters-before-limit

fix: apply history filters before limit
This commit is contained in:
Peter Steinberger 2026-01-16 21:21:08 +00:00 committed by GitHub
commit 40e2084ef3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 85 additions and 13 deletions

View File

@ -1,8 +1,9 @@
# Changelog
## Unreleased
## 0.4.1 - Unreleased
- fix: prefer handle sends when chat identifier is a direct handle
- fix: apply history filters before limit (#20, thanks @tommybananas)
## 0.4.0 - 2026-01-07
- feat: surface audio message transcriptions (thanks @antons)

View File

@ -76,6 +76,11 @@ extension MessageStore {
return error
}
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}
func appleDate(from value: Int64?) -> Date {
guard let value else { return Date(timeIntervalSince1970: MessageStore.appleEpochOffset) }
return Date(

View File

@ -3,6 +3,10 @@ import SQLite
extension MessageStore {
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
return try messages(chatID: chatID, limit: limit, filter: nil)
}
public func messages(chatID: Int64, limit: Int, filter: MessageFilter?) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let guidColumn = hasReactionColumns ? "m.guid" : "NULL"
let associatedGuidColumn = hasReactionColumns ? "m.associated_message_guid" : "NULL"
@ -13,7 +17,7 @@ extension MessageStore {
hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
let sql = """
var sql = """
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
@ -23,12 +27,36 @@ extension MessageStore {
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE cmj.chat_id = ?\(reactionFilter)
ORDER BY m.date DESC
LIMIT ?
"""
var bindings: [Binding?] = [chatID]
if let filter {
if let startDate = filter.startDate {
sql += " AND m.date >= ?"
bindings.append(MessageStore.appleEpoch(startDate))
}
if let endDate = filter.endDate {
sql += " AND m.date < ?"
bindings.append(MessageStore.appleEpoch(endDate))
}
if !filter.participants.isEmpty {
let placeholders = Array(repeating: "?", count: filter.participants.count).joined(
separator: ",")
// Match current in-memory behavior: Message.sender is either handle.id or destination_caller_id.
sql +=
" AND COALESCE(NULLIF(h.id,''), \(destinationCallerColumn)) COLLATE NOCASE IN (\(placeholders))"
for participant in filter.participants {
bindings.append(participant)
}
}
}
sql += " ORDER BY m.date DESC LIMIT ?"
bindings.append(limit)
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, chatID, limit) {
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let handleID = int64Value(row[1])
var sender = stringValue(row[2])

View File

@ -46,8 +46,7 @@ enum HistoryCommand {
)
let store = try MessageStore(path: dbPath)
let messages = try store.messages(chatID: chatID, limit: limit)
let filtered = messages.filter { filter.allows($0) }
let filtered = try store.messages(chatID: chatID, limit: limit, filter: filter)
if runtime.jsonOutput {
for message in filtered {

View File

@ -111,8 +111,7 @@ final class RPCServer {
startISO: startISO,
endISO: endISO
)
let messages = try store.messages(chatID: chatID, limit: max(limit, 1))
let filtered = messages.filter { filter.allows($0) }
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
let payloads = try filtered.map { message in
try buildMessagePayload(
store: store,

View File

@ -11,9 +11,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.4</string>
<string>0.4.1</string>
<key>CFBundleVersion</key>
<string>0.4</string>
<string>0.4.1</string>
<key>NSAppleEventsUsageDescription</key>
<string>Send messages via Messages.app.</string>
</dict>

View File

@ -1,4 +1,4 @@
// Generated by scripts/generate-version.sh. Do not edit.
enum IMsgVersion {
static let current = "0.4"
static let current = "0.4.1"
}

View File

@ -63,6 +63,46 @@ func messagesByChatReturnsMessages() throws {
#expect(messages[0].attachmentsCount == 0)
}
@Test
func messagesByChatAppliesDateFilterBeforeLimit() throws {
let store = try TestDatabase.makeStore()
let all = try store.messages(chatID: 1, limit: 10)
let target = all.first { $0.rowID == 2 }
#expect(target != nil)
// Build a tight window around message 2's date so the filter matches it but not the newest message.
guard let target else { return }
let filter = MessageFilter(
startDate: target.date.addingTimeInterval(-1),
endDate: target.date.addingTimeInterval(1)
)
let filtered = try store.messages(chatID: 1, limit: 1, filter: filter)
#expect(filtered.count == 1)
#expect(filtered.first?.rowID == 2)
}
@Test
func messagesByChatAppliesParticipantFilterBeforeLimit() throws {
let store = try TestDatabase.makeStore()
// Insert a newer "from me" message so limit=1 would pick it unless filtering happens in SQL.
try store.withConnection { db in
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (4, 2, 'newest from me', ?, 1, 'iMessage')
""",
TestDatabase.appleEpoch(Date().addingTimeInterval(5))
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 4)")
}
let filter = MessageFilter(participants: ["+123"])
let filtered = try store.messages(chatID: 1, limit: 1, filter: filter)
#expect(filtered.count == 1)
#expect(filtered.first?.sender == "+123")
}
@Test
func messagesAfterReturnsMessages() throws {
let store = try TestDatabase.makeStore()

View File

@ -1 +1 @@
MARKETING_VERSION=0.4
MARKETING_VERSION=0.4.1