diff --git a/Sources/App/ViewModels/AppState.swift b/Sources/App/ViewModels/AppState.swift index 65bc195..a40598f 100644 --- a/Sources/App/ViewModels/AppState.swift +++ b/Sources/App/ViewModels/AppState.swift @@ -88,9 +88,20 @@ final class AppState: ObservableObject { } private func configureInitialDatabase() throws { - if let siteID = userDefaults.string(forKey: Self.currentSiteIDKey), - let sharedDatabase = try openSharedDatabase(siteID: siteID, seedFrom: nil) { - database = sharedDatabase + if let siteID = userDefaults.string(forKey: Self.currentSiteIDKey) { + // Try to open the shared database directly first. + if let sharedDatabase = try openSharedDatabase(siteID: siteID, seedFrom: nil) { + database = sharedDatabase + return + } + // Shared database needs seeding — bootstrap from the app group database + // so the File Provider picks up existing data on relaunch. + let bootstrapDatabase = try Database() + if let sharedDatabase = try openSharedDatabase(siteID: siteID, seedFrom: bootstrapDatabase) { + database = sharedDatabase + } else { + database = bootstrapDatabase + } } else { database = try Database() } diff --git a/Sources/App/Views/ContentView.swift b/Sources/App/Views/ContentView.swift index ae2dd78..b99095d 100644 --- a/Sources/App/Views/ContentView.swift +++ b/Sources/App/Views/ContentView.swift @@ -4,6 +4,7 @@ // You may obtain a copy of the License in the LICENSE file at the root of this repository. import SwiftUI +import WhatsNewKit struct ContentView: View { @EnvironmentObject var appState: AppState @@ -15,6 +16,14 @@ struct ContentView: View { OnboardingView() case .workspace: WorkspaceView() + .environment( + \.whatsNew, + WhatsNewEnvironment( + versionStore: UserDefaultsWhatsNewVersionStore(), + whatsNewCollection: WhatsNewProvider.collection + ) + ) + .whatsNewSheet() } } } diff --git a/Sources/App/Views/WhatsNew/WhatsNewProvider.swift b/Sources/App/Views/WhatsNew/WhatsNewProvider.swift new file mode 100644 index 0000000..be8ed66 --- /dev/null +++ b/Sources/App/Views/WhatsNew/WhatsNewProvider.swift @@ -0,0 +1,56 @@ +// Copyright 2026 Alejandro Modroño Vara +// +// 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 +import WhatsNewKit + +/// Central registry of all What's New entries shown after app updates. +enum WhatsNewProvider { + nonisolated(unsafe) static let collection: WhatsNewCollection = [ + WhatsNew( + version: "1.0.0", + title: "Welcome to Findle", + features: [ + WhatsNew.Feature( + image: .init( + systemName: "folder.fill", + foregroundColor: .accentColor + ), + title: "Your Courses in Finder", + subtitle: "Browse and open Moodle files directly from the Finder sidebar — no browser needed." + ), + WhatsNew.Feature( + image: .init( + systemName: "arrow.down.circle.fill", + foregroundColor: .green + ), + title: "On-Demand Downloads", + subtitle: "Files download only when you open them and can be evicted to save disk space." + ), + WhatsNew.Feature( + image: .init( + systemName: "lock.shield.fill", + foregroundColor: .orange + ), + title: "SSO & Direct Login", + subtitle: "Sign in with your university's SSO provider or with username and password." + ), + WhatsNew.Feature( + image: .init( + systemName: "doc.badge.plus", + foregroundColor: .purple + ), + title: "Local Files", + subtitle: "Create your own notes and files alongside course content — they stay local and never sync." + ) + ], + primaryAction: WhatsNew.PrimaryAction( + title: "Continue", + backgroundColor: .accentColor, + foregroundColor: .white + ) + ) + ] +} diff --git a/Sources/FileProviderExtension/FileProviderExtension.swift b/Sources/FileProviderExtension/FileProviderExtension.swift index e0aaf7c..45fc8c6 100644 --- a/Sources/FileProviderExtension/FileProviderExtension.swift +++ b/Sources/FileProviderExtension/FileProviderExtension.swift @@ -211,6 +211,12 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } } + // Local items have no remote — if their content is missing, report an error. + if localItem.isLocal { + completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) + return progress + } + do { try FileDownloader.startDownload( item: localItem, @@ -227,7 +233,24 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { // Download logic moved to FileDownloader for Sendable compliance. - // MARK: - Modification (Read-Only for now) + // MARK: - Local Content Storage + + /// Directory for storing user-created local file content. + private func localContentDirectory() throws -> URL { + let stateDir = try Self.stateDirectoryURL(for: domain) + let dir = stateDir + .appendingPathComponent(".FoodleState", isDirectory: true) + .appendingPathComponent("LocalContent", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Returns the on-disk URL for a local item's content. + private func localContentURL(itemID: String) throws -> URL { + try localContentDirectory().appendingPathComponent(itemID) + } + + // MARK: - Modification func createItem( basedOn itemTemplate: NSFileProviderItem, @@ -237,8 +260,62 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void ) -> Progress { - // Moodle content is read-only for now - completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + guard let db = database, let siteID else { + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + // Determine the parent. Items at the root have parentID = nil. + let parentID: String? + if itemTemplate.parentItemIdentifier == .rootContainer { + parentID = nil + } else { + parentID = itemTemplate.parentItemIdentifier.rawValue + } + + // Infer courseID from parent item (local items inherit their parent's course). + let courseID: Int + if let parentID, let parentItem = try? db.fetchItem(id: parentID) { + courseID = parentItem.courseID + } else { + courseID = 0 // Root-level local item + } + + let itemID = "local-\(UUID().uuidString)" + let now = Date() + let isDirectory = itemTemplate.contentType == .folder + + var localItem = LocalItem( + id: itemID, + parentID: parentID, + siteID: siteID, + courseID: courseID, + remoteID: 0, + filename: itemTemplate.filename, + isDirectory: isDirectory, + contentType: isDirectory ? nil : itemTemplate.contentType?.preferredMIMEType, + fileSize: (itemTemplate.documentSize ?? nil)?.int64Value ?? 0, + creationDate: now, + modificationDate: now, + syncState: .materialized, + isLocal: true + ) + + do { + // Persist file content for non-directory items. + if !isDirectory, let contentURL = url { + let dest = try localContentURL(itemID: itemID) + try FileManager.default.copyItem(at: contentURL, to: dest) + localItem.localPath = dest.path + } + + try db.saveItems([localItem]) + completionHandler(FileProviderItem(localItem: localItem), [], false, nil) + } catch { + logger.error("createItem failed: \(error.localizedDescription, privacy: .public)") + completionHandler(nil, [], false, error) + } + return Progress() } @@ -251,7 +328,75 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void ) -> Progress { - completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + guard let db = database else { + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + guard var localItem = try? db.fetchItem(id: item.itemIdentifier.rawValue) else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + + // Only allow modifications to local items. + guard localItem.isLocal else { + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + return Progress() + } + + do { + var newFilename = localItem.filename + var newParentID = localItem.parentID + var newLocalPath = localItem.localPath + var newFileSize = localItem.fileSize + var newTagData = localItem.tagData + + if changedFields.contains(.filename) { + newFilename = item.filename + } + if changedFields.contains(.parentItemIdentifier) { + newParentID = item.parentItemIdentifier == .rootContainer ? nil : item.parentItemIdentifier.rawValue + } + if changedFields.contains(.contents), let newContents { + let dest = try localContentURL(itemID: localItem.id) + try? FileManager.default.removeItem(at: dest) + try FileManager.default.copyItem(at: newContents, to: dest) + newLocalPath = dest.path + let attrs = try? FileManager.default.attributesOfItem(atPath: dest.path) + newFileSize = (attrs?[.size] as? Int64) ?? 0 + } + if changedFields.contains(.tagData) { + newTagData = item.tagData ?? nil + } + + let updated = LocalItem( + id: localItem.id, + parentID: newParentID, + siteID: localItem.siteID, + courseID: localItem.courseID, + remoteID: localItem.remoteID, + filename: newFilename, + isDirectory: localItem.isDirectory, + contentType: localItem.contentType, + fileSize: newFileSize, + creationDate: localItem.creationDate, + modificationDate: Date(), + syncState: localItem.syncState, + isPinned: localItem.isPinned, + localPath: newLocalPath, + remoteURL: localItem.remoteURL, + contentVersion: "\(Date().timeIntervalSince1970)", + tagData: newTagData, + isLocal: true + ) + + try db.saveItems([updated]) + completionHandler(FileProviderItem(localItem: updated), [], false, nil) + } catch { + logger.error("modifyItem failed: \(error.localizedDescription, privacy: .public)") + completionHandler(nil, [], false, error) + } + return Progress() } @@ -262,7 +407,34 @@ final class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { request: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void ) -> Progress { - completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + guard let db = database else { + completionHandler(NSFileProviderError(.notAuthenticated)) + return Progress() + } + + guard let localItem = try? db.fetchItem(id: identifier.rawValue) else { + completionHandler(NSFileProviderError(.noSuchItem)) + return Progress() + } + + // Only allow deletion of local items. + guard localItem.isLocal else { + completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + return Progress() + } + + do { + // Remove stored content. + if let path = localItem.localPath { + try? FileManager.default.removeItem(atPath: path) + } + try db.deleteItemAndChildren(id: localItem.id) + completionHandler(nil) + } catch { + logger.error("deleteItem failed: \(error.localizedDescription, privacy: .public)") + completionHandler(error) + } + return Progress() } } diff --git a/Sources/FileProviderExtension/FileProviderItem.swift b/Sources/FileProviderExtension/FileProviderItem.swift index 337da36..46059b4 100644 --- a/Sources/FileProviderExtension/FileProviderItem.swift +++ b/Sources/FileProviderExtension/FileProviderItem.swift @@ -27,8 +27,19 @@ final class FileProviderItem: NSObject, NSFileProviderItem { } var capabilities: NSFileProviderItemCapabilities { + if localItem.isLocal { + var caps: NSFileProviderItemCapabilities = [ + .allowsReading, .allowsWriting, .allowsRenaming, + .allowsReparenting, .allowsDeleting + ] + if localItem.isDirectory { + caps.insert(.allowsContentEnumerating) + caps.insert(.allowsAddingSubItems) + } + return caps + } if localItem.isDirectory { - return [.allowsReading, .allowsContentEnumerating] + return [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems] } return [.allowsReading] } @@ -77,13 +88,17 @@ final class FileProviderItem: NSObject, NSFileProviderItem { } var isUploaded: Bool { - true // read-only content is always "uploaded" + true // Local items are never uploaded; remote items are always "uploaded" } var isUploading: Bool { false } + var isMostRecentVersionDownloaded: Bool { + localItem.syncState == .materialized + } + var tagData: Data? { localItem.tagData } @@ -111,7 +126,7 @@ final class RootContainerItem: NSObject, NSFileProviderItem { var parentItemIdentifier: NSFileProviderItemIdentifier { .rootContainer } var filename: String { rootName } var contentType: UTType { .folder } - var capabilities: NSFileProviderItemCapabilities { [.allowsReading, .allowsContentEnumerating] } + var capabilities: NSFileProviderItemCapabilities { [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems] } var itemVersion: NSFileProviderItemVersion { let versionData = Data(rootName.utf8) return NSFileProviderItemVersion(contentVersion: versionData, metadataVersion: versionData) diff --git a/Sources/Persistence/Database.swift b/Sources/Persistence/Database.swift index 9f8433a..99051a8 100644 --- a/Sources/Persistence/Database.swift +++ b/Sources/Persistence/Database.swift @@ -17,7 +17,7 @@ public final class Database: @unchecked Sendable { private let path: String public var filePath: String { path } - public static let schemaVersion = 7 + public static let schemaVersion = 8 public init(path: String? = nil) throws { if let path = path { @@ -298,6 +298,14 @@ public final class Database: @unchecked Sendable { logger.info("Migrated database schema to version 7 (custom course icons)") } + if currentVersion < 8 { + let itemColumns = try existingColumns(table: "items") + if !itemColumns.contains("is_local") { + try execute("ALTER TABLE items ADD COLUMN is_local INTEGER NOT NULL DEFAULT 0") + } + logger.info("Migrated database schema to version 8 (local items)") + } + try execute("PRAGMA user_version = \(Self.schemaVersion)") } @@ -730,8 +738,8 @@ extension Database { let sql = """ INSERT OR REPLACE INTO items (id, parent_id, site_id, course_id, remote_id, filename, is_directory, content_type, file_size, creation_date, - modification_date, sync_state, is_pinned, local_path, remote_url, content_version, tag_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + modification_date, sync_state, is_pinned, local_path, remote_url, content_version, tag_data, is_local) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ try queue.sync { try executeUnsafe("BEGIN TRANSACTION") @@ -760,6 +768,7 @@ extension Database { } else { sqlite3_bind_null(stmt, 17) } + sqlite3_bind_int(stmt, 18, item.isLocal ? 1 : 0) _ = sqlite3_step(stmt) } @@ -892,14 +901,14 @@ extension Database { try queue.sync { // Clear stale pending deletions from previous cycles before recording new ones. try executeUnsafe("DELETE FROM pending_deletions") - // Record IDs for the File Provider to report as deletions. - let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE course_id = ? AND site_id = ?") + // Record IDs for the File Provider to report as deletions (skip local items). + let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE course_id = ? AND site_id = ? AND is_local = 0") defer { sqlite3_finalize(insertStmt) } sqlite3_bind_int(insertStmt, 1, Int32(courseID)) sqlite3_bind_text(insertStmt, 2, (siteID as NSString).utf8String, -1, nil) _ = sqlite3_step(insertStmt) - let deleteStmt = try prepareStatement("DELETE FROM items WHERE course_id = ? AND site_id = ?") + let deleteStmt = try prepareStatement("DELETE FROM items WHERE course_id = ? AND site_id = ? AND is_local = 0") defer { sqlite3_finalize(deleteStmt) } sqlite3_bind_int(deleteStmt, 1, Int32(courseID)) sqlite3_bind_text(deleteStmt, 2, (siteID as NSString).utf8String, -1, nil) @@ -911,18 +920,50 @@ extension Database { try queue.sync { try executeUnsafe("DELETE FROM pending_deletions") - let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE site_id = ?") + let insertStmt = try prepareStatement("INSERT INTO pending_deletions (item_id) SELECT id FROM items WHERE site_id = ? AND is_local = 0") defer { sqlite3_finalize(insertStmt) } sqlite3_bind_text(insertStmt, 1, (siteID as NSString).utf8String, -1, nil) _ = sqlite3_step(insertStmt) - let deleteStmt = try prepareStatement("DELETE FROM items WHERE site_id = ?") + let deleteStmt = try prepareStatement("DELETE FROM items WHERE site_id = ? AND is_local = 0") defer { sqlite3_finalize(deleteStmt) } sqlite3_bind_text(deleteStmt, 1, (siteID as NSString).utf8String, -1, nil) _ = sqlite3_step(deleteStmt) } } + /// Delete a single item and all its children by ID. + public func deleteItemAndChildren(id: String) throws { + try queue.sync { + // Delete children first (recursive via parent_id chain). + // For simplicity, collect all descendant IDs then delete them. + var idsToDelete = [id] + var frontier = [id] + + while !frontier.isEmpty { + var nextFrontier: [String] = [] + for parentID in frontier { + let stmt = try prepareStatement("SELECT id FROM items WHERE parent_id = ?") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, (parentID as NSString).utf8String, -1, nil) + while sqlite3_step(stmt) == SQLITE_ROW { + let childID = String(cString: sqlite3_column_text(stmt, 0)) + idsToDelete.append(childID) + nextFrontier.append(childID) + } + } + frontier = nextFrontier + } + + for itemID in idsToDelete { + let stmt = try prepareStatement("DELETE FROM items WHERE id = ?") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, (itemID as NSString).utf8String, -1, nil) + _ = sqlite3_step(stmt) + } + } + } + public func fetchPendingDeletions() throws -> [String] { let sql = "SELECT item_id FROM pending_deletions ORDER BY deleted_at" return try queue.sync { @@ -949,8 +990,9 @@ extension Database { } private func readItem(from stmt: OpaquePointer) -> LocalItem { + let colCount = sqlite3_column_count(stmt) + let tagData: Data? = { - let colCount = sqlite3_column_count(stmt) guard colCount > 16, sqlite3_column_type(stmt, 16) != SQLITE_NULL else { return nil } let bytes = sqlite3_column_blob(stmt, 16) let length = sqlite3_column_bytes(stmt, 16) @@ -958,6 +1000,8 @@ extension Database { return Data(bytes: bytes, count: Int(length)) }() + let isLocal = colCount > 17 ? sqlite3_column_int(stmt, 17) == 1 : false + return LocalItem( id: String(cString: sqlite3_column_text(stmt, 0)), parentID: sqlite3_column_text(stmt, 1).map { String(cString: $0) }, @@ -975,7 +1019,8 @@ extension Database { localPath: sqlite3_column_text(stmt, 13).map { String(cString: $0) }, remoteURL: sqlite3_column_text(stmt, 14).flatMap { URL(string: String(cString: $0)) }, contentVersion: sqlite3_column_text(stmt, 15).map { String(cString: $0) }, - tagData: tagData + tagData: tagData, + isLocal: isLocal ) } } diff --git a/Sources/SharedDomain/State/SyncState.swift b/Sources/SharedDomain/State/SyncState.swift index e631bd3..d3a6b67 100644 --- a/Sources/SharedDomain/State/SyncState.swift +++ b/Sources/SharedDomain/State/SyncState.swift @@ -68,6 +68,9 @@ public struct LocalItem: Sendable, Codable, Equatable, Identifiable { public var remoteURL: URL? public var contentVersion: String? public var tagData: Data? + /// When `true`, the item was created locally by the user and should never be + /// synced to Moodle. The sync engine skips local items entirely. + public var isLocal: Bool public init( id: String = UUID().uuidString, @@ -86,7 +89,8 @@ public struct LocalItem: Sendable, Codable, Equatable, Identifiable { localPath: String? = nil, remoteURL: URL? = nil, contentVersion: String? = nil, - tagData: Data? = nil + tagData: Data? = nil, + isLocal: Bool = false ) { self.id = id self.parentID = parentID @@ -105,5 +109,6 @@ public struct LocalItem: Sendable, Codable, Equatable, Identifiable { self.remoteURL = remoteURL self.contentVersion = contentVersion self.tagData = tagData + self.isLocal = isLocal } } diff --git a/Sources/SyncEngine/SyncEngine.swift b/Sources/SyncEngine/SyncEngine.swift index 5436082..e28573b 100644 --- a/Sources/SyncEngine/SyncEngine.swift +++ b/Sources/SyncEngine/SyncEngine.swift @@ -133,7 +133,7 @@ public actor SyncEngine { syncProgress[course.id]?.totalItems = allItems.count // Diff against existing items - let existingItems = try database.fetchAllItems(siteID: site.id).filter { $0.courseID == course.id } + let existingItems = try database.fetchAllItems(siteID: site.id).filter { $0.courseID == course.id && !$0.isLocal } let changes = diffItems(existing: existingItems, incoming: allItems) diff --git a/project.yml b/project.yml index f5be90d..f481e7e 100644 --- a/project.yml +++ b/project.yml @@ -79,6 +79,9 @@ packages: Sparkle: url: https://github.com/sparkle-project/Sparkle from: 2.6.0 + WhatsNewKit: + url: https://github.com/SvenTiigi/WhatsNewKit + from: 2.2.1 targets: Foodle: @@ -107,6 +110,8 @@ targets: product: Airlock - package: Sparkle product: Sparkle + - package: WhatsNewKit + product: WhatsNewKit - sdk: CoreSpotlight.framework settings: base: