Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
13 changes: 13 additions & 0 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import AppIntents
import CoreSpotlight
import SwiftUI

@main
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down
31 changes: 31 additions & 0 deletions Data/Spotlight/CoreSpotlightIndexer.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
47 changes: 47 additions & 0 deletions Domain/Entities/SearchIndex.swift
Original file line number Diff line number Diff line change
@@ -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
}
59 changes: 59 additions & 0 deletions Domain/UseCases/SearchIndexUseCases.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
4 changes: 4 additions & 0 deletions Presentation/Modules/ModuleDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
2 changes: 2 additions & 0 deletions Presentation/Navigation/AppDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions Presentation/Navigation/AppNavigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
85 changes: 85 additions & 0 deletions StudyAppTests/SearchIndexTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
6 changes: 6 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading