Commit 1535a3164e
Verified · cmc
Layout: unified · split
Package.swift +5 −2
| @@ -7,7 +7,8 @@ let package = Package( | |||
| 7 | products: [ | 7 | products: [ |
| 8 | .library(name: "OrgCore", targets: ["OrgCore"]), | 8 | .library(name: "OrgCore", targets: ["OrgCore"]), |
| 9 | .library(name: "OrgDocument", targets: ["OrgDocument"]), | 9 | .library(name: "OrgDocument", targets: ["OrgDocument"]), |
| 10 | .library(name: "OrgIndex", targets: ["OrgIndex"]) | 10 | .library(name: "OrgIndex", targets: ["OrgIndex"]), |
| 11 | .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]) | ||
| 11 | ], | 12 | ], |
| 12 | dependencies: [ | 13 | dependencies: [ |
| 13 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") | 14 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") |
| @@ -16,8 +17,10 @@ let package = Package( | |||
| 16 | .target(name: "OrgCore"), | 17 | .target(name: "OrgCore"), |
| 17 | .target(name: "OrgDocument", dependencies: ["OrgCore"]), | 18 | .target(name: "OrgDocument", dependencies: ["OrgCore"]), |
| 18 | .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]), | 19 | .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]), |
| 20 | .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]), | ||
| 19 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), | 21 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), |
| 20 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), | 22 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), |
| 21 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]) | 23 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
| 24 | .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]) | ||
| 22 | ] | 25 | ] |
| 23 | ) | 26 | ) |
Sources/OrgWorkspace/Discovery.swift added +110
| @@ -0,0 +1,110 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgIndex | ||
| 3 | |||
| 4 | /// A folder the user added. Identified by its bookmark, so a moved folder is followed. | ||
| 5 | public 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 | |||
| 24 | public 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 | |||
| 31 | public 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 | |||
| 37 | public 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 | public 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. | ||
| 97 | public 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 | } | ||
Tests/OrgWorkspaceTests/DiscoveryTests.swift added +46
| @@ -0,0 +1,46 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgIndex | ||
| 3 | import Testing | ||
| 4 | @testable import OrgWorkspace | ||
| 5 | |||
| 6 | struct ClassificationTests { | ||
| 7 | @Test(arguments: [ | ||
| 8 | ("a.org", FileClassification.file(.org)), | ||
| 9 | ("a.org_archive", .file(.archive)), | ||
| 10 | ("a.sync-conflict-20261004-120000-ABCDEFG.org", .file(.conflict)), | ||
| 11 | (".a.org.icloud", .placeholder("a.org")), | ||
| 12 | (".syncthing.a.org.tmp", .ignored), | ||
| 13 | (".a.org.orgstar-1234", .ignored), | ||
| 14 | ("a.org~", .ignored), | ||
| 15 | ("#a.org#", .ignored), | ||
| 16 | ("a.md", .ignored), | ||
| 17 | (".hidden.org", .ignored), | ||
| 18 | ]) | ||
| 19 | func classify(name: String, expected: FileClassification) { | ||
| 20 | #expect(WorkspaceScanner.classify(name) == expected) | ||
| 21 | } | ||
| 22 | |||
| 23 | @Test func agendaScope() { | ||
| 24 | #expect(AgendaScope().contains(relativePath: "a.org", kind: .org)) | ||
| 25 | #expect(!AgendaScope().contains(relativePath: "a.org_archive", kind: .archive)) | ||
| 26 | #expect(!AgendaScope().contains(relativePath: "a.org", kind: .conflict)) | ||
| 27 | let work = AgendaScope(globs: ["work/*"]) | ||
| 28 | #expect(work.contains(relativePath: "work/deep/a.org", kind: .org)) | ||
| 29 | #expect(!work.contains(relativePath: "home/a.org", kind: .org)) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | struct ScanTests { | ||
| 34 | @Test func skipsHiddenFoldersAndFindsPlaceholders() throws { | ||
| 35 | let folder = try Folder() | ||
| 36 | try folder.write("a.org", "* a\n") | ||
| 37 | try folder.write("sub/b.org_archive", "* b\n") | ||
| 38 | try folder.write(".git/c.org", "* c\n") | ||
| 39 | try folder.write(".stversions/d.org", "* d\n") | ||
| 40 | try folder.write("sub/.e.org.icloud", "") | ||
| 41 | try folder.write("notes.txt", "") | ||
| 42 | let discovery = try WorkspaceScanner.scan(folder.url) | ||
| 43 | #expect(discovery.files.map(\.url.lastPathComponent) == ["a.org", "b.org_archive"]) | ||
| 44 | #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"]) | ||
| 45 | } | ||
| 46 | } | ||
Tests/OrgWorkspaceTests/Folder.swift added +23
| @@ -0,0 +1,23 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A temporary folder that removes itself. | ||
| 4 | final class Folder { | ||
| 5 | let url: URL | ||
| 6 | |||
| 7 | init() throws { | ||
| 8 | url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL | ||
| 9 | try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) | ||
| 10 | } | ||
| 11 | |||
| 12 | deinit { | ||
| 13 | try? FileManager.default.removeItem(at: url) | ||
| 14 | } | ||
| 15 | |||
| 16 | @discardableResult | ||
| 17 | func write(_ path: String, _ text: String) throws -> URL { | ||
| 18 | let file = url.appendingPathComponent(path) | ||
| 19 | try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| 20 | try Data(text.utf8).write(to: file) | ||
| 21 | return file.standardizedFileURL | ||
| 22 | } | ||
| 23 | } | ||