From 7f4ee69707b88fabff2cee155363f5aeace2f5da Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Mon, 26 Feb 2024 14:24:24 -1000 Subject: [PATCH] fix: Store modification dates and handle updates (#78) --- Folders.xcodeproj/project.pbxproj | 4 +++ Folders/Extensions/Date.swift | 36 +++++++++++++++++++ Folders/Extensions/FileManager.swift | 23 +++++++++--- Folders/Models/ApplicationModel.swift | 10 ++++-- Folders/Models/Details.swift | 15 +++++++- Folders/Utilities/DirectoryScanner.swift | 45 ++++++++++++++++++++++-- Folders/Utilities/Store.swift | 12 ++++--- 7 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 Folders/Extensions/Date.swift diff --git a/Folders.xcodeproj/project.pbxproj b/Folders.xcodeproj/project.pbxproj index 595de01..10b5131 100644 --- a/Folders.xcodeproj/project.pbxproj +++ b/Folders.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ D8F6A6092B8D3D7E0003B1A6 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = D8F6A6082B8D3D7E0003B1A6 /* Yams */; }; D8F6A60B2B8D41F90003B1A6 /* yams-license in Resources */ = {isa = PBXBuildFile; fileRef = D8F6A60A2B8D41F90003B1A6 /* yams-license */; }; D8F6A60D2B8D46720003B1A6 /* FolderSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F6A60C2B8D46720003B1A6 /* FolderSettings.swift */; }; + D8F6A60F2B8D52200003B1A6 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F6A60E2B8D52200003B1A6 /* Date.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -119,6 +120,7 @@ D8F349B42B8150690037D66A /* UTType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTType.swift; sourceTree = ""; }; D8F6A60A2B8D41F90003B1A6 /* yams-license */ = {isa = PBXFileReference; lastKnownFileType = text; path = "yams-license"; sourceTree = ""; }; D8F6A60C2B8D46720003B1A6 /* FolderSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSettings.swift; sourceTree = ""; }; + D8F6A60E2B8D52200003B1A6 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -246,6 +248,7 @@ isa = PBXGroup; children = ( D855DE1F2AD31EC0003FE04D /* CGRect.swift */, + D8F6A60E2B8D52200003B1A6 /* Date.swift */, D8DDBCC52B2A1317003EAF4E /* FileManager.swift */, D8472A6B2B2518900070DB64 /* NSCollectionViewDiffableDataSource.swift */, D8472A692B2518600070DB64 /* NSWorkspace.swift */, @@ -444,6 +447,7 @@ D89622E42ACD4266006F7D2E /* LibraryView.swift in Sources */, D8DDBCC42B2A0F8E003EAF4E /* PreviewItem.swift in Sources */, D855DE202AD31EC0003FE04D /* CGRect.swift in Sources */, + D8F6A60F2B8D52200003B1A6 /* Date.swift in Sources */, D85C07B12B7EBC3A00C8BAA6 /* FolderView.swift in Sources */, D814EA012B7EB620008BF46C /* Sort.swift in Sources */, D8DDBCC82B2A1582003EAF4E /* DirectoryScanner.swift in Sources */, diff --git a/Folders/Extensions/Date.swift b/Folders/Extensions/Date.swift new file mode 100644 index 0000000..86fdcd1 --- /dev/null +++ b/Folders/Extensions/Date.swift @@ -0,0 +1,36 @@ +// MIT License +// +// Copyright (c) 2023-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +extension Date { + + var millisecondsSinceReferenceDate: Int { + return Int(timeIntervalSinceReferenceDate * 1000) + } + + init(millisecondsSinceReferenceDate: Int) { + let timeInterval: TimeInterval = Double(millisecondsSinceReferenceDate) / 1000.0 + self.init(timeIntervalSinceReferenceDate: timeInterval) + } + +} diff --git a/Folders/Extensions/FileManager.swift b/Folders/Extensions/FileManager.swift index 28df293..2f346cd 100644 --- a/Folders/Extensions/FileManager.swift +++ b/Folders/Extensions/FileManager.swift @@ -27,18 +27,26 @@ extension FileManager { func files(directoryURL: URL) throws -> [Details] { let date = Date() - let resourceKeys = Set([.nameKey, .isDirectoryKey, .contentTypeKey]) + let resourceKeys = Set([.nameKey, + .isDirectoryKey, + .contentTypeKey, + .contentModificationDateKey]) let directoryEnumerator = enumerator(at: directoryURL, includingPropertiesForKeys: Array(resourceKeys), options: [.skipsHiddenFiles])! var files: [Details] = [] for case let fileURL as URL in directoryEnumerator { - guard let contentType = try fileURL.resourceValues(forKeys: [.contentTypeKey]).contentType else { + guard let contentType = try fileURL.resourceValues(forKeys: [.contentTypeKey]).contentType, + let contentModificationDate = try fileURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + else { print("Failed to determine content type for \(fileURL).") continue } - files.append(Details(ownerURL: directoryURL, url: fileURL, contentType: contentType)) + files.append(Details(ownerURL: directoryURL, + url: fileURL, + contentType: contentType, + contentModificationDate: contentModificationDate.millisecondsSinceReferenceDate)) } let duration = date.distance(to: Date()) @@ -61,7 +69,14 @@ extension FileManager { throw FoldersError.general("Unable to get content type for file '\(url.path)'.") } - return Details(ownerURL: owner, url: url, contentType: isDirectory ? .directory : contentType) + guard let contentModificationDate = try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { + throw FoldersError.general("Unable to get content modification date for file '\(url.path)'.") + } + + return Details(ownerURL: owner, + url: url, + contentType: isDirectory ? .directory : contentType, + contentModificationDate: contentModificationDate.millisecondsSinceReferenceDate) } } diff --git a/Folders/Models/ApplicationModel.swift b/Folders/Models/ApplicationModel.swift index 52ef6c9..ae95363 100644 --- a/Folders/Models/ApplicationModel.swift +++ b/Folders/Models/ApplicationModel.swift @@ -107,12 +107,18 @@ class ApplicationModel: NSObject, ObservableObject { // Add just the new files. let newFiles = currentFiles.subtracting(storedFiles) - try store.insertBlocking(files: newFiles) + if newFiles.count > 0 { + print("Inserting \(newFiles.count) new files...") + try store.insertBlocking(files: newFiles) + } // Remove the remaining files. let deletedIdentifiers = storedFiles.subtracting(currentFiles) .map { $0.identifier } - try store.removeBlocking(identifiers: deletedIdentifiers) + if deletedIdentifiers.count > 0 { + print("Removing \(deletedIdentifiers.count) deleted files...") + try store.removeBlocking(identifiers: deletedIdentifiers) + } let insertDuration = insertStart.distance(to: Date()) print("Update took \(insertDuration.formatted()) seconds.") diff --git a/Folders/Models/Details.swift b/Folders/Models/Details.swift index 017e529..4cd8659 100644 --- a/Folders/Models/Details.swift +++ b/Folders/Models/Details.swift @@ -39,15 +39,28 @@ struct Details: Hashable { let url: URL let contentType: UTType - init(ownerURL: URL, url: URL, contentType: UTType) { + // Even though modern Swift APIs expose the content modification date, round-tripping this into SQLite looses + // precision causing us to incorrectly think files have changed. To make it much harder to make this mistake, we + // instead store the contentModificationDate as an Int which represents milliseconds since the reference data. + let contentModificationDate: Int + + init(ownerURL: URL, url: URL, contentType: UTType, contentModificationDate: Int) { self.identifier = Identifier(ownerURL: ownerURL, url: url) self.ownerURL = ownerURL self.url = url self.contentType = contentType + self.contentModificationDate = contentModificationDate } var parentURL: URL { return url.deletingLastPathComponent() } + func setting(ownerURL: URL) -> Details { + return Details(ownerURL: ownerURL, + url: url, + contentType: contentType, + contentModificationDate: contentModificationDate) + } + } diff --git a/Folders/Utilities/DirectoryScanner.swift b/Folders/Utilities/DirectoryScanner.swift index 89a91a4..8a8c270 100644 --- a/Folders/Utilities/DirectoryScanner.swift +++ b/Folders/Utilities/DirectoryScanner.swift @@ -70,8 +70,20 @@ class DirectoryScanner { do { let url = URL(filePath: path, directoryHint: itemType == .dir ? .isDirectory : .notDirectory) if fileManager.fileExists(atPath: url.path) { - print("File added by rename '\(url)'") + let details = try FileManager.default.details(for: url, owner: ownerURL) + + // If a file exists at the new path and also exists in our runtime cache of files then we infer + // that this rename actuall represents a content modification operation; our file has been + // atomically replaced by a new file containing new content. + if self.identifiers.contains(details.identifier) { + print("File updated by rename '\(url)'") + onFileDeletion([details.identifier]) + // TODO: We should ensure we delete all our children if we're a directory. + } else { + print("File added by rename '\(url)'") + } + onFileCreation([details]) self.identifiers.insert(details.identifier) @@ -79,7 +91,7 @@ class DirectoryScanner { if itemType == .dir { let files = try fileManager.files(directoryURL: url) .map { details in - return Details(ownerURL: ownerURL, url: details.url, contentType: details.contentType) + return details.setting(ownerURL: ownerURL) } onFileCreation(files) self.identifiers.formUnion(files.map({ $0.identifier })) @@ -111,6 +123,35 @@ class DirectoryScanner { onFileDeletion([identifier]) self.identifiers.remove(identifier) + case .itemInodeMetadataModified(path: let path, itemType: let itemType, eventId: _, fromUs: _): + /* .itemXattrModified(path: let path, itemType: let itemType, eventId: _, fromUs: _) */ + + // TODO: Common error handling for all callbacks. + + do { + + // TODO: Consider generalising this code. + let url = URL(filePath: path, directoryHint: itemType == .dir ? .isDirectory : .notDirectory) + let identifier = Details.Identifier(ownerURL: ownerURL, url: url) + + // Remove the file if it exists in our set. + if self.identifiers.contains(identifier) { + onFileDeletion([identifier]) + } + + // Create a new identifier corresponding to the udpated file. + let details = try FileManager.default.details(for: url, owner: ownerURL) // TODO: details(for identifier: Details.Identifier)? + onFileCreation([details]) + + // Ensure there's an entry for the (potentially) new file. + self.identifiers.insert(identifier) + + print("Item modified!") + + } catch { + print("Failed to handle file modification with error \(error).") + } + default: print("Unhandled file event \(event).") } diff --git a/Folders/Utilities/Store.swift b/Folders/Utilities/Store.swift index 0e0da18..5c6bcdf 100644 --- a/Folders/Utilities/Store.swift +++ b/Folders/Utilities/Store.swift @@ -41,9 +41,10 @@ class Store { static let path = Expression("path") static let name = Expression("name") static let type = Expression("type") + static let modificationDate = Expression("modification_date") } - static let majorVersion = 45 + static let majorVersion = 47 var observers: [StoreObserver] = [] @@ -60,6 +61,7 @@ class Store { t.column(Schema.path) t.column(Schema.name) t.column(Schema.type) + t.column(Schema.modificationDate) }) try connection.run(Schema.files.createIndex(Schema.path)) }, @@ -159,7 +161,8 @@ class Store { Schema.owner <- file.ownerURL.path, Schema.path <- file.url.path, Schema.name <- file.url.displayName, - Schema.type <- file.contentType.identifier)) + Schema.type <- file.contentType.identifier, + Schema.modificationDate <- file.contentModificationDate)) // Track the inserted files to notify our observers. insertions.append(file) @@ -209,7 +212,7 @@ class Store { func syncQueue_files(filter: Filter, sort: Sort) throws -> [Details] { dispatchPrecondition(condition: .onQueue(syncQueue)) - return try connection.prepareRowIterator(Schema.files.select(Schema.owner, Schema.path, Schema.type) + return try connection.prepareRowIterator(Schema.files.select(Schema.owner, Schema.path, Schema.type, Schema.modificationDate) .filter(filter.filter) .order(sort.order)) .map { row in @@ -217,7 +220,8 @@ class Store { let ownerURL = URL(filePath: row[Schema.owner], directoryHint: .isDirectory) let url = URL(filePath: row[Schema.path], directoryHint: type.conforms(to: .directory) ? .isDirectory : .notDirectory) - return Details(ownerURL: ownerURL, url: url, contentType: type) + let modificationDate = row[Schema.modificationDate] + return Details(ownerURL: ownerURL, url: url, contentType: type, contentModificationDate: modificationDate) } }