Sources/OrgWorkspace/Discovery.swift
156 lines · 6867 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
44/// Which files and folders a scan takes in.
45public struct ScanRules: Sendable, Equatable {
46 /// Dotfiles and dot folders, other than ignored folders.
47 public var hiddenFiles: Bool
48 /// Folder names never looked into: version control, caches, build output.
49 public var ignoredFolders: Set<String>
50
51 public static let defaultIgnoredFolders: Set<String> = [
52 ".git", ".hg", ".svn", ".jj", ".bzr", ".stfolder", ".stversions", ".Trash", ".Spotlight-V100", ".fseventsd",
53 ".build", ".venv", ".cache", ".tox", ".mypy_cache", ".pytest_cache", ".gradle", ".next", ".terraform", "node_modules",
54 ]
55
56 public init(hiddenFiles: Bool = true, ignoredFolders: Set<String> = defaultIgnoredFolders) {
57 self.hiddenFiles = hiddenFiles
58 self.ignoredFolders = ignoredFolders
59 }
60
61 public func skipsFolder(_ name: String) -> Bool {
62 ignoredFolders.contains(name) || (!hiddenFiles && name.hasPrefix("."))
63 }
64}
65
66public enum WorkspaceScanner {
67 /// What a file name means to the workspace. Syncthing temp files, our own temp and backup
68 /// files, Emacs backups, auto-saves and lock files, and Finder's files are ignored, and
69 /// other dotfiles unless `rules` takes them in; files that aren't org files are `.other`.
70 public static func classify(_ name: String, rules: ScanRules = ScanRules()) -> FileClassification {
71 if name.hasPrefix(".") {
72 if name.hasSuffix(".icloud") {
73 let inner = String(name.dropFirst().dropLast(".icloud".count))
74 if case .file = classify(inner, rules: rules) { return .placeholder(inner) }
75 return .ignored
76 }
77 if !rules.hiddenFiles || name.hasPrefix(".#") || name.hasPrefix(".syncthing.") || name.contains(".orgstar-")
78 || name == ".DS_Store" || name == ".localized" {
79 return .ignored
80 }
81 }
82 if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored }
83 let conflict = name.contains(".sync-conflict-")
84 if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) }
85 if name.hasSuffix(".org_archive") { return .file(conflict ? .conflict : .archive) }
86 return .file(.other)
87 }
88
89 /// Resource keys a scan reads.
90 static let keys: [URLResourceKey] = [
91 .isDirectoryKey, .fileSizeKey, .contentModificationDateKey, .isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey,
92 ]
93
94 /// An iCloud file whose contents aren't on this device: reading it would wait for the
95 /// download, so it's asked to download and indexed when it arrives. Since macOS 14 these
96 /// keep their own names rather than `.name.icloud`.
97 public static func isPending(isUbiquitous: Bool?, status: URLUbiquitousItemDownloadingStatus?) -> Bool {
98 isUbiquitous == true && status == .notDownloaded
99 }
100
101 static func isPending(_ values: URLResourceValues) -> Bool {
102 isPending(isUbiquitous: values.isUbiquitousItem, status: values.ubiquitousItemDownloadingStatus)
103 }
104
105 /// Every file under `root`, skipping the folders `rules` ignores.
106 public static func scan(_ root: URL, rules: ScanRules = ScanRules()) throws -> Discovery {
107 let keys = Self.keys
108 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
109 return Discovery()
110 }
111 var discovery = Discovery()
112 for case let url as URL in enumerator {
113 let values = try url.resourceValues(forKeys: Set(keys))
114 let name = url.lastPathComponent
115 if values.isDirectory == true {
116 if rules.skipsFolder(name) { enumerator.skipDescendants() }
117 continue
118 }
119 switch classify(name, rules: rules) {
120 case .file where isPending(values):
121 discovery.placeholders.append(url.standardizedFileURL)
122 case .file(let kind):
123 discovery.files.append(DiscoveredFile(
124 url: url.standardizedFileURL,
125 kind: kind,
126 size: values.fileSize ?? 0,
127 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
128 ))
129 case .placeholder(let inner):
130 discovery.placeholders.append(url.deletingLastPathComponent().appendingPathComponent(inner).standardizedFileURL)
131 case .ignored:
132 break
133 }
134 }
135 discovery.files.sort { $0.url.path < $1.url.path }
136 return discovery
137 }
138}
139
140/// Which files feed the agenda: `.org` files (not archives or conflict copies) matching one of
141/// the globs, relative to their root. No globs means every `.org` file.
142public struct AgendaScope: Sendable, Equatable {
143 public var globs: [String]
144
145 public init(globs: [String] = []) {
146 self.globs = globs
147 }
148
149 public func contains(relativePath: String, kind: FileKind) -> Bool {
150 // `org-agenda-file-regexp` leaves out dotfiles.
151 guard kind == .org, !(relativePath as NSString).lastPathComponent.hasPrefix(".") else { return false }
152 guard !globs.isEmpty else { return true }
153 // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders.
154 return globs.contains { fnmatch($0, relativePath, 0) == 0 }
155 }
156}