Sources/OrgWorkspace/Discovery.swift
110 lines · 4526 bytes
1import Foundation
2import OrgIndex
3
4/// A folder the user added. Identified by its bookmark, so a moved folder is followed.
5public struct WorkspaceRoot: Sendable, Equatable, Codable {
6 public let bookmark: Data
7 /// The path when the bookmark was made or last refreshed, for display.
8 public var path: String
9
10 public init(url: URL) throws {
11 bookmark = try url.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil)
12 path = url.standardizedFileURL.path
13 }
14
15 /// The folder's current location. `isStale` means the bookmark should be made again.
16 public func resolve() throws -> (url: URL, isStale: Bool) {
17 var isStale = false
18 let url = try URL(resolvingBookmarkData: bookmark, options: [], relativeTo: nil, bookmarkDataIsStale: &isStale)
19 _ = url.startAccessingSecurityScopedResource()
20 return (url.standardizedFileURL, isStale)
21 }
22}
23
24public struct DiscoveredFile: Sendable, Equatable {
25 public let url: URL
26 public let kind: FileKind
27 public let size: Int
28 public let mtime: Double
29}
30
31public struct Discovery: Sendable, Equatable {
32 public var files: [DiscoveredFile] = []
33 /// iCloud files not downloaded yet, by the name they will have.
34 public var placeholders: [URL] = []
35}
36
37public enum FileClassification: Sendable, Equatable {
38 case file(FileKind)
39 /// An iCloud placeholder (`.name.org.icloud`) for the file at the given name.
40 case placeholder(String)
41 case ignored
42}
43
44public enum WorkspaceScanner {
45 /// What a file name means to the workspace. Syncthing temp files, our own temp and backup
46 /// files, Emacs backups and auto-saves, and other hidden files are ignored.
47 public static func classify(_ name: String) -> FileClassification {
48 if name.hasPrefix(".") {
49 if name.hasSuffix(".icloud") {
50 let inner = String(name.dropFirst().dropLast(".icloud".count))
51 if case .file = classify(inner) { return .placeholder(inner) }
52 }
53 return .ignored
54 }
55 if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored }
56 let conflict = name.contains(".sync-conflict-")
57 if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) }
58 if name.hasSuffix(".org_archive") { return .file(conflict ? .conflict : .archive) }
59 return .ignored
60 }
61
62 /// Every org file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`).
63 public static func scan(_ root: URL) throws -> Discovery {
64 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
65 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
66 return Discovery()
67 }
68 var discovery = Discovery()
69 for case let url as URL in enumerator {
70 let values = try url.resourceValues(forKeys: Set(keys))
71 let name = url.lastPathComponent
72 if values.isDirectory == true {
73 if name.hasPrefix(".") { enumerator.skipDescendants() }
74 continue
75 }
76 switch classify(name) {
77 case .file(let kind):
78 discovery.files.append(DiscoveredFile(
79 url: url.standardizedFileURL,
80 kind: kind,
81 size: values.fileSize ?? 0,
82 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
83 ))
84 case .placeholder(let inner):
85 discovery.placeholders.append(url.deletingLastPathComponent().appendingPathComponent(inner).standardizedFileURL)
86 case .ignored:
87 break
88 }
89 }
90 discovery.files.sort { $0.url.path < $1.url.path }
91 return discovery
92 }
93}
94
95/// Which files feed the agenda: `.org` files (not archives or conflict copies) matching one of
96/// the globs, relative to their root. No globs means every `.org` file.
97public struct AgendaScope: Sendable, Equatable {
98 public var globs: [String]
99
100 public init(globs: [String] = []) {
101 self.globs = globs
102 }
103
104 public func contains(relativePath: String, kind: FileKind) -> Bool {
105 guard kind == .org else { return false }
106 guard !globs.isEmpty else { return true }
107 // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders.
108 return globs.contains { fnmatch($0, relativePath, 0) == 0 }
109 }
110}