2 * Copyright (C) 2022 by Claudio Cambra <claudio.cambra@nextcloud.com>
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 2 of the License, or
7 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful, but
10 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
21 class NextcloudFilesDatabaseManager {
22 static let shared = NextcloudFilesDatabaseManager()!
24 private static let relativeDatabaseFolderPath = "Database/"
25 private static let databaseFilename = "fileproviderextdatabase.realm"
26 private static let schemaVersion: UInt64 = 100
28 init(realmConfig: Realm.Configuration = Realm.Configuration.defaultConfiguration) {
29 Realm.Configuration.defaultConfiguration = realmConfig
33 Logger.ncFilesDatabase.info("Successfully started Realm db for FileProviderExt")
35 Logger.ncFilesDatabase.error("Error opening Realm db: \(error, privacy: .public)")
40 let relativeDatabaseFilePath = Self.relativeDatabaseFolderPath + Self.databaseFilename
41 guard let fileProviderDataDirUrl = pathForFileProviderExtData() else { return nil }
42 let databasePath = fileProviderDataDirUrl.appendingPathComponent(relativeDatabaseFilePath)
44 // Disable file protection for directory DB
45 // https://docs.mongodb.com/realm/sdk/ios/examples/configure-and-open-a-realm/
46 let dbFolder = fileProviderDataDirUrl.appendingPathComponent(Self.relativeDatabaseFolderPath)
47 let dbFolderPath = dbFolder.path
49 try FileManager.default.createDirectory(at: dbFolder, withIntermediateDirectories: true)
50 try FileManager.default.setAttributes(
51 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
52 ofItemAtPath: dbFolderPath
55 Logger.ncFilesDatabase.error(
56 "Could not set permission level for db folder: \(error, privacy: .public)"
60 let config = Realm.Configuration(
61 fileURL: databasePath,
62 schemaVersion: Self.schemaVersion,
63 objectTypes: [NextcloudItemMetadataTable.self, NextcloudLocalFileMetadataTable.self]
65 self.init(realmConfig: config)
68 func ncDatabase() -> Realm {
69 let realm = try! Realm()
74 func anyItemMetadatasForAccount(_ account: String) -> Bool {
75 !ncDatabase().objects(NextcloudItemMetadataTable.self).filter("account == %@", account)
79 func itemMetadataFromOcId(_ ocId: String) -> NextcloudItemMetadataTable? {
80 // Realm objects are live-fire, i.e. they will be changed and invalidated according to changes in the db
81 // Let's therefore create a copy
82 if let itemMetadata = ncDatabase().objects(NextcloudItemMetadataTable.self).filter(
85 return NextcloudItemMetadataTable(value: itemMetadata)
91 func sortedItemMetadatas(_ metadatas: Results<NextcloudItemMetadataTable>)
92 -> [NextcloudItemMetadataTable]
94 let sortedMetadatas = metadatas.sorted(byKeyPath: "fileName", ascending: true)
95 return Array(sortedMetadatas.map { NextcloudItemMetadataTable(value: $0) })
98 func itemMetadatas(account: String) -> [NextcloudItemMetadataTable] {
99 let metadatas = ncDatabase().objects(NextcloudItemMetadataTable.self).filter(
100 "account == %@", account)
101 return sortedItemMetadatas(metadatas)
104 func itemMetadatas(account: String, serverUrl: String) -> [NextcloudItemMetadataTable] {
105 let metadatas = ncDatabase().objects(NextcloudItemMetadataTable.self).filter(
106 "account == %@ AND serverUrl == %@", account, serverUrl)
107 return sortedItemMetadatas(metadatas)
111 account: String, serverUrl: String, status: NextcloudItemMetadataTable.Status
113 -> [NextcloudItemMetadataTable]
115 let metadatas = ncDatabase().objects(NextcloudItemMetadataTable.self).filter(
116 "account == %@ AND serverUrl == %@ AND status == %@",
120 return sortedItemMetadatas(metadatas)
123 func itemMetadataFromFileProviderItemIdentifier(_ identifier: NSFileProviderItemIdentifier)
124 -> NextcloudItemMetadataTable?
126 let ocId = identifier.rawValue
127 return itemMetadataFromOcId(ocId)
130 private func processItemMetadatasToDelete(
131 existingMetadatas: Results<NextcloudItemMetadataTable>,
132 updatedMetadatas: [NextcloudItemMetadataTable]
133 ) -> [NextcloudItemMetadataTable] {
134 var deletedMetadatas: [NextcloudItemMetadataTable] = []
136 for existingMetadata in existingMetadatas {
137 guard !updatedMetadatas.contains(where: { $0.ocId == existingMetadata.ocId }),
138 let metadataToDelete = itemMetadataFromOcId(existingMetadata.ocId)
141 deletedMetadatas.append(metadataToDelete)
143 Logger.ncFilesDatabase.debug(
144 "Deleting item metadata during update. ocID: \(existingMetadata.ocId, privacy: .public), etag: \(existingMetadata.etag, privacy: .public), fileName: \(existingMetadata.fileName, privacy: .public)"
148 return deletedMetadatas
151 private func processItemMetadatasToUpdate(
152 existingMetadatas: Results<NextcloudItemMetadataTable>,
153 updatedMetadatas: [NextcloudItemMetadataTable],
154 updateDirectoryEtags: Bool
156 newMetadatas: [NextcloudItemMetadataTable], updatedMetadatas: [NextcloudItemMetadataTable],
157 directoriesNeedingRename: [NextcloudItemMetadataTable]
159 var returningNewMetadatas: [NextcloudItemMetadataTable] = []
160 var returningUpdatedMetadatas: [NextcloudItemMetadataTable] = []
161 var directoriesNeedingRename: [NextcloudItemMetadataTable] = []
163 for updatedMetadata in updatedMetadatas {
164 if let existingMetadata = existingMetadatas.first(where: {
165 $0.ocId == updatedMetadata.ocId
167 if existingMetadata.status == NextcloudItemMetadataTable.Status.normal.rawValue,
168 !existingMetadata.isInSameDatabaseStoreableRemoteState(updatedMetadata)
170 if updatedMetadata.directory {
171 if updatedMetadata.serverUrl != existingMetadata.serverUrl
172 || updatedMetadata.fileName != existingMetadata.fileName
174 directoriesNeedingRename.append(
175 NextcloudItemMetadataTable(value: updatedMetadata))
176 updatedMetadata.etag = "" // Renaming doesn't change the etag so reset manually
178 } else if !updateDirectoryEtags {
179 updatedMetadata.etag = existingMetadata.etag
183 returningUpdatedMetadatas.append(updatedMetadata)
185 Logger.ncFilesDatabase.debug(
186 "Updated existing item metadata. ocID: \(updatedMetadata.ocId, privacy: .public), etag: \(updatedMetadata.etag, privacy: .public), fileName: \(updatedMetadata.fileName, privacy: .public)"
189 Logger.ncFilesDatabase.debug(
190 "Skipping item metadata update; same as existing, or still downloading/uploading. ocID: \(updatedMetadata.ocId, privacy: .public), etag: \(updatedMetadata.etag, privacy: .public), fileName: \(updatedMetadata.fileName, privacy: .public)"
194 } else { // This is a new metadata
195 if !updateDirectoryEtags, updatedMetadata.directory {
196 updatedMetadata.etag = ""
199 returningNewMetadatas.append(updatedMetadata)
201 Logger.ncFilesDatabase.debug(
202 "Created new item metadata during update. ocID: \(updatedMetadata.ocId, privacy: .public), etag: \(updatedMetadata.etag, privacy: .public), fileName: \(updatedMetadata.fileName, privacy: .public)"
207 return (returningNewMetadatas, returningUpdatedMetadatas, directoriesNeedingRename)
210 func updateItemMetadatas(
213 updatedMetadatas: [NextcloudItemMetadataTable],
214 updateDirectoryEtags: Bool
216 newMetadatas: [NextcloudItemMetadataTable]?,
217 updatedMetadatas: [NextcloudItemMetadataTable]?,
218 deletedMetadatas: [NextcloudItemMetadataTable]?
220 let database = ncDatabase()
223 let existingMetadatas = database.objects(NextcloudItemMetadataTable.self).filter(
224 "account == %@ AND serverUrl == %@ AND status == %@",
227 NextcloudItemMetadataTable.Status.normal.rawValue)
229 let metadatasToDelete = processItemMetadatasToDelete(
230 existingMetadatas: existingMetadatas,
231 updatedMetadatas: updatedMetadatas)
233 let metadatasToChange = processItemMetadatasToUpdate(
234 existingMetadatas: existingMetadatas,
235 updatedMetadatas: updatedMetadatas,
236 updateDirectoryEtags: updateDirectoryEtags)
238 var metadatasToUpdate = metadatasToChange.updatedMetadatas
239 let metadatasToCreate = metadatasToChange.newMetadatas
240 let directoriesNeedingRename = metadatasToChange.directoriesNeedingRename
243 Array(metadatasToUpdate.map { NextcloudItemMetadataTable(value: $0) })
244 + Array(metadatasToCreate.map { NextcloudItemMetadataTable(value: $0) })
246 for metadata in directoriesNeedingRename {
247 if let updatedDirectoryChildren = renameDirectoryAndPropagateToChildren(
249 newServerUrl: metadata.serverUrl,
250 newFileName: metadata.fileName)
252 metadatasToUpdate += updatedDirectoryChildren
257 for metadata in metadatasToDelete {
258 // Can't pass copies, we need the originals from the database
260 ncDatabase().objects(NextcloudItemMetadataTable.self).filter(
261 "ocId == %@", metadata.ocId))
264 for metadata in metadatasToAdd {
265 database.add(metadata, update: .all)
270 newMetadatas: metadatasToCreate,
271 updatedMetadatas: metadatasToUpdate,
272 deletedMetadatas: metadatasToDelete
275 Logger.ncFilesDatabase.error(
276 "Could not update any item metadatas, received error: \(error.localizedDescription, privacy: .public)"
278 return (nil, nil, nil)
282 func setStatusForItemMetadata(
283 _ metadata: NextcloudItemMetadataTable,
284 status: NextcloudItemMetadataTable.Status,
285 completionHandler: @escaping (_ updatedMetadata: NextcloudItemMetadataTable?) -> Void
287 let database = ncDatabase()
292 let result = database.objects(NextcloudItemMetadataTable.self).filter(
293 "ocId == %@", metadata.ocId
296 Logger.ncFilesDatabase.debug(
297 "Did not update status for item metadata as it was not found. ocID: \(metadata.ocId, privacy: .public)"
302 result.status = status.rawValue
303 database.add(result, update: .all)
304 Logger.ncFilesDatabase.debug(
305 "Updated status for item metadata. ocID: \(metadata.ocId, privacy: .public), etag: \(metadata.etag, privacy: .public), fileName: \(metadata.fileName, privacy: .public)"
308 completionHandler(NextcloudItemMetadataTable(value: result))
311 Logger.ncFilesDatabase.error(
312 "Could not update status for item metadata with ocID: \(metadata.ocId, privacy: .public), etag: \(metadata.etag, privacy: .public), fileName: \(metadata.fileName, privacy: .public), received error: \(error.localizedDescription, privacy: .public)"
314 completionHandler(nil)
318 func addItemMetadata(_ metadata: NextcloudItemMetadataTable) {
319 let database = ncDatabase()
323 database.add(metadata, update: .all)
324 Logger.ncFilesDatabase.debug(
325 "Added item metadata. ocID: \(metadata.ocId, privacy: .public), etag: \(metadata.etag, privacy: .public), fileName: \(metadata.fileName, privacy: .public)"
329 Logger.ncFilesDatabase.error(
330 "Could not add item metadata. ocID: \(metadata.ocId, privacy: .public), etag: \(metadata.etag, privacy: .public), fileName: \(metadata.fileName, privacy: .public), received error: \(error.localizedDescription, privacy: .public)"
335 @discardableResult func deleteItemMetadata(ocId: String) -> Bool {
336 let database = ncDatabase()
340 let results = database.objects(NextcloudItemMetadataTable.self).filter(
343 Logger.ncFilesDatabase.debug("Deleting item metadata. \(ocId, privacy: .public)")
344 database.delete(results)
349 Logger.ncFilesDatabase.error(
350 "Could not delete item metadata with ocId: \(ocId, privacy: .public), received error: \(error.localizedDescription, privacy: .public)"
356 func renameItemMetadata(ocId: String, newServerUrl: String, newFileName: String) {
357 let database = ncDatabase()
362 let itemMetadata = database.objects(NextcloudItemMetadataTable.self).filter(
366 Logger.ncFilesDatabase.debug(
367 "Could not find an item with ocID \(ocId, privacy: .public) to rename to \(newFileName, privacy: .public)"
372 let oldFileName = itemMetadata.fileName
373 let oldServerUrl = itemMetadata.serverUrl
375 itemMetadata.fileName = newFileName
376 itemMetadata.fileNameView = newFileName
377 itemMetadata.serverUrl = newServerUrl
379 database.add(itemMetadata, update: .all)
381 Logger.ncFilesDatabase.debug(
382 "Renamed item \(oldFileName, privacy: .public) to \(newFileName, privacy: .public), moved from serverUrl: \(oldServerUrl, privacy: .public) to serverUrl: \(newServerUrl, privacy: .public)"
386 Logger.ncFilesDatabase.error(
387 "Could not rename filename of item metadata with ocID: \(ocId, privacy: .public) to proposed name \(newFileName, privacy: .public) at proposed serverUrl \(newServerUrl, privacy: .public), received error: \(error.localizedDescription, privacy: .public)"
392 func parentItemIdentifierFromMetadata(_ metadata: NextcloudItemMetadataTable)
393 -> NSFileProviderItemIdentifier?
395 let homeServerFilesUrl = metadata.urlBase + "/remote.php/dav/files/" + metadata.userId
397 if metadata.serverUrl == homeServerFilesUrl {
398 return .rootContainer
401 guard let itemParentDirectory = parentDirectoryMetadataForItem(metadata) else {
402 Logger.ncFilesDatabase.error(
403 "Could not get item parent directory metadata for metadata. ocID: \(metadata.ocId, privacy: .public), etag: \(metadata.etag, privacy: .public), fileName: \(metadata.fileName, privacy: .public)"
408 if let parentDirectoryMetadata = itemMetadataFromOcId(itemParentDirectory.ocId) {
409 return NSFileProviderItemIdentifier(parentDirectoryMetadata.ocId)
412 Logger.ncFilesDatabase.error(
413 "Could not get item parent directory item metadata for metadata. ocID: \(metadata.ocId, privacy: .public), etag: \(metadata.etag, privacy: .public), fileName: \(metadata.fileName, privacy: .public)"