iOS: Quick Look previews and Spotlight !119
5 files changed, +169 −1
Layout: unified · split
Sources/OrgstarMobile/MobileModel.swift +1
| @@ -17,6 +17,7 @@ final class MobileModel { | |||
| 17 | let agenda: AgendaModel? | 17 | let agenda: AgendaModel? |
| 18 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) | 18 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) |
| 19 | let clock = ClockModel() | 19 | let clock = ClockModel() |
| 20 | let spotlight = SpotlightIndexer() | ||
| 20 | private(set) var configFolder: URL? | 21 | private(set) var configFolder: URL? |
| 21 | private(set) var configProblems: [String] = [] | 22 | private(set) var configProblems: [String] = [] |
| 22 | /// An entry to show, from a tapped reminder. | 23 | /// An entry to show, from a tapped reminder. |
Sources/OrgstarMobile/OrgstarMobileApp.swift +14
| @@ -1,4 +1,5 @@ | |||
| 1 | #if os(iOS) | 1 | #if os(iOS) |
| 2 | import CoreSpotlight | ||
| 2 | import OrgApp | 3 | import OrgApp |
| 3 | import OrgCore | 4 | import OrgCore |
| 4 | import SwiftUI | 5 | import SwiftUI |
| @@ -79,6 +80,12 @@ struct RootView: View { | |||
| 79 | 80 | ||
| 80 | private var session: DocumentSession { model.session } | 81 | private var session: DocumentSession { model.session } |
| 81 | 82 | ||
| 83 | private struct SpotlightKey: Equatable { | ||
| 84 | var files: Int | ||
| 85 | var changed: Int? | ||
| 86 | var saved: Int | ||
| 87 | } | ||
| 88 | |||
| 82 | private struct ReminderKey: Equatable { | 89 | private struct ReminderKey: Equatable { |
| 83 | var files: Int | 90 | var files: Int |
| 84 | var changed: Int? | 91 | var changed: Int? |
| @@ -159,6 +166,13 @@ struct RootView: View { | |||
| 159 | guard !Task.isCancelled else { return } | 166 | guard !Task.isCancelled else { return } |
| 160 | await model.updateReminders() | 167 | await model.updateReminders() |
| 161 | } | 168 | } |
| 169 | // Spotlight follows the files and saved edits. | ||
| 170 | .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) { | ||
| 171 | await model.spotlight.update(workspace.files) | ||
| 172 | } | ||
| 173 | .onContinueUserActivity(CSSearchableItemActionType) { activity in | ||
| 174 | if let path = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String { model.openRequest = .file(path) } | ||
| 175 | } | ||
| 162 | .onChange(of: scenePhase) { _, phase in | 176 | .onChange(of: scenePhase) { _, phase in |
| 163 | // Unsaved edits are written before the app may be stopped. | 177 | // Unsaved edits are written before the app may be stopped. |
| 164 | if phase == .background { | 178 | if phase == .background { |
Sources/OrgstarMobile/SpotlightIndexer.swift added +83
| @@ -0,0 +1,83 @@ | |||
| 1 | #if os(iOS) | ||
| 2 | import CoreSpotlight | ||
| 3 | import Foundation | ||
| 4 | import OrgApp | ||
| 5 | import OrgCore | ||
| 6 | import UniformTypeIdentifiers | ||
| 7 | |||
| 8 | /// The workspace's org files in Spotlight with what the Mac's importer gives it: the text, | ||
| 9 | /// title, author, description, tags and headings. A file is indexed again when it changes. | ||
| 10 | @MainActor | ||
| 11 | final class SpotlightIndexer { | ||
| 12 | private struct State: Codable { | ||
| 13 | /// Also kept as the index's client state; the system deleting the index (as it does | ||
| 14 | /// when the app is installed again) takes it away, and every file is indexed again. | ||
| 15 | var generation = UUID() | ||
| 16 | /// Modification dates of the files as indexed. | ||
| 17 | var stamps: [String: Date] = [:] | ||
| 18 | } | ||
| 19 | |||
| 20 | private let index = CSSearchableIndex(name: "files") | ||
| 21 | private let stateFile = WorkspaceModel.defaultDirectory.appendingPathComponent("spotlight.json") | ||
| 22 | private var state: State | ||
| 23 | /// Files to index once the running update finishes; a batch can't start while one is open. | ||
| 24 | private var waiting: [FileEntry]? | ||
| 25 | private var running = false | ||
| 26 | |||
| 27 | init() { | ||
| 28 | state = (try? JSONDecoder().decode(State.self, from: Data(contentsOf: stateFile))) ?? State() | ||
| 29 | } | ||
| 30 | |||
| 31 | func update(_ files: [FileEntry]) async { | ||
| 32 | guard !running else { return waiting = files } | ||
| 33 | running = true | ||
| 34 | defer { running = false } | ||
| 35 | var next: [FileEntry]? = files | ||
| 36 | while let files = next { | ||
| 37 | await index(files) | ||
| 38 | next = waiting | ||
| 39 | waiting = nil | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | private func index(_ files: [FileEntry]) async { | ||
| 44 | let generation = Data(state.generation.uuidString.utf8) | ||
| 45 | let stamps = (try? await index.fetchLastClientState()) == generation ? state.stamps : [:] | ||
| 46 | var current: [String: Date] = [:] | ||
| 47 | var items: [CSSearchableItem] = [] | ||
| 48 | for file in files where file.kind == .org { | ||
| 49 | guard let date = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.modificationDate] as? Date else { continue } | ||
| 50 | if stamps[file.path] == date { | ||
| 51 | current[file.path] = date | ||
| 52 | } else if let text = try? String(contentsOfFile: file.path, encoding: .utf8) { | ||
| 53 | items.append(Self.item(file, text: text)) | ||
| 54 | current[file.path] = date | ||
| 55 | } | ||
| 56 | } | ||
| 57 | let gone = stamps.keys.filter { current[$0] == nil } | ||
| 58 | guard !items.isEmpty || !gone.isEmpty else { return } | ||
| 59 | index.beginBatch() | ||
| 60 | if !items.isEmpty { index.indexSearchableItems(items, completionHandler: nil) } | ||
| 61 | if !gone.isEmpty { index.deleteSearchableItems(withIdentifiers: Array(gone), completionHandler: nil) } | ||
| 62 | do { | ||
| 63 | try await index.endBatch(withClientState: generation) | ||
| 64 | } catch { | ||
| 65 | return | ||
| 66 | } | ||
| 67 | state.stamps = current | ||
| 68 | try? JSONEncoder().encode(state).write(to: stateFile, options: .atomic) | ||
| 69 | } | ||
| 70 | |||
| 71 | private static func item(_ file: FileEntry, text: String) -> CSSearchableItem { | ||
| 72 | let summary = FileSummary(text) | ||
| 73 | let attributes = CSSearchableItemAttributeSet(contentType: .text) | ||
| 74 | attributes.title = summary.title ?? (file.name as NSString).deletingPathExtension | ||
| 75 | attributes.displayName = attributes.title | ||
| 76 | attributes.contentDescription = summary.description ?? file.relativePath | ||
| 77 | attributes.textContent = text | ||
| 78 | if let author = summary.author { attributes.authorNames = [author] } | ||
| 79 | attributes.keywords = summary.tags + summary.headings | ||
| 80 | return CSSearchableItem(uniqueIdentifier: file.path, domainIdentifier: "files", attributeSet: attributes) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | #endif | ||
Sources/OrgstarQuickLook/PreviewProvider.swift +4
| @@ -1,6 +1,10 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | #if canImport(QuickLookUI) | ||
| 3 | import QuickLookUI | 4 | import QuickLookUI |
| 5 | #else | ||
| 6 | import QuickLook | ||
| 7 | #endif | ||
| 4 | 8 | ||
| 5 | /// Quick Look's preview of an org file: the file as the HTML export renders it. | 9 | /// Quick Look's preview of an org file: the file as the HTML export renders it. |
| 6 | @objc(PreviewProvider) | 10 | @objc(PreviewProvider) |
scripts/build-ios-app.sh +67 −1
| @@ -9,7 +9,9 @@ version="${1:-0.1.0}" | |||
| 9 | app=".build/ios-app/Orgstar.app" | 9 | app=".build/ios-app/Orgstar.app" |
| 10 | products=".build/ios/Build/Products/Debug-iphonesimulator" | 10 | products=".build/ios/Build/Products/Debug-iphonesimulator" |
| 11 | 11 | ||
| 12 | xcodebuild -scheme OrgstarMobile -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet | 12 | for scheme in OrgstarMobile OrgstarQuickLook; do |
| 13 | xcodebuild -scheme "$scheme" -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet | ||
| 14 | done | ||
| 13 | 15 | ||
| 14 | rm -rf "$app" | 16 | rm -rf "$app" |
| 15 | mkdir -p "$app" | 17 | mkdir -p "$app" |
| @@ -63,6 +65,24 @@ cat > "$app/Info.plist" <<PLIST | |||
| 63 | <string>UIInterfaceOrientationLandscapeLeft</string> | 65 | <string>UIInterfaceOrientationLandscapeLeft</string> |
| 64 | <string>UIInterfaceOrientationLandscapeRight</string> | 66 | <string>UIInterfaceOrientationLandscapeRight</string> |
| 65 | </array> | 67 | </array> |
| 68 | <!-- public.text, not public.plain-text as on the Mac: iOS previews plain text itself and | ||
| 69 | never asks the Quick Look extension. --> | ||
| 70 | <key>UTImportedTypeDeclarations</key> | ||
| 71 | <array> | ||
| 72 | <dict> | ||
| 73 | <key>UTTypeIdentifier</key> | ||
| 74 | <string>org.orgmode.org</string> | ||
| 75 | <key>UTTypeDescription</key> | ||
| 76 | <string>Org document</string> | ||
| 77 | <key>UTTypeConformsTo</key> | ||
| 78 | <array><string>public.text</string></array> | ||
| 79 | <key>UTTypeTagSpecification</key> | ||
| 80 | <dict> | ||
| 81 | <key>public.filename-extension</key> | ||
| 82 | <array><string>org</string><string>org_archive</string></array> | ||
| 83 | </dict> | ||
| 84 | </dict> | ||
| 85 | </array> | ||
| 66 | <key>CFBundleURLTypes</key> | 86 | <key>CFBundleURLTypes</key> |
| 67 | <array> | 87 | <array> |
| 68 | <dict> | 88 | <dict> |
| @@ -76,5 +96,51 @@ cat > "$app/Info.plist" <<PLIST | |||
| 76 | </plist> | 96 | </plist> |
| 77 | PLIST | 97 | PLIST |
| 78 | 98 | ||
| 99 | # Quick Look previews of org files, in Files and wherever else Quick Look shows them. | ||
| 100 | appex="$app/PlugIns/OrgstarQuickLook.appex" | ||
| 101 | mkdir -p "$appex" | ||
| 102 | cp "$products/OrgstarQuickLook" "$appex/OrgstarQuickLook" | ||
| 103 | cat > "$appex/Info.plist" <<PLIST | ||
| 104 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 105 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 106 | <plist version="1.0"> | ||
| 107 | <dict> | ||
| 108 | <key>CFBundleIdentifier</key> | ||
| 109 | <string>sh.krz.orgstar.quicklook</string> | ||
| 110 | <key>CFBundleName</key> | ||
| 111 | <string>OrgstarQuickLook</string> | ||
| 112 | <key>CFBundleDisplayName</key> | ||
| 113 | <string>Orgstar</string> | ||
| 114 | <key>CFBundleExecutable</key> | ||
| 115 | <string>OrgstarQuickLook</string> | ||
| 116 | <key>CFBundlePackageType</key> | ||
| 117 | <string>XPC!</string> | ||
| 118 | <key>CFBundleShortVersionString</key> | ||
| 119 | <string>${version}</string> | ||
| 120 | <key>CFBundleVersion</key> | ||
| 121 | <string>${version}</string> | ||
| 122 | <key>CFBundleSupportedPlatforms</key> | ||
| 123 | <array><string>iPhoneSimulator</string></array> | ||
| 124 | <key>MinimumOSVersion</key> | ||
| 125 | <string>26.0</string> | ||
| 126 | <key>NSExtension</key> | ||
| 127 | <dict> | ||
| 128 | <key>NSExtensionPointIdentifier</key> | ||
| 129 | <string>com.apple.quicklook.preview</string> | ||
| 130 | <key>NSExtensionPrincipalClass</key> | ||
| 131 | <string>PreviewProvider</string> | ||
| 132 | <key>NSExtensionAttributes</key> | ||
| 133 | <dict> | ||
| 134 | <key>QLSupportedContentTypes</key> | ||
| 135 | <array><string>org.orgmode.org</string></array> | ||
| 136 | <key>QLIsDataBasedPreview</key> | ||
| 137 | <true/> | ||
| 138 | </dict> | ||
| 139 | </dict> | ||
| 140 | </dict> | ||
| 141 | </plist> | ||
| 142 | PLIST | ||
| 143 | codesign --force --sign - "$appex" | ||
| 144 | |||
| 79 | codesign --force --sign - "$app" | 145 | codesign --force --sign - "$app" |
| 80 | echo "$app" | 146 | echo "$app" |