Quick Look preview and Spotlight import extensions !104
8 files changed, +145 −1
Layout: unified · split
Package.swift +9 −1
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | // swift-tools-version: 6.2 |
| 2 | 2 | import PackageDescription |
| 3 | 3 | |
| 4 | /// App extensions start in Foundation's NSExtensionMain. | |
| 5 | let extensionEntry: [LinkerSetting] = [.unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"])] | |
| 6 | ||
| 4 | 7 | let package = Package( |
| 5 | 8 | name: "Orgstar", |
| 6 | 9 | platforms: [.macOS(.v26), .iOS(.v26)], |
| @@ -14,7 +17,9 @@ let package = Package( | ||
| 14 | 17 | .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]), |
| 15 | 18 | .library(name: "OrgApp", targets: ["OrgApp"]), |
| 16 | 19 | .library(name: "OrgHighlight", targets: ["OrgHighlight"]), |
| 17 | .executable(name: "Orgstar", targets: ["Orgstar"]) | |
| 20 | .executable(name: "Orgstar", targets: ["Orgstar"]), | |
| 21 | .executable(name: "OrgstarQuickLook", targets: ["OrgstarQuickLook"]), | |
| 22 | .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"]) | |
| 18 | 23 | ], |
| 19 | 24 | dependencies: [ |
| 20 | 25 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1"), |
| @@ -75,6 +80,9 @@ let package = Package( | ||
| 75 | 80 | .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), |
| 76 | 81 | .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]), |
| 77 | 82 | .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit", "OrgHighlight"]), |
| 83 | // App extensions, bundled into Orgstar.app by scripts/build-app.sh. | |
| 84 | .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry), | |
| 85 | .executableTarget(name: "OrgstarSpotlight", dependencies: ["OrgCore"], linkerSettings: extensionEntry), | |
| 78 | 86 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), |
| 79 | 87 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), |
| 80 | 88 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
Sources/OrgCore/Semantic/FileSummary.swift added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// What Spotlight shows and searches for a file: its keywords, tags and headings. | |
| 4 | public struct FileSummary: Sendable, Equatable { | |
| 5 | public var title: String? | |
| 6 | public var author: String? | |
| 7 | public var description: String? | |
| 8 | /// `#+FILETAGS` and every heading's tags, in order, once each. | |
| 9 | public var tags: [String] | |
| 10 | /// Heading titles, with TODO keywords, priorities and tags left out. | |
| 11 | public var headings: [String] | |
| 12 | ||
| 13 | public init(_ text: String) { | |
| 14 | let tree = OrgParser.parse(text) | |
| 15 | func keyword(_ name: String) -> String? { | |
| 16 | let pattern = "^[ \\t]*#\\+" + name + ":[ \\t]*(.*?)[ \\t]*$" | |
| 17 | guard let m = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines, .caseInsensitive]) | |
| 18 | .firstMatch(in: text, range: NSRange(location: 0, length: (text as NSString).length)) else { return nil } | |
| 19 | let value = (text as NSString).substring(with: m.range(at: 1)) | |
| 20 | return value.isEmpty ? nil : value | |
| 21 | } | |
| 22 | title = keyword("TITLE") | |
| 23 | author = keyword("AUTHOR") | |
| 24 | description = keyword("DESCRIPTION") | |
| 25 | let model = DocumentModel(tree: tree) | |
| 26 | var tags = (keyword("FILETAGS") ?? "").split { $0 == ":" || $0 == " " }.map(String.init) | |
| 27 | for heading in model.headings { tags += heading.localTags } | |
| 28 | var seen = Set<String>() | |
| 29 | self.tags = tags.filter { seen.insert($0).inserted } | |
| 30 | headings = model.headings.map { Links.normalizedTitle($0.title) } | |
| 31 | } | |
| 32 | } | |
Sources/OrgstarQuickLook/PreviewProvider.swift added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import QuickLookUI | |
| 4 | ||
| 5 | /// Quick Look's preview of an org file: the file as the HTML export renders it. | |
| 6 | @objc(PreviewProvider) | |
| 7 | final class PreviewProvider: QLPreviewProvider, QLPreviewingController { | |
| 8 | func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply { | |
| 9 | let text = try String(contentsOf: request.fileURL, encoding: .utf8) | |
| 10 | let title = request.fileURL.deletingPathExtension().lastPathComponent | |
| 11 | let html = HTMLExport.page(text, fallbackTitle: title, path: request.fileURL.path) | |
| 12 | return QLPreviewReply(dataOfContentType: .html, contentSize: CGSize(width: 800, height: 800)) { _ in Data(html.utf8) } | |
| 13 | } | |
| 14 | } | |
Sources/OrgstarQuickLook/main.swift added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | // The extension starts in NSExtensionMain (linker entry point); this file only makes the | |
| 2 | // target an executable. | |
Sources/OrgstarSpotlight/ImportExtension.swift added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | import CoreSpotlight | |
| 2 | import Foundation | |
| 3 | import OrgCore | |
| 4 | ||
| 5 | /// Spotlight's importer for org files: the text, plus the title, author, tags and headings. | |
| 6 | @objc(ImportExtension) | |
| 7 | final class ImportExtension: CSImportExtension { | |
| 8 | override func update(_ attributes: CSSearchableItemAttributeSet, forFileAt contentURL: URL) throws { | |
| 9 | let text = try String(contentsOf: contentURL, encoding: .utf8) | |
| 10 | let summary = FileSummary(text) | |
| 11 | attributes.textContent = text | |
| 12 | attributes.title = summary.title ?? contentURL.deletingPathExtension().lastPathComponent | |
| 13 | if let author = summary.author { attributes.authorNames = [author] } | |
| 14 | if let description = summary.description { attributes.contentDescription = description } | |
| 15 | attributes.keywords = summary.tags + summary.headings | |
| 16 | } | |
| 17 | } | |
Sources/OrgstarSpotlight/main.swift added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | // The extension starts in NSExtensionMain (linker entry point); this file only makes the | |
| 2 | // target an executable. | |
Tests/OrgCoreTests/FileSummaryTests.swift added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | import Testing | |
| 2 | @testable import OrgCore | |
| 3 | ||
| 4 | struct FileSummaryTests { | |
| 5 | @Test func readsKeywordsTagsAndHeadings() { | |
| 6 | let summary = FileSummary("#+title: Notes\n#+AUTHOR: Me\n#+FILETAGS: :home:\n* TODO [#A] Fix the sink [1/2] :home:diy:\n** Buy parts\n") | |
| 7 | #expect(summary.title == "Notes" && summary.author == "Me" && summary.description == nil) | |
| 8 | #expect(summary.tags == ["home", "diy"]) | |
| 9 | #expect(summary.headings == ["Fix the sink", "Buy parts"]) | |
| 10 | #expect(FileSummary("plain\n").title == nil) | |
| 11 | } | |
| 12 | } | |
scripts/build-app.sh +57
| @@ -9,6 +9,8 @@ version="${1:-0.1.0}" | ||
| 9 | 9 | app=".build/app/Orgstar.app" |
| 10 | 10 | |
| 11 | 11 | swift build -c release --product Orgstar |
| 12 | swift build -c release --product OrgstarQuickLook | |
| 13 | swift build -c release --product OrgstarSpotlight | |
| 12 | 14 | binary="$(swift build -c release --show-bin-path)/Orgstar" |
| 13 | 15 | |
| 14 | 16 | rm -rf "$app" |
| @@ -113,5 +115,60 @@ echo "$PWD/$objects/Orgstar-primary.swiftconstvalues" > "$work/constvalues.txt" | ||
| 113 | 115 | --swift-const-vals-list "$work/constvalues.txt" --output "$app/Contents/Resources" --force > /dev/null 2>&1 |
| 114 | 116 | rm -rf "$work" |
| 115 | 117 | |
| 118 | # App extensions: Quick Look previews and the Spotlight importer. They run sandboxed. | |
| 119 | appex() { # name, extension point, principal class, attributes | |
| 120 | dir="$app/Contents/PlugIns/$1.appex/Contents" | |
| 121 | mkdir -p "$dir/MacOS" | |
| 122 | cp "$(dirname "$binary")/$1" "$dir/MacOS/$1" | |
| 123 | cat > "$dir/Info.plist" <<PLIST | |
| 124 | <?xml version="1.0" encoding="UTF-8"?> | |
| 125 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 126 | <plist version="1.0"> | |
| 127 | <dict> | |
| 128 | <key>CFBundleIdentifier</key> | |
| 129 | <string>sh.krz.orgstar.$(echo "$1" | sed 's/^Orgstar//' | tr 'A-Z' 'a-z')</string> | |
| 130 | <key>CFBundleName</key> | |
| 131 | <string>$1</string> | |
| 132 | <key>CFBundleExecutable</key> | |
| 133 | <string>$1</string> | |
| 134 | <key>CFBundlePackageType</key> | |
| 135 | <string>XPC!</string> | |
| 136 | <key>CFBundleShortVersionString</key> | |
| 137 | <string>${version}</string> | |
| 138 | <key>CFBundleVersion</key> | |
| 139 | <string>${version}</string> | |
| 140 | <key>LSMinimumSystemVersion</key> | |
| 141 | <string>26.0</string> | |
| 142 | <key>NSExtension</key> | |
| 143 | <dict> | |
| 144 | <key>NSExtensionPointIdentifier</key> | |
| 145 | <string>$2</string> | |
| 146 | <key>NSExtensionPrincipalClass</key> | |
| 147 | <string>$3</string> | |
| 148 | <key>NSExtensionAttributes</key> | |
| 149 | <dict>$4</dict> | |
| 150 | </dict> | |
| 151 | </dict> | |
| 152 | </plist> | |
| 153 | PLIST | |
| 154 | cat > "$work/sandbox.entitlements" <<PLIST | |
| 155 | <?xml version="1.0" encoding="UTF-8"?> | |
| 156 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 157 | <plist version="1.0"> | |
| 158 | <dict> | |
| 159 | <key>com.apple.security.app-sandbox</key> | |
| 160 | <true/> | |
| 161 | </dict> | |
| 162 | </plist> | |
| 163 | PLIST | |
| 164 | codesign --force --sign - --entitlements "$work/sandbox.entitlements" "$app/Contents/PlugIns/$1.appex" | |
| 165 | } | |
| 166 | work="$(mktemp -d)" | |
| 167 | appex OrgstarQuickLook com.apple.quicklook.preview PreviewProvider \ | |
| 168 | "<key>QLSupportedContentTypes</key><array><string>org.orgmode.org</string></array><key>QLIsDataBasedPreview</key><true/>" | |
| 169 | appex OrgstarSpotlight com.apple.spotlight.import ImportExtension \ | |
| 170 | "<key>CSSupportedContentTypes</key><array><string>org.orgmode.org</string></array>" | |
| 171 | rm -rf "$work" | |
| 172 | ||
| 116 | 173 | codesign --force --sign - "$app" |
| 117 | 174 | echo "$app" |