From e88f9c25b07de9df576e29a1b451c853b65bb1b9 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 8 Sep 2026 18:43:29 -0500 Subject: [PATCH 01/20] feat(mobile): Home Screen and Lock Screen widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three WidgetKit widgets, zero configuration: New Note (small, plus the Lock Screen circular, rectangular and inline families on iOS 16+), Recent Notes and Today's Tasks (medium and large). They wear the app's theme, and every tap is a zennotes:// link: a fresh Inbox note with the title focused, the tapped note, the tapped task's line, or the Tasks view. A widget extension cannot see the vault, so the shell publishes a snapshot — pinned + recent notes, the Home dashboard's Today bucket (overdue first), the --z-* theme colors, no bodies — into the App Group through the new WidgetBridgePlugin. widgets.ts is the publisher, throttled to one reload per 8 s while editing and flushed on backgrounding; widget-snapshot.ts is the contract and WidgetSnapshot.swift its mirror. Task freshness on the phone is the publisher's job: app-core rescans a note's tasks only while a tasks surface is visible, so it diffs updatedAt and rescans changed notes itself. deep-links.ts runs the links after the workspace restores so the shell's cold-launch landing runs first and the link wins; while booting the newest link wins, read from ZenWidgets.consumeLaunchLink (the Android shell needs it because Capacitor's getLaunchUrl is stale for a recreated activity; kept identical here so the file stays shared). mobile-cloud-auth.ts now only takes zennotes://auth. The ZenWidgets target (md.zennotes.ZenWidgets) is wired by tooling/add-widget-extension.rb, mirroring the share extension script; the deployment target stays 15.0 with the 16+ and 17+ APIs gated. Verified on the iPhone 17 Pro simulator: gallery previews, all three placed, warm and cold taps landing on the right note and task line. --- ios/App/App.xcodeproj/project.pbxproj | 172 ++++++++++++- ios/App/App/AppDelegate.swift | 3 + ios/App/App/WidgetBridgePlugin.swift | 83 +++++++ ios/App/App/ZNViewController.swift | 1 + .../ZenWidgets/Assets.xcassets/Contents.json | 6 + .../Enso.imageset/Contents.json | 13 + .../Assets.xcassets/Enso.imageset/enso.png | Bin 0 -> 51111 bytes ios/App/ZenWidgets/Info.plist | 29 +++ ios/App/ZenWidgets/NewNoteWidget.swift | 148 ++++++++++++ ios/App/ZenWidgets/PrivacyInfo.xcprivacy | 17 ++ ios/App/ZenWidgets/RecentNotesWidget.swift | 145 +++++++++++ ios/App/ZenWidgets/TasksWidget.swift | 175 ++++++++++++++ ios/App/ZenWidgets/WidgetSnapshot.swift | 135 +++++++++++ ios/App/ZenWidgets/WidgetTheme.swift | 155 ++++++++++++ ios/App/ZenWidgets/ZenWidgets.entitlements | 10 + ios/App/ZenWidgets/ZenWidgetsBundle.swift | 16 ++ src/bridge/mobile-cloud-auth.ts | 20 +- src/bridge/widget-snapshot.test.ts | 147 ++++++++++++ src/bridge/widget-snapshot.ts | 226 ++++++++++++++++++ src/bridge/widgets.ts | 190 +++++++++++++++ src/main.tsx | 6 + src/ui-mobile/deep-links.ts | 151 ++++++++++++ src/ui-mobile/pins.ts | 8 + src/ui-mobile/widget-links.test.ts | 59 +++++ src/ui-mobile/widget-links.ts | 61 +++++ tooling/add-privacy-manifests.rb | 3 +- tooling/add-widget-extension.rb | 94 ++++++++ 27 files changed, 2069 insertions(+), 4 deletions(-) create mode 100644 ios/App/App/WidgetBridgePlugin.swift create mode 100644 ios/App/ZenWidgets/Assets.xcassets/Contents.json create mode 100644 ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/Contents.json create mode 100644 ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/enso.png create mode 100644 ios/App/ZenWidgets/Info.plist create mode 100644 ios/App/ZenWidgets/NewNoteWidget.swift create mode 100644 ios/App/ZenWidgets/PrivacyInfo.xcprivacy create mode 100644 ios/App/ZenWidgets/RecentNotesWidget.swift create mode 100644 ios/App/ZenWidgets/TasksWidget.swift create mode 100644 ios/App/ZenWidgets/WidgetSnapshot.swift create mode 100644 ios/App/ZenWidgets/WidgetTheme.swift create mode 100644 ios/App/ZenWidgets/ZenWidgets.entitlements create mode 100644 ios/App/ZenWidgets/ZenWidgetsBundle.swift create mode 100644 src/bridge/widget-snapshot.test.ts create mode 100644 src/bridge/widget-snapshot.ts create mode 100644 src/bridge/widgets.ts create mode 100644 src/ui-mobile/deep-links.ts create mode 100644 src/ui-mobile/widget-links.test.ts create mode 100644 src/ui-mobile/widget-links.ts create mode 100644 tooling/add-widget-extension.rb diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index f6b48e8..cd5aecf 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -7,28 +7,48 @@ objects = { /* Begin PBXBuildFile section */ + 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 */; }; 283DF465CD667CDB34C0DE25 /* CloudFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5CCEA5991955FBC0360 /* CloudFlowUITests.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 3322BE59AC62263A587F4D6B /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85847153B31EF561562A9281 /* SwiftUI.framework */; }; + 4444E1477FBE380A04E0BFE0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */; }; 46516E19362E75EC2801386B /* ShareInboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80582C35DDDDF14031E4EA94 /* ShareInboxPlugin.swift */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; - FB56F003004840A2A553E931 /* KeyboardBackdropPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F5A2D64394F429AF0ED50 /* KeyboardBackdropPlugin.swift */; }; 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 5CA8650C3B7DF616A8B69229 /* ZNViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24C226FEA7EA30D28C20477 /* ZNViewController.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 */; }; + 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 */; }; 9B5224B5172D08349CEDEFFC /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018C9D85A9EF4E14FC95804A /* ShareViewController.swift */; }; + 9F314931CA3D31AAFCF26E15 /* TasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA702ECFEE20C3DACF6336FB /* TasksWidget.swift */; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + A4CABD8562655E4574EA3944 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB10756BA75711FC150E34FD /* Assets.xcassets */; }; B1F519EA73BD94F20A3F1DD1 /* ICloudVaultPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA3D1BD7D10EE9B188D5FBF /* ICloudVaultPlugin.swift */; }; + B516881658A9D3C9AFC1259E /* RecentNotesWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BE1DA68F6223E14C0761AA /* RecentNotesWidget.swift */; }; BA7E23B65E109265261E0F5E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 155E04D763AD80DA2E56D38D /* Foundation.framework */; }; D17E4E8551A97CF08FDE5F82 /* FolderPickerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A91962841901607F2906263 /* FolderPickerPlugin.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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 604D2CAAF799146B5F1A1C7F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = A0081C14C7FAA80567CB18EC; + remoteInfo = ZenWidgets; + }; 7042DBA548F62B8A89076836 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 504EC2FC1FED79650016851F /* Project object */; @@ -53,6 +73,7 @@ dstSubfolderSpec = 13; files = ( DE19FCF1249C6A4D528227C2 /* ShareExtension.appex in Embed Foundation Extensions */, + 1620DB6173A8CFE3D41D46DA /* ZenWidgets.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -64,7 +85,11 @@ 10A78CB5D81347F3CBBC9579 /* AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 155E04D763AD80DA2E56D38D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 3175A5673A3065A7A684CC9D /* WidgetTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetTheme.swift; sourceTree = ""; }; + 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 = ""; }; + 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; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -74,15 +99,26 @@ 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 56C2868F946FC6AE7F918E2A /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 7B09FFFEE67FC0E73BE167CD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 80582C35DDDDF14031E4EA94 /* ShareInboxPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInboxPlugin.swift; sourceTree = ""; }; + 81BE1DA68F6223E14C0761AA /* RecentNotesWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecentNotesWidget.swift; sourceTree = ""; }; + 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 = ""; }; + 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; A24C226FEA7EA30D28C20477 /* ZNViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ZNViewController.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 = ""; }; + 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 = ""; }; D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; D956FF4D03CEB97B048B0860 /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + DB10756BA75711FC150E34FD /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E1424FEA5946506EA505941C /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + E826831F57D7B9D9D5121461 /* ZenWidgets.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ZenWidgets.entitlements; sourceTree = ""; }; + EA702ECFEE20C3DACF6336FB /* TasksWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TasksWidget.swift; sourceTree = ""; }; + F342B601563BB02825474641 /* ZenWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ZenWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7650E04BDA8754AB2119967 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; FAA3D1BD7D10EE9B188D5FBF /* ICloudVaultPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ICloudVaultPlugin.swift; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; @@ -97,6 +133,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 75067D2C3F9B2DE5E3FA6FE6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4444E1477FBE380A04E0BFE0 /* Foundation.framework in Frameworks */, + 80DB78E0507751992FCE4245 /* WidgetKit.framework in Frameworks */, + 3322BE59AC62263A587F4D6B /* SwiftUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7D23A80D4913A33A86B77D59 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -133,6 +179,7 @@ 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, A9B8728D56A50AB5408DE0FB /* ShareExtension */, BBA22C854FA25C0FEB3E21B8 /* AppUITests */, + E09334D859B0854D0FE3C5FE /* ZenWidgets */, ); sourceTree = ""; }; @@ -142,6 +189,7 @@ 504EC3041FED79650016851F /* App.app */, E1424FEA5946506EA505941C /* ShareExtension.appex */, 10A78CB5D81347F3CBBC9579 /* AppUITests.xctest */, + F342B601563BB02825474641 /* ZenWidgets.appex */, ); name = Products; sourceTree = ""; @@ -163,6 +211,7 @@ FAA3D1BD7D10EE9B188D5FBF /* ICloudVaultPlugin.swift */, 8A91962841901607F2906263 /* FolderPickerPlugin.swift */, D61B685B388CE720F2588065 /* PrivacyInfo.xcprivacy */, + 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */, ); path = App; sourceTree = ""; @@ -180,6 +229,9 @@ isa = PBXGroup; children = ( 155E04D763AD80DA2E56D38D /* Foundation.framework */, + 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */, + 4ED02E2781BE90C097D648B8 /* WidgetKit.framework */, + 85847153B31EF561562A9281 /* SwiftUI.framework */, ); name = iOS; sourceTree = ""; @@ -205,6 +257,24 @@ path = AppUITests; sourceTree = ""; }; + E09334D859B0854D0FE3C5FE /* ZenWidgets */ = { + isa = PBXGroup; + children = ( + C218F6CD95CD249E2926D1DA /* ZenWidgetsBundle.swift */, + 8EAB64B0086A91CBCB3B5802 /* WidgetSnapshot.swift */, + 3175A5673A3065A7A684CC9D /* WidgetTheme.swift */, + 486736422F5CB5716CAA674F /* NewNoteWidget.swift */, + 81BE1DA68F6223E14C0761AA /* RecentNotesWidget.swift */, + EA702ECFEE20C3DACF6336FB /* TasksWidget.swift */, + 7B09FFFEE67FC0E73BE167CD /* Info.plist */, + E826831F57D7B9D9D5121461 /* ZenWidgets.entitlements */, + DB10756BA75711FC150E34FD /* Assets.xcassets */, + 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */, + ); + name = ZenWidgets; + path = ZenWidgets; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -241,12 +311,30 @@ ); dependencies = ( 328F1E5314F407002A3AD7DE /* PBXTargetDependency */, + B60643996DA006DC755EB4F2 /* PBXTargetDependency */, ); name = App; productName = App; productReference = 504EC3041FED79650016851F /* App.app */; productType = "com.apple.product-type.application"; }; + A0081C14C7FAA80567CB18EC /* ZenWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7C8A1DA0D090CC1FD271B28F /* Build configuration list for PBXNativeTarget "ZenWidgets" */; + buildPhases = ( + 212E05C406E5D787E692B6A3 /* Sources */, + 75067D2C3F9B2DE5E3FA6FE6 /* Frameworks */, + 561EA786F4F8154947CC511A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ZenWidgets; + productName = ZenWidgets; + productReference = F342B601563BB02825474641 /* ZenWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; C4333346707B0BA0DC86389D /* ShareExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 0A1F00A0FA0C6A15D03A4E31 /* Build configuration list for PBXNativeTarget "ShareExtension" */; @@ -296,6 +384,7 @@ 504EC3031FED79650016851F /* App */, C4333346707B0BA0DC86389D /* ShareExtension */, 1D1E21A139C8A67B180DBB69 /* AppUITests */, + A0081C14C7FAA80567CB18EC /* ZenWidgets */, ); }; /* End PBXProject section */ @@ -315,6 +404,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 561EA786F4F8154947CC511A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A4CABD8562655E4574EA3944 /* Assets.xcassets in Resources */, + 6B30A84670984EC66F0422E2 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B09E33C2E6C9AEE8E88B8710 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -377,6 +475,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 212E05C406E5D787E692B6A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F59E347AA2D96EB117D7C2CD /* ZenWidgetsBundle.swift in Sources */, + 9929F4C805DC04F7A7F2058C /* WidgetSnapshot.swift in Sources */, + 172569C163FC68A0C0C58F02 /* WidgetTheme.swift in Sources */, + 60BB3C8B9335A8DF5C0C752D /* NewNoteWidget.swift in Sources */, + B516881658A9D3C9AFC1259E /* RecentNotesWidget.swift in Sources */, + 9F314931CA3D31AAFCF26E15 /* TasksWidget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 504EC3001FED79650016851F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -387,6 +498,7 @@ FB56F003004840A2A553E931 /* KeyboardBackdropPlugin.swift in Sources */, B1F519EA73BD94F20A3F1DD1 /* ICloudVaultPlugin.swift in Sources */, D17E4E8551A97CF08FDE5F82 /* FolderPickerPlugin.swift in Sources */, + 66714DDFC6EB0A9732DE176A /* WidgetBridgePlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -413,6 +525,12 @@ target = 504EC3031FED79650016851F /* App */; targetProxy = E82844074641B4C71B7708C5 /* PBXContainerItemProxy */; }; + B60643996DA006DC755EB4F2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ZenWidgets; + target = A0081C14C7FAA80567CB18EC /* ZenWidgets */; + targetProxy = 604D2CAAF799146B5F1A1C7F /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -452,6 +570,27 @@ }; name = Release; }; + 4FC356E793114A0E062EDB3F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 18; + DEVELOPMENT_TEAM = WYY7PK57DM; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ZenWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.9.7; + PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 504EC3141FED79650016851F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -624,6 +763,28 @@ }; name = Release; }; + 993B81FA9C1D533125041F86 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 18; + DEVELOPMENT_TEAM = WYY7PK57DM; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ZenWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.9.7; + PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; C39978C52715031CB13E5F59 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -700,6 +861,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7C8A1DA0D090CC1FD271B28F /* Build configuration list for PBXNativeTarget "ZenWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 993B81FA9C1D533125041F86 /* Release */, + 4FC356E793114A0E062EDB3F /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index c3cd83b..b884ebf 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -36,6 +36,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { // Called when the app was launched with a url. Feel free to add additional processing here, // but if you want the App API to support tracking app url opens, make sure to keep this call + // Widget taps (zennotes:// links) are remembered for the WebView to + // consume at boot; see WidgetBridgePlugin.consumeLaunchLink. + WidgetBridgePlugin.stashLaunchLink(url) return ApplicationDelegateProxy.shared.application(app, open: url, options: options) } diff --git a/ios/App/App/WidgetBridgePlugin.swift b/ios/App/App/WidgetBridgePlugin.swift new file mode 100644 index 0000000..9ae2677 --- /dev/null +++ b/ios/App/App/WidgetBridgePlugin.swift @@ -0,0 +1,83 @@ +import Capacitor +import Foundation +import WidgetKit + +/// App-local Capacitor plugin behind the Home Screen / Lock Screen widgets +/// (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. +@objc(WidgetBridgePlugin) +public class WidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "WidgetBridgePlugin" + public let jsName = "ZenWidgets" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "consumeLaunchLink", returnType: CAPPluginReturnPromise) + ] + + private let appGroupId = "group.md.zennotes" + /// Mirrored by WidgetSnapshotStore.relativePath in the extension. + private let snapshotPath = "widgets/snapshot.json" + + /// The latest zennotes:// link this process was opened with, for the + /// WebView to consume at boot (deep-links.ts). AppDelegate stashes every + /// URL open here; the newest wins. Same contract as the Android plugin, + /// where it exists because Capacitor's getLaunchUrl can be stale for a + /// recreated activity — kept on both so the shell code stays shared. + private static var pendingLaunchLink: String? + + static func stashLaunchLink(_ url: URL) { + guard url.scheme?.lowercased() == "zennotes" else { return } + pendingLaunchLink = url.absoluteString + } + + @objc func consumeLaunchLink(_ call: CAPPluginCall) { + let link = Self.pendingLaunchLink + Self.pendingLaunchLink = nil + call.resolve(["url": link ?? NSNull()]) + } + + private func snapshotURL() -> URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)? + .appendingPathComponent(snapshotPath) + } + + @objc func update(_ call: CAPPluginCall) { + guard let json = call.getString("snapshot"), !json.isEmpty else { + call.reject("snapshot is required") + return + } + DispatchQueue.global(qos: .utility).async { + guard let url = self.snapshotURL() else { + call.reject("The App Group container is unavailable.") + return + } + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + // Atomic: a widget waking mid-write must see the old snapshot + // or the new one, never a truncated file. + try Data(json.utf8).write(to: url, options: .atomic) + } catch { + call.reject("Could not write the widget snapshot: \(error.localizedDescription)") + return + } + WidgetCenter.shared.reloadAllTimelines() + call.resolve() + } + } + + @objc func clear(_ call: CAPPluginCall) { + DispatchQueue.global(qos: .utility).async { + if let url = self.snapshotURL() { + try? FileManager.default.removeItem(at: url) + } + WidgetCenter.shared.reloadAllTimelines() + call.resolve() + } + } +} diff --git a/ios/App/App/ZNViewController.swift b/ios/App/App/ZNViewController.swift index 3b52b0a..78bc85d 100644 --- a/ios/App/App/ZNViewController.swift +++ b/ios/App/App/ZNViewController.swift @@ -10,5 +10,6 @@ class ZNViewController: CAPBridgeViewController { bridge?.registerPluginInstance(ICloudVaultPlugin()) bridge?.registerPluginInstance(FolderPickerPlugin()) bridge?.registerPluginInstance(KeyboardBackdropPlugin()) + bridge?.registerPluginInstance(WidgetBridgePlugin()) } } diff --git a/ios/App/ZenWidgets/Assets.xcassets/Contents.json b/ios/App/ZenWidgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/App/ZenWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/Contents.json b/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/Contents.json new file mode 100644 index 0000000..4896f13 --- /dev/null +++ b/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enso.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/enso.png b/ios/App/ZenWidgets/Assets.xcassets/Enso.imageset/enso.png new file mode 100644 index 0000000000000000000000000000000000000000..d82d859e7c75b6d57c33d3e2d590d05f18234c9c GIT binary patch literal 51111 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 literal 0 HcmV?d00001 diff --git a/ios/App/ZenWidgets/Info.plist b/ios/App/ZenWidgets/Info.plist new file mode 100644 index 0000000..fdfa93d --- /dev/null +++ b/ios/App/ZenWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ZenNotes + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/App/ZenWidgets/NewNoteWidget.swift b/ios/App/ZenWidgets/NewNoteWidget.swift new file mode 100644 index 0000000..f919c44 --- /dev/null +++ b/ios/App/ZenWidgets/NewNoteWidget.swift @@ -0,0 +1,148 @@ +import SwiftUI +import WidgetKit + +/// One tap → a fresh note in the Inbox, title focused (the ⊕ sheet's "New +/// note"). Small on the Home Screen; circular / rectangular / inline on the +/// Lock Screen from iOS 16. +struct NewNoteEntry: TimelineEntry { + let date: Date + let vaultName: String? + let palette: WidgetPalette +} + +struct NewNoteProvider: TimelineProvider { + func placeholder(in context: Context) -> NewNoteEntry { + NewNoteEntry(date: Date(), vaultName: "My Vault", palette: .fallback) + } + + func getSnapshot(in context: Context, completion: @escaping (NewNoteEntry) -> Void) { + completion(entry(now: Date())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + // Nothing here drifts with time; the app reloads on vault or theme + // changes. The long horizon only guards against a missed reload. + completion(Timeline(entries: [entry(now: now)], policy: .after(now.addingTimeInterval(12 * 3600)))) + } + + private func entry(now: Date) -> NewNoteEntry { + let snapshot = WidgetSnapshotStore.load() + return NewNoteEntry( + date: now, + vaultName: snapshot?.vaultName, + palette: WidgetPalette(theme: snapshot?.theme) + ) + } +} + +struct NewNoteWidget: Widget { + static let kind = "md.zennotes.widgets.new-note" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: NewNoteProvider()) { entry in + NewNoteWidgetView(entry: entry) + } + .configurationDisplayName("New Note") + .description("Start a new note with one tap.") + .supportedFamilies(Self.families) + } + + private static var families: [WidgetFamily] { + var families: [WidgetFamily] = [.systemSmall] + if #available(iOS 16.0, *) { + families += [.accessoryCircular, .accessoryRectangular, .accessoryInline] + } + return families + } +} + +struct NewNoteWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: NewNoteEntry + + var body: some View { + Group { + switch family { + case .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge: + NewNoteSmallView(entry: entry) + default: + if #available(iOS 16.0, *) { + NewNoteAccessoryView(family: family, vaultName: entry.vaultName) + .zenAccessoryBackground() + } else { + NewNoteSmallView(entry: entry) + } + } + } + .widgetURL(ZenLinks.newNote) + } +} + +private struct NewNoteSmallView: View { + let entry: NewNoteEntry + + var body: some View { + let p = entry.palette + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Image("Enso") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + Spacer(minLength: 0) + ZStack { + Circle().fill(p.accent) + Image(systemName: "plus") + .font(.system(size: 15, weight: .bold)) + .foregroundColor(p.bg) + } + .frame(width: 30, height: 30) + } + Spacer(minLength: 0) + Text("New note") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(p.fg) + Text(entry.vaultName ?? "ZenNotes") + .font(.system(size: 12)) + .foregroundColor(p.muted) + .lineLimit(1) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .zenWidgetBackground(p.bg) + } +} + +@available(iOS 16.0, *) +private struct NewNoteAccessoryView: View { + let family: WidgetFamily + let vaultName: String? + + var body: some View { + switch family { + case .accessoryCircular: + ZStack { + AccessoryWidgetBackground() + Image(systemName: "square.and.pencil") + .font(.system(size: 22, weight: .medium)) + } + case .accessoryRectangular: + HStack(spacing: 8) { + Image(systemName: "square.and.pencil") + .font(.system(size: 20, weight: .medium)) + VStack(alignment: .leading, spacing: 1) { + Text("New note") + .font(.headline) + Text(vaultName ?? "ZenNotes") + .font(.caption) + .opacity(0.8) + .lineLimit(1) + } + Spacer(minLength: 0) + } + default: + Label("New note", systemImage: "square.and.pencil") + } + } +} diff --git a/ios/App/ZenWidgets/PrivacyInfo.xcprivacy b/ios/App/ZenWidgets/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..590abf8 --- /dev/null +++ b/ios/App/ZenWidgets/PrivacyInfo.xcprivacy @@ -0,0 +1,17 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/ios/App/ZenWidgets/RecentNotesWidget.swift b/ios/App/ZenWidgets/RecentNotesWidget.swift new file mode 100644 index 0000000..28df005 --- /dev/null +++ b/ios/App/ZenWidgets/RecentNotesWidget.swift @@ -0,0 +1,145 @@ +import SwiftUI +import WidgetKit + +/// Pinned notes first, then the ones edited last — the Home dashboard's +/// Recent list with the drawer's pins on top. Every row opens its note; the +/// header's + starts a new one. +struct RecentNotesEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot? + let palette: WidgetPalette +} + +struct RecentNotesProvider: TimelineProvider { + /// The "3h ago" stamps drift with no app activity at all, so one + /// timeline re-renders the same snapshot a few times over the next + /// hours; a real change reloads everything from the app. + private static let refreshOffsetsMinutes: [Double] = [0, 5, 15, 30, 60, 120, 240, 480] + + func placeholder(in context: Context) -> RecentNotesEntry { + RecentNotesEntry(date: Date(), snapshot: WidgetSnapshotStore.sample(), palette: .fallback) + } + + func getSnapshot(in context: Context, completion: @escaping (RecentNotesEntry) -> Void) { + let real = WidgetSnapshotStore.load() + // The gallery should show a lived-in widget, not an empty vault. + let snapshot = (context.isPreview && (real?.notes.isEmpty ?? true)) + ? WidgetSnapshotStore.sample() + : real + completion(RecentNotesEntry(date: Date(), snapshot: snapshot, palette: WidgetPalette(theme: real?.theme))) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let snapshot = WidgetSnapshotStore.load() + let palette = WidgetPalette(theme: snapshot?.theme) + let entries = Self.refreshOffsetsMinutes.map { offset in + RecentNotesEntry(date: now.addingTimeInterval(offset * 60), snapshot: snapshot, palette: palette) + } + completion(Timeline(entries: entries, policy: .atEnd)) + } +} + +struct RecentNotesWidget: Widget { + static let kind = "md.zennotes.widgets.recent-notes" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: RecentNotesProvider()) { entry in + RecentNotesWidgetView(entry: entry) + } + .configurationDisplayName("Recent Notes") + .description("Your pinned notes, then the ones you edited last.") + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +struct RecentNotesWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: RecentNotesEntry + + private var isLarge: Bool { family == .systemLarge } + private var maxRows: Int { isLarge ? 9 : 4 } + private var rowHeight: CGFloat { isLarge ? 30 : 22 } + + var body: some View { + let p = entry.palette + let notes = Array((entry.snapshot?.notes ?? []).prefix(maxRows)) + VStack(alignment: .leading, spacing: 0) { + header(p) + if notes.isEmpty { + emptyState(p) + } else { + ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in + Link(destination: ZenLinks.open(note.path)) { + row(note, p) + } + if index < notes.count - 1 { + Rectangle().fill(p.bg2).frame(height: 0.5) + } + } + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .zenWidgetBackground(p.bg) + .widgetURL(ZenLinks.home) + } + + private func header(_ p: WidgetPalette) -> some View { + HStack(spacing: 6) { + Image("Enso") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + Text(entry.snapshot?.vaultName ?? "ZenNotes") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(p.muted) + .lineLimit(1) + Spacer(minLength: 8) + Link(destination: ZenLinks.newNote) { + ZStack { + Circle().fill(p.accent.opacity(0.18)) + Image(systemName: "plus") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(p.accent) + } + .frame(width: 22, height: 22) + } + } + .frame(height: 22) + .padding(.bottom, 4) + } + + private func row(_ note: WidgetNote, _ p: WidgetPalette) -> some View { + HStack(spacing: 8) { + Image(systemName: note.pinned ? "pin.fill" : "doc.text") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(note.pinned ? p.accent : p.muted) + .frame(width: 14) + Text(note.title) + .font(.system(size: isLarge ? 14 : 13, weight: .medium)) + .foregroundColor(p.fg) + .lineLimit(1) + Spacer(minLength: 8) + Text(WidgetFormat.timeAgo(note.updatedDate, now: entry.date)) + .font(.system(size: 11)) + .foregroundColor(p.muted) + .lineLimit(1) + } + .frame(height: rowHeight) + .contentShape(Rectangle()) + } + + private func emptyState(_ p: WidgetPalette) -> some View { + VStack(alignment: .leading, spacing: 3) { + Spacer(minLength: 0) + Text(entry.snapshot == nil ? "Open ZenNotes once" : "No notes yet") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.fg) + Text(entry.snapshot == nil ? "The widget fills in from your vault." : "Tap + to write your first.") + .font(.system(size: 12)) + .foregroundColor(p.muted) + Spacer(minLength: 0) + } + } +} diff --git a/ios/App/ZenWidgets/TasksWidget.swift b/ios/App/ZenWidgets/TasksWidget.swift new file mode 100644 index 0000000..73642f3 --- /dev/null +++ b/ios/App/ZenWidgets/TasksWidget.swift @@ -0,0 +1,175 @@ +import SwiftUI +import WidgetKit + +/// The Home dashboard's Today bucket: due today, overdue, and undated open +/// tasks, in the Tasks view's order. A row jumps to the task's line in its +/// note; the header (and any empty space) opens the Tasks view. +struct TasksEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot? + let palette: WidgetPalette +} + +struct TasksProvider: TimelineProvider { + func placeholder(in context: Context) -> TasksEntry { + TasksEntry(date: Date(), snapshot: WidgetSnapshotStore.sample(), palette: .fallback) + } + + func getSnapshot(in context: Context, completion: @escaping (TasksEntry) -> Void) { + let real = WidgetSnapshotStore.load() + let snapshot = (context.isPreview && (real?.tasks.isEmpty ?? true)) + ? WidgetSnapshotStore.sample() + : real + completion(TasksEntry(date: Date(), snapshot: snapshot, palette: WidgetPalette(theme: real?.theme))) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let snapshot = WidgetSnapshotStore.load() + let entry = TasksEntry(date: now, snapshot: snapshot, palette: WidgetPalette(theme: snapshot?.theme)) + // Re-render at local midnight so "due today" turns overdue on time; + // the app reloads on every task 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], policy: .after(midnight))) + } +} + +struct TasksWidget: Widget { + static let kind = "md.zennotes.widgets.tasks" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: TasksProvider()) { entry in + TasksWidgetView(entry: entry) + } + .configurationDisplayName("Today's Tasks") + .description("What's due today, overdue, or waiting for a date.") + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +struct TasksWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: TasksEntry + + private var isLarge: Bool { family == .systemLarge } + private var maxRows: Int { isLarge ? 8 : 3 } + private var rowHeight: CGFloat { isLarge ? 34 : 30 } + + var body: some View { + let p = entry.palette + let todayIso = WidgetFormat.isoDate(entry.date) + let all = entry.snapshot?.tasks ?? [] + let rows = Array(all.prefix(maxRows)) + let hidden = max(0, (entry.snapshot?.todayCount ?? 0) - rows.count) + VStack(alignment: .leading, spacing: 0) { + header(p, todayIso: todayIso) + if rows.isEmpty { + emptyState(p) + } else { + ForEach(rows) { task in + Link(destination: ZenLinks.task(id: task.id, path: task.path)) { + row(task, p, todayIso: todayIso) + } + } + if hidden > 0 && isLarge { + Text("+\(hidden) more") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(p.muted) + .padding(.top, 4) + } + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .zenWidgetBackground(p.bg) + .widgetURL(ZenLinks.tasks) + } + + private func header(_ p: WidgetPalette, todayIso: String) -> some View { + let today = entry.snapshot?.todayCount ?? 0 + let overdue = (entry.snapshot?.tasks ?? []).filter { isOverdue($0, todayIso: todayIso) }.count + let overdueTotal = max(overdue, entry.snapshot?.overdueCount ?? 0) + return HStack(spacing: 6) { + Image(systemName: "checkmark.square") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(p.accent) + Text("Today") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(p.fg) + Spacer(minLength: 8) + if overdueTotal > 0 { + Text("\(overdueTotal) overdue") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(p.red) + } else if today > 0 { + Text("\(today) open") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(p.muted) + } + } + .frame(height: 22) + .padding(.bottom, 4) + } + + private func isOverdue(_ task: WidgetTask, todayIso: String) -> Bool { + if let due = task.due { return due < todayIso } + return task.overdue + } + + private func row(_ task: WidgetTask, _ p: WidgetPalette, todayIso: String) -> some View { + let overdue = isOverdue(task, todayIso: todayIso) + var detail = task.noteTitle + if overdue, let due = task.due { + detail = "\(WidgetFormat.shortDate(iso: due)) · \(task.noteTitle)" + } + return HStack(spacing: 8) { + Image(systemName: task.inProgress ? "circle.lefthalf.filled" : "square") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(overdue ? p.red : p.muted) + .frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + Text(task.content) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(p.fg) + .lineLimit(1) + Text(detail) + .font(.system(size: 10.5)) + .foregroundColor(overdue ? p.red : p.muted) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(height: rowHeight) + .contentShape(Rectangle()) + } + + private func emptyState(_ p: WidgetPalette) -> some View { + let ready = entry.snapshot?.tasksReady ?? false + return VStack(alignment: .leading, spacing: 3) { + Spacer(minLength: 0) + if entry.snapshot == nil || !ready { + Text("Open ZenNotes once") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.fg) + Text("Your tasks show up after the first scan.") + .font(.system(size: 12)) + .foregroundColor(p.muted) + } else { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.accent) + Text("All clear") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(p.fg) + } + Text("Nothing due today.") + .font(.system(size: 12)) + .foregroundColor(p.muted) + } + Spacer(minLength: 0) + } + } +} diff --git a/ios/App/ZenWidgets/WidgetSnapshot.swift b/ios/App/ZenWidgets/WidgetSnapshot.swift new file mode 100644 index 0000000..4354651 --- /dev/null +++ b/ios/App/ZenWidgets/WidgetSnapshot.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Mirror of src/bridge/widget-snapshot.ts — the app is the writer. Every +/// field a later shell might add or drop decodes as optional, so an older +/// extension never fails on a newer snapshot (and vice versa). +struct WidgetThemeData: Decodable { + var mode: String? + var bg: String? + var bg1: String? + var bg2: String? + var fg: String? + var fg2: String? + var muted: String? + var accent: String? + var red: String? +} + +struct WidgetNote: Decodable, Identifiable { + var id: String { path } + let path: String + let title: String + let folder: String? + /// ms since epoch. + let updatedAt: Double + let pinned: Bool + + var updatedDate: Date { Date(timeIntervalSince1970: updatedAt / 1000) } +} + +struct WidgetTask: Decodable, Identifiable { + let id: String + let path: String + let noteTitle: String + let content: String + /// ISO YYYY-MM-DD; nil for an undated task. + let due: String? + let overdue: Bool + let inProgress: Bool + let priority: String? +} + +struct WidgetTaskCounts: Decodable { + let today: Int + let overdue: Int +} + +struct WidgetSnapshot: Decodable { + let version: Int + /// ms since epoch. + let generatedAt: Double + let vaultName: String? + let theme: WidgetThemeData? + let notes: [WidgetNote] + let tasks: [WidgetTask] + let taskCounts: WidgetTaskCounts? + let tasksReady: Bool? + + var generatedDate: Date { Date(timeIntervalSince1970: generatedAt / 1000) } + var todayCount: Int { taskCounts?.today ?? tasks.count } + var overdueCount: Int { taskCounts?.overdue ?? tasks.filter { $0.overdue }.count } +} + +enum WidgetSnapshotStore { + static let appGroupId = "group.md.zennotes" + /// Mirrored by WidgetBridgePlugin.snapshotPath in the app. + static let relativePath = "widgets/snapshot.json" + + static var url: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupId)? + .appendingPathComponent(relativePath) + } + + static func load() -> WidgetSnapshot? { + guard let url = url, let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(WidgetSnapshot.self, from: data) + } + + /// Gallery previews and placeholders — what a lived-in vault looks like. + static func sample(now: Date = Date()) -> WidgetSnapshot { + let ms = now.timeIntervalSince1970 * 1000 + let minute = 60_000.0 + let hour = 3_600_000.0 + let today = WidgetFormat.isoDate(now) + let yesterday = WidgetFormat.isoDate(now.addingTimeInterval(-86_400)) + return WidgetSnapshot( + version: 1, + generatedAt: ms, + vaultName: "My Vault", + theme: nil, + notes: [ + WidgetNote(path: "inbox/Reading list.md", title: "Reading list", folder: "inbox", + updatedAt: ms - 25 * minute, pinned: true), + WidgetNote(path: "inbox/Product ideas.md", title: "Product ideas", folder: "inbox", + updatedAt: ms - 3 * hour, pinned: false), + WidgetNote(path: "inbox/Meeting notes.md", title: "Meeting notes", folder: "inbox", + updatedAt: ms - 6 * hour, pinned: false), + WidgetNote(path: "quick/Grocery run.md", title: "Grocery run", folder: "quick", + updatedAt: ms - 26 * hour, pinned: false), + WidgetNote(path: "inbox/Trip planning.md", title: "Trip planning", folder: "inbox", + updatedAt: ms - 2 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Weekly review.md", title: "Weekly review", folder: "inbox", + updatedAt: ms - 3 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Book notes.md", title: "Book notes", folder: "inbox", + updatedAt: ms - 4 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Recipes.md", title: "Recipes", folder: "inbox", + updatedAt: ms - 5 * 24 * hour, pinned: false), + WidgetNote(path: "inbox/Journal.md", title: "Journal", folder: "inbox", + updatedAt: ms - 6 * 24 * hour, pinned: false) + ], + tasks: [ + WidgetTask(id: "inbox/Today.md#0", path: "inbox/Today.md", noteTitle: "Today", + content: "Reply to the design review", due: today, overdue: false, + inProgress: false, priority: nil), + WidgetTask(id: "inbox/Trip planning.md#2", path: "inbox/Trip planning.md", + noteTitle: "Trip planning", content: "Book the October flights", + due: yesterday, overdue: true, inProgress: false, priority: "high"), + WidgetTask(id: "inbox/Product ideas.md#1", path: "inbox/Product ideas.md", + noteTitle: "Product ideas", content: "Draft the release notes", + due: today, overdue: false, inProgress: true, priority: nil), + WidgetTask(id: "inbox/Today.md#3", path: "inbox/Today.md", noteTitle: "Today", + content: "Call the dentist", due: nil, overdue: false, + inProgress: false, priority: nil), + WidgetTask(id: "inbox/Weekly review.md#0", path: "inbox/Weekly review.md", + noteTitle: "Weekly review", content: "Plan next week's focus", + due: today, overdue: false, inProgress: false, priority: nil), + WidgetTask(id: "quick/Grocery run.md#0", path: "quick/Grocery run.md", + noteTitle: "Grocery run", content: "Pick up coffee beans", + due: nil, overdue: false, inProgress: false, priority: nil) + ], + taskCounts: WidgetTaskCounts(today: 6, overdue: 1), + tasksReady: true + ) + } +} diff --git a/ios/App/ZenWidgets/WidgetTheme.swift b/ios/App/ZenWidgets/WidgetTheme.swift new file mode 100644 index 0000000..f33cd10 --- /dev/null +++ b/ios/App/ZenWidgets/WidgetTheme.swift @@ -0,0 +1,155 @@ +import SwiftUI +import WidgetKit + +/// The widgets wear the app's active theme: the shell samples the `--z-*` +/// tokens (background, foreground, accent, …) into the snapshot and this +/// resolves them to SwiftUI colors, falling back to ZenNotes' default +/// dark-hard palette before the first publish. +struct WidgetPalette { + let isDark: Bool + let bg: Color + let bg1: Color + let bg2: Color + let fg: Color + let fg2: Color + let muted: Color + let accent: Color + let red: Color + + static let fallback = WidgetPalette( + isDark: true, + bg: Color(hex: "#1d2021") ?? .black, + bg1: Color(hex: "#32302f") ?? .gray, + bg2: Color(hex: "#3c3836") ?? .gray, + fg: Color(hex: "#d4be98") ?? .white, + fg2: Color(hex: "#ddc7a1") ?? .white, + muted: Color(hex: "#a89984") ?? .gray, + accent: Color(hex: "#e78a4e") ?? .orange, + red: Color(hex: "#ea6962") ?? .red + ) +} + +extension WidgetPalette { + init(theme: WidgetThemeData?) { + let f = WidgetPalette.fallback + self.init( + isDark: theme?.mode != "light", + bg: Color(hex: theme?.bg) ?? f.bg, + bg1: Color(hex: theme?.bg1) ?? f.bg1, + bg2: Color(hex: theme?.bg2) ?? f.bg2, + fg: Color(hex: theme?.fg) ?? f.fg, + fg2: Color(hex: theme?.fg2) ?? f.fg2, + muted: Color(hex: theme?.muted) ?? f.muted, + accent: Color(hex: theme?.accent) ?? f.accent, + red: Color(hex: theme?.red) ?? f.red + ) + } +} + +extension Color { + /// `#rrggbb` (the hash optional) → sRGB color; anything else is nil. + init?(hex: String?) { + guard var text = hex?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + if text.hasPrefix("#") { text.removeFirst() } + guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil } + self.init( + .sRGB, + red: Double((value >> 16) & 0xff) / 255, + green: Double((value >> 8) & 0xff) / 255, + blue: Double(value & 0xff) / 255, + opacity: 1 + ) + } +} + +enum WidgetFormat { + /// The Home dashboard's stamp: just now, 5m ago, 3h ago, yesterday, + /// 4d ago, then a short date. + static func timeAgo(_ date: Date, now: Date) -> String { + let minutes = Int((now.timeIntervalSince(date) / 60).rounded()) + if minutes < 1 { return "just now" } + if minutes < 60 { return "\(minutes)m ago" } + let hours = Int((Double(minutes) / 60).rounded()) + if hours < 24 { return "\(hours)h ago" } + let days = Int((Double(hours) / 24).rounded()) + if days == 1 { return "yesterday" } + if days < 7 { return "\(days)d ago" } + return shortDate(date) + } + + /// 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) + } + + static func shortDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMM d") + return formatter.string(from: date) + } + + /// "Sep 5" for an ISO due date; the raw string if it doesn't parse. + static func shortDate(iso: String) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + guard let date = formatter.date(from: iso) else { return iso } + return shortDate(date) + } +} + +/// The `zennotes://` links the shell runs (src/ui-mobile/widget-links.ts is +/// the parser and the source of truth for the vocabulary). +enum ZenLinks { + static let newNote = URL(string: "zennotes://new")! + static let tasks = URL(string: "zennotes://tasks")! + static let home = URL(string: "zennotes://home")! + + static func open(_ path: String) -> URL { + URL(string: "zennotes://open?path=\(encode(path))") ?? home + } + + static func task(id: String, path: String) -> URL { + URL(string: "zennotes://task?id=\(encode(id))&path=\(encode(path))") ?? open(path) + } + + /// Only unreserved characters stay bare: `#` in task ids and `&` in + /// titles would otherwise split the URL. + private static let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + + private static func encode(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "" + } +} + +extension View { + /// iOS 17 draws widgets inside a system container (margins included); + /// earlier releases get the same look from a plain background + padding. + @ViewBuilder + func zenWidgetBackground(_ color: Color) -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(for: .widget) { color } + } else { + self + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(color) + } + } + + /// Lock Screen accessories render vibrant on the system's own material; + /// iOS 17 still wants a container background declared. + @ViewBuilder + func zenAccessoryBackground() -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(for: .widget) { Color.clear } + } else { + self + } + } +} diff --git a/ios/App/ZenWidgets/ZenWidgets.entitlements b/ios/App/ZenWidgets/ZenWidgets.entitlements new file mode 100644 index 0000000..0904259 --- /dev/null +++ b/ios/App/ZenWidgets/ZenWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.md.zennotes + + + diff --git a/ios/App/ZenWidgets/ZenWidgetsBundle.swift b/ios/App/ZenWidgets/ZenWidgetsBundle.swift new file mode 100644 index 0000000..0753314 --- /dev/null +++ b/ios/App/ZenWidgets/ZenWidgetsBundle.swift @@ -0,0 +1,16 @@ +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 +/// 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). +@main +struct ZenWidgetsBundle: WidgetBundle { + var body: some Widget { + NewNoteWidget() + RecentNotesWidget() + TasksWidget() + } +} diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index b0b91a4..8b83038 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -110,9 +110,25 @@ export async function configureMobileCloudAuth(appVersion: string): Promise scheduleAuthCallback(url)) + await CapApp.addListener('appUrlOpen', ({ url }) => { + if (isCloudAuthUrl(url)) scheduleAuthCallback(url) + }) const launch = await CapApp.getLaunchUrl() - if (launch?.url) scheduleAuthCallback(launch.url) + if (launch?.url && isCloudAuthUrl(launch.url)) scheduleAuthCallback(launch.url) +} + +/** The scheme is shared with the widget links (ui-mobile/widget-links.ts); + * only `zennotes://auth…` is this module's to handle. */ +function isCloudAuthUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl.trim()) + return ( + parsed.protocol === 'zennotes:' && + (parsed.hostname || parsed.pathname.replace(/^\/+/, '')) === 'auth' + ) + } catch { + return false + } } export async function getMobileCloudAccountStatus(): Promise { diff --git a/src/bridge/widget-snapshot.test.ts b/src/bridge/widget-snapshot.test.ts new file mode 100644 index 0000000..5c911b5 --- /dev/null +++ b/src/bridge/widget-snapshot.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { WidgetNoteSource, WidgetTaskSource } from './widget-snapshot.ts' +import { + FALLBACK_WIDGET_THEME, + channelsToHex, + filterLiveTasks, + selectWidgetNotes, + selectWidgetTasks, + themeFromTokens +} from './widget-snapshot.ts' + +function note( + path: string, + updatedAt: number, + folder: WidgetNoteSource['folder'] = 'inbox', + title = path.replace(/^.*\//, '').replace(/\.md$/, '') +): WidgetNoteSource { + return { path, title, folder, updatedAt } +} + +test('pinned notes lead in pin order, then recents newest first', () => { + const notes = [ + note('inbox/Old.md', 100), + note('inbox/Newest.md', 900), + note('inbox/Pinned B.md', 300), + note('inbox/Pinned A.md', 200), + note('inbox/Middle.md', 500) + ] + const out = selectWidgetNotes(notes, ['inbox/Pinned B.md', 'inbox/Pinned A.md']) + assert.deepEqual( + out.map((n) => [n.path, n.pinned]), + [ + ['inbox/Pinned B.md', true], + ['inbox/Pinned A.md', true], + ['inbox/Newest.md', false], + ['inbox/Middle.md', false], + ['inbox/Old.md', false] + ] + ) +}) + +test('trash and archive never show; a stale pin is skipped; the cap holds', () => { + const notes = [ + note('trash/Gone.md', 999, 'trash'), + note('archive/Done.md', 998, 'archive'), + note('inbox/A.md', 3), + note('inbox/B.md', 2), + note('inbox/C.md', 1) + ] + const out = selectWidgetNotes(notes, ['inbox/Missing.md', 'trash/Gone.md'], 2) + assert.deepEqual( + out.map((n) => n.path), + ['inbox/A.md', 'inbox/B.md'] + ) + assert.equal(out.every((n) => !n.pinned), true) +}) + +test('an empty title reads Untitled and pins are not duplicated as recents', () => { + const out = selectWidgetNotes([note('inbox/x.md', 1, 'inbox', ' ')], ['inbox/x.md']) + assert.deepEqual(out, [ + { path: 'inbox/x.md', title: 'Untitled', folder: 'inbox', updatedAt: 1, pinned: true } + ]) +}) + +test('channel triplets become hex; anything else is rejected', () => { + assert.equal(channelsToHex('29 32 33'), '#1d2021') + assert.equal(channelsToHex(' 255 255 255 '), '#ffffff') + assert.equal(channelsToHex('0 0 0'), '#000000') + assert.equal(channelsToHex(''), null) + assert.equal(channelsToHex('#1d2021'), null) + assert.equal(channelsToHex('300 0 0'), null) +}) + +test('the theme reads every token it can and falls back per token', () => { + const tokens: Record = { + '--z-bg': '251 241 199', + '--z-accent': '195 94 10', + '--z-red': 'not a color' + } + const theme = themeFromTokens((t) => tokens[t] ?? '', 'light') + assert.equal(theme.mode, 'light') + assert.equal(theme.bg, '#fbf1c7') + assert.equal(theme.accent, '#c35e0a') + assert.equal(theme.red, FALLBACK_WIDGET_THEME.red) + assert.equal(theme.fg, FALLBACK_WIDGET_THEME.fg) +}) + +function task( + id: string, + content: string, + extra: Partial = {} +): WidgetTaskSource { + const sourcePath = id.slice(0, id.lastIndexOf('#')) + return { + id, + sourcePath, + noteTitle: sourcePath.replace(/^.*\//, '').replace(/\.md$/, ''), + content, + inProgress: false, + ...extra + } +} + +test('overdue tasks lead, the rest keep the bucket order, and counts cover the cut-off rows', () => { + const today = [ + task('inbox/A.md#1', 'Due today', { due: '2026-09-08', inProgress: true }), + task('inbox/B.md#0', ' Undated ', { priority: 'high' }), + task('inbox/A.md#0', 'Overdue thing', { due: '2026-09-05' }), + task('inbox/C.md#0', 'Older overdue', { due: '2026-09-01' }) + ] + const { tasks, counts } = selectWidgetTasks(today, 2, '2026-09-08', 3) + assert.deepEqual(counts, { today: 4, overdue: 2 }) + assert.deepEqual( + tasks.map((t) => t.content), + ['Overdue thing', 'Older overdue', 'Due today'] + ) + assert.deepEqual(tasks[0], { + id: 'inbox/A.md#0', + path: 'inbox/A.md', + noteTitle: 'A', + content: 'Overdue thing', + due: '2026-09-05', + overdue: true, + inProgress: false, + priority: null + }) + assert.equal(tasks[2]!.overdue, false) + assert.equal(tasks[2]!.inProgress, true) + const all = selectWidgetTasks(today, 2, '2026-09-08').tasks + assert.equal(all[3]!.content, 'Undated') + assert.equal(all[3]!.due, null) + assert.equal(all[3]!.priority, 'high') +}) + +test('tasks from deleted or trashed notes are dropped', () => { + const tasks = [ + { sourcePath: 'inbox/Keep.md', id: 'k' }, + { sourcePath: 'trash/Bin.md', id: 't' }, + { sourcePath: 'inbox/Gone.md', id: 'g' } + ] + const notes = [note('inbox/Keep.md', 1), note('trash/Bin.md', 1, 'trash')] + assert.deepEqual( + filterLiveTasks(tasks, notes).map((t) => t.id), + ['k'] + ) +}) diff --git a/src/bridge/widget-snapshot.ts b/src/bridge/widget-snapshot.ts new file mode 100644 index 0000000..bd950ab --- /dev/null +++ b/src/bridge/widget-snapshot.ts @@ -0,0 +1,226 @@ +/** + * The Home Screen / Lock Screen widget snapshot — the contract between the + * shell and the WidgetKit extension (ios/App/ZenWidgets). + * + * A widget extension runs in its own sandbox and cannot see the vault + * (on-device vaults live in the app's Documents container, iCloud vaults in + * the ubiquity container), so the app publishes what the widgets show — the + * active vault's pinned + recent notes, today's tasks, and the active theme's + * colors — as one JSON document in the App Group: WidgetBridgePlugin.swift + * writes it, WidgetSnapshot.swift decodes it. Keep the two sides in step; + * the Swift decoder treats every field a later shell might add as optional. + * + * Only pure selectors live here (node --test covers them). The store/pins + * wiring and the native call are in widgets.ts. + */ +import type { NoteMeta } from '@bridge-contract/ipc' +import type { VaultTask } from '@shared/tasks' +import { parseThemeBackdropColor } from './keyboard-backdrop-color.ts' + +export const WIDGET_SNAPSHOT_VERSION = 1 +/** Large Recent Notes shows nine rows; a few spares cover pins that vanish. */ +export const WIDGET_MAX_NOTES = 12 +/** Large Tasks shows eight rows plus a "+N more" footer. */ +export const WIDGET_MAX_TASKS = 12 + +export interface WidgetTheme { + mode: 'light' | 'dark' + /** Hex colors (#rrggbb) sampled from the app-core `--z-*` tokens. */ + bg: string + bg1: string + bg2: string + fg: string + fg2: string + muted: string + accent: string + red: string +} + +export interface WidgetNote { + path: string + title: string + folder: string + /** ms since epoch, as NoteMeta reports it. */ + updatedAt: number + pinned: boolean +} + +export interface WidgetTask { + /** VaultTask id (`${sourcePath}#${taskIndex}`) — the tap link carries it + * back so the shell can jump to the exact line. */ + id: string + path: string + noteTitle: string + content: string + /** ISO YYYY-MM-DD or null for an undated task (those sit in Today too). */ + due: string | null + overdue: boolean + inProgress: boolean + priority: string | null +} + +export interface WidgetTaskCounts { + /** Everything in the Today bucket, not just the rows that fit. */ + today: number + overdue: number +} + +export interface WidgetSnapshot { + version: typeof WIDGET_SNAPSHOT_VERSION + /** ms since epoch. */ + generatedAt: number + vaultName: string | null + theme: WidgetTheme + /** Pinned notes first (in pin order), then the most recently edited. */ + notes: WidgetNote[] + /** The Today bucket, in the Tasks view's order. */ + tasks: WidgetTask[] + taskCounts: WidgetTaskCounts + /** False until the first task scan for this vault has landed, so the + * widget shows "loading" rather than a misleading "All clear". */ + tasksReady: boolean +} + +/** ZenNotes' default theme (dark-hard), used before the first publish and + * for any token the active theme leaves undefined. */ +export const FALLBACK_WIDGET_THEME: WidgetTheme = { + mode: 'dark', + bg: '#1d2021', + bg1: '#32302f', + bg2: '#3c3836', + fg: '#d4be98', + fg2: '#ddc7a1', + muted: '#a89984', + accent: '#e78a4e', + red: '#ea6962' +} + +const THEME_TOKENS: Record, string> = { + bg: '--z-bg', + bg1: '--z-bg-1', + bg2: '--z-bg-2', + fg: '--z-fg', + fg2: '--z-fg-2', + muted: '--z-grey-2', + accent: '--z-accent', + red: '--z-red' +} + +/** `"29 32 33"` (the `--z-*` channel triplet form) → `"#1d2021"`. */ +export function channelsToHex(value: string): string | null { + const color = parseThemeBackdropColor(value) + if (!color) return null + return ( + '#' + + [color.red, color.green, color.blue].map((n) => n.toString(16).padStart(2, '0')).join('') + ) +} + +/** Resolve the widget palette from a token reader (getComputedStyle in the + * app); tokens that don't parse keep the fallback value. */ +export function themeFromTokens( + read: (token: string) => string, + mode: WidgetTheme['mode'] +): WidgetTheme { + const theme: WidgetTheme = { ...FALLBACK_WIDGET_THEME, mode } + for (const [key, token] of Object.entries(THEME_TOKENS) as Array< + [Exclude, string] + >) { + const hex = channelsToHex(read(token)) + if (hex) theme[key] = hex + } + return theme +} + +export type WidgetNoteSource = Pick + +/** + * Pinned notes first, in the order they were pinned (the drawer's own + * convention — pins sort to the top of their group), then the most recently + * edited notes, mirroring the Home dashboard's Recent list. Trash and + * Archive never show; a pin whose note is gone is skipped, not surfaced. + */ +export function selectWidgetNotes( + notes: readonly WidgetNoteSource[], + pinnedPaths: readonly string[], + max = WIDGET_MAX_NOTES +): WidgetNote[] { + const live = notes.filter((n) => n.folder !== 'trash' && n.folder !== 'archive') + const byPath = new Map(live.map((n) => [n.path, n] as const)) + const out: WidgetNote[] = [] + const seen = new Set() + const push = (note: WidgetNoteSource, pinned: boolean): void => { + if (seen.has(note.path) || out.length >= max) return + seen.add(note.path) + out.push({ + path: note.path, + title: note.title.trim() || 'Untitled', + folder: note.folder, + updatedAt: note.updatedAt, + pinned + }) + } + for (const path of pinnedPaths) { + const note = byPath.get(path) + if (note) push(note, true) + } + for (const note of live.slice().sort((a, b) => b.updatedAt - a.updatedAt)) { + if (out.length >= max) break + push(note, false) + } + return out +} + +export type WidgetTaskSource = Pick< + VaultTask, + 'id' | 'sourcePath' | 'noteTitle' | 'content' | 'due' | 'inProgress' | 'priority' +> + +/** + * Drop tasks whose note no longer exists (or sits in Trash). App-core keeps + * `vaultTasks` fresh only while a tasks surface is on screen, so on the + * phone a note deleted from the drawer can leave its tasks in the cache + * until the next full scan — the widget must not show them. + */ +export function filterLiveTasks( + tasks: readonly T[], + notes: readonly Pick[] +): T[] { + const live = new Set(notes.filter((n) => n.folder !== 'trash').map((n) => n.path)) + return tasks.filter((t) => live.has(t.sourcePath)) +} + +/** + * The rows for the Tasks widget from the Today bucket app-core's + * `computeTasksRender` produces (due today, overdue, or undated — the same + * list the Home dashboard shows), plus the counts the header needs even + * when rows are cut off. `todayIso` is the local calendar day. + * + * One departure from the bucket's file order: overdue tasks lead. The + * widget shows three to eight rows under a header that counts the overdue + * ones, and in a vault with many undated tasks the bucket order would keep + * every overdue row out of sight. The sort is stable, so everything else + * keeps the app's order. + */ +export function selectWidgetTasks( + today: readonly WidgetTaskSource[], + overdueCount: number, + todayIso: string, + max = WIDGET_MAX_TASKS +): { tasks: WidgetTask[]; counts: WidgetTaskCounts } { + const isOverdue = (t: WidgetTaskSource): boolean => typeof t.due === 'string' && t.due < todayIso + const ordered = today.slice().sort((a, b) => Number(isOverdue(b)) - Number(isOverdue(a))) + const tasks = ordered.slice(0, max).map( + (t): WidgetTask => ({ + id: t.id, + path: t.sourcePath, + noteTitle: t.noteTitle, + content: t.content.trim() || 'Untitled task', + due: t.due ?? null, + overdue: typeof t.due === 'string' && t.due < todayIso, + inProgress: t.inProgress, + priority: t.priority ?? null + }) + ) + return { tasks, counts: { today: today.length, overdue: overdueCount } } +} diff --git a/src/bridge/widgets.ts b/src/bridge/widgets.ts new file mode 100644 index 0000000..2239d96 --- /dev/null +++ b/src/bridge/widgets.ts @@ -0,0 +1,190 @@ +/** + * Widget publisher: keeps the App Group snapshot the WidgetKit extension + * renders (widget-snapshot.ts is the contract) in step with the store. + * + * Sources of change are the note index (every rescan and vault mutation + * replaces `notes`), the shared task cache, the drawer's pins, the active + * vault, and the theme. Each publish is a WidgetKit reload, so the first + * change after a quiet spell goes out almost at once and edits that keep + * landing (every autosave bumps a note's updatedAt) are coalesced to one + * publish per interval; backgrounding flushes whatever is pending so the + * Home Screen is current the moment the user leaves. + * + * Task freshness is this module's job too: app-core rescans a note's tasks + * on change only while a tasks surface is on screen (store.ts, + * `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. + */ +import { App as CapApp } from '@capacitor/app' +import { Capacitor, registerPlugin } from '@capacitor/core' +import { useStore } from '@zennotes/app-core/store' +import { computeTasksRender } from '@zennotes/app-core/lib/tasks-filter' +import { filterTasksForDisplay, toIsoDateLocal } from '@shared/tasks' +import { activeVaultStateKey, isMobileNoteIndexReady } from './mobile-bridge' +import { getPinnedNotes, subscribePins } from '../ui-mobile/pins' +import { + WIDGET_SNAPSHOT_VERSION, + filterLiveTasks, + selectWidgetNotes, + selectWidgetTasks, + themeFromTokens, + type WidgetSnapshot, + type WidgetTheme +} from './widget-snapshot' + +interface ZenWidgetsPlugin { + update(options: { snapshot: string }): Promise + clear(): Promise + /** 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 }> +} + +export const ZenWidgets = registerPlugin('ZenWidgets') + +const NO_COLLAPSE = { + today: false, + upcoming: false, + waiting: false, + forwarded: false, + done: false, + cancelled: false +} + +const PUBLISH_DEBOUNCE_MS = 400 +const PUBLISH_MIN_INTERVAL_MS = 8000 +/** Past this many changed notes one full scan beats per-note rescans. */ +const RESCAN_BATCH_LIMIT = 8 + +type StoreState = ReturnType + +let timer = 0 +let lastPublishedAt = 0 +let lastPayload = '' +let taskVaultKey: string | null = null +let tasksSettled = false +let knownUpdatedAt = new Map() + +function themeMode(): WidgetTheme['mode'] { + return document.documentElement.dataset.themeMode === 'light' ? 'light' : 'dark' +} + +function buildSnapshot(state: StoreState, now: Date): Omit { + const style = getComputedStyle(document.documentElement) + const live = filterLiveTasks( + filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks), + state.notes + ) + const render = computeTasksRender(live, '', now, NO_COLLAPSE) + const { tasks, counts } = selectWidgetTasks( + render.groups.today, + render.groups.overdueCount ?? 0, + toIsoDateLocal(now) + ) + return { + version: WIDGET_SNAPSHOT_VERSION, + vaultName: state.vault?.name ?? null, + theme: themeFromTokens((token) => style.getPropertyValue(token), themeMode()), + notes: selectWidgetNotes(state.notes, getPinnedNotes(activeVaultStateKey())), + tasks, + taskCounts: counts, + tasksReady: tasksSettled + } +} + +async function publish(): Promise { + const state = useStore.getState() + // No vault (onboarding, a switch in flight): keep whatever the widgets + // already show rather than blanking them. + if (!state.vault) return + let body: Omit + try { + body = buildSnapshot(state, new Date()) + } catch (err) { + console.error('widget snapshot failed', err) + return + } + const payload = JSON.stringify(body) + if (payload === lastPayload) return + lastPayload = payload + lastPublishedAt = Date.now() + const snapshot: WidgetSnapshot = { ...body, generatedAt: Date.now() } + await ZenWidgets.update({ snapshot: JSON.stringify(snapshot) }).catch(() => {}) +} + +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() + }, wait) +} + +function flush(): void { + if (!timer) return + window.clearTimeout(timer) + timer = 0 + void publish() +} + +function reconcileTasks(state: StoreState, prev: StoreState | null): void { + if (!state.vault || !isMobileNoteIndexReady()) return + const key = activeVaultStateKey() + if (key !== taskVaultKey) { + taskVaultKey = key + tasksSettled = false + knownUpdatedAt = new Map(state.notes.map((n) => [n.path, n.updatedAt])) + if (!state.tasksLoading) void state.refreshTasks() + return + } + if (prev && prev.tasksLoading && !state.tasksLoading) tasksSettled = true + if (!prev || state.notes === prev.notes) return + const changed: string[] = [] + const next = new Map() + for (const n of state.notes) { + next.set(n.path, n.updatedAt) + if (n.folder !== 'trash' && knownUpdatedAt.get(n.path) !== n.updatedAt) changed.push(n.path) + } + knownUpdatedAt = next + if (changed.length === 0) return + if (changed.length > RESCAN_BATCH_LIMIT) { + if (!state.tasksLoading) void state.refreshTasks() + return + } + for (const path of changed) void state.rescanTasksForPath(path) +} + +/** Start publishing; returns the teardown (tests / hot paths — the shell + * itself never stops). No-op off the native platform. */ +export function installWidgetPublisher(): () => void { + if (!Capacitor.isNativePlatform()) return () => {} + const unsubStore = useStore.subscribe((state, prev) => { + reconcileTasks(state, prev) + if ( + state.notes !== prev.notes || + state.vaultTasks !== prev.vaultTasks || + state.showArchivedTasks !== prev.showArchivedTasks || + state.tasksLoading !== prev.tasksLoading || + state.vault !== prev.vault || + state.themeId !== prev.themeId || + state.themeMode !== prev.themeMode + ) { + schedule() + } + }) + const unsubPins = subscribePins(schedule) + const appState = CapApp.addListener('appStateChange', ({ isActive }) => { + if (!isActive) flush() + }) + reconcileTasks(useStore.getState(), null) + schedule() + return () => { + unsubStore() + unsubPins() + void appState.then((handle) => handle.remove()).catch(() => {}) + window.clearTimeout(timer) + timer = 0 + } +} diff --git a/src/main.tsx b/src/main.tsx index c602ed2..d284c27 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,8 @@ import { import { syncKeyboardBackdrop } from './bridge/keyboard-backdrop' import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth' import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding' +import { installWidgetPublisher } from './bridge/widgets' +import { installDeepLinks } from './ui-mobile/deep-links' import { mountMobileShell } from './ui-mobile/MobileShell' import { installHomeGuard } from './ui-mobile/nav' import { refreshVault, wireICloudLiveRefresh } from './ui-mobile/refresh' @@ -111,6 +113,10 @@ async function boot(): Promise { if (!root) throw new Error('Renderer root element #root was not found') renderZenNotesApp(root) mountMobileShell() + // Home Screen / Lock Screen widgets: publish what they show, and run the + // links they open the app with (both wait for the workspace themselves). + installWidgetPublisher() + installDeepLinks() } void boot().catch((err) => { diff --git a/src/ui-mobile/deep-links.ts b/src/ui-mobile/deep-links.ts new file mode 100644 index 0000000..f45096a --- /dev/null +++ b/src/ui-mobile/deep-links.ts @@ -0,0 +1,151 @@ +/** + * Runs the widgets' `zennotes://` links (widget-links.ts parses them). + * + * Delivery: `appUrlOpen` while the app runs (a widget tap reaches the + * single-task activity as a new intent; on iOS, an open-URL), and at boot the newest link the + * native side saw — `ZenWidgets.consumeLaunchLink()`, kept by the platform + * plugin from every URL open. Capacitor's own `getLaunchUrl` stays the + * fallback: on Android it captures the activity's intent once, and an + * activity recreated into its old task reports the task's ORIGINAL intent + * there while the tap that actually woke it arrives as a retained + * `appUrlOpen` the Cloud auth listener (registered first) consumes. The + * Cloud auth callback shares the scheme; its listener ignores these links + * and this one ignores `zennotes://auth`. + * + * A link waits for the workspace: the vault open, the store restored, and + * the note index in, since a stale row (the note was deleted after the last + * publish) must fall back to Home, not throw. Links that land while booting + * coalesce to the newest one — a wake can deliver the old task intent and + * the real tap back to back, and only the last is the user's. The runner + * then yields a beat so the shell's own cold-launch landing + * (usePhoneLayoutBoot: Home, or where the user left) has run first and the + * link wins, exactly as tapping the note in the app would. + */ +import { App as CapApp } from '@capacitor/app' +import { Capacitor } from '@capacitor/core' +import { useStore } from '@zennotes/app-core/store' +import { isMobileNoteIndexReady } from '../bridge/mobile-bridge' +import { ZenWidgets } from '../bridge/widgets' +import { setDrawerOpen } from './drawer-state' +import { goHome } from './nav' +import { closeNoteMenu } from './note-actions' +import { closeMobileSheet } from './sheet-state' +import { parseWidgetLink, type WidgetLink } from './widget-links' + +const DUPLICATE_WINDOW_MS = 5000 +const LANDING_SETTLE_MS = 80 +/** A vault that never becomes ready (remote workspace offline, onboarding + * abandoned) must not pin a link forever; past this the link runs anyway + * and its own guards decide. */ +const READY_TIMEOUT_MS = 20000 + +type StoreState = ReturnType + +let lastUrl = '' +let lastAt = 0 +let pending: WidgetLink | null = null +let waiting = false + +export function installDeepLinks(): void { + if (!Capacitor.isNativePlatform()) return + void CapApp.addListener('appUrlOpen', ({ url }) => handleDeepLink(url)).catch(() => {}) + void ZenWidgets.consumeLaunchLink() + .then((result) => { + if (result?.url) handleDeepLink(result.url) + }) + .catch(() => + CapApp.getLaunchUrl() + .then((launch) => { + if (launch?.url) handleDeepLink(launch.url) + }) + .catch(() => {}) + ) +} + +export function handleDeepLink(raw: string): void { + const link = parseWidgetLink(raw) + if (!link) return + const now = Date.now() + if (raw === lastUrl && now - lastAt < DUPLICATE_WINDOW_MS) return + lastUrl = raw + lastAt = now + if (isReady()) { + window.setTimeout(() => void run(link), LANDING_SETTLE_MS) + return + } + pending = link + if (waiting) return + waiting = true + whenReady(() => { + waiting = false + const next = pending + pending = null + if (next) void run(next) + }) +} + +function isReady(): boolean { + const s = useStore.getState() + return Boolean(s.vault) && s.workspaceRestored && isMobileNoteIndexReady() +} + +function whenReady(cb: () => void): void { + let done = false + const finish = (): void => { + if (done) return + done = true + unsub() + window.clearTimeout(deadline) + window.setTimeout(cb, LANDING_SETTLE_MS) + } + const unsub = useStore.subscribe(() => { + if (isReady()) finish() + }) + const deadline = window.setTimeout(finish, READY_TIMEOUT_MS) +} + +function hasNote(s: StoreState, path: string): boolean { + return s.notes.some((n) => n.path === path && n.folder !== 'trash') +} + +async function run(link: WidgetLink): Promise { + // Whatever chrome was up when the user left is in the way now. + closeMobileSheet() + closeNoteMenu() + setDrawerOpen(false) + const s = useStore.getState() + switch (link.kind) { + case 'new': + // The ⊕ sheet's "New note" (commands.ts `note.new.inbox`). + await s.createAndOpen('inbox', '', { focusTitle: true }) + return + case 'open': + if (hasNote(s, link.path)) await s.selectNote(link.path) + else goHome() + return + case 'task': + await openTask(s, link) + return + case 'tasks': + await s.openTasksView() + return + case 'home': + goHome() + return + } +} + +async function openTask(s: StoreState, link: Extract): Promise { + if (!hasNote(s, link.path)) { + goHome() + return + } + let task = s.vaultTasks.find((t) => t.id === link.id) + if (!task) { + // The cache is lazy on the phone; one per-note scan is enough to find it. + await s.rescanTasksForPath(link.path) + task = useStore.getState().vaultTasks.find((t) => t.id === link.id) + } + if (task) await useStore.getState().openTaskAt(task) + else await useStore.getState().selectNote(link.path) +} diff --git a/src/ui-mobile/pins.ts b/src/ui-mobile/pins.ts index adaa2ec..ca29def 100644 --- a/src/ui-mobile/pins.ts +++ b/src/ui-mobile/pins.ts @@ -137,6 +137,14 @@ export function toggleFolderPin( toggle(vaultKey, 'folders', subpath, livePaths) } +/** Plain subscription for code outside React (the widget publisher). */ +export function subscribePins(cb: () => void): () => void { + subscribers.add(cb) + return () => { + subscribers.delete(cb) + } +} + /** Reactive pins for a vault; stable snapshot while nothing changes. */ export function usePins(vaultKey: string | null): VaultPins { return useSyncExternalStore( diff --git a/src/ui-mobile/widget-links.test.ts b/src/ui-mobile/widget-links.test.ts new file mode 100644 index 0000000..0c4e2d3 --- /dev/null +++ b/src/ui-mobile/widget-links.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { isSafeVaultPath, parseWidgetLink } from './widget-links.ts' + +test('the action links parse by host', () => { + assert.deepEqual(parseWidgetLink('zennotes://new'), { kind: 'new' }) + assert.deepEqual(parseWidgetLink('zennotes://tasks'), { kind: 'tasks' }) + assert.deepEqual(parseWidgetLink('zennotes://home'), { kind: 'home' }) + assert.deepEqual(parseWidgetLink(' zennotes://NEW '), { kind: 'new' }) + assert.deepEqual(parseWidgetLink('zennotes:new'), { kind: 'new' }) +}) + +test('open carries a decoded vault path', () => { + assert.deepEqual(parseWidgetLink('zennotes://open?path=inbox%2FMeeting%20notes.md'), { + kind: 'open', + path: 'inbox/Meeting notes.md' + }) + assert.deepEqual(parseWidgetLink('zennotes://open?path=Ideas%20%26%20plans.md'), { + kind: 'open', + path: 'Ideas & plans.md' + }) + assert.equal(parseWidgetLink('zennotes://open'), null) + assert.equal(parseWidgetLink('zennotes://open?path='), null) +}) + +test('task carries the VaultTask id; the path is explicit or derived from the id', () => { + assert.deepEqual( + parseWidgetLink('zennotes://task?id=inbox%2FA.md%233&path=inbox%2FA.md'), + { kind: 'task', id: 'inbox/A.md#3', path: 'inbox/A.md' } + ) + assert.deepEqual(parseWidgetLink('zennotes://task?id=quick%2FQ.md%230'), { + kind: 'task', + id: 'quick/Q.md#0', + path: 'quick/Q.md' + }) + assert.equal(parseWidgetLink('zennotes://task?id=%230'), null) + assert.equal(parseWidgetLink('zennotes://task'), null) +}) + +test('unsafe paths are refused', () => { + assert.equal(isSafeVaultPath('inbox/a.md'), true) + assert.equal(isSafeVaultPath('a.md'), true) + assert.equal(isSafeVaultPath(''), false) + assert.equal(isSafeVaultPath('/etc/passwd'), false) + assert.equal(isSafeVaultPath('../secret.md'), false) + assert.equal(isSafeVaultPath('inbox/../../x.md'), false) + assert.equal(isSafeVaultPath('inbox//x.md'), false) + assert.equal(isSafeVaultPath('inbox/./x.md'), false) + assert.equal(isSafeVaultPath('inbox\\x.md'), false) + assert.equal(parseWidgetLink('zennotes://open?path=..%2Fsecret.md'), null) +}) + +test('other schemes, unknown actions, and the Cloud auth callback are not ours', () => { + assert.equal(parseWidgetLink('zennotes://auth?code=abc&state=xyz'), null) + assert.equal(parseWidgetLink('zennotes://settings'), null) + assert.equal(parseWidgetLink('https://zennotes.org/open?path=a.md'), null) + assert.equal(parseWidgetLink('not a url'), null) + assert.equal(parseWidgetLink(''), null) +}) diff --git a/src/ui-mobile/widget-links.ts b/src/ui-mobile/widget-links.ts new file mode 100644 index 0000000..f0701b7 --- /dev/null +++ b/src/ui-mobile/widget-links.ts @@ -0,0 +1,61 @@ +/** + * The `zennotes://` links the widgets fire (ios/App/ZenWidgets — + * WidgetTheme.swift's ZenLinks builds them; deep-links.ts runs them): + * + * zennotes://new create a note in the Inbox and open it + * zennotes://open?path= open a note by vault-relative path + * zennotes://task?id=&path= jump to a task line (VaultTask id) + * zennotes://tasks the Tasks view + * zennotes://home the Home dashboard + * + * Values are percent-encoded with only unreserved characters left bare, so + * `#` (task ids are `path#index`) and `&` in titles survive the trip. The + * Cloud auth callback (`zennotes://auth?…`) shares the scheme and is not + * ours — it parses to null here. Pure, so node --test covers it. + */ +export type WidgetLink = + | { kind: 'new' } + | { kind: 'open'; path: string } + | { kind: 'task'; id: string; path: string } + | { kind: 'tasks' } + | { kind: 'home' } + +/** Vault-relative, forward-slash, no traversal — the same shape the bridge's + * `resolveSafeRel` accepts, checked here so a bad link never throws. */ +export function isSafeVaultPath(path: string): boolean { + if (!path || path.length > 2048) return false + if (path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false + return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') +} + +export function parseWidgetLink(raw: string): WidgetLink | null { + let url: URL + try { + url = new URL(raw.trim()) + } catch { + return null + } + if (url.protocol !== 'zennotes:') return null + const action = (url.hostname || url.pathname.replace(/^\/+/, '')).toLowerCase() + switch (action) { + case 'new': + return { kind: 'new' } + case 'tasks': + return { kind: 'tasks' } + case 'home': + return { kind: 'home' } + case 'open': { + const path = url.searchParams.get('path') ?? '' + return isSafeVaultPath(path) ? { kind: 'open', path } : null + } + case 'task': { + const id = url.searchParams.get('id') ?? '' + const hash = id.lastIndexOf('#') + const path = url.searchParams.get('path') ?? (hash > 0 ? id.slice(0, hash) : '') + if (!id || !isSafeVaultPath(path)) return null + return { kind: 'task', id, path } + } + default: + return null + } +} diff --git a/tooling/add-privacy-manifests.rb b/tooling/add-privacy-manifests.rb index fdd1b0b..faef59a 100644 --- a/tooling/add-privacy-manifests.rb +++ b/tooling/add-privacy-manifests.rb @@ -12,7 +12,8 @@ { 'App' => 'App', - 'ShareExtension' => 'ShareExtension' + 'ShareExtension' => 'ShareExtension', + 'ZenWidgets' => 'ZenWidgets' }.each do |target_name, group_name| target = project.targets.find { |t| t.name == target_name } raise "#{target_name} target not found" unless target diff --git a/tooling/add-widget-extension.rb b/tooling/add-widget-extension.rb new file mode 100644 index 0000000..099f359 --- /dev/null +++ b/tooling/add-widget-extension.rb @@ -0,0 +1,94 @@ +# Wires the ZenWidgets WidgetKit extension target into the Capacitor-generated +# Xcode project (Home Screen / Lock Screen widgets, ios/App/ZenWidgets), and +# adds the app-side WidgetBridgePlugin.swift to the App target. Mirrors +# add-share-extension.rb. Idempotent: safe to re-run (skips work that's +# already done). Re-run it if the Xcode project is ever regenerated. +# +# Run with the xcodeproj gem vendored in Homebrew's CocoaPods: +# GEM_PATH=$(ls -d /opt/homebrew/Cellar/cocoapods/*/libexec | head -1) \ +# ruby tooling/add-widget-extension.rb +require 'xcodeproj' + +project_path = File.expand_path('../ios/App/App.xcodeproj', __dir__) +project = Xcodeproj::Project.open(project_path) + +app_target = project.targets.find { |t| t.name == 'App' } +raise 'App target not found' unless app_target + +TARGET_NAME = 'ZenWidgets' +BUNDLE_ID = 'md.zennotes.ZenWidgets' +# Keep in lockstep with the other nine IPHONEOS_DEPLOYMENT_TARGET sites +# (project + App + ShareExtension + AppUITests, Debug/Release) and the Podfile. +DEPLOYMENT_TARGET = '15.0' +SOURCES = %w[ + ZenWidgetsBundle.swift + WidgetSnapshot.swift + WidgetTheme.swift + NewNoteWidget.swift + RecentNotesWidget.swift + TasksWidget.swift +].freeze + +# --- App target: the publisher plugin -------------------------------------- + +app_group = project.main_group['App'] +%w[WidgetBridgePlugin.swift].each do |name| + next if app_group.files.any? { |f| f.display_name == name } + ref = app_group.new_reference(name) + app_target.add_file_references([ref]) + puts "added #{name} to App target" +end + +# --- ZenWidgets target ------------------------------------------------------ + +ext_target = project.targets.find { |t| t.name == TARGET_NAME } +if ext_target.nil? + ext_target = project.new_target(:app_extension, TARGET_NAME, :ios, DEPLOYMENT_TARGET) + puts "created #{TARGET_NAME} target" + + ext_group = project.main_group.new_group(TARGET_NAME, TARGET_NAME) + ext_target.add_file_references(SOURCES.map { |name| ext_group.new_reference(name) }) + ext_group.new_reference('Info.plist') + ext_group.new_reference("#{TARGET_NAME}.entitlements") + assets = ext_group.new_reference('Assets.xcassets') + privacy = ext_group.new_reference('PrivacyInfo.xcprivacy') + ext_target.add_resources([assets, privacy]) + ext_target.add_system_frameworks(%w[WidgetKit SwiftUI]) + + # Version numbers and the signing team follow the App target's same-named + # configuration, so the release bump (MARKETING_VERSION + + # CURRENT_PROJECT_VERSION across every target) has one more pair to touch. + app_settings = app_target.build_configurations.to_h { |c| [c.name, c.build_settings] } + ext_target.build_configurations.each do |config| + from_app = app_settings[config.name] || {} + s = config.build_settings + s['PRODUCT_BUNDLE_IDENTIFIER'] = BUNDLE_ID + s['INFOPLIST_FILE'] = "#{TARGET_NAME}/Info.plist" + s['CODE_SIGN_ENTITLEMENTS'] = "#{TARGET_NAME}/#{TARGET_NAME}.entitlements" + s['SWIFT_VERSION'] = '5.0' + s['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOYMENT_TARGET + s['TARGETED_DEVICE_FAMILY'] = '1,2' + s['GENERATE_INFOPLIST_FILE'] = 'NO' + s['SKIP_INSTALL'] = 'YES' + s['CODE_SIGN_STYLE'] = 'Automatic' + s['MARKETING_VERSION'] = from_app['MARKETING_VERSION'] || '1.0' + s['CURRENT_PROJECT_VERSION'] = from_app['CURRENT_PROJECT_VERSION'] || '1' + s['DEVELOPMENT_TEAM'] = from_app['DEVELOPMENT_TEAM'] if from_app['DEVELOPMENT_TEAM'] + s['PRODUCT_NAME'] = '$(TARGET_NAME)' + end + + app_target.add_dependency(ext_target) + + embed = app_target.copy_files_build_phases.find { |p| p.name == 'Embed Foundation Extensions' } + if embed.nil? + embed = app_target.new_copy_files_build_phase('Embed Foundation Extensions') + embed.dst_subfolder_spec = Xcodeproj::Constants::COPY_FILES_BUILD_PHASE_DESTINATIONS[:plug_ins] + embed.dst_path = '' + end + build_file = embed.add_file_reference(ext_target.product_reference) + build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] } + puts "embedded #{TARGET_NAME} into App" +end + +project.save +puts 'project saved' From bd8b525f8d05321bc95efb204fd79455bd2cd4f4 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 9 Sep 2026 14:43:28 -0500 Subject: [PATCH 02/20] fix(mobile): keep comment authors and threads when the phone rewrites a sidecar --- src/bridge/vault-fs.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 503ddad..229d9f0 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -25,6 +25,7 @@ import type { CustomTemplateFile, WriteTemplateInput } from '@bridge-contract/te import type { VaultTask } from '@shared/tasks' import { parseTaskFile, parseTasksFromBody } from '@shared/tasks' import { normalizeHarperVaultState } from '@shared/harper-settings' +import { normalizeNoteComments } from '@shared/note-comments' import { isPathExcludedFromTasks, normalizeTasksExcludedFolders @@ -944,14 +945,11 @@ export class MobileVault { } async readNoteComments(relPath: string): Promise { - const raw = await this.fs.readTextOrNull(this.commentsPathFor(resolveSafeRel(relPath))) + const rel = resolveSafeRel(relPath) + const raw = await this.fs.readTextOrNull(this.commentsPathFor(rel)) if (!raw) return [] try { - const parsed = JSON.parse(raw) as { comments?: NoteComment[] } | NoteComment[] - const list = Array.isArray(parsed) ? parsed : (parsed.comments ?? []) - return list - .filter((c) => c && typeof c === 'object') - .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)) + return normalizeNoteComments(JSON.parse(raw), rel) } catch { return [] } @@ -968,18 +966,11 @@ export class MobileVault { async writeNoteComments(relPath: string, inputs: NoteCommentInput[]): Promise { const rel = resolveSafeRel(relPath) - const now = Date.now() - const comments: NoteComment[] = inputs.map((input) => ({ - id: input.id ?? uuid(), - notePath: rel, - anchorStart: Math.max(0, Math.min(input.anchorStart, input.anchorEnd)), - anchorEnd: Math.max(0, Math.max(input.anchorStart, input.anchorEnd)), - anchorText: (input.anchorText ?? '').slice(0, 500), - body: input.body ?? '', - createdAt: input.createdAt ?? now, - updatedAt: input.updatedAt ?? now, - resolvedAt: input.resolvedAt ?? null - })) + // Desktop's own normalizer (shared since app core 2.46). App-core always + // hands over the whole list, so the writer must keep the optional `author` + // and `parentId` a desktop or an assistant wrote, or one comment action on + // the phone flattens every thread and drops every name in the sidecar. + const comments = normalizeNoteComments(inputs, rel) await this.writeCommentsFile(rel, comments) emitVaultChange({ kind: 'change', From 41758bdc340d2790cf8bca0101e4e5cc87919518 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 9 Sep 2026 14:43:28 -0500 Subject: [PATCH 03/20] Version 1.9.8 (build 19) --- ios/App/App.xcodeproj/project.pbxproj | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index cd5aecf..e21812d 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -576,12 +576,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.9.8; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -705,12 +705,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.9.8; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -727,12 +727,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.9.8; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -747,12 +747,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.9.8; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -769,12 +769,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.9.8; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -807,12 +807,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 19; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.7; + MARKETING_VERSION = 1.9.8; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 7a8b7efc1bc2c597f887ba8cf5438932883cd6bc Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 9 Sep 2026 14:43:28 -0500 Subject: [PATCH 04/20] docs: 1.9.8 release pack (widgets, app core 2.46) --- .../releases/v1.9.8/APP_STORE_DESCRIPTION.txt | 42 ++++++++ docs/releases/v1.9.8/APP_STORE_METADATA.md | 95 +++++++++++++++++++ .../v1.9.8/APP_STORE_REVIEW_NOTES.txt | 14 +++ docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt | 1 + docs/releases/v1.9.8/RELEASE_NOTES.md | 75 +++++++++++++++ docs/releases/v1.9.8/SOCIAL.md | 20 ++++ docs/releases/v1.9.8/WHATS_NEW.txt | 16 ++++ docs/releases/v1.9.8/twitter-post.md | 5 + 8 files changed, 268 insertions(+) create mode 100644 docs/releases/v1.9.8/APP_STORE_DESCRIPTION.txt create mode 100644 docs/releases/v1.9.8/APP_STORE_METADATA.md create mode 100644 docs/releases/v1.9.8/APP_STORE_REVIEW_NOTES.txt create mode 100644 docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt create mode 100644 docs/releases/v1.9.8/RELEASE_NOTES.md create mode 100644 docs/releases/v1.9.8/SOCIAL.md create mode 100644 docs/releases/v1.9.8/WHATS_NEW.txt create mode 100644 docs/releases/v1.9.8/twitter-post.md diff --git a/docs/releases/v1.9.8/APP_STORE_DESCRIPTION.txt b/docs/releases/v1.9.8/APP_STORE_DESCRIPTION.txt new file mode 100644 index 0000000..28985ef --- /dev/null +++ b/docs/releases/v1.9.8/APP_STORE_DESCRIPTION.txt @@ -0,0 +1,42 @@ +ZenNotes is a local-first markdown notes app for writing, organizing, and connecting ideas without giving up ownership of your files. + +YOUR NOTES, YOUR FILES + +Every note is a plain .md file in a vault you control. Start with storage on your iPhone or iPad, use iCloud Drive, or open a folder through the system document picker. Local and iCloud vaults work without an account. + +ZENNOTES CLOUD + +Connect an optional ZenNotes Cloud plan to: +• Sync a vault across ZenNotes desktop and mobile +• Sync notes and attachments +• Create manual and automatic daily backups +• Restore a full vault or a single note from a backup +• Publish notes to the web and manage their public links + +Cloud never replaces the free local-first workflow. Use it only when you want hosted sync, backup, or publishing. + +WRITE IN MARKDOWN + +Use a focused editor with headings, lists, tasks, tables, code, links, wikilinks, tags, callouts, footnotes, and frontmatter. A mobile formatting bar keeps common actions close to the keyboard, and a pinch resizes the text to your eyes. + +RICH, OFFLINE PREVIEW + +Render KaTeX math, Mermaid diagrams, JSXGraph, function plots, tables, callouts, and more directly on your device. + +ORGANIZE YOUR WAY + +Pin your go-to notes and folders to the top, swipe rows to act on them, create and rename nested folders, browse tags, search the full vault, manage tasks, create periodic notes, and work with table databases stored as CSV. + +CAPTURE QUICKLY + +Create a quick note from the app or send text and links to ZenNotes from the iOS share sheet. + +HOME SCREEN WIDGETS + +Start a note, open a recent one, or check today's tasks without opening the app. New Note also lives on the Lock Screen, and the widgets wear your theme. + +PRIVACY BY DEFAULT + +ZenNotes has no advertising or tracking. Local and iCloud notes stay in the storage you choose. When you enable ZenNotes Cloud, the account identity, connected-device information, and vault content needed for the Cloud features you use are sent to the ZenNotes service. Payment details are handled by Stripe. + +ZenNotes is open source. Your vault remains useful outside the app because it is made of ordinary files. diff --git a/docs/releases/v1.9.8/APP_STORE_METADATA.md b/docs/releases/v1.9.8/APP_STORE_METADATA.md new file mode 100644 index 0000000..ce5237f --- /dev/null +++ b/docs/releases/v1.9.8/APP_STORE_METADATA.md @@ -0,0 +1,95 @@ +# App Store Connect metadata: ZenNotes 1.9.8 + +| Field | Value | Limit | +| --- | --- | --- | +| Name | ZenNotes: Markdown Notes | 30 | +| Subtitle | Plain-file notes, math, tasks | 30 | +| Category | Productivity (secondary: Utilities) | n/a | +| Keywords | markdown,notes,widgets,sync,backup,offline,icloud,wikilink,math,tasks,vault,editor | 100 | +| Promotional text | see `PROMOTIONAL_TEXT.txt` | 170 | +| Description | see `APP_STORE_DESCRIPTION.txt` (updated: widgets section) | 4000 | +| What's New | see `WHATS_NEW.txt` | 4000 | +| Review notes | see `APP_STORE_REVIEW_NOTES.txt` (no account required) | n/a | +| Support URL | https://github.com/ZenNotes/zennotes/issues | n/a | +| Marketing URL | https://zennotes.org | n/a | +| Privacy policy URL | Unchanged from 1.9 | n/a | +| Age rating | Unchanged from 1.9 (4+) | n/a | +| Price | Free app; optional external SaaS subscription | n/a | +| Version | 1.9.8 (build 19) | n/a | + +## App Privacy + +**Unchanged from 1.9.7.** The widgets read a summary file the app writes +into its App Group container on the device: note titles, paths and +modification dates, today's task lines, and the theme's colors. No note +bodies, nothing off the device, no new processing. From app core 2.46, +comment authorship is a name stored in the note's own comment file inside +your vault, and saved Tasks filters are a device preference. No new +permissions, data collection, accounts, background modes, network +services, or third-party SDKs. The App Group (`group.md.zennotes`) was +already in use by the Share Extension; the new extension only adds a +second reader. + +## Release checks + +- Version 1.9.8 (build 19) is set in all six Xcode build configurations + (App, ShareExtension, ZenWidgets × Debug/Release), committed on its own + after the widget commit. Build 19 is unused: the last archive in Xcode + is 1.9.7 (18) from 2026-09-08, and no 1.9.8 build was ever archived or + uploaded. +- Branch: `release/1.9.8` off main, opened as `release/1.9.9` on the + assumption that 1.9.8 had shipped and renamed on 2026-09-09. Commits: + the widgets, the comment-sidecar fix, the version bump, this pack. main + already carried the + a3e638fc pin (app core 2.46.0 plus the js-yaml / svgo lockfile fix) + and the shell's own js-yaml bump. +- Supersedes the pin-only `release/1.9.8` still on origin (1.9.8 build 19 + at the `v2.46.0` tag `da59c372`, pack "app core 2.46"), which was never + archived, submitted, or merged; its local copy was deleted when this + branch took the name, so the first push needs `--force-with-lease`. 1.9.7 shipped on app core 2.45, so its + five phone-visible changes are folded into this pack's What's New, + review notes, and release notes. +- Pin: `.zennotes-commit` = `a3e638fc`, on ZenNotes/zennotes `main`. + Between 1.9.7's pin (`3301a29d`, one past `v2.45.0`) and this one, + app-core gains comment threads with authors and replies, the @ menu + calendar, saved Tasks filters, the folder-based Kanban board, and the + two math fixes; the keymap unbind and ignored-keys features are desktop + surfaces; the bridge contract gains optional comment fields (`author`, + `parentId`) and the `kanbanFolderRoot` view setting. +- Manual mirror (`git diff --stat 3301a29d..a3e638fc -- packages/bridge-contract + apps/desktop/src/main/vault.ts`): desktop `vault.ts` dropped its private + comment normalizer for the shared `@shared/note-comments`, which keeps + `author` and `parentId`. The shell's `MobileVault.writeNoteComments` + still rebuilt each record from a fixed field list, and app-core hands + over the whole list on every comment action, so one reply, resolve, or + delete on the phone would have flattened every thread and dropped every + name the desktop or an assistant had written. Fixed in this release: + `src/bridge/vault-fs.ts` reads and writes the sidecar through the shared + normalizer, as desktop does. `kanbanFolderRoot` needs nothing: the shell + keeps no field list for view settings. +- New target: `ZenWidgets`, bundle `md.zennotes.ZenWidgets`, deployment + target 15.0 like the rest, embedded in Embed Foundation Extensions, App + Group entitlement only, privacy manifest with no required-reason APIs. + Wired by `tooling/add-widget-extension.rb`; `add-privacy-manifests.rb` + knows the target. +- Verified 2026-09-08 on the iPhone 17 Pro simulator (iOS 26.5): the + gallery previews, all three widgets placed, warm and cold taps land on + the right note and task line, a new note from the widget, the widgets + updating within seconds of a change; xcodebuild clean with no warnings + in the new files; boot-path chunking check unchanged. +- Re-verified 2026-09-09 after renumbering 1.9.9 (20) to 1.9.8 (19) and + the comment-sidecar fix: `npm test` 45/45, `npm run typecheck` clean at + the pin, `npm run build` clean, and `dist/index.html` modulepreloads + unchanged (rolldown runtime, app-local-assets, vendor-react, + vendor-editor, app-wikilinks, markdown-lines; no mermaid, + vendor-markdown, or vendor-highlight chunk). The pbxproj change is the + twelve version strings only. `MobileVault` cannot run under `node + --test` (path aliases), so the fix rests on the typecheck, the bundle, + and upstream's own `note-comments.test.ts`; not re-run on the simulator. +- Not verified on a device or on iOS 15/16 hardware for this release. The + app-core 2.46 changes were verified on the desktop build for the 2.46.0 + release, not on the phone. +- Matching port: Android ZenNotes/zennotesandroid 1.1.18 (versionCode + 20): the widgets, with its Play notes also carrying the app core 2.46 + changes from 1.1.17. Its release notes say "in step with iPhone 1.9.9"; + that is this release. diff --git a/docs/releases/v1.9.8/APP_STORE_REVIEW_NOTES.txt b/docs/releases/v1.9.8/APP_STORE_REVIEW_NOTES.txt new file mode 100644 index 0000000..bf9eb67 --- /dev/null +++ b/docs/releases/v1.9.8/APP_STORE_REVIEW_NOTES.txt @@ -0,0 +1,14 @@ +ZenNotes 1.9.8 adds Home Screen and Lock Screen widgets and moves the shared app core from 2.45 to 2.46. No new permissions, data collection, accounts, network services, or privacy-answer changes. + +WHAT CHANGED +- A new WidgetKit extension (bundle md.zennotes.ZenWidgets) offers three widgets: New Note (small, plus Lock Screen circular, rectangular, and inline on iOS 16+), Recent Notes (medium, large), and Today's Tasks (medium, large). +- The widgets render a small summary the app writes into its existing App Group container (group.md.zennotes, already used by the Share Extension): note titles, paths, and dates; task lines; the theme's colors. Note bodies never leave the app's own storage, and nothing is sent anywhere. +- Tapping a widget opens the app through its existing zennotes:// URL scheme: New Note creates a note in the Inbox and opens it; a Recent Notes row opens that note; a task row opens the note at that task's line; the tasks header opens the Tasks view. +- The shared app core moves from 2.45 to 2.46: comment threads that show who wrote each entry, a calendar in the editor's @ date menu, saved Tasks filters, a Kanban board with one column per folder, and math rendering fixes. Editor and view behavior inside the web bundle only. The optional ZenNotes Cloud subscription is unchanged. + +REVIEWER STEPS +1. First launch: choose "On this iPhone"; the welcome note opens. No account is required. +2. Long-press the Home Screen, tap Edit, then Add Widget, and search "ZenNotes". Three widgets are listed; add any of them. +3. New Note: tap it; the app opens a fresh note with its title field focused. Recent Notes: tap a row; that note opens. Today's Tasks: the welcome note's checklist appears; tap a row and the note opens on that line. +4. Optional, iOS 16+: long-press the Lock Screen, Customize, Lock Screen, tap the widget area, and add ZenNotes' New Note. +5. Everything else is unchanged from 1.9.7. diff --git a/docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt b/docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt new file mode 100644 index 0000000..e695b7f --- /dev/null +++ b/docs/releases/v1.9.8/PROMOTIONAL_TEXT.txt @@ -0,0 +1 @@ +Widgets: start a note, open a recent one, or see today's tasks from the Home Screen and the Lock Screen. App core 2.46 underneath. diff --git a/docs/releases/v1.9.8/RELEASE_NOTES.md b/docs/releases/v1.9.8/RELEASE_NOTES.md new file mode 100644 index 0000000..e2c5805 --- /dev/null +++ b/docs/releases/v1.9.8/RELEASE_NOTES.md @@ -0,0 +1,75 @@ +# ZenNotes for iPhone and iPad 1.9.8: widgets and app core 2.46 + +Home Screen and Lock Screen widgets, the first native surface the shell +adds beyond the share sheet. Three of them, zero configuration, wearing +whatever theme the app wears. Underneath, the app core moves from 2.45.0 +to 2.46.0: the pin release that was prepared as 1.9.8 and never shipped +on its own rides along here. + +## What changes on the phone + +- **New Note.** A small Home Screen widget, and on iOS 16 and later a + Lock Screen circle, rectangle, or inline line. One tap creates a note + in the Inbox and opens it with the title focused, the same path as the + ⊕ sheet's New note. +- **Recent Notes.** Medium or large. Your pinned notes first, in the + order you pinned them (the drawer's own rule), then the ones you edited + last, each with its "3h ago" stamp. A row opens the note; the + in the + header starts a new one. +- **Today's Tasks.** Medium or large. The Home dashboard's Today bucket: + due today, overdue, or undated, with overdue tasks first and their + count in the header. A row lands on the task's line in its note; the + header opens the Tasks view. "All clear" when nothing is due. +- **Live.** The widgets update within seconds of a change: a saved note, + a pinned one, a ticked task, a theme switch. Time stamps keep counting + between updates. + +## App core 2.46 + +- **Comment threads with names.** A reply files under the comment it + answers and every entry shows who wrote it. An assistant connected over + MCP on your desktop can answer in the same threads (list_comments, + add_comment, reply_to_comment, resolve_comment); its replies arrive on + the phone through your synced vault, signed with its name. +- **Pick any date from the @ menu.** The list ends with Date…, which opens + a calendar; Today, Yesterday, Tomorrow and Now stay one tap away. +- **Saved Tasks filters.** Name a filter once and recall it from the chips + under the Tasks header; the list lives in your preferences. +- **The Kanban Folder board** gives every note folder its own column, or + the children of one folder you name (kanban_folder_root). +- **Math.** A $$ block inside a callout renders in both views, and Typst + formulas match KaTeX's size with square-root bars in the text color. + +Not on the phone: the desktop-only keymap changes (Unbind, ignored keys), +the CLI fix and the remote-template routes, which need a desktop or a +ZenNotes server. + +## Under the hood + +- A WidgetKit extension target, `ZenWidgets` (`md.zennotes.ZenWidgets`), + wired by `tooling/add-widget-extension.rb` the way the Share Extension + is; deployment target stays iOS 15.0, with the Lock Screen families + gated to 16+ and the container background API to 17+. +- A widget cannot see the vault, so the shell publishes a snapshot into + the App Group it already shares with the Share Extension: pinned and + recent note titles, paths and dates; today's tasks; the theme's colors. + No note bodies. `src/bridge/widgets.ts` publishes on change, throttled + to one reload per 8 s while typing and flushed on backgrounding; + `WidgetBridgePlugin.swift` writes the file and reloads the timelines. +- Taps are `zennotes://` links the shell runs after the workspace + restores, so the cold-launch landing runs first and the link wins. The + newest link wins while booting. +- Pinned to upstream `a3e638fc`, on ZenNotes/zennotes `main`: app core + 2.46.0 plus the js-yaml and svgo lockfile fix for the advisories + published on 2026-09-08. The bridge contract gains optional comment + fields (`author`, `parentId`) and the `kanbanFolderRoot` view setting. + The on-device vault now reads and writes the comment sidecar through + the same shared normalizer desktop uses, so a comment action on the + phone keeps the names and threads instead of rebuilding each record + from the old field list. Harper stays off the phone. + +No new permissions, services, or data collection. Local-first storage and +iCloud Drive continue to work without an account; self-hosted and +ZenNotes Cloud vaults remain optional. The same release ships on Android +as ZenNotes 1.1.18: the widgets, with the app core 2.46 changes from +1.1.17 in its notes. diff --git a/docs/releases/v1.9.8/SOCIAL.md b/docs/releases/v1.9.8/SOCIAL.md new file mode 100644 index 0000000..3ccd014 --- /dev/null +++ b/docs/releases/v1.9.8/SOCIAL.md @@ -0,0 +1,20 @@ +# Social copy: ZenNotes iPhone 1.9.8 + +## Short + +ZenNotes for iPhone and iPad 1.9.8 adds widgets: start a note, open a +recent one, or see today's tasks from the Home Screen, and start a note +from the Lock Screen. App core 2.46 underneath. + +## Long + +ZenNotes for iPhone and iPad 1.9.8 puts your notes on the Home Screen. +New Note starts a note in your Inbox with one tap, on the Home Screen or +the Lock Screen. Recent Notes shows your pinned notes first, then the +ones you edited last, with a + to start another. Today's Tasks shows +what's due today, overdue first, and lands you on the task's line when +you tap it. The widgets wear your theme and update within seconds of a +change. Underneath, app core 2.46: comment threads with names, a +calendar in the @ menu, saved Tasks filters, and a Kanban board with one +column per folder. Nothing new is collected: the widgets read a small +summary stored on your device, never your note bodies. diff --git a/docs/releases/v1.9.8/WHATS_NEW.txt b/docs/releases/v1.9.8/WHATS_NEW.txt new file mode 100644 index 0000000..77b311c --- /dev/null +++ b/docs/releases/v1.9.8/WHATS_NEW.txt @@ -0,0 +1,16 @@ +ZenNotes 1.9.8 puts your notes on the Home Screen, and brings app core 2.46 to the phone. + +• New Note widget. One tap starts a note in your Inbox with the title ready to type. Small on the Home Screen; on iOS 16 and later also as a Lock Screen circle, rectangle, or inline line. +• Recent Notes widget. Your pinned notes first, then the ones you edited last, with a + to start a new one. Tap a row to open it. Medium or large. +• Today's Tasks widget. What's due today, overdue, or waiting for a date, overdue first, with the count in the header. Tap a task to land on its line; tap the header for the Tasks view. +• The widgets wear your theme and update within seconds of a change, so the Home Screen stays current without opening the app. + +Underneath, app core 2.46: + +• Comment threads with names. A reply files under the comment it answers, every entry shows who wrote it, and an assistant connected over MCP on your desktop can answer in the same threads; its replies arrive on the phone through your synced vault, signed with its name. +• Pick any date from the @ menu. Date… opens a calendar; Today, Yesterday, Tomorrow and Now stay one tap away. +• Saved Tasks filters. Name a filter once and recall it from the chips under the Tasks header. +• The Kanban Folder board gives every note folder its own column, or the children of one folder you name. +• Display math renders inside callouts, and Typst formulas match KaTeX's size, with square-root bars in the text color. + +No new permissions or data collection: the widgets read a small summary stored on your device, never your note bodies. Local and iCloud vaults work without an account. diff --git a/docs/releases/v1.9.8/twitter-post.md b/docs/releases/v1.9.8/twitter-post.md new file mode 100644 index 0000000..42d6d8e --- /dev/null +++ b/docs/releases/v1.9.8/twitter-post.md @@ -0,0 +1,5 @@ +ZenNotes for iPhone and iPad 1.9.8 is out. + +Widgets. New Note on the Home Screen and the Lock Screen, one tap to a fresh note. Recent Notes, pinned first. Today's Tasks, overdue first, a tap away from the task's line. They wear your theme and update within seconds of a change. + +App core 2.46 underneath: comment threads with names, a calendar in the @ menu, saved Tasks filters, a Kanban board with one column per folder. From 7645235e55e83052f8e8e6cdc9d9f05bc224ea3a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 12:40:14 -0500 Subject: [PATCH 05/20] perf(mobile): quieter Cloud sync while typing The 60-second auto-sync could stall the editor and rebuild every view even when nothing had changed. Three changes, all in the mobile bridge; the shared engine and the native filesystem are untouched: - Refresh only when the disk changed. cloud-sync-refresh.ts wraps the host filesystem and marks before every write, delete and rename (a native write can change the disk before it rejects), so a no-op sync no longer runs vault.rescan(), a batch of pulled files refreshes once, a failed pull still surfaces its partial writes, and a refresh that throws is carried into the next host operation. The scan marks too when it finds a changed local file without a pull. - Give input a turn. The directory walk and base64 decoding yield to the UI every 8 ms (scheduler.yield, setTimeout on iOS 15); decoding runs in 64 KiB chunks instead of Uint8Array.from's per-byte iterator, and binary uploads reuse the original base64 instead of re-encoding. - Tighten the scan cache. Files in a pending conflict (base/local/cloud paths and hashes, which also cover moved local versions, plus paused paths) are never skipped, and review and restore actions bypass the cache so they always get real bytes. Unknown or invalid mtimes are misses, and a file that changes while its bytes are read is not cached under the old fingerprint. Tests: tooling/load-mobile-module.ts bundles real mobile modules with the pinned source aliases (esbuild from .zennotes-source), so the repository, refresh, rescan and adapter -> host -> coordinator paths run under node --test against fake storage and server boundaries. 83 tests, 38 new. Claude-Session: https://claude.ai/code/session_01KWGNhGMPV2dC4APvksmJt7 --- src/bridge/cloud-sync-refresh.test.ts | 60 ++++ src/bridge/cloud-sync-refresh.ts | 30 ++ src/bridge/cloud-sync-repository.test.ts | 317 ++++++++++++++++++ src/bridge/cloud-sync-repository.ts | 78 +++-- src/bridge/cloud-sync-rescan.test.ts | 31 ++ src/bridge/cloud-sync-work.test.ts | 41 +++ src/bridge/cloud-sync-work.ts | 41 +++ .../mobile-cloud-sync.integration.test.ts | 307 +++++++++++++++++ src/bridge/mobile-cloud-sync.ts | 17 +- tooling/load-mobile-module.ts | 46 +++ 10 files changed, 938 insertions(+), 30 deletions(-) create mode 100644 src/bridge/cloud-sync-refresh.test.ts create mode 100644 src/bridge/cloud-sync-refresh.ts create mode 100644 src/bridge/cloud-sync-repository.test.ts create mode 100644 src/bridge/cloud-sync-rescan.test.ts create mode 100644 src/bridge/cloud-sync-work.test.ts create mode 100644 src/bridge/cloud-sync-work.ts create mode 100644 src/bridge/mobile-cloud-sync.integration.test.ts create mode 100644 tooling/load-mobile-module.ts diff --git a/src/bridge/cloud-sync-refresh.test.ts b/src/bridge/cloud-sync-refresh.test.ts new file mode 100644 index 0000000..196ec0d --- /dev/null +++ b/src/bridge/cloud-sync-refresh.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { trackCloudSyncChanges } from './cloud-sync-refresh.ts' + +describe('cloud sync refresh', () => { + it('retains a failed refresh for the next host operation', async () => { + const state = { changed: false } + const fs = { readdir: async () => [], stat: async () => null, readBase64: async () => '', + writeText: async () => {}, writeBase64: async () => {}, deleteFile: async () => {}, rename: async () => {} } + const first = trackCloudSyncChanges(fs, async () => { throw new Error('unavailable') }, state) + first.markChanged() + await assert.rejects(first.refresh(), /unavailable/) + let refreshes = 0 + const next = trackCloudSyncChanges(fs, async () => { refreshes++ }, state) + await next.refresh() + await next.refresh() + assert.equal(refreshes, 1) + }) + function fixture(failWrite = false) { + let refreshes = 0 + const changes = trackCloudSyncChanges({ + readdir: async () => [], stat: async () => null, readBase64: async () => '', + writeText: async () => { if (failWrite) throw new Error('disk full') }, + writeBase64: async () => {}, deleteFile: async () => {}, rename: async () => {} + }, async () => { refreshes++ }) + return { changes, count: () => refreshes } + } + + it('does not refresh the editor after a read-only / no-op sync', async () => { + const { changes, count } = fixture() + await changes.fs.readdir('') + await changes.fs.readBase64('note.md') + await changes.refresh() + assert.equal(count(), 0) + }) + + it('refreshes once for a batch of pulled notes, assets, moves and deletions', async () => { + const { changes, count } = fixture() + await changes.fs.writeText('note.md', 'new content') + await changes.fs.writeBase64('image.png', 'AA==') + await changes.fs.rename('note.md', 'renamed.md') + await changes.fs.deleteFile('old.md') + await changes.refresh() + assert.equal(count(), 1) + }) + + it('still refreshes partial filesystem changes when a sync fails', async () => { + const { changes, count } = fixture(true) + await assert.rejects(changes.fs.writeText('note.md', 'partial'), /disk full/) + await changes.refresh() + assert.equal(count(), 1) + }) + + it('refreshes externally changed files found during scanning without a pull', async () => { + const { changes, count } = fixture() + changes.markChanged() + await changes.refresh() + assert.equal(count(), 1) + }) +}) diff --git a/src/bridge/cloud-sync-refresh.ts b/src/bridge/cloud-sync-refresh.ts new file mode 100644 index 0000000..34f5ba7 --- /dev/null +++ b/src/bridge/cloud-sync-refresh.ts @@ -0,0 +1,30 @@ +import type { PortableCloudSyncFileSystem } from '@zennotes/shared-domain/cloud-sync-portable-filesystem' + +/** One host operation owns this tracker. Mark before writes: a native write + * can change the disk before rejecting. A no-op sync must not rebuild every + * editor surface, but partial failed pulls still need to become visible. */ +export function trackCloudSyncChanges( + fs: PortableCloudSyncFileSystem, + refresh: () => Promise, + state = { changed: false } +) { + const markChanged = () => { state.changed = true } + return { + markChanged, + fs: { + ...fs, + writeText: async (path: string, value: string) => { markChanged(); await fs.writeText(path, value) }, + writeBase64: async (path: string, value: string) => { markChanged(); await fs.writeBase64(path, value) }, + deleteFile: async (path: string) => { markChanged(); await fs.deleteFile(path) }, + rename: async (from: string, to: string) => { markChanged(); await fs.rename(from, to) } + }, + async refresh() { + if (!state.changed) return + state.changed = false + try { await refresh() } catch (error) { + state.changed = true + throw error + } + } + } +} diff --git a/src/bridge/cloud-sync-repository.test.ts b/src/bridge/cloud-sync-repository.test.ts new file mode 100644 index 0000000..292c82e --- /dev/null +++ b/src/bridge/cloud-sync-repository.test.ts @@ -0,0 +1,317 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import type { CloudSyncContent, CloudSyncMutation } from '@zennotes/bridge-contract/cloud-sync' +import type { + CloudSyncLocalItem, CloudSyncState, CloudSyncStoredConflict +} from '@zennotes/shared-domain/cloud-sync-engine' +import type { CloudSyncRepository } from '@zennotes/shared-domain/cloud-sync-coordinator' + +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const { CachedCloudSyncRepository } = await loadMobileModule('./src/bridge/cloud-sync-repository') +const { CloudSyncCoordinator } = await loadMobileModule('@zennotes/shared-domain/cloud-sync-coordinator') + +type StoredFile = { bytes: Buffer; mtime: number } +function harness(initial: Record = { 'note.md': 'Hello' }) { + const files = new Map( + Object.entries(initial).map(([path, body]) => [path, { bytes: Buffer.from(body), mtime: 1000 }]) + ) + let cache: unknown = null + let state: CloudSyncState | null = null + let clock = 1000 + const reads: string[] = [] + const failures = { cacheRead: false, cacheWrite: false, stateRead: false, directory: false, file: false } + let onRead: ((path: string) => void) | undefined + const put = (path: string, body: string | Buffer) => { + files.set(path, { bytes: Buffer.from(body), mtime: ++clock }) + } + const stat = async (path: string) => { + const file = files.get(path) + if (file) return { type: 'file' as const, size: file.bytes.length, mtime: file.mtime } + if ([...files.keys()].some((name) => name.startsWith(path + '/'))) { + return { type: 'directory' as const, size: 0, mtime: 1000 } + } + return null + } + const readdir = async (directory: string) => { + if (failures.directory) throw new Error('Directory unavailable') + const entries = new Map() + const prefix = directory ? directory + '/' : '' + for (const [path, file] of files) { + if (!path.startsWith(prefix)) continue + const relative = path.slice(prefix.length) + const [name, nested] = relative.split('/') + entries.set(name, { + name, type: nested ? 'directory' : 'file', + size: nested ? 0 : file.bytes.length, mtime: nested ? 1000 : file.mtime + }) + } + return [...entries.values()] + } + const readBase64 = async (path: string) => { + reads.push(path) + if (failures.file) throw new Error('File unavailable') + onRead?.(path) + const file = files.get(path) + if (!file) throw new Error('File missing') + return file.bytes.toString('base64') + } + const fs = { + readdir, + stat: async (path: string) => (await stat(path))?.type ?? null, + readBase64, + writeText: async (path: string, data: string) => put(path, data), + writeBase64: async (path: string, data: string) => put(path, Buffer.from(data, 'base64')), + deleteFile: async (path: string) => { files.delete(path) }, + rename: async (from: string, to: string) => { + const file = files.get(from) + if (!file) throw new Error('File missing') + files.set(to, file) + files.delete(from) + } + } + const native = { readdirStrict: readdir, readBase64, statOrNull: stat, stat } + const store = { + loadTracked: async () => { + if (failures.stateRead) throw new Error('State unavailable') + return state + }, + loadCache: async () => { + if (failures.cacheRead) throw new Error('Cache unavailable') + return cache + }, + saveCache: async (next: unknown) => { + if (failures.cacheWrite) throw new Error('Cache unavailable') + cache = structuredClone(next) + } + } + const repository: CloudSyncRepository = new CachedCloudSyncRepository(fs, native, store) + function acknowledge(items: CloudSyncLocalItem[]) { + state = { + version: 1, vault_id: 'vault-1', cursor: 1, + items: Object.fromEntries(items.map((item, index) => [`item-${index}`, { + item_id: `item-${index}`, path: item.path, kind: item.kind, revision: 1, + sha256: item.content.sha256, byte_length: item.content.byte_length, + media_type: item.content.media_type + }])) + } + } + const coordinator = () => { + const mutations: CloudSyncMutation[] = [] + const remote = { + manifest: async () => ({ data: [], cursor: state?.cursor ?? 0, next_page: null }), + changes: async () => ({ data: [], cursor: state?.cursor ?? 0, has_more: false }), + mutate: async (_vaultId: string, body: { mutations: CloudSyncMutation[] }) => { + // Serialization is deliberately real: a cache placeholder must never be uploaded. + mutations.push(...JSON.parse(JSON.stringify(body.mutations))) + return { + acknowledged: body.mutations.map((mutation) => ({ + operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 2, sequence: 1 + })), + conflicts: [], cursor: 1 + } + } + } + let id = 0 + return { + mutations, + service: new CloudSyncCoordinator('vault-1', remote, repository, { + load: async () => state, + save: async (next: CloudSyncState) => { state = structuredClone(next) } + }, { itemId: () => `new-${++id}`, operationId: () => `op-${++id}` }) + } + } + return { + files, reads, failures, repository, acknowledge, coordinator, put, + setReadHook: (hook: typeof onRead) => { onRead = hook }, + get cache() { return cache }, set cache(next: unknown) { cache = next }, + get state() { return state }, set state(next: CloudSyncState | null) { state = next } + } +} + +function content(text: string): CloudSyncContent { + return { + encoding: 'utf8', data: text, sha256: createHash('sha256').update(text).digest('hex'), + byte_length: Buffer.byteLength(text), media_type: 'text/markdown' + } +} + +function pending(path: string, local: CloudSyncContent, cloud = content('Other device')): CloudSyncStoredConflict { + return { + id: 'conflict-1', item_id: 'item-0', kind: 'content', sequence: 2, + base: { path, revision: 1, kind: 'text', content: content('Base') }, + local: { path, revision: null, kind: 'text', content: local }, + cloud: { path, revision: 2, kind: 'text', content: cloud } + } +} + +describe('cached mobile Cloud scan', () => { + it('reads and hashes new text and binary files with the same portable semantics', async () => { + const bytes = Buffer.from([0, 255, 1, 128]) + const h = harness({ 'note.md': 'Hello', 'assets/photo.png': bytes, '.zennotes/cache.json': '{}' }) + const items = await h.repository.scan() + assert.deepEqual(items.map((item) => item.path), ['assets/photo.png', 'note.md']) + assert.equal(items[0].kind, 'binary') + assert.equal(items[0].content.data, bytes.toString('base64')) + assert.equal(items[0].content.sha256, createHash('sha256').update(bytes).digest('hex')) + assert.deepEqual(items[1].content, content('Hello')) + }) + + it('does not reread unchanged acknowledged files on the next sync', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B', 'assets/p.png': Buffer.from([0, 255]) }) + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + const result = await h.coordinator().service.sync() + assert.deepEqual(h.reads, []) + assert.equal(result.pushed, 0) + assert.equal(result.pendingConflicts.length, 0) + }) + + it('rereads only the edited file and uploads its complete current bytes', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B' }) + h.acknowledge(await h.repository.scan()) + h.put('a.md', 'Changed') + h.reads.length = 0 + const c = h.coordinator() + const result = await c.service.sync() + assert.deepEqual(h.reads, ['a.md']) + assert.equal(result.pushed, 1) + assert.equal(c.mutations[0].type, 'upsert') + if (c.mutations[0].type === 'upsert') assert.equal(c.mutations[0].content.data, 'Changed') + }) + + + it('rereads a same-size edit when its modification time changes', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.put('note.md', 'World') + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'World') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads a size change even if a provider preserves the modification time', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.files.set('note.md', { bytes: Buffer.from('Longer content'), mtime: 1000 }) + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Longer content') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads files whose scanned content has not yet been acknowledged', async () => { + const h = harness() + await h.repository.scan() + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('rereads an unacknowledged edit even when its fingerprint is cached', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.put('note.md', 'Local edit') + await h.repository.scan() + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Local edit') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('keeps rename and deletion semantics intact without uploading cached bytes', async () => { + const h = harness({ 'a.md': 'A', 'b.md': 'B' }) + h.acknowledge(await h.repository.scan()) + h.files.set('renamed.md', h.files.get('a.md')!) + h.files.delete('a.md') + h.files.delete('b.md') + const c = h.coordinator() + await c.service.sync() + assert.deepEqual(c.mutations.map((mutation) => mutation.type).sort(), ['delete', 'move']) + assert.equal(c.mutations.find((mutation) => mutation.type === 'move')?.path, 'renamed.md') + assert.deepEqual(Object.keys(h.cache as object), ['renamed.md']) + }) + + for (const failure of ['cacheRead', 'stateRead', 'cacheWrite'] as const) { + it(`falls back safely when ${failure} fails`, async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + h.failures[failure] = true + const items = await h.repository.scan() + assert.equal(items[0].content.sha256, content('Hello').sha256) + if (failure !== 'cacheWrite') assert.deepEqual(h.reads, ['note.md']) + }) + } + + it('rebuilds a lost or malformed cache from actual file content', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + for (const damaged of [null, { 'note.md': { mtime: 1000, sha256: 'not-an-entry' } }]) { + h.cache = damaged + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + } + }) + + for (const mtime of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + it(`does not trust unavailable or invalid modification time ${mtime}`, async () => { + const h = harness() + h.files.get('note.md')!.mtime = mtime + h.acknowledge(await h.repository.scan()) + h.reads.length = 0 + assert.equal((await h.repository.scan())[0].content.data, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + } + + it('does not cache a file that changes while its bytes are being read', async () => { + const h = harness() + h.setReadHook((path) => { h.put(path, 'World') }) + h.acknowledge(await h.repository.scan()) + h.setReadHook(undefined) + // Restore the old metadata as can happen with coarse timestamps: the + // unstable read must not leave a reusable cache entry for that fingerprint. + h.files.get('note.md')!.mtime = 1000 + h.reads.length = 0 + await h.repository.scan() + assert.deepEqual(h.reads, ['note.md']) + }) + + for (const failure of ['directory', 'file'] as const) { + it(`fails closed on a ${failure} read error, without manufacturing a deletion`, async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + const priorCache = structuredClone(h.cache) + h.put('note.md', 'Unreadable new edit') + h.failures[failure] = true + const c = h.coordinator() + await assert.rejects(c.service.sync(), /unavailable/) + assert.deepEqual(c.mutations, []) + assert.deepEqual(h.cache, priorCache) + }) + } +}) + +describe('cached scan with the pinned conflict coordinator', () => { + it('returns real bytes for review when pending local content matches acknowledged content', async () => { + const h = harness() + h.acknowledge(await h.repository.scan()) + h.state!.pending_conflicts = { 'conflict-1': pending('note.md', content('Hello')) } + h.reads.length = 0 + const details = await h.coordinator().service.getConflict('conflict-1') + assert.equal(details.local.text, 'Hello') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('can locate and read a moved pending-conflict file even when its new path is acknowledged', async () => { + const h = harness({ 'renamed.md': 'Hello' }) + h.acknowledge(await h.repository.scan()) + h.state!.pending_conflicts = { 'conflict-1': pending('old.md', content('Hello')) } + h.reads.length = 0 + const details = await h.coordinator().service.getConflict('conflict-1') + assert.equal(details.local.path, 'renamed.md') + assert.equal(details.local.text, 'Hello') + assert.deepEqual(h.reads, ['renamed.md']) + }) +}) diff --git a/src/bridge/cloud-sync-repository.ts b/src/bridge/cloud-sync-repository.ts index ba31925..cf5063f 100644 --- a/src/bridge/cloud-sync-repository.ts +++ b/src/bridge/cloud-sync-repository.ts @@ -1,25 +1,24 @@ /** * PortableCloudSyncRepository with a scan cache. The upstream portable scan - * reads and hashes every file's full bytes across the WebKit bridge on every + * reads and hashes every file's full bytes across the native bridge on every * sync run — a 60-second background cadence on app-core's auto-sync — which * scales battery and memory cost with vault size. This subclass skips the * read for files that are provably not needed: * * skip ⇔ (mtime AND size unchanged since the last real read) * AND (that read's hash equals the acked sync state's hash) + * AND (the file is not involved in a pending conflict) * * The engine (cloud-sync-engine planCloudSyncMutations) touches * `content.data` only for items whose hash differs from the tracked state or - * that the state does not know; the bootstrap path compares hashes only. A - * skipped item therefore never needs its bytes — and to keep that a proven - * invariant rather than a hope, its `data` property THROWS if anything reads - * it: a failed sync run instead of silently pushing content we never read. + * that the state does not know. The host disables skipping for review and + * restore actions, which need real bytes even for acknowledged content. + * A skipped item's `data` property THROWS if unexpectedly consumed, rather + * than silently pushing content we never read. * - * Cache safety is one-directional by construction: a stale or lost cache - * only causes extra reads (miss → full read), never a wrong skip — a written - * file has a new mtime/size, and a hash the state doesn't vouch for is a - * miss. The residual risk is the standard mtime+size fingerprint collision - * every file watcher accepts. + * A lost cache or unknown timestamp causes a full read. Like other + * metadata-based caches, this relies on the provider updating mtime or size + * when content changes; same-size writes preserving mtime cannot be detected. */ import type { CloudSyncContent, @@ -37,7 +36,7 @@ import { } from '@zennotes/shared-domain/cloud-sync-portable-filesystem' import type { CloudSyncLocalItem, CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' import type { NativeFs } from './native-fs' -import { base64ToBytes, bytesToBase64 } from './base64' +import { cloudSyncWorkBudget, decodeCloudSyncBase64 } from './cloud-sync-work' export interface ScanCacheEntry { mtime: number @@ -59,8 +58,9 @@ export interface ScanCacheStore { export class CachedCloudSyncRepository extends PortableCloudSyncRepository { constructor( fs: PortableCloudSyncFileSystem, - private readonly native: NativeFs, - private readonly store: ScanCacheStore + private readonly native: Pick, + private readonly store: ScanCacheStore, + private readonly onChanged: () => void = () => {} ) { super(fs) } @@ -71,6 +71,7 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { const nextCache: ScanCache = {} const items: CloudSyncLocalItem[] = [] await this.walkCached('', trackedSha, cache, nextCache, items) + if (Object.keys(cache).some((path) => !nextCache[path])) this.onChanged() // Cache loss is only a slow next scan — never let it fail the sync run. await this.store.saveCache(nextCache).catch(() => {}) return items.sort((left, right) => left.path.localeCompare(right.path)) @@ -84,10 +85,12 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { items: CloudSyncLocalItem[] ): Promise { // readdirStrict entries carry mtime and size, so validating the cache - // costs no extra stat calls. An evicted iCloud file surfaces with its - // stub's mtime/size — a guaranteed miss, so it gets downloaded and read. + // costs no extra stat calls on a hit. Fresh reads are checked again + // before their fingerprints can be reused on a later scan. const entries = await this.native.readdirStrict(directory) + const checkpoint = cloudSyncWorkBudget() for (const entry of entries) { + await checkpoint() const relPath = directory ? `${directory}/${entry.name}` : entry.name if (entry.type === 'directory') { if (shouldTraverseCloudSyncDirectory(relPath)) { @@ -111,7 +114,15 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { } const item = await this.readItemFresh(path) - nextCache[path] = { + if (!cached || cached.mtime !== entry.mtime || cached.size !== entry.size || cached.sha256 !== item.content.sha256) { + this.onChanged() + } + // Never seed a reusable fingerprint from an unstable native read. + // Unknown provider timestamps are deliberately always cache misses. + const after = await this.native.statOrNull(path).catch(() => null) + if (validFingerprint(entry) && after?.type === 'file' && + after.mtime === entry.mtime && after.size === entry.size && + item.content.byte_length === entry.size) nextCache[path] = { mtime: entry.mtime, size: entry.size, sha256: item.content.sha256, @@ -124,19 +135,19 @@ export class CachedCloudSyncRepository extends PortableCloudSyncRepository { } // --------------------------------------------------------------------- - // Mirrored 1:1 from upstream cloud-sync-portable-filesystem.ts readItem - // (whose helpers are module-private) — keep in lockstep. + // Preserve upstream readItem's encoding/hash semantics while yielding + // during large base64 decoding and avoiding a binary re-encode. // --------------------------------------------------------------------- private async readItemFresh(path: string): Promise { - const bytes = base64ToBytes(await this.native.readBase64(path)) + const { bytes, base64 } = await decodeCloudSyncBase64(await this.native.readBase64(path)) const text = decodeText(path, bytes) return { path, kind: text === null ? 'binary' : 'text', content: { encoding: text === null ? 'base64' : 'utf8', - data: text === null ? bytesToBase64(bytes) : text, + data: text === null ? base64 : text, sha256: await sha256(bytes), byte_length: bytes.byteLength, media_type: mediaType(path, text !== null) @@ -171,9 +182,28 @@ function trackedShaByPath(state: CloudSyncState | null): Map { out.set(cloudSyncPathKey(item.path), item.sha256) } } + // Conflict review/resolution consumes actual bytes, even if a version is + // already acknowledged. Hash exclusions also cover moved local versions. + const paths = new Set() + const hashes = new Set() + for (const conflict of Object.values(state.pending_conflicts ?? {})) { + for (const snapshot of [conflict.base, conflict.local, conflict.cloud]) { + if (snapshot?.path) paths.add(cloudSyncPathKey(snapshot.path)) + if (snapshot?.content?.sha256) hashes.add(snapshot.content.sha256) + } + for (const path of conflict.paused_paths ?? []) paths.add(cloudSyncPathKey(path)) + } + for (const [path, hash] of out) { + if (paths.has(path) || hashes.has(hash)) out.delete(path) + } return out } +function validFingerprint(entry: { mtime?: number; size?: number }): boolean { + return typeof entry.mtime === 'number' && Number.isFinite(entry.mtime) && entry.mtime > 0 && + typeof entry.size === 'number' && Number.isSafeInteger(entry.size) && entry.size >= 0 +} + function normalizeScanCache(raw: unknown): ScanCache { if (!raw || typeof raw !== 'object') return {} const out: ScanCache = {} @@ -181,8 +211,7 @@ function normalizeScanCache(raw: unknown): ScanCache { const entry = value as Partial | null if ( entry && - typeof entry.mtime === 'number' && - typeof entry.size === 'number' && + validFingerprint(entry) && typeof entry.sha256 === 'string' && (entry.kind === 'text' || entry.kind === 'binary') && typeof entry.byte_length === 'number' && @@ -260,9 +289,8 @@ function mediaType(path: string, text: boolean): string { return MEDIA_TYPES[extension(path)] ?? (text ? 'text/plain' : 'application/octet-stream') } -async function sha256(bytes: Uint8Array): Promise { - const input = Uint8Array.from(bytes).buffer - const digest = await crypto.subtle.digest('SHA-256', input) +async function sha256(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes.buffer) return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, '0')) .join('') diff --git a/src/bridge/cloud-sync-rescan.test.ts b/src/bridge/cloud-sync-rescan.test.ts new file mode 100644 index 0000000..9c3fb95 --- /dev/null +++ b/src/bridge/cloud-sync-rescan.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault, onVaultChange } = await loadMobileModule(['./src/bridge/vault-fs', './src/bridge/events']) + +it('batches a foreground/cloud refresh into one resync without triggering local autosync events', async () => { + const events: unknown[] = [] + const unsubscribe = onVaultChange((event: unknown) => events.push(event)) + const invalidated: string[] = [] + const vault = { + settingsCache: {}, + metaCache: new Map([ + ['deleted.md', { meta: { updatedAt: 1 }, size: 1 }], + ['changed.md', { meta: { updatedAt: 1 }, size: 1 }] + ]), + listNotes: async () => [ + { path: 'changed.md', updatedAt: 2, size: 2, folder: 'inbox' }, + { path: 'new.md', updatedAt: 2, size: 2, folder: 'inbox' } + ], + invalidateMeta: (path: string) => invalidated.push(path), + folderOf: async () => 'inbox' + } + try { + await MobileVault.prototype.rescan.call(vault) + assert.deepEqual(events, [{ kind: 'change', path: '', folder: 'inbox', scope: 'resync' }]) + assert.deepEqual(invalidated, ['deleted.md']) + assert.equal(vault.settingsCache, null) + } finally { unsubscribe() } +}) diff --git a/src/bridge/cloud-sync-work.test.ts b/src/bridge/cloud-sync-work.test.ts new file mode 100644 index 0000000..61a2480 --- /dev/null +++ b/src/bridge/cloud-sync-work.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { decodeCloudSyncBase64, yieldToUi } from './cloud-sync-work.ts' + +describe('cloud sync cooperative work', () => { + it('decodes exact bytes, including padding, whitespace and data URLs', async () => { + for (const size of [0, 1, 2, 3, 49_151, 49_152, 49_153, 300_001]) { + const expected = Buffer.alloc(size) + for (let i = 0; i < size; i++) expected[i] = i % 256 + const base64 = expected.toString('base64') + const result = await decodeCloudSyncBase64(`data:image/png;base64,\n${base64}\n`) + assert.deepEqual(Buffer.from(result.bytes), expected) + assert.equal(result.base64, base64) + } + }) + + it('lets pending input run before a large attachment finishes decoding', async () => { + const input = Buffer.alloc(8_100_000, 125).toString('base64') + let inputProcessed = false + const timer = setTimeout(() => { inputProcessed = true }, 0) + try { + const result = await decodeCloudSyncBase64(input) + assert.equal(inputProcessed, true) + assert.equal(result.bytes.length, 8_100_000) + assert.equal(result.bytes.at(-1), 125) + } finally { + clearTimeout(timer) + } + }) + + it('uses a real task boundary on older WebViews without scheduler.yield', async () => { + let inputProcessed = false + setTimeout(() => { inputProcessed = true }, 0) + await yieldToUi() + assert.equal(inputProcessed, true) + }) + + it('rejects malformed base64 instead of hashing damaged bytes', async () => { + await assert.rejects(decodeCloudSyncBase64('AA!A')) + }) +}) diff --git a/src/bridge/cloud-sync-work.ts b/src/bridge/cloud-sync-work.ts new file mode 100644 index 0000000..2f47c18 --- /dev/null +++ b/src/bridge/cloud-sync-work.ts @@ -0,0 +1,41 @@ +/** Shared with the iOS shell. Async filesystem calls do not move the + * following JavaScript off the editor's thread. Give input a real turn. + * https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield + * The timer fallback also supports iOS 15 / older Android WebViews. */ +export function yieldToUi(): Promise { + const scheduler = (globalThis as typeof globalThis & { + scheduler?: { yield?: () => Promise } + }).scheduler + return scheduler?.yield ? scheduler.yield() : new Promise((resolve) => setTimeout(resolve, 0)) +} + +export function cloudSyncWorkBudget(): () => Promise | undefined { + let started = performance.now() + return () => { + if (performance.now() - started < 8) return + return yieldToUi().then(() => { started = performance.now() }) + } +} + +/** Decode in bounded chunks instead of Uint8Array.from(string, callback), + * which visits every byte through an allocating JS iterator. Keep the + * original base64 for binary uploads rather than encoding the bytes again. */ +export async function decodeCloudSyncBase64(value: string): Promise<{ + bytes: Uint8Array + base64: string +}> { + if (value.length >= 262_144) await yieldToUi() + const base64 = (value.includes(',') ? value.slice(value.indexOf(',') + 1) : value).replace(/\s/g, '') + const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0 + const bytes = new Uint8Array(Math.max(0, Math.floor(base64.length * 3 / 4) - padding)) + const checkpoint = cloudSyncWorkBudget() + let offset = 0 + // Multiples of four keep each base64 chunk independently decodable. + for (let start = 0; start < base64.length; start += 65_536) { + const binary = atob(base64.slice(start, start + 65_536)) + for (let i = 0; i < binary.length; i++) bytes[offset++] = binary.charCodeAt(i) + await checkpoint() + } + if (offset !== bytes.length) throw new Error('Invalid Cloud file encoding.') + return { bytes, base64 } +} diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts new file mode 100644 index 0000000..f1f1118 --- /dev/null +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -0,0 +1,307 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import type { + CloudSyncChange, CloudSyncContent, CloudSyncManifestItem, CloudSyncMutation +} from '@zennotes/bridge-contract/cloud-sync' +import type { CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +function textContent(text: string): CloudSyncContent { + return { + encoding: 'utf8', data: text, byte_length: Buffer.byteLength(text), + sha256: createHash('sha256').update(text).digest('hex'), media_type: 'text/markdown' + } +} + +/** Real mobile adapter -> host service -> coordinator -> cached repository. + * Only Capacitor storage/auth/layout and the server boundary are fakes. */ +async function fixture(initial: Record = { 'note.md': 'Original' }) { + const persisted = new Map() + const files = new Map(Object.entries(initial).map(([path, data]) => [ + path, { bytes: Buffer.from(data), mtime: 1000 } + ])) + const remoteItems = new Map() + const revisions = new Map() + const feed: CloudSyncChange[] = [] + const reads: string[] = [] + const refreshes: Record[] = [] + const uploaded: CloudSyncMutation[] = [] + let cursor = 0 + let clock = 1000 + let failWritePath: string | null = null + let beforeChanges: (() => void) | undefined + + const put = (path: string, bytes: string | Buffer) => { + files.set(path, { bytes: Buffer.from(bytes), mtime: ++clock }) + } + function remoteText(path: string, text: string) { + const previous = [...remoteItems.values()].find((item) => item.path === path) + const itemId = previous?.item_id ?? 'remote-' + path + const content = textContent(text) + const revision = (previous?.revision ?? 0) + 1 + const item: CloudSyncManifestItem = { + item_id: itemId, path, kind: 'text', revision, content, + sha256: content.sha256, byte_length: content.byte_length, media_type: content.media_type + } + remoteItems.set(itemId, item) + revisions.set(itemId + ':' + revision, structuredClone(item)) + feed.push({ + sequence: ++cursor, item_id: itemId, type: 'upsert', path, + previous_path: previous?.path ?? null, revision, content + }) + return item + } + const remote = { + listVaults: async () => ({ data: [{ id: 'vault-1', name: 'Test vault' }] }), + manifest: async () => ({ data: [...remoteItems.values()], cursor, next_page: null }), + changes: async (_vaultId: string, after: number) => { + beforeChanges?.() + return { data: feed.filter((change) => change.sequence > after), cursor, has_more: false } + }, + revision: async (_vaultId: string, itemId: string, revision: number) => { + const item = revisions.get(itemId + ':' + revision) + assert.ok(item) + return { data: { ...item, deleted: false } } + }, + mutate: async (_vaultId: string, request: { mutations: CloudSyncMutation[] }) => { + uploaded.push(...JSON.parse(JSON.stringify(request.mutations))) + const acknowledged = request.mutations.map((mutation) => { + const previous = remoteItems.get(mutation.item_id) + const revision = (previous?.revision ?? 0) + 1 + if (mutation.type === 'upsert') { + const item: CloudSyncManifestItem = { + item_id: mutation.item_id, path: mutation.path, kind: mutation.kind, + revision, content: structuredClone(mutation.content), + sha256: mutation.content.sha256, byte_length: mutation.content.byte_length, + media_type: mutation.content.media_type + } + remoteItems.set(item.item_id, item) + revisions.set(item.item_id + ':' + revision, structuredClone(item)) + feed.push({ + sequence: ++cursor, item_id: item.item_id, type: 'upsert', path: item.path, + previous_path: previous?.path ?? null, revision, content: item.content + }) + } else { + assert.ok(previous) + if (mutation.type === 'delete') remoteItems.delete(mutation.item_id) + else remoteItems.set(mutation.item_id, { ...previous, path: mutation.path, revision }) + feed.push({ + sequence: ++cursor, item_id: mutation.item_id, type: mutation.type, + path: mutation.type === 'move' ? mutation.path : previous.path, + previous_path: previous.path, revision + }) + } + return { operation_id: mutation.operation_id, item_id: mutation.item_id, revision, sequence: cursor } + }) + return { acknowledged, conflicts: [], cursor } + } + } + const native = { + rootPath: 'ZenNotes/Test', + async readdirStrict(directory: string) { + const entries = new Map() + const prefix = directory ? directory + '/' : '' + for (const [path, file] of files) { + if (!path.startsWith(prefix)) continue + const [name, nested] = path.slice(prefix.length).split('/') + entries.set(name, { + name, type: nested ? 'directory' : 'file', + size: nested ? 0 : file.bytes.length, mtime: nested ? 1000 : file.mtime + }) + } + return [...entries.values()] + }, + async statOrNull(path: string) { + const file = files.get(path) + return file ? { type: 'file' as const, mtime: file.mtime, size: file.bytes.length } : null + }, + async statVerified(path: string) { return files.has(path) ? 'file' : null }, + async readBase64(path: string) { + reads.push(path) + const file = files.get(path) + assert.ok(file) + return file.bytes.toString('base64') + }, + async writeText(path: string, data: string) { + put(path, data) + if (path === failWritePath) throw new Error('Native write failed after writing') + }, + async writeBase64(path: string, data: string) { + put(path, Buffer.from(data, 'base64')) + if (path === failWritePath) throw new Error('Native write failed after writing') + }, + async deleteFile(path: string) { files.delete(path) }, + async mkdir(_path: string) {}, + async rename(from: string, to: string) { + const file = files.get(from) + assert.ok(file) + files.set(to, file) + files.delete(from) + } + } + const vault = { + rootLabel: 'ZenNotes/Test', fs: native, + async rescan() { + refreshes.push(Object.fromEntries([...files].map(([path, file]) => [path, file.bytes.toString()]))) + } + } + const api = await loadMobileModule('./src/bridge/mobile-cloud-sync', { + '@capacitor/filesystem': { + Directory: { Data: 'DATA', Cache: 'CACHE' }, Encoding: { UTF8: 'utf8' }, + Filesystem: { + async readFile({ path }: { path: string }) { + if (!persisted.has(path)) throw Object.assign(new Error('Missing'), { code: 'OS-PLUG-FILE-0008' }) + return { data: persisted.get(path)! } + }, + async writeFile({ path, data }: { path: string; data: string }) { persisted.set(path, data) }, + async deleteFile({ path }: { path: string }) { persisted.delete(path) } + } + }, + '@capacitor/share': { Share: {} }, + './vault-fs': { MobileVault: class {} }, + './cloud-layout': { reconcileLayoutForCloudJoin: async () => {} }, + './mobile-cloud-auth': { + authenticatedCredential: async () => ({ base_url: 'https://sync.example.test', token: 'test-only' }), + authenticatedClient: async () => remote, + getMobileCloudAccountStatus: async () => ({ + state: 'connected', account: { base_url: 'https://sync.example.test' } + }) + } + }) + await api.linkMobileCloudVault(vault, 'vault-1') + const stateKey = () => [...persisted.keys()].find((path) => path.includes('/states/')) + return { + api, vault, files, reads, uploaded, refreshes, remoteText, put, + sync: () => api.syncMobileCloudVault(vault), + setFailWrite: (path: string | null) => { failWritePath = path }, + setBeforeChanges: (callback: typeof beforeChanges) => { beforeChanges = callback }, + clearState: () => { const key = stateKey(); if (key) persisted.delete(key) }, + get state(): CloudSyncState { + const key = stateKey() + assert.ok(key) + return JSON.parse(persisted.get(key)!) + }, + set state(value: CloudSyncState) { + const key = stateKey() + assert.ok(key) + persisted.set(key, JSON.stringify(value)) + } + } +} + +describe('mobile Cloud adapter wiring', () => { + it('does not rescan the vault or reread acknowledged bytes during a no-op sync', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.reads.length = 0 + const summary = await h.sync() + assert.equal(summary.pushed, 0) + assert.equal(summary.pulled, 0) + assert.deepEqual(h.refreshes, []) + assert.deepEqual(h.reads, []) + }) + + it('resynchronizes UI state once after a whole batch of pulled files', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Remote update') + h.remoteText('second.md', 'Second note') + h.remoteText('folder/third.md', 'Third note') + const summary = await h.sync() + assert.equal(summary.pulled, 3) + assert.equal(h.refreshes.length, 1) + assert.deepEqual(h.refreshes[0], { + 'note.md': 'Remote update', 'second.md': 'Second note', 'folder/third.md': 'Third note' + }) + h.refreshes.length = 0 + await h.sync() + assert.deepEqual(h.refreshes, []) + }) + + it('refreshes a newly discovered local edit even when nothing is downloaded', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.put('note.md', 'Local changes') + const summary = await h.sync() + assert.equal(summary.pulled, 0) + assert.equal(summary.pushed, 1) + assert.deepEqual(h.refreshes, [{ 'note.md': 'Local changes' }]) + const uploaded = h.uploaded.at(-1) + assert.equal(uploaded?.type, 'upsert') + if (uploaded?.type === 'upsert') assert.equal(uploaded.content.data, 'Local changes') + }) + + it('exposes partial native writes when a pull fails, and can retry safely', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Remote update') + h.remoteText('second.md', 'Partially written') + h.setFailWrite('second.md') + await assert.rejects(h.sync(), /Native write failed/) + assert.deepEqual(h.refreshes, [{ 'note.md': 'Remote update', 'second.md': 'Partially written' }]) + h.setFailWrite(null) + await h.sync() + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Remote update') + assert.equal(h.files.get('second.md')?.bytes.toString(), 'Partially written') + }) + + it('keeps a conflicting local edit visible when a remote version arrives', async () => { + const h = await fixture() + await h.sync() + h.refreshes.length = 0 + h.remoteText('note.md', 'Cloud replacement') + h.setBeforeChanges(() => { + h.setBeforeChanges(undefined) + h.put('note.md', 'Local replacement') + }) + const summary = await h.sync() + assert.equal(summary.pending_conflicts.length, 1) + assert.equal(h.files.get('note.md')?.bytes.toString(), 'Local replacement') + assert.equal(h.refreshes.at(-1)?.['note.md'], 'Local replacement') + const details = await h.api.getMobileCloudConflict(h.vault, summary.pending_conflicts[0].id) + assert.equal(details.local.text, 'Local replacement') + assert.equal(details.cloud.text, 'Cloud replacement') + }) + + it('loads full pending-conflict review bytes despite an acknowledged warm scan cache', async () => { + const h = await fixture() + await h.sync() + const state = h.state + const [item] = Object.values(state.items) + state.pending_conflicts = { + [item.item_id]: { + id: item.item_id, item_id: item.item_id, kind: 'content', sequence: 2, + base: { path: item.path, revision: 1, kind: 'text', content: textContent('Base') }, + local: { path: item.path, revision: null, kind: 'text', content: textContent('Original') }, + cloud: { path: item.path, revision: 2, kind: 'text', content: textContent('Cloud replacement') } + } + } + h.state = state + h.reads.length = 0 + const details = await h.api.getMobileCloudConflict(h.vault, item.item_id) + assert.equal(details.local.text, 'Original') + assert.equal(details.cloud.text, 'Cloud replacement') + assert.deepEqual(h.reads, ['note.md']) + }) + + it('loads full bootstrap review bytes when old scan cache survives a lost sync state', async () => { + const h = await fixture() + await h.sync() + const cloud = h.remoteText('note.md', 'Cloud replacement') + h.clearState() + h.reads.length = 0 + const details = await h.api.getMobileCloudBootstrapConflict(h.vault, { + code: 'BOOTSTRAP_CONTENT_CONFLICT', item_id: cloud.item_id, path: cloud.path, + local_sha256: textContent('Original').sha256, remote_sha256: cloud.sha256 + }) + assert.equal(details.local.text, 'Original') + assert.equal(details.cloud.text, 'Cloud replacement') + assert.deepEqual(h.reads, ['note.md']) + }) +}) + diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index 77dc695..5748b74 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -29,6 +29,7 @@ import type { PortableCloudSyncFileSystem } from '@zennotes/shared-domain/cloud- import type { CloudSyncState } from '@zennotes/shared-domain/cloud-sync-engine' import { CachedCloudSyncRepository, type ScanCache } from './cloud-sync-repository' import { MobileVault } from './vault-fs' +import { trackCloudSyncChanges } from './cloud-sync-refresh' import { authenticatedCredential, authenticatedClient, @@ -38,6 +39,7 @@ import { isNotFoundError } from './native-fs' import { randomUUID } from './uuid' const STORAGE_ROOT = 'zennotes-cloud-sync' +const refreshStates = new WeakMap() const persistence: CloudSyncHostPersistence = { async loadLink(vaultKey: string): Promise { @@ -134,7 +136,7 @@ export async function syncMobileCloudVault(vault: MobileVault): Promise vault.rescan(), state) return { // The contract requires a key stable per local vault. rootLabel resolves // to an absolute container URI whose UUID iOS rotates on app update and // restore; rootPath (ZenNotes/) survives both, and keeps the link // when a vault migrates between the local and iCloud tiers. key: vaultKey, - repository: new CachedCloudSyncRepository(fs, vault.fs, { + repository: new CachedCloudSyncRepository(changes.fs, vault.fs, { // A transient failure here disables skipping for the run (full read) — // the safe direction — rather than failing or, worse, mis-skipping. loadTracked: async () => { + // Review/restore actions always receive real bytes, not scan placeholders. + if (!cacheScan) return null const link = await readJson(await linkPath(vaultKey)) if (!isRecord(link) || typeof link.base_url !== 'string' || typeof link.vault_id !== 'string') { return null @@ -307,8 +314,8 @@ function hostVault(vault: MobileVault): CloudSyncHostVault { }, loadCache: async () => readJson(await scanCachePath(vaultKey)), saveCache: async (cache: ScanCache) => writeJson(await scanCachePath(vaultKey), cache) - }), - refresh: () => vault.rescan() + }, changes.markChanged), + refresh: changes.refresh } } diff --git a/tooling/load-mobile-module.ts b/tooling/load-mobile-module.ts new file mode 100644 index 0000000..98ee3e0 --- /dev/null +++ b/tooling/load-mobile-module.ts @@ -0,0 +1,46 @@ +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const require = createRequire(resolve(root, '.zennotes-source/package.json')) + +/** Load real TypeScript modules with this mobile app's pinned source aliases. */ +export async function loadMobileModule( + entry: string | string[], + mocks: Record> = {} +): Promise> { + const { build } = require('esbuild') + const result = await build({ + stdin: { + contents: (Array.isArray(entry) ? entry : [entry]).map((path) => `export * from ${JSON.stringify(path)};`).join('\n'), + resolveDir: root, + loader: 'ts' + }, + absWorkingDir: root, + tsconfig: resolve(root, 'tsconfig.json'), + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + plugins: [{ + name: 'mobile-test-boundaries', + setup(plugin: any) { + plugin.onResolve({ filter: /.*/ }, (args: { path: string }) => + Object.hasOwn(mocks, args.path) ? { path: args.path, namespace: 'mobile-test-boundary' } : undefined + ) + plugin.onLoad({ filter: /.*/, namespace: 'mobile-test-boundary' }, (args: { path: string }) => ({ + contents: `module.exports = __mobileTestMocks[${JSON.stringify(args.path)}];`, + loader: 'js' + })) + } + }] + }) + const module = { exports: {} } + const execute = new Function( + 'require', 'module', 'exports', '__mobileTestMocks', + result.outputFiles[0].text + '\n//# sourceURL=' + entry + ) + execute(require, module, module.exports, mocks) + return module.exports +} From 82df3a8886c7982b8a05d11c42158a694e188943 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 11 Sep 2026 12:44:31 -0500 Subject: [PATCH 06/20] Version 1.9.9 (build 20) Claude-Session: https://claude.ai/code/session_01KWGNhGMPV2dC4APvksmJt7 --- ios/App/App.xcodeproj/project.pbxproj | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index e21812d..b6dc344 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -576,12 +576,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -705,12 +705,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -727,12 +727,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -747,12 +747,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -769,12 +769,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -807,12 +807,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 9e58fa90e4c247267958b58469da8252d74428c0 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 11:20:42 -0500 Subject: [PATCH 07/20] fix: adopt Cloud 2.50.0 fixes and probe incoming changes --- .zennotes-commit | 2 +- src/bridge/mobile-bridge.ts | 5 ++ .../mobile-cloud-sync.integration.test.ts | 47 +++++++++++++++++-- src/bridge/mobile-cloud-sync.ts | 15 ++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/.zennotes-commit b/.zennotes-commit index 92ac9f2..3119a4f 100644 --- a/.zennotes-commit +++ b/.zennotes-commit @@ -1 +1 @@ -a3e638fc93852e602501d6cbbed6e0c0a60d8cd4 +104416f560bb9e709620cabe9e1f225306f7a4ea diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 7e7aa6d..5368e4a 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -102,6 +102,7 @@ import { restoreMobileCloudBackup, restoreMobileCloudBackupNote, syncMobileCloudVault, + hasMobileCloudVaultChanges, updateMobileCloudBackupSchedule, unlinkMobileCloudVault, deleteMobileCloudVault, @@ -852,6 +853,10 @@ export const mobileBridge: ZenBridge = { resolveCloudSettingsConflict: (choice) => resolveMobileCloudSettingsConflict(activeMobileVault(), choice), syncCloudVault: () => syncMobileCloudVault(activeMobileVault()), + hasCloudVaultChanges: () => { + const vault = activeVault() + return vault instanceof MobileVault ? hasMobileCloudVaultChanges(vault) : Promise.resolve(false) + }, getCloudBootstrapConflict: (conflict) => getMobileCloudBootstrapConflict(activeMobileVault(), conflict), resolveCloudBootstrapConflict: (resolution) => diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts index f1f1118..8c42507 100644 --- a/src/bridge/mobile-cloud-sync.integration.test.ts +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -27,6 +27,8 @@ async function fixture(initial: Record = { 'note.md': 'Original' const reads: string[] = [] const refreshes: Record[] = [] const uploaded: CloudSyncMutation[] = [] + const manifestRequests: unknown[] = [] + let accountStatus = { state: 'connected', account: { base_url: 'https://sync.example.test' } } let cursor = 0 let clock = 1000 let failWritePath: string | null = null @@ -54,7 +56,10 @@ async function fixture(initial: Record = { 'note.md': 'Original' } const remote = { listVaults: async () => ({ data: [{ id: 'vault-1', name: 'Test vault' }] }), - manifest: async () => ({ data: [...remoteItems.values()], cursor, next_page: null }), + manifest: async (_vaultId: string, options?: unknown) => { + manifestRequests.push(options) + return { data: [...remoteItems.values()], cursor, next_page: null } + }, changes: async (_vaultId: string, after: number) => { beforeChanges?.() return { data: feed.filter((change) => change.sequence > after), cursor, has_more: false } @@ -164,15 +169,14 @@ async function fixture(initial: Record = { 'note.md': 'Original' './mobile-cloud-auth': { authenticatedCredential: async () => ({ base_url: 'https://sync.example.test', token: 'test-only' }), authenticatedClient: async () => remote, - getMobileCloudAccountStatus: async () => ({ - state: 'connected', account: { base_url: 'https://sync.example.test' } - }) + getMobileCloudAccountStatus: async () => accountStatus } }) await api.linkMobileCloudVault(vault, 'vault-1') const stateKey = () => [...persisted.keys()].find((path) => path.includes('/states/')) return { - api, vault, files, reads, uploaded, refreshes, remoteText, put, + api, vault, files, reads, uploaded, refreshes, remoteText, put, manifestRequests, + setAccountStatus: (value: typeof accountStatus) => { accountStatus = value }, sync: () => api.syncMobileCloudVault(vault), setFailWrite: (path: string | null) => { failWritePath = path }, setBeforeChanges: (callback: typeof beforeChanges) => { beforeChanges = callback }, @@ -191,6 +195,39 @@ async function fixture(initial: Record = { 'note.md': 'Original' } describe('mobile Cloud adapter wiring', () => { + it('detects remote changes from a one-item metadata manifest without scanning local files', async () => { + const h = await fixture() + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), true) + assert.deepEqual(h.manifestRequests, []) + await h.sync() + h.manifestRequests.length = 0 + h.reads.length = 0 + h.refreshes.length = 0 + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + h.remoteText('note.md', 'Incoming edit') + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), true) + assert.deepEqual(h.manifestRequests, [ + { includeContent: false, perPage: 1 }, { includeContent: false, perPage: 1 } + ]) + assert.deepEqual(h.reads, []) + assert.deepEqual(h.refreshes, []) + await h.sync() + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + }) + + it('does not probe an unlinked, disconnected, or different-origin account', async () => { + const h = await fixture() + await h.sync() + h.manifestRequests.length = 0 + h.setAccountStatus({ state: 'disconnected', account: { base_url: 'https://sync.example.test' } }) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + h.setAccountStatus({ state: 'connected', account: { base_url: 'https://another.example.test' } }) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + await h.api.unlinkMobileCloudVault(h.vault) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + assert.deepEqual(h.manifestRequests, []) + }) + it('does not rescan the vault or reread acknowledged bytes during a no-op sync', async () => { const h = await fixture() await h.sync() diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index 5748b74..c630270 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -132,6 +132,21 @@ export async function resolveMobileCloudSettingsConflict( await vault.fs.deleteFile(CLOUD_SYNC_SETTINGS_CONFLICT_PATH).catch(() => {}) } +/** Check the server cursor without scanning the vault or downloading attachment bytes. */ +export async function hasMobileCloudVaultChanges(vault: MobileVault): Promise { + const link = await getMobileCloudVaultLink(vault) + if (!link) return false + const status = await getMobileCloudAccountStatus() + if (status.state !== 'connected' || status.account?.base_url !== link.base_url) return false + const value = await persistence.loadState(vault.fs.rootPath, link.base_url, link.vault_id) + const state = value as Partial | null + if (!state || state.version !== 1 || state.vault_id !== link.vault_id || + typeof state.cursor !== 'number' || !Number.isInteger(state.cursor) || state.cursor < 0) return true + const client = await authenticatedClient() + const manifest = await client.manifest(link.vault_id, { includeContent: false, perPage: 1 }) + return manifest.cursor !== state.cursor +} + export async function syncMobileCloudVault(vault: MobileVault): Promise { // No emit here: the host service runs vault.rescan() after every sync // (cloud-sync-host-service run()'s finally), and rescan emits the one From 42911bfbd927839908aae9ee7f5fa9f4a9ef19ac Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 11:20:42 -0500 Subject: [PATCH 08/20] fix: send iOS Cloud upload bodies and honor request timeouts --- src/bridge/cloud-sync-client.test.ts | 27 +++++++++++++++++++++++++ src/bridge/cloud-sync-client.ts | 11 +++++----- src/bridge/mobile-direct-upload.test.ts | 22 +++++++++++++++++++- src/bridge/mobile-direct-upload.ts | 11 ++++++++-- 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 src/bridge/cloud-sync-client.test.ts diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts new file mode 100644 index 0000000..b4928a5 --- /dev/null +++ b/src/bridge/cloud-sync-client.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +it('allows the full publishing timeout through the native iOS transport', async () => { + const requests: Array<{ connectTimeout?: number; readTimeout?: number }> = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + CapacitorHttp: { + request: async (options: { connectTimeout?: number; readTimeout?: number }) => { + requests.push(options) + return { status: 200, data: { id: 1, slug: 'test', url: 'https://example.test/s/test' } } + } + } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only') + const note = { note_path: 'Test.md', title: 'Test', markdown: 'Latest content' } + await client.publishNote(note) + await client.updatePublishedNote(1, note) + // Capacitor 7 iOS applies connectTimeout ?? readTimeout to the entire + // URLRequest, so a shorter connection value silently wins over readTimeout. + assert.deepEqual(requests.map(({ connectTimeout, readTimeout }) => ({ connectTimeout, readTimeout })), [ + { connectTimeout: 300_000, readTimeout: 300_000 }, + { connectTimeout: 300_000, readTimeout: 300_000 } + ]) +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 665cf24..91114ed 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -39,6 +39,7 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync const transport: CloudSyncHttpTransport = { async request(request: CloudSyncHttpRequest): Promise { const multipart = request.body instanceof FormData + const timeoutMs = request.timeoutMs ?? 300_000 const response = await CapacitorHttp.request({ method: request.method, url: `${normalizedBaseUrl}${request.path}`, @@ -55,12 +56,10 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync ? await serializeFormData(request.body) : request.body, ...(multipart ? { dataType: 'formData' as const } : {}), - connectTimeout: 30_000, - // Generous on purpose: a first sync of an attachment-heavy vault - // legitimately pushes 100-item base64 batches over cellular, and a - // timeout here retries into the same wall forever. Desktop's fetch - // transport has no read timeout at all. - readTimeout: request.timeoutMs ?? 300_000 + // Capacitor iOS uses connectTimeout ahead of readTimeout for the + // entire request. Keep them equal so long publications can finish. + connectTimeout: timeoutMs, + readTimeout: timeoutMs }) if (response.status < 200 || response.status >= 300) { diff --git a/src/bridge/mobile-direct-upload.test.ts b/src/bridge/mobile-direct-upload.test.ts index 2a99f5b..cc80f7a 100644 --- a/src/bridge/mobile-direct-upload.test.ts +++ b/src/bridge/mobile-direct-upload.test.ts @@ -15,6 +15,26 @@ import { } from './mobile-direct-upload.ts' describe('mutateWithMobileDirectUploads', () => { + it('supplies a content type when production signs only the host so iOS sends the file body', () => { + const headers = { Host: 'objects.example.test' } + const options = mobileObjectUploadOptions({ + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', headers, base64: 'AQID', byteLength: 3 + }) + assert.equal(options.headers['Content-Type'], 'application/octet-stream') + assert.equal(options.headers.Host, headers.Host) + assert.deepEqual(headers, { Host: 'objects.example.test' }) + }) + + it('preserves a signed content type regardless of header casing', () => { + const options = mobileObjectUploadOptions({ + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', headers: { 'content-type': 'image/jpeg' }, base64: 'AQID', byteLength: 3 + }) + const contentTypes = Object.entries(options.headers).filter(([key]) => key.toLowerCase() === 'content-type') + assert.deepEqual(contentTypes, [['content-type', 'image/jpeg']]) + }) + it('builds a native binary PUT without an account bearer token or redirects', () => { const options = mobileObjectUploadOptions({ url: 'https://objects.example.test/upload?signature=signed', @@ -36,7 +56,7 @@ describe('mutateWithMobileDirectUploads', () => { }, data: 'AQID', dataType: 'file', - connectTimeout: 30_000, + connectTimeout: 300_000, readTimeout: 300_000, disableRedirects: true }) diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index 22aa87b..2c3ec09 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -57,13 +57,20 @@ export interface MobileObjectUploadOptions { export function mobileObjectUploadOptions( request: MobileObjectUploadRequest ): MobileObjectUploadOptions { + // Capacitor iOS only attaches the binary body when Content-Type exists. + // Production presigned URLs may return just Host; preserve signed headers. + const headers = { ...request.headers } + if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) { + headers['Content-Type'] = 'application/octet-stream' + } return { url: request.url, method: request.method, - headers: request.headers, + headers, data: request.base64, dataType: 'file', - connectTimeout: 30_000, + // iOS uses connectTimeout ahead of readTimeout for the whole request. + connectTimeout: 300_000, readTimeout: 300_000, disableRedirects: true } From 9971018286cd371f90d7afb882e46d8be3afde50 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 11:20:42 -0500 Subject: [PATCH 09/20] fix: preserve Cloud sign-in across native app restarts --- src/bridge/mobile-cloud-auth.test.ts | 86 ++++++++++++++++++++++++++++ src/bridge/mobile-cloud-auth.ts | 47 ++++++++++++--- tooling/load-mobile-module.ts | 1 + 3 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 src/bridge/mobile-cloud-auth.test.ts diff --git a/src/bridge/mobile-cloud-auth.test.ts b/src/bridge/mobile-cloud-auth.test.ts new file mode 100644 index 0000000..8787175 --- /dev/null +++ b/src/bridge/mobile-cloud-auth.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { it } from 'node:test' +import { registerPlugin } from '@capacitor/core' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const account = { + base_url: 'https://zennotes.org', connected_at: '2026-09-14T12:00:00Z', + user: { name: 'Test', email: 'test@example.test' }, + device: { id: 'test-device', name: 'Test phone', platform: 'ios' } +} +const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account }) + +async function coldLaunch(saved: Map) { + // Exercise the real Capacitor lazy proxy: concurrent first calls can + // instantiate separate implementations, each with its own key prefix. + const secureStorage = registerPlugin(`TestCloudStorage${randomUUID()}`, { + web: async () => { + await Promise.resolve() + return new class { + prefix = 'capacitor-storage_' + async setKeyPrefix(prefix: string) { this.prefix = prefix } + async setSynchronize(_value: boolean) {} + async setDefaultKeychainAccess(_value: unknown) {} + async getItem(key: string) { return saved.get(this.prefix + key) ?? null } + async setItem(key: string, value: string) { saved.set(this.prefix + key, value) } + async removeItem(key: string) { saved.delete(this.prefix + key) } + }() + } + }) + const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', { + '@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} }, + '@capacitor/app': { App: { addListener: async () => ({}), getLaunchUrl: async () => null } }, + '@aparajita/capacitor-secure-storage': { + SecureStorage: secureStorage, + KeychainAccess: { whenUnlockedThisDeviceOnly: 'device-only' } + }, + './cloud-sync-client': { createCloudSyncClient: () => assert.fail('status must only read storage') } + }) + await api.configureMobileCloudAuth('test-version') + return api +} + +for (const prefix of ['zennotes.cloud.', 'capacitor-storage_']) { + it(`loads a saved account from ${prefix} on every cold launch`, async () => { + const saved = new Map([[prefix + 'credential', credential]]) + for (let launch = 0; launch < 2; launch++) { + const api = await coldLaunch(saved) + const statuses = await Promise.all([api.getMobileCloudAccountStatus(), api.getMobileCloudAccountStatus()]) + assert.deepEqual(statuses, [{ state: 'connected', account }, { state: 'connected', account }]) + assert.deepEqual([...saved.keys()], ['zennotes.cloud.credential']) + } + }) +} + +it('preserves the canonical account and prevents a legacy credential from returning after logout', async () => { + const saved = new Map([ + ['zennotes.cloud.credential', credential], + ['capacitor-storage_credential', JSON.stringify({ base_url: account.base_url, token: 'old-test-token', account })] + ]) + const api = await coldLaunch(saved) + assert.equal((await api.authenticatedCredential()).token, 'test-only') + await api.logoutMobileCloudAccount() + assert.equal(saved.size, 0) + assert.deepEqual(await (await coldLaunch(saved)).getMobileCloudAccountStatus(), { state: 'disconnected', account: null }) +}) + +it('rejects invalid recovered credentials through the shared auth validator', async () => { + const invalid = JSON.stringify({ base_url: 'https://wrong.example.test', token: 'test-only', account }) + const saved = new Map([['capacitor-storage_credential', invalid]]) + const api = await coldLaunch(saved) + assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'disconnected', account: null }) + assert.equal(saved.size, 0) +}) + +it('recovers pending sign-in state across cold launches', async () => { + const pending = JSON.stringify({ + base_url: account.base_url, state: 'test-state', code_verifier: 'a'.repeat(43), expires_at: '2099-01-01T00:00:00Z' + }) + const saved = new Map([['capacitor-storage_pending-auth', pending]]) + for (let launch = 0; launch < 2; launch++) { + const api = await coldLaunch(saved) + assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'connecting', account: null }) + assert.deepEqual([...saved.entries()], [['zennotes.cloud.pending-auth', pending]]) + } +}) diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index 8b83038..6bc2712 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -36,17 +36,13 @@ const accountListeners = new Set<(status: CloudAccountStatus) => void>() let authFlow: CloudAuthFlow | null = null let callbackQueue = Promise.resolve() -// Lazy and retryable: a module-level Promise.all that rejected once would -// poison every later storage call for the whole session. +// Lazy and retryable so a transient native storage failure does not poison +// every later account read for the session. let secureStorageSetup: Promise | null = null function secureStorageReady(): Promise { if (!Capacitor.isNativePlatform()) return Promise.resolve() if (!secureStorageSetup) { - secureStorageSetup = Promise.all([ - SecureStorage.setKeyPrefix('zennotes.cloud.'), - SecureStorage.setSynchronize(false), - SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly) - ]).then(() => undefined) + secureStorageSetup = configureSecureStorage() secureStorageSetup.catch(() => { secureStorageSetup = null }) @@ -54,6 +50,43 @@ function secureStorageReady(): Promise { return secureStorageSetup } +async function configureSecureStorage(): Promise { + // The Capacitor proxy loads its implementation lazily. Parallel first + // calls can initialize separate instances and lose the configured prefix. + await SecureStorage.setKeyPrefix('zennotes.cloud.') + await SecureStorage.setSynchronize(false) + await SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly) + await migrateAuthStoragePrefix() +} + +async function migrateAuthStoragePrefix(): Promise { + const keys = [CREDENTIAL_KEY, PENDING_AUTH_KEY] + const canonical = new Map() + for (const key of keys) canonical.set(key, await SecureStorage.getItem(key)) + + // Affected builds could persist auth under the plugin's default prefix. + // All storage callers await setup, so none can see this temporary prefix. + // CloudAuthFlow still validates every recovered record before using it. + try { + await SecureStorage.setKeyPrefix('capacitor-storage_') + const legacy = new Map() + for (const key of keys) { + const value = await SecureStorage.getItem(key) + if (value !== null) legacy.set(key, value) + } + await SecureStorage.setKeyPrefix('zennotes.cloud.') + for (const [key, value] of legacy) { + if (canonical.get(key) === null) await SecureStorage.setItem(key, value) + } + // Remove superseded records as well, so logout cannot resurrect an older + // credential on the next launch. Copying must finish before removal. + await SecureStorage.setKeyPrefix('capacitor-storage_') + for (const key of legacy.keys()) await SecureStorage.removeItem(key) + } finally { + await SecureStorage.setKeyPrefix('zennotes.cloud.') + } +} + const storage: CloudAuthStorage = { async loadPending(): Promise { if (!Capacitor.isNativePlatform()) return null diff --git a/tooling/load-mobile-module.ts b/tooling/load-mobile-module.ts index 98ee3e0..0f91a50 100644 --- a/tooling/load-mobile-module.ts +++ b/tooling/load-mobile-module.ts @@ -23,6 +23,7 @@ export async function loadMobileModule( platform: 'node', format: 'cjs', write: false, + define: { 'import.meta.env': '{}' }, plugins: [{ name: 'mobile-test-boundaries', setup(plugin: any) { From e53fab7d5f66e3f0c4a8a91a55820d340ea4f923 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 20:08:37 -0500 Subject: [PATCH 10/20] Refactor(ios): consume the shared editor through public packages Replace the cloned desktop source and private renderer/store imports with immutable core, bridge-contract, and shared-domain packages. iOS retains ownership of native files, Cloud/iCloud behavior, keyboard handling, and gestures while public core APIs coordinate editor and workspace actions. - Reserve native vault moves across save, move, reopen, and rollback; invalidate stale attachment/gesture contexts. - Initialize native typing and editor geometry through public host APIs. - Add package-boundary checks and a disposable native runtime fixture. - Align pod deployment targets with the app's existing iOS 15 minimum for the current Xcode toolchain. This PR is stacked on [#22](https://github.com/ZenNotes/zennotesios/pull/22), `fix/cloud-live-regressions`. Its Cloud fixes are prerequisites, not duplicated changes. The unrelated local 1.9.10/build 21 Xcode version bump is excluded. --- .github/dependabot.yml | 3 +- .github/workflows/ci.yml | 7 +- .gitignore | 2 + .zennotes-commit | 1 - README.md | 47 +- docs/native-boundary-validation.md | 60 + ios/App/Podfile | 9 + ios/App/Podfile.lock | 2 +- package-lock.json | 3240 +++++++++++++++-- package.json | 20 +- src/bridge/events.ts | 2 +- src/bridge/imported-assets.ts | 4 +- src/bridge/link-metadata.ts | 2 +- src/bridge/mobile-bridge.ts | 85 +- src/bridge/native-fs.ts | 3 +- src/bridge/remote-client.ts | 4 +- src/bridge/remote-vault.ts | 12 +- src/bridge/remote-workspace.ts | 2 +- src/bridge/tikz.ts | 2 +- src/bridge/vault-core.ts | 4 +- src/bridge/vault-fs.ts | 135 +- src/bridge/vault-lifecycle.test.ts | 110 + src/bridge/widget-snapshot.ts | 4 +- src/bridge/widgets.ts | 78 +- src/main.tsx | 2 + src/ui-mobile/EditorToolbar.tsx | 117 +- src/ui-mobile/MobileDrawer.tsx | 252 +- src/ui-mobile/MobileShell.tsx | 441 +-- src/ui-mobile/attach.ts | 54 +- src/ui-mobile/deep-links.ts | 41 +- src/ui-mobile/editor-host.ts | 41 + src/ui-mobile/editor-keyboard-scroll.ts | 119 +- src/ui-mobile/editor-native-typing.ts | 55 +- src/ui-mobile/nav.ts | 67 +- src/ui-mobile/note-actions.tsx | 107 +- src/ui-mobile/note-order.ts | 80 +- src/ui-mobile/note-row-gestures.ts | 33 +- src/ui-mobile/selection-toolbar-space.ts | 7 + src/ui-mobile/workspace-context.test.ts | 20 + src/ui-mobile/workspace-context.ts | 20 + tailwind.config.js | 1 - tooling/build-native-boundary-fixture.mjs | 15 + tooling/check-core-boundary.mjs | 43 + tooling/load-mobile-module.ts | 4 +- tooling/native-boundary-fixture.ts | 111 + tooling/prepare-zennotes.sh | 61 - tooling/upstream-check.sh | 18 - tsconfig.json | 31 +- vendor/zennotes/manifest.json | 35 + ...app-core-2.50.4-core.h8a09555824b619b5.tgz | Bin 0 -> 3100761 bytes ...ct-2.50.4-boundaries.h776a0eb6d6ed1862.tgz | Bin 0 -> 36226 bytes ...in-2.50.4-boundaries.h776a0eb6d6ed1862.tgz | Bin 0 -> 255768 bytes vite.config.ts | 161 +- 53 files changed, 3927 insertions(+), 1847 deletions(-) delete mode 100644 .zennotes-commit create mode 100644 docs/native-boundary-validation.md create mode 100644 src/bridge/vault-lifecycle.test.ts create mode 100644 src/ui-mobile/editor-host.ts create mode 100644 src/ui-mobile/selection-toolbar-space.ts create mode 100644 src/ui-mobile/workspace-context.test.ts create mode 100644 src/ui-mobile/workspace-context.ts create mode 100644 tooling/build-native-boundary-fixture.mjs create mode 100644 tooling/check-core-boundary.mjs create mode 100644 tooling/native-boundary-fixture.ts delete mode 100755 tooling/prepare-zennotes.sh delete mode 100755 tooling/upstream-check.sh create mode 100644 vendor/zennotes/manifest.json create mode 100644 vendor/zennotes/zennotes-app-core-2.50.4-core.h8a09555824b619b5.tgz create mode 100644 vendor/zennotes/zennotes-bridge-contract-2.50.4-boundaries.h776a0eb6d6ed1862.tgz create mode 100644 vendor/zennotes/zennotes-shared-domain-2.50.4-boundaries.h776a0eb6d6ed1862.tgz diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 03a13e8..f74deca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,8 +11,7 @@ updates: dependency-type: development ignore: # Majors are taken by hand: React, Vite, TypeScript, Tailwind, and KaTeX - # follow the pinned ZenNotes toolchain (the shell's copies are what - # app-core runs on via resolve.dedupe), and Capacitor needs a migration + # follow the pinned core package peer requirements, and Capacitor needs a migration # pass with a simulator run. KaTeX is 0.x, so its minors are majors. - dependency-name: "*" update-types: ["version-update:semver-major"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 569ec9a..dd403d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ concurrency: jobs: verify: - name: TypeScript, source pin, and iOS build + name: TypeScript, package boundary, and iOS build runs-on: macos-latest steps: - name: Check out repository @@ -29,13 +29,12 @@ jobs: - name: Install mobile dependencies run: npm ci - - name: Prepare exact ZenNotes source - run: npm run source:prepare + - name: Verify installed core packages + run: npm run boundaries:check - name: Reject high-severity production advisories run: | npm audit --omit=dev --audit-level=high - npm --prefix .zennotes-source audit --omit=dev --audit-level=high - name: Test and typecheck bridges run: | diff --git a/.gitignore b/.gitignore index afe71d0..180d8e6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ build/ docs/releases/* + +/dist-boundary-check/ diff --git a/.zennotes-commit b/.zennotes-commit deleted file mode 100644 index 3119a4f..0000000 --- a/.zennotes-commit +++ /dev/null @@ -1 +0,0 @@ -104416f560bb9e709620cabe9e1f225306f7a4ea diff --git a/README.md b/README.md index 4220a80..14b3ce1 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,15 @@ the [zennotes monorepo](https://github.com/ZenNotes/zennotes)) inside a WKWebVie by a local-first vault on the device filesystem. Implements the architecture in `docs/specs/mobile/` (Phase 0 + the on-device parts of Phase 1). -The zennotes repo is consumed **read-only at the exact commit in -`.zennotes-commit`**. `npm run source:prepare` checks that commit out under the -ignored `.zennotes-source/` directory and installs its locked dependencies. -Every typecheck and release build verifies the pin; no ambient sibling checkout -can silently change a mobile binary. +The shell consumes immutable, compiled `@zennotes/app-core`, +`@zennotes/bridge-contract`, and `@zennotes/shared-domain` archives. The current +local candidates live in `vendor/zennotes`; its manifest records their source +identity and checksums. A clean checkout installs them with `npm ci`, without a +source clone or sibling repository. They have not been published. + +`npm run boundaries:check` verifies the pins, installed versions, singleton +React/CodeMirror peers, and public export usage. Native storage, iCloud, stable +vault identities, keyboard behavior, sync, and preferences remain in this repo. ## Architecture @@ -26,21 +30,21 @@ src/ events.ts VaultChangeEvent emitter (in-app writes + rescan) ui-mobile/ MobileShell.tsx bottom nav (capture ⊕ / search / sidebar / palette), - phone drawer behavior via the shared Zustand store + phone drawer behavior via public core snapshots/actions mobile.css safe areas, overlay drawers, keyboard handling ios/ Capacitor-generated Xcode project (appId md.zennotes) ``` -Key decisions (all forced by "don't modify the zennotes repo"): +Host decisions: -- **`runtime: 'web'`** — the bridge contract has no `'mobile'` runtime yet. +- **`hostKind: 'ios'`, `runtime: 'web'`** — the bridge contract has no `'mobile'` runtime yet. Every desktop-only affordance in app-core gates on `runtime === 'desktop'`, so `'web'` + the capability flags produces correct mobile behavior. When the contract gains `'mobile'` + the new capability flags (spec 02), flip it here. - **Vault location** — `Documents/ZenNotes/` in the app container (visible in the Files app via `UIFileSharingEnabled`). First run creates `My Vault` seeded with the official demo tour (imported read-only from - `apps/desktop/src/main/demo-tour-data.ts`). + `@zennotes/shared-domain/demo-tour-data`). - **On-disk contract is byte-compatible with desktop**: same folder layout (`inbox|quick|archive|trash`, `assets/`, legacy `attachements/` recognized — the misspelling is intentional and load-bearing), same `.zennotes/` @@ -48,7 +52,7 @@ Key decisions (all forced by "don't modify the zennotes repo"): rules, same NoteMeta extraction regexes. Includes desktop 2.20's `systemFolderPaths` remaps (vault.json can point `inbox` at `01 - Entry/` etc.) — classification, walking, capture targets, the drawer, and database - path composition all resolve through `@shared/system-folder-paths`, so a + path composition all resolve through `@zennotes/shared-domain/system-folder-paths`, so a remapped vault synced from a Mac files notes identically here. - **Desktop 2.20 features on mobile**: renaming a note carries its leading `# heading` along (runs in the shared store — nothing to port, verified on @@ -65,7 +69,7 @@ Key decisions (all forced by "don't modify the zennotes repo"): Settings → Editor → Text replacements), configurable tab size, manual kanban card order (`kanbanCardOrder` passes through the mobile vault.json layer verbatim). Remote reads use the shared absence-aware reader - (`@shared/remote-absence`): a 500 from a schema read surfaces as an error + (`@zennotes/shared-domain/remote-absence`): a 500 from a schema read surfaces as an error instead of adopting-and-overwriting the database sidecar; pre-2.20.2 servers that answer 500 for missing files are probed once per connection. - **TikZ** is capability-gated off (no WASM TeX on device); blocks show the @@ -77,8 +81,9 @@ Key decisions (all forced by "don't modify the zennotes repo"): ## Build & run ```sh -npm install -npm run sync # prepare pinned source + vite build + cap sync ios +npm ci +npm run boundaries:check +npm run sync # vite build + cap sync ios npx cap open ios # open in Xcode, or: xcodebuild -workspace ios/App/App.xcworkspace -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build @@ -88,9 +93,10 @@ Dev loop against a browser (no simulator): `npm run dev` — note Capacitor plugins are absent in a plain browser, so vault I/O won't work; use the simulator for real testing. -To adopt a newer ZenNotes core, update `.zennotes-commit` to a reviewed full -commit SHA and run `npm run upstream`. Commit the pin with the mobile changes -that depend on it. +To adopt a newer core, copy the reviewed package archives and portable manifest +into `vendor/zennotes`, update the three exact dependencies, and refresh the lockfile. +Run the boundary check, tests, typecheck, and native build before changing the pin. +Retain the previous artifacts for rollback. Never resolve a mutable branch at build time. ## What works today (verified on the iPhone 17 Pro simulator) @@ -135,8 +141,7 @@ that depend on it. - The spec-06 **editing toolbar** docked above the soft keyboard (undo/redo, checkbox, bullet, heading cycle, bold/italic/highlight/code, link, wikilink, - tag, indent/outdent, dismiss) — drives the shared editor via the store's - `editorViewRef` + app-core's `lib/cm-format.ts`; auto-hides with a hardware + tag, indent/outdent, dismiss) — drives the shared editor through named public commands; auto-hides with a hardware keyboard - **Long-press context menus**: a 450ms press on chrome surfaces synthesizes the `contextmenu` event the desktop handlers already listen for (the @@ -213,8 +218,8 @@ kept off the object-storage request and a five-minute mobile transfer timeout. ## Release verification -Pull requests and `main` run bridge tests, a pinned-source typecheck, -production dependency audits for both repositories, a Capacitor sync, and an +Pull requests and `main` run bridge tests, an installed-package typecheck, +production dependency audits, a Capacitor sync, and an Xcode `build-for-testing` of the app and Cloud UI-test targets. Dependabot opens weekly npm and GitHub Actions updates. @@ -232,3 +237,5 @@ signed object upload, completion, manifest, and cleanup with a deterministic - Home-screen widget / App Shortcuts capture entry points - iPad split view (two notes side by side); Android (Phase 2) - Store distribution work (signing, TestFlight, App Store listing — spec 08) + +For device-level package checks, see [native boundary validation](docs/native-boundary-validation.md). diff --git a/docs/native-boundary-validation.md b/docs/native-boundary-validation.md new file mode 100644 index 0000000..a81ef7a --- /dev/null +++ b/docs/native-boundary-validation.md @@ -0,0 +1,60 @@ +# Native package boundary validation + +The host consumes the three archives pinned in `vendor/zennotes/manifest.json`. +There is no main-repository checkout or private editor/store import in the build. + +## Package checks + +```sh +npm ci +npm run boundaries:check +npm run typecheck +npm test +npm run build +``` + +Run the same checks in a fresh copy containing the package manifest/lock, vendor +archives, source, public assets, tooling, TypeScript/Vite/Tailwind/PostCSS config, +and native project. Omit `node_modules` and any historical source clone. + +## Disposable native runtime + +Use a newly created simulator/emulator with no real account or vault. The fixture +creates a uniquely named test vault and writes notes and attachments. Do not put +this fixture in a release or install it on a personal device. + +1. Run `npm run build:boundary-fixture`. Only this explicit command adds + `tooling/native-boundary-fixture.ts` to the app; ordinary `npm run build` does not. +2. In a disposable checkout, copy `dist-boundary-check/` into `dist/`, then run + `npx cap sync ios` and build the native debug/simulator app as below. +3. Install and launch on the disposable device. The fixture checks native typing, + exact Unicode and trailing whitespace, search, task observation, attachments, + note rename, comments, trash/restore, and whole-vault rename under the public + workspace transition lock. +4. Read `Documents/boundary-validation.json` in the app data container. It must + report `passed-awaiting-restart` with 20 checks and no error. +5. Terminate and relaunch the app without clearing its data. The report must now + be `restart-passed`, including vault identity, selected note and exact bytes. +6. Remove the disposable device when finished. Before a normal build, use + `npm run build` and `npx cap sync ios` to replace the fixture assets. + +The fixture uses public core APIs and the native filesystem bridge. It needs no +account credentials. It does not prove live Cloud sync, iCloud account behavior, +or every third-party storage provider; those remain separate release checks. + +## iOS native checks + +From the repository root, on a configured Xcode installation: + +```sh +xcodebuild build-for-testing -workspace ios/App/App.xcworkspace \ + -scheme AppCloudUITests -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath /tmp/zn-boundary-ios-build CODE_SIGNING_ALLOWED=NO +``` + +Install the simulator app on a newly created simulator with `simctl install`. +Use `simctl get_app_container DEVICE md.zennotes data` to locate the report. +Do not run account-backed Cloud UI tests against a personal account as part of +this fixture. iCloud remains native-host-owned and requires a separate test +account/device validation; package adoption does not change its implementation. diff --git a/ios/App/Podfile b/ios/App/Podfile index 2a40c96..a04bbc3 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -30,4 +30,13 @@ end post_install do |installer| assertDeploymentTarget(installer) + # Match the app's existing minimum. Xcode 27 rejects older pod targets. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + minimum = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + if minimum && Gem::Version.new(minimum) < Gem::Version.new('15.0') + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end + end end diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index a5c285f..f24f9f9 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -88,6 +88,6 @@ SPEC CHECKSUMS: IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d -PODFILE CHECKSUM: 8c2072557d285523b52c09b408d696d31beaa1a6 +PODFILE CHECKSUM: 22927e348daa753f82fe2ad9a8f18100024b7007 COCOAPODS: 1.17.0 diff --git a/package-lock.json b/package-lock.json index 84d687b..d33bb8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,13 @@ "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", + "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.11.5", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.50.4-core.h8a09555824b619b5.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.50.4-boundaries.h776a0eb6d6ed1862.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.50.4-boundaries.h776a0eb6d6ed1862.tgz", "codemirror": "^6.0.1", "dompurify": "^3.3.4", "function-plot": "^1.25.3", @@ -64,6 +68,7 @@ "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "^10.5.4", + "esbuild": "^0.28.2", "harper.js": "^2.7.0", "postcss": "^8.5.10", "tailwindcss": "^3.4.17", @@ -112,6 +117,15 @@ "node": ">=20.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -268,12 +282,69 @@ "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", "license": "ISC" }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast/node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/gast/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, "node_modules/@chevrotain/types": { "version": "11.1.2", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", @@ -675,463 +746,2228 @@ "w3c-keyname": "^2.2.4" } }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "license": "MIT" - }, - "node_modules/@iconify/utils": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", - "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@antfu/install-pkg": "^1.1.0", - "@iconify/types": "^2.0.0", - "import-meta-resolve": "^4.2.0" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@ionic/cli-framework-output": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", - "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@ionic/utils-terminal": "2.3.5", - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-array": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", - "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-fs": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", - "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/fs-extra": "^8.0.0", - "debug": "^4.0.0", - "fs-extra": "^9.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-fs/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@ionic/utils-object": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", - "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-process": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", - "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@ionic/utils-object": "2.1.6", - "@ionic/utils-terminal": "2.3.5", - "debug": "^4.0.0", - "signal-exit": "^3.0.3", - "tree-kill": "^1.2.2", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", - "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-subprocess": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", - "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@ionic/utils-array": "2.1.6", - "@ionic/utils-fs": "3.1.7", - "@ionic/utils-process": "2.1.12", - "@ionic/utils-stream": "3.1.7", - "@ionic/utils-terminal": "2.3.5", - "cross-spawn": "^7.0.3", - "debug": "^4.0.0", - "tslib": "^2.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@ionic/utils-terminal": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", - "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/slice-ansi": "^4.0.0", - "debug": "^4.0.0", - "signal-exit": "^3.0.3", - "slice-ansi": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "tslib": "^2.0.1", - "untildify": "^4.0.0", - "wrap-ansi": "^7.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16.0.0" + "node": ">=18" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@excalidraw/excalidraw": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz", + "integrity": "sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "6.0.2", + "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/mermaid-to-excalidraw": "2.2.2", + "@excalidraw/random-username": "1.1.0", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-tabs": "1.0.2", + "browser-fs-access": "0.29.1", + "canvas-roundrect-polyfill": "0.0.1", + "clsx": "1.1.1", + "cross-env": "7.0.3", + "es6-promise-pool": "2.5.0", + "fractional-indexing": "3.2.0", + "fuzzy": "0.1.3", + "image-blob-reduce": "3.0.1", + "jotai": "2.11.0", + "jotai-scope": "0.7.2", + "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", + "nanoid": "3.3.3", + "open-color": "1.9.1", + "pako": "2.0.3", + "perfect-freehand": "1.2.0", + "pica": "7.1.1", + "png-chunk-text": "1.0.0", + "png-chunks-encode": "1.0.0", + "png-chunks-extract": "1.0.0", + "points-on-curve": "1.0.1", + "pwacompat": "2.0.17", + "roughjs": "4.6.4", + "sass": "1.51.0", + "tunnel-rat": "0.1.2" + }, + "peerDependencies": { + "react": "^17.0.2 || ^18.2.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@braintree/sanitize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/immutable": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/points-on-curve": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", + "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/roughjs": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", + "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/roughjs/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/sass": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", + "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@excalidraw/laser-pointer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", + "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==", + "license": "MIT" + }, + "node_modules/@excalidraw/markdown-to-text": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz", + "integrity": "sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==", + "license": "MIT" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz", + "integrity": "sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==", + "license": "MIT", + "dependencies": { + "@excalidraw/markdown-to-text": "0.1.2", + "@mermaid-js/parser": "^0.6.3", + "mermaid": "^11.12.1", + "nanoid": "4.0.2" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@excalidraw/random-username": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz", + "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.0.0" + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", + "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz", + "integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@myriaddreamin/typst-ts-renderer": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@myriaddreamin/typst-ts-renderer/-/typst-ts-renderer-0.7.0.tgz", + "integrity": "sha512-3sXIGxZn9MufPrPn6251DeuLf2FIEILYNzY5lX0XOJmaIYqgvQ3qpfqkCjgQD+splBvt5R1N3BuuNFrZKsHhMw==", + "license": "Apache-2.0" + }, + "node_modules/@myriaddreamin/typst-ts-web-compiler": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@myriaddreamin/typst-ts-web-compiler/-/typst-ts-web-compiler-0.7.0.tgz", + "integrity": "sha512-nMwMcfOBy5pABPDuVM7/u8425SxhPUM+OJe6daqqgVOT3+neCTz4JKwx2L8XasfEHTNvd0cUI07LR9q5ADo7Gw==", + "license": "Apache-2.0" + }, + "node_modules/@myriaddreamin/typst.ts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@myriaddreamin/typst.ts/-/typst.ts-0.7.0.tgz", + "integrity": "sha512-JtO5Td/1QesH1IBKAZtbayFNK8u2sl3iz18Y67uYqBEdcXiu3z+wpZcDDKlmVJvAcCgSjHTnuk6ZLfkYclrGGQ==", + "license": "Apache-2.0", + "dependencies": { + "idb": "^7.1.1" + }, + "peerDependencies": { + "@myriaddreamin/typst-ts-renderer": "^0.7.0", + "@myriaddreamin/typst-ts-web-compiler": "^0.7.0" + }, + "peerDependenciesMeta": { + "@myriaddreamin/typst-ts-renderer": { + "optional": true + }, + "@myriaddreamin/typst-ts-web-compiler": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz", + "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz", + "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz", + "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/common": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", - "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", - "license": "MIT" + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } }, - "node_modules/@lezer/cpp": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", - "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/css": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", - "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/go": { + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", - "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/highlight": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.3.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/html": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", - "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/java": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", - "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/javascript": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", - "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.1.3", - "@lezer/lr": "^1.3.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/json": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", - "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@lezer/lr": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "node_modules/@radix-ui/react-tabs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-roving-focus": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/markdown": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz", - "integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.5.0", - "@lezer/highlight": "^1.0.0" + "@babel/runtime": "^7.13.10" } }, - "node_modules/@lezer/php": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", - "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.1.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/python": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", - "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/rust": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", - "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/sass": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", - "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", + "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/xml": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", - "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@lezer/yaml": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", - "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.4.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", - "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", - "license": "MIT" + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } }, - "node_modules/@mermaid-js/parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", - "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.2" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@radix-ui/react-use-callback-ref": "1.1.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", "license": "MIT", - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/rect": "1.1.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@oxc-project/types": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", - "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", - "dev": true, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, "node_modules/@replit/codemirror-vim": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.4.0.tgz", @@ -1413,7 +3249,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/d3": { @@ -1908,6 +3744,97 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/@zennotes/app-core": { + "version": "2.50.4-core.h8a09555824b619b5", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.50.4-core.h8a09555824b619b5.tgz", + "integrity": "sha512-U1E7L1oiaNAqwMxER8ZsN/xiH3uS2bzvgg0eQDK6HZ+y+FQvqXzWs5mEsD63YYdumedlCO1lkbVezq7n8G3BGQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language-data": "^6.5.1", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.5.8", + "@excalidraw/excalidraw": "^0.18.1", + "@myriaddreamin/typst-ts-renderer": "^0.7.0", + "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", + "@myriaddreamin/typst.ts": "^0.7.0", + "@replit/codemirror-vim": "^6.3.0", + "@xyflow/react": "^12.11.2", + "@zennotes/bridge-contract": "2.50.4-boundaries.h776a0eb6d6ed1862", + "@zennotes/shared-domain": "2.50.4-boundaries.h776a0eb6d6ed1862", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "harper.js": "^2.7.0", + "hast": "^1.0.0", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mdast": "^3.0.0", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "vscode-oniguruma": "^2.0.1", + "vscode-textmate": "^9.3.2" + }, + "peerDependencies": { + "@codemirror/language": "^6.10.6", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^6.4.3 || ^7.0.0 || ^8.0.0", + "zustand": "^5.0.2" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/@zennotes/bridge-contract": { + "version": "2.50.4-boundaries.h776a0eb6d6ed1862", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.50.4-boundaries.h776a0eb6d6ed1862.tgz", + "integrity": "sha512-hHS62ENXjQ9ROVdyoC9CUmgXk0mzJU4UE5+8hwtATn+GreZBcKZZS1mdP0adWBjWwgGePfwqGovcojAutm+ntA==", + "license": "MIT" + }, + "node_modules/@zennotes/shared-domain": { + "version": "2.50.4-boundaries.h776a0eb6d6ed1862", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.50.4-boundaries.h776a0eb6d6ed1862.tgz", + "integrity": "sha512-TV9u2Foo/NvetN+vBZ5BQlzCV+U0vok7Ojqkk9LLr0s+2y/1JN1qKVBfnPjqPttEXTXY2vgAL9fJa0vT28CynQ==", + "license": "MIT", + "dependencies": { + "@zennotes/bridge-contract": "2.50.4-boundaries.h776a0eb6d6ed1862", + "lz-string": "^1.5.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1945,7 +3872,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -1971,6 +3897,18 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2096,7 +4034,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2135,7 +4072,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -2144,6 +4080,12 @@ "node": ">=8" } }, + "node_modules/browser-fs-access": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz", + "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==", + "license": "Apache-2.0" + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -2228,6 +4170,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-roundrect-polyfill": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz", + "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", + "license": "MIT" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2268,11 +4216,48 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/chevrotain/node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/chevrotain/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -2297,7 +4282,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -2322,6 +4306,15 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -2386,17 +4379,43 @@ "layout-base": "^1.0.0" } }, + "node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/crelt": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3003,12 +5022,18 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3120,6 +5145,57 @@ "benchmarks" ] }, + "node_modules/es6-promise-pool": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", + "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3258,14 +5334,12 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -3296,6 +5370,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fractional-indexing": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/fs-extra": { "version": "11.3.6", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", @@ -3315,7 +5398,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3355,6 +5437,23 @@ "interval-arithmetic-eval": "^0.5.3" } }, + "node_modules/fuzzy": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", + "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -3386,6 +5485,12 @@ "node": ">=10.13.0" } }, + "node_modules/glur": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3418,7 +5523,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/harper.js/-/harper.js-2.7.0.tgz", "integrity": "sha512-INDnUMNJvQzv5Zv9lhgGuIRYNIpDvOXcieCbo5ED/dwn8V/02zVW5kKvU6jSJJHq1MpY0VDyg+5RjE7z5D+FeA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "fflate": "^0.8.2" @@ -3437,6 +5541,13 @@ "node": ">= 0.4" } }, + "node_modules/hast": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hast/-/hast-1.0.0.tgz", + "integrity": "sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA==", + "deprecated": "Renamed to rehype", + "license": "MIT" + }, "node_modules/hast-util-from-dom": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", @@ -3676,6 +5787,30 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/image-blob-reduce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", + "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==", + "license": "MIT", + "dependencies": { + "pica": "^7.1.0" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -3690,7 +5825,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -3735,7 +5869,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -3789,7 +5922,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3809,7 +5941,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -3822,7 +5953,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -3857,7 +5987,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jiti": { @@ -3870,6 +5999,37 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jotai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", + "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-scope": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz", + "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.9.2", + "react": ">=17.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3963,6 +6123,22 @@ "node": ">=6" } }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -3973,7 +6149,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -4256,6 +6432,18 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -4302,6 +6490,15 @@ "node": ">=12.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4334,6 +6531,13 @@ "mr-parser": "^0.2.1" } }, + "node_modules/mdast": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast/-/mdast-3.0.0.tgz", + "integrity": "sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==", + "deprecated": "`mdast` was renamed to `remark`", + "license": "MIT" + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -5298,6 +7502,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multimath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "object-assign": "^4.1.1" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5314,7 +7528,7 @@ "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -5364,6 +7578,15 @@ "double-bits": "^1.1.0" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -5378,7 +7601,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5388,7 +7610,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5422,6 +7643,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-color": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", + "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==", + "license": "MIT" + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5435,6 +7662,12 @@ "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", "license": "MIT" }, + "node_modules/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5457,7 +7690,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5504,18 +7736,36 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-freehand": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", + "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==", + "license": "MIT" + }, + "node_modules/pica": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "inherits": "^2.0.3", + "multimath": "^2.0.0", + "object-assign": "^4.1.1", + "webworkify": "^1.5.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5559,6 +7809,31 @@ "node": ">=10.4.0" } }, + "node_modules/png-chunk-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", + "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", + "license": "MIT" + }, + "node_modules/png-chunks-encode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", + "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0", + "sliced": "^1.0.1" + } + }, + "node_modules/png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -5579,7 +7854,7 @@ "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -5738,6 +8013,21 @@ "dev": true, "license": "MIT" }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -5772,6 +8062,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pwacompat": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", + "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==", + "license": "Apache-2.0" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5818,6 +8114,75 @@ "react": "^18.3.1" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5847,7 +8212,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -6098,7 +8462,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.148.0", @@ -6197,6 +8561,63 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sass": { + "version": "1.104.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.104.1.tgz", + "integrity": "sha512-yDA+1aIG3EHgN4V/BvuhCvu61FF6hEd4e+9DxikUm9U0CAGuvdIZ/UYy7qbOxjhbbWQraoyLlMuzdRGOSV5Bmw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/sax": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", @@ -6243,7 +8664,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6256,7 +8676,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6294,11 +8713,17 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", + "deprecated": "Unsupported", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6566,7 +8991,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -6583,7 +9008,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6601,7 +9026,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -6614,7 +9039,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -6675,6 +9099,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6862,6 +9323,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -6937,7 +9441,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", @@ -7015,7 +9519,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -7024,6 +9528,49 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/vscode-oniguruma": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", @@ -7036,6 +9583,12 @@ "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", "license": "MIT" }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -7052,11 +9605,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webworkify": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 78d2bd5..c543bc0 100644 --- a/package.json +++ b/package.json @@ -3,20 +3,19 @@ "private": true, "version": "0.1.0", "type": "module", - "description": "ZenNotes for iPhone — Capacitor shell over the ZenNotes app core", + "description": "ZenNotes for iPhone \u2014 Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", "scripts": { "dev": "vite", - "source:prepare": "sh tooling/prepare-zennotes.sh", - "prebuild": "npm run source:prepare", "build": "vite build", "test": "node --test", "e2e:cloud": "node tooling/cloud-direct-upload-e2e.mjs", - "pretypecheck": "npm run source:prepare", "typecheck": "tsc --noEmit", "sync": "npm run build && cap sync ios", - "upstream": "sh tooling/upstream-check.sh", - "ios": "npm run build && cap sync ios && cap open ios" + "upstream": "npm run boundaries:check && npm run typecheck", + "ios": "npm run build && cap sync ios && cap open ios", + "boundaries:check": "node tooling/check-core-boundary.mjs", + "build:boundary-fixture": "node tooling/build-native-boundary-fixture.mjs" }, "dependencies": { "@aparajita/capacitor-secure-storage": "7.1.6", @@ -66,7 +65,11 @@ "unist-util-visit": "^5.0.0", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", - "zustand": "^5.0.15" + "zustand": "^5.0.15", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.50.4-core.h8a09555824b619b5.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.50.4-boundaries.h776a0eb6d6ed1862.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.50.4-boundaries.h776a0eb6d6ed1862.tgz", + "@lezer/common": "^1.5.2" }, "devDependencies": { "@capacitor/cli": "^7.6.8", @@ -79,6 +82,7 @@ "postcss": "^8.5.10", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", - "vite": "^8.2.2" + "vite": "^8.2.2", + "esbuild": "^0.28.2" } } diff --git a/src/bridge/events.ts b/src/bridge/events.ts index 2c23ecb..859336a 100644 --- a/src/bridge/events.ts +++ b/src/bridge/events.ts @@ -5,7 +5,7 @@ * runs after every sync. The subscription API is identical to desktop/web so * app-core consumes it as-is. */ -import type { VaultChangeEvent } from '@bridge-contract/ipc' +import type { VaultChangeEvent } from '@zennotes/bridge-contract/ipc' type VaultChangeListener = (ev: VaultChangeEvent) => void diff --git a/src/bridge/imported-assets.ts b/src/bridge/imported-assets.ts index 1195a1f..6c0cd60 100644 --- a/src/bridge/imported-assets.ts +++ b/src/bridge/imported-assets.ts @@ -1,13 +1,13 @@ /** * Where an imported file goes and how it is linked. * - * A leaf module on purpose: `vault-core` reaches `@shared/*` through a Vite + * A leaf module on purpose: `vault-core` reaches `@zennotes/shared-domain/*` through a Vite * alias, which plain `node --test` cannot resolve, so nothing there is * unit-testable. These rules are where the attachment bugs hid, so they live * where a test can reach them. The vault core re-exports them so existing * importers remain unaffected. */ -import type { ImportedAssetKind } from '@bridge-contract/ipc' +import type { ImportedAssetKind } from '@zennotes/bridge-contract/ipc' export const ASSETS_DIR = 'assets' diff --git a/src/bridge/link-metadata.ts b/src/bridge/link-metadata.ts index 57b7fbb..a90425d 100644 --- a/src/bridge/link-metadata.ts +++ b/src/bridge/link-metadata.ts @@ -14,7 +14,7 @@ * (metadata lives in ``, so the whole page is never needed). */ import { CapacitorHttp } from '@capacitor/core' -import type { LinkMetadata } from '@shared/ipc' +import type { LinkMetadata } from '@zennotes/shared-domain/ipc' const TIMEOUT_MS = 6000 const MAX_CHARS = 512 * 1024 diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 5368e4a..0e0e017 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -1,3 +1,4 @@ +import { relocateLocalVault } from '@zennotes/app-core/workspace' /** * The mobile `window.zen` — third ZenBridge implementation (after Electron IPC * and the web HTTP bridge). Vault operations run against the on-device vault @@ -36,13 +37,13 @@ import type { VaultTextSearchBackendPreference, VaultTextSearchCapabilities, VaultTextSearchMatch -} from '@shared/ipc' -import { createDatabaseOps } from '@shared/database-ops' +} from '@zennotes/shared-domain/ipc' +import { createDatabaseOps } from '@zennotes/shared-domain/database-ops' import type { CustomCodeLanguage, CustomCodeLanguageInstallInput, CustomCodeLanguageUpdateInput -} from '@shared/custom-code-languages' +} from '@zennotes/shared-domain/custom-code-languages' import type { ApplyWorkflowInput, WorkflowFile, @@ -50,12 +51,12 @@ import type { WorkflowRunSummary, WorkflowUndoResult, WriteWorkflowInput -} from '@bridge-contract/workflows' +} from '@zennotes/bridge-contract/workflows' import type { McpClientStatus, McpInstructionsPayload, McpServerRuntime -} from '@shared/mcp-clients' +} from '@zennotes/shared-domain/mcp-clients' import { MobileVault } from './vault-fs' import { listVaultDirs, VAULTS_DIR } from './native-fs' import { randomUUID } from './uuid' @@ -263,23 +264,22 @@ export async function renameVault(entry: MobileVaultEntry, newName: string): Pro if (!clean) throw new Error('Enter a name.') if (clean === entry.name) return if (entry.tier === 'external') throw new Error('Rename this folder in the Files app.') - await assertNameFree(entry.tier, clean) const wasCurrent = isCurrentVaultEntry(entry) - if (entry.tier === 'icloud') { - await Filesystem.rename({ - from: await icloudVaultUrl(entry.name), - to: await icloudVaultUrl(clean) - }) - if (wasCurrent) await openVaultByName(clean, await icloudVaultUrl(clean)) - } else { - await Filesystem.rename({ - from: `${VAULTS_DIR}/${entry.name}`, - to: `${VAULTS_DIR}/${clean}`, - directory: Directory.Documents, - toDirectory: Directory.Documents - }) - if (wasCurrent) await openVaultByName(clean) - } + const token = (name: string): string => entry.tier === 'icloud' + ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(name)}` : `${VAULT_ROOT_PREFIX}${name}` + let from = '', to = '' + await relocateLocalVault({ + ...(wasCurrent ? { reopen: { source: token(entry.name), destination: token(clean) } } : {}), + move: async () => { + await assertNameFree(entry.tier, clean) + from = entry.tier === 'icloud' ? await icloudVaultUrl(entry.name) : `${VAULTS_DIR}/${entry.name}` + to = entry.tier === 'icloud' ? await icloudVaultUrl(clean) : `${VAULTS_DIR}/${clean}` + await Filesystem.rename({ from, to, ...(entry.tier === 'icloud' ? {} : { directory: Directory.Documents, toDirectory: Directory.Documents }) }) + }, + rollback: async () => { + await Filesystem.rename({ from: to, to: from, ...(entry.tier === 'icloud' ? {} : { directory: Directory.Documents, toDirectory: Directory.Documents }) }) + } + }) } /** Permanently removes the vault directory and everything in it. The UI owns @@ -310,23 +310,26 @@ export function forgetExternalVault(): void { * the hood, so notes transfer — not copy). Reopens it when it's current. */ export async function moveVault(entry: MobileVaultEntry, to: 'local' | 'icloud'): Promise { if (entry.tier === 'external' || entry.tier === to) return - await assertNameFree(to, entry.name) const wasCurrent = isCurrentVaultEntry(entry) - const localPath = await localVaultPath(entry.name) - if (to === 'icloud') { - const status = await icloudStatus() - if (!status.available) { - throw new Error('iCloud is not available. Sign in to iCloud and turn on iCloud Drive.') + const token = (tier: 'local' | 'icloud'): string => tier === 'icloud' + ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(entry.name)}` : `${VAULT_ROOT_PREFIX}${entry.name}` + let localPath = '' + await relocateLocalVault({ + ...(wasCurrent ? { reopen: { source: token(entry.tier), destination: token(to) } } : {}), + move: async () => { + await assertNameFree(to, entry.name) + localPath = await localVaultPath(entry.name) + if (to === 'icloud') { + const status = await icloudStatus() + if (!status.available) throw new Error('iCloud is not available. Sign in to iCloud and turn on iCloud Drive.') + await ICloudVault.enable({ localPath, name: entry.name }) + } else await ICloudVault.disable({ name: entry.name, localPath }) + }, + rollback: async () => { + if (to === 'icloud') await ICloudVault.disable({ name: entry.name, localPath }) + else await ICloudVault.enable({ localPath, name: entry.name }) } - await ICloudVault.enable({ localPath, name: entry.name }) - } else { - await ICloudVault.disable({ name: entry.name, localPath }) - } - if (wasCurrent) { - setStoragePref(to) - if (to === 'icloud') await openVaultByName(entry.name, await icloudVaultUrl(entry.name)) - else await openVaultByName(entry.name) - } + }) } const MOBILE_CAPABILITIES: ZenCapabilities = { @@ -360,7 +363,8 @@ function mobileAppInfo(): ZenAppInfo { version: appVersion, description: 'ZenNotes for iPhone', homepage: 'https://zennotes.org', - runtime: 'web' + runtime: 'web', + hostKind: 'ios' } } @@ -581,7 +585,7 @@ export async function importPendingShares(): Promise { } // -------------------------------------------------------------------- -// Databases — the shared composition (@shared/database-ops, extracted from +// Databases — the shared composition (@zennotes/shared-domain/database-ops, extracted from // the web bridge in 2.20) bound to the active vault's file ops. Remap-aware: // the shared layout honors vault.json `systemFolderPaths` when composing // `.base/` paths, which the old local copy of this glue did not. @@ -800,8 +804,8 @@ export const mobileBridge: ZenBridge = { getCapabilities: (): ZenCapabilities => MOBILE_CAPABILITIES, getAppInfo: (): ZenAppInfo => mobileAppInfo(), - platform: async () => 'darwin' as NodeJS.Platform, - platformSync: () => 'darwin' as NodeJS.Platform, + platform: async () => 'darwin' as const, + platformSync: () => 'darwin' as const, listSystemFonts: async () => [ 'Avenir', 'Charter', @@ -1098,6 +1102,7 @@ export const mobileBridge: ZenBridge = { // External file links name OS paths outside the iOS sandbox; the exact // 'desktop-only' token makes app-core show its friendly toast. openExternalFile: async () => ({ ok: false, error: 'desktop-only' }), + openExternalUrl: async () => ({ ok: false, error: 'desktop-only' }), // A vault attachment, unlike an arbitrary OS path, is a file this app owns // — so the phone can genuinely open it (share sheet / Quick Look) instead // of answering 'desktop-only' the way the web bridge must. diff --git a/src/bridge/native-fs.ts b/src/bridge/native-fs.ts index 617d16a..199182f 100644 --- a/src/bridge/native-fs.ts +++ b/src/bridge/native-fs.ts @@ -141,7 +141,8 @@ export class NativeFs { async readTextOrNull(relPath: string): Promise { try { return await this.readText(relPath) - } catch { + } catch (error) { + if (!isNotFoundError(error)) throw error return null } } diff --git a/src/bridge/remote-client.ts b/src/bridge/remote-client.ts index 7a8f80e..de7b89c 100644 --- a/src/bridge/remote-client.ts +++ b/src/bridge/remote-client.ts @@ -25,8 +25,8 @@ import type { VaultInfo, VaultSettings, VaultTextSearchMatch -} from '@shared/ipc' -import type { VaultTask } from '@shared/tasks' +} from '@zennotes/shared-domain/ipc' +import type { VaultTask } from '@zennotes/shared-domain/tasks' import { importedAssetFilename } from './imported-assets.ts' export interface RemoteClientOptions { diff --git a/src/bridge/remote-vault.ts b/src/bridge/remote-vault.ts index 80e56bf..9a9af24 100644 --- a/src/bridge/remote-vault.ts +++ b/src/bridge/remote-vault.ts @@ -29,12 +29,12 @@ import type { VaultInfo, VaultSettings, VaultTextSearchMatch -} from '@shared/ipc' -import type { VaultTask } from '@shared/tasks' -import type { CustomTemplateFile, WriteTemplateInput } from '@bridge-contract/templates' -import type { ImportedAsset } from '@shared/ipc' -import { createAbsenceAwareReader } from '@shared/remote-absence' -import { pastedImageFilename } from '@shared/pasted-image' +} from '@zennotes/shared-domain/ipc' +import type { VaultTask } from '@zennotes/shared-domain/tasks' +import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' +import type { ImportedAsset } from '@zennotes/shared-domain/ipc' +import { createAbsenceAwareReader } from '@zennotes/shared-domain/remote-absence' +import { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' import { emitVaultChange } from './events' import { importedAssetFilename } from './imported-assets' import { RemoteClient, RemoteRequestError } from './remote-client' diff --git a/src/bridge/remote-workspace.ts b/src/bridge/remote-workspace.ts index 0e6c222..9da2f52 100644 --- a/src/bridge/remote-workspace.ts +++ b/src/bridge/remote-workspace.ts @@ -15,7 +15,7 @@ import type { RemoteWorkspaceProfileInput, ServerCapabilities, VaultInfo -} from '@shared/ipc' +} from '@zennotes/shared-domain/ipc' import { RemoteClient, normalizeBaseUrl } from './remote-client' import { randomUUID } from './uuid' import { RemoteVault } from './remote-vault' diff --git a/src/bridge/tikz.ts b/src/bridge/tikz.ts index 57fe7e6..a6131fe 100644 --- a/src/bridge/tikz.ts +++ b/src/bridge/tikz.ts @@ -16,7 +16,7 @@ * per-source caching, and a serialized render queue (the TeX engine does not * tolerate concurrent runs). */ -import type { TikzRenderResponse } from '@shared/ipc' +import type { TikzRenderResponse } from '@zennotes/shared-domain/ipc' const ASSET_BASE = 'tikzjax/' // Generous: the first render on a cold device includes engine load + wasm diff --git a/src/bridge/vault-core.ts b/src/bridge/vault-core.ts index aad3b3c..9e736ed 100644 --- a/src/bridge/vault-core.ts +++ b/src/bridge/vault-core.ts @@ -6,12 +6,12 @@ * The `attachements` (sic) constant is the intentional, load-bearing legacy * spelling — do not "fix" it and do not add an `attachments` variant. */ -import type { ImportedAssetKind, NoteFolder, VaultSettings } from '@bridge-contract/ipc' +import type { ImportedAssetKind, NoteFolder, VaultSettings } from '@zennotes/bridge-contract/ipc' import { resolveFolderPath, systemFolderForDirName, type SystemFolderPaths -} from '@shared/system-folder-paths' +} from '@zennotes/shared-domain/system-folder-paths' import { ASSETS_DIR } from './imported-assets' // Re-exported so every existing importer keeps reaching them here; they live in diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 229d9f0..05456a0 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -1,3 +1,4 @@ +import { relocateVaultEntries, type VaultRelocation, type VaultRelocationIO } from '@zennotes/shared-domain/vault-relocation' /** * The on-device vault: desktop `vault.ts` semantics reimplemented over the * Capacitor Filesystem (there is no Node `fs` in a WKWebView). Same on-disk @@ -19,28 +20,28 @@ import type { VaultDemoTourResult, VaultSettings, VaultTextSearchMatch -} from '@bridge-contract/ipc' -import { DEFAULT_VAULT_SETTINGS } from '@bridge-contract/ipc' -import type { CustomTemplateFile, WriteTemplateInput } from '@bridge-contract/templates' -import type { VaultTask } from '@shared/tasks' -import { parseTaskFile, parseTasksFromBody } from '@shared/tasks' -import { normalizeHarperVaultState } from '@shared/harper-settings' -import { normalizeNoteComments } from '@shared/note-comments' +} from '@zennotes/bridge-contract/ipc' +import { DEFAULT_VAULT_SETTINGS } from '@zennotes/bridge-contract/ipc' +import type { CustomTemplateFile, WriteTemplateInput } from '@zennotes/bridge-contract/templates' +import type { VaultTask } from '@zennotes/shared-domain/tasks' +import { parseTaskFile, parseTasksFromBody } from '@zennotes/shared-domain/tasks' +import { normalizeHarperVaultState } from '@zennotes/shared-domain/harper-settings' +import { normalizeNoteComments } from '@zennotes/shared-domain/note-comments' import { isPathExcludedFromTasks, normalizeTasksExcludedFolders -} from '@shared/tasks-excluded-folders' -import { pastedImageFilename } from '@shared/pasted-image' +} from '@zennotes/shared-domain/tasks-excluded-folders' +import { pastedImageFilename } from '@zennotes/shared-domain/pasted-image' import { randomUUID } from './uuid' import { bytesToBase64 } from './base64' -import { isFormDirName, isDatabaseInternalPath } from '@shared/databases' -import { emptyExcalidrawDocument } from '@shared/excalidraw' -import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@desktop-main/demo-tour-data' +import { isFormDirName, isDatabaseInternalPath } from '@zennotes/shared-domain/databases' +import { emptyExcalidrawDocument } from '@zennotes/shared-domain/excalidraw' +import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@zennotes/shared-domain/demo-tour-data' import { WELCOME_NOTE_PATH, WELCOME_NOTE_BODY } from './welcome-note' import { rewriteWikilinksForRename, type RenameNoteRef -} from '@desktop-main/wikilink-rename' +} from '@zennotes/shared-domain/wikilink-rename' import { NativeFs } from './native-fs' import { ensureDownloaded } from './icloud' import { emitVaultChange } from './events' @@ -83,7 +84,7 @@ import { normalizeSystemFolderPaths, resolveFolderPath, type SystemFolderPaths -} from '@shared/system-folder-paths' +} from '@zennotes/shared-domain/system-folder-paths' const META_CACHE_FILE = `${INTERNAL_VAULT_DIR}/mobile-note-meta-cache-v1.json` /** Restore metadata written next to each deleted asset (desktop parity). */ @@ -679,6 +680,37 @@ export class MobileVault { return await this.metaForPath(rel) } + private relocationIO(): VaultRelocationIO { + return { + stat: path => this.fs.statVerified(path), + mkdir: path => this.fs.mkdir(path), + rename: (from, to) => this.fs.rename(from, to) + } + } + + private async relocateNote(oldPath: string, newPath: string): Promise { + await relocateVaultEntries(this.relocationIO(), [ + { from: oldPath, to: newPath, required: true }, + { from: this.commentsPathFor(oldPath), to: this.commentsPathFor(newPath) } + ]) + this.invalidateMeta(oldPath) + } + + /** Detach both trees before cleanup so a failed move can restore the original. */ + private async detachContent(path: string, comments: string, required = true): Promise { + const temporary = `${INTERNAL_VAULT_DIR}/delete-${uuid()}` + const moves: VaultRelocation[] = [ + { from: path, to: `${temporary}/content`, required }, + { from: comments, to: `${temporary}/comments` } + ] + await relocateVaultEntries(this.relocationIO(), moves) + try { + if (await this.fs.statVerified(temporary) !== null) await this.fs.rmdir(temporary) + } catch (error) { + console.warn('Detached deleted content retained for cleanup', temporary, error) + } + } + async renameNote(relPath: string, nextTitle: string): Promise { const rel = resolveSafeRel(relPath) const folder = await this.folderOf(rel) @@ -693,9 +725,7 @@ export class MobileVault { } // Snapshot for inbound wikilink rewriting before the rename lands. const preNotes = await this.listNotes() - await this.fs.rename(rel, target) - this.invalidateMeta(rel) - await this.moveNoteComments(rel, target) + await this.relocateNote(rel, target) emitVaultChange({ kind: 'unlink', path: rel, folder, scope: 'content' }) emitVaultChange({ kind: 'add', path: target, folder, scope: 'content' }) await this.updateInboundWikilinks(preNotes, rel, trimmed) @@ -760,9 +790,7 @@ export class MobileVault { const baseTitle = stemName(filename) const finalTitle = await this.uniqueTitle(destDir, baseTitle, ext) const destRel = joinPath(destDir, `${finalTitle}${ext}`) - await this.fs.rename(rel, destRel) - this.invalidateMeta(rel) - await this.moveNoteComments(rel, destRel) + await this.relocateNote(rel, destRel) emitVaultChange({ kind: 'unlink', path: rel, folder: sourceFolder, scope: 'content' }) emitVaultChange({ kind: 'add', path: destRel, folder: target, scope: 'content' }) return await this.metaForPath(destRel) @@ -783,25 +811,17 @@ export class MobileVault { async emptyTrash(): Promise { const trashDir = await this.folderRootRel('trash') - const entries = await this.fs.readdir(trashDir) - for (const entry of entries) { - const rel = `${trashDir}/${entry.name}` - await this.removeNoteComments(rel) - if (entry.type === 'directory') { - await this.fs.rmdir(rel).catch(() => {}) - } else { - await this.fs.deleteFile(rel).catch(() => {}) - } - this.invalidateMeta(rel) - emitVaultChange({ kind: 'unlink', path: rel, folder: 'trash', scope: 'content' }) + await this.detachContent(trashDir, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${trashDir}`, false) + for (const key of [...this.metaCache.keys()]) { + if (key.startsWith(`${trashDir}/`)) this.invalidateMeta(key) } + emitVaultChange({ kind: 'unlink', path: trashDir, folder: 'trash', scope: 'folder' }) } async deleteNote(relPath: string): Promise { const rel = resolveSafeRel(relPath) const folder = (await this.folderOf(rel)) ?? 'trash' - await this.fs.deleteFile(rel) - await this.removeNoteComments(rel) + await this.detachContent(rel, this.commentsPathFor(rel)) this.invalidateMeta(rel) emitVaultChange({ kind: 'unlink', path: rel, folder, scope: 'content' }) } @@ -842,9 +862,7 @@ export class MobileVault { `${await this.uniqueTitle(destDir, baseTitle, ext || '.md')}${ext || '.md'}` ) if (destRel === rel) return await this.metaForPath(rel) - await this.fs.rename(rel, destRel) - this.invalidateMeta(rel) - await this.moveNoteComments(rel, destRel) + await this.relocateNote(rel, destRel) emitVaultChange({ kind: 'unlink', path: rel, folder: sourceFolder, scope: 'content' }) emitVaultChange({ kind: 'add', path: destRel, folder: targetFolder, scope: 'content' }) return await this.metaForPath(destRel) @@ -878,7 +896,6 @@ export class MobileVault { } const parent = dirName(newRel) if (parent) await this.fs.mkdir(parent) - await this.fs.rename(oldRel, newRel) // Re-key folder icons/colors + drop stale meta cache entries under the old path. const settings = await this.getVaultSettings() const rekey = (map: Record): Record => { @@ -892,10 +909,30 @@ export class MobileVault { } return out } - await this.setVaultSettings({ + const settingsPath = `${INTERNAL_VAULT_DIR}/vault.json` + const hadSettings = await this.fs.statVerified(settingsPath) !== null + const originalSettings = hadSettings ? await this.fs.readText(settingsPath) : null + await relocateVaultEntries(this.relocationIO(), [ + { from: oldRel, to: newRel, required: true }, + { from: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${oldRel}`, + to: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${newRel}` } + ], async () => { + try { + await this.setVaultSettings({ ...settings, folderIcons: rekey(settings.folderIcons as Record) as VaultSettings['folderIcons'], folderColors: rekey(settings.folderColors as Record) as VaultSettings['folderColors'] + }) + } catch (error) { + try { + if (originalSettings !== null) await this.fs.writeText(settingsPath, originalSettings) + else if (await this.fs.statVerified(settingsPath) !== null) await this.fs.deleteFile(settingsPath) + this.settingsCache = settings + } catch (rollback) { + throw new AggregateError([error, rollback], 'FOLDER_STATE_UNCERTAIN: Could not restore vault settings') + } + throw error + } }) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${oldRel}/`)) this.invalidateMeta(key) @@ -910,7 +947,7 @@ export class MobileVault { const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) return const rel = resolveSafeRel(joinPath(topRel, clean)) - await this.fs.rmdir(rel) + await this.detachContent(rel, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}`) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${rel}/`)) this.invalidateMeta(key) } @@ -981,24 +1018,6 @@ export class MobileVault { return comments } - private async moveNoteComments(oldRel: string, newRel: string): Promise { - const oldPath = this.commentsPathFor(oldRel) - const raw = await this.fs.readTextOrNull(oldPath) - if (raw === null) return - try { - const parsed = JSON.parse(raw) as { comments?: NoteComment[] } - const comments = (parsed.comments ?? []).map((c) => ({ ...c, notePath: newRel })) - await this.writeCommentsFile(newRel, comments) - } catch { - // unreadable sidecar — drop it - } - await this.fs.deleteFile(oldPath).catch(() => {}) - } - - private async removeNoteComments(rel: string): Promise { - await this.fs.deleteFile(this.commentsPathFor(rel)).catch(() => {}) - } - // ------------------------------------------------------------------- // Workspace state // ------------------------------------------------------------------- @@ -1464,7 +1483,7 @@ export class MobileVault { } } -// Pasted image naming lives in @shared/pasted-image (upstream 80303bb), which +// Pasted image naming lives in @zennotes/shared-domain/pasted-image (upstream 80303bb), which // is where the local copy that used to sit here went: the scrub of the // characters that break the `![[...]]` embed a paste writes has to agree // across desktop, web, the server and here, and one module is how it stays diff --git a/src/bridge/vault-lifecycle.test.ts b/src/bridge/vault-lifecycle.test.ts new file mode 100644 index 0000000..6f58675 --- /dev/null +++ b/src/bridge/vault-lifecycle.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault, NativeFs, DEFAULT_VAULT_SETTINGS } = await loadMobileModule([ + './src/bridge/vault-fs', './src/bridge/native-fs', '@zennotes/bridge-contract/ipc' +]) + +class MemoryFs { + files = new Map() + directories = new Set() + failMove: ((from: string, to: string) => boolean) | null = null + failWrite = false + async statVerified(path: string) { + return this.files.has(path) ? 'file' : this.directories.has(path) || [...this.files.keys()].some(key => key.startsWith(`${path}/`)) ? 'directory' : null + } + async exists(path: string) { return await this.statVerified(path) !== null } + async mkdir(path: string) { this.directories.add(path) } + async readText(path: string) { if (!this.files.has(path)) throw new Error('ENOENT'); return this.files.get(path)! } + async readTextOrNull(path: string) { return this.files.get(path) ?? null } + async writeText(path: string, body: string) { + if (this.failWrite) { this.failWrite = false; throw new Error('Settings write refused') } + this.files.set(path, body) + } + async deleteFile(path: string) { this.files.delete(path) } + async rmdir(path: string) { + for (const key of this.files.keys()) if (key === path || key.startsWith(`${path}/`)) this.files.delete(key) + for (const key of this.directories) if (key === path || key.startsWith(`${path}/`)) this.directories.delete(key) + } + async rename(from: string, to: string) { + if (this.failMove?.(from, to)) throw new Error('Provider refused move') + assert.ok(await this.exists(from)); assert.equal(await this.exists(to), false) + for (const [key, body] of [...this.files]) if (key === from || key.startsWith(`${from}/`)) { + this.files.set(to + key.slice(from.length), body); this.files.delete(key) + } + for (const key of [...this.directories]) if (key === from || key.startsWith(`${from}/`)) { + this.directories.add(to + key.slice(from.length)); this.directories.delete(key) + } + } +} +function fixture(rootMode = false) { + const fs = new MemoryFs(), vault = new MobileVault('Fixture') + Object.assign(vault, { + fs, settingsCache: { ...structuredClone(DEFAULT_VAULT_SETTINGS), primaryNotesLocation: rootMode ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'Notes', archive: 'Old', trash: 'Bin', quick: 'Capture' } }, + listNotes: async () => [], invalidateMeta: () => {}, + metaForPath: async (path: string) => ({ path, title: path.split('/').pop() }) + }) + const note = `${rootMode ? '' : 'Notes/'}Work/One.md` + fs.files.set(note, '# One\n\nExact café 日本語. \n') + fs.files.set(`.zennotes/comments/${note}.comments.json`, '{malformed but preserved sidecar}') + return { fs, vault, note, original: new Map(fs.files) } +} +for (const rootMode of [false, true]) { + test(`native move and restore preserve bytes and sidecars with remapping (root=${rootMode})`, async () => { + const s = fixture(rootMode), result = await s.vault.moveToTrash(s.note) + assert.equal(result.path, 'Bin/Work/One.md') + assert.equal(s.fs.files.get(result.path), s.original.get(s.note)) + assert.equal(s.fs.files.get(`.zennotes/comments/${result.path}.comments.json`), '{malformed but preserved sidecar}') + const restored = await s.vault.restoreFromTrash(result.path) + assert.equal(restored.path, s.note) + assert.deepEqual(s.fs.files, s.original) + }) +} +test('native note relocation restores content when the comments provider fails', async () => { + const s = fixture() + s.fs.failMove = from => from.startsWith('.zennotes/comments/') + await assert.rejects(s.vault.moveToTrash(s.note), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) +}) +test('native folder rename moves database files and nested sidecars together', async () => { + const s = fixture() + s.fs.files.set('Notes/Work/Projects.base/data.csv', 'ID,Name\n1,One\n') + s.fs.files.set('Notes/Work/Projects.base/schema.json', '{"pages":{"1":"One.md"}}') + await s.vault.renameFolder('inbox', 'Work', 'Moved') + assert.equal(s.fs.files.get('Notes/Moved/Projects.base/data.csv'), 'ID,Name\n1,One\n') + assert.equal(s.fs.files.get('.zennotes/comments/Notes/Moved/One.md.comments.json'), '{malformed but preserved sidecar}') + assert.equal(s.fs.files.has(s.note), false) +}) +test('native folder rename restores content, comments and settings on metadata failure', async () => { + const s = fixture() + s.fs.files.set('.zennotes/vault.json', '{"keep":"exact settings"}\n') + s.original.set('.zennotes/vault.json', '{"keep":"exact settings"}\n') + s.fs.failWrite = true + await assert.rejects(s.vault.renameFolder('inbox', 'Work', 'Moved'), /Settings write refused/) + assert.deepEqual(s.fs.files, s.original) +}) +test('native Empty Trash rolls back if comments cannot detach, then deletes nested comments on retry', async () => { + const s = fixture() + await s.vault.moveToTrash(s.note) + const before = new Map(s.fs.files) + s.fs.failMove = from => from === '.zennotes/comments/Bin' + await assert.rejects(s.vault.emptyTrash(), /Provider refused move/) + assert.deepEqual(s.fs.files, before) + s.fs.failMove = null + await s.vault.emptyTrash() + assert.equal(s.fs.files.size, 0) + await s.vault.emptyTrash() +}) +test('native deletion does not report success after a failed sidecar move', async () => { + const s = fixture() + s.fs.failMove = from => from.startsWith('.zennotes/comments/') + await assert.rejects(s.vault.deleteNote(s.note), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) +}) +test('native absent-file reads propagate provider failures instead of allowing schema adoption', async () => { + const permission = new Error('Permission denied') + await assert.rejects(NativeFs.prototype.readTextOrNull.call({ readText: async () => { throw permission } }, 'schema.json'), permission) +}) diff --git a/src/bridge/widget-snapshot.ts b/src/bridge/widget-snapshot.ts index bd950ab..e3c720a 100644 --- a/src/bridge/widget-snapshot.ts +++ b/src/bridge/widget-snapshot.ts @@ -13,8 +13,8 @@ * Only pure selectors live here (node --test covers them). The store/pins * wiring and the native call are in widgets.ts. */ -import type { NoteMeta } from '@bridge-contract/ipc' -import type { VaultTask } from '@shared/tasks' +import type { NoteMeta } from '@zennotes/bridge-contract/ipc' +import type { VaultTask } from '@zennotes/shared-domain/tasks' import { parseThemeBackdropColor } from './keyboard-backdrop-color.ts' export const WIDGET_SNAPSHOT_VERSION = 1 diff --git a/src/bridge/widgets.ts b/src/bridge/widgets.ts index 2239d96..3ec8982 100644 --- a/src/bridge/widgets.ts +++ b/src/bridge/widgets.ts @@ -18,14 +18,14 @@ */ import { App as CapApp } from '@capacitor/app' import { Capacitor, registerPlugin } from '@capacitor/core' -import { useStore } from '@zennotes/app-core/store' -import { computeTasksRender } from '@zennotes/app-core/lib/tasks-filter' -import { filterTasksForDisplay, toIsoDateLocal } from '@shared/tasks' +import { getShellSnapshot, subscribeShell, type ShellSnapshot } from '@zennotes/app-core/shell' +import { getTasksSnapshot, subscribeTasks, refreshTasks, getTodayTasks, type TasksSnapshot } from '@zennotes/app-core/tasks' +import { subscribeSettings } from '@zennotes/app-core/settings' +import { toIsoDateLocal } from '@zennotes/shared-domain/tasks' import { activeVaultStateKey, isMobileNoteIndexReady } from './mobile-bridge' import { getPinnedNotes, subscribePins } from '../ui-mobile/pins' import { WIDGET_SNAPSHOT_VERSION, - filterLiveTasks, selectWidgetNotes, selectWidgetTasks, themeFromTokens, @@ -43,21 +43,12 @@ interface ZenWidgetsPlugin { export const ZenWidgets = registerPlugin('ZenWidgets') -const NO_COLLAPSE = { - today: false, - upcoming: false, - waiting: false, - forwarded: false, - done: false, - cancelled: false -} - const PUBLISH_DEBOUNCE_MS = 400 const PUBLISH_MIN_INTERVAL_MS = 8000 /** Past this many changed notes one full scan beats per-note rescans. */ const RESCAN_BATCH_LIMIT = 8 -type StoreState = ReturnType +type StoreState = ShellSnapshot let timer = 0 let lastPublishedAt = 0 @@ -72,16 +63,8 @@ function themeMode(): WidgetTheme['mode'] { function buildSnapshot(state: StoreState, now: Date): Omit { const style = getComputedStyle(document.documentElement) - const live = filterLiveTasks( - filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks), - state.notes - ) - const render = computeTasksRender(live, '', now, NO_COLLAPSE) - const { tasks, counts } = selectWidgetTasks( - render.groups.today, - render.groups.overdueCount ?? 0, - toIsoDateLocal(now) - ) + const today = getTodayTasks(now) + const { tasks, counts } = selectWidgetTasks(today.tasks, today.overdueCount, toIsoDateLocal(now)) return { version: WIDGET_SNAPSHOT_VERSION, vaultName: state.vault?.name ?? null, @@ -94,10 +77,10 @@ function buildSnapshot(state: StoreState, now: Date): Omit { - const state = useStore.getState() + const state = getShellSnapshot() // No vault (onboarding, a switch in flight): keep whatever the widgets // already show rather than blanking them. - if (!state.vault) return + if (!state.vault || !state.workspaceRestored) return let body: Omit try { body = buildSnapshot(state, new Date()) @@ -129,17 +112,17 @@ function flush(): void { void publish() } -function reconcileTasks(state: StoreState, prev: StoreState | null): void { - if (!state.vault || !isMobileNoteIndexReady()) return +function reconcileTasks(state: StoreState, prev: StoreState | null, tasks: TasksSnapshot, previousTasks: TasksSnapshot | null): void { + if (!state.vault || !state.workspaceRestored || !isMobileNoteIndexReady()) return const key = activeVaultStateKey() if (key !== taskVaultKey) { taskVaultKey = key tasksSettled = false knownUpdatedAt = new Map(state.notes.map((n) => [n.path, n.updatedAt])) - if (!state.tasksLoading) void state.refreshTasks() + if (!tasks.loading) void refreshTasks() return } - if (prev && prev.tasksLoading && !state.tasksLoading) tasksSettled = true + if (previousTasks?.loading && !tasks.loading) tasksSettled = true if (!prev || state.notes === prev.notes) return const changed: string[] = [] const next = new Map() @@ -150,38 +133,37 @@ function reconcileTasks(state: StoreState, prev: StoreState | null): void { knownUpdatedAt = next if (changed.length === 0) return if (changed.length > RESCAN_BATCH_LIMIT) { - if (!state.tasksLoading) void state.refreshTasks() + if (!tasks.loading) void refreshTasks() return } - for (const path of changed) void state.rescanTasksForPath(path) + for (const path of changed) void refreshTasks(path) } /** Start publishing; returns the teardown (tests / hot paths — the shell * itself never stops). No-op off the native platform. */ export function installWidgetPublisher(): () => void { if (!Capacitor.isNativePlatform()) return () => {} - const unsubStore = useStore.subscribe((state, prev) => { - reconcileTasks(state, prev) - if ( - state.notes !== prev.notes || - state.vaultTasks !== prev.vaultTasks || - state.showArchivedTasks !== prev.showArchivedTasks || - state.tasksLoading !== prev.tasksLoading || - state.vault !== prev.vault || - state.themeId !== prev.themeId || - state.themeMode !== prev.themeMode - ) { - schedule() - } - }) + let shell = getShellSnapshot() + let tasks = getTasksSnapshot() + const changed = (): void => { + const nextShell = getShellSnapshot(), nextTasks = getTasksSnapshot() + const previousShell = shell, previousTasks = tasks + // Store subscriptions can fire synchronously when refreshTasks starts. + shell = nextShell; tasks = nextTasks + reconcileTasks(nextShell, previousShell, nextTasks, previousTasks) + schedule() + } + const unsubShell = subscribeShell(changed) + const unsubTasks = subscribeTasks(changed) + const unsubSettings = subscribeSettings(schedule) const unsubPins = subscribePins(schedule) const appState = CapApp.addListener('appStateChange', ({ isActive }) => { if (!isActive) flush() }) - reconcileTasks(useStore.getState(), null) + reconcileTasks(getShellSnapshot(), null, getTasksSnapshot(), null) schedule() return () => { - unsubStore() + unsubShell(); unsubTasks(); unsubSettings() unsubPins() void appState.then((handle) => handle.remove()).catch(() => {}) window.clearTimeout(timer) diff --git a/src/main.tsx b/src/main.tsx index d284c27..1cc03e3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,6 +10,7 @@ */ import { App as CapApp } from '@capacitor/app' import { Keyboard, KeyboardResize } from '@capacitor/keyboard' +import { installMobileEditorHost } from './ui-mobile/editor-host' import { renderZenNotesApp } from '@zennotes/app-core/main' import { installMobileBridge, @@ -111,6 +112,7 @@ async function boot(): Promise { const root = document.getElementById('root') if (!root) throw new Error('Renderer root element #root was not found') + installMobileEditorHost() renderZenNotesApp(root) mountMobileShell() // Home Screen / Lock Screen widgets: publish what they show, and run the diff --git a/src/ui-mobile/EditorToolbar.tsx b/src/ui-mobile/EditorToolbar.tsx index c06ede0..5a9af05 100644 --- a/src/ui-mobile/EditorToolbar.tsx +++ b/src/ui-mobile/EditorToolbar.tsx @@ -2,95 +2,17 @@ * The mobile editing toolbar (spec 06's marquee input feature): a horizontally * scrollable formatting row docked above the soft keyboard while the * CodeMirror editor is focused. Actions drive the shared editor through the - * store's `editorViewRef` using app-core's own formatting helpers - * (lib/cm-format.ts) plus stock @codemirror/commands — no editor logic is - * duplicated, and no zennotes code changes. + * public semantic commands, without owning editor state or formatting logic. * * With the Capacitor Keyboard in `resize: native` mode the viewport shrinks * when the keyboard shows, so `bottom: 0` docks exactly on the keyboard's top. */ import React, { useEffect, useState } from 'react' import { Keyboard } from '@capacitor/keyboard' -import type { EditorView } from '@codemirror/view' -import { indentLess, indentMore, redo, undo } from '@codemirror/commands' -import { openSearchPanel } from '@codemirror/search' -import { EditorSelection } from '@codemirror/state' -import { useStore } from '@zennotes/app-core/store' -import { - setBlockType, - toggleWrap, - wrapLink, - type BlockType -} from '@zennotes/app-core/lib/cm-format' +import { runEditorCommand } from '@zennotes/app-core/editor' import { promptAttachFiles } from './attach' import { revealCaretAboveKeyboardSoon } from './editor-keyboard-scroll' -function view(): EditorView | null { - return useStore.getState().editorViewRef -} - -function withView(fn: (v: EditorView) => void): void { - const v = view() - if (!v) return - fn(v) - v.focus() -} - -/** Insert text at the cursor, placing the caret `caretOffset` chars in. */ -function insertSnippet(v: EditorView, text: string, caretOffset: number): void { - const { from, to } = v.state.selection.main - v.dispatch({ - changes: { from, to, insert: text }, - selection: EditorSelection.cursor(from + caretOffset) - }) -} - -/** - * Markers app-core's blockPrefix would put on these block types. app-core's - * setBlockType converts existing lines and deliberately skips blank ones, so - * on a fresh line Bullet / Checkbox / Heading did nothing until something was - * typed (Adib, device testing 2026-09-08). Desktop users just type the - * marker; on the phone the button IS the way to start a list. - */ -const BLANK_LINE_MARKERS: Partial> = { - bullet: '- ', - todo: '- [ ] ', - h1: '# ', - h2: '## ', - h3: '### ' -} - -/** - * setBlockType, plus the blank-line case it skips: a collapsed cursor on an - * empty (or whitespace-only) line gets the marker inserted after the existing - * indentation, caret after the marker. Selections and non-blank lines go - * through setBlockType unchanged. - */ -function applyBlockType(v: EditorView, type: BlockType): void { - const { from, to } = v.state.selection.main - const line = v.state.doc.lineAt(from) - const marker = BLANK_LINE_MARKERS[type] - if (marker !== undefined && from === to && line.text.trim() === '') { - // line.text is whitespace-only here, so it doubles as the indent. - const insert = line.text + marker - v.dispatch({ - changes: { from: line.from, to: line.to, insert }, - selection: EditorSelection.cursor(line.from + insert.length) - }) - return - } - setBlockType(v, type) -} - -/** Cycle the current line's heading level: none → # → ## → ### → none. */ -function cycleHeading(v: EditorView): void { - const line = v.state.doc.lineAt(v.state.selection.main.from) - const m = line.text.match(/^(#{1,6})\s/) - const level = m ? m[1]!.length : 0 - const next = level >= 3 ? 'paragraph' : (['h1', 'h2', 'h3'] as const)[level]! - applyBlockType(v, next) -} - interface ToolButton { key: string label: string @@ -105,13 +27,13 @@ const BUTTONS: ToolButton[] = [ key: 'undo', label: 'Undo', d: 'M9 14L4 9l5-5M4 9h10.5a5.5 5.5 0 015.5 5.5v0a5.5 5.5 0 01-5.5 5.5H11', - run: () => withView((v) => undo(v)) + run: () => runEditorCommand('undo') }, { key: 'redo', label: 'Redo', d: 'M15 14l5-5-5-5M20 9H9.5A5.5 5.5 0 004 14.5v0A5.5 5.5 0 009.5 20H13', - run: () => withView((v) => redo(v)) + run: () => runEditorCommand('redo') }, { key: 'find', @@ -121,10 +43,7 @@ const BUTTONS: ToolButton[] = [ // withView's editor refocus would immediately steal it back. Focus moves // input-to-input, so the keyboard stays up (Discord feedback, 2026-08-20: // "I have to exit the note to search for a word"). - run: () => { - const v = view() - if (v) openSearchPanel(v) - } + run: () => { runEditorCommand('open-search') } }, { key: 'attach', @@ -138,84 +57,84 @@ const BUTTONS: ToolButton[] = [ key: 'todo', label: 'Checkbox', d: 'M9 11l3 3L22 4M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11', - run: () => withView((v) => applyBlockType(v, 'todo')) + run: () => runEditorCommand('set-task-list') }, { key: 'bullet', label: 'Bullet list', d: 'M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01', - run: () => withView((v) => applyBlockType(v, 'bullet')) + run: () => runEditorCommand('set-bullet-list') }, { key: 'heading', label: 'Heading', glyph: 'H', d: '', - run: () => withView((v) => cycleHeading(v)) + run: () => runEditorCommand('cycle-heading') }, { key: 'bold', label: 'Bold', glyph: 'B', d: '', - run: () => withView((v) => toggleWrap(v, '**')) + run: () => runEditorCommand('toggle-bold') }, { key: 'italic', label: 'Italic', glyph: 'I', d: '', - run: () => withView((v) => toggleWrap(v, '*')) + run: () => runEditorCommand('toggle-italic') }, { key: 'strike', label: 'Strikethrough', d: 'M16 4H9a3 3 0 00-2.83 4M14 12a4 4 0 010 8H6M4 12h16', - run: () => withView((v) => toggleWrap(v, '~~')) + run: () => runEditorCommand('toggle-strikethrough') }, { key: 'highlight', label: 'Highlight', d: 'M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z', - run: () => withView((v) => toggleWrap(v, '==')) + run: () => runEditorCommand('toggle-highlight') }, { key: 'code', label: 'Inline code', d: 'M16 18l6-6-6-6M8 6l-6 6 6 6', - run: () => withView((v) => toggleWrap(v, '`')) + run: () => runEditorCommand('toggle-inline-code') }, { key: 'link', label: 'Link', d: 'M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71', - run: () => withView((v) => wrapLink(v)) + run: () => runEditorCommand('insert-link') }, { key: 'wikilink', label: 'Wikilink', glyph: '[[', d: '', - run: () => withView((v) => insertSnippet(v, '[[]]', 2)) + run: () => runEditorCommand('insert-wikilink') }, { key: 'tag', label: 'Tag', glyph: '#', d: '', - run: () => withView((v) => insertSnippet(v, '#', 1)) + run: () => runEditorCommand('insert-tag') }, { key: 'outdent', label: 'Outdent', d: 'M11 8h10M11 12h10M11 16h10M7 8l-4 4 4 4', - run: () => withView((v) => indentLess(v)) + run: () => runEditorCommand('outdent') }, { key: 'indent', label: 'Indent', d: 'M11 8h10M11 12h10M11 16h10M3 8l4 4-4 4', - run: () => withView((v) => indentMore(v)) + run: () => runEditorCommand('indent') } ] diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 09e44d9..e6ec809 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -1,3 +1,4 @@ +import { dirOf } from './note-order' /** * The phone navigation drawer — a purpose-built mobile surface that REPLACES * app-core's desktop sidebar below 768px (which is hidden by CSS). Flat, @@ -7,23 +8,20 @@ */ import React, { useEffect, useMemo, useRef, useState } from 'react' import ReactDOM from 'react-dom/client' -import { useStore } from '@zennotes/app-core/store' -import type { NoteSortOrder } from '@zennotes/app-core/store' -import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' -import { promptApp } from '@zennotes/app-core/lib/prompt-requests' -import { notePathWithinFolder } from '@zennotes/app-core/lib/vault-layout' -import { resolveFolderPath } from '@zennotes/shared-domain/system-folder-paths' -import { - csvPathForFormDir, - databaseTabPath, - FORM_DIR_SUFFIX, - isFormDirName -} from '@zennotes/shared-domain/databases' +import { getShellSnapshot, useShellSnapshot, setNoteSortOrder, type NoteSortOrder } from '@zennotes/app-core/shell' +import { getBrowseSnapshot, useBrowseSnapshot, getBrowseDirectory, requestCreateBrowseFolder, + requestRenameBrowseFolder, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' +import { useWorkspaceSnapshot, openLocalVault, pickLocalVault, refreshRemoteProfiles, connectRemoteWorkspace, + connectRemoteProfile, changeRemoteVaultPath, deleteRemoteProfile } from '@zennotes/app-core/workspace' +import { openNote, openAppPage } from '@zennotes/app-core/navigation' +import { showSearch } from '@zennotes/app-core/commands' +import { setSettingsVisible } from '@zennotes/app-core/settings' +import { confirm as confirmApp, prompt as promptApp } from '@zennotes/app-core/dialogs' +import { captureMobileWorkspace, reportActionError } from './workspace-context' import { Keyboard } from '@capacitor/keyboard' import { setDrawerOpen, takeDrawerPath, useDrawerOpen } from './drawer-state' import { openMobileSheet } from './sheet-state' import { goHome } from './nav' -import { dirOf, noteComparator, pinnedFirst } from './note-order' import { usePins, toggleNotePin, toggleFolderPin } from './pins' import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' @@ -178,9 +176,7 @@ function NewVaultSheet({ tier === 'icloud' ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(clean)}` : `${VAULT_ROOT_PREFIX}${clean}` - useStore - .getState() - .openLocalVault(root) + openLocalVault(root) .then(() => onDone(true)) .catch((err) => { setError(String((err as Error)?.message ?? err)) @@ -197,12 +193,10 @@ function NewVaultSheet({ setBusy('pick') setError('') dismissKeyboard() - const before = useStore.getState().vault?.root ?? null - useStore - .getState() - .openVaultPicker() + const before = getShellSnapshot().vault?.root ?? null + pickLocalVault() .then(() => { - const after = useStore.getState().vault?.root ?? null + const after = getShellSnapshot().vault?.root ?? null if (after !== before) onDone(true) else setBusy(null) }) @@ -335,10 +329,8 @@ const TIER_SECTIONS = [ ] as const export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Element { - const currentName = useStore((s) => s.vault?.name ?? null) - const workspaceMode = useStore((s) => s.workspaceMode) - const remoteProfileId = useStore((s) => s.remoteWorkspaceInfo?.profileId ?? null) - const remoteProfiles = useStore((s) => s.remoteWorkspaceProfiles) + const currentName = useShellSnapshot().vault?.name ?? null + const { mode: workspaceMode, remoteProfileId, remoteProfiles } = useWorkspaceSnapshot() const [entries, setEntries] = useState(null) const [view, setView] = useState({ kind: 'list' }) const [busy, setBusy] = useState(null) @@ -357,7 +349,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele void icloudStatus() .then((s) => setCloudOk(Boolean(s.available && s.rootUrl))) .catch(() => {}) - void useStore.getState().refreshRemoteWorkspaceProfiles() + void refreshRemoteProfiles() }, []) // The storage pref tracks whichever tier is open (every switch path sets @@ -398,35 +390,23 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele }) } - const tokenFor = (tier: 'local' | 'icloud', name: string): string => - tier === 'icloud' - ? `${ICLOUD_VAULT_ROOT_PREFIX}${encodeURIComponent(name)}` - : `${VAULT_ROOT_PREFIX}${name}` - const submitRename = (entry: MobileVaultEntry): void => { if (entry.tier === 'external') return - const tier = entry.tier const clean = sanitizeNoteTitle(renameTo.trim()) dismissKeyboard() if (!clean || clean === entry.name) { setView({ kind: 'vault', entry }) return } - const wasCurrent = isCurrent(entry) manage('rename', async () => { await renameVault(entry, clean) - // Renaming the open vault: route the store through its normal switch so - // the whole workspace picks up the new identity. - if (wasCurrent) await useStore.getState().openLocalVault(tokenFor(tier, clean)) }) } const moveEntry = (entry: MobileVaultEntry, to: 'local' | 'icloud'): void => { if (entry.tier === 'external') return - const wasCurrent = isCurrent(entry) manage('move', async () => { await moveVault(entry, to) - if (wasCurrent) await useStore.getState().openLocalVault(tokenFor(to, entry.name)) }) } @@ -444,7 +424,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele onClose() // Let the sheet unmount so the guided URL/token prompts get focus. window.setTimeout(() => { - void useStore.getState().connectRemoteWorkspace() + void connectRemoteWorkspace() }, 30) } @@ -505,7 +485,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele disabled={current} onClick={() => act(`switch:${entry.root}`, () => - useStore.getState().openLocalVault(entry.root) + openLocalVault(entry.root) ) } > @@ -546,7 +526,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele disabled={current} onClick={() => act(`switch:${profile.id}`, () => - useStore.getState().connectRemoteWorkspaceProfile(profile.id) + connectRemoteProfile(profile.id) ) } > @@ -614,7 +594,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele className="zn-mobile-sheet-row" onClick={() => act(`switch:${view.entry.root}`, () => - useStore.getState().openLocalVault(view.entry.root) + openLocalVault(view.entry.root) ) } > @@ -779,7 +759,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele className="zn-mobile-sheet-row" onClick={() => act(`switch:${view.id}`, () => - useStore.getState().connectRemoteWorkspaceProfile(view.id) + connectRemoteProfile(view.id) ) } > @@ -796,7 +776,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele // Server-side folder browser renders as a modal — leave // the sheet first so it gets focus. window.setTimeout(() => { - void useStore.getState().changeRemoteWorkspaceVaultPath() + void changeRemoteVaultPath() }, 30) }} > @@ -809,7 +789,7 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele className="zn-mobile-sheet-row zn-danger" onClick={() => manage('remove', () => - useStore.getState().deleteRemoteWorkspaceProfile(view.id) + deleteRemoteProfile(view.id) ) } > @@ -831,32 +811,13 @@ export function VaultsSheet({ onClose }: { onClose: () => void }): React.JSX.Ele export function MobileDrawer(): React.JSX.Element | null { const open = useDrawerOpen() - const vaultName = useStore((s) => s.vault?.name ?? 'ZenNotes') - // The store subscription is only the change signal (root flips on every - // vault switch); pins key on the bridge's STABLE identity token — - // `vault.root` itself is friendlyVaultRoot()'s presentation copy, which a - // wording tweak would change, orphaning every pin (activeVaultStateKey). - const vaultRoot = useStore((s) => s.vault?.root ?? null) - const pinKey = useMemo(() => (vaultRoot ? activeVaultStateKey() : null), [vaultRoot]) + const browse = useBrowseSnapshot() + const vaultName = browse.vault?.name ?? 'ZenNotes' + const vaultRoot = browse.vault?.root ?? null + const pinKey = vaultRoot ? activeVaultStateKey() : null const pins = usePins(pinKey) - const notes = useStore((s) => s.notes) - const folders = useStore((s) => s.folders) - const primaryAtRoot = useStore((s) => s.vaultSettings.primaryNotesLocation === 'root') - // Primitive selectors only — returning a fresh object from a selector - // re-renders forever (Object.is on a new Set is never equal). - const dailyDir = useStore((s) => - s.vaultSettings.dailyNotes.enabled ? s.vaultSettings.dailyNotes.directory : null - ) - const weeklyDir = useStore((s) => - s.vaultSettings.weeklyNotes.enabled ? s.vaultSettings.weeklyNotes.directory : null - ) - const monthlyDir = useStore((s) => - s.vaultSettings.monthlyNotes.enabled ? s.vaultSettings.monthlyNotes.directory : null - ) - const noteSortOrder = useStore((s) => s.noteSortOrder) - // Stable reference from the store (replaced wholesale on settings reload), - // so this is selector-safe; needed for remap-aware path composition. - const vaultSettings = useStore((s) => s.vaultSettings) + 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) @@ -873,45 +834,13 @@ export function MobileDrawer(): React.JSX.Element | null { }, [open]) const { childFolders, childDatabases, childNotes } = useMemo(() => { - const inboxDir = resolveFolderPath('inbox', vaultSettings.systemFolderPaths) - const folderSet = new Map() - const databases: Array<[string, string, string]> = [] - for (const f of folders) { - if (f.folder !== 'inbox') continue - if (dirOf(f.subpath) !== path) continue - const name = f.subpath.split('/').pop() ?? f.subpath - if (isFormDirName(name)) { - // Databases are `.base` folders — surface them as openable rows. - const vaultRel = primaryAtRoot ? f.subpath : `${inboxDir}/${f.subpath}` - databases.push([ - databaseTabPath(csvPathForFormDir(vaultRel)), - name.slice(0, -FORM_DIR_SUFFIX.length), - f.subpath - ]) - continue - } - folderSet.set(f.subpath, name) - } - const noteRows = notes - .filter((n) => { - if (n.folder !== 'inbox') return false - const sub = notePathWithinFolder(n.path, 'inbox', vaultSettings) - return dirOf(sub) === path && !isFormDirName(dirOf(sub).split('/').pop() ?? '') - }) - .sort(noteComparator(noteSortOrder)) - // Pinned rows float to the top of their group, keeping the sort order - // within each half (pins.ts). - const pinnedNoteSet = new Set(pins.notes) - const pinnedFolderSet = new Set(pins.folders) + const rows = getBrowseDirectory(browse, path, pins) return { - childFolders: pinnedFirst( - [...folderSet.entries()].sort((a, b) => a[1].localeCompare(b[1])), - ([subpath]) => pinnedFolderSet.has(subpath) - ), - childDatabases: databases.sort((a, b) => a[1].localeCompare(b[1])), - childNotes: pinnedFirst(noteRows, (n) => pinnedNoteSet.has(n.path)) + childFolders: rows.folders.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] } - }, [notes, folders, path, primaryAtRoot, noteSortOrder, vaultSettings, pins]) + }, [browse, path, pins]) if (!open) return null @@ -925,7 +854,6 @@ export function MobileDrawer(): React.JSX.Element | null { window.setTimeout(() => void action(), 30) } - const s = (): ReturnType => useStore.getState() return ( <> @@ -946,7 +874,6 @@ export function MobileDrawer(): React.JSX.Element | null { noteSortOrder={noteSortOrder} close={close} go={go} - s={s} onOpenVaults={() => openMobileSheet('vaults')} /> @@ -1118,7 +1045,6 @@ function MobileDrawerBody(props: { noteSortOrder: NoteSortOrder close: () => void go: (action: () => unknown) => void - s: () => ReturnType }): React.JSX.Element { const { vaultName, @@ -1136,25 +1062,24 @@ function MobileDrawerBody(props: { childNotes, noteSortOrder, close, - go, - s + go } = props const lp = useLongPress() const [sortOpen, setSortOpen] = useState(false) // Long-pressing a row opens its action sheet — the phone's right-click - // (Discord folder feedback, ported from the Android shell). Notes open the - // shell-wide note sheet (note-actions.tsx, shared with app-core's lists); - // folders get Rename/Delete here. Prompts overlay the open drawer (Modal - // layers above it), so the drawer stays put and its list refreshes in - // place via the vault change events. - const [folderMenu, setFolderMenu] = useState<{ subpath: string; name: string } | null>(null) + // (Discord folder feedback). Notes open the shell-wide note sheet + // (note-actions.tsx, shared with app-core's lists); folders get + // Rename/Delete here. Prompts overlay the open drawer (Modal layers above + // z-49), so the drawer stays put and its list refreshes in place via the + // vault change events. + const [folderMenu, setFolderMenu] = useState<{ subpath: string; name: string; host: ReturnType } | null>(null) const pinNote = (notePath: string): void => { if (!pinKey) return toggleNotePin( pinKey, notePath, - s().notes.map((n) => n.path) + getShellSnapshot().notes.map((n) => n.path) ) } @@ -1163,9 +1088,7 @@ function MobileDrawerBody(props: { toggleFolderPin( pinKey, subpath, - s() - .folders.filter((f) => f.folder === 'inbox') - .map((f) => f.subpath) + getBrowseSnapshot().folders.map(row => row.directory) ) } @@ -1176,63 +1099,20 @@ function MobileDrawerBody(props: { const scrollRef = useRef(null) - const renameFolderFromDrawer = (subpath: string, name: string): void => { + const renameFolderFromDrawer = (subpath: string, _name: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() setFolderMenu(null) - void (async () => { - const next = await promptApp({ - title: 'Rename folder', - initialValue: name, - okLabel: 'Rename', - validate: (v: string) => (v.includes('/') ? 'Folder name cannot contain "/"' : null) - }) - const clean = next?.trim() - if (!clean || clean === name) return - const parent = subpath.includes('/') ? subpath.slice(0, subpath.lastIndexOf('/')) : '' - try { - await s().renameFolder('inbox', subpath, parent ? `${parent}/${clean}` : clean) - } catch (err) { - window.alert(err instanceof Error ? err.message : String(err)) - } - })() + void requestRenameBrowseFolder(host, subpath).catch(reportActionError) } - const newFolderHere = (): void => { - void (async () => { - const leaf = path === '' ? '' : (path.split('/').pop() ?? '') - const name = await promptApp({ - title: leaf ? `New folder in ${leaf}` : 'New folder', - placeholder: 'Folder name', - okLabel: 'Create', - validate: (v: string) => (v.includes('/') ? 'Folder name cannot contain "/"' : null) - }) - const clean = name?.trim().replace(/^\/+|\/+$/g, '') - if (!clean) return - await s().createFolder('inbox', path === '' ? clean : `${path}/${clean}`) - })() + void requestCreateBrowseFolder(captureMobileWorkspace(), path).catch(reportActionError) } - - const deleteDatabase = (subpath: string, title: string): void => { - void (async () => { - const ok = await confirmApp({ - title: `Delete "${title}"?`, - description: 'All records will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await s().deleteFolder('inbox', subpath) - })() + const deleteDatabase = (subpath: string, _title: string): void => { + void requestDeleteBrowseDirectory(captureMobileWorkspace(), subpath).catch(reportActionError) } - - const deleteFolder = (subpath: string, name: string): void => { - void (async () => { - const ok = await confirmApp({ - title: `Delete "${name}"?`, - description: 'Everything inside will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await s().deleteFolder('inbox', subpath) - })() + const deleteFolder = (subpath: string, _name: string): void => { + const host = folderMenu?.host ?? captureMobileWorkspace() + void requestDeleteBrowseDirectory(host, subpath).catch(reportActionError) } return ( @@ -1251,7 +1131,7 @@ function MobileDrawerBody(props: { - @@ -1266,11 +1146,11 @@ function MobileDrawerBody(props: { Home - - @@ -1295,22 +1175,22 @@ function MobileDrawerBody(props: { )} - {/* The assets table only exists as a pane tab (zen://assets) — the palette's "Go to Files" drives the desktop sidebar list, which phones don't render, so this row is the phone's way in. */} - - - @@ -1350,7 +1230,7 @@ function MobileDrawerBody(props: { aria-checked={noteSortOrder === order} className={noteSortOrder === order ? 'is-active' : ''} onClick={() => { - s().setNoteSortOrder(order) + setNoteSortOrder(order) setSortOpen(false) }} > @@ -1373,7 +1253,7 @@ function MobileDrawerBody(props: { diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 30d9f04..351f247 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -6,44 +6,24 @@ * full-screen flow. Rendered into its own React root so app-core stays * untouched; state is driven through the shared Zustand store. */ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import ReactDOM from 'react-dom/client' import { Haptics, ImpactStyle } from '@capacitor/haptics' import { Keyboard } from '@capacitor/keyboard' -import { useStore } from '@zennotes/app-core/store' -import { - getGesturePrefs, - setGesturePrefs, - type GesturePrefs, - type PullAction, - type SwipeAction -} from './gestures' -import type { TaskMutation } from '@zennotes/app-core/store' -import type { VaultTask } from '@shared/tasks' -import { toIsoDateLocal } from '@shared/tasks' -import { buildCommands } from '@zennotes/app-core/lib/commands' -import { findLeaf, updateLeaf } from '@zennotes/app-core/lib/pane-layout' -import { - paneModeForPath, - paneModesWithPathMode, - requestPaneMode -} from '@zennotes/app-core/lib/pane-mode' -import { - isSameFileHeadingLink, - resolveWikilinkTarget, - wikilinkHeadingAnchor -} from '@zennotes/app-core/lib/wikilinks' -import { - openDatabaseFromWikilink, - openWikilinkHeading -} from '@zennotes/app-core/lib/wikilink-navigation' +import { getShellSnapshot, useShellSnapshot, subscribeShell, getAdjacentNotePath, + getTagPresenceSnapshot, subscribeTagPresence } from '@zennotes/app-core/shell' +import { getBrowseSnapshot, requestCreateBrowseFolder, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' +import { getWorkspaceSnapshot, useWorkspaceSnapshot, subscribeWorkspace, readPersistedHomeState, + configureWorkspacePresentation, pickLocalVault, openLocalVault, connectRemoteProfile, refreshRemoteProfiles } from '@zennotes/app-core/workspace' +import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize } from '@zennotes/app-core/settings' +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' +import { openNote, openWikilink, openTodayDailyNote } from '@zennotes/app-core/navigation' +import { requestTrashNote } from '@zennotes/app-core/notes' +import { getGesturePrefs, setGesturePrefs, type GesturePrefs, type PullAction, type SwipeAction } from './gestures' import { MobileEditorToolbar } from './EditorToolbar' -import { promptApp } from '@zennotes/app-core/lib/prompt-requests' -import { confirmApp } from '@zennotes/app-core/lib/confirm-requests' -import { notePathWithinFolder } from '@zennotes/app-core/lib/vault-layout' -import { noteTagsForCount } from '@zennotes/app-core/lib/tags' -import { resolveTypstPreambleFolder } from '@zennotes/app-core/lib/typst-preamble' -import { csvPathFromDatabaseTab, formDirFromCsvPath } from '@zennotes/shared-domain/databases' +import { captureMobileWorkspace, reportActionError } from './workspace-context' import { MobileDrawer } from './MobileDrawer' import { isDrawerOpen, setDrawerOpen, useDrawerOpen } from './drawer-state' import { goHome } from './nav' @@ -67,11 +47,6 @@ import { WELCOME_PENDING_KEY, FAB_HINT_KEY } from './Onboarding' import { WELCOME_NOTE_PATH } from '../bridge/welcome-note' import ensoUrl from '../assets/enso.png' import { getStoragePref } from '../bridge/icloud' -import { - createTagsEmptyStateTracker, - type TagsEmptyStateSnapshot -} from './tags-empty-state' -import { siblingNotesInDrawerOrder } from './note-order' import { getPinnedNotes, loadPins } from './pins' import { isSwipeRowGestureActive } from './SwipeRow' import { installNoteRowGestures, NOTE_ROW_SELECTOR } from './note-row-gestures' @@ -94,10 +69,8 @@ import { /** Run a command from the shared registry by id (same path the palette uses). */ function runCommand(id: string): void { - const cmd = buildCommands({ includeUnavailable: true }).find((c) => c.id === id) - if (!cmd) return - if (cmd.when && !cmd.when()) return - void cmd.run() + void runAppCommand(id).catch(reportActionError) + } function Icon({ d, filled }: { d: string; filled?: boolean }): React.JSX.Element { @@ -194,56 +167,22 @@ const APP_ROWS: SheetRow[] = [ ] function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { - const selectedPath = useStore((s) => s.selectedPath) - const workspaceMode = useStore((s) => s.workspaceMode) - // Virtual tabs (zen://help, zen://tasks, ...) aren't notes — their rows - // (rename/trash/...) would silently no-op. - const hasNote = Boolean(selectedPath) && !selectedPath?.startsWith('zen://') - const noteFolder = useStore((s) => { - if (!s.selectedPath) return null - return s.notes.find((n) => n.path === s.selectedPath)?.folder ?? null - }) - const calendarAvailable = useStore( - (s) => s.vaultSettings.dailyNotes.enabled || s.vaultSettings.weeklyNotes.enabled - ) - // With a database tab open, offer its removal — every open thing should be - // deletable from •••. - const dbFormDir = useStore((s) => { - const csv = csvPathFromDatabaseTab(s.selectedPath) - return csv ? formDirFromCsvPath(csv) : null - }) - const dbTitle = dbFormDir - ? (dbFormDir.split('/').pop() ?? '').replace(/\.base$/i, '') - : null - const title = useStore((s) => { - if (!s.selectedPath) return 'ZenNotes' - const note = s.notes.find((n) => n.path === s.selectedPath) - return note?.title ?? 'ZenNotes' - }) - + const shell = useShellSnapshot() + const { selectedPath, workspaceMode } = shell + const hasNote = !!shell.selectedNote + const noteFolder = shell.selectedNote?.folder ?? null + const { calendarAvailable } = useSettingsSnapshot() + const database = getBrowseSnapshot().databases.find(row => row.path === selectedPath) + const dbFormDir = database?.directory ?? null + const dbTitle = database?.title ?? null + const title = shell.selectedNote?.title ?? 'ZenNotes' + const workspace = useWorkspaceSnapshot() + const host = useMemo(() => captureMobileWorkspace(), [shell.vault, workspaceMode, workspace.generation, workspace.transitioning]) const deleteOpenDatabase = (): void => { - const formDir = dbFormDir - const label = dbTitle - if (!formDir || label === null) return + if (!dbFormDir) return onClose() window.setTimeout(() => { - void (async () => { - const ok = await confirmApp({ - title: `Delete "${label}"?`, - description: 'All records will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete', - danger: true - }) - if (!ok) return - // Remap-aware: the inbox may live in a renamed directory - // (vault.json systemFolderPaths), so strip the RESOLVED prefix. - const subpath = notePathWithinFolder( - formDir, - 'inbox', - useStore.getState().vaultSettings - ) - await useStore.getState().deleteFolder('inbox', subpath) - })() + void requestDeleteBrowseDirectory(host, dbFormDir).catch(reportActionError) }, 30) } @@ -253,7 +192,7 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { // focus lands in the right place. window.setTimeout(() => { if (id === 'zn.palette') { - useStore.getState().setCommandPaletteOpen(true) + showCommandPalette() return } if (id === 'zn.vaults') { @@ -261,26 +200,12 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { return } if (id === 'zn.pickfolder') { - void useStore.getState().openVaultPicker() + void pickLocalVault() return } + if (!host.isCurrent()) return if (id === 'note.trash') { - // Own the confirm copy ("Delete") — app-core's command would show its - // desktop "Move to Trash?" dialog. The bridge's unlink event closes - // the tab via applyChange. - void (async () => { - const st = useStore.getState() - const path = st.selectedPath - if (!path) return - const noteTitle = st.notes.find((n) => n.path === path)?.title - const ok = await confirmApp({ - title: `Delete "${noteTitle ?? 'this note'}"?`, - description: 'It will move to the trash.', - confirmLabel: 'Delete', - danger: true - }) - if (ok) await window.zen.moveToTrash(path) - })() + if (selectedPath) void requestTrashNote(host, selectedPath).catch(reportActionError) return } runCommand(id) @@ -377,22 +302,14 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { * quick-action chips are hidden on phones, so this is its one-tap home. */ function CreateSheet({ onClose }: { onClose: () => void }): React.JSX.Element { - const dailyEnabled = useStore((s) => s.vaultSettings.dailyNotes.enabled) + const dailyEnabled = useSettingsSnapshot().dailyNotesEnabled const run = (fn: () => unknown): void => { onClose() window.setTimeout(() => void fn(), 30) } const newFolder = async (): Promise => { - const name = await promptApp({ - title: 'New folder', - placeholder: 'Folder name', - okLabel: 'Create', - validate: (v: string) => (v.includes('/') ? 'Folder name cannot contain "/"' : null) - }) - const clean = name?.trim().replace(/^\/+|\/+$/g, '') - if (!clean) return - await useStore.getState().createFolder('inbox', clean) + await requestCreateBrowseFolder(captureMobileWorkspace(), '').catch(reportActionError) } return ( @@ -422,7 +339,7 @@ function CreateSheet({ onClose }: { onClose: () => void }): React.JSX.Element { + {folderMenu.kind === 'folder' && ( + + )}