krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgWorkspace/Discovery.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgWorkspace/Discovery.swift history · blame · raw

156 lines · 6867 bytes

14 symbols in this file
  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}