krz/orgstar

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

Sources/OrgWorkspace/Discovery.swift

056befa97fffa110959cb6010c1986461c09dc0b
orgstar/Sources/OrgWorkspace/Discovery.swift history · blame · raw

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}