Add folders for organizing saved conversations
Conversations can now be filed into flat (non-nested) folders, shown as collapsible sections in both the sidebar and the advanced conversation list. New folders migration (v9) adds a folders table and conversations.folderId with ON DELETE SET NULL, so deleting a folder unfiles its conversations rather than losing them. Move/rename/ delete via context menu; conversation lists with no folders fall back to the existing flat view unchanged.
This commit is contained in:
@@ -35,6 +35,16 @@ struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable
|
||||
var createdAt: String
|
||||
var updatedAt: String
|
||||
var primaryModel: String?
|
||||
var folderId: String?
|
||||
}
|
||||
|
||||
struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "folders"
|
||||
|
||||
var id: String
|
||||
var name: String
|
||||
var sortOrder: Int
|
||||
var createdAt: String
|
||||
}
|
||||
|
||||
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
@@ -342,6 +352,20 @@ final class DatabaseService: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
migrator.registerMigration("v9") { db in
|
||||
// Folders for organizing conversations
|
||||
try db.create(table: "folders") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.column("name", .text).notNull()
|
||||
t.column("sortOrder", .integer).notNull().defaults(to: 0)
|
||||
t.column("createdAt", .text).notNull()
|
||||
}
|
||||
|
||||
try db.alter(table: "conversations") { t in
|
||||
t.add(column: "folderId", .text).references("folders", onDelete: .setNull)
|
||||
}
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -404,7 +428,7 @@ final class DatabaseService: Sendable {
|
||||
return try saveConversation(id: UUID(), name: name, messages: messages, primaryModel: nil)
|
||||
}
|
||||
|
||||
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
|
||||
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?, folderId: UUID? = nil) throws -> Conversation {
|
||||
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
||||
let now = Date()
|
||||
let nowString = Self.isoString(from: now)
|
||||
@@ -414,7 +438,8 @@ final class DatabaseService: Sendable {
|
||||
name: name,
|
||||
createdAt: nowString,
|
||||
updatedAt: nowString,
|
||||
primaryModel: primaryModel
|
||||
primaryModel: primaryModel,
|
||||
folderId: folderId?.uuidString
|
||||
)
|
||||
|
||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||
@@ -446,7 +471,8 @@ final class DatabaseService: Sendable {
|
||||
messages: savedMessages,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
primaryModel: primaryModel
|
||||
primaryModel: primaryModel,
|
||||
folderId: folderId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -526,7 +552,8 @@ final class DatabaseService: Sendable {
|
||||
messages: messages,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
primaryModel: convRecord.primaryModel
|
||||
primaryModel: convRecord.primaryModel,
|
||||
folderId: convRecord.folderId.flatMap { UUID(uuidString: $0) }
|
||||
)
|
||||
|
||||
return (conversation, messages)
|
||||
@@ -568,7 +595,8 @@ final class DatabaseService: Sendable {
|
||||
messages: Array(repeating: Message(role: .user, content: ""), count: messageCount),
|
||||
createdAt: createdAt,
|
||||
updatedAt: lastDate,
|
||||
primaryModel: primaryModel
|
||||
primaryModel: primaryModel,
|
||||
folderId: record.folderId.flatMap { UUID(uuidString: $0) }
|
||||
)
|
||||
conv.updatedAt = lastDate
|
||||
return conv
|
||||
@@ -576,6 +604,68 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Folders
|
||||
|
||||
nonisolated func createFolder(name: String) throws -> Folder {
|
||||
let folder = Folder(name: name, sortOrder: try nextFolderSortOrder())
|
||||
let record = FolderRecord(
|
||||
id: folder.id.uuidString,
|
||||
name: folder.name,
|
||||
sortOrder: folder.sortOrder,
|
||||
createdAt: Self.isoString(from: folder.createdAt)
|
||||
)
|
||||
try dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
private nonisolated func nextFolderSortOrder() throws -> Int {
|
||||
try dbQueue.read { db in
|
||||
let row = try Row.fetchOne(db, sql: "SELECT MAX(sortOrder) AS maxOrder FROM folders")
|
||||
let maxOrder: Int? = row?["maxOrder"]
|
||||
return (maxOrder ?? -1) + 1
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func renameFolder(id: UUID, name: String) throws {
|
||||
try dbQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE folders SET name = ? WHERE id = ?",
|
||||
arguments: [name, id.uuidString]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func deleteFolder(id: UUID) throws {
|
||||
try dbQueue.write { db in
|
||||
try FolderRecord.deleteOne(db, key: id.uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func listFolders() throws -> [Folder] {
|
||||
try dbQueue.read { db in
|
||||
let records = try FolderRecord
|
||||
.order(Column("sortOrder"))
|
||||
.fetchAll(db)
|
||||
return records.compactMap { record -> Folder? in
|
||||
guard let id = UUID(uuidString: record.id),
|
||||
let createdAt = Self.isoDate(from: record.createdAt)
|
||||
else { return nil }
|
||||
return Folder(id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func moveConversation(id: UUID, toFolder folderId: UUID?) throws {
|
||||
try dbQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE conversations SET folderId = ? WHERE id = ?",
|
||||
arguments: [folderId?.uuidString, id.uuidString]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage Statistics
|
||||
|
||||
nonisolated func getOverallUsageStats() throws -> UsageStats {
|
||||
|
||||
Reference in New Issue
Block a user