From f4aabe06fbdba2785d62c92fdf5fca2514ac56a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:19:44 +0000 Subject: [PATCH 1/3] feat(mac): build the Spotlight index in the domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchTarget encodes where a result leads (module, note, study sheet) in the item identifier and decodes it again. BuildSearchIndexUseCase collects active modules, notes with content and study sheets with a subtitle such as „Notiz · Analysis I · VL01“; ReindexSearchUseCase hands them to the SearchIndexing port. Refs #48 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Domain/Entities/SearchIndex.swift | 47 +++++++++++++++++ Domain/UseCases/SearchIndexUseCases.swift | 59 ++++++++++++++++++++++ StudyAppTests/SearchIndexTests.swift | 61 +++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 Domain/Entities/SearchIndex.swift create mode 100644 Domain/UseCases/SearchIndexUseCases.swift create mode 100644 StudyAppTests/SearchIndexTests.swift 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/StudyAppTests/SearchIndexTests.swift b/StudyAppTests/SearchIndexTests.swift new file mode 100644 index 0000000..fce3496 --- /dev/null +++ b/StudyAppTests/SearchIndexTests.swift @@ -0,0 +1,61 @@ +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") + } +} From 7fb8c2eed7a00c21be9374f81dcf2306b82a011c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:21:57 +0000 Subject: [PATCH 2/3] feat(mac): index notes and study sheets in Spotlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreSpotlightIndexer replaces StudyApp's items in its own index on launch and with the five-minute loop. Clicking a result continues the user activity: AppNavigation.open(_:) opens the module, selects the note or – with the new ModuleIntent.selectStudySheet – the study sheet. Refs #48 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/AppContainer.swift | 10 +++++++ App/StudyAppApp.swift | 13 ++++++++ Data/Spotlight/CoreSpotlightIndexer.swift | 31 ++++++++++++++++++++ Presentation/Modules/ModuleDetailView.swift | 4 +++ Presentation/Navigation/AppDestination.swift | 2 ++ Presentation/Navigation/AppNavigation.swift | 9 ++++++ StudyAppTests/SearchIndexTests.swift | 24 +++++++++++++++ 7 files changed, 93 insertions(+) create mode 100644 Data/Spotlight/CoreSpotlightIndexer.swift diff --git a/App/AppContainer.swift b/App/AppContainer.swift index 9d944fc..639f521 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -128,6 +128,16 @@ final class AppContainer { ) } + /// 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 d894132..cacddcf 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -1,4 +1,5 @@ import AppKit +import CoreSpotlight import SwiftUI @main @@ -104,6 +105,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 @@ -156,10 +162,16 @@ struct StudyAppApp: App { await calendarViewModel.refreshStaleSubscriptions() autopilot.run(isLaunch: false) await studIPViewModel.syncIfDue() + await reindexSearch() await backupViewModel.runAutomaticBackupIfDue() await notificationsViewModel.refresh() } + /// 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() @@ -186,6 +198,7 @@ struct StudyAppApp: App { try? await Task.sleep(for: .seconds(300)) await calendarViewModel.refreshStaleSubscriptions() await studIPViewModel.syncIfDue() + 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/Presentation/Modules/ModuleDetailView.swift b/Presentation/Modules/ModuleDetailView.swift index e173823..bbba7e9 100644 --- a/Presentation/Modules/ModuleDetailView.swift +++ b/Presentation/Modules/ModuleDetailView.swift @@ -180,6 +180,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 e083e0b..efeaeb1 100644 --- a/Presentation/Navigation/AppDestination.swift +++ b/Presentation/Navigation/AppDestination.swift @@ -48,6 +48,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 eaa4649..293ac01 100644 --- a/Presentation/Navigation/AppNavigation.swift +++ b/Presentation/Navigation/AppNavigation.swift @@ -96,6 +96,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 index fce3496..59a80fe 100644 --- a/StudyAppTests/SearchIndexTests.swift +++ b/StudyAppTests/SearchIndexTests.swift @@ -59,3 +59,27 @@ struct BuildSearchIndexTests { #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)) + } +} From 1ab19fb9c3cca8e913b3aecf6dbf4ba4eac8f436 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:21:57 +0000 Subject: [PATCH 3/3] docs(mac): describe the Spotlight integration Refs #48 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/FEATURES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 264d3a7..b8e69bc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -69,6 +69,12 @@ Code: `Presentation/Grades/`, `Domain/UseCases/GradeUseCases.swift`, `Domain/Ent Code: `Presentation/StudIP/`, `Domain/UseCases/StudIPUseCases.swift`, `Data/StudIP/` · Details: [STUDIP.md](STUDIP.md) +## 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).