Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format has been adjusted to include a release Title. This has been inserted
## [Unreleased]

### Added
- Files can be drag and dropped into Minna.
- Not all files are supported because of issues in macOS drag and drop behaviors.
- Minna is now licensed under the Apache License 2.0.

### Changed
Expand Down
4 changes: 1 addition & 3 deletions Minna.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,6 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
DBC0F1A02FF0000100000001 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
DBC0F1A02FF0000100000002 /* Config.local.example.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.local.example.xcconfig; sourceTree = "<group>"; };
DB0CF4D02FEDCA6B0036306B /* Minna.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Minna.app; sourceTree = BUILT_PRODUCTS_DIR; };
DB0CF4DF2FEDCA6D0036306B /* MinnaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MinnaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
DB0CF4E92FEDCA6D0036306B /* MinnaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MinnaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand All @@ -94,6 +92,7 @@
DB6E4C843013E9F800487D86 /* SearchModelAssets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = SearchModelAssets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
DB9696A62FF4929600CB037A /* DatabaseSchema */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = DatabaseSchema; sourceTree = "<group>"; };
DB9696A72FF4929600CB037A /* MinnaChat */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MinnaChat; sourceTree = "<group>"; };
DBC0F1A02FF0000100000001 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand Down Expand Up @@ -218,7 +217,6 @@
isa = PBXGroup;
children = (
DBC0F1A02FF0000100000001 /* Config.xcconfig */,
DBC0F1A02FF0000100000002 /* Config.local.example.xcconfig */,
DB0CF7242FEDE88A0036306B /* ci_scripts */,
DB0CF4D22FEDCA6B0036306B /* Minna */,
DB0CF4E22FEDCA6D0036306B /* MinnaTests */,
Expand Down
4 changes: 2 additions & 2 deletions Minna/Database/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import Foundation
import DatabaseSchema

protocol Database {
var unfilledFolderUUID: UUID { get }
var unfiledFolderUUID: UUID { get }
var initializationError: (any Error)? { get }

func unfilledFolder() -> Folder
func unfiledFolder() -> Folder
func queueDescriptionUpdate(for file: File)
}
35 changes: 18 additions & 17 deletions Minna/Database/FrontendDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,29 @@ import FoundationModels
class FrontendDatabase: Database {
static let shared: FrontendDatabase = FrontendDatabase()

private static let unfilledFolderKey: String = "unfilled_folder_key"
// Yes there is a spelling mistake in this key, it is too late to change it.
private static let unfiledFolderKey: String = "unfilled_folder_key"

// Swift Data Variables
var modelContainer: ModelContainer
var context: ModelContext {
modelContainer.mainContext
}

var unfilledFolderUUID: UUID
var unfiledFolderUUID: UUID
var initializationError: (any Error)?

private var fileDescriptionWriter: FileDescriptionWriter

private let indexingQueue: RateLimitedQueue = RateLimitedQueue()

init() {
if let uuidString = UserDefaults.standard.object(forKey: FrontendDatabase.unfilledFolderKey) as? String, let uuid = UUID(uuidString: uuidString) {
unfilledFolderUUID = uuid
if let uuidString = UserDefaults.standard.object(forKey: FrontendDatabase.unfiledFolderKey) as? String, let uuid = UUID(uuidString: uuidString) {
unfiledFolderUUID = uuid
} else {
// TODO: Try and find an existing unfilled folder UUID in case the user defaults got wiped.
unfilledFolderUUID = UUID()
UserDefaults.standard.set(unfilledFolderUUID.uuidString, forKey: FrontendDatabase.unfilledFolderKey)
// TODO: Try and find an existing unfiled folder UUID in case the user defaults got wiped.
unfiledFolderUUID = UUID()
UserDefaults.standard.set(unfiledFolderUUID.uuidString, forKey: FrontendDatabase.unfiledFolderKey)
}

let modelConfiguration = ModelConfiguration(schema: Schema.minnaSchema)
Expand Down Expand Up @@ -83,35 +84,35 @@ class FrontendDatabase: Database {
}

private func populateStartupData() throws {
try populateUnfilledFolder()
try populateunfiledFolder()
try populateAppleProvider()
try populateMLXProvider()

try context.save()
}

private func populateUnfilledFolder() throws {
let uuid = unfilledFolderUUID
private func populateunfiledFolder() throws {
let uuid = unfiledFolderUUID
var descriptor = FetchDescriptor<Folder>(predicate: #Predicate { $0.uuid == uuid })
descriptor.fetchLimit = 1

guard try context.fetch(descriptor).isEmpty else { return }
let unfilledFolder = Folder(uuid: unfilledFolderUUID, name: "Unfilled", icon: FolderIcon(symbol: .symbol(SFSymbol.trayFull.rawValue), color: .champagne), protected: true)
context.insert(unfilledFolder)
let unfiledFolder = Folder(uuid: unfiledFolderUUID, name: "Unfiled", icon: FolderIcon(symbol: .symbol(SFSymbol.trayFull.rawValue), color: .champagne), protected: true)
context.insert(unfiledFolder)
}

public func unfilledFolder() -> Folder {
let uuid = unfilledFolderUUID
public func unfiledFolder() -> Folder {
let uuid = unfiledFolderUUID
var descriptor = FetchDescriptor<Folder>(predicate: #Predicate { $0.uuid == uuid })
descriptor.fetchLimit = 1

if let folder = try? context.fetch(descriptor).first {
return folder
}

let unfilledFolder = Folder(uuid: unfilledFolderUUID, name: "Unfilled", icon: FolderIcon(symbol: .symbol(SFSymbol.trayFull.rawValue), color: .champagne), protected: true)
context.insert(unfilledFolder)
return unfilledFolder
let unfiledFolder = Folder(uuid: unfiledFolderUUID, name: "Unfiled", icon: FolderIcon(symbol: .symbol(SFSymbol.trayFull.rawValue), color: .champagne), protected: true)
context.insert(unfiledFolder)
return unfiledFolder
}

private func populateAppleProvider() throws {
Expand Down
195 changes: 106 additions & 89 deletions Minna/Database/Import/FileImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,45 +14,56 @@ import DatabaseSchema
import Logging

extension View {
// func standardDropDestination(presented: Binding<Bool>, selectedFolder: Folder?, modelContext: ModelContext, irisContext: IrisContext, database: Database) -> some View {
// self
// .onDrop(of: [.fileURL], isTargeted: presented) { providers in
// for provider in providers {
// provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in
// guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
//
// if url.hasDirectoryPath {
// if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.contentTypeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) {
// let urls = enumerator.allObjects.compactMap { $0 as? URL }
//
//
// do {
// try importURLs(urls, selectedFolder: selectedFolder, modelContext: modelContext, irisContext: irisContext, database: database)
// } catch {
// SentrySDK.capture(error: error)
// Log.logger.error("Failed to import files", error: error)
// }
////
//// for case let fileURL as URL in enumerator {
//// }
// }
// } else {
//// do {
//// let selectedURLs = try result.get()
//// try importURLs(selectedURLs, selectedFolder: selectedFolder, modelContext: modelContext, irisContext: irisContext, database: database)
//// } catch {
//// SentrySDK.capture(error: error)
//// Log.logger.error("Failed to import files", error: error)
//// }
//
// }
// }
// }
//
// return true
// }
//
// }
private func loadProviderItem(provider: NSItemProvider, for type: UTType, options: NSDictionary?) async -> URL? {
await withCheckedContinuation { continuation in
// MAYBE: Pass the progress that is retrieved here backwards so the user can see the import process
_ = provider.loadFileRepresentation(for: type, openInPlace: true) { url, wasOpenedInPlace, error in
guard error == nil, let fileURL = url else {
Log.logger.error("Failed to access dropped item", error: error)
continuation.resume(returning: nil)
return
}

if wasOpenedInPlace {
continuation.resume(returning: fileURL)
} else {
Log.logger.warning("Was not able to open \(fileURL) in place")
continuation.resume(returning: fileURL)
}
}
}
}

func standardDropDestination(presented: Binding<Bool>, selectedFolder: Folder?, modelContext: ModelContext, irisContext: IrisContext, database: Database) -> some View {
self
.dropDestination(for: URL.self) { urls, session in
var scopedURLs: [URL] = []

for url in urls {
do {
// OKAY LISTEN UP
// This is a workaround to get security scoped URLs out of a drop destination. By default macOS (and maybe iOS) do not allow you to get security scoped URLs within a drop session. This is because drop sessions are intended to be a form of IPC. When performing IPC you don't really want another app to be able to jump into another app's storage.
// One thing the system does give us is a URL that is resolvable to a bookmark. If we resolve the bookmark with security scope options, and then immediately reconstruct it, we get access to an actual URL that we can use the same as a url from a file importer.
// This is most definitely a hack / workaround. There is a chance this breaks in future versions of the app. In that case we need to prompt the user to open URLs that can't be security scoped using a file importer with its starting file set to the URL we retrieve here.
let data = try url.bookmarkData(options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], includingResourceValuesForKeys: [.contentTypeKey, .isDirectoryKey])
var isStale: Bool = false

guard let url = try? URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale) else { throw SecurityScopeError.unableToCreateSecurityScope }

scopedURLs.append(url)
} catch {
// TODO: Tell the user and have them use the file selector.
Log.logger.error("Failed to gain security scope for \(url)", error: error)
}
}

do {
try importURLs(scopedURLs, selectedFolder: selectedFolder, modelContext: modelContext, irisContext: irisContext, database: database)
} catch {
Log.logger.error("Failed to import urls in a drop session", error: error)
}
}
}

func standardFileImporter(presented: Binding<Bool>, selectedFolder: Folder?, modelContext: ModelContext, irisContext: IrisContext, database: Database) -> some View {
self
Expand All @@ -76,69 +87,75 @@ extension View {
SentrySDK.capture(message: "IrisDB instance was not connected.")
return
}

var folder: Folder

if let selectedFolder {
folder = selectedFolder
folder = selectedFolder
} else {
// Capture the unfilled UUID so the predicate operates (it needs local state)
let unfilledUUID = database.unfilledFolderUUID
var descriptor = FetchDescriptor<Folder>(predicate: #Predicate { $0.uuid == unfilledUUID })
descriptor.fetchLimit = 1
let folders = try modelContext.fetch(descriptor)
guard let unfilledFolder = folders.first else { return }
folder = unfilledFolder
// Capture the unfiled UUID so the predicate operates (it needs local state)
let unfiledUUID = database.unfiledFolderUUID
var descriptor = FetchDescriptor<Folder>(predicate: #Predicate { $0.uuid == unfiledUUID })
descriptor.fetchLimit = 1
let folders = try modelContext.fetch(descriptor)
guard let unfiledFolder = folders.first else {
Log.logger.error("Failed to find the unfiled folder.")
return
}
folder = unfiledFolder
}

// Track URLs inserted so we do not insert any duplicates.
var insertedURLs: Set<URL> = []

var insertedFiles: [File] = []

for url in urls {
let gotAccess = url.startAccessingSecurityScopedResource()
guard gotAccess else { return }

do {
// URL may be a directory, so this can return many urls.
let files = try FileFactory.files(from: url, in: folder)

// Local copy of file urls for the search predicate.
let urls = files.compactMap({$0.url})

// Skip anything already persisted in the store.
let descriptor = FetchDescriptor<File>(predicate: #Predicate { urls.contains($0.url) })

// Find all of the URLs in this set that already exist in the database.
let existingURLs: Set<URL> = Set((try? modelContext.fetch(descriptor))?.compactMap({$0.url}) ?? [])
insertedURLs = insertedURLs.union(existingURLs)

for file in files {
// Skip anything we've already inserted into the database.
guard !insertedURLs.contains(file.url) else {
continue
}

insertedFiles.append(file)
modelContext.insert(file)
}
} catch {
SentrySDK.capture(error: error)
Log.logger.error("Failed to create file", error: error, metadata: ["url": "\(url)"])
}

url.stopAccessingSecurityScopedResource()
let gotAccess = url.startAccessingSecurityScopedResource()
defer { url.stopAccessingSecurityScopedResource() }
guard gotAccess else {
Log.logger.error("Failed to gain security scope for \(url)")
continue
}

do {
// URL may be a directory, so this can return many urls.
let files = try FileFactory.files(from: url, in: folder)

// Local copy of file urls for the search predicate.
let urls = files.compactMap({$0.url})

// Skip anything already persisted in the store.
let descriptor = FetchDescriptor<File>(predicate: #Predicate { urls.contains($0.url) })

// Find all of the URLs in this set that already exist in the database.
let existingURLs: Set<URL> = Set((try? modelContext.fetch(descriptor))?.compactMap({$0.url}) ?? [])
insertedURLs = insertedURLs.union(existingURLs)

for file in files {
// Skip anything we've already inserted into the database.
guard !insertedURLs.contains(file.url) else {
Log.logger.info("Skipping \(file.url) because it has already been imported.")
continue
}

insertedFiles.append(file)
modelContext.insert(file)
}
} catch {
SentrySDK.capture(error: error)
Log.logger.error("Failed to create file", error: error, metadata: ["url": "\(url)"])
}
}

// Make sure the database is fully saved so service tasks can access files.
try modelContext.save()

// Run service tasks. These are both async, they will dispatch their own tasks within.
for file in insertedFiles {
try irisContext.insert(file)
database.queueDescriptionUpdate(for: file)
Log.logger.info("Inserting \(file.title) into search database.")
try irisContext.insert(file)
database.queueDescriptionUpdate(for: file)
}

}
}
10 changes: 5 additions & 5 deletions Minna/Database/Previews/SampleDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class SampleDatabase: Database {
static let shared = SampleDatabase()

public var sampleFolders: [Folder] = [
Folder(name: "Unfilled", icon: FolderIcon(symbol: .symbol(SFSymbol.trayFull.rawValue), color: .champagne), files: [
Folder(name: "Unfiled", icon: FolderIcon(symbol: .symbol(SFSymbol.trayFull.rawValue), color: .champagne), files: [

], protected: true),
Folder(name: "Coding", icon: FolderIcon(symbol: .symbol(SFSymbol.ellipsisCurlybraces.rawValue), color: .azure), children: [
Expand Down Expand Up @@ -279,7 +279,7 @@ class SampleDatabase: Database {
]

let modelContainer: ModelContainer
var unfilledFolderUUID: UUID = UUID()
var unfiledFolderUUID: UUID = UUID()
var initializationError: (any Error)?

var context: ModelContext {
Expand All @@ -299,8 +299,8 @@ class SampleDatabase: Database {

private func populateSampleData() throws {
for folder in sampleFolders {
if folder.name == "Unfilled" {
unfilledFolderUUID = folder.uuid
if folder.name == "Unfiled" {
unfiledFolderUUID = folder.uuid
}

context.insert(folder)
Expand All @@ -317,7 +317,7 @@ class SampleDatabase: Database {
context.insert(appleProvider)
}

func unfilledFolder() -> Folder {
func unfiledFolder() -> Folder {
return sampleFolders.first!
}

Expand Down
Loading
Loading