diff --git a/README.md b/README.md index 63e4749..813d7d9 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. A clean checkout installs them with `npm ci`, without a source clone or sibling repository. The vendored set is the core release -[core-2.77.0-core.hfe38f5ee2a70bd64](https://github.com/ZenNotes/zennotes/releases/tag/core-2.77.0-core.hfe38f5ee2a70bd64) -(desktop commit `3f0356de`, clean tree). +[core-2.78.0-core.h0bc32cbd73a78fac](https://github.com/ZenNotes/zennotes/releases/tag/core-2.78.0-core.h0bc32cbd73a78fac) +(desktop commit `52ab86fd`, clean tree). `npm run boundaries:check` verifies the pins, installed versions, singleton React/CodeMirror peers, and public export usage. `npm run core:adopt -- ` diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 88f3611..3d1fafc 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 0D482B74B72550D754DC59E4 /* CalendarWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D3810E32BD1F68308D5F9E /* CalendarWidget.swift */; }; + 11A978B6D133577F0EF5748C /* CalendarWidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B50085E6BF8C0AEEA206C86 /* CalendarWidgetIntents.swift */; }; 1620DB6173A8CFE3D41D46DA /* ZenWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F342B601563BB02825474641 /* ZenWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 172569C163FC68A0C0C58F02 /* WidgetTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3175A5673A3065A7A684CC9D /* WidgetTheme.swift */; }; 230C7E4950AA2E50A25A394C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F7650E04BDA8754AB2119967 /* PrivacyInfo.xcprivacy */; }; @@ -28,9 +30,11 @@ 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 5CA8650C3B7DF616A8B69229 /* ZNViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24C226FEA7EA30D28C20477 /* ZNViewController.swift */; }; 5CA8650D3B7DF616A8B69229 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24C2270EA7EA30D28C20477 /* SceneDelegate.swift */; }; + 5F3188CBB861D6DF7A1A2DDC /* WidgetActionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48BC3D75A44C9064DF636C2F /* WidgetActionLog.swift */; }; 60BB3C8B9335A8DF5C0C752D /* NewNoteWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 486736422F5CB5716CAA674F /* NewNoteWidget.swift */; }; 66714DDFC6EB0A9732DE176A /* WidgetBridgePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */; }; 6B30A84670984EC66F0422E2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */; }; + 7933D95CCAF6766300D6F19B /* CalendarWidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B50085E6BF8C0AEEA206C86 /* CalendarWidgetIntents.swift */; }; 80DB78E0507751992FCE4245 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4ED02E2781BE90C097D648B8 /* WidgetKit.framework */; }; 88E96F6793476380FB4028C7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */; }; 9929F4C805DC04F7A7F2058C /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */; }; @@ -43,6 +47,7 @@ BA7E23B65E109265261E0F5E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 155E04D763AD80DA2E56D38D /* Foundation.framework */; }; C10DF11E5A2B4C7D9E8F0A1B /* CloudFilesPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10DF11F5A2B4C7D9E8F0A1C /* CloudFilesPlugin.swift */; }; D17E4E8551A97CF08FDE5F82 /* FolderPickerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A91962841901607F2906263 /* FolderPickerPlugin.swift */; }; + D283CDD647BD7C0D803FB8FE /* WidgetActionLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48BC3D75A44C9064DF636C2F /* WidgetActionLog.swift */; }; DE19FCF1249C6A4D528227C2 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E1424FEA5946506EA505941C /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F59E347AA2D96EB117D7C2CD /* ZenWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */; }; FB56F003004840A2A553E931 /* KeyboardBackdropPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F5A2D64394F429AF0ED50 /* KeyboardBackdropPlugin.swift */; }; @@ -97,6 +102,7 @@ 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 424F5A2D64394F429AF0ED50 /* KeyboardBackdropPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeyboardBackdropPlugin.swift; sourceTree = ""; }; 486736422F5CB5716CAA674F /* NewNoteWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewNoteWidget.swift; sourceTree = ""; }; + 48BC3D75A44C9064DF636C2F /* WidgetActionLog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetActionLog.swift; sourceTree = ""; }; 4ED02E2781BE90C097D648B8 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/WidgetKit.framework; sourceTree = DEVELOPER_DIR; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -115,12 +121,14 @@ 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetBridgePlugin.swift; sourceTree = ""; }; 85847153B31EF561562A9281 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/SwiftUI.framework; sourceTree = DEVELOPER_DIR; }; 8A91962841901607F2906263 /* FolderPickerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FolderPickerPlugin.swift; sourceTree = ""; }; + 8B50085E6BF8C0AEEA206C86 /* CalendarWidgetIntents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CalendarWidgetIntents.swift; sourceTree = ""; }; 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; 9115A46C405EC261610EC467 /* CloudFileStream.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudFileStream.swift; sourceTree = ""; }; A24C226FEA7EA30D28C20477 /* ZNViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZNViewController.swift; sourceTree = ""; }; A24C2270EA7EA30D28C20477 /* SceneDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + B6D3810E32BD1F68308D5F9E /* CalendarWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CalendarWidget.swift; sourceTree = ""; }; C10DF11F5A2B4C7D9E8F0A1C /* CloudFilesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudFilesPlugin.swift; sourceTree = ""; }; C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZenWidgetsBundle.swift; sourceTree = ""; }; CC30A5CCEA5991955FBC0360 /* CloudFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudFlowUITests.swift; sourceTree = ""; }; @@ -294,6 +302,9 @@ E826831F57D7B9D9D5121461 /* ZenWidgets.entitlements */, DB10756BA75711FC150E34FD /* Assets.xcassets */, 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */, + B6D3810E32BD1F68308D5F9E /* CalendarWidget.swift */, + 8B50085E6BF8C0AEEA206C86 /* CalendarWidgetIntents.swift */, + 48BC3D75A44C9064DF636C2F /* WidgetActionLog.swift */, ); name = ZenWidgets; path = ZenWidgets; @@ -509,6 +520,9 @@ 60BB3C8B9335A8DF5C0C752D /* NewNoteWidget.swift in Sources */, B516881658A9D3C9AFC1259E /* RecentNotesWidget.swift in Sources */, 9F314931CA3D31AAFCF26E15 /* TasksWidget.swift in Sources */, + 0D482B74B72550D754DC59E4 /* CalendarWidget.swift in Sources */, + 11A978B6D133577F0EF5748C /* CalendarWidgetIntents.swift in Sources */, + D283CDD647BD7C0D803FB8FE /* WidgetActionLog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -527,6 +541,8 @@ C10DF11E5A2B4C7D9E8F0A1B /* CloudFilesPlugin.swift in Sources */, FD65529207121AA61FF892C5 /* CloudFileStream.swift in Sources */, 34CD033D6469BD27C1F5F22B /* BackgroundSyncPlugin.swift in Sources */, + 7933D95CCAF6766300D6F19B /* CalendarWidgetIntents.swift in Sources */, + 5F3188CBB861D6DF7A1A2DDC /* WidgetActionLog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -608,12 +624,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.33.0; + MARKETING_VERSION = 1.34.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -737,12 +753,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.33.0; + MARKETING_VERSION = 1.34.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -759,12 +775,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.33.0; + MARKETING_VERSION = 1.34.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -779,12 +795,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.33.0; + MARKETING_VERSION = 1.34.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -801,12 +817,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.33.0; + MARKETING_VERSION = 1.34.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -839,12 +855,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 51; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.33.0; + MARKETING_VERSION = 1.34.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/ios/App/App/WidgetBridgePlugin.swift b/ios/App/App/WidgetBridgePlugin.swift index 9ef0ef5..7f3e3a8 100644 --- a/ios/App/App/WidgetBridgePlugin.swift +++ b/ios/App/App/WidgetBridgePlugin.swift @@ -6,7 +6,9 @@ import WidgetKit /// (ios/App/ZenWidgets). The WebView publishes the widget snapshot /// (src/bridge/widgets.ts builds it from the store) and this writes it into /// the App Group — the only place a widget extension can read — then asks -/// WidgetKit to re-render. The extension never sees the vault itself. +/// WidgetKit to re-render. The extension never sees the vault itself; the +/// Calendar widget's checkbox taps come back through readActions +/// (WidgetActionLog, src/bridge/widget-calendar.ts). @objc(WidgetBridgePlugin) public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "WidgetBridgePlugin" @@ -14,7 +16,8 @@ public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "consumeLaunchLink", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "consumeLaunchLink", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "readActions", returnType: CAPPluginReturnPromise) ] private let appGroupId = "group.md.zennotes" @@ -66,11 +69,29 @@ public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Could not write the widget snapshot: \(error.localizedDescription)") return } + WidgetActionLog.prune(through: Self.actionsAck(json)) WidgetCenter.shared.reloadAllTimelines() call.resolve() } } + /// The newest Calendar widget tap this snapshot says the app applied. + private static func actionsAck(_ json: String) -> Double { + guard let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any], + let calendar = object["calendar"] as? [String: Any] + else { return 0 } + return calendar["actionsAck"] as? Double ?? 0 + } + + /// The Calendar widget's logged taps, oldest first. Reading leaves them + /// in place: a snapshot whose `actionsAck` covers a tap clears it. + @objc func readActions(_ call: CAPPluginCall) { + DispatchQueue.global(qos: .utility).async { + let list = WidgetActionLog.read().sorted { ($0["at"] as? Double ?? 0) < ($1["at"] as? Double ?? 0) } + call.resolve(["actions": list]) + } + } + @objc func clear(_ call: CAPPluginCall) { DispatchQueue.global(qos: .utility).async { if let url = self.snapshotURL() { diff --git a/ios/App/ZenWidgets/CalendarWidget.swift b/ios/App/ZenWidgets/CalendarWidget.swift new file mode 100644 index 0000000..7c28787 --- /dev/null +++ b/ios/App/ZenWidgets/CalendarWidget.swift @@ -0,0 +1,591 @@ +import AppIntents +import SwiftUI +import WidgetKit + +/// The Calendar widget: app-core's Calendar sheet on the Home Screen (Dave, +/// Discord 2026-10-27). The month grid with its writing dots, open-task +/// marks and week numbers on top, the selected day's tasks below with boxes +/// to tick. +/// +/// A widget cannot scroll, so the list pages instead: the large size lists +/// the selected day and pages by day, the extra-large portrait size (iOS 27) +/// lists the selected week and pages by week. Like the panel on a touch +/// screen, a tap selects a day and a tap on the selected day opens its daily +/// note. "Add a task for …" opens the Calendar in the app with the cursor in +/// its field (a widget holds no text field). Ticks are logged for the app +/// (WidgetActionLog), which writes them into the note through app-core. +/// Buttons need iOS 17; on 15 and 16 every part is a link into the app. +struct CalendarWidget: Widget { + static let kind = "md.zennotes.widgets.calendar" + + private static var families: [WidgetFamily] { + // `#available` guards the run, not the build: the extra-large + // portrait size is in the iOS 27 SDK only, and an Xcode 26 toolchain + // (CI's) cannot name it at all. Swift 6.4 (Xcode 27.1, which builds + // the release) is the gate; an older toolchain builds the large size + // alone. + #if compiler(>=6.4) + if #available(iOS 27.0, *) { return [.systemLarge, .systemExtraLargePortrait] } + #endif + return [.systemLarge] + } + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: CalendarProvider()) { entry in + CalendarWidgetView(entry: entry) + } + .configurationDisplayName("Calendar") + .description("The month, with each day's tasks to tick off.") + .supportedFamilies(Self.families) + .contentMarginsDisabled() + } +} + +// MARK: - Calendar arithmetic (the panel's grid, lib/calendar-data.ts) + +enum CalendarGrid { + /// The panel's weekday labels (it uppercases them in CSS). + static let dayLabels = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] + + static func addDays(_ iso: String, _ days: Int) -> String { + guard let date = CalendarWidgetState.date(iso), + let moved = CalendarWidgetState.isoCalendar.date(byAdding: .day, value: days, to: date) else { return iso } + return CalendarWidgetState.iso(moved) + } + + /// 0 = Sunday … 6 = Saturday, as the snapshot's `weekStart`. + static func weekday(_ iso: String) -> Int { + guard let date = CalendarWidgetState.date(iso) else { return 0 } + return CalendarWidgetState.isoCalendar.component(.weekday, from: date) - 1 + } + + /// The 42 days of `month`'s grid starting on `weekStart` (buildGrid). + static func grid(month: String, weekStart: Int) -> [String] { + let first = month + "-01" + let start = addDays(first, -((weekday(first) - weekStart + 7) % 7)) + return (0..<42).map { addDays(start, $0) } + } + + /// The 7 days of the week holding `iso`, from `weekStart` (the agenda). + static func week(of iso: String, weekStart: Int) -> [String] { + let start = addDays(iso, -((weekday(iso) - weekStart + 7) % 7)) + return (0..<7).map { addDays(start, $0) } + } + + /// The day a grid row's week number comes from: its Monday, else its first day. + static func rowMonday(_ row: [String]) -> String { + row.first { weekday($0) == 1 } ?? row[0] + } + + private static var isoCalendar: Calendar { + var calendar = Calendar(identifier: .iso8601) + calendar.timeZone = .current + return calendar + } + + static func isoWeek(_ iso: String) -> Int { + guard let date = CalendarWidgetState.date(iso) else { return 0 } + return isoCalendar.component(.weekOfYear, from: date) + } + + /// `2026-W44`: how the snapshot keys weekly notes. + static func isoWeekKey(_ iso: String) -> String { + guard let date = CalendarWidgetState.date(iso) else { return "" } + let parts = isoCalendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date) + return String(format: "%d-W%02d", parts.yearForWeekOfYear ?? 0, parts.weekOfYear ?? 0) + } + + private static func format(_ iso: String, _ template: String) -> String { + guard let date = CalendarWidgetState.date(iso) else { return iso } + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate(template) + return formatter.string(from: date) + } + + /// "October 2026". + static func monthLabel(_ month: String) -> String { format(month + "-01", "MMMMy") } + + /// "27 Tue", the panel's `{ weekday: 'short', day: 'numeric' }`. + static func shortDay(_ iso: String) -> String { format(iso, "dEEE") } + + /// "Oct 26 – Nov 1", or "Oct 5 – 11" within one month (the panel's label). + static func weekRange(_ week: [String]) -> String { + let start = week[0], end = week[6] + let endLabel = start.prefix(7) == end.prefix(7) ? format(end, "d") : format(end, "MMMd") + return "\(format(start, "MMMd")) \u{2013} \(endLabel)" + } +} + +// MARK: - Timeline + +struct CalendarEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot? + let palette: WidgetPalette + /// `YYYY-MM` in the grid and the selected `YYYY-MM-DD`. + let month: String + let selected: String + /// Task id -> the state a tap the app has yet to apply asked for. + let marks: [String: Bool] +} + +struct CalendarProvider: TimelineProvider { + func placeholder(in context: Context) -> CalendarEntry { + entry(WidgetSnapshotStore.sample(), now: Date()) + } + + func getSnapshot(in context: Context, completion: @escaping (CalendarEntry) -> Void) { + let real = WidgetSnapshotStore.load() + let snapshot = context.isPreview && real?.calendar == nil ? WidgetSnapshotStore.sample() : real + completion(entry(snapshot, now: Date())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + // Today's ring and a stale selection move at midnight; the app + // reloads on every change in between. + let midnight = Calendar.current.nextDate( + after: now, matching: DateComponents(hour: 0, minute: 0, second: 5), matchingPolicy: .nextTime + ) ?? now.addingTimeInterval(6 * 3600) + completion(Timeline(entries: [entry(WidgetSnapshotStore.load(), now: now)], policy: .after(midnight))) + } + + private func entry(_ snapshot: WidgetSnapshot?, now: Date) -> CalendarEntry { + let state = CalendarWidgetState.load(today: WidgetFormat.isoDate(now)) + let key = snapshot?.calendar?.vaultKey ?? "" + return CalendarEntry( + date: now, snapshot: snapshot, palette: WidgetPalette(theme: snapshot?.theme), + month: state.month, selected: state.selected, + marks: key.isEmpty ? [:] : WidgetActionLog.marks(vaultKey: key) + ) + } +} + +// MARK: - View + +struct CalendarWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: CalendarEntry + + private var cal: WidgetCalendarData? { entry.snapshot?.calendar } + private var large: Bool { family == .systemLarge } + private var today: String { WidgetFormat.isoDate(entry.date) } + private var weekStart: Int { cal?.weekStart ?? 1 } + private var daily: Bool { cal?.dailyEnabled == true } + private var weekNumbers: Bool { cal?.showWeekNumbers == true } + private var cellHeight: CGFloat { large ? 19 : 27 } + private var rowHeight: CGFloat { large ? 22 : 25 } + private var weekColumn: CGFloat { large ? 18 : 22 } + /// The panel's faint ink for days outside the month and week numbers. + private var faint: Color { entry.palette.muted.opacity(0.55) } + + var body: some View { + let p = entry.palette + VStack(spacing: 0) { + header(p) + labels(p) + grid(p) + Rectangle().fill(p.bg2).frame(height: 1).padding(.vertical, large ? 4 : 7) + if daily { + agendaBar(p) + addField(p) + agenda(p) + } else { + emptyState(p) + } + } + .padding(.horizontal, 12) + .padding(.top, 9) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .calendarBackground(p.bg) + .widgetURL(ZenLinks.calendar(date: entry.selected)) + } + + // Header: ‹ month ›. The label returns to the current month, as in the panel. + private func header(_ p: WidgetPalette) -> some View { + HStack(spacing: 0) { + pageButton(months: -1, symbol: "chevron.left", p) + Spacer(minLength: 4) + monthLabel(p) + Spacer(minLength: 4) + pageButton(months: 1, symbol: "chevron.right", p) + } + .frame(height: large ? 20 : 26) + } + + @ViewBuilder + private func monthLabel(_ p: WidgetPalette) -> some View { + let label = Text(CalendarGrid.monthLabel(entry.month)) + .font(.system(size: large ? 13 : 15, weight: .semibold)) + .foregroundColor(p.fg) + if #available(iOS 17.0, *) { + Button(intent: ShowCalendarMonthIntent(delta: 0)) { label }.buttonStyle(.plain) + } else { + label + } + } + + @ViewBuilder + private func pageButton(months: Int, symbol: String, _ p: WidgetPalette) -> some View { + if #available(iOS 17.0, *) { + Button(intent: ShowCalendarMonthIntent(delta: months)) { chevron(symbol, p) }.buttonStyle(.plain) + } else { + Color.clear.frame(width: 26, height: 20) + } + } + + private func chevron(_ symbol: String, _ p: WidgetPalette) -> some View { + Image(systemName: symbol) + .font(.system(size: large ? 11 : 13, weight: .semibold)) + .foregroundColor(p.muted) + .frame(width: 26, height: large ? 20 : 26) + .contentShape(Rectangle()) + } + + private func labels(_ p: WidgetPalette) -> some View { + HStack(spacing: 0) { + if weekNumbers { Text("W").frame(width: weekColumn) } + ForEach(0..<7, id: \.self) { i in + Text(CalendarGrid.dayLabels[(weekStart + i) % 7]).frame(maxWidth: .infinity) + } + } + .font(.system(size: large ? 8.5 : 10, weight: .medium)) + .foregroundColor(p.muted) + .frame(height: large ? 12 : 15) + } + + private func grid(_ p: WidgetPalette) -> some View { + let days = CalendarGrid.grid(month: entry.month, weekStart: weekStart) + return VStack(spacing: 0) { + ForEach(0..<6, id: \.self) { r in + let row = Array(days[(r * 7)..<(r * 7 + 7)]) + HStack(spacing: 0) { + if weekNumbers { weekCell(CalendarGrid.rowMonday(row), p) } + ForEach(row, id: \.self) { iso in dayCell(iso, p) } + } + } + } + } + + private func dayCell(_ iso: String, _ p: WidgetPalette) -> some View { + let inMonth = iso.hasPrefix(entry.month) + let selected = daily && iso == entry.selected + let isToday = iso == today + let color: Color = selected ? p.fg : isToday ? p.accent : inMonth ? p.fg : faint + let number = String(Int(iso.suffix(2)) ?? 0) + return tappableDay(iso, selected: selected) { + cell(number, color: color, selected: selected, today: isToday, mark: cal?.days?[iso], p) + } + } + + private func weekCell(_ monday: String, _ p: WidgetPalette) -> some View { + let weekly = cal?.weeklyEnabled == true + let content = cell(String(CalendarGrid.isoWeek(monday)), color: faint, selected: false, today: false, + mark: weekly ? cal?.weeks?[CalendarGrid.isoWeekKey(monday)] : nil, p) + .frame(width: weekColumn) + return Group { + if weekly { + Link(destination: ZenLinks.week(monday)) { content } + } else { + content + } + } + } + + /// One cell as CalendarPanel draws it: selected = fill + ring, today = + /// ring, dots for the note's words, a corner mark for its open tasks. + private func cell(_ text: String, color: Color, selected: Bool, today: Bool, + mark: WidgetCalendarMark?, _ p: WidgetPalette) -> some View { + ZStack(alignment: .topTrailing) { + RoundedRectangle(cornerRadius: 5).fill(selected ? p.bg2 : Color.clear) + if selected || today { + RoundedRectangle(cornerRadius: 5).strokeBorder(p.accent.opacity(selected ? 0.6 : 0.5), lineWidth: 1) + } + VStack(spacing: large ? 1 : 2) { + Text(text) + .font(.system(size: large ? 11 : 13, weight: selected || today ? .semibold : .regular)) + .foregroundColor(color) + dots(mark, p) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + if (mark?.openTasks ?? 0) > 0 { + Circle().fill(p.accent).frame(width: 4, height: 4).padding(large ? 2 : 3) + } + } + .frame(maxWidth: .infinity) + .frame(height: cellHeight) + .padding(.horizontal, 1) + } + + private func dots(_ mark: WidgetCalendarMark?, _ p: WidgetPalette) -> some View { + HStack(spacing: 1.5) { + ForEach(0..(_ iso: String, selected: Bool, + @ViewBuilder _ content: () -> Content) -> some View { + if cal == nil { + Link(destination: ZenLinks.calendar(date: nil)) { content() } + } else if !daily { + content() + } else if selected { + Link(destination: ZenLinks.day(iso)) { content() } + } else if #available(iOS 17.0, *) { + Button(intent: SelectCalendarDayIntent(day: iso)) { content() }.buttonStyle(.plain) + } else { + Link(destination: ZenLinks.calendar(date: iso)) { content() } + } + } + + // The bar over the list: the large size lists one day, the extra-large a week. + private func agendaBar(_ p: WidgetPalette) -> some View { + let week = CalendarGrid.week(of: entry.selected, weekStart: weekStart) + let title = large + ? CalendarGrid.shortDay(entry.selected) + : "Week of \(CalendarGrid.weekRange(week))" + let hasDay = cal?.days?[entry.selected] != nil + return HStack(spacing: 4) { + Text(title) + .font(.system(size: large ? 12 : 13, weight: .semibold)) + .foregroundColor(p.fg) + .lineLimit(1) + if large && entry.selected == today { todayBadge(p) } + Spacer(minLength: 4) + if #available(iOS 17.0, *) { + Button(intent: ShiftCalendarDaysIntent(days: large ? -1 : -7)) { chevron("chevron.left", p) } + .buttonStyle(.plain) + Button(intent: ShiftCalendarDaysIntent(days: large ? 1 : 7)) { chevron("chevron.right", p) } + .buttonStyle(.plain) + } + Link(destination: ZenLinks.day(entry.selected)) { + Text(hasDay ? "Day \u{2192}" : "Day +") + .font(.system(size: large ? 11 : 12)) + .foregroundColor(p.muted) + } + if cal?.weeklyEnabled == true { + let hasWeek = cal?.weeks?[CalendarGrid.isoWeekKey(entry.selected)] != nil + Link(destination: ZenLinks.week(entry.selected)) { + Text(hasWeek ? "Week \u{2192}" : "Week +") + .font(.system(size: large ? 11 : 12)) + .foregroundColor(p.muted) + } + .padding(.leading, 4) + } + } + .frame(height: large ? 20 : 26) + } + + private func todayBadge(_ p: WidgetPalette) -> some View { + Text("Today") + .font(.system(size: large ? 9.5 : 10.5, weight: .medium)) + .foregroundColor(p.accent) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(RoundedRectangle(cornerRadius: 3).fill(p.accent.opacity(0.15))) + } + + private func addField(_ p: WidgetPalette) -> some View { + Link(destination: ZenLinks.calendar(date: entry.selected, add: true)) { + HStack { + Text("Add a task for \(CalendarGrid.shortDay(entry.selected))\u{2026}") + .font(.system(size: large ? 11.5 : 13)) + .foregroundColor(p.muted) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: large ? 24 : 30) + .background(RoundedRectangle(cornerRadius: 6).fill(p.bg2.opacity(0.6))) + } + .padding(.top, 2) + .padding(.bottom, large ? 2 : 4) + } + + // MARK: Agenda rows + + private struct Row: Identifiable { + enum Kind { case header, task, message } + let id: String + let kind: Kind + let iso: String + var task: WidgetCalendarTask? + var count = 0 + } + + private func rows() -> [Row] { + guard let cal else { return [] } + let week = CalendarGrid.week(of: entry.selected, weekStart: weekStart) + let days = large ? [entry.selected] : week + guard days.allSatisfy({ cal.covers($0) }) else { + return [Row(id: "message", kind: .message, iso: entry.selected)] + } + var out: [Row] = [] + for iso in days { + let day = cal.agenda?[iso] + if !large { out.append(Row(id: "h\(iso)", kind: .header, iso: iso, count: day?.total ?? 0)) } + for task in day?.tasks ?? [] { + out.append(Row(id: "t\(task.id)", kind: .task, iso: iso, task: task)) + } + } + if large && out.isEmpty { + out.append(Row(id: "none", kind: .message, iso: entry.selected, count: -1)) + } + return out + } + + private func agenda(_ p: WidgetPalette) -> some View { + GeometryReader { geo in + let all = rows() + let fit = max(1, Int(geo.size.height / rowHeight)) + let shown = all.count > fit ? Array(all.prefix(fit - 1)) : all + VStack(alignment: .leading, spacing: 0) { + ForEach(shown) { row in agendaRow(row, p) } + if all.count > fit { + let hidden = all.dropFirst(fit - 1).filter { $0.kind == .task }.count + Link(destination: ZenLinks.calendar(date: entry.selected)) { + Text("+\(hidden) more") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(p.muted) + .frame(height: rowHeight, alignment: .leading) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + @ViewBuilder + private func agendaRow(_ row: Row, _ p: WidgetPalette) -> some View { + switch row.kind { + case .header: + dayHeader(row, p) + case .task: + if let task = row.task { taskRow(task, p) } + case .message: + Link(destination: ZenLinks.calendar(date: row.iso)) { + Text(row.count < 0 ? "Nothing due \(CalendarGrid.shortDay(row.iso))." : "Open ZenNotes to see this week.") + .font(.system(size: 11.5)) + .foregroundColor(p.muted) + .frame(height: rowHeight, alignment: .leading) + } + } + } + + @ViewBuilder + private func dayHeader(_ row: Row, _ p: WidgetPalette) -> some View { + let label = HStack(spacing: 5) { + Text(CalendarGrid.shortDay(row.iso).uppercased()) + .font(.system(size: 10.5, weight: .semibold)) + .kerning(0.4) + .foregroundColor(row.iso == entry.selected ? p.fg : p.muted) + if row.iso == today { todayBadge(p) } + Spacer(minLength: 4) + if row.count > 0 { + Text("\(row.count)").font(.system(size: 10.5)).foregroundColor(p.muted) + } + } + .frame(height: rowHeight) + .contentShape(Rectangle()) + if #available(iOS 17.0, *) { + Button(intent: SelectCalendarDayIntent(day: row.iso)) { label }.buttonStyle(.plain) + } else { + label + } + } + + private func taskRow(_ task: WidgetCalendarTask, _ p: WidgetPalette) -> some View { + let checked = entry.marks[task.id] ?? false + return HStack(spacing: 6) { + checkbox(task, checked: checked, p) + Link(destination: ZenLinks.task(id: task.id, path: task.path)) { + HStack(spacing: 4) { + Text(task.content) + .font(.system(size: large ? 12 : 13)) + .foregroundColor(checked ? p.muted : p.fg) + .strikethrough(checked, color: p.muted) + .lineLimit(1) + Spacer(minLength: 4) + if task.inDailyNote != true { + Text(task.noteTitle) + .font(.system(size: large ? 10 : 11)) + .foregroundColor(p.muted) + .lineLimit(1) + } + } + } + } + .frame(height: rowHeight) + } + + @ViewBuilder + private func checkbox(_ task: WidgetCalendarTask, checked: Bool, _ p: WidgetPalette) -> some View { + // A Button, not a Toggle: a widget Toggle held its in-between state + // over the reloaded row on iOS 27, so the tick never showed. The + // reload after the tap draws the box from the log instead. + if #available(iOS 17.0, *) { + if let key = cal?.vaultKey { + Button(intent: ToggleCalendarTaskIntent( + checked: !checked, taskID: task.id, path: task.path, line: task.line, raw: task.raw, + toggled: task.toggled ?? "", vaultKey: key + )) { + CalendarCheckBox.box(checked: checked, palette: p, compact: large) + } + .buttonStyle(.plain) + } else { + CalendarCheckBox.box(checked: checked, palette: p, compact: large) + } + } else { + CalendarCheckBox.box(checked: checked, palette: p, compact: large) + } + } + + @ViewBuilder + private func emptyState(_ p: WidgetPalette) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(cal == nil ? "Open ZenNotes once" : "Daily notes are off") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(p.fg) + Text(cal == nil ? "The calendar fills in from your vault." : "Turn them on in ZenNotes to list tasks by day.") + .font(.system(size: 11.5)) + .foregroundColor(p.muted) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } +} + +/// The agenda's checkbox. A tick waits for the app (nothing is written from +/// the widget), so a ticked box is drawn in the muted color until the app +/// has applied it and the task leaves the list. +enum CalendarCheckBox { + static func box(checked: Bool, palette: WidgetPalette, compact: Bool) -> some View { + Image(systemName: checked ? "checkmark.square.fill" : "square") + .font(.system(size: compact ? 14 : 16)) + .foregroundColor(palette.muted) + .frame(width: compact ? 20 : 24, height: compact ? 20 : 24) + .contentShape(Rectangle()) + } +} + +private extension View { + /// The system container from iOS 17 (content margins are off: the grid + /// needs the room), a plain background before. + @ViewBuilder + func calendarBackground(_ color: Color) -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(for: .widget) { color } + } else { + self.background(color) + } + } +} diff --git a/ios/App/ZenWidgets/CalendarWidgetIntents.swift b/ios/App/ZenWidgets/CalendarWidgetIntents.swift new file mode 100644 index 0000000..a4e65bd --- /dev/null +++ b/ios/App/ZenWidgets/CalendarWidgetIntents.swift @@ -0,0 +1,179 @@ +import AppIntents +import Foundation + +/// What the Calendar widget shows: the month in its grid and the selected +/// day (whose tasks it lists), like the panel's `anchor` and `selectedIso`. +/// One state for every placed Calendar widget (a StaticConfiguration widget +/// has no instance id). A new day starts over on today, the way the panel +/// orients on today when it opens. +enum CalendarWidgetState { + private static var defaults: UserDefaults? { UserDefaults(suiteName: WidgetActionLog.appGroupId) } + private static let monthKey = "calendarWidget.month" + private static let selectedKey = "calendarWidget.selected" + private static let onKey = "calendarWidget.on" + + /// `YYYY-MM`, `YYYY-MM-DD`. + static func load(today: String) -> (month: String, selected: String) { + let d = defaults + guard d?.string(forKey: onKey) == today, + let month = d?.string(forKey: monthKey), + let selected = d?.string(forKey: selectedKey) + else { return (String(today.prefix(7)), today) } + return (month, selected) + } + + static func save(month: String, selected: String, today: String) { + let d = defaults + d?.set(today, forKey: onKey) + d?.set(month, forKey: monthKey) + d?.set(selected, forKey: selectedKey) + } + + /// Gregorian in the device's time zone whatever calendar the user reads + /// in: ISO dates are Gregorian, and a Buddhist or Japanese year here made + /// every key miss (2569-10-27 for 2026-10-27). Here rather than beside + /// WidgetFormat because the app compiles this file too. + static let isoCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar + }() + + static var today: String { iso(Date()) } + + static func iso(_ date: Date) -> String { + let parts = isoCalendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", parts.year ?? 1970, parts.month ?? 1, parts.day ?? 1) + } + + static func date(_ iso: String) -> Date? { + let parts = iso.split(separator: "-").compactMap { Int($0) } + guard parts.count >= 2 else { return nil } + return CalendarWidgetState.isoCalendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts.count > 2 ? parts[2] : 1)) + } + + /// The month label returns to the current month (`delta` 0), as in the panel. + static func shiftMonth(by delta: Int) { + let today = today + let state = load(today: today) + var month = String(today.prefix(7)) + if delta != 0, let first = date(state.month + "-01"), + let moved = CalendarWidgetState.isoCalendar.date(byAdding: .month, value: delta, to: first) { + month = String(iso(moved).prefix(7)) + } + save(month: month, selected: state.selected, today: today) + } + + static func select(_ day: String) { + let today = today + save(month: load(today: today).month, selected: day, today: today) + } + + /// Page the task list by days (or a week); the grid follows the day into + /// its month so the selection stays in view. + static func shiftSelection(by days: Int) { + let today = today + let state = load(today: today) + guard let current = date(state.selected), + let moved = CalendarWidgetState.isoCalendar.date(byAdding: .day, value: days, to: current) else { return } + let selected = iso(moved) + save(month: String(selected.prefix(7)), selected: selected, today: today) + } +} + +// The widget's buttons. They run in the extension (no app launch) and only +// move the widget's own state or log a tap; the timeline reloads after each. + +@available(iOS 17.0, *) +struct ShowCalendarMonthIntent: AppIntent { + static var title: LocalizedStringResource = "Show Another Month" + static var isDiscoverable: Bool = false + + @Parameter(title: "Months") var delta: Int + + init() {} + init(delta: Int) { self.delta = delta } + + func perform() async throws -> some IntentResult { + CalendarWidgetState.shiftMonth(by: delta) + return .result() + } +} + +@available(iOS 17.0, *) +struct SelectCalendarDayIntent: AppIntent { + static var title: LocalizedStringResource = "Show a Day's Tasks" + static var isDiscoverable: Bool = false + + @Parameter(title: "Day") var day: String + + init() {} + init(day: String) { self.day = day } + + func perform() async throws -> some IntentResult { + CalendarWidgetState.select(day) + return .result() + } +} + +@available(iOS 17.0, *) +struct ShiftCalendarDaysIntent: AppIntent { + static var title: LocalizedStringResource = "Show Other Days' Tasks" + static var isDiscoverable: Bool = false + + @Parameter(title: "Days") var days: Int + + init() {} + init(days: Int) { self.days = days } + + func perform() async throws -> some IntentResult { + CalendarWidgetState.shiftSelection(by: days) + return .result() + } +} + +/// A checkbox tap. The note is not touched here: the tap is logged for the +/// app (WidgetActionLog), which ticks the task through app-core next time it +/// opens; the reload after the tap draws the row ticked from that log. The +/// state it asks for is fixed when the row is drawn (the opposite of what the +/// row shows): a widget Toggle left a SetValueIntent's `value` unset, and an +/// intent that then asks for it fails without a word. +@available(iOS 17.0, *) +struct ToggleCalendarTaskIntent: AppIntent { + static var title: LocalizedStringResource = "Tick a Task" + static var isDiscoverable: Bool = false + + @Parameter(title: "Done") var checked: Bool + @Parameter(title: "Task") var taskID: String + @Parameter(title: "Note") var path: String + @Parameter(title: "Line") var line: Int + @Parameter(title: "Line text") var raw: String + @Parameter(title: "Ticked line text") var toggled: String + @Parameter(title: "Vault") var vaultKey: String + + init() {} + + init(checked: Bool, taskID: String, path: String, line: Int, raw: String, toggled: String, vaultKey: String) { + self.checked = checked + self.taskID = taskID + self.path = path + self.line = line + self.raw = raw + self.toggled = toggled + self.vaultKey = vaultKey + } + + func perform() async throws -> some IntentResult { + WidgetActionLog.append([ + "vaultKey": vaultKey, + "id": taskID, + "path": path, + "line": line, + "raw": raw, + "toggled": toggled, + "checked": checked, + "written": false + ]) + return .result() + } +} diff --git a/ios/App/ZenWidgets/WidgetActionLog.swift b/ios/App/ZenWidgets/WidgetActionLog.swift new file mode 100644 index 0000000..b607eb6 --- /dev/null +++ b/ios/App/ZenWidgets/WidgetActionLog.swift @@ -0,0 +1,103 @@ +import Foundation + +/// The Calendar widget's checkbox taps (src/bridge/widget-calendar.ts +/// `WidgetAction`), kept in the App Group until the app has applied them. +/// +/// The extension cannot reach the vault (on-device vaults live in the app's +/// Documents, iCloud vaults in the ubiquity container), so it never writes a +/// note: a tap is logged here and drawn ticked at once, and the app applies +/// it through app-core's own toggle the next time it runs. The log is what +/// keeps the box ticked until then; a snapshot whose `calendar.actionsAck` +/// covers a tap is what clears it, so a snapshot built before the app saw +/// the tap never undoes the tick on screen. +/// +/// Compiled into both the widget extension (it appends) and the app +/// (WidgetBridgePlugin reads and prunes); NSFileCoordinator keeps the two +/// processes from writing over each other. +enum WidgetActionLog { + static let appGroupId = "group.md.zennotes" + static let relativePath = "widgets/actions.json" + /// A tap the app never got to must not grow the log forever. + private static let maxActions = 200 + + static var url: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)? + .appendingPathComponent(relativePath) + } + + private static func decode(_ url: URL) -> [[String: Any]] { + guard let data = try? Data(contentsOf: url), + let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return [] } + return list + } + + private static func encode(_ list: [[String: Any]], to url: URL) { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + guard let data = try? JSONSerialization.data(withJSONObject: list) else { return } + try? data.write(to: url, options: .atomic) + } + + static func read() -> [[String: Any]] { + guard let url else { return [] } + var list: [[String: Any]] = [] + var error: NSError? + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &error) { list = decode($0) } + return list + } + + private static func update(_ change: (inout [[String: Any]]) -> Void) { + guard let url else { return } + var error: NSError? + NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { url in + var list = decode(url) + change(&list) + encode(list, to: url) + } + } + + /// The newest `at` ever handed out. Kept beside the log because the log + /// can be pruned empty: a clock set back after that would otherwise give + /// a new tap an `at` the app has already acked, and it would be pruned + /// unapplied. + private static let lastAtKey = "widgetActions.lastAt" + + /// Append a tap; its `at` (ms) is set here, above every earlier one. + static func append(_ action: [String: Any]) { + update { list in + let defaults = UserDefaults(suiteName: appGroupId) + var at = (Date().timeIntervalSince1970 * 1000).rounded() + let floor = max(list.last?["at"] as? Double ?? 0, defaults?.double(forKey: lastAtKey) ?? 0) + if at <= floor { at = floor + 1 } + defaults?.set(at, forKey: lastAtKey) + var entry = action + entry["at"] = at + list.append(entry) + if list.count > maxActions { list.removeFirst(list.count - maxActions) } + } + } + + /// Drop every tap the app has applied (`at <= ack`). + static func prune(through ack: Double) { + guard ack > 0 else { return } + update { list in list.removeAll { ($0["at"] as? Double ?? 0) <= ack } } + } + + /// The log as JSON, for WidgetBridgePlugin.readActions. + static func json() -> String { + guard let data = try? JSONSerialization.data(withJSONObject: read()) else { return "[]" } + return String(decoding: data, as: UTF8.self) + } + + /// Task id -> the state its latest tap in `vaultKey` asked for. + static func marks(vaultKey: String) -> [String: Bool] { + var out: [String: Bool] = [:] + for action in read() where action["vaultKey"] as? String == vaultKey { + if let id = action["id"] as? String, let checked = action["checked"] as? Bool { out[id] = checked } + } + return out + } +} diff --git a/ios/App/ZenWidgets/WidgetSnapshot.swift b/ios/App/ZenWidgets/WidgetSnapshot.swift index 4354651..af401d1 100644 --- a/ios/App/ZenWidgets/WidgetSnapshot.swift +++ b/ios/App/ZenWidgets/WidgetSnapshot.swift @@ -54,12 +54,61 @@ struct WidgetSnapshot: Decodable { let tasks: [WidgetTask] let taskCounts: WidgetTaskCounts? let tasksReady: Bool? + /// The Calendar widget's block (src/bridge/widget-calendar.ts), nil from + /// a shell that predates it. + let calendar: WidgetCalendarData? var generatedDate: Date { Date(timeIntervalSince1970: generatedAt / 1000) } var todayCount: Int { taskCounts?.today ?? tasks.count } var overdueCount: Int { taskCounts?.overdue ?? tasks.filter { $0.overdue }.count } } +/// A day's (or week's) note on the calendar grid: word-count dots and its +/// own open tasks (the panel's corner mark). +struct WidgetCalendarMark: Decodable { + let dots: Int + let faint: Bool + let openTasks: Int +} + +struct WidgetCalendarTask: Decodable, Identifiable { + let id: String + let path: String + let noteTitle: String + let content: String + let inProgress: Bool? + /// Written in that day's own daily note; the panel leaves its title off. + let inDailyNote: Bool? + let line: Int + let raw: String + let toggled: String? +} + +struct WidgetCalendarDay: Decodable { + let total: Int + let tasks: [WidgetCalendarTask] +} + +/// The Calendar sheet's grid marks and week agenda for the months around +/// the day the app published (app-core's getCalendarWidgetData). +struct WidgetCalendarData: Decodable { + let todayIso: String? + /// 0 = Sunday … 6 = Saturday. + let weekStart: Int? + let showWeekNumbers: Bool? + let dailyEnabled: Bool? + let weeklyEnabled: Bool? + let from: String + let to: String + let days: [String: WidgetCalendarMark]? + let weeks: [String: WidgetCalendarMark]? + let agenda: [String: WidgetCalendarDay]? + let vaultKey: String? + let actionsAck: Double? + + func covers(_ iso: String) -> Bool { iso >= from && iso <= to } +} + enum WidgetSnapshotStore { static let appGroupId = "group.md.zennotes" /// Mirrored by WidgetBridgePlugin.snapshotPath in the app. @@ -129,7 +178,39 @@ enum WidgetSnapshotStore { due: nil, overdue: false, inProgress: false, priority: nil) ], taskCounts: WidgetTaskCounts(today: 6, overdue: 1), - tasksReady: true + tasksReady: true, + calendar: sampleCalendar(now: now) + ) + } + + /// The Calendar widget's gallery preview: a written-in week around today. + static func sampleCalendar(now: Date) -> WidgetCalendarData { + let day = { (offset: Int) in WidgetFormat.isoDate(now.addingTimeInterval(Double(offset) * 86_400)) } + func task(_ id: String, _ content: String, note: String = "Daily", daily: Bool = true) -> WidgetCalendarTask { + WidgetCalendarTask(id: id, path: "inbox/\(note).md", noteTitle: note, content: content, + inProgress: false, inDailyNote: daily, line: 0, raw: "- [ ] \(content)", + toggled: "- [x] \(content)") + } + return WidgetCalendarData( + todayIso: day(0), weekStart: 1, showWeekNumbers: true, dailyEnabled: true, weeklyEnabled: false, + from: day(-45), to: day(45), + days: [ + day(-3): WidgetCalendarMark(dots: 2, faint: false, openTasks: 1), + day(-1): WidgetCalendarMark(dots: 1, faint: false, openTasks: 0), + day(0): WidgetCalendarMark(dots: 3, faint: false, openTasks: 2), + day(1): WidgetCalendarMark(dots: 1, faint: false, openTasks: 1) + ], + weeks: [:], + agenda: [ + day(0): WidgetCalendarDay(total: 3, tasks: [ + task("s#0", "Pick up the dry cleaning", note: "Errands", daily: false), + task("s#1", "Book the October flights"), + task("s#2", "Reply to the design review") + ]), + day(1): WidgetCalendarDay(total: 1, tasks: [task("s#3", "Dentist at 3")]), + day(2): WidgetCalendarDay(total: 1, tasks: [task("s#4", "Return library books", note: "Errands", daily: false)]) + ], + vaultKey: nil, actionsAck: nil ) } } diff --git a/ios/App/ZenWidgets/WidgetTheme.swift b/ios/App/ZenWidgets/WidgetTheme.swift index f33cd10..a3bb918 100644 --- a/ios/App/ZenWidgets/WidgetTheme.swift +++ b/ios/App/ZenWidgets/WidgetTheme.swift @@ -78,11 +78,9 @@ enum WidgetFormat { } /// Local calendar day as ISO YYYY-MM-DD — the form task `due` uses, so - /// overdue is a plain string comparison. - static func isoDate(_ date: Date) -> String { - let parts = Calendar.current.dateComponents([.year, .month, .day], from: date) - return String(format: "%04d-%02d-%02d", parts.year ?? 1970, parts.month ?? 1, parts.day ?? 1) - } + /// overdue is a plain string comparison. Gregorian whatever calendar the + /// user reads in (CalendarWidgetState.isoCalendar). + static func isoDate(_ date: Date) -> String { CalendarWidgetState.iso(date) } static func shortDate(_ date: Date) -> String { let formatter = DateFormatter() @@ -116,6 +114,22 @@ enum ZenLinks { URL(string: "zennotes://task?id=\(encode(id))&path=\(encode(path))") ?? open(path) } + /// The Calendar sheet on a day; `add` puts the cursor in "Add a task". + static func calendar(date: String?, add: Bool = false) -> URL { + guard let date else { return URL(string: "zennotes://calendar")! } + return URL(string: "zennotes://calendar?date=\(encode(date))\(add ? "&add=1" : "")") ?? home + } + + /// The day's daily note, or the Calendar on that day when it has none. + static func day(_ date: String) -> URL { + URL(string: "zennotes://day?date=\(encode(date))") ?? home + } + + /// The day's weekly note, or the Calendar on that day when it has none. + static func week(_ date: String) -> URL { + URL(string: "zennotes://week?date=\(encode(date))") ?? home + } + /// Only unreserved characters stay bare: `#` in task ids and `&` in /// titles would otherwise split the URL. private static let unreserved = CharacterSet( diff --git a/ios/App/ZenWidgets/ZenWidgetsBundle.swift b/ios/App/ZenWidgets/ZenWidgetsBundle.swift index 0753314..6021c53 100644 --- a/ios/App/ZenWidgets/ZenWidgetsBundle.swift +++ b/ios/App/ZenWidgets/ZenWidgetsBundle.swift @@ -2,7 +2,8 @@ import SwiftUI import WidgetKit /// The ZenNotes widget gallery: capture (New Note), pick up where you left -/// off (Recent Notes), and what's due (Today's Tasks). All three render from +/// off (Recent Notes), what's due (Today's Tasks), and the month with its +/// tasks to tick (Calendar). All three render from /// the snapshot the app publishes into the App Group (WidgetSnapshot.swift) /// — the extension never touches the vault — and every tap is a /// `zennotes://` link the shell resolves (src/ui-mobile/deep-links.ts). @@ -12,5 +13,6 @@ struct ZenWidgetsBundle: WidgetBundle { NewNoteWidget() RecentNotesWidget() TasksWidget() + CalendarWidget() } } diff --git a/package-lock.json b/package-lock.json index 7ffebcf..3e63056 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -3666,9 +3666,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.77.0-core.hfe38f5ee2a70bd64", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", - "integrity": "sha512-wZNZS/WkLLqi9DzWlKo5FYPl6Dpkk+C/viiiu7rhsryZARIrQodj5LDa9gTx3TUsIiDKFSg5XoP73ShmLuYsVA==", + "version": "2.78.0-core.h0bc32cbd73a78fac", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz", + "integrity": "sha512-Hq5qAJFFBJrJd67PxazwxgVPTZQ6K6bXkU9FoUILCywi4JcGR/hy60Klb5rClsO/Q+E8a/l6IbkE+T9TsUK4YA==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3698,8 +3698,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b", - "@zennotes/shared-domain": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "@zennotes/bridge-contract": "2.78.0-boundaries.h328ca592d70ad8f4", + "@zennotes/shared-domain": "2.78.0-boundaries.h328ca592d70ad8f4", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3709,7 +3709,7 @@ "jsxgraph": "^1.12.2", "katex": "^0.16.15", "mdast": "^3.0.0", - "pele": "1.6.0", + "pele": "1.12.0", "prettier": "^3.8.2", "rehype-highlight": "^7.0.1", "rehype-katex": "^7.0.1", @@ -3743,19 +3743,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", - "integrity": "sha512-wMp1ytIf4U1v4ngHlJ7CegQfQPC1OhyQQXoM4O+tPXmaTWYIUsPHVhgs+i7sptraWQJJZ636Ybn27jDQHTIQ6Q==", + "version": "2.78.0-boundaries.h328ca592d70ad8f4", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "integrity": "sha512-7Hm+frjf+iiQg9x0UXaZzZ0Wbbbg2aqbHb7W2iXGAaOEjvQXnxwzRv8vOGlPlf3KKsQvz8X0TRQ1TzGoOh/vAQ==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", - "integrity": "sha512-x52yLX/DGx2tNN+3F5TBakphOnLq5TtXIhFM5prS1pp49i6IacXRo+ZZ9wmLr54zm4rqL6x2mfSbd4Dk3+fbJw==", + "version": "2.78.0-boundaries.h328ca592d70ad8f4", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "integrity": "sha512-SlD18XyKoX0/68yPwZKUuitgsd3lzOLbsnC6vytvem/qfC3LdHDRWg4LaphxLO7h3bMChOLWUhd9MG36n5hldw==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "@zennotes/bridge-contract": "2.78.0-boundaries.h328ca592d70ad8f4", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } @@ -7610,9 +7610,9 @@ } }, "node_modules/pele": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/pele/-/pele-1.6.0.tgz", - "integrity": "sha512-VfAiVItsOGlGhvnBfphdT28VCG7mXMZxQNOwAGsCtxI2ztbUsT39MraEOYjsapLxVETkdUZscm0DdotYDQMLdw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pele/-/pele-1.12.0.tgz", + "integrity": "sha512-3OdOK6rvkaTB569R4/I8U0H82MqPRxj7vfkbowa2rRhgSBPFhvWlfFy5eae0jP+2A6hE8abE17nsdyxJz2VBiQ==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 7eb27bb..4e9174b 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index a9c32f7..023de79 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -423,6 +423,18 @@ function activeMobileVault(): MobileVault { return current } +/** The open vault's root as a native URI, for the Calendar widget's tick on + * Android (the iPhone widget cannot reach the vault and never asks); null + * for a remote workspace, whose notes are not on this device. */ +export function activeVaultNativeRoot(): string | null { + try { + const current = activeVault() + return current instanceof MobileVault ? current.fs.rootUri : null + } catch { + return null + } +} + /** The open vault as background sync sees it: ready when it is an on-device * vault linked to the signed-in account, the check app-core's automatic * sync makes before each run. */ diff --git a/src/bridge/widget-calendar.test.ts b/src/bridge/widget-calendar.test.ts new file mode 100644 index 0000000..e9c5b67 --- /dev/null +++ b/src/bridge/widget-calendar.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseWidgetActions, seenLine, type WidgetAction } from './widget-calendar.ts' + +const tick: WidgetAction = { + at: 2, + vaultKey: 'zn://vaults/My Vault', + id: 'inbox/Daily/2026-10-27.md#0', + path: 'inbox/Daily/2026-10-27.md', + line: 4, + raw: '- [ ] Buy Cougar Gold', + toggled: '- [x] Buy Cougar Gold', + checked: true, + written: true +} + +test('the log comes back oldest first, with written defaulting to false', () => { + const { written: _w, ...unwritten } = tick + const parsed = parseWidgetActions([tick, { ...unwritten, at: 1, checked: false }]) + assert.deepEqual(parsed.map((a) => [a.at, a.checked, a.written]), [ + [1, false, false], + [2, true, true] + ]) +}) + +test('anything malformed from the native side is dropped, not trusted', () => { + assert.deepEqual(parseWidgetActions(null), []) + assert.deepEqual(parseWidgetActions({ actions: [tick] }), []) + assert.deepEqual( + parseWidgetActions([ + { ...tick, id: '' }, + { ...tick, path: 42 }, + { ...tick, at: Number.NaN }, + { ...tick, checked: 'yes' }, + { ...tick, raw: undefined }, + 'tick', + null + ]), + [] + ) +}) + +test('core looks a task up by the line the widget showed before the tap', () => { + assert.equal(seenLine(tick), '- [ ] Buy Cougar Gold') + assert.equal(seenLine({ ...tick, checked: false }), '- [x] Buy Cougar Gold') +}) diff --git a/src/bridge/widget-calendar.ts b/src/bridge/widget-calendar.ts new file mode 100644 index 0000000..8a0fc0e --- /dev/null +++ b/src/bridge/widget-calendar.ts @@ -0,0 +1,92 @@ +/** + * The Calendar widget's part of the widget snapshot, and the checkbox taps + * it hands back. Same file, byte for byte, in both phone shells. + * + * App-core's `getCalendarWidgetData` builds the calendar (the Calendar + * sheet's grid marks and week agenda for the months around today); the + * shell adds where the vault lives and how far it has read the widget's + * taps. A tap never waits for the app: the native side logs it, draws the + * row ticked straight away and, on Android, writes the task's line into the + * note itself when that line is still exactly what the snapshot said + * (`raw` swapped for `toggled`, both computed by core, so no native code + * parses a task). The log stays until the app has applied it and published + * a snapshot whose `actionsAck` covers it; only then does the native side + * forget it, so a snapshot computed before the app saw a tap never undoes + * the tick on screen. + * + * Only types and pure helpers live here (node --test covers them); the + * wiring is in widgets.ts. + */ +import type { CalendarWidgetData } from '@zennotes/app-core/calendar' + +export interface WidgetCalendar extends CalendarWidgetData { + /** Which vault this is (its `zn://` root). A tap logged against another + * vault is dropped rather than applied to whatever note shares its path. */ + vaultKey: string + /** Android only: the open vault's root as a native URI (`file://` or a + * SAF `content://` tree) the widget writes a tick into. Null on iPhone, + * where the widget cannot reach the vault, and for a remote workspace. */ + vaultRoot: string | null + /** The newest tap (`at`) the app has applied. */ + actionsAck: number +} + +/** One checkbox tap, as the native widget logged it. */ +export interface WidgetAction { + /** ms since epoch; unique within the log (the native side bumps a tie). */ + at: number + vaultKey: string + /** VaultTask id (`${sourcePath}#${taskIndex}`). */ + id: string + path: string + /** 0-based line of the task in its file. */ + line: number + /** The task's line unticked, as the snapshot gave it. */ + raw: string + /** The same line ticked, as core's toggle writes it. */ + toggled: string + /** The state the tap asked for. */ + checked: boolean + /** The widget already wrote the line into the note (Android, when the + * line on disk still matched); the app only needs to rescan it. */ + written: boolean +} + +/** Native data is checked, not trusted: anything malformed is dropped. */ +export function parseWidgetActions(input: unknown): WidgetAction[] { + if (!Array.isArray(input)) return [] + const out: WidgetAction[] = [] + for (const item of input) { + if (!item || typeof item !== 'object') continue + const a = item as Record + if ( + typeof a.at !== 'number' || !Number.isFinite(a.at) || + typeof a.vaultKey !== 'string' || + typeof a.id !== 'string' || !a.id || + typeof a.path !== 'string' || !a.path || + typeof a.line !== 'number' || + typeof a.raw !== 'string' || + typeof a.toggled !== 'string' || + typeof a.checked !== 'boolean' + ) { + continue + } + out.push({ + at: a.at, + vaultKey: a.vaultKey, + id: a.id, + path: a.path, + line: a.line, + raw: a.raw, + toggled: a.toggled, + checked: a.checked, + written: a.written === true + }) + } + return out.sort((x, y) => x.at - y.at) +} + +/** The line the widget showed before the tap: what core looks the task up by. */ +export function seenLine(action: WidgetAction): string { + return action.checked ? action.raw : action.toggled +} diff --git a/src/bridge/widget-snapshot.ts b/src/bridge/widget-snapshot.ts index e3c720a..05ff217 100644 --- a/src/bridge/widget-snapshot.ts +++ b/src/bridge/widget-snapshot.ts @@ -15,6 +15,7 @@ */ import type { NoteMeta } from '@zennotes/bridge-contract/ipc' import type { VaultTask } from '@zennotes/shared-domain/tasks' +import type { WidgetCalendar } from './widget-calendar.ts' import { parseThemeBackdropColor } from './keyboard-backdrop-color.ts' export const WIDGET_SNAPSHOT_VERSION = 1 @@ -79,6 +80,9 @@ export interface WidgetSnapshot { /** False until the first task scan for this vault has landed, so the * widget shows "loading" rather than a misleading "All clear". */ tasksReady: boolean + /** The Calendar widget's month marks and week agenda (widget-calendar.ts); + * null while it could not be built. */ + calendar: WidgetCalendar | null } /** ZenNotes' default theme (dark-hard), used before the first publish and diff --git a/src/bridge/widgets.ts b/src/bridge/widgets.ts index 3ec8982..71f2c87 100644 --- a/src/bridge/widgets.ts +++ b/src/bridge/widgets.ts @@ -15,14 +15,20 @@ * `tasksSurfaceVisible`), which on the phone is rarely the case while * editing. Notes whose updatedAt moved get a per-note rescan; a vault switch * (or a big batch, e.g. iCloud landing many files) gets one full scan. + * + * The Calendar widget's checkbox taps come back through here too: the + * native side logs each one (widget-calendar.ts), this applies the log at + * launch and on every return to the foreground, and the next snapshot's + * `actionsAck` tells the widget which taps it may forget. */ import { App as CapApp } from '@capacitor/app' import { Capacitor, registerPlugin } from '@capacitor/core' import { getShellSnapshot, subscribeShell, type ShellSnapshot } from '@zennotes/app-core/shell' -import { getTasksSnapshot, subscribeTasks, refreshTasks, getTodayTasks, type TasksSnapshot } from '@zennotes/app-core/tasks' +import { getTasksSnapshot, subscribeTasks, refreshTasks, getTodayTasks, setTaskChecked, type TasksSnapshot } from '@zennotes/app-core/tasks' +import { getCalendarWidgetData } from '@zennotes/app-core/calendar' import { subscribeSettings } from '@zennotes/app-core/settings' import { toIsoDateLocal } from '@zennotes/shared-domain/tasks' -import { activeVaultStateKey, isMobileNoteIndexReady } from './mobile-bridge' +import { activeVaultNativeRoot, activeVaultStateKey, isMobileNoteIndexReady } from './mobile-bridge' import { getPinnedNotes, subscribePins } from '../ui-mobile/pins' import { WIDGET_SNAPSHOT_VERSION, @@ -32,6 +38,7 @@ import { type WidgetSnapshot, type WidgetTheme } from './widget-snapshot' +import { parseWidgetActions, seenLine, type WidgetCalendar } from './widget-calendar' interface ZenWidgetsPlugin { update(options: { snapshot: string }): Promise @@ -39,6 +46,10 @@ interface ZenWidgetsPlugin { /** The newest `zennotes://` link that launched or woke this process, once * (deep-links.ts). Null when the app was opened normally. */ consumeLaunchLink(): Promise<{ url: string | null }> + /** The Calendar widget's logged checkbox taps, oldest first. Reading + * leaves them in place; a snapshot whose `calendar.actionsAck` covers a + * tap is what clears it. */ + readActions(): Promise<{ actions: unknown }> } export const ZenWidgets = registerPlugin('ZenWidgets') @@ -56,12 +67,46 @@ let lastPayload = '' let taskVaultKey: string | null = null let tasksSettled = false let knownUpdatedAt = new Map() +let inFlight: Promise | null = null +let publishAgain = false +let actionsAck = 0 +let actionsWanted = true +let applyingActions = false +let actionsRetryTimer = 0 +let actionsRetries = 0 +/** A tap that cannot be applied yet (its note open with a buffer the + * foreground resync has yet to reload, a write that failed) is tried again + * after this, a few times, then on the next return to the foreground. */ +const ACTIONS_RETRY_MS = 4000 +const ACTIONS_MAX_RETRIES = 5 function themeMode(): WidgetTheme['mode'] { return document.documentElement.dataset.themeMode === 'light' ? 'light' : 'dark' } -function buildSnapshot(state: StoreState, now: Date): Omit { +/** The Calendar widget's block; null when core could not build it (a note + * read failed mid-switch), which the widget shows as "open the app". */ +async function buildCalendar(state: StoreState, now: Date): Promise { + const vaultKey = state.vault?.root + if (!vaultKey) return null + // Taken before the data is read: a tap applied while it is being built + // must not be acked by a snapshot that predates it, or the widget would + // forget the tap and draw the box empty again. + const ack = actionsAck + try { + return { + ...(await getCalendarWidgetData(now)), + vaultKey, + vaultRoot: Capacitor.getPlatform() === 'android' ? activeVaultNativeRoot() : null, + actionsAck: ack + } + } catch (err) { + console.error('calendar widget data failed', err) + return null + } +} + +async function buildSnapshot(state: StoreState, now: Date): Promise> { const style = getComputedStyle(document.documentElement) const today = getTodayTasks(now) const { tasks, counts } = selectWidgetTasks(today.tasks, today.overdueCount, toIsoDateLocal(now)) @@ -72,7 +117,8 @@ function buildSnapshot(state: StoreState, now: Date): Omit { if (!state.vault || !state.workspaceRestored) return let body: Omit try { - body = buildSnapshot(state, new Date()) + body = await buildSnapshot(state, new Date()) } catch (err) { console.error('widget snapshot failed', err) return } + // The vault switched while the calendar was read: that publish is stale. + if (getShellSnapshot().vault?.root !== state.vault.root) return const payload = JSON.stringify(body) if (payload === lastPayload) return lastPayload = payload @@ -96,12 +144,28 @@ async function publish(): Promise { await ZenWidgets.update({ snapshot: JSON.stringify(snapshot) }).catch(() => {}) } +/** One publish at a time: building the calendar awaits note reads, and two + * overlapping publishes could land out of order. */ +function publishNow(): void { + if (inFlight) { + publishAgain = true + return + } + inFlight = publish().finally(() => { + inFlight = null + if (publishAgain) { + publishAgain = false + publishNow() + } + }) +} + function schedule(): void { if (timer) return const wait = Math.max(PUBLISH_DEBOUNCE_MS, lastPublishedAt + PUBLISH_MIN_INTERVAL_MS - Date.now()) timer = window.setTimeout(() => { timer = 0 - void publish() + publishNow() }, wait) } @@ -109,7 +173,64 @@ function flush(): void { if (!timer) return window.clearTimeout(timer) timer = 0 - void publish() + publishNow() +} + +/** + * Apply the Calendar widget's logged taps once the vault is ready, in the + * order they were made, through core's own toggle, which finds the task + * again or leaves it alone when its line changed. A tap the widget already + * wrote goes through it too: it finds the line in the wanted state and + * only rescans, and a later tap on the same task still wins. A tap that + * cannot be applied yet stops the pass there, unacked, so nothing after it + * jumps ahead. A tap logged against another vault is dropped: the same path + * in this vault is a different note. + */ +async function applyWidgetActions(): Promise { + if (applyingActions || !actionsWanted) return + const state = getShellSnapshot() + const root = state.vault?.root + if (!root || !state.workspaceRestored || !isMobileNoteIndexReady()) return + applyingActions = true + actionsWanted = false + try { + const actions = parseWidgetActions((await ZenWidgets.readActions()).actions) + let ack = actionsAck + let blocked = false + for (const action of actions) { + if (action.at <= actionsAck) continue + if (action.vaultKey === root) { + const result = await setTaskChecked(action.id, seenLine(action), action.checked) + if (result === 'retry') { + blocked = true + break + } + if (result === 'stale') { + console.warn('[zen] a widget tick no longer matches its note; left alone:', action.path) + } + } + ack = Math.max(ack, action.at) + } + window.clearTimeout(actionsRetryTimer) + if (blocked && actionsRetries < ACTIONS_MAX_RETRIES) { + actionsRetries += 1 + actionsRetryTimer = window.setTimeout(() => { + actionsWanted = true + void applyWidgetActions() + }, ACTIONS_RETRY_MS) + } else if (!blocked) { + actionsRetries = 0 + } + if (ack > actionsAck) { + actionsAck = ack + lastPayload = '' + schedule() + } + } catch (err) { + console.error('widget actions failed', err) + } finally { + applyingActions = false + } } function reconcileTasks(state: StoreState, prev: StoreState | null, tasks: TasksSnapshot, previousTasks: TasksSnapshot | null): void { @@ -151,6 +272,7 @@ export function installWidgetPublisher(): () => void { // Store subscriptions can fire synchronously when refreshTasks starts. shell = nextShell; tasks = nextTasks reconcileTasks(nextShell, previousShell, nextTasks, previousTasks) + void applyWidgetActions() schedule() } const unsubShell = subscribeShell(changed) @@ -158,9 +280,16 @@ export function installWidgetPublisher(): () => void { const unsubSettings = subscribeSettings(schedule) const unsubPins = subscribePins(schedule) const appState = CapApp.addListener('appStateChange', ({ isActive }) => { - if (!isActive) flush() + if (!isActive) { + flush() + return + } + actionsWanted = true + actionsRetries = 0 + void applyWidgetActions() }) reconcileTasks(getShellSnapshot(), null, getTasksSnapshot(), null) + void applyWidgetActions() schedule() return () => { unsubShell(); unsubTasks(); unsubSettings() diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index d792b82..53d38db 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -28,6 +28,7 @@ import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' import { SwipeRow } from './SwipeRow' import { drawerFileRows, fileExtensionLabel, type DrawerFileRow } from './browse-files' +import { hiddenDrawerFolders } from './drawer-folders' import { getStoragePref, icloudStatus } from '../bridge/icloud' import { ICLOUD_VAULT_ROOT_PREFIX, @@ -840,13 +841,7 @@ export function MobileDrawer(): React.JSX.Element | null { const pins = usePins(pinKey) const { daily: dailyDir, weekly: weeklyDir, monthly: monthlyDir } = browse.dateDirectories const noteSortOrder = browse.noteSortOrder - const dateDirs = useMemo(() => { - const dirs = new Set() - if (dailyDir) dirs.add(dailyDir) - if (weeklyDir) dirs.add(weeklyDir) - if (monthlyDir) dirs.add(monthlyDir) - return dirs - }, [dailyDir, weeklyDir, monthlyDir]) + const hiddenFolders = useMemo(() => hiddenDrawerFolders(browse), [browse]) const [path, setPath] = useState('') // On each open, jump to the folder a breadcrumb tap requested ('' for a @@ -857,13 +852,19 @@ export function MobileDrawer(): React.JSX.Element | null { const { childFolders, childDatabases, childNotes, childFiles } = useMemo(() => { const rows = getBrowseDirectory(browse, path, pins) + // A note's breadcrumb can open the drawer inside a folder the list hides + // (`Journal` around `Journal/Daily`); there the periodic folder is shown, + // or the drawer would call a folder empty that is not. + const hiding = !hiddenFolders.has(path) return { - childFolders: rows.folders.map(row => [row.directory, row.title] as [string, string]), + childFolders: rows.folders + .filter(row => !hiding || !hiddenFolders.has(row.directory)) + .map(row => [row.directory, row.title] as [string, string]), childDatabases: rows.databases.map(row => [row.path, row.title, row.directory] as [string, string, string]), childNotes: [...rows.notes], childFiles: drawerFileRows(rows) } - }, [browse, path, pins]) + }, [browse, path, pins, hiddenFolders]) if (!open) return null @@ -888,7 +889,6 @@ export function MobileDrawer(): React.JSX.Element | null { dailyDir={dailyDir} weeklyDir={weeklyDir} monthlyDir={monthlyDir} - dateDirs={dateDirs} path={path} setPath={setPath} childFolders={childFolders} @@ -1060,7 +1060,6 @@ function MobileDrawerBody(props: { dailyDir: string | null weeklyDir: string | null monthlyDir: string | null - dateDirs: Set path: string setPath: (p: string) => void childFolders: Array<[string, string]> @@ -1079,7 +1078,6 @@ function MobileDrawerBody(props: { dailyDir, weeklyDir, monthlyDir, - dateDirs, path, setPath, childFolders, @@ -1296,7 +1294,7 @@ function MobileDrawerBody(props: { onClick={() => setPath(subpath)} {...lp(() => setFolderMenu({ kind: 'folder', subpath, name, host: captureMobileWorkspace() }))} > - + {name} {isPinned && ( diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 7b382b9..be03539 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import ReactDOM from 'react-dom/client' import { Haptics, ImpactStyle } from '@capacitor/haptics' import { Keyboard } from '@capacitor/keyboard' +import { Capacitor } from '@capacitor/core' import { getShellSnapshot, useShellSnapshot, subscribeShell, getAdjacentNotePath, getTagPresenceSnapshot, subscribeTagPresence } from '@zennotes/app-core/shell' import { getBrowseSnapshot, requestCreateBrowseFolder, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' @@ -18,6 +19,7 @@ import { getWorkspaceSnapshot, useWorkspaceSnapshot, subscribeWorkspace, readPer import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize, showSettingsPage } from '@zennotes/app-core/settings' import { DateCalendar } from '@zennotes/app-core/calendar' +import { CLOSE_CALENDAR_SHEET_EVENT, SHOW_CALENDAR_EVENT, requestCalendar, type CalendarRequest } from './calendar-request' import { useEditorPresentation, setEditorMode, hasEditorSelection, runEditorCommand } from '@zennotes/app-core/editor' import { getTasksSnapshot, moveTaskToColumn, type KanbanGroupBy } from '@zennotes/app-core/tasks' import { runAppCommand, showCommandPalette, showSearch, showTemplates, showOutline } from '@zennotes/app-core/commands' @@ -400,8 +402,6 @@ function CreateSheet({ onClose }: { onClose: () => void }): React.JSX.Element { ) } -const SHOW_CALENDAR_EVENT = 'zn:show-calendar' - /** * Open the Calendar. A phone has no room beside a note, and Home has no note * to sit beside, so there it is a sheet of its own (CalendarSheet). An iPad @@ -409,7 +409,7 @@ const SHOW_CALENDAR_EVENT = 'zn:show-calendar' * its ••• row offers it. */ function showCalendar(): void { - if (isPhoneWidth()) window.dispatchEvent(new Event(SHOW_CALENDAR_EVENT)) + if (isPhoneWidth()) requestCalendar() else window.dispatchEvent(new Event('zen:toggle-calendar')) } @@ -427,17 +427,43 @@ function showCalendar(): void { * With daily and weekly notes both off there is nothing for it to open, so * the row says how to turn daily notes on instead of disappearing. */ -function CalendarSheet({ onClose }: { onClose: () => void }): React.JSX.Element { +function CalendarSheet({ + onClose, + date, + focusAdd +}: { + onClose: () => void + /** The widget's day; null opens on the calendar's own choice. */ + date: string | null + focusAdd: boolean +}): React.JSX.Element { const { calendarAvailable } = useSettingsSnapshot() + // Android raises no keyboard for a focus nobody tapped, and the widget's + // "Add a task" asked for one. Core focuses the field on mount, but a link + // that just brought the app back can land that focus before the WebView + // has its window, so it is placed again right before the keyboard. + useEffect(() => { + if (!focusAdd || !calendarAvailable || Capacitor.getPlatform() !== 'android') return + const timer = window.setTimeout(() => { + document.querySelector('.zn-calendar-sheet [data-calendar-panel] form input')?.focus() + void Keyboard.show().catch(() => {}) + }, 300) + return () => window.clearTimeout(timer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) useEffect(() => { window.addEventListener('zen:close-right-panel', onClose) - return () => window.removeEventListener('zen:close-right-panel', onClose) + window.addEventListener(CLOSE_CALENDAR_SHEET_EVENT, onClose) + return () => { + window.removeEventListener('zen:close-right-panel', onClose) + window.removeEventListener(CLOSE_CALENDAR_SHEET_EVENT, onClose) + } }, [onClose]) if (calendarAvailable) { return (
- +
) } @@ -475,6 +501,9 @@ function MobileNav(): React.JSX.Element | null { const [createOpen, setCreateOpen] = useState(false) const [fabOpen, setFabOpen] = useState(false) const [calendarOpen, setCalendarOpen] = useState(false) + // Which day the sheet opens on; the key remounts it when a widget link + // asks for another day while it is already up. + const [calendarAt, setCalendarAt] = useState({ date: null as string | null, add: false, key: 0 }) const closeCalendar = useCallback(() => setCalendarOpen(false), []) // First-run coach mark: the unlabeled ensō reads as a loading spinner to // new users (real-user tested). Set by onboarding, cleared on first tap. @@ -487,7 +516,17 @@ function MobileNav(): React.JSX.Element | null { // the Calendar, which the ••• sheet opens too. useEffect(() => { const onCreate = (): void => setCreateOpen(true) - const onCalendar = (): void => setCalendarOpen(true) + const onCalendar = (e: Event): void => { + const request = (e as CustomEvent).detail + // A tablet keeps the panel beside the open note and has no sheet; + // the widget's day does not reach it there. + if (request && !isPhoneWidth()) { + if (!document.querySelector('[data-calendar-panel]')) window.dispatchEvent(new Event('zen:toggle-calendar')) + return + } + setCalendarAt((prev) => ({ date: request?.date ?? null, add: request?.add ?? false, key: prev.key + 1 })) + setCalendarOpen(true) + } window.addEventListener('zn:quick-create', onCreate) window.addEventListener(SHOW_CALENDAR_EVENT, onCalendar) return () => { @@ -618,7 +657,14 @@ function MobileNav(): React.JSX.Element | null { {sheetOpen && setSheetOpen(false)} />} {createOpen && setCreateOpen(false)} />} - {calendarOpen && } + {calendarOpen && ( + + )} ) } @@ -1526,6 +1572,29 @@ function useCalendarWeekMode(): void { const RIGHT_PANEL_SELECTOR = '[data-calendar-panel], [data-connections-panel], [data-comments-panel], .zn-app-shell section[class*="border-l"][class*="shrink-0"], .zn-app-shell aside[class*="border-l"][class*="shrink-0"], .zn-app-shell div[class*="border-l"][class*="shrink-0"][class*="flex-col"]' +/** The reference pane (a pinned note or PDF). It drew as a full-screen sheet + * like the other right panels, but it lives outside EditorPane and never + * hears `zen:close-right-panel`: Done did nothing over it (and raised the + * keyboard for the note underneath), and its header's back chevron left the + * note for Home, where reopening the note brought the sheet straight back + * (Dave, Discord, 2026-10-27, reported on Android; the same here). */ +const REFERENCE_PANE_SELECTOR = '[data-pane-id="pinned-ref"]' + +/** + * Close the top-most right panel, the way its own close control would. The + * reference pane renders after the editor panes, so it is the one on top; it + * hides through core's Hide Reference Pane (the pane's own square button), + * which keeps the pin, so the note's card brings it back with one tap. + */ +function closeRightPanel(): void { + const reference = document.querySelector(REFERENCE_PANE_SELECTOR) + if (reference && reference.style.display !== 'none') { + runCommand('ref.toggle') + return + } + window.dispatchEvent(new Event('zen:close-right-panel')) +} + function useRightPanelCloseButton(): void { useEffect(() => { if (!isPhoneWidth()) return @@ -1542,9 +1611,7 @@ function useRightPanelCloseButton(): void { button.type = 'button' button.className = 'zn-panel-close' button.textContent = 'Done' - button.addEventListener('click', () => { - window.dispatchEvent(new Event('zen:close-right-panel')) - }) + button.addEventListener('click', closeRightPanel) document.body.appendChild(button) } else if (!open && button) { button.remove() @@ -1828,13 +1895,15 @@ function useHeaderBackButton(): void { '.zn-app-shell header.glass-header' )) { if (header.querySelector('.zn-header-back')) continue + // The reference sheet's chevron goes back to the note under it. + const inReference = header.closest(REFERENCE_PANE_SELECTOR) !== null const btn = document.createElement('button') btn.type = 'button' btn.className = 'zn-header-back' - btn.setAttribute('aria-label', 'Go back') + btn.setAttribute('aria-label', inReference ? 'Back to the note' : 'Go back') btn.innerHTML = '' - btn.addEventListener('click', goBack) + btn.addEventListener('click', inReference ? closeRightPanel : goBack) header.insertAdjacentElement('afterbegin', btn) } } diff --git a/src/ui-mobile/calendar-request.ts b/src/ui-mobile/calendar-request.ts new file mode 100644 index 0000000..c6473a9 --- /dev/null +++ b/src/ui-mobile/calendar-request.ts @@ -0,0 +1,26 @@ +/** + * Asking MobileShell for the Calendar from outside it: the circle and the + * ••• sheet ask with no day, the home-screen widget's links with the day it + * showed (and `add` to put the cursor in "Add a task for ..."). An event, not + * an import, so deep-links.ts does not pull the shell's components in. + */ +export const SHOW_CALENDAR_EVENT = 'zn:show-calendar' + +export interface CalendarRequest { + /** `YYYY-MM-DD`, or null for the calendar's own choice (the open daily + * note's day, else today). */ + date: string | null + add: boolean +} + +export function requestCalendar(request?: CalendarRequest): void { + window.dispatchEvent(new CustomEvent(SHOW_CALENDAR_EVENT, { detail: request })) +} + +export const CLOSE_CALENDAR_SHEET_EVENT = 'zn:close-calendar-sheet' + +/** Put the phone's Calendar sheet away (a widget link is about to show + * something else). A tablet's panel beside the note is left as it is. */ +export function closeCalendarSheet(): void { + window.dispatchEvent(new Event(CLOSE_CALENDAR_SHEET_EVENT)) +} diff --git a/src/ui-mobile/deep-links.ts b/src/ui-mobile/deep-links.ts index c283018..cda751f 100644 --- a/src/ui-mobile/deep-links.ts +++ b/src/ui-mobile/deep-links.ts @@ -27,6 +27,7 @@ import { getShellSnapshot, subscribeShell, type ShellSnapshot } from '@zennotes/ import { openNote, openAppPage } from '@zennotes/app-core/navigation' import { getTasksSnapshot, refreshTasks, openTask } from '@zennotes/app-core/tasks' import { runAppCommand } from '@zennotes/app-core/commands' +import { openDateNote } from '@zennotes/app-core/calendar' import { captureMobileWorkspace } from './workspace-context' import { isMobileNoteIndexReady } from '../bridge/mobile-bridge' import { ZenWidgets } from '../bridge/widgets' @@ -35,6 +36,7 @@ import { goHome } from './nav' import { closeNoteMenu } from './note-actions' import { closeMobileSheet } from './sheet-state' import { parseWidgetLink, type WidgetLink } from './widget-links' +import { closeCalendarSheet, requestCalendar } from './calendar-request' const DUPLICATE_WINDOW_MS = 5000 const LANDING_SETTLE_MS = 80 @@ -117,6 +119,10 @@ async function run(link: WidgetLink): Promise { closeMobileSheet() closeNoteMenu() setDrawerOpen(false) + // The Calendar sheet too; a calendar link opens it again on its own day. + // Its own event, not core's right-panel one, which would also shut a + // tablet's outline or comments panel. + closeCalendarSheet() const s = getShellSnapshot() switch (link.kind) { case 'new': @@ -136,6 +142,17 @@ async function run(link: WidgetLink): Promise { case 'home': goHome() return + case 'calendar': + requestCalendar({ date: link.date, add: link.add }) + return + case 'day': + case 'week': + // A day with no note opens the Calendar on it, where "Day +" asks + // before creating one, as tapping that day in the app would. + if (!(await openDateNote(link.kind === 'day' ? 'daily' : 'weekly', link.date))) { + requestCalendar({ date: link.date, add: false }) + } + return } } diff --git a/src/ui-mobile/drawer-folders.test.ts b/src/ui-mobile/drawer-folders.test.ts new file mode 100644 index 0000000..2e87a2a --- /dev/null +++ b/src/ui-mobile/drawer-folders.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { hiddenDrawerFolders, type DrawerFolderSource } from './drawer-folders.ts' + +const dates = { daily: 'Daily Notes', weekly: 'Weekly Notes', monthly: 'Monthly Notes' } +const off = { daily: null, weekly: null, monthly: null } +const folder = (directory: string) => ({ directory }) +const note = (directory: string, noteFolder = 'inbox') => ({ folder: noteFolder, directory }) + +function browse(source: Partial): DrawerFolderSource { + return { folders: [], databases: [], notes: [], files: [], dateDirectories: dates, ...source } +} + +function hidden(source: Partial): string[] { + return [...hiddenDrawerFolders(browse(source))].sort() +} + +test('the folders the Daily, Weekly and Monthly rows open are not listed again under Notes', () => { + const source = { + folders: ['Daily Notes', 'Weekly Notes', 'Monthly Notes', 'Projects'].map(folder), + notes: [note('Daily Notes'), note('Weekly Notes'), note('Monthly Notes'), note('Projects')] + } + assert.deepEqual(hidden(source), ['Daily Notes', 'Monthly Notes', 'Weekly Notes']) +}) + +test('with periodic notes off, every folder is listed', () => { + assert.equal(hiddenDrawerFolders(browse({ folders: [folder('Daily Notes')], dateDirectories: off })).size, 0) +}) + +test('only the kinds that are on have rows of their own, so only those folders leave the list', () => { + const source = { + folders: ['Daily Notes', 'Weekly Notes'].map(folder), + dateDirectories: { ...off, daily: 'Daily Notes' } + } + assert.deepEqual(hidden(source), ['Daily Notes']) +}) + +test('a periodic folder leaves the list even when it also holds other notes, which its row still opens', () => { + const source = { folders: [folder('Daily Notes')], notes: [note('Daily Notes'), note('Daily Notes')] } + assert.deepEqual(hidden(source), ['Daily Notes', 'Monthly Notes', 'Weekly Notes']) +}) + +test('a folder holding only periodic folders goes too, rather than opening onto nothing', () => { + const source = { + folders: ['Journal', 'Journal/Daily', 'Journal/Weekly'].map(folder), + notes: [note('Journal/Daily'), note('Journal/Weekly')], + dateDirectories: { ...off, daily: 'Journal/Daily', weekly: 'Journal/Weekly' } + } + assert.deepEqual(hidden(source), ['Journal', 'Journal/Daily', 'Journal/Weekly']) +}) + +test('a folder around a periodic folder keeps its row when it holds anything else', () => { + const periodic = { ...off, daily: 'Journal/Daily' } + const spine = ['Journal', 'Journal/Daily'].map(folder) + assert.deepEqual(hidden({ folders: spine, notes: [note('Journal')], dateDirectories: periodic }), ['Journal/Daily']) + assert.deepEqual( + hidden({ folders: [...spine, folder('Journal/Trips')], dateDirectories: periodic }), + ['Journal/Daily'] + ) + assert.deepEqual( + hidden({ folders: spine, files: [folder('Journal')], dateDirectories: periodic }), + ['Journal/Daily'] + ) + assert.deepEqual( + hidden({ folders: spine, databases: [folder('Journal/Reading.base')], dateDirectories: periodic }), + ['Journal/Daily'] + ) +}) + +test('content of the same name outside the notes area does not keep a folder around', () => { + const source = { + folders: ['Journal', 'Journal/Daily'].map(folder), + notes: [note('Journal', 'archive'), note('Journal/Daily')], + dateDirectories: { ...off, daily: 'Journal/Daily' } + } + assert.deepEqual(hidden(source), ['Journal', 'Journal/Daily']) +}) + +test('a folder whose name merely starts like a periodic folder is not part of it', () => { + const source = { + folders: ['Journal', 'Journal/Daily', 'Journal/Daily Archive'].map(folder), + dateDirectories: { ...off, daily: 'Journal/Daily' } + } + assert.deepEqual(hidden(source), ['Journal/Daily']) +}) diff --git a/src/ui-mobile/drawer-folders.ts b/src/ui-mobile/drawer-folders.ts new file mode 100644 index 0000000..95818a9 --- /dev/null +++ b/src/ui-mobile/drawer-folders.ts @@ -0,0 +1,61 @@ +/** + * The folders the drawer's Notes list leaves out. The Daily, Weekly and + * Monthly Notes rows above it open those folders directly, so listing them + * again under Notes showed every periodic note twice: two rows, one folder + * (uNyanda, Discord 2026-10-27, after the layout fix for + * ZenNotes/zennotesandroid#17 had shipped). The desktop sidebar draws them + * once, too: periodic notes get their own Quick Access rows and the folders + * that only held them leave its tree. + * + * A folder that holds nothing but periodic folders (`Journal` around + * `Journal/Daily`) goes as well, so the list never offers a folder that opens + * onto nothing. One holding anything else, a note, a file, a database or a + * folder of its own, keeps its row. Nothing is lost by hiding: everything + * inside a periodic folder, including notes that are not periodic, is one tap + * away through its row. + */ +import { dirOf } from './note-order.ts' + +export interface DrawerFolderSource { + readonly folders: readonly { readonly directory: string }[] + readonly databases: readonly { readonly directory: string }[] + readonly notes: readonly { readonly folder: string; readonly directory: string }[] + readonly files?: readonly { readonly directory: string }[] + readonly dateDirectories: { + readonly daily: string | null + readonly weekly: string | null + readonly monthly: string | null + } +} + +const NONE: ReadonlySet = new Set() + +export function hiddenDrawerFolders(browse: DrawerFolderSource): ReadonlySet { + const { daily, weekly, monthly } = browse.dateDirectories + const periodic = [daily, weekly, monthly].filter((dir): dir is string => !!dir) + if (periodic.length === 0) return NONE + const within = (path: string, dir: string): boolean => path === dir || path.startsWith(`${dir}/`) + const inPeriodic = (path: string): boolean => periodic.some(dir => within(path, dir)) + + const spine = new Set() + for (const dir of periodic) { + for (let parent = dirOf(dir); parent; parent = dirOf(parent)) spine.add(parent) + } + const kept = new Set() + const keepAround = (path: string): void => { + if (inPeriodic(path)) return + for (const dir of spine) if (within(path, dir)) kept.add(dir) + } + if (spine.size > 0) { + for (const note of browse.notes) if (note.folder === 'inbox') keepAround(note.directory) + for (const file of browse.files ?? []) keepAround(file.directory) + for (const database of browse.databases) keepAround(database.directory) + for (const folder of browse.folders) { + if (!spine.has(folder.directory)) keepAround(folder.directory) + } + } + + const hidden = new Set(periodic) + for (const dir of spine) if (!kept.has(dir)) hidden.add(dir) + return hidden +} diff --git a/src/ui-mobile/widget-links.test.ts b/src/ui-mobile/widget-links.test.ts index 0c4e2d3..8afae2b 100644 --- a/src/ui-mobile/widget-links.test.ts +++ b/src/ui-mobile/widget-links.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { isSafeVaultPath, parseWidgetLink } from './widget-links.ts' +import { isIsoDay, isSafeVaultPath, parseWidgetLink } from './widget-links.ts' test('the action links parse by host', () => { assert.deepEqual(parseWidgetLink('zennotes://new'), { kind: 'new' }) @@ -57,3 +57,23 @@ test('other schemes, unknown actions, and the Cloud auth callback are not ours', assert.equal(parseWidgetLink('not a url'), null) assert.equal(parseWidgetLink(''), null) }) + +test('calendar opens on a real day, or on its own choice', () => { + assert.deepEqual(parseWidgetLink('zennotes://calendar'), { kind: 'calendar', date: null, add: false }) + assert.deepEqual(parseWidgetLink('zennotes://calendar?date=2026-10-27&add=1'), { + kind: 'calendar', + date: '2026-10-27', + add: true + }) + assert.deepEqual(parseWidgetLink('zennotes://calendar?date=2026-02-30'), { kind: 'calendar', date: null, add: false }) +}) + +test('day and week need a real day', () => { + assert.deepEqual(parseWidgetLink('zennotes://day?date=2026-10-27'), { kind: 'day', date: '2026-10-27' }) + assert.deepEqual(parseWidgetLink('zennotes://week?date=2026-10-26'), { kind: 'week', date: '2026-10-26' }) + assert.equal(parseWidgetLink('zennotes://day'), null) + assert.equal(parseWidgetLink('zennotes://day?date=27-10-2026'), null) + assert.equal(parseWidgetLink('zennotes://week?date=2026-13-01'), null) + assert.equal(isIsoDay('2028-02-29'), true) + assert.equal(isIsoDay('2026-02-29'), false) +}) diff --git a/src/ui-mobile/widget-links.ts b/src/ui-mobile/widget-links.ts index f0701b7..11bfa9b 100644 --- a/src/ui-mobile/widget-links.ts +++ b/src/ui-mobile/widget-links.ts @@ -7,6 +7,13 @@ * zennotes://task?id=&path= jump to a task line (VaultTask id) * zennotes://tasks the Tasks view * zennotes://home the Home dashboard + * zennotes://calendar[?date=&add=1] + * the Calendar sheet, on a day; add=1 puts + * the cursor in "Add a task for ..." + * zennotes://day?date= that day's daily note, else the Calendar on it + * zennotes://week?date= that day's weekly note, else the Calendar on it + * + * A is YYYY-MM-DD, a real calendar date. * * Values are percent-encoded with only unreserved characters left bare, so * `#` (task ids are `path#index`) and `&` in titles survive the trip. The @@ -19,6 +26,9 @@ export type WidgetLink = | { kind: 'task'; id: string; path: string } | { kind: 'tasks' } | { kind: 'home' } + | { kind: 'calendar'; date: string | null; add: boolean } + | { kind: 'day'; date: string } + | { kind: 'week'; date: string } /** Vault-relative, forward-slash, no traversal — the same shape the bridge's * `resolveSafeRel` accepts, checked here so a bad link never throws. */ @@ -28,6 +38,14 @@ export function isSafeVaultPath(path: string): boolean { return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') } +/** `YYYY-MM-DD` naming a day that exists (no 2026-02-30). */ +export function isIsoDay(value: string | null): value is string { + if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const [y, m, d] = value.split('-').map(Number) + const date = new Date(y, m - 1, d) + return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d +} + export function parseWidgetLink(raw: string): WidgetLink | null { let url: URL try { @@ -55,6 +73,19 @@ export function parseWidgetLink(raw: string): WidgetLink | null { if (!id || !isSafeVaultPath(path)) return null return { kind: 'task', id, path } } + case 'calendar': { + const date = url.searchParams.get('date') + return { + kind: 'calendar', + date: isIsoDay(date) ? date : null, + add: url.searchParams.get('add') === '1' + } + } + case 'day': + case 'week': { + const date = url.searchParams.get('date') + return isIsoDay(date) ? { kind: action, date } : null + } default: return null } diff --git a/tooling/add-widget-extension.rb b/tooling/add-widget-extension.rb index 099f359..097c018 100644 --- a/tooling/add-widget-extension.rb +++ b/tooling/add-widget-extension.rb @@ -27,6 +27,16 @@ NewNoteWidget.swift RecentNotesWidget.swift TasksWidget.swift + CalendarWidget.swift + CalendarWidgetIntents.swift + WidgetActionLog.swift +].freeze +# Extension sources the app compiles too: the Calendar widget's tap log +# (WidgetBridgePlugin reads and prunes it) and its App Intents, which Apple +# asks to be in the app target as well as the extension. +SHARED_SOURCES = %w[ + CalendarWidgetIntents.swift + WidgetActionLog.swift ].freeze # --- App target: the publisher plugin -------------------------------------- @@ -90,5 +100,20 @@ puts "embedded #{TARGET_NAME} into App" end +# Sources added after the target was created (the block above only runs once). +ext_group = project.main_group[TARGET_NAME] +SOURCES.each do |name| + ref = ext_group.files.find { |f| f.display_name == name } || ext_group.new_reference(name) + next if ext_target.source_build_phase.files_references.include?(ref) + ext_target.add_file_references([ref]) + puts "added #{name} to #{TARGET_NAME} target" +end +SHARED_SOURCES.each do |name| + ref = ext_group.files.find { |f| f.display_name == name } + next if ref.nil? || app_target.source_build_phase.files_references.include?(ref) + app_target.add_file_references([ref]) + puts "added #{name} to App target" +end + project.save puts 'project saved' diff --git a/vendor/zennotes/manifest.json b/vendor/zennotes/manifest.json index bd98661..7b316ba 100644 --- a/vendor/zennotes/manifest.json +++ b/vendor/zennotes/manifest.json @@ -1,12 +1,12 @@ { "name": "@zennotes/app-core", - "version": "2.77.0-core.hfe38f5ee2a70bd64", - "file": "zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", - "sha256": "1103891fbbe1fc446cc1291af54116e5252e573c705f3766cdb7723d919f68f2", - "integrity": "sha512-wZNZS/WkLLqi9DzWlKo5FYPl6Dpkk+C/viiiu7rhsryZARIrQodj5LDa9gTx3TUsIiDKFSg5XoP73ShmLuYsVA==", - "sourceCommit": "3f0356de0d9351d9414ffd103cfd5d919c122cc3", + "version": "2.78.0-core.h0bc32cbd73a78fac", + "file": "zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz", + "sha256": "10949ac8983d4ac2947337392801bf91fc7f402c70afe066f9ccfba47ea17603", + "integrity": "sha512-Hq5qAJFFBJrJd67PxazwxgVPTZQ6K6bXkU9FoUILCywi4JcGR/hy60Klb5rClsO/Q+E8a/l6IbkE+T9TsUK4YA==", + "sourceCommit": "52ab86fdff9c4bb86ed3fb692b86a164e15fbbcf", "workingTreeDirty": false, - "sourceLockSha256": "359935dfa6dcf79d4f921e2026ed7368f7e389335f94dd4006c2ab12cbca3ea5", + "sourceLockSha256": "620d41fea1c82b1cac88a3ae73128ddf7fa04cac727d834c140be8f9a4b20997", "toolchain": { "node": "v22.23.3", "typescript": "5.9.3", @@ -15,20 +15,20 @@ "dependencies": [ { "name": "@zennotes/bridge-contract", - "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", - "file": "zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", - "sha256": "609267af04adaf76d7f7334d93153b03d817c8af3c1c80708f89a4d475af94a0", - "integrity": "sha512-wMp1ytIf4U1v4ngHlJ7CegQfQPC1OhyQQXoM4O+tPXmaTWYIUsPHVhgs+i7sptraWQJJZ636Ybn27jDQHTIQ6Q==", - "sourceCommit": "3f0356de0d9351d9414ffd103cfd5d919c122cc3", + "version": "2.78.0-boundaries.h328ca592d70ad8f4", + "file": "zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "sha256": "30fd5633301728ada9dbbec60e69012eb93bc6696f7988503eb92fc237388a6f", + "integrity": "sha512-7Hm+frjf+iiQg9x0UXaZzZ0Wbbbg2aqbHb7W2iXGAaOEjvQXnxwzRv8vOGlPlf3KKsQvz8X0TRQ1TzGoOh/vAQ==", + "sourceCommit": "52ab86fdff9c4bb86ed3fb692b86a164e15fbbcf", "workingTreeDirty": false }, { "name": "@zennotes/shared-domain", - "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", - "file": "zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", - "sha256": "ef6be959cf0dfd3e309591bc7b9e1c453dd5851bf48f28c749f2d99da34ea025", - "integrity": "sha512-x52yLX/DGx2tNN+3F5TBakphOnLq5TtXIhFM5prS1pp49i6IacXRo+ZZ9wmLr54zm4rqL6x2mfSbd4Dk3+fbJw==", - "sourceCommit": "3f0356de0d9351d9414ffd103cfd5d919c122cc3", + "version": "2.78.0-boundaries.h328ca592d70ad8f4", + "file": "zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "sha256": "2183aa36fc2d02825a7ead96de41fa40c9f09bd7be6369081307f00cd63b5132", + "integrity": "sha512-SlD18XyKoX0/68yPwZKUuitgsd3lzOLbsnC6vytvem/qfC3LdHDRWg4LaphxLO7h3bMChOLWUhd9MG36n5hldw==", + "sourceCommit": "52ab86fdff9c4bb86ed3fb692b86a164e15fbbcf", "workingTreeDirty": false } ] diff --git a/vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz b/vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz deleted file mode 100644 index e69ec2d..0000000 Binary files a/vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz b/vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz new file mode 100644 index 0000000..fa60a0b Binary files /dev/null and b/vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz b/vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz deleted file mode 100644 index b4ca39e..0000000 Binary files a/vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz b/vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz new file mode 100644 index 0000000..c9d192e Binary files /dev/null and b/vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz b/vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz deleted file mode 100644 index ac955ab..0000000 Binary files a/vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz and /dev/null differ diff --git a/vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz b/vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz new file mode 100644 index 0000000..1d61e34 Binary files /dev/null and b/vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz differ