Quick Look preview and Spotlight import extensions !104

merged merged by cmc on 2026-10-06 07:03 UTC · krz/orgstar:finder-extensions into main

8 files changed, +145 −1

Layout: unified · split

Package.swift +9 −1
@@ -1,6 +1,9 @@
1// swift-tools-version: 6.2 1// swift-tools-version: 6.2
2import PackageDescription 2import PackageDescription
3 3
4/// App extensions start in Foundation's NSExtensionMain.
5let extensionEntry: [LinkerSetting] = [.unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"])]
6
4let package = Package( 7let package = Package(
5 name: "Orgstar", 8 name: "Orgstar",
6 platforms: [.macOS(.v26), .iOS(.v26)], 9 platforms: [.macOS(.v26), .iOS(.v26)],
@@ -14,7 +17,9 @@ let package = Package(
14 .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]), 17 .executable(name: "OrgstarSpike", targets: ["OrgstarSpike"]),
15 .library(name: "OrgApp", targets: ["OrgApp"]), 18 .library(name: "OrgApp", targets: ["OrgApp"]),
16 .library(name: "OrgHighlight", targets: ["OrgHighlight"]), 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 dependencies: [ 24 dependencies: [
20 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1"), 25 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1"),
@@ -75,6 +80,9 @@ let package = Package(
75 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), 80 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]),
76 .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]), 81 .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]),
77 .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit", "OrgHighlight"]), 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 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), 86 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
79 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), 87 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
80 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), 88 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
Sources/OrgCore/Semantic/FileSummary.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2
3/// What Spotlight shows and searches for a file: its keywords, tags and headings.
4public 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 @@
1import Foundation
2import OrgCore
3import QuickLookUI
4
5/// Quick Look's preview of an org file: the file as the HTML export renders it.
6@objc(PreviewProvider)
7final 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 @@
1import CoreSpotlight
2import Foundation
3import OrgCore
4
5/// Spotlight's importer for org files: the text, plus the title, author, tags and headings.
6@objc(ImportExtension)
7final 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 @@
1import Testing
2@testable import OrgCore
3
4struct 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}"
9app=".build/app/Orgstar.app" 9app=".build/app/Orgstar.app"
10 10
11swift build -c release --product Orgstar 11swift build -c release --product Orgstar
12swift build -c release --product OrgstarQuickLook
13swift build -c release --product OrgstarSpotlight
12binary="$(swift build -c release --show-bin-path)/Orgstar" 14binary="$(swift build -c release --show-bin-path)/Orgstar"
13 15
14rm -rf "$app" 16rm -rf "$app"
@@ -113,5 +115,60 @@ echo "$PWD/$objects/Orgstar-primary.swiftconstvalues" > "$work/constvalues.txt"
113 --swift-const-vals-list "$work/constvalues.txt" --output "$app/Contents/Resources" --force > /dev/null 2>&1 115 --swift-const-vals-list "$work/constvalues.txt" --output "$app/Contents/Resources" --force > /dev/null 2>&1
114rm -rf "$work" 116rm -rf "$work"
115 117
118# App extensions: Quick Look previews and the Spotlight importer. They run sandboxed.
119appex() { # 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>
153PLIST
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>
163PLIST
164 codesign --force --sign - --entitlements "$work/sandbox.entitlements" "$app/Contents/PlugIns/$1.appex"
165}
166work="$(mktemp -d)"
167appex OrgstarQuickLook com.apple.quicklook.preview PreviewProvider \
168 "<key>QLSupportedContentTypes</key><array><string>org.orgmode.org</string></array><key>QLIsDataBasedPreview</key><true/>"
169appex OrgstarSpotlight com.apple.spotlight.import ImportExtension \
170 "<key>CSSupportedContentTypes</key><array><string>org.orgmode.org</string></array>"
171rm -rf "$work"
172
116codesign --force --sign - "$app" 173codesign --force --sign - "$app"
117echo "$app" 174echo "$app"