Skip to content

Commit

Permalink
fix: Store modification dates and handle updates (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbmorley authored Feb 27, 2024
1 parent 59b6eb7 commit 7f4ee69
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 13 deletions.
4 changes: 4 additions & 0 deletions Folders.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -119,6 +120,7 @@
D8F349B42B8150690037D66A /* UTType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTType.swift; sourceTree = "<group>"; };
D8F6A60A2B8D41F90003B1A6 /* yams-license */ = {isa = PBXFileReference; lastKnownFileType = text; path = "yams-license"; sourceTree = "<group>"; };
D8F6A60C2B8D46720003B1A6 /* FolderSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSettings.swift; sourceTree = "<group>"; };
D8F6A60E2B8D52200003B1A6 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -246,6 +248,7 @@
isa = PBXGroup;
children = (
D855DE1F2AD31EC0003FE04D /* CGRect.swift */,
D8F6A60E2B8D52200003B1A6 /* Date.swift */,
D8DDBCC52B2A1317003EAF4E /* FileManager.swift */,
D8472A6B2B2518900070DB64 /* NSCollectionViewDiffableDataSource.swift */,
D8472A692B2518600070DB64 /* NSWorkspace.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
36 changes: 36 additions & 0 deletions Folders/Extensions/Date.swift
Original file line number Diff line number Diff line change
@@ -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)
}

}
23 changes: 19 additions & 4 deletions Folders/Extensions/FileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,26 @@ extension FileManager {

func files(directoryURL: URL) throws -> [Details] {
let date = Date()
let resourceKeys = Set<URLResourceKey>([.nameKey, .isDirectoryKey, .contentTypeKey])
let resourceKeys = Set<URLResourceKey>([.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())
Expand All @@ -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)
}

}
10 changes: 8 additions & 2 deletions Folders/Models/ApplicationModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
15 changes: 14 additions & 1 deletion Folders/Models/Details.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
45 changes: 43 additions & 2 deletions Folders/Utilities/DirectoryScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,28 @@ 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)

// We don't get notified about files contained within a directory, so we walk those explicitly.
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 }))
Expand Down Expand Up @@ -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).")
}
Expand Down
12 changes: 8 additions & 4 deletions Folders/Utilities/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ class Store {
static let path = Expression<String>("path")
static let name = Expression<String>("name")
static let type = Expression<String>("type")
static let modificationDate = Expression<Int>("modification_date")
}

static let majorVersion = 45
static let majorVersion = 47

var observers: [StoreObserver] = []

Expand All @@ -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))
},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -209,15 +212,16 @@ 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
let type = UTType(row[Schema.type])!
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)
}
}

Expand Down

0 comments on commit 7f4ee69

Please sign in to comment.