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
51 changes: 46 additions & 5 deletions Sources/App/ViewModels/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,43 @@ final class AppState: ObservableObject {
courseTags = (try? db.fetchAllCourseTags(siteID: site.id)) ?? [:]
}

/// Import Finder tags that users may have applied directly on course
/// folders in `~/Library/CloudStorage`. Tags already tracked in the
/// database are left untouched; only newly-discovered tags are added.
func importFinderTagsFromDisk() async {
guard let site = currentSite, let db = database else { return }
guard let rootURL = await fileProviderRootURL(for: site) else { return }

for course in courses where course.isSyncEnabled {
let folderURL = rootURL.appendingPathComponent(course.effectiveFolderName, isDirectory: true)
guard let diskTags = Self.readFinderTags(at: folderURL), !diskTags.isEmpty else { continue }

let existingTags = (try? db.fetchCourseTags(courseID: course.id, siteID: course.siteID)) ?? []
let existingNames = Set(existingTags.map(\.name))
let newTags = diskTags.filter { !existingNames.contains($0.name) }
guard !newTags.isEmpty else { continue }

let merged = existingTags + newTags
updateCourseTags(for: course, tags: merged)
}
}

/// Read Finder tags from a file/directory URL by parsing the
/// `com.apple.metadata:_kMDItemUserTags` resource value.
private static func readFinderTags(at url: URL) -> [FinderTag]? {
guard let values = try? url.resourceValues(forKeys: [.tagNamesKey]),
let names = values.tagNames, !names.isEmpty else { return nil }

return names.compactMap { raw -> FinderTag? in
// macOS stores tags as "Name\nColorIndex" or just "Name"
let parts = raw.split(separator: "\n", maxSplits: 1)
let name = String(parts[0])
let colorIndex = parts.count > 1 ? Int(parts[1]) ?? 0 : 0
let color = FinderTag.Color(rawValue: colorIndex) ?? .none
return FinderTag(name: name, color: color)
}
}

// MARK: - Course Customization

func updateCustomFolderName(for course: MoodleCourse, name: String?) {
Expand Down Expand Up @@ -834,11 +871,13 @@ final class AppState: ObservableObject {
targetURL = rootURL
}

// Use selectFile/activateFileViewerSelecting instead of open() — the
// sandbox blocks NSWorkspace.open() on File Provider URLs, but revealing
// in Finder works because it asks Finder to navigate rather than the app
// to open the path.
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: targetURL.path)
// NSWorkspace.shared.open() can be blocked by the sandbox for File
// Provider CloudStorage URLs after binary changes (e.g. Sparkle updates).
// Shell out to /usr/bin/open which is not subject to the app sandbox.
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = [targetURL.path]
try? process.run()
}

func resetProvider() async {
Expand Down Expand Up @@ -967,6 +1006,8 @@ final class AppState: ObservableObject {
try Task.checkCancellation()
await self.loadCourses()
try Task.checkCancellation()
await self.importFinderTagsFromDisk()
try Task.checkCancellation()
if triggerLaunchSync && self.userDefaults.bool(forKey: Self.syncOnLaunchKey) {
await self.syncAll()
}
Expand Down
1 change: 1 addition & 0 deletions Sources/App/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct ContentView: View {
)
)
.whatsNewSheet()
.supportPrompt()
}
}
}
Expand Down
32 changes: 21 additions & 11 deletions Sources/App/Views/Courses/CoursesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,31 @@ struct CourseRow: View {
let course: MoodleCourse
let tags: [FinderTag]

var body: some View {
HStack(spacing: 8) {
Image(systemName: course.customIconName ?? "folder.fill")
.foregroundStyle(course.isSyncEnabled ? .secondary : .quaternary)
.imageScale(.large)
private var iconName: String {
course.customIconName ?? "folder.fill"
}

private var primaryText: String {
if let custom = course.customFolderName,
!custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return custom
}
return course.fullName
}

var body: some View {
Label {
VStack(alignment: .leading, spacing: 2) {
if let custom = course.customFolderName,
!custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text(custom)
.lineLimit(2)
Text(primaryText)
.lineLimit(2)

if course.customFolderName != nil &&
!(course.customFolderName ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text(course.fullName)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text(course.fullName)
.lineLimit(2)
Text(course.shortName)
.font(.subheadline)
.foregroundStyle(.secondary)
Expand All @@ -43,6 +50,9 @@ struct CourseRow: View {
}
}
}
} icon: {
Image(systemName: iconName)
.foregroundStyle(course.isSyncEnabled ? .secondary : .quaternary)
}
.opacity(course.isSyncEnabled ? 1.0 : 0.5)
.padding(.vertical, 1)
Expand Down
47 changes: 47 additions & 0 deletions Sources/App/Views/Workspace/SupportPrompt.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2026 Alejandro Modroño Vara <amodrono@alu.icai.comillas.edu>
//
// Licensed under the Apache License, Version 2.0.
// You may obtain a copy of the License in the LICENSE file at the root of this repository.

import SwiftUI

/// Occasional prompt asking users to support the project.
struct SupportPrompt: ViewModifier {
private static let launchCountKey = "supportPromptLaunchCount"
private static let minLaunches = 5
private static let showProbability = 0.3

@AppStorage(SupportPrompt.launchCountKey) private var launchCount = 0
@State private var showAlert = false

func body(content: Content) -> some View {
content
.onAppear {
launchCount += 1
guard launchCount >= Self.minLaunches,
Double.random(in: 0...1) < Self.showProbability else { return }
showAlert = true
}
.alert("Enjoying Findle?", isPresented: $showAlert) {
Button("Star on GitHub") {
if let url = URL(string: "https://github.com/alexmodrono/Findle") {
NSWorkspace.shared.open(url)
}
}
Button("Buy Me a Coffee") {
if let url = URL(string: "https://buymeacoffee.com/amodrono") {
NSWorkspace.shared.open(url)
}
}
Button("Maybe Later", role: .cancel) {}
} message: {
Text("Please consider leaving a star on GitHub or donating to support open-source projects like this.")
}
}
}

extension View {
func supportPrompt() -> some View {
modifier(SupportPrompt())
}
}
Loading