diff --git a/App/AppContainer.swift b/App/AppContainer.swift index 1823db4..e6ffbb4 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -153,6 +153,16 @@ final class AppContainer { MensaViewModel(service: canteenService, settings: preferences) } + /// Rebuilds the Spotlight index from the current data. + func makeReindexSearch() -> ReindexSearchUseCase { + ReindexSearchUseCase( + build: BuildSearchIndexUseCase( + modules: moduleRepository, slides: slideRepository, notes: noteRepository, studySheets: studySheetRepository + ), + index: CoreSpotlightIndexer() + ) + } + func makeAssignmentsViewModel() -> AssignmentsViewModel { AssignmentsViewModel(repository: assignmentRepository) } diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index abe6043..5f1d012 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -1,5 +1,6 @@ import AppKit import AppIntents +import CoreSpotlight import SwiftUI @main @@ -123,6 +124,11 @@ struct StudyAppApp: App { .tint(Theme.accent) .preferredColorScheme(appearance.colorScheme) .task { await startUp() } + .onContinueUserActivity(CSSearchableItemActionType) { activity in + guard let identifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String, + let target = SearchTarget(identifier: identifier) else { return } + navigation.open(target) + } .task { await keepClockRunning() } .task { await keepSourcesFresh() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in @@ -208,6 +214,7 @@ struct StudyAppApp: App { autopilot.run(isLaunch: false) await studIPViewModel.refreshSessionState() await studIPViewModel.syncIfDue() + await reindexSearch() await backupViewModel.runAutomaticBackupIfDue() await notificationsViewModel.refresh() } @@ -218,6 +225,11 @@ struct StudyAppApp: App { NSApplication.shared.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil) } + /// Keeps Spotlight in line with notes and study sheets; failures only cost search results. + private func reindexSearch() async { + try? await container.makeReindexSearch()() + } + /// Opens what a clicked notification refers to. private static func open(_ target: NotificationTarget, navigation: AppNavigation) { NSApplication.shared.activate() @@ -246,6 +258,7 @@ struct StudyAppApp: App { await studIPViewModel.syncIfDue() // Switches to tomorrow's menu after lunch; cached, so usually no request. await mensaViewModel.refresh() + await reindexSearch() // Picks up new or moved lectures and assignments. await notificationsViewModel.refresh() } diff --git a/Data/Spotlight/CoreSpotlightIndexer.swift b/Data/Spotlight/CoreSpotlightIndexer.swift new file mode 100644 index 0000000..5a6495e --- /dev/null +++ b/Data/Spotlight/CoreSpotlightIndexer.swift @@ -0,0 +1,31 @@ +import CoreSpotlight +import Foundation +import UniformTypeIdentifiers + +/// Writes StudyApp's items into a dedicated Core Spotlight index. Replacing everything keeps the +/// index free of deleted notes without tracking changes; the data set is small. +struct CoreSpotlightIndexer: SearchIndexing { + static let indexName = "StudyApp" + static let domainIdentifier = "studyapp" + + private let index = CSSearchableIndex(name: Self.indexName) + + func replaceAll(with items: [SearchableItem]) async throws { + try await index.deleteSearchableItems(withDomainIdentifiers: [Self.domainIdentifier]) + guard !items.isEmpty else { return } + try await index.indexSearchableItems(items.map(Self.searchableItem)) + } + + private static func searchableItem(_ item: SearchableItem) -> CSSearchableItem { + let attributes = CSSearchableItemAttributeSet(contentType: .text) + attributes.title = item.title + attributes.contentDescription = item.subtitle + attributes.textContent = item.content + attributes.contentModificationDate = item.modifiedAt + return CSSearchableItem( + uniqueIdentifier: item.target.identifier, + domainIdentifier: domainIdentifier, + attributeSet: attributes + ) + } +} diff --git a/Domain/Entities/SearchIndex.swift b/Domain/Entities/SearchIndex.swift new file mode 100644 index 0000000..0654f29 --- /dev/null +++ b/Domain/Entities/SearchIndex.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Where a search result leads. Encoded in the Spotlight item identifier, so a click on a result can be +/// turned back into navigation without a lookup table. +enum SearchTarget: Hashable, Sendable { + case module(UUID) + case note(moduleID: UUID, noteID: UUID) + case studySheet(moduleID: UUID, sheetID: UUID) + + var identifier: String { + switch self { + case .module(let id): "module/\(id.uuidString)" + case .note(let moduleID, let noteID): "note/\(moduleID.uuidString)/\(noteID.uuidString)" + case .studySheet(let moduleID, let sheetID): "sheet/\(moduleID.uuidString)/\(sheetID.uuidString)" + } + } + + init?(identifier: String) { + let parts = identifier.split(separator: "/").map(String.init) + let ids = parts.dropFirst().compactMap(UUID.init(uuidString:)) + guard ids.count == parts.count - 1 else { return nil } + switch (parts.first, ids.count) { + case ("module", 1): self = .module(ids[0]) + case ("note", 2): self = .note(moduleID: ids[0], noteID: ids[1]) + case ("sheet", 2): self = .studySheet(moduleID: ids[0], sheetID: ids[1]) + default: return nil + } + } +} + +/// One entry for the system search. +struct SearchableItem: Equatable, Sendable { + let target: SearchTarget + let title: String + /// Text Spotlight searches and previews. + let content: String + /// e.g. „Notiz · Analysis I“. + let subtitle: String + let modifiedAt: Date? +} + +/// Port to the system search index (Core Spotlight). +@MainActor +protocol SearchIndexing { + /// Replaces everything StudyApp has indexed with `items`. + func replaceAll(with items: [SearchableItem]) async throws +} diff --git a/Domain/UseCases/SearchIndexUseCases.swift b/Domain/UseCases/SearchIndexUseCases.swift new file mode 100644 index 0000000..b032f05 --- /dev/null +++ b/Domain/UseCases/SearchIndexUseCases.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Collects what StudyApp offers to Spotlight: active modules, notes with content and study sheets. +@MainActor +struct BuildSearchIndexUseCase { + let modules: any ModuleRepository + let slides: any SlideDocumentRepository + let notes: any NoteRepository + let studySheets: any StudySheetRepository + + func callAsFunction() throws -> [SearchableItem] { + var items: [SearchableItem] = [] + for module in try modules.all() where !module.isArchived { + items.append(SearchableItem( + target: .module(module.id), + title: module.name, + content: module.code, + subtitle: "Modul", + modifiedAt: nil + )) + // Module notes are stored under the module ID, lecture notes under their slide document. + let documents = try slides.documents(forModule: module.id) + let titles = Dictionary(uniqueKeysWithValues: documents.map { ($0.id, $0.title) }) + for owner in [module.id] + documents.map(\.id) { + for note in try notes.notes(forSlideDocument: owner) where note.hasContent { + let source = titles[owner].map { " · \($0)" } ?? "" + items.append(SearchableItem( + target: .note(moduleID: module.id, noteID: note.id), + title: note.title.isEmpty ? "Notiz" : note.title, + content: note.content, + subtitle: "Notiz · \(module.name)\(source)", + modifiedAt: note.updatedAt + )) + } + } + for sheet in try studySheets.sheets(forModule: module.id) { + items.append(SearchableItem( + target: .studySheet(moduleID: module.id, sheetID: sheet.id), + title: sheet.title, + content: sheet.content, + subtitle: "Lernzettel · \(module.name)", + modifiedAt: sheet.updatedAt + )) + } + } + return items + } +} + +/// Rebuilds the Spotlight index. Cheap enough to run on launch and after changes: a few hundred items. +@MainActor +struct ReindexSearchUseCase { + let build: BuildSearchIndexUseCase + let index: any SearchIndexing + + func callAsFunction() async throws { + try await index.replaceAll(with: try build()) + } +} diff --git a/Presentation/Modules/ModuleDetailView.swift b/Presentation/Modules/ModuleDetailView.swift index a62c10b..0fe1a41 100644 --- a/Presentation/Modules/ModuleDetailView.swift +++ b/Presentation/Modules/ModuleDetailView.swift @@ -183,6 +183,10 @@ struct ModuleDetailView: View { learningViewModel.load() learningViewModel.openLectureNote(start: start, eventTitle: title) section = .notes + case .selectStudySheet(let id): + learningViewModel.load() + learningViewModel.selectedStudySheetID = id + section = .studySheets } } diff --git a/Presentation/Navigation/AppDestination.swift b/Presentation/Navigation/AppDestination.swift index ba6a32b..56e90cf 100644 --- a/Presentation/Navigation/AppDestination.swift +++ b/Presentation/Navigation/AppDestination.swift @@ -52,6 +52,8 @@ enum ModuleIntent: Equatable { case selectNote(UUID) /// Notiz zu einem Vorlesungstermin öffnen bzw. anlegen. case lectureNote(start: Date, title: String) + /// Select a study sheet, e.g. after a click on a Spotlight result. + case selectStudySheet(UUID) } /// Die Bereiche innerhalb eines Moduls. diff --git a/Presentation/Navigation/AppNavigation.swift b/Presentation/Navigation/AppNavigation.swift index 8a099e5..13925d8 100644 --- a/Presentation/Navigation/AppNavigation.swift +++ b/Presentation/Navigation/AppNavigation.swift @@ -100,6 +100,15 @@ final class AppNavigation { navigate(to: .module(id, section: section)) } + /// Opens what a Spotlight result points to. + func open(_ target: SearchTarget) { + switch target { + case .module(let id): show(module: id, section: .overview) + case .note(let moduleID, let noteID): show(module: moduleID, section: .notes, intent: .selectNote(noteID)) + case .studySheet(let moduleID, let sheetID): show(module: moduleID, section: .studySheets, intent: .selectStudySheet(sheetID)) + } + } + func go(to location: NavigationLocation) { navigate(to: location) } diff --git a/StudyAppTests/SearchIndexTests.swift b/StudyAppTests/SearchIndexTests.swift new file mode 100644 index 0000000..59a80fe --- /dev/null +++ b/StudyAppTests/SearchIndexTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import StudyApp + +struct SearchTargetTests { + @Test func identifiersRoundTrip() { + let targets: [SearchTarget] = [ + .module(UUID()), + .note(moduleID: UUID(), noteID: UUID()), + .studySheet(moduleID: UUID(), sheetID: UUID()), + ] + for target in targets { + #expect(SearchTarget(identifier: target.identifier) == target) + } + #expect(SearchTarget(identifier: "note/kaputt") == nil) + #expect(SearchTarget(identifier: "other/\(UUID().uuidString)") == nil) + } +} + +@MainActor +final class RecordingSearchIndex: SearchIndexing { + private(set) var items: [SearchableItem] = [] + func replaceAll(with items: [SearchableItem]) async throws { self.items = items } +} + +@MainActor +struct BuildSearchIndexTests { + let modules = FakeModuleRepository() + let slides = FakeSlideDocumentRepository() + let notes = FakeNoteRepository() + let sheets = FakeStudySheetRepository() + let now = Date(timeIntervalSince1970: 1_760_000_000) + + private func note(_ owner: UUID, _ title: String, _ content: String) -> LectureNote { + LectureNote( + id: UUID(), slideDocumentID: owner, title: title, content: content, + format: .markdown, pageNumber: nil, createdAt: now, updatedAt: now + ) + } + + @Test func indexesActiveModulesNotesWithContentAndStudySheets() async throws { + let analysis = StudyModule(id: UUID(), name: "Analysis I", code: "MAT101", color: .blue, createdAt: now) + let archived = StudyModule(id: UUID(), name: "Alt", code: "", color: .blue, createdAt: now, isArchived: true) + try modules.add(analysis) + try modules.add(archived) + let document = SlideDocument(id: UUID(), moduleID: analysis.id, title: "VL01", fileName: "a.pdf", pageCount: 3, importedAt: now) + try slides.add(document) + try notes.add(note(document.id, "Grenzwerte", "ε-δ-Definition")) + try notes.add(note(analysis.id, "Fragen", "Warum konvergiert das?")) + try notes.add(note(analysis.id, "Leer", " ")) + try sheets.add(StudySheet(moduleID: analysis.id, title: "Klausur-Lernzettel", content: "Alles", sourceNoteIDs: [])) + + let index = RecordingSearchIndex() + let build = BuildSearchIndexUseCase(modules: modules, slides: slides, notes: notes, studySheets: sheets) + try await ReindexSearchUseCase(build: build, index: index)() + + #expect(index.items.map(\.title).sorted() == ["Analysis I", "Fragen", "Grenzwerte", "Klausur-Lernzettel"]) + #expect(index.items.first { $0.title == "Grenzwerte" }?.subtitle == "Notiz · Analysis I · VL01") + #expect(index.items.first { $0.title == "Fragen" }?.subtitle == "Notiz · Analysis I") + } +} + +@MainActor +struct SearchNavigationTests { + @Test func resultsOpenTheRightPlace() throws { + let name = "SearchNavigationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let navigation = AppNavigation(defaults: defaults) + let module = UUID() + let note = UUID() + let sheet = UUID() + + navigation.open(SearchTarget.note(moduleID: module, noteID: note)) + #expect(navigation.currentLocation == .module(module, section: .notes)) + #expect(navigation.pendingIntent == .selectNote(note)) + + navigation.open(SearchTarget.studySheet(moduleID: module, sheetID: sheet)) + #expect(navigation.currentLocation == .module(module, section: .studySheets)) + #expect(navigation.pendingIntent == .selectStudySheet(sheet)) + + navigation.open(SearchTarget.module(module)) + #expect(navigation.currentLocation == .module(module, section: .overview)) + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f0d3f07..f1f0ff6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -104,6 +104,12 @@ Settings → Allgemein → Mensa picks a canteen near the University of Oldenbur Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (OpenMensa API v2), `Presentation/Mensa/` +## Spotlight + +Active modules, notes with content and study sheets appear in the macOS search (Spotlight). The index is rebuilt on launch and every five minutes; deleted notes disappear with the next rebuild. Choosing a result opens StudyApp at the module, note or study sheet. Spotlight can be limited in System Settings → Spotlight. + +Code: `Domain/UseCases/SearchIndexUseCases.swift`, `Data/Spotlight/` + ## Updates and settings - Automatic updates through Sparkle („Nach Updates suchen …“ in the app menu). See [RELEASING.md](RELEASING.md).