diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 00000000..3dfb1a09 --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -0,0 +1,82 @@ +name: Build EdgeEver iOS + +on: + push: + branches: + - main + paths: + - "apps/ios/**" + - ".github/workflows/ios-build.yml" + pull_request: + paths: + - "apps/ios/**" + - ".github/workflows/ios-build.yml" + workflow_dispatch: + +concurrency: + group: edgeever-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Xcode build + unit tests + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer || \ + sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + xcodebuild -version + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate project + working-directory: apps/ios + run: xcodegen generate + + - name: Resolve packages + working-directory: apps/ios + run: | + xcodebuild -project EdgeEver.xcodeproj -scheme EdgeEver \ + -resolvePackageDependencies \ + -destination 'generic/platform=iOS Simulator' + + - name: Build + working-directory: apps/ios + run: | + set -o pipefail + xcodebuild build \ + -project EdgeEver.xcodeproj \ + -scheme EdgeEver \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty || true + xcodebuild build \ + -project EdgeEver.xcodeproj \ + -scheme EdgeEver \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO + + - name: Test + working-directory: apps/ios + run: | + DEST="$( + xcodebuild -project EdgeEver.xcodeproj -scheme EdgeEver -showdestinations 2>/dev/null \ + | sed -n 's/.*name:\(iPhone[^,}]*\).*/\1/p' \ + | head -1 + )" + if [[ -z "$DEST" ]]; then + DEST="iPhone 16" + fi + echo "Using simulator: $DEST" + xcodebuild test \ + -project EdgeEver.xcodeproj \ + -scheme EdgeEver \ + -destination "platform=iOS Simulator,name=$DEST" \ + CODE_SIGNING_ALLOWED=NO \ + -only-testing:EdgeEverTests diff --git a/apps/ios/Config/Debug.xcconfig b/apps/ios/Config/Debug.xcconfig new file mode 100644 index 00000000..73401547 --- /dev/null +++ b/apps/ios/Config/Debug.xcconfig @@ -0,0 +1,5 @@ +#include "Version.xcconfig" + +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG +DEBUG_INFORMATION_FORMAT = dwarf +SWIFT_OPTIMIZATION_LEVEL = -Onone diff --git a/apps/ios/Config/Release.xcconfig b/apps/ios/Config/Release.xcconfig new file mode 100644 index 00000000..608c3b70 --- /dev/null +++ b/apps/ios/Config/Release.xcconfig @@ -0,0 +1,5 @@ +#include "Version.xcconfig" + +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +SWIFT_OPTIMIZATION_LEVEL = -O +SWIFT_COMPILATION_MODE = wholemodule diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig new file mode 100644 index 00000000..3d76c33c --- /dev/null +++ b/apps/ios/Config/Version.xcconfig @@ -0,0 +1,4 @@ +// Bump MARKETING_VERSION with monorepo release X.Y.Z when shipping App Store builds. +// CURRENT_PROJECT_VERSION must increase for every store upload (TestFlight / App Store). +MARKETING_VERSION = 1.0.0 +CURRENT_PROJECT_VERSION = 1 diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj new file mode 100644 index 00000000..a4c2083f --- /dev/null +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -0,0 +1,957 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 039594C5BEE4844FBB131C7F /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F510BF90B418266F363EBD /* APIClient.swift */; }; + 0614453E8E9EC13B212B5A2F /* NotebookPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3328D871910187ED827B6CE /* NotebookPickerSheet.swift */; }; + 06D2661CAB0ADED27F322649 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D87A8CED8498AE654EF8CD1F /* Localizable.xcstrings */; }; + 08A3D4F0E80ACE44E36AD87D /* MemoCreateCommit.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC0AD00435CCEFD7934201C /* MemoCreateCommit.swift */; }; + 08D0159B8F194E04C02B69BF /* index.html in Resources */ = {isa = PBXBuildFile; fileRef = AB02424D0412FE36C4BCD116 /* index.html */; }; + 0D67F968CAFCC17F28A5555C /* URLNormalizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B15501968A29C372E7B3BB /* URLNormalizeTests.swift */; }; + 100A3242D8AA714261236773 /* ChromeParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 213837D2B2F025307A5EBB69 /* ChromeParityTests.swift */; }; + 132BA05FF9673B95D3E3959A /* JSONCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD904708FC1AD8971439BD90 /* JSONCoding.swift */; }; + 1510CEE8BDDBD1710C0CE88C /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 574912976CB43DD9821678EE /* SessionStore.swift */; }; + 1F8E6268C9DA330CA236B36F /* SyncProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */; }; + 2581F6050C02A4A36340CC0D /* ShareHandoffStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */; }; + 2A07555B54946A177E1ECFF2 /* Typography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB54673617E14F28B7608BD /* Typography.swift */; }; + 2E185C3C27DA3A729F2734B4 /* SyncOutboxRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */; }; + 33330C89D977D3903F091165 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B812AD3502D649CE7B84DEBE /* LoginView.swift */; }; + 35AAF7A2B1B4D5EE38F8518C /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C1F4BC7DB150AE8F85AA13 /* SyncEngine.swift */; }; + 35DF4BD1617191AB0B95CC4A /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */; }; + 38640D9CE35D118F325AABC5 /* WorkspaceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9799115E572F9AF0B59A5AD4 /* WorkspaceStore.swift */; }; + 3993FE79886C4DFCE7838B65 /* ListActionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033565CE2E96EC2D23105188 /* ListActionsSheet.swift */; }; + 3D467E1BD92078C96C97E237 /* DeviceId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D65FED94BCD836BDD5CB752 /* DeviceId.swift */; }; + 487717BFBCCF86B783B33B44 /* WorkspaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50D91D8505FE6446ABD647C /* WorkspaceView.swift */; }; + 4A2DD0673B81D32FA9E5F9DB /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F590FE9371571DC7456154 /* Theme.swift */; }; + 4A771542C5AD2E6A907187AD /* OutboxFlusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */; }; + 4BA97CD264D0929B20ED19DB /* TipTapWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */; }; + 506702F1751945424AC4CB98 /* MobileUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A375D81573943A98C5732F81 /* MobileUI.swift */; }; + 50800DF91A760DE7FB68DFB4 /* Motion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C065FED3C78404D4D8DD19 /* Motion.swift */; }; + 511A8DCB204AFCB2880AE51E /* MemoCreateCommitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */; }; + 5EEFDEEDC372725C7C7F22ED /* EdgeEverApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CEA2902CDE0D7C4137D173 /* EdgeEverApp.swift */; }; + 5F5EC9565A02FAFDD5D7B54C /* SyncProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */; }; + 6A671BB702DA77A0548E6E29 /* URL+Normalize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8662720DE42695E29F5741 /* URL+Normalize.swift */; }; + 7BDAD91E69B7B62D7D390CB5 /* MemoEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3674473015F8EB6863B69B0F /* MemoEditView.swift */; }; + 8430B1EDE6CA87A0C922E484 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D94BB61519CA2A345D99C1 /* SettingsView.swift */; }; + 8785D0133655387F399C3C58 /* LocalMirrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2035E878FD92B74D889D6047 /* LocalMirrorTests.swift */; }; + 88C20914237162E295798B54 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 1B167394120674FECA86D42F /* GRDB */; }; + 8C761DABADB93F2F15E21E80 /* EditFabButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2829CE5DF5C15ACF41CC17DA /* EditFabButton.swift */; }; + 90FFFFD00B2D19F96EA299C8 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5126510AFB9AFBF7ABADB45 /* Models.swift */; }; + 93556FDCB87BB93990221E01 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8691859094F8C08AE49FE27D /* Assets.xcassets */; }; + 94456E5BCAB7496FAD4685A6 /* Pow in Frameworks */ = {isa = PBXBuildFile; productRef = E37BD605D826474D3E842F31 /* Pow */; }; + 98010B5978B98C1F8D073C02 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = BF956A8F1A951AA5547675AD /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9CBE3E1196B24C4DDFC7CD0A /* DraftRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3D6EDCC2D76EEC0E51DD26 /* DraftRepository.swift */; }; + 9E5B44C7F70B5896D27A00E3 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4341E86DEF60272AE37A8704 /* Colors.swift */; }; + A63E64622A06DF3842908A66 /* MobileUIParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */; }; + AA7C45A51254081D6035E20F /* ChromeParity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 410470CB94CB0505A3EF5801 /* ChromeParity.swift */; }; + B08E58823845817F84C52A52 /* NotesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30C95F4EABCD08555D97C6B /* NotesListView.swift */; }; + B13CF9D8320DF366EA58601F /* ImageCompressor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B15550738F6080C23DE15F /* ImageCompressor.swift */; }; + BBFC2539859878814E2F7197 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C2945A52C2957CCFC09FCF /* KeychainStore.swift */; }; + C37DA99E090A783562506CDA /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = EFF10CDC8FB8C97DA0103AA0 /* PrivacyInfo.xcprivacy */; }; + C8A4FA6119134F68F5DC7415 /* LocalMirrorRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEF3A661FA08F338EC96C74 /* LocalMirrorRepository.swift */; }; + C8ED8D6620BC657699331527 /* ResourceActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9182F742B06FB1713E27C2A /* ResourceActions.swift */; }; + CA033464E43E9CF7C86585E6 /* MemoDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387980E9CCA7C1C52EAAB6A0 /* MemoDetailView.swift */; }; + D57261115E6F7ABB665DDBCC /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F080AD98FDF3AADDD90B9931 /* ShareViewController.swift */; }; + D6FD6B00023FC1A830684385 /* PreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB16CA1499554463E1927E7 /* PreferencesStore.swift */; }; + DE9649944BC9CD8775003203 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B2C459C9903FC6EBA4E478 /* RootView.swift */; }; + E51CD822863D3494E35ADB27 /* EditorBundleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BEF7AFA15A0972ED3475F23 /* EditorBundleTests.swift */; }; + EAD067F29A94BB2BA633EC90 /* ConflictResolutionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 776B0012B526A65B99921B4B /* ConflictResolutionView.swift */; }; + F51F6C5ED7C2B0E68DF5A55D /* LiveLocalAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57312B99FFBD9A2186A8C0D0 /* LiveLocalAPITests.swift */; }; + FC6501567FDC2F9900E973A4 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06F83AE844C342D3F6EAD7F /* AppEnvironment.swift */; }; + FDF0A8FA27796FB9500F97D4 /* ResourceCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A3C0399C910B150A02D06E /* ResourceCache.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 53E8C491B050CDC322721D62 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B8C95B723D4044F58C620BBD /* Project object */; + proxyType = 1; + remoteGlobalIDString = E55B18ED6114D23320B85FA0; + remoteInfo = EdgeEver; + }; + C1A8F66075747C3C6E134FFB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B8C95B723D4044F58C620BBD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7B4AF236874DD8459683F02B; + remoteInfo = ShareExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 963B22B1C6B716729C2FC851 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 98010B5978B98C1F8D073C02 /* ShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 033565CE2E96EC2D23105188 /* ListActionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListActionsSheet.swift; sourceTree = ""; }; + 0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = ""; }; + 12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocol.swift; sourceTree = ""; }; + 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapWebView.swift; sourceTree = ""; }; + 1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUIParityTests.swift; sourceTree = ""; }; + 1CD75D20D94A27E982103AB3 /* EdgeEver.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = EdgeEver.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2035E878FD92B74D889D6047 /* LocalMirrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMirrorTests.swift; sourceTree = ""; }; + 213837D2B2F025307A5EBB69 /* ChromeParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeParityTests.swift; sourceTree = ""; }; + 22941D02A07C4B75AE89B053 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + 243811C4B4CBF13982976A56 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 2829CE5DF5C15ACF41CC17DA /* EditFabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFabButton.swift; sourceTree = ""; }; + 2A8662720DE42695E29F5741 /* URL+Normalize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Normalize.swift"; sourceTree = ""; }; + 34A3C0399C910B150A02D06E /* ResourceCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCache.swift; sourceTree = ""; }; + 3674473015F8EB6863B69B0F /* MemoEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoEditView.swift; sourceTree = ""; }; + 387980E9CCA7C1C52EAAB6A0 /* MemoDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoDetailView.swift; sourceTree = ""; }; + 38B2C459C9903FC6EBA4E478 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + 40C1F4BC7DB150AE8F85AA13 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = ""; }; + 410470CB94CB0505A3EF5801 /* ChromeParity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeParity.swift; sourceTree = ""; }; + 41B15501968A29C372E7B3BB /* URLNormalizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLNormalizeTests.swift; sourceTree = ""; }; + 4341E86DEF60272AE37A8704 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; + 4BEF7AFA15A0972ED3475F23 /* EditorBundleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorBundleTests.swift; sourceTree = ""; }; + 57312B99FFBD9A2186A8C0D0 /* LiveLocalAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocalAPITests.swift; sourceTree = ""; }; + 574912976CB43DD9821678EE /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = ""; }; + 58C065FED3C78404D4D8DD19 /* Motion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Motion.swift; sourceTree = ""; }; + 5CB54673617E14F28B7608BD /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = ""; }; + 62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncOutboxRepository.swift; sourceTree = ""; }; + 6CB16CA1499554463E1927E7 /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = ""; }; + 776B0012B526A65B99921B4B /* ConflictResolutionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictResolutionView.swift; sourceTree = ""; }; + 82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoCreateCommitTests.swift; sourceTree = ""; }; + 8691859094F8C08AE49FE27D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 86F590FE9371571DC7456154 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxFlusher.swift; sourceTree = ""; }; + 94DF0091B1FC2783AEC2D9F6 /* EdgeEverTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = EdgeEverTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 9799115E572F9AF0B59A5AD4 /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = ""; }; + 9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9D65FED94BCD836BDD5CB752 /* DeviceId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceId.swift; sourceTree = ""; }; + A375D81573943A98C5732F81 /* MobileUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUI.swift; sourceTree = ""; }; + AB02424D0412FE36C4BCD116 /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = index.html; sourceTree = ""; }; + AD904708FC1AD8971439BD90 /* JSONCoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoding.swift; sourceTree = ""; }; + B1D94BB61519CA2A345D99C1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B30C95F4EABCD08555D97C6B /* NotesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesListView.swift; sourceTree = ""; }; + B812AD3502D649CE7B84DEBE /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + B9CEA2902CDE0D7C4137D173 /* EdgeEverApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeEverApp.swift; sourceTree = ""; }; + BF956A8F1A951AA5547675AD /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C5126510AFB9AFBF7ABADB45 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + C693439A39274372B39F10DE /* EdgeEver.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EdgeEver.entitlements; sourceTree = ""; }; + C6B15550738F6080C23DE15F /* ImageCompressor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCompressor.swift; sourceTree = ""; }; + D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareHandoffStore.swift; sourceTree = ""; }; + D06F83AE844C342D3F6EAD7F /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; + D2C2945A52C2957CCFC09FCF /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; + D6F510BF90B418266F363EBD /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + D87A8CED8498AE654EF8CD1F /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + DA3D6EDCC2D76EEC0E51DD26 /* DraftRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftRepository.swift; sourceTree = ""; }; + DBC0AD00435CCEFD7934201C /* MemoCreateCommit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoCreateCommit.swift; sourceTree = ""; }; + E3328D871910187ED827B6CE /* NotebookPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotebookPickerSheet.swift; sourceTree = ""; }; + E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocolTests.swift; sourceTree = ""; }; + EFF10CDC8FB8C97DA0103AA0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + F080AD98FDF3AADDD90B9931 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + F50D91D8505FE6446ABD647C /* WorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceView.swift; sourceTree = ""; }; + F9182F742B06FB1713E27C2A /* ResourceActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceActions.swift; sourceTree = ""; }; + FDEF3A661FA08F338EC96C74 /* LocalMirrorRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMirrorRepository.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 736F644C57A8805092F82568 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 88C20914237162E295798B54 /* GRDB in Frameworks */, + 94456E5BCAB7496FAD4685A6 /* Pow in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0C073569B955CD568840A35D /* Config */ = { + isa = PBXGroup; + children = ( + 9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */, + 243811C4B4CBF13982976A56 /* Release.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; + 1161CF1FFAC898B00498D97D /* Database */ = { + isa = PBXGroup; + children = ( + 0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */, + DA3D6EDCC2D76EEC0E51DD26 /* DraftRepository.swift */, + FDEF3A661FA08F338EC96C74 /* LocalMirrorRepository.swift */, + 62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */, + ); + path = Database; + sourceTree = ""; + }; + 15C520F3EAEBE355CFFF9C41 /* ShareExtension */ = { + isa = PBXGroup; + children = ( + 22941D02A07C4B75AE89B053 /* ShareExtension.entitlements */, + F080AD98FDF3AADDD90B9931 /* ShareViewController.swift */, + ); + path = ShareExtension; + sourceTree = ""; + }; + 1C9B7294934FF01F82DDBEB1 /* Share */ = { + isa = PBXGroup; + children = ( + D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */, + ); + path = Share; + sourceTree = ""; + }; + 2377E3F57CD40951D1F3AA40 /* EdgeEver */ = { + isa = PBXGroup; + children = ( + B0AF73B8D61DFCCD37B124B1 /* App */, + 8BCFBC0CDC794B02EAD907EC /* Data */, + 8A404C1D0F3056A4C32DDE63 /* DesignSystem */, + BEFB911E3106AB68356141B7 /* Editor */, + 49ADF38F8269841510F1C638 /* Features */, + 7404AFC10807CAA326C34580 /* Resources */, + A6CFB6D6E7BFE63D698DFE51 /* Supporting */, + ); + path = EdgeEver; + sourceTree = ""; + }; + 297E35F57A6FB4C490FEE440 /* Session */ = { + isa = PBXGroup; + children = ( + 9D65FED94BCD836BDD5CB752 /* DeviceId.swift */, + D2C2945A52C2957CCFC09FCF /* KeychainStore.swift */, + 574912976CB43DD9821678EE /* SessionStore.swift */, + ); + path = Session; + sourceTree = ""; + }; + 2E86C2538BFD6FD31BF84916 /* Cache */ = { + isa = PBXGroup; + children = ( + C6B15550738F6080C23DE15F /* ImageCompressor.swift */, + 34A3C0399C910B150A02D06E /* ResourceCache.swift */, + ); + path = Cache; + sourceTree = ""; + }; + 2EE88A37487842A849874933 /* Products */ = { + isa = PBXGroup; + children = ( + 1CD75D20D94A27E982103AB3 /* EdgeEver.app */, + 94DF0091B1FC2783AEC2D9F6 /* EdgeEverTests.xctest */, + BF956A8F1A951AA5547675AD /* ShareExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + 47F79BD2D97384A3B7EB9A7D /* Tests */ = { + isa = PBXGroup; + children = ( + F82E0DDA9B1E6FCB6CB7E083 /* EdgeEverTests */, + ); + path = Tests; + sourceTree = ""; + }; + 49ADF38F8269841510F1C638 /* Features */ = { + isa = PBXGroup; + children = ( + D1C1EF697B501DEE7E568338 /* Auth */, + 1C9B7294934FF01F82DDBEB1 /* Share */, + AE31B21B9B3513CC73436E46 /* Workspace */, + ); + path = Features; + sourceTree = ""; + }; + 7404AFC10807CAA326C34580 /* Resources */ = { + isa = PBXGroup; + children = ( + 821234A55C8B7CF544B28EE6 /* EditorBundle */, + 8691859094F8C08AE49FE27D /* Assets.xcassets */, + C693439A39274372B39F10DE /* EdgeEver.entitlements */, + D87A8CED8498AE654EF8CD1F /* Localizable.xcstrings */, + EFF10CDC8FB8C97DA0103AA0 /* PrivacyInfo.xcprivacy */, + ); + path = Resources; + sourceTree = ""; + }; + 802936642DF4142908AF3184 /* Sync */ = { + isa = PBXGroup; + children = ( + 8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */, + 40C1F4BC7DB150AE8F85AA13 /* SyncEngine.swift */, + 12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */, + ); + path = Sync; + sourceTree = ""; + }; + 821234A55C8B7CF544B28EE6 /* EditorBundle */ = { + isa = PBXGroup; + children = ( + AB02424D0412FE36C4BCD116 /* index.html */, + ); + path = EditorBundle; + sourceTree = ""; + }; + 8A404C1D0F3056A4C32DDE63 /* DesignSystem */ = { + isa = PBXGroup; + children = ( + 410470CB94CB0505A3EF5801 /* ChromeParity.swift */, + 4341E86DEF60272AE37A8704 /* Colors.swift */, + 2829CE5DF5C15ACF41CC17DA /* EditFabButton.swift */, + A375D81573943A98C5732F81 /* MobileUI.swift */, + 58C065FED3C78404D4D8DD19 /* Motion.swift */, + 86F590FE9371571DC7456154 /* Theme.swift */, + 5CB54673617E14F28B7608BD /* Typography.swift */, + ); + path = DesignSystem; + sourceTree = ""; + }; + 8BCFBC0CDC794B02EAD907EC /* Data */ = { + isa = PBXGroup; + children = ( + 2E86C2538BFD6FD31BF84916 /* Cache */, + 1161CF1FFAC898B00498D97D /* Database */, + B905FCD37DFCE6B112511261 /* Models */, + BB746E5BDC1029DD2ECF3850 /* Network */, + 9F7C048A5E535F8CAB41F56E /* Preferences */, + 297E35F57A6FB4C490FEE440 /* Session */, + 802936642DF4142908AF3184 /* Sync */, + ); + path = Data; + sourceTree = ""; + }; + 9F7C048A5E535F8CAB41F56E /* Preferences */ = { + isa = PBXGroup; + children = ( + 6CB16CA1499554463E1927E7 /* PreferencesStore.swift */, + ); + path = Preferences; + sourceTree = ""; + }; + A22DC90C50256855231B9F51 = { + isa = PBXGroup; + children = ( + 0C073569B955CD568840A35D /* Config */, + 2377E3F57CD40951D1F3AA40 /* EdgeEver */, + 15C520F3EAEBE355CFFF9C41 /* ShareExtension */, + 47F79BD2D97384A3B7EB9A7D /* Tests */, + 2EE88A37487842A849874933 /* Products */, + ); + sourceTree = ""; + }; + A6CFB6D6E7BFE63D698DFE51 /* Supporting */ = { + isa = PBXGroup; + children = ( + AD904708FC1AD8971439BD90 /* JSONCoding.swift */, + 2A8662720DE42695E29F5741 /* URL+Normalize.swift */, + ); + path = Supporting; + sourceTree = ""; + }; + AE31B21B9B3513CC73436E46 /* Workspace */ = { + isa = PBXGroup; + children = ( + 776B0012B526A65B99921B4B /* ConflictResolutionView.swift */, + 033565CE2E96EC2D23105188 /* ListActionsSheet.swift */, + DBC0AD00435CCEFD7934201C /* MemoCreateCommit.swift */, + 387980E9CCA7C1C52EAAB6A0 /* MemoDetailView.swift */, + 3674473015F8EB6863B69B0F /* MemoEditView.swift */, + E3328D871910187ED827B6CE /* NotebookPickerSheet.swift */, + B30C95F4EABCD08555D97C6B /* NotesListView.swift */, + B1D94BB61519CA2A345D99C1 /* SettingsView.swift */, + 9799115E572F9AF0B59A5AD4 /* WorkspaceStore.swift */, + F50D91D8505FE6446ABD647C /* WorkspaceView.swift */, + ); + path = Workspace; + sourceTree = ""; + }; + B0AF73B8D61DFCCD37B124B1 /* App */ = { + isa = PBXGroup; + children = ( + D06F83AE844C342D3F6EAD7F /* AppEnvironment.swift */, + B9CEA2902CDE0D7C4137D173 /* EdgeEverApp.swift */, + 38B2C459C9903FC6EBA4E478 /* RootView.swift */, + ); + path = App; + sourceTree = ""; + }; + B905FCD37DFCE6B112511261 /* Models */ = { + isa = PBXGroup; + children = ( + C5126510AFB9AFBF7ABADB45 /* Models.swift */, + ); + path = Models; + sourceTree = ""; + }; + BB746E5BDC1029DD2ECF3850 /* Network */ = { + isa = PBXGroup; + children = ( + D6F510BF90B418266F363EBD /* APIClient.swift */, + ); + path = Network; + sourceTree = ""; + }; + BEFB911E3106AB68356141B7 /* Editor */ = { + isa = PBXGroup; + children = ( + F9182F742B06FB1713E27C2A /* ResourceActions.swift */, + 12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */, + ); + path = Editor; + sourceTree = ""; + }; + D1C1EF697B501DEE7E568338 /* Auth */ = { + isa = PBXGroup; + children = ( + B812AD3502D649CE7B84DEBE /* LoginView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + F82E0DDA9B1E6FCB6CB7E083 /* EdgeEverTests */ = { + isa = PBXGroup; + children = ( + 213837D2B2F025307A5EBB69 /* ChromeParityTests.swift */, + 4BEF7AFA15A0972ED3475F23 /* EditorBundleTests.swift */, + 57312B99FFBD9A2186A8C0D0 /* LiveLocalAPITests.swift */, + 2035E878FD92B74D889D6047 /* LocalMirrorTests.swift */, + 82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */, + 1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */, + E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */, + 41B15501968A29C372E7B3BB /* URLNormalizeTests.swift */, + ); + path = EdgeEverTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 06BD8BFE81996E1AAAACD634 /* EdgeEverTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AD4988DEE01B3298BD32D35 /* Build configuration list for PBXNativeTarget "EdgeEverTests" */; + buildPhases = ( + 42C3EE07C909E2E001E877E3 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 6D7518248FEE83B6AC76FEC3 /* PBXTargetDependency */, + ); + name = EdgeEverTests; + packageProductDependencies = ( + ); + productName = EdgeEverTests; + productReference = 94DF0091B1FC2783AEC2D9F6 /* EdgeEverTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 7B4AF236874DD8459683F02B /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00D9D7AC59D511B80CC7CCF9 /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + 34803173963F1A76B23320AB /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ShareExtension; + packageProductDependencies = ( + ); + productName = ShareExtension; + productReference = BF956A8F1A951AA5547675AD /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + E55B18ED6114D23320B85FA0 /* EdgeEver */ = { + isa = PBXNativeTarget; + buildConfigurationList = B556FD174F5B3FB80F311258 /* Build configuration list for PBXNativeTarget "EdgeEver" */; + buildPhases = ( + E77E6932D9BF8F3D7B4D613E /* Sources */, + 1FEB068FC9AB5865E45F4E3A /* Resources */, + 736F644C57A8805092F82568 /* Frameworks */, + 963B22B1C6B716729C2FC851 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + A712C90D3040D07EB8356F24 /* PBXTargetDependency */, + ); + name = EdgeEver; + packageProductDependencies = ( + 1B167394120674FECA86D42F /* GRDB */, + E37BD605D826474D3E842F31 /* Pow */, + ); + productName = EdgeEver; + productReference = 1CD75D20D94A27E982103AB3 /* EdgeEver.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B8C95B723D4044F58C620BBD /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + TargetAttributes = { + 06BD8BFE81996E1AAAACD634 = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + 7B4AF236874DD8459683F02B = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + E55B18ED6114D23320B85FA0 = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 2A1B852B872C6D3D31D0281E /* Build configuration list for PBXProject "EdgeEver" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + "zh-Hans", + ); + mainGroup = A22DC90C50256855231B9F51; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 883036D8C6E7A1AEB7094AC7 /* XCRemoteSwiftPackageReference "GRDB.swift" */, + 98D6A9C2B6DE22F3B5AFEB19 /* XCRemoteSwiftPackageReference "Pow" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 2EE88A37487842A849874933 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E55B18ED6114D23320B85FA0 /* EdgeEver */, + 7B4AF236874DD8459683F02B /* ShareExtension */, + 06BD8BFE81996E1AAAACD634 /* EdgeEverTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1FEB068FC9AB5865E45F4E3A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 93556FDCB87BB93990221E01 /* Assets.xcassets in Resources */, + 06D2661CAB0ADED27F322649 /* Localizable.xcstrings in Resources */, + C37DA99E090A783562506CDA /* PrivacyInfo.xcprivacy in Resources */, + 08D0159B8F194E04C02B69BF /* index.html in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 34803173963F1A76B23320AB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D57261115E6F7ABB665DDBCC /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 42C3EE07C909E2E001E877E3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 100A3242D8AA714261236773 /* ChromeParityTests.swift in Sources */, + E51CD822863D3494E35ADB27 /* EditorBundleTests.swift in Sources */, + F51F6C5ED7C2B0E68DF5A55D /* LiveLocalAPITests.swift in Sources */, + 8785D0133655387F399C3C58 /* LocalMirrorTests.swift in Sources */, + 511A8DCB204AFCB2880AE51E /* MemoCreateCommitTests.swift in Sources */, + A63E64622A06DF3842908A66 /* MobileUIParityTests.swift in Sources */, + 1F8E6268C9DA330CA236B36F /* SyncProtocolTests.swift in Sources */, + 0D67F968CAFCC17F28A5555C /* URLNormalizeTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E77E6932D9BF8F3D7B4D613E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 039594C5BEE4844FBB131C7F /* APIClient.swift in Sources */, + 35DF4BD1617191AB0B95CC4A /* AppDatabase.swift in Sources */, + FC6501567FDC2F9900E973A4 /* AppEnvironment.swift in Sources */, + AA7C45A51254081D6035E20F /* ChromeParity.swift in Sources */, + 9E5B44C7F70B5896D27A00E3 /* Colors.swift in Sources */, + EAD067F29A94BB2BA633EC90 /* ConflictResolutionView.swift in Sources */, + 3D467E1BD92078C96C97E237 /* DeviceId.swift in Sources */, + 9CBE3E1196B24C4DDFC7CD0A /* DraftRepository.swift in Sources */, + 5EEFDEEDC372725C7C7F22ED /* EdgeEverApp.swift in Sources */, + 8C761DABADB93F2F15E21E80 /* EditFabButton.swift in Sources */, + B13CF9D8320DF366EA58601F /* ImageCompressor.swift in Sources */, + 132BA05FF9673B95D3E3959A /* JSONCoding.swift in Sources */, + BBFC2539859878814E2F7197 /* KeychainStore.swift in Sources */, + 3993FE79886C4DFCE7838B65 /* ListActionsSheet.swift in Sources */, + C8A4FA6119134F68F5DC7415 /* LocalMirrorRepository.swift in Sources */, + 33330C89D977D3903F091165 /* LoginView.swift in Sources */, + 08A3D4F0E80ACE44E36AD87D /* MemoCreateCommit.swift in Sources */, + CA033464E43E9CF7C86585E6 /* MemoDetailView.swift in Sources */, + 7BDAD91E69B7B62D7D390CB5 /* MemoEditView.swift in Sources */, + 506702F1751945424AC4CB98 /* MobileUI.swift in Sources */, + 90FFFFD00B2D19F96EA299C8 /* Models.swift in Sources */, + 50800DF91A760DE7FB68DFB4 /* Motion.swift in Sources */, + 0614453E8E9EC13B212B5A2F /* NotebookPickerSheet.swift in Sources */, + B08E58823845817F84C52A52 /* NotesListView.swift in Sources */, + 4A771542C5AD2E6A907187AD /* OutboxFlusher.swift in Sources */, + D6FD6B00023FC1A830684385 /* PreferencesStore.swift in Sources */, + C8ED8D6620BC657699331527 /* ResourceActions.swift in Sources */, + FDF0A8FA27796FB9500F97D4 /* ResourceCache.swift in Sources */, + DE9649944BC9CD8775003203 /* RootView.swift in Sources */, + 1510CEE8BDDBD1710C0CE88C /* SessionStore.swift in Sources */, + 8430B1EDE6CA87A0C922E484 /* SettingsView.swift in Sources */, + 2581F6050C02A4A36340CC0D /* ShareHandoffStore.swift in Sources */, + 35AAF7A2B1B4D5EE38F8518C /* SyncEngine.swift in Sources */, + 2E185C3C27DA3A729F2734B4 /* SyncOutboxRepository.swift in Sources */, + 5F5EC9565A02FAFDD5D7B54C /* SyncProtocol.swift in Sources */, + 4A2DD0673B81D32FA9E5F9DB /* Theme.swift in Sources */, + 4BA97CD264D0929B20ED19DB /* TipTapWebView.swift in Sources */, + 2A07555B54946A177E1ECFF2 /* Typography.swift in Sources */, + 6A671BB702DA77A0548E6E29 /* URL+Normalize.swift in Sources */, + 38640D9CE35D118F325AABC5 /* WorkspaceStore.swift in Sources */, + 487717BFBCCF86B783B33B44 /* WorkspaceView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6D7518248FEE83B6AC76FEC3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E55B18ED6114D23320B85FA0 /* EdgeEver */; + targetProxy = 53E8C491B050CDC322721D62 /* PBXContainerItemProxy */; + }; + A712C90D3040D07EB8356F24 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7B4AF236874DD8459683F02B /* ShareExtension */; + targetProxy = C1A8F66075747C3C6E134FFB /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 10286547E2614C4976245BFF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "org.edgeever.mobile.share-extension"; + PRODUCT_NAME = ShareExtension; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 1D67AE2BE71F3F9EC50390A8 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 243811C4B4CBF13982976A56 /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 25678172825C033DEF77B2E9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = EdgeEver/Resources/EdgeEver.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = EdgeEver/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.edgeever.mobile; + PRODUCT_NAME = EdgeEver; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 6C4B12F17B0877DC28C15A9F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 9BE845D95C8AF7B567916133 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "org.edgeever.mobile.share-extension"; + PRODUCT_NAME = ShareExtension; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AC8FA0B2EC5CBFC175F3111D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.edgeever.mobile.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EdgeEver.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EdgeEver"; + }; + name = Release; + }; + E02510661F0A2F87DF739AC9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.edgeever.mobile.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EdgeEver.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EdgeEver"; + }; + name = Debug; + }; + E3708B932E980E3B69A2CB48 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = EdgeEver/Resources/EdgeEver.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = EdgeEver/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.edgeever.mobile; + PRODUCT_NAME = EdgeEver; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 00D9D7AC59D511B80CC7CCF9 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9BE845D95C8AF7B567916133 /* Debug */, + 10286547E2614C4976245BFF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 2A1B852B872C6D3D31D0281E /* Build configuration list for PBXProject "EdgeEver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6C4B12F17B0877DC28C15A9F /* Debug */, + 1D67AE2BE71F3F9EC50390A8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 5AD4988DEE01B3298BD32D35 /* Build configuration list for PBXNativeTarget "EdgeEverTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E02510661F0A2F87DF739AC9 /* Debug */, + AC8FA0B2EC5CBFC175F3111D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B556FD174F5B3FB80F311258 /* Build configuration list for PBXNativeTarget "EdgeEver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E3708B932E980E3B69A2CB48 /* Debug */, + 25678172825C033DEF77B2E9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 883036D8C6E7A1AEB7094AC7 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDB.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.0.0; + }; + }; + 98D6A9C2B6DE22F3B5AFEB19 /* XCRemoteSwiftPackageReference "Pow" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/EmergeTools/Pow"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1B167394120674FECA86D42F /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = 883036D8C6E7A1AEB7094AC7 /* XCRemoteSwiftPackageReference "GRDB.swift" */; + productName = GRDB; + }; + E37BD605D826474D3E842F31 /* Pow */ = { + isa = XCSwiftPackageProductDependency; + package = 98D6A9C2B6DE22F3B5AFEB19 /* XCRemoteSwiftPackageReference "Pow" */; + productName = Pow; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = B8C95B723D4044F58C620BBD /* Project object */; +} diff --git a/apps/ios/EdgeEver.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/ios/EdgeEver.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/apps/ios/EdgeEver.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/ios/EdgeEver.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/EdgeEver.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..cc9ee148 --- /dev/null +++ b/apps/ios/EdgeEver.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "2119a74b595d1446da8bba7b8bfd032220c0862799be21cafc7e510c4dd34ed2", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" + } + }, + { + "identity" : "pow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EmergeTools/Pow", + "state" : { + "revision" : "1b4b1dda28c50b95f0872927ee2226fe8b58950e", + "version" : "1.0.6" + } + } + ], + "version" : 3 +} diff --git a/apps/ios/EdgeEver.xcodeproj/xcshareddata/xcschemes/EdgeEver.xcscheme b/apps/ios/EdgeEver.xcodeproj/xcshareddata/xcschemes/EdgeEver.xcscheme new file mode 100644 index 00000000..b8df83f3 --- /dev/null +++ b/apps/ios/EdgeEver.xcodeproj/xcshareddata/xcschemes/EdgeEver.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/EdgeEver/App/AppEnvironment.swift b/apps/ios/EdgeEver/App/AppEnvironment.swift new file mode 100644 index 00000000..c41bb4b3 --- /dev/null +++ b/apps/ios/EdgeEver/App/AppEnvironment.swift @@ -0,0 +1,85 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class AppEnvironment { + let session: SessionStore + let mirror: LocalMirrorRepository + let outbox: SyncOutboxRepository + let drafts: DraftRepository + let syncEngine: SyncEngine + let outboxFlusher: OutboxFlusher + let preferences: PreferencesStore + let shareHandoff: ShareHandoffStore + + private(set) var bootstrapProgress: BootstrapProgress? + private(set) var isSyncing = false + private(set) var lastSyncError: String? + private(set) var lastOutboxResult: SyncRunResult? + + init() { + let dbQueue = try! AppDatabase.makeShared() + let mirror = LocalMirrorRepository(dbQueue: dbQueue) + let outbox = SyncOutboxRepository(dbQueue: dbQueue) + let drafts = DraftRepository(dbQueue: dbQueue) + let session = SessionStore() + let syncEngine = SyncEngine(mirror: mirror, client: session.client) + let flusher = OutboxFlusher(outbox: outbox, mirror: mirror, client: session.client) + self.session = session + self.mirror = mirror + self.outbox = outbox + self.drafts = drafts + self.syncEngine = syncEngine + self.outboxFlusher = flusher + self.preferences = PreferencesStore() + self.shareHandoff = ShareHandoffStore() + } + + /// Test-friendly initializer. + init( + session: SessionStore, + mirror: LocalMirrorRepository, + outbox: SyncOutboxRepository, + drafts: DraftRepository, + syncEngine: SyncEngine, + outboxFlusher: OutboxFlusher, + preferences: PreferencesStore = PreferencesStore(), + shareHandoff: ShareHandoffStore = ShareHandoffStore() + ) { + self.session = session + self.mirror = mirror + self.outbox = outbox + self.drafts = drafts + self.syncEngine = syncEngine + self.outboxFlusher = outboxFlusher + self.preferences = preferences + self.shareHandoff = shareHandoff + } + + func bootstrap() async { + await session.bootstrap() + if session.isSignedIn { + await runSyncCycle() + } + } + + func runSyncCycle() async { + guard let scope = session.dataScope else { return } + isSyncing = true + lastSyncError = nil + defer { isSyncing = false } + do { + _ = try await outboxFlusher.flush(scope: scope) + _ = try await syncEngine.sync(scope: scope) { [weak self] progress in + Task { @MainActor in + self?.bootstrapProgress = progress + } + } + lastOutboxResult = try await outboxFlusher.flush(scope: scope) + bootstrapProgress = nil + } catch { + lastSyncError = error.localizedDescription + } + } +} diff --git a/apps/ios/EdgeEver/App/EdgeEverApp.swift b/apps/ios/EdgeEver/App/EdgeEverApp.swift new file mode 100644 index 00000000..d769046e --- /dev/null +++ b/apps/ios/EdgeEver/App/EdgeEverApp.swift @@ -0,0 +1,52 @@ +import SwiftUI + +@main +struct EdgeEverApp: App { + @State private var env = AppEnvironment() + + var body: some Scene { + WindowGroup { + RootView() + .environment(env) + .onOpenURL { url in + // Share Extension opens edgeever://share after writing App Group payload. + guard url.scheme == "edgeever" else { return } + // WorkspaceView consumes pending share payloads on appear / foreground. + NotificationCenter.default.post(name: .edgeEverShareReceived, object: nil) + } + } + } +} + +/// Simulator / debug helper. Enabled only when launch args provide credentials: +/// `-EdgeEverAutoLoginURL` `-EdgeEverAutoLoginUser` `-EdgeEverAutoLoginPassword` +enum AutoLogin { + @MainActor + static func attemptIfConfigured(env: AppEnvironment) async { + let args = ProcessInfo.processInfo.arguments + func value(for flag: String) -> String? { + guard let idx = args.firstIndex(of: flag), args.index(after: idx) < args.endIndex else { + return nil + } + return args[args.index(after: idx)] + } + guard + let base = value(for: "-EdgeEverAutoLoginURL"), + let user = value(for: "-EdgeEverAutoLoginUser"), + let password = value(for: "-EdgeEverAutoLoginPassword") + else { return } + guard !env.session.isSignedIn else { return } + do { + try await env.session.signIn(baseUrl: base, username: user, password: password) + await env.runSyncCycle() + } catch { + // Surface on login screen via next manual attempt; keep silent for automation logs. + print("AutoLogin failed: \(error.localizedDescription)") + } + } +} + +extension Notification.Name { + static let edgeEverShareReceived = Notification.Name("edgeEverShareReceived") + static let edgeEverResourcePress = Notification.Name("edgeEverResourcePress") +} diff --git a/apps/ios/EdgeEver/App/RootView.swift b/apps/ios/EdgeEver/App/RootView.swift new file mode 100644 index 00000000..48a4431a --- /dev/null +++ b/apps/ios/EdgeEver/App/RootView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct RootView: View { + @Environment(AppEnvironment.self) private var env + + var body: some View { + Group { + if env.session.isLoading { + ProgressView("启动中…") + } else if env.session.isSignedIn { + WorkspaceView() + } else { + LoginView() + } + } + .task { + await env.bootstrap() + await AutoLogin.attemptIfConfigured(env: env) + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + Task { + if env.session.isSignedIn { + await env.runSyncCycle() + } + } + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in + Task { + if let scope = env.session.dataScope { + _ = try? await env.outboxFlusher.flush(scope: scope) + } + } + } + } +} diff --git a/apps/ios/EdgeEver/Data/Cache/ImageCompressor.swift b/apps/ios/EdgeEver/Data/Cache/ImageCompressor.swift new file mode 100644 index 00000000..dac4409c --- /dev/null +++ b/apps/ios/EdgeEver/Data/Cache/ImageCompressor.swift @@ -0,0 +1,46 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +enum ImageCompressor { + /// Max edge ~2560, JPEG quality ~0.82 — spirit of mobile-image-upload.ts + static func compressIfNeeded(_ data: Data, maxEdge: CGFloat = 2560, quality: CGFloat = 0.82) -> (data: Data, mimeType: String, filename: String) { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + return (data, "application/octet-stream", "upload.bin") + } + + let width = CGFloat(cgImage.width) + let height = CGFloat(cgImage.height) + let longest = max(width, height) + let scale = longest > maxEdge ? maxEdge / longest : 1 + let targetSize = CGSize(width: floor(width * scale), height: floor(height * scale)) + + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: Int(targetSize.width), + height: Int(targetSize.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return (data, "image/jpeg", "image.jpg") + } + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(origin: .zero, size: targetSize)) + guard let scaled = context.makeImage() else { + return (data, "image/jpeg", "image.jpg") + } + + let mutable = NSMutableData() + guard let dest = CGImageDestinationCreateWithData(mutable, UTType.jpeg.identifier as CFString, 1, nil) else { + return (data, "image/jpeg", "image.jpg") + } + CGImageDestinationAddImage(dest, scaled, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary) + CGImageDestinationFinalize(dest) + return (mutable as Data, "image/jpeg", "image.jpg") + } +} diff --git a/apps/ios/EdgeEver/Data/Cache/ResourceCache.swift b/apps/ios/EdgeEver/Data/Cache/ResourceCache.swift new file mode 100644 index 00000000..ce70f88b --- /dev/null +++ b/apps/ios/EdgeEver/Data/Cache/ResourceCache.swift @@ -0,0 +1,73 @@ +import Foundation +import CryptoKit + +actor ResourceCache { + private let directory: URL + private let maxBytes: Int64 = 200 * 1024 * 1024 + + init() { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + directory = base.appendingPathComponent("resource-cache", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func fileURL(for resourceId: String) -> URL { + let safe = resourceId.replacingOccurrences(of: "/", with: "_") + return directory.appendingPathComponent(safe) + } + + func dataURL(for resourceId: String, data: Data, mimeType: String) throws -> String { + let url = fileURL(for: resourceId) + try data.write(to: url, options: .atomic) + let base64 = data.base64EncodedString() + return "data:\(mimeType);base64,\(base64)" + } + + func cachedData(for resourceId: String) -> Data? { + let url = fileURL(for: resourceId) + return try? Data(contentsOf: url) + } + + static func resourceId(from source: String) -> String? { + // /api/v1/resources/:id or .../blob + guard let url = URL(string: source, relativeTo: URL(string: "https://placeholder.local")) else { return nil } + let parts = url.path.split(separator: "/").map(String.init) + if let idx = parts.firstIndex(of: "resources"), idx + 1 < parts.count { + return parts[idx + 1] + } + return nil + } + + /// Match Android `normalizeProtectedResourcePath` — ensure blob route. + static func normalizeProtectedResourcePath(_ source: String, baseURL: URL?) -> String { + var path = source + if let base = baseURL?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")), + path.hasPrefix(base + "/") + { + path = String(path.dropFirst(base.count)) + } + if path.hasPrefix("http://") || path.hasPrefix("https://"), + let url = URL(string: path) + { + path = url.path + } + guard path.hasPrefix("/api/v1/resources/") else { return path } + if path.range(of: #"/blob(?:$|[?#])"#, options: .regularExpression) != nil { + return path + } + if let match = path.range(of: #"^/api/v1/resources/[^/?#]+"#, options: .regularExpression) { + return String(path[match]) + "/blob" + } + return path + } + + static func isProtectedResourceSource(_ source: String, baseURL: URL?) -> Bool { + if source.hasPrefix("/api/v1/resources/") { return true } + if let base = baseURL?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")), + source.hasPrefix(base + "/api/v1/resources/") + { + return true + } + return false + } +} diff --git a/apps/ios/EdgeEver/Data/Database/AppDatabase.swift b/apps/ios/EdgeEver/Data/Database/AppDatabase.swift new file mode 100644 index 00000000..45e6d8c7 --- /dev/null +++ b/apps/ios/EdgeEver/Data/Database/AppDatabase.swift @@ -0,0 +1,119 @@ +import Foundation +import GRDB + +enum AppDatabase { + static let fileName = "edgeever-ios.sqlite" + + static func makeShared() throws -> DatabaseQueue { + let dir = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("EdgeEver", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let path = dir.appendingPathComponent(fileName).path + var config = Configuration() + config.prepareDatabase { db in + try db.execute(sql: "PRAGMA foreign_keys = ON") + } + let dbQueue = try DatabaseQueue(path: path, configuration: config) + try dbQueue.writeWithoutTransaction { db in + try db.execute(sql: "PRAGMA journal_mode = WAL") + } + try migrator.migrate(dbQueue) + return dbQueue + } + + /// In-memory queue for unit tests. + static func makeEmpty() throws -> DatabaseQueue { + let dbQueue = try DatabaseQueue() + try migrator.migrate(dbQueue) + return dbQueue + } + + private static var migrator: DatabaseMigrator { + var migrator = DatabaseMigrator() + migrator.registerMigration("v1_mirror_outbox_drafts") { db in + try db.execute(sql: """ + CREATE TABLE mobile_notebooks ( + scope TEXT NOT NULL, + id TEXT NOT NULL, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL, + data_json TEXT NOT NULL, + PRIMARY KEY (scope, id) + ); + + CREATE TABLE mobile_memos ( + scope TEXT NOT NULL, + id TEXT NOT NULL, + notebook_id TEXT NOT NULL, + title TEXT NOT NULL, + content_text TEXT NOT NULL, + tags_text TEXT NOT NULL, + is_pinned INTEGER NOT NULL, + is_deleted INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + data_json TEXT NOT NULL, + PRIMARY KEY (scope, id) + ); + + CREATE INDEX idx_mobile_memos_feed + ON mobile_memos(scope, is_deleted, updated_at DESC); + CREATE INDEX idx_mobile_memos_notebook + ON mobile_memos(scope, notebook_id, is_deleted, updated_at DESC); + + CREATE TABLE mobile_sync_meta ( + scope TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (scope, key) + ); + + CREATE TABLE mobile_id_mappings ( + scope TEXT NOT NULL, + temporary_id TEXT NOT NULL, + remote_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (scope, temporary_id) + ); + + CREATE TABLE mobile_sync_outbox ( + scope TEXT NOT NULL, + id TEXT NOT NULL, + kind TEXT NOT NULL, + memo_id TEXT NOT NULL, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + next_attempt_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (scope, id) + ); + + CREATE INDEX idx_mobile_outbox_flush + ON mobile_sync_outbox(scope, status, next_attempt_at, created_at); + + CREATE TABLE mobile_drafts ( + scope TEXT NOT NULL, + draft_key TEXT NOT NULL, + title TEXT NOT NULL, + content_markdown TEXT NOT NULL, + content_json TEXT, + notebook_id TEXT NOT NULL, + tags_text TEXT NOT NULL, + expected_revision INTEGER, + updated_at TEXT NOT NULL, + PRIMARY KEY (scope, draft_key) + ); + """) + } + return migrator + } +} diff --git a/apps/ios/EdgeEver/Data/Database/DraftRepository.swift b/apps/ios/EdgeEver/Data/Database/DraftRepository.swift new file mode 100644 index 00000000..8e180e4a --- /dev/null +++ b/apps/ios/EdgeEver/Data/Database/DraftRepository.swift @@ -0,0 +1,65 @@ +import Foundation +import GRDB + +final class DraftRepository: @unchecked Sendable { + private let dbQueue: DatabaseQueue + + init(dbQueue: DatabaseQueue) { + self.dbQueue = dbQueue + } + + static func memoKey(_ memoId: String) -> String { "memo:\(memoId)" } + static let newKey = "new" + + func read(scope: String, key: String) throws -> MemoDraft? { + try dbQueue.read { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM mobile_drafts WHERE scope = ? AND draft_key = ?", + arguments: [scope, key] + ) else { return nil } + return MemoDraft( + draftKey: row["draft_key"], + title: row["title"], + contentMarkdown: row["content_markdown"], + contentJson: row["content_json"], + notebookId: row["notebook_id"], + tagsText: row["tags_text"], + expectedRevision: row["expected_revision"], + updatedAt: row["updated_at"] + ) + } + } + + func write(scope: String, draft: MemoDraft) throws { + try dbQueue.write { db in + try db.execute( + sql: """ + INSERT OR REPLACE INTO mobile_drafts + (scope, draft_key, title, content_markdown, content_json, notebook_id, tags_text, expected_revision, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + scope, + draft.draftKey, + draft.title, + draft.contentMarkdown, + draft.contentJson, + draft.notebookId, + draft.tagsText, + draft.expectedRevision, + draft.updatedAt, + ] + ) + } + } + + func clear(scope: String, key: String) throws { + try dbQueue.write { db in + try db.execute( + sql: "DELETE FROM mobile_drafts WHERE scope = ? AND draft_key = ?", + arguments: [scope, key] + ) + } + } +} diff --git a/apps/ios/EdgeEver/Data/Database/LocalMirrorRepository.swift b/apps/ios/EdgeEver/Data/Database/LocalMirrorRepository.swift new file mode 100644 index 00000000..90bff876 --- /dev/null +++ b/apps/ios/EdgeEver/Data/Database/LocalMirrorRepository.swift @@ -0,0 +1,346 @@ +import Foundation +import GRDB + +struct LocalMemoListParams: Sendable { + var notebookId: String? = nil + var notebookIds: [String]? = nil + var q: String? = nil + var trash: Bool = false + var sort: MemoSortMode = .updatedDesc + var filter: MemoFilterMode = .all + var limit: Int = 50 + var offset: Int = 0 +} + +struct LocalMemoListResult: Sendable { + var memos: [MemoSummary] + var totalCount: Int + var nextOffset: Int? +} + +final class LocalMirrorRepository: @unchecked Sendable { + private let dbQueue: DatabaseQueue + + init(dbQueue: DatabaseQueue) { + self.dbQueue = dbQueue + } + + func isInitialized(scope: String) throws -> Bool { + try dbQueue.read { db in + let rows = try Row.fetchAll( + db, + sql: "SELECT key, value FROM mobile_sync_meta WHERE scope = ? AND key IN ('cursor', 'identity')", + arguments: [scope] + ) + var cursor: String? + var identity: String? + for row in rows { + let key: String = row["key"] + let value: String = row["value"] + if key == "cursor" { cursor = value } + if key == "identity" { identity = value } + } + return SyncProtocol.isMetadataInitialized(cursorValue: cursor, identityValue: identity) + } + } + + func listNotebooks(scope: String) throws -> [Notebook] { + try dbQueue.read { db in + let rows = try Row.fetchAll( + db, + sql: """ + SELECT n.data_json, + COUNT(CASE WHEN m.is_deleted = 0 THEN 1 END) AS memo_count, + MAX(CASE WHEN m.is_deleted = 0 THEN m.updated_at END) AS last_memo_updated_at + FROM mobile_notebooks n + LEFT JOIN mobile_memos m ON m.scope = n.scope AND m.notebook_id = n.id + WHERE n.scope = ? + GROUP BY n.id, n.data_json + ORDER BY n.sort_order ASC, n.name COLLATE NOCASE ASC + """, + arguments: [scope] + ) + return try rows.map { row in + var notebook = try EdgeEverJSON.decoder.decode(Notebook.self, from: Data((row["data_json"] as String).utf8)) + notebook.memoCount = row["memo_count"] + notebook.lastMemoUpdatedAt = row["last_memo_updated_at"] + return notebook + } + } + } + + func listMemos(scope: String, params: LocalMemoListParams) throws -> LocalMemoListResult { + try dbQueue.read { db in + var conditions = ["scope = ?", "is_deleted = ?"] + var arguments: [any DatabaseValueConvertible] = [scope, params.trash ? 1 : 0] + + if let notebookIds = params.notebookIds, !notebookIds.isEmpty { + conditions.append("notebook_id IN (\(notebookIds.map { _ in "?" }.joined(separator: ",")))") + arguments.append(contentsOf: notebookIds) + } else if let notebookId = params.notebookId { + conditions.append("notebook_id = ?") + arguments.append(notebookId) + } + if let q = params.q?.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty { + conditions.append("(title LIKE ? OR content_text LIKE ? OR tags_text LIKE ?)") + let pattern = "%\(q)%" + arguments.append(contentsOf: [pattern, pattern, pattern]) + } + switch params.filter { + case .all: break + case .tagged: conditions.append("tags_text <> ''") + case .untagged: conditions.append("tags_text = ''") + case .pinned: conditions.append("is_pinned = 1") + } + + let orderBy: String + if params.trash { + orderBy = "deleted_at DESC, id DESC" + } else { + switch params.sort { + case .createdDesc: orderBy = "is_pinned DESC, created_at DESC, id DESC" + case .titleAsc: orderBy = "is_pinned DESC, title COLLATE NOCASE ASC, updated_at DESC, id DESC" + case .updatedDesc: orderBy = "is_pinned DESC, updated_at DESC, id DESC" + } + } + + let whereSQL = conditions.joined(separator: " AND ") + let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM mobile_memos WHERE \(whereSQL)", arguments: StatementArguments(arguments)) ?? 0 + let limit = params.limit + let offset = max(0, params.offset) + var listArgs = arguments + listArgs.append(limit) + listArgs.append(offset) + let rows = try Row.fetchAll( + db, + sql: "SELECT data_json FROM mobile_memos WHERE \(whereSQL) ORDER BY \(orderBy) LIMIT ? OFFSET ?", + arguments: StatementArguments(listArgs) + ) + let memos = try rows.map { row -> MemoSummary in + let detail = try EdgeEverJSON.decoder.decode(MemoDetail.self, from: Data((row["data_json"] as String).utf8)) + return detail.asSummary() + } + let next = offset + memos.count + return LocalMemoListResult(memos: memos, totalCount: count, nextOffset: next < count ? next : nil) + } + } + + func getMemo(scope: String, id: String) throws -> MemoDetail? { + try dbQueue.read { db in + guard let json = try String.fetchOne( + db, + sql: "SELECT data_json FROM mobile_memos WHERE scope = ? AND id = ?", + arguments: [scope, id] + ) else { return nil } + return try EdgeEverJSON.decoder.decode(MemoDetail.self, from: Data(json.utf8)) + } + } + + func resolveMemo(scope: String, id: String) throws -> MemoDetail? { + if let direct = try getMemo(scope: scope, id: id) { return direct } + return try dbQueue.read { db in + guard let remoteId = try String.fetchOne( + db, + sql: "SELECT remote_id FROM mobile_id_mappings WHERE scope = ? AND temporary_id = ?", + arguments: [scope, id] + ) else { return nil } + guard let json = try String.fetchOne( + db, + sql: "SELECT data_json FROM mobile_memos WHERE scope = ? AND id = ?", + arguments: [scope, remoteId] + ) else { return nil } + return try EdgeEverJSON.decoder.decode(MemoDetail.self, from: Data(json.utf8)) + } + } + + func upsertMemo(scope: String, memo: MemoDetail) throws { + try dbQueue.write { db in + try upsertMemo(db, scope: scope, memo: memo) + } + } + + func softDeleteMemo(scope: String, id: String) throws -> Bool { + guard var memo = try getMemo(scope: scope, id: id), !memo.isDeleted else { return false } + let now = ISO8601DateFormatter.edgeEver.string(from: Date()) + memo.isDeleted = true + memo.deletedAt = now + memo.updatedAt = now + try upsertMemo(scope: scope, memo: memo) + return true + } + + func deleteMemo(scope: String, id: String) throws { + try dbQueue.write { db in + try db.execute(sql: "DELETE FROM mobile_memos WHERE scope = ? AND id = ?", arguments: [scope, id]) + } + } + + func replaceLocalMemoId(scope: String, temporaryId: String, memo: MemoDetail) throws { + try dbQueue.write { db in + try upsertMemo(db, scope: scope, memo: memo) + try db.execute(sql: "DELETE FROM mobile_memos WHERE scope = ? AND id = ?", arguments: [scope, temporaryId]) + try db.execute( + sql: """ + INSERT OR REPLACE INTO mobile_id_mappings (scope, temporary_id, remote_id, created_at) + VALUES (?, ?, ?, ?) + """, + arguments: [scope, temporaryId, memo.id, ISO8601DateFormatter.edgeEver.string(from: Date())] + ) + } + } + + func clearRemoteMirrorPreservingLocal(scope: String) throws { + try dbQueue.write { db in + try db.execute(sql: "DELETE FROM mobile_notebooks WHERE scope = ?", arguments: [scope]) + try db.execute(sql: "DELETE FROM mobile_memos WHERE scope = ? AND id NOT LIKE 'local:%'", arguments: [scope]) + } + } + + func clearSyncCursor(scope: String) throws { + try dbQueue.write { db in + try db.execute( + sql: "DELETE FROM mobile_sync_meta WHERE scope = ? AND key IN ('cursor', 'identity')", + arguments: [scope] + ) + } + } + + func setCursor(scope: String, cursor: Int) throws { + try dbQueue.write { db in + try db.execute( + sql: "INSERT OR REPLACE INTO mobile_sync_meta (scope, key, value) VALUES (?, 'cursor', ?)", + arguments: [scope, String(cursor)] + ) + } + } + + func setIdentity(scope: String, identity: String) throws { + try dbQueue.write { db in + try db.execute( + sql: "INSERT OR REPLACE INTO mobile_sync_meta (scope, key, value) VALUES (?, 'identity', ?)", + arguments: [scope, identity] + ) + } + } + + func getCursor(scope: String) throws -> Int? { + try dbQueue.read { db in + guard let value = try String.fetchOne( + db, + sql: "SELECT value FROM mobile_sync_meta WHERE scope = ? AND key = 'cursor'", + arguments: [scope] + ) else { return nil } + return Int(value) + } + } + + func getIdentity(scope: String) throws -> String? { + try dbQueue.read { db in + try String.fetchOne( + db, + sql: "SELECT value FROM mobile_sync_meta WHERE scope = ? AND key = 'identity'", + arguments: [scope] + ) + } + } + + func applyBootstrapBatch(scope: String, notebooks: [Notebook]?, memos: [MemoDetail]) throws { + try dbQueue.write { db in + if let notebooks { + for notebook in notebooks { + try upsertNotebook(db, scope: scope, notebook: notebook) + } + } + for memo in memos { + try upsertMemo(db, scope: scope, memo: memo) + } + } + } + + func applyChanges(scope: String, changes: [MobileSyncChange], cursor: Int) throws { + try dbQueue.write { db in + for change in changes { + if change.entityType == "memo" { + if let memo = change.memo { + try upsertMemo(db, scope: scope, memo: memo) + } else { + try db.execute( + sql: "DELETE FROM mobile_memos WHERE scope = ? AND id = ?", + arguments: [scope, change.entityId] + ) + } + } else if let notebook = change.notebook { + try upsertNotebook(db, scope: scope, notebook: notebook) + } else { + try db.execute( + sql: "DELETE FROM mobile_notebooks WHERE scope = ? AND id = ?", + arguments: [scope, change.entityId] + ) + } + } + try db.execute( + sql: "INSERT OR REPLACE INTO mobile_sync_meta (scope, key, value) VALUES (?, 'cursor', ?)", + arguments: [scope, String(cursor)] + ) + } + } + + // MARK: - Private upserts + + private func upsertNotebook(_ db: Database, scope: String, notebook: Notebook) throws { + let json = try String(data: EdgeEverJSON.encoder.encode(notebook), encoding: .utf8) ?? "{}" + try db.execute( + sql: """ + INSERT OR REPLACE INTO mobile_notebooks (scope, id, name, sort_order, data_json) + VALUES (?, ?, ?, ?, ?) + """, + arguments: [scope, notebook.id, notebook.name, notebook.sortOrder, json] + ) + } + + private func upsertMemo(_ db: Database, scope: String, memo: MemoDetail) throws { + let json = try String(data: EdgeEverJSON.encoder.encode(memo), encoding: .utf8) ?? "{}" + try db.execute( + sql: """ + INSERT OR REPLACE INTO mobile_memos + (scope, id, notebook_id, title, content_text, tags_text, is_pinned, is_deleted, + created_at, updated_at, deleted_at, data_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + scope, + memo.id, + memo.notebookId, + memo.title ?? "", + memo.contentText, + memo.tags.joined(separator: " "), + memo.isPinned ? 1 : 0, + memo.isDeleted ? 1 : 0, + memo.createdAt, + memo.updatedAt, + memo.deletedAt, + json, + ] + ) + } +} + +extension ISO8601DateFormatter { + static let edgeEver: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + static let edgeEverFallback: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() +} + +enum EdgeEverDate { + static func nowString() -> String { + ISO8601DateFormatter.edgeEver.string(from: Date()) + } +} diff --git a/apps/ios/EdgeEver/Data/Database/SyncOutboxRepository.swift b/apps/ios/EdgeEver/Data/Database/SyncOutboxRepository.swift new file mode 100644 index 00000000..d588928e --- /dev/null +++ b/apps/ios/EdgeEver/Data/Database/SyncOutboxRepository.swift @@ -0,0 +1,309 @@ +import Foundation +import GRDB + +final class SyncOutboxRepository: @unchecked Sendable { + private let dbQueue: DatabaseQueue + + init(dbQueue: DatabaseQueue) { + self.dbQueue = dbQueue + } + + static func createQueueId(memoId: String) -> String { "memo.create:\(memoId)" } + static func updateQueueId(memoId: String) -> String { "memo.update:\(memoId)" } + + func enqueueCreate(scope: String, payload: MemoCreatePayload) throws { + let now = EdgeEverDate.nowString() + let id = Self.createQueueId(memoId: payload.memoId) + let payloadJSON = try String(data: EdgeEverJSON.encoder.encode(payload), encoding: .utf8) ?? "{}" + try dbQueue.write { db in + let existingVersion = try Int.fetchOne( + db, + sql: "SELECT version FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, id] + ) ?? 0 + let createdAt = try String.fetchOne( + db, + sql: "SELECT created_at FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, id] + ) ?? now + try db.execute( + sql: """ + INSERT OR REPLACE INTO mobile_sync_outbox + (scope, id, kind, memo_id, status, payload_json, attempt_count, last_error, + next_attempt_at, created_at, updated_at, version) + VALUES (?, ?, ?, ?, 'pending', ?, 0, NULL, NULL, ?, ?, ?) + """, + arguments: [scope, id, OutboxKind.memoCreate.rawValue, payload.memoId, payloadJSON, createdAt, now, existingVersion + 1] + ) + } + } + + func enqueueUpdate(scope: String, payload: MemoUpdatePayload) throws { + let now = EdgeEverDate.nowString() + let createId = Self.createQueueId(memoId: payload.memoId) + let updateId = Self.updateQueueId(memoId: payload.memoId) + let updateJSON = try String(data: EdgeEverJSON.encoder.encode(payload), encoding: .utf8) ?? "{}" + + try dbQueue.write { db in + // Create-absorbs-update: fold into pending create if present. + if let createRow = try Row.fetchOne( + db, + sql: "SELECT * FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, createId] + ) { + var createPayload = try EdgeEverJSON.decoder.decode( + MemoCreatePayload.self, + from: Data((createRow["payload_json"] as String).utf8) + ) + createPayload.title = payload.title + createPayload.contentMarkdown = payload.contentMarkdown + createPayload.notebookId = payload.notebookId + createPayload.tags = payload.tags + let json = try String(data: EdgeEverJSON.encoder.encode(createPayload), encoding: .utf8) ?? "{}" + let version: Int = createRow["version"] + try db.execute( + sql: """ + UPDATE mobile_sync_outbox + SET status = 'pending', payload_json = ?, last_error = NULL, next_attempt_at = NULL, + updated_at = ?, version = ? + WHERE scope = ? AND id = ? + """, + arguments: [json, now, version + 1, scope, createId] + ) + return + } + + let existingVersion = try Int.fetchOne( + db, + sql: "SELECT version FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, updateId] + ) ?? 0 + let createdAt = try String.fetchOne( + db, + sql: "SELECT created_at FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, updateId] + ) ?? now + let attemptCount = try Int.fetchOne( + db, + sql: "SELECT attempt_count FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, updateId] + ) ?? 0 + try db.execute( + sql: """ + INSERT OR REPLACE INTO mobile_sync_outbox + (scope, id, kind, memo_id, status, payload_json, attempt_count, last_error, + next_attempt_at, created_at, updated_at, version) + VALUES (?, ?, ?, ?, 'pending', ?, ?, NULL, NULL, ?, ?, ?) + """, + arguments: [ + scope, updateId, OutboxKind.memoUpdate.rawValue, payload.memoId, updateJSON, + attemptCount, createdAt, now, existingVersion + 1, + ] + ) + } + } + + func cancelMemo(scope: String, memoId: String) throws { + try dbQueue.write { db in + try db.execute(sql: "DELETE FROM mobile_sync_outbox WHERE scope = ? AND memo_id = ?", arguments: [scope, memoId]) + } + } + + func listItems(scope: String) throws -> [OutboxItem] { + try dbQueue.read { db in + let rows = try Row.fetchAll( + db, + sql: "SELECT * FROM mobile_sync_outbox WHERE scope = ? ORDER BY updated_at DESC", + arguments: [scope] + ) + return rows.compactMap { Self.item(from: $0) } + } + } + + func flushableItems(scope: String, now: Date = Date()) throws -> [OutboxItem] { + let nowString = ISO8601DateFormatter.edgeEver.string(from: now) + return try dbQueue.read { db in + let rows = try Row.fetchAll( + db, + sql: """ + SELECT * FROM mobile_sync_outbox + WHERE scope = ? + AND status IN ('pending', 'error', 'syncing') + AND (next_attempt_at IS NULL OR next_attempt_at <= ?) + ORDER BY created_at ASC + """, + arguments: [scope, nowString] + ) + return rows.compactMap { Self.item(from: $0) } + } + } + + func markSyncing(scope: String, id: String, expectedVersion: Int) throws -> Bool { + try dbQueue.write { db in + try db.execute( + sql: """ + UPDATE mobile_sync_outbox + SET status = 'syncing', updated_at = ? + WHERE scope = ? AND id = ? AND version = ? + """, + arguments: [EdgeEverDate.nowString(), scope, id, expectedVersion] + ) + return db.changesCount > 0 + } + } + + func remove(scope: String, id: String, expectedVersion: Int?) throws -> Bool { + try dbQueue.write { db in + if let expectedVersion { + try db.execute( + sql: "DELETE FROM mobile_sync_outbox WHERE scope = ? AND id = ? AND version = ?", + arguments: [scope, id, expectedVersion] + ) + } else { + try db.execute( + sql: "DELETE FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, id] + ) + } + return db.changesCount > 0 + } + } + + func updateStatus( + scope: String, + id: String, + expectedVersion: Int, + status: OutboxStatus, + attemptCount: Int, + lastError: String?, + nextAttemptAt: String? + ) throws { + try dbQueue.write { db in + try db.execute( + sql: """ + UPDATE mobile_sync_outbox + SET status = ?, attempt_count = ?, last_error = ?, next_attempt_at = ?, updated_at = ? + WHERE scope = ? AND id = ? AND version = ? + """, + arguments: [ + status.rawValue, attemptCount, lastError, nextAttemptAt, EdgeEverDate.nowString(), + scope, id, expectedVersion, + ] + ) + } + } + + func promoteCreateToUpdate(scope: String, createId: String, expectedVersion: Int, memo: MemoDetail) throws -> Bool { + // If create row still exists at same version, remove it (caller remaps id). + // If version advanced (user edited during sync), convert remaining create payload to update. + try dbQueue.write { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, createId] + ) else { return false } + + let version: Int = row["version"] + if version == expectedVersion { + try db.execute(sql: "DELETE FROM mobile_sync_outbox WHERE scope = ? AND id = ?", arguments: [scope, createId]) + return true + } + // Rebase: convert create → update with new revision base + guard let createPayload = try? EdgeEverJSON.decoder.decode( + MemoCreatePayload.self, + from: Data((row["payload_json"] as String).utf8) + ) else { return false } + let update = MemoUpdatePayload( + memoId: memo.id, + expectedRevision: memo.revision, + expectedContentHash: memo.contentHash, + title: createPayload.title, + contentMarkdown: createPayload.contentMarkdown, + notebookId: createPayload.notebookId, + tags: createPayload.tags + ) + let json = try String(data: EdgeEverJSON.encoder.encode(update), encoding: .utf8) ?? "{}" + let updateId = Self.updateQueueId(memoId: memo.id) + let now = EdgeEverDate.nowString() + try db.execute(sql: "DELETE FROM mobile_sync_outbox WHERE scope = ? AND id = ?", arguments: [scope, createId]) + try db.execute( + sql: """ + INSERT OR REPLACE INTO mobile_sync_outbox + (scope, id, kind, memo_id, status, payload_json, attempt_count, last_error, + next_attempt_at, created_at, updated_at, version) + VALUES (?, ?, 'memo.update', ?, 'pending', ?, 0, NULL, NULL, ?, ?, 1) + """, + arguments: [scope, updateId, memo.id, json, now, now] + ) + return true + } + } + + func rebaseUpdate(scope: String, id: String, syncedVersion: Int, memo: MemoDetail) throws { + try dbQueue.write { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, id] + ) else { return } + let version: Int = row["version"] + guard version > syncedVersion, row["kind"] as String == OutboxKind.memoUpdate.rawValue else { return } + var payload = try EdgeEverJSON.decoder.decode( + MemoUpdatePayload.self, + from: Data((row["payload_json"] as String).utf8) + ) + payload.expectedRevision = memo.revision + payload.expectedContentHash = memo.contentHash + let json = try String(data: EdgeEverJSON.encoder.encode(payload), encoding: .utf8) ?? "{}" + try db.execute( + sql: """ + UPDATE mobile_sync_outbox + SET payload_json = ?, status = 'pending', last_error = NULL, next_attempt_at = NULL, updated_at = ? + WHERE scope = ? AND id = ? + """, + arguments: [json, EdgeEverDate.nowString(), scope, id] + ) + } + } + + func pendingCreate(scope: String, memoId: String) throws -> OutboxItem? { + try dbQueue.read { db in + let id = Self.createQueueId(memoId: memoId) + guard let row = try Row.fetchOne( + db, + sql: "SELECT * FROM mobile_sync_outbox WHERE scope = ? AND id = ?", + arguments: [scope, id] + ) else { return nil } + return Self.item(from: row) + } + } + + private static func item(from row: Row) -> OutboxItem? { + guard + let kind = OutboxKind(rawValue: row["kind"]), + let status = OutboxStatus(rawValue: row["status"]) + else { return nil } + return OutboxItem( + id: row["id"], + kind: kind, + memoId: row["memo_id"], + status: status, + payloadJSON: row["payload_json"], + attemptCount: row["attempt_count"], + lastError: row["last_error"], + nextAttemptAt: row["next_attempt_at"], + createdAt: row["created_at"], + updatedAt: row["updated_at"], + version: row["version"] + ) + } +} + +enum SyncRetry { + static func nextAttemptAt(attemptCount: Int, from date: Date = Date()) -> String { + // Exponential-ish backoff: 2^min(attempt,6) seconds + let seconds = pow(2.0, Double(min(attemptCount, 6))) + let at = date.addingTimeInterval(seconds) + return ISO8601DateFormatter.edgeEver.string(from: at) + } +} diff --git a/apps/ios/EdgeEver/Data/Models/Models.swift b/apps/ios/EdgeEver/Data/Models/Models.swift new file mode 100644 index 00000000..f0adf5f5 --- /dev/null +++ b/apps/ios/EdgeEver/Data/Models/Models.swift @@ -0,0 +1,443 @@ +import Foundation + +// MARK: - Auth + +struct AuthUser: Codable, Equatable, Sendable, Identifiable { + var id: String + var username: String + var displayName: String? + var role: String +} + +struct AuthSession: Codable, Equatable, Sendable { + var authRequired: Bool + var authenticated: Bool + var demoMode: Bool + var user: AuthUser? + var sessionToken: String? +} + +struct LoginInput: Encodable, Sendable { + var username: String + var password: String + var deviceId: String +} + +struct MobileSession: Codable, Equatable, Sendable { + var baseUrl: String + var token: String + var user: AuthUser? +} + +struct LoginDeviceSession: Codable, Equatable, Sendable, Identifiable { + var id: String + var userAgent: String? + var label: String? + var ipAddress: String? + var ipCountry: String? + var ipRegion: String? + var isCurrent: Bool + var createdAt: String + var lastSeenAt: String + var expiresAt: String +} + +// MARK: - Notebook / Memo + +struct Notebook: Codable, Equatable, Sendable, Identifiable { + var id: String + var parentId: String? + var name: String + var slug: String? + var icon: String? + var color: String? + var sortOrder: Int + var memoCount: Int + var lastMemoUpdatedAt: String? + var createdAt: String + var updatedAt: String +} + +struct MemoSummary: Codable, Equatable, Sendable, Identifiable { + var id: String + var notebookId: String + var title: String? + var excerpt: String + var tags: [String] + var isPinned: Bool + var isArchived: Bool + var isDeleted: Bool + var revision: Int + var createdAt: String + var updatedAt: String + var deletedAt: String? +} + +struct MemoDetail: Codable, Equatable, Sendable, Identifiable { + var id: String + var notebookId: String + var title: String? + var excerpt: String + var tags: [String] + var isPinned: Bool + var isArchived: Bool + var isDeleted: Bool + var revision: Int + var createdAt: String + var updatedAt: String + var deletedAt: String? + var contentJson: JSONValue + var contentMarkdown: String + var contentText: String + var contentHash: String + var sourceMemoIds: [String] + var mergeSourceCount: Int + var mergedIntoMemoId: String? + + var displayTitle: String { + let t = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return t.isEmpty ? "无标题笔记" : t + } + + func asSummary() -> MemoSummary { + MemoSummary( + id: id, + notebookId: notebookId, + title: title, + excerpt: excerpt, + tags: tags, + isPinned: isPinned, + isArchived: isArchived, + isDeleted: isDeleted, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } +} + +struct MemoEditSession: Codable, Equatable, Sendable { + var id: String + var memoId: String + var baseRevision: Int + var baseContentHash: String + var expiresAt: String +} + +struct Resource: Codable, Equatable, Sendable, Identifiable { + var id: String + var memoId: String + var originalMemoId: String? + var kind: String + var mimeType: String? + var filename: String? + var byteSize: Int + var sha256: String? + var width: Int? + var height: Int? + var createdAt: String + var updatedAt: String + var url: String +} + +struct ApiToken: Codable, Equatable, Sendable, Identifiable { + var id: String + var name: String + var token: String? + var scopes: [String] + var lastUsedAt: String? + var expiresAt: String? + var isRevoked: Bool + var createdAt: String +} + +struct CreatedApiToken: Codable, Equatable, Sendable { + var token: String + var apiToken: ApiToken +} + +struct TagSummary: Codable, Equatable, Sendable { + var name: String + var memoCount: Int + var updatedAt: String? +} + +struct MemoRevision: Codable, Equatable, Sendable, Identifiable { + var id: String + var memoId: String + var revision: Int + var title: String? + var tags: [String] + var contentMarkdown: String + var contentText: String + var contentHash: String + var createdBy: String + var createdAt: String +} + +struct MemoShare: Codable, Equatable, Sendable { + var memoId: String + var token: String + var createdAt: String + var updatedAt: String +} + +struct InstanceUser: Codable, Equatable, Sendable, Identifiable { + var id: String + var username: String + var displayName: String? + var role: String + var isDisabled: Bool + var lastLoginAt: String? + var createdAt: String +} + +// MARK: - Sync wire types + +struct MobileSyncBootstrapPage: Codable, Sendable { + var notebooks: [Notebook] + var memos: [MemoDetail] + var snapshotCursor: Int + var syncIdentity: String? + var totalCount: Int + var nextAfterId: String? +} + +struct MobileSyncChange: Codable, Sendable { + var cursor: Int + var entityType: String + var entityId: String + var operation: String + var notebook: Notebook? + var memo: MemoDetail? +} + +struct MobileSyncChangesPage: Codable, Sendable { + var changes: [MobileSyncChange] + var cursor: Int + var hasMore: Bool + var serverCursor: Int? + var syncIdentity: String? +} + +// MARK: - Envelope helpers + +struct MemoResponse: Codable, Sendable { + var memo: MemoDetail +} + +struct NotebookResponse: Codable, Sendable { + var notebook: Notebook +} + +struct NotebooksResponse: Codable, Sendable { + var notebooks: [Notebook] +} + +struct EditSessionResponse: Codable, Sendable { + var editSession: MemoEditSession +} + +struct ResourceResponse: Codable, Sendable { + var resource: Resource +} + +struct OkResponse: Codable, Sendable { + var ok: Bool +} + +struct SessionsResponse: Codable, Sendable { + var sessions: [LoginDeviceSession] +} + +struct ApiTokensResponse: Codable, Sendable { + var apiTokens: [ApiToken] + var availableScopes: [String] +} + +struct TagsResponse: Codable, Sendable { + var tags: [TagSummary] +} + +// MARK: - Outbox / drafts domain + +enum MemoSortMode: String, CaseIterable, Sendable { + case updatedDesc = "updated-desc" + case createdDesc = "created-desc" + case titleAsc = "title-asc" +} + +enum MemoFilterMode: String, CaseIterable, Sendable { + case all + case tagged + case untagged + case pinned +} + +struct MemoCreatePayload: Codable, Equatable, Sendable { + var memoId: String + var title: String + var contentMarkdown: String + var notebookId: String + var tags: [String] + var createdAt: String +} + +struct MemoUpdatePayload: Codable, Equatable, Sendable { + var memoId: String + var expectedRevision: Int + var expectedContentHash: String + var title: String + var contentMarkdown: String + var notebookId: String + var tags: [String] +} + +enum OutboxKind: String, Codable, Sendable { + case memoCreate = "memo.create" + case memoUpdate = "memo.update" +} + +enum OutboxStatus: String, Codable, Sendable { + case pending + case syncing + case conflict + case error +} + +struct OutboxItem: Codable, Equatable, Sendable, Identifiable { + var id: String + var kind: OutboxKind + var memoId: String + var status: OutboxStatus + var payloadJSON: String + var attemptCount: Int + var lastError: String? + var nextAttemptAt: String? + var createdAt: String + var updatedAt: String + var version: Int + + func createPayload() throws -> MemoCreatePayload { + try EdgeEverJSON.decoder.decode(MemoCreatePayload.self, from: Data(payloadJSON.utf8)) + } + + func updatePayload() throws -> MemoUpdatePayload { + try EdgeEverJSON.decoder.decode(MemoUpdatePayload.self, from: Data(payloadJSON.utf8)) + } +} + +struct MemoDraft: Codable, Equatable, Sendable { + var draftKey: String + var title: String + var contentMarkdown: String + var contentJson: String? + var notebookId: String + var tagsText: String + var expectedRevision: Int? + var updatedAt: String +} + +struct SyncRunResult: Equatable, Sendable { + var attempted = 0 + var synced = 0 + var failed = 0 + var conflicted = 0 +} + +struct BootstrapProgress: Equatable, Sendable { + var loadedCount: Int + var totalCount: Int +} + +// MARK: - Flexible JSON for TipTap docs + +enum JSONValue: Codable, Equatable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } + + static var emptyDoc: JSONValue { + .object([ + "type": .string("doc"), + "content": .array([.object(["type": .string("paragraph")])]), + ]) + } + + func jsonString() throws -> String { + let data = try EdgeEverJSON.encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "{}" + } + + static func parse(_ string: String) throws -> JSONValue { + try EdgeEverJSON.decoder.decode(JSONValue.self, from: Data(string.utf8)) + } +} + +extension MemoDetail { + static func localPlaceholder( + id: String, + notebookId: String, + title: String, + contentMarkdown: String, + tags: [String], + createdAt: String + ) -> MemoDetail { + MemoDetail( + id: id, + notebookId: notebookId, + title: title, + excerpt: String(contentMarkdown.prefix(160)), + tags: tags, + isPinned: false, + isArchived: false, + isDeleted: false, + revision: 0, + createdAt: createdAt, + updatedAt: createdAt, + deletedAt: nil, + contentJson: .emptyDoc, + contentMarkdown: contentMarkdown, + contentText: contentMarkdown, + contentHash: "", + sourceMemoIds: [], + mergeSourceCount: 0, + mergedIntoMemoId: nil + ) + } +} diff --git a/apps/ios/EdgeEver/Data/Network/APIClient.swift b/apps/ios/EdgeEver/Data/Network/APIClient.swift new file mode 100644 index 00000000..f9f47d48 --- /dev/null +++ b/apps/ios/EdgeEver/Data/Network/APIClient.swift @@ -0,0 +1,463 @@ +import Foundation + +actor APIClient { + private(set) var baseURL: URL + private(set) var token: String? + private var onUnauthorized: (@Sendable () -> Void)? + + private let session: URLSession + + init( + baseURL: URL, + token: String? = nil, + session: URLSession = .shared, + onUnauthorized: (@Sendable () -> Void)? = nil + ) { + self.baseURL = baseURL.edgeEverNormalizedBase + self.token = token + self.session = session + self.onUnauthorized = onUnauthorized + } + + func update(baseURL: URL, token: String?, onUnauthorized: (@Sendable () -> Void)? = nil) { + self.baseURL = baseURL.edgeEverNormalizedBase + self.token = token + if let onUnauthorized { + self.onUnauthorized = onUnauthorized + } + } + + func setOnUnauthorized(_ handler: (@Sendable () -> Void)?) { + onUnauthorized = handler + } + + // MARK: - Auth + + func login(_ input: LoginInput) async throws -> AuthSession { + try await request(path: "/api/v1/auth/login", method: "POST", body: input) + } + + func logout() async throws { + let _: OkResponse = try await request(path: "/api/v1/auth/logout", method: "POST", body: EmptyBody()) + } + + func getSession() async throws -> AuthSession { + try await request(path: "/api/v1/auth/session") + } + + func changePassword(current: String, newPassword: String, confirm: String) async throws { + struct Body: Encodable { + var currentPassword: String + var newPassword: String + var confirmPassword: String + } + let _: OkResponse = try await request( + path: "/api/v1/auth/change-password", + method: "POST", + body: Body(currentPassword: current, newPassword: newPassword, confirmPassword: confirm) + ) + } + + func listLoginDeviceSessions() async throws -> [LoginDeviceSession] { + let response: SessionsResponse = try await request(path: "/api/v1/auth/sessions") + return response.sessions + } + + func revokeLoginDeviceSession(id: String) async throws { + let _: OkResponse = try await request(path: "/api/v1/auth/sessions/\(id)", method: "DELETE") + } + + func revokeOtherLoginDeviceSessions() async throws { + let _: OkResponse = try await request(path: "/api/v1/auth/sessions", method: "DELETE") + } + + // MARK: - Sync + + func getMobileSyncBootstrapPage(afterId: String?, limit: Int = 200) async throws -> MobileSyncBootstrapPage { + var items: [URLQueryItem] = [.init(name: "limit", value: String(limit))] + if let afterId { items.append(.init(name: "afterId", value: afterId)) } + return try await request(path: "/api/v1/sync/bootstrap", query: items) + } + + func getMobileSyncChanges(cursor: Int, limit: Int = 200) async throws -> MobileSyncChangesPage { + try await request( + path: "/api/v1/sync/changes", + query: [ + .init(name: "cursor", value: String(cursor)), + .init(name: "limit", value: String(limit)), + ] + ) + } + + // MARK: - Memos + + func createMemo( + notebookId: String, + title: String?, + contentMarkdown: String?, + tags: [String]?, + createdAt: String? = nil, + updatedAt: String? = nil + ) async throws -> MemoDetail { + struct Body: Encodable { + var notebookId: String + var title: String? + var contentMarkdown: String? + var tags: [String]? + var createdAt: String? + var updatedAt: String? + } + let response: MemoResponse = try await request( + path: "/api/v1/memos", + method: "POST", + body: Body( + notebookId: notebookId, + title: title, + contentMarkdown: contentMarkdown, + tags: tags, + createdAt: createdAt, + updatedAt: updatedAt + ) + ) + return response.memo + } + + func updateMemo( + id: String, + expectedRevision: Int?, + expectedContentHash: String?, + editSessionId: String?, + notebookId: String?, + title: String?, + isPinned: Bool?, + contentMarkdown: String?, + tags: [String]? + ) async throws -> MemoDetail { + struct Body: Encodable { + var expectedRevision: Int? + var expectedContentHash: String? + var editSessionId: String? + var notebookId: String? + var title: String? + var isPinned: Bool? + var contentMarkdown: String? + var tags: [String]? + } + let response: MemoResponse = try await request( + path: "/api/v1/memos/\(id)", + method: "PATCH", + body: Body( + expectedRevision: expectedRevision, + expectedContentHash: expectedContentHash, + editSessionId: editSessionId, + notebookId: notebookId, + title: title, + isPinned: isPinned, + contentMarkdown: contentMarkdown, + tags: tags + ) + ) + return response.memo + } + + func getMemo(id: String, includeDeleted: Bool = false) async throws -> MemoDetail { + var query: [URLQueryItem] = [] + if includeDeleted { query.append(.init(name: "includeDeleted", value: "1")) } + let response: MemoResponse = try await request(path: "/api/v1/memos/\(id)", query: query) + return response.memo + } + + func deleteMemo(id: String, permanent: Bool = false) async throws { + var query: [URLQueryItem] = [] + if permanent { query.append(.init(name: "permanent", value: "1")) } + let _: OkResponse = try await request(path: "/api/v1/memos/\(id)", method: "DELETE", query: query) + } + + func createMemoEditSession(memoId: String) async throws -> MemoEditSession { + let response: EditSessionResponse = try await request( + path: "/api/v1/memos/\(memoId)/edit-sessions", + method: "POST", + body: EmptyBody() + ) + return response.editSession + } + + // MARK: - Resources / tokens / tags + + func uploadMemoResource(memoId: String, filename: String, mimeType: String, data: Data) async throws -> Resource { + let boundary = "EdgeEverBoundary\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + var request = URLRequest(url: makeURL(path: "/api/v1/memos/\(memoId)/resources")) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.httpBody = body + return try await perform(request).resource + } + + func getResourceData(path: String) async throws -> Data { + var request = URLRequest(url: makeURL(path: path)) + request.httpMethod = "GET" + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw APIError(status: -1, code: nil, message: "Invalid response") + } + if http.statusCode == 401 { + onUnauthorized?() + } + guard (200 ..< 300).contains(http.statusCode) else { + throw APIError(status: http.statusCode, code: nil, message: HTTPURLResponse.localizedString(forStatusCode: http.statusCode)) + } + return data + } + + func renameResource(id: String, filename: String) async throws -> Resource { + struct Body: Encodable { var filename: String } + let response: ResourceResponse = try await request( + path: "/api/v1/resources/\(id)", + method: "PATCH", + body: Body(filename: filename) + ) + return response.resource + } + + func deleteResource(id: String) async throws { + let _: OkResponse = try await request(path: "/api/v1/resources/\(id)", method: "DELETE") + } + + func listApiTokens() async throws -> ApiTokensResponse { + try await request(path: "/api/v1/api-tokens") + } + + func createApiToken(name: String, scopes: [String]) async throws -> CreatedApiToken { + struct Body: Encodable { + var name: String + var scopes: [String] + } + return try await request(path: "/api/v1/api-tokens", method: "POST", body: Body(name: name, scopes: scopes)) + } + + func revokeApiToken(id: String) async throws { + let _: OkResponse = try await request(path: "/api/v1/api-tokens/\(id)", method: "DELETE") + } + + func listTags() async throws -> [TagSummary] { + let response: TagsResponse = try await request(path: "/api/v1/tags") + return response.tags + } + + func renameTag(tag: String, name: String) async throws { + struct Body: Encodable { var name: String } + let encoded = tag.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? tag + let _: OkResponse = try await request( + path: "/api/v1/tags/\(encoded)", + method: "PATCH", + body: Body(name: name) + ) + } + + func deleteTag(tag: String) async throws { + let encoded = tag.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? tag + let _: OkResponse = try await request(path: "/api/v1/tags/\(encoded)", method: "DELETE") + } + + func listMemoRevisions(memoId: String) async throws -> [MemoRevision] { + struct Response: Codable { var revisions: [MemoRevision] } + let response: Response = try await request(path: "/api/v1/memos/\(memoId)/revisions") + return response.revisions + } + + func restoreMemoRevision(memoId: String, revisionId: String) async throws -> MemoDetail { + let response: MemoResponse = try await request( + path: "/api/v1/memos/\(memoId)/revisions/\(revisionId)/restore", + method: "POST", + body: EmptyBody() + ) + return response.memo + } + + func getMemoShare(memoId: String) async throws -> MemoShare? { + struct Response: Codable { var share: MemoShare? } + let response: Response = try await request(path: "/api/v1/memos/\(memoId)/share") + return response.share + } + + func createMemoShare(memoId: String) async throws -> MemoShare { + struct Response: Codable { var share: MemoShare } + let response: Response = try await request( + path: "/api/v1/memos/\(memoId)/share", + method: "POST", + body: EmptyBody() + ) + return response.share + } + + func revokeMemoShare(memoId: String) async throws { + let _: OkResponse = try await request(path: "/api/v1/memos/\(memoId)/share", method: "DELETE") + } + + func listUsers() async throws -> [InstanceUser] { + struct Response: Codable { var users: [InstanceUser] } + let response: Response = try await request(path: "/api/v1/users") + return response.users + } + + func createUser(username: String, displayName: String?, password: String) async throws -> InstanceUser { + struct Body: Encodable { + var username: String + var displayName: String? + var password: String + } + struct Response: Codable { var user: InstanceUser } + let response: Response = try await request( + path: "/api/v1/users", + method: "POST", + body: Body(username: username, displayName: displayName, password: password) + ) + return response.user + } + + func updateUser(userId: String, displayName: String?, password: String?, isDisabled: Bool?) async throws -> InstanceUser { + struct Body: Encodable { + var displayName: String? + var password: String? + var isDisabled: Bool? + } + struct Response: Codable { var user: InstanceUser } + let response: Response = try await request( + path: "/api/v1/users/\(userId)", + method: "PATCH", + body: Body(displayName: displayName, password: password, isDisabled: isDisabled) + ) + return response.user + } + + func deleteMemos(memoIds: [String], permanent: Bool = false) async throws { + struct Body: Encodable { + var memoIds: [String] + var permanent: Bool? + } + let _: OkResponse = try await request( + path: "/api/v1/memos/batch/delete", + method: "POST", + body: Body(memoIds: memoIds, permanent: permanent ? true : nil) + ) + } + + func moveMemos(memoIds: [String], notebookId: String) async throws { + struct Body: Encodable { + var memoIds: [String] + var notebookId: String + } + let _: OkResponse = try await request( + path: "/api/v1/memos/batch/move", + method: "POST", + body: Body(memoIds: memoIds, notebookId: notebookId) + ) + } + + // MARK: - Core request + + private struct EmptyBody: Encodable {} + + private func request( + path: String, + method: String = "GET", + query: [URLQueryItem] = [], + body: (any Encodable)? = nil + ) async throws -> T { + var request = URLRequest(url: makeURL(path: path, query: query)) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try EdgeEverJSON.encoder.encode(AnyEncodable(body)) + } + let data = try await performRaw(request) + return try EdgeEverJSON.decoder.decode(T.self, from: data) + } + + private func perform(_ request: URLRequest) async throws -> ResourceResponse { + let data = try await performRaw(request) + return try EdgeEverJSON.decoder.decode(ResourceResponse.self, from: data) + } + + private func performRaw(_ request: URLRequest) async throws -> Data { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw APIError(status: -1, code: nil, message: "Invalid response") + } + if http.statusCode == 401 { + onUnauthorized?() + } + guard (200 ..< 300).contains(http.statusCode) else { + let message = Self.parseErrorMessage(data: data) ?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode) + let code = Self.parseErrorCode(data: data) + throw APIError(status: http.statusCode, code: code, message: message) + } + return data + } + + private func makeURL(path: String, query: [URLQueryItem] = []) -> URL { + let base = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let suffix = path.hasPrefix("/") ? path : "/\(path)" + guard var components = URLComponents(string: base + suffix) else { + preconditionFailure("Invalid URL for path \(path)") + } + if !query.isEmpty { + components.queryItems = query + } + guard let url = components.url else { + preconditionFailure("Invalid URL components for path \(path)") + } + return url + } + + private static func parseErrorMessage(data: Data) -> String? { + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String + else { return nil } + return message + } + + private static func parseErrorCode(data: Data) -> String? { + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let code = error["code"] as? String + else { return nil } + return code + } +} + +struct APIError: Error, LocalizedError, Equatable, Sendable { + let status: Int + let code: String? + let message: String + + var errorDescription: String? { message } + var isUnauthorized: Bool { status == 401 } + var isRevisionConflict: Bool { code == "revision_conflict" || status == 409 } +} + +private struct AnyEncodable: Encodable { + private let encodeFunc: (Encoder) throws -> Void + init(_ value: any Encodable) { encodeFunc = value.encode } + func encode(to encoder: Encoder) throws { try encodeFunc(encoder) } +} diff --git a/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift b/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift new file mode 100644 index 00000000..549ac4cf --- /dev/null +++ b/apps/ios/EdgeEver/Data/Preferences/PreferencesStore.swift @@ -0,0 +1,74 @@ +import Foundation +import Observation +import SwiftUI + +@Observable +@MainActor +final class PreferencesStore { + private let defaults: UserDefaults + + /// system | zh-CN | en-US — matches Android locale preference. + var localeCode: String { + didSet { defaults.set(localeCode, forKey: Keys.locale) } + } + + var useCompression: Bool { + didSet { defaults.set(useCompression, forKey: Keys.compression) } + } + + /// system | light | dark + var theme: String { + didSet { defaults.set(theme, forKey: Keys.theme) } + } + + var listDensity: ListDensity { + didSet { defaults.set(listDensity.rawValue, forKey: Keys.density) } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.localeCode = defaults.string(forKey: Keys.locale) ?? "system" + self.useCompression = defaults.object(forKey: Keys.compression) as? Bool ?? true + self.theme = defaults.string(forKey: Keys.theme) ?? "system" + let densityRaw = defaults.string(forKey: Keys.density) ?? ListDensity.preview.rawValue + // Migrate legacy "comfortable" → preview + if densityRaw == "comfortable" { + self.listDensity = .preview + } else { + self.listDensity = ListDensity(rawValue: densityRaw) ?? .preview + } + } + + var resolvedLocale: Locale { + switch localeCode { + case "zh-CN": return Locale(identifier: "zh-Hans") + case "en-US": return Locale(identifier: "en-US") + default: return .autoupdatingCurrent + } + } + + var isEnglish: Bool { + if localeCode == "en-US" { return true } + if localeCode == "zh-CN" { return false } + return Locale.autoupdatingCurrent.language.languageCode?.identifier == "en" + } + + var colorScheme: ColorScheme? { + switch theme { + case "light": return .light + case "dark": return .dark + default: return nil + } + } + + func t(_ zh: String, en: String) -> String { + isEnglish ? en : zh + } + + private enum Keys { + static let locale = "edgeever.ios.locale" + static let compression = "edgeever.ios.imageCompression" + static let theme = "edgeever.ios.theme" + static let density = "edgeever.ios.listDensity" + } +} diff --git a/apps/ios/EdgeEver/Data/Session/DeviceId.swift b/apps/ios/EdgeEver/Data/Session/DeviceId.swift new file mode 100644 index 00000000..14ce986e --- /dev/null +++ b/apps/ios/EdgeEver/Data/Session/DeviceId.swift @@ -0,0 +1,13 @@ +import Foundation + +enum DeviceId { + /// Stable per-install device id sent with login (`deviceId`), matching RN `edgeever.mobile.device-id`. + static func getOrCreate() throws -> String { + if let existing = try KeychainStore.string(forKey: KeychainStore.Key.deviceId), !existing.isEmpty { + return existing + } + let id = "mobile-\(UUID().uuidString.lowercased())" + try KeychainStore.setString(id, forKey: KeychainStore.Key.deviceId) + return id + } +} diff --git a/apps/ios/EdgeEver/Data/Session/KeychainStore.swift b/apps/ios/EdgeEver/Data/Session/KeychainStore.swift new file mode 100644 index 00000000..86fcd50a --- /dev/null +++ b/apps/ios/EdgeEver/Data/Session/KeychainStore.swift @@ -0,0 +1,74 @@ +import Foundation +import Security + +/// Keychain-backed storage with UserDefaults fallback when Keychain is unavailable +/// (unsigned simulator installs, missing entitlements, etc.). +enum KeychainStore { + static let service = "org.edgeever.mobile" + + enum Key { + static let session = "edgeever.mobile.session" + static let deviceId = "edgeever.mobile.device-id" + } + + private static let fallbackPrefix = "edgeever.keychain.fallback." + + static func setString(_ value: String, forKey key: String) throws { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + var add = query + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(add as CFDictionary, nil) + if status == errSecSuccess { + UserDefaults.standard.removeObject(forKey: fallbackPrefix + key) + return + } + // Fallback for environments where Keychain is restricted. + UserDefaults.standard.set(value, forKey: fallbackPrefix + key) + if status != errSecDuplicateItem && status != errSecInteractionNotAllowed && status != errSecMissingEntitlement { + // Still record unusual codes but do not fail the app bootstrap path. + #if DEBUG + print("KeychainStore.setString fallback status=\(status) key=\(key)") + #endif + } + } + + static func string(forKey key: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess, let data = item as? Data, let value = String(data: data, encoding: .utf8) { + return value + } + if status == errSecItemNotFound || status != errSecSuccess { + return UserDefaults.standard.string(forKey: fallbackPrefix + key) + } + return UserDefaults.standard.string(forKey: fallbackPrefix + key) + } + + static func delete(forKey key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + UserDefaults.standard.removeObject(forKey: fallbackPrefix + key) + } +} + +enum KeychainError: Error { + case unhandled(OSStatus) +} diff --git a/apps/ios/EdgeEver/Data/Session/SessionStore.swift b/apps/ios/EdgeEver/Data/Session/SessionStore.swift new file mode 100644 index 00000000..c1dbc969 --- /dev/null +++ b/apps/ios/EdgeEver/Data/Session/SessionStore.swift @@ -0,0 +1,86 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class SessionStore { + private(set) var isLoading = true + private(set) var session: MobileSession? + private(set) var lastError: String? + + let client: APIClient + private let keychainSessionKey = KeychainStore.Key.session + + init(client: APIClient = APIClient(baseURL: URL(string: "https://localhost")!)) { + self.client = client + } + + var isSignedIn: Bool { session != nil } + + var dataScope: String? { + guard let session else { return nil } + guard let url = try? EdgeEverURLNormalizer.normalizeInstanceURL(session.baseUrl) else { return nil } + return SyncProtocol.createDataScope(baseURL: url, userId: session.user?.id) + } + + func bootstrap() async { + defer { isLoading = false } + do { + guard let raw = try KeychainStore.string(forKey: keychainSessionKey), + let data = raw.data(using: .utf8) + else { return } + let stored = try EdgeEverJSON.decoder.decode(MobileSession.self, from: data) + let baseURL = try EdgeEverURLNormalizer.normalizeInstanceURL(stored.baseUrl) + await client.update(baseURL: baseURL, token: stored.token, onUnauthorized: { [weak self] in + Task { @MainActor in self?.clearLocalSession() } + }) + session = stored + } catch { + lastError = error.localizedDescription + clearLocalSession() + } + } + + func signIn(baseUrl: String, username: String, password: String) async throws { + lastError = nil + let url = try EdgeEverURLNormalizer.normalizeInstanceURL(baseUrl) + if url.scheme?.lowercased() == "http" { + // Allowed with local networking; UI should already warn. + } + let deviceId = try DeviceId.getOrCreate() + await client.update(baseURL: url, token: nil) + let auth = try await client.login(LoginInput(username: username, password: password, deviceId: deviceId)) + guard auth.authenticated, let token = auth.sessionToken else { + throw APIError( + status: 400, + code: nil, + message: "登录成功但服务端没有返回移动端会话。请确认服务端已更新到支持 App 登录的版本。" + ) + } + let next = MobileSession(baseUrl: url.absoluteString, token: token, user: auth.user) + try persist(next) + await client.update(baseURL: url, token: token, onUnauthorized: { [weak self] in + Task { @MainActor in self?.clearLocalSession() } + }) + session = next + } + + func signOut() async { + if session != nil { + try? await client.logout() + } + clearLocalSession() + } + + private func persist(_ session: MobileSession) throws { + let data = try EdgeEverJSON.encoder.encode(session) + guard let raw = String(data: data, encoding: .utf8) else { return } + try KeychainStore.setString(raw, forKey: keychainSessionKey) + } + + func clearLocalSession() { + try? KeychainStore.delete(forKey: keychainSessionKey) + session = nil + Task { await client.update(baseURL: URL(string: "https://localhost")!, token: nil) } + } +} diff --git a/apps/ios/EdgeEver/Data/Sync/OutboxFlusher.swift b/apps/ios/EdgeEver/Data/Sync/OutboxFlusher.swift new file mode 100644 index 00000000..908e5205 --- /dev/null +++ b/apps/ios/EdgeEver/Data/Sync/OutboxFlusher.swift @@ -0,0 +1,106 @@ +import Foundation + +actor OutboxFlusher { + private let outbox: SyncOutboxRepository + private let mirror: LocalMirrorRepository + private let client: APIClient + + init(outbox: SyncOutboxRepository, mirror: LocalMirrorRepository, client: APIClient) { + self.outbox = outbox + self.mirror = mirror + self.client = client + } + + func flush(scope: String) async throws -> SyncRunResult { + var result = SyncRunResult() + let items = try outbox.flushableItems(scope: scope) + for item in items { + result.attempted += 1 + let marked = try outbox.markSyncing(scope: scope, id: item.id, expectedVersion: item.version) + guard marked else { continue } + + do { + let memo = try await syncItem(item) + let removed = try outbox.remove(scope: scope, id: item.id, expectedVersion: item.version) + if removed { + if item.kind == .memoCreate, item.memoId.hasPrefix("local:") { + try mirror.replaceLocalMemoId(scope: scope, temporaryId: item.memoId, memo: memo) + } else { + try mirror.upsertMemo(scope: scope, memo: memo) + } + } else if item.kind == .memoCreate { + let promoted = try outbox.promoteCreateToUpdate( + scope: scope, + createId: item.id, + expectedVersion: item.version, + memo: memo + ) + if promoted { + try mirror.replaceLocalMemoId(scope: scope, temporaryId: item.memoId, memo: memo) + } else { + // User cancelled while in flight — soft-delete remote orphan. + try? await client.deleteMemo(id: memo.id, permanent: false) + } + } else { + try outbox.rebaseUpdate(scope: scope, id: item.id, syncedVersion: item.version, memo: memo) + try mirror.upsertMemo(scope: scope, memo: memo) + } + result.synced += 1 + } catch { + let apiError = error as? APIError + let isConflict = apiError?.isRevisionConflict == true + let status: OutboxStatus = isConflict ? .conflict : .error + let attempts = item.attemptCount + 1 + try outbox.updateStatus( + scope: scope, + id: item.id, + expectedVersion: item.version, + status: status, + attemptCount: attempts, + lastError: (error as? LocalizedError)?.errorDescription ?? error.localizedDescription, + nextAttemptAt: isConflict ? nil : SyncRetry.nextAttemptAt(attemptCount: attempts) + ) + if isConflict { + result.conflicted += 1 + } else { + result.failed += 1 + } + } + } + return result + } + + private func syncItem(_ item: OutboxItem) async throws -> MemoDetail { + switch item.kind { + case .memoCreate: + let payload = try item.createPayload() + return try await client.createMemo( + notebookId: payload.notebookId, + title: payload.title, + contentMarkdown: payload.contentMarkdown, + tags: payload.tags, + createdAt: payload.createdAt, + updatedAt: item.updatedAt + ) + case .memoUpdate: + let payload = try item.updatePayload() + let editSession = try await client.createMemoEditSession(memoId: item.memoId) + if editSession.baseRevision != payload.expectedRevision + || editSession.baseContentHash != payload.expectedContentHash + { + throw APIError(status: 409, code: "revision_conflict", message: "Note changed before the offline draft could sync.") + } + return try await client.updateMemo( + id: item.memoId, + expectedRevision: payload.expectedRevision, + expectedContentHash: payload.expectedContentHash, + editSessionId: editSession.id, + notebookId: payload.notebookId, + title: payload.title, + isPinned: nil, + contentMarkdown: payload.contentMarkdown, + tags: payload.tags + ) + } + } +} diff --git a/apps/ios/EdgeEver/Data/Sync/SyncEngine.swift b/apps/ios/EdgeEver/Data/Sync/SyncEngine.swift new file mode 100644 index 00000000..e51766b4 --- /dev/null +++ b/apps/ios/EdgeEver/Data/Sync/SyncEngine.swift @@ -0,0 +1,88 @@ +import Foundation + +actor SyncEngine { + private let mirror: LocalMirrorRepository + private let client: APIClient + private var inFlight: [String: Task] = [:] + + init(mirror: LocalMirrorRepository, client: APIClient) { + self.mirror = mirror + self.client = client + } + + func sync(scope: String, onBootstrapProgress: (@Sendable (BootstrapProgress) -> Void)? = nil) async throws -> Int { + if let existing = inFlight[scope] { + return try await existing.value + } + let task = Task { try await performSync(scope: scope, onBootstrapProgress: onBootstrapProgress) } + inFlight[scope] = task + defer { inFlight[scope] = nil } + return try await task.value + } + + private func performSync(scope: String, onBootstrapProgress: (@Sendable (BootstrapProgress) -> Void)?) async throws -> Int { + var cursor = try mirror.getCursor(scope: scope) + var identity = try mirror.getIdentity(scope: scope) + + if cursor == nil || identity == nil { + try mirror.clearRemoteMirrorPreservingLocal(scope: scope) + var afterId: String? + var loaded = 0 + var snapshotCursor = 0 + var snapshotIdentity = "legacy" + var firstPage = true + + while true { + let page = try await client.getMobileSyncBootstrapPage( + afterId: afterId, + limit: SyncProtocol.bootstrapPageSize + ) + if firstPage { + snapshotCursor = page.snapshotCursor + snapshotIdentity = page.syncIdentity ?? "legacy" + firstPage = false + } + let batches = SyncProtocol.splitBootstrapWriteBatches( + page.memos, + batchSize: SyncProtocol.bootstrapWriteBatchSize + ) + for (index, batch) in batches.enumerated() { + try mirror.applyBootstrapBatch( + scope: scope, + notebooks: index == 0 ? page.notebooks : nil, + memos: batch + ) + loaded += batch.count + onBootstrapProgress?(BootstrapProgress(loadedCount: loaded, totalCount: page.totalCount)) + } + guard let next = page.nextAfterId else { break } + afterId = next + } + cursor = snapshotCursor + identity = snapshotIdentity + try mirror.setCursor(scope: scope, cursor: snapshotCursor) + try mirror.setIdentity(scope: scope, identity: snapshotIdentity) + } + + var currentCursor = cursor ?? 0 + var currentIdentity = identity ?? "legacy" + + while true { + let page = try await client.getMobileSyncChanges( + cursor: currentCursor, + limit: SyncProtocol.changePageSize + ) + if SyncProtocol.hasCursorRewound(localCursor: currentCursor, serverCursor: page.serverCursor) + || SyncProtocol.hasIdentityChanged(localIdentity: currentIdentity, serverIdentity: page.syncIdentity) + { + try mirror.clearSyncCursor(scope: scope) + return try await performSync(scope: scope, onBootstrapProgress: onBootstrapProgress) + } + try mirror.applyChanges(scope: scope, changes: page.changes, cursor: page.cursor) + currentCursor = page.cursor + if !page.hasMore { + return currentCursor + } + } + } +} diff --git a/apps/ios/EdgeEver/Data/Sync/SyncProtocol.swift b/apps/ios/EdgeEver/Data/Sync/SyncProtocol.swift new file mode 100644 index 00000000..e08b541a --- /dev/null +++ b/apps/ios/EdgeEver/Data/Sync/SyncProtocol.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Port of `apps/mobile/src/lib/mobile-sync-protocol.ts`. +enum SyncProtocol { + static func hasCursorRewound(localCursor: Int, serverCursor: Int?) -> Bool { + guard let serverCursor else { return false } + return serverCursor < localCursor + } + + static func hasIdentityChanged(localIdentity: String, serverIdentity: String?) -> Bool { + guard let serverIdentity, !serverIdentity.isEmpty else { return false } + return serverIdentity != localIdentity + } + + static func isMetadataInitialized(cursorValue: String?, identityValue: String?) -> Bool { + guard + let identity = identityValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !identity.isEmpty, + let cursorValue, + !cursorValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let _ = Int(cursorValue.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + return false + } + return true + } + + static func splitBootstrapWriteBatches(_ items: [T], batchSize: Int) -> [[T]] { + let size = max(1, batchSize) + if items.isEmpty { return [[]] } + return stride(from: 0, to: items.count, by: size).map { + Array(items[$0 ..< min($0 + size, items.count)]) + } + } + + static func createDataScope(baseURL: URL, userId: String?) -> String { + let base = baseURL.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return "\(base)|\(userId ?? "anonymous")" + } + + static let bootstrapPageSize = 200 + static let bootstrapWriteBatchSize = 50 + static let changePageSize = 200 +} diff --git a/apps/ios/EdgeEver/DesignSystem/ChromeParity.swift b/apps/ios/EdgeEver/DesignSystem/ChromeParity.swift new file mode 100644 index 00000000..0df645b3 --- /dev/null +++ b/apps/ios/EdgeEver/DesignSystem/ChromeParity.swift @@ -0,0 +1,161 @@ +import SwiftUI + +// MARK: - Create / edit chrome (Android createMemo* tokens) + +/// Accessibility / role ids matching Android create memo shell. +enum CreateMemoChrome { + static let root = "createMemoSafeArea" + static let header = "createMemoHeader" + static let back = "createMemoBackButton" + static let status = "createMemoStatus" + static let done = "createMemoDoneButton" + static let title = "createMemoTitleInput" + static let metaRow = "createMemoMetaRow" + static let notebook = "createMemoNotebookButton" + static let tags = "createMemoTagsInput" + static let editorFrame = "createMemoEditorFrame" + static let imageTool = "createMemoImageTool" +} + +/// Save-status chip labels (Android create / rich-edit header status). +enum CreateSaveStatus: Equatable, Sendable { + case starting + case saved + case saving + case creating + case uploading + case error + + var labelZH: String { + switch self { + case .starting: return "正在启动" + case .saved: return "已保存" + case .saving: return "保存中" + case .creating: return "正在创建" + case .uploading: return "正在上传" + case .error: return "保存失败" + } + } + + var labelEN: String { + switch self { + case .starting: return "Starting" + case .saved: return "Saved" + case .saving: return "Saving" + case .creating: return "Creating" + case .uploading: return "Uploading" + case .error: return "Save failed" + } + } + + var isActive: Bool { + switch self { + case .saving, .creating, .uploading: return true + case .starting, .saved, .error: return false + } + } + + /// Derive chip state from editor lifecycle flags (same priority as Android). + static func derive( + editorReady: Bool, + isDirty: Bool, + isSaving: Bool, + isCreating: Bool, + isUploading: Bool, + hasError: Bool + ) -> CreateSaveStatus { + if isUploading { return .uploading } + if isCreating { return .creating } + if hasError { return .error } + if isSaving || isDirty { return .saving } + if editorReady { return .saved } + return .starting + } +} + +// MARK: - Detail chrome (Android detailHeader* / detailEditFab) + +enum DetailMemoChrome { + static let root = "detailSafeArea" + static let header = "detailHeader" + static let back = "detailHeaderBack" + static let syncStatus = "detailSyncStatus" + static let share = "detailHeaderShare" + static let history = "detailHeaderHistory" + static let search = "detailHeaderSearch" + static let more = "detailHeaderMore" + static let title = "detailTitle" + static let metaRow = "detailMetaRow" + static let notebook = "detailNotebook" + static let tags = "detailTags" + static let editFab = "detailEditFab" + static let body = "detailBody" +} + +/// Detail sync chip (Android `detailSyncStatus*` + label mapping). +enum DetailSyncStatus: Equatable, Sendable { + case synced + case pending + case syncing + case conflict + case error + + var labelZH: String { + switch self { + case .synced: return "已同步" + case .pending: return "待同步" + case .syncing: return "保存中" + case .conflict: return "同步冲突" + case .error: return "同步失败" + } + } + + var labelEN: String { + switch self { + case .synced: return "Synced" + case .pending: return "Pending" + case .syncing: return "Saving" + case .conflict: return "Conflict" + case .error: return "Sync failed" + } + } + + var isInteractive: Bool { + switch self { + case .conflict, .error, .pending: return true + case .synced, .syncing: return false + } + } + + /// Map outbox row + global sync flag to chip status (Android parity). + static func derive(outboxStatus: OutboxStatus?, isGlobalSyncing: Bool) -> DetailSyncStatus { + if isGlobalSyncing { return .syncing } + guard let outboxStatus else { return .synced } + switch outboxStatus { + case .pending: return .pending + case .syncing: return .syncing + case .conflict: return .conflict + case .error: return .error + } + } + + var background: Color { + switch self { + case .synced: return Color(hex: 0xF1F5F9) + case .pending: return Color(hex: 0xEFF6FF) + case .syncing: return Color(hex: 0xF1F5F9) + case .conflict: return Color(hex: 0xFFF7ED) + case .error: return Color(hex: 0xFEF2F2) + } + } + + var foreground: Color { + switch self { + case .synced: return Color(hex: 0x64748B) + case .pending: return Color(hex: 0x1D4ED8) + case .syncing: return Color(hex: 0x64748B) + case .conflict: return Color(hex: 0xC2410C) + case .error: return Color(hex: 0xB91C1C) + } + } +} diff --git a/apps/ios/EdgeEver/DesignSystem/Colors.swift b/apps/ios/EdgeEver/DesignSystem/Colors.swift new file mode 100644 index 00000000..3ac25928 --- /dev/null +++ b/apps/ios/EdgeEver/DesignSystem/Colors.swift @@ -0,0 +1,7 @@ +import SwiftUI + +enum EdgeEverColors { + /// Matches mobile brand green used in adaptive icon / marketing. + static let accent = Color(red: 0.098, green: 0.675, blue: 0.451) + static let splashBackground = Color(red: 0.851, green: 0.980, blue: 0.918) +} diff --git a/apps/ios/EdgeEver/DesignSystem/EditFabButton.swift b/apps/ios/EdgeEver/DesignSystem/EditFabButton.swift new file mode 100644 index 00000000..ab3cd07d --- /dev/null +++ b/apps/ios/EdgeEver/DesignSystem/EditFabButton.swift @@ -0,0 +1,44 @@ +import SwiftUI +import UIKit + +/// UIKit pencil FAB — WKWebView routinely steals hits from SwiftUI buttons in overlays. +struct EditFabButton: UIViewRepresentable { + var accessibilityLabel: String + var action: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + func makeUIView(context: Context) -> UIButton { + let button = UIButton(type: .system) + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: "pencil", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)) + config.baseForegroundColor = .white + config.baseBackgroundColor = UIColor(red: 0x10 / 255, green: 0xB9 / 255, blue: 0x81 / 255, alpha: 1) + config.cornerStyle = .capsule + config.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 14, bottom: 14, trailing: 14) + button.configuration = config + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.2 + button.layer.shadowRadius = 8 + button.layer.shadowOffset = CGSize(width: 0, height: 4) + button.accessibilityLabel = accessibilityLabel + button.accessibilityIdentifier = DetailMemoChrome.editFab + button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside) + // Always on top of sibling UIKit views inside the same hierarchy. + button.layer.zPosition = 10_000 + return button + } + + func updateUIView(_ button: UIButton, context: Context) { + context.coordinator.action = action + button.accessibilityLabel = accessibilityLabel + } + + final class Coordinator: NSObject { + var action: () -> Void + init(action: @escaping () -> Void) { self.action = action } + @objc func tapped() { action() } + } +} diff --git a/apps/ios/EdgeEver/DesignSystem/MobileUI.swift b/apps/ios/EdgeEver/DesignSystem/MobileUI.swift new file mode 100644 index 00000000..629906b6 --- /dev/null +++ b/apps/ios/EdgeEver/DesignSystem/MobileUI.swift @@ -0,0 +1,224 @@ +import Foundation +import UIKit + +/// Port of `@edgeever/shared/mobile-ui` — keep behavior identical to Android RN. +enum MobileUIMetrics { + static let bottomNavigationHeight: CGFloat = 52 + static let compactControlHeight: CGFloat = 36 + static let floatingCreateButtonSize: CGFloat = 52 + static let floatingSheetCornerRadius: CGFloat = 10 + static let minimumTouchTarget: CGFloat = 44 + + /// Home-indicator / gesture bar inset — same role as RN `useSafeAreaInsets().bottom`. + /// Used so bottom chrome height is `bottomNavigationHeight + bottomSafeInset` (Android parity). + static var bottomSafeInset: CGFloat { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + for scene in scenes { + if let key = scene.windows.first(where: \.isKeyWindow) { + return key.safeAreaInsets.bottom + } + } + return scenes.flatMap(\.windows).first?.safeAreaInsets.bottom ?? 0 + } + + /// Total bottom chrome height including home indicator (Android `height: 52 + safeAreaInsets.bottom`). + static var bottomChromeHeight: CGFloat { + bottomNavigationHeight + bottomSafeInset + } +} + +enum MobileMemoFilterMode: String, CaseIterable, Sendable { + case all + case tagged + case untagged + case pinned +} + +/// Matches Android `MobileMemoListDensity`: preview | compact. +enum ListDensity: String, CaseIterable, Sendable { + case preview + case compact + + var showsExcerpt: Bool { self == .preview } + var cardMinHeight: CGFloat { self == .compact ? 84 : 132 } + var cardPadding: CGFloat { self == .compact ? 13 : 16 } + var cardBottomMargin: CGFloat { self == .compact ? 10 : 12 } + var metaTop: CGFloat { self == .compact ? 8 : 20 } +} + +enum MobileUI { + /// Toggle filter chip: pressing the active filter returns to `all`. + static func toggleMemoFilterMode( + current: MobileMemoFilterMode, + requested: MobileMemoFilterMode + ) -> MobileMemoFilterMode { + guard requested != .all else { return .all } + return current == requested ? .all : requested + } + + static func toggleMemoSelection(current: Set, memoId: String) -> Set { + var next = current + if next.contains(memoId) { + next.remove(memoId) + } else { + next.insert(memoId) + } + return next + } +} + +struct NotebookTreeItem: Identifiable, Equatable { + var id: String + var name: String + var parentId: String? + var depth: Int + var memoCount: Int +} + +enum NotebookHierarchy { + /// Port of `getNotebookDescendantIds` from packages/shared notebooks.ts + static func descendantIds(notebooks: [Notebook], targetNotebookId: String) -> [String] { + var childrenByParent: [String: [String]] = [:] + for notebook in notebooks { + guard let parentId = notebook.parentId else { continue } + childrenByParent[parentId, default: []].append(notebook.id) + } + var descendantIds: [String] = [] + var visited = Set() + var pending = [targetNotebookId] + while let notebookId = pending.popLast() { + if visited.contains(notebookId) { continue } + visited.insert(notebookId) + descendantIds.append(notebookId) + pending.append(contentsOf: childrenByParent[notebookId] ?? []) + } + return descendantIds + } + + static func treeItems(from notebooks: [Notebook]) -> [NotebookTreeItem] { + let byParent = Dictionary(grouping: notebooks) { $0.parentId ?? "" } + var items: [NotebookTreeItem] = [] + func walk(parentId: String?, depth: Int) { + let key = parentId ?? "" + let children = (byParent[key] ?? []).sorted { + if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + for child in children { + items.append( + NotebookTreeItem( + id: child.id, + name: child.name, + parentId: child.parentId, + depth: depth, + memoCount: child.memoCount + ) + ) + walk(parentId: child.id, depth: depth + 1) + } + } + walk(parentId: nil, depth: 0) + return items + } + + /// Port of `getMobileNotebookSearchVisibleIds` + static func searchVisibleIds(notebooks: [Notebook], searchText: String) -> Set { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let byId = Dictionary(uniqueKeysWithValues: notebooks.map { ($0.id, $0) }) + if query.isEmpty { + return Set(byId.keys) + } + var childrenByParent: [String: [String]] = [:] + for notebook in notebooks { + guard let parentId = notebook.parentId, byId[parentId] != nil else { continue } + childrenByParent[parentId, default: []].append(notebook.id) + } + var visible = Set() + func includeDescendants(_ id: String, visited: inout Set) { + if visited.contains(id) { return } + visited.insert(id) + visible.insert(id) + for child in childrenByParent[id] ?? [] { + includeDescendants(child, visited: &visited) + } + } + for notebook in notebooks where notebook.name.lowercased().contains(query) { + var visited = Set() + includeDescendants(notebook.id, visited: &visited) + var parentId = notebook.parentId + var seen: Set = [notebook.id] + while let pid = parentId, !seen.contains(pid) { + seen.insert(pid) + visible.insert(pid) + parentId = byId[pid]?.parentId + } + } + return visible + } + + /// Parent ids that have at least one child (collapse targets). + static func parentIdsWithChildren(from notebooks: [Notebook]) -> Set { + var parents = Set() + let ids = Set(notebooks.map(\.id)) + for notebook in notebooks { + if let parentId = notebook.parentId, ids.contains(parentId) { + parents.insert(parentId) + } + } + return parents + } + + /// Ancestors of `notebookId` (not including itself). + static func ancestorIds(notebooks: [Notebook], notebookId: String) -> Set { + let byId = Dictionary(uniqueKeysWithValues: notebooks.map { ($0.id, $0) }) + var ancestors = Set() + var current = byId[notebookId] + while let parentId = current?.parentId { + ancestors.insert(parentId) + current = byId[parentId] + } + return ancestors + } + + /// Port of `filterCollapsedNotebookOptions`. + static func filterCollapsed(items: [NotebookTreeItem], collapsedIds: Set) -> [NotebookTreeItem] { + if collapsedIds.isEmpty { return items } + var visible: [NotebookTreeItem] = [] + var hiddenDepth: Int? + for item in items { + if let hiddenDepth, item.depth > hiddenDepth { + continue + } + hiddenDepth = nil + visible.append(item) + if collapsedIds.contains(item.id) { + hiddenDepth = item.depth + } + } + return visible + } +} + +enum MemoPreviewDate { + /// Port of Android `formatMemoPreviewDate`: today → time, yesterday → 昨天, else y/m/d. + static func format(_ iso: String, locale: Locale = .current, isEnglish: Bool = false) -> String { + let parsers = [ISO8601DateFormatter.edgeEver, ISO8601DateFormatter.edgeEverFallback] + guard let date = parsers.compactMap({ $0.date(from: iso) }).first else { return "" } + let calendar = Calendar.current + let now = Date() + if calendar.isDateInToday(date) { + let f = DateFormatter() + f.locale = locale + f.dateStyle = .none + f.timeStyle = .short + return f.string(from: date) + } + if calendar.isDateInYesterday(date) { + return isEnglish ? "Yesterday" : "昨天" + } + let f = DateFormatter() + f.locale = locale + f.setLocalizedDateFormatFromTemplate("yMd") + return f.string(from: date) + } +} diff --git a/apps/ios/EdgeEver/DesignSystem/Motion.swift b/apps/ios/EdgeEver/DesignSystem/Motion.swift new file mode 100644 index 00000000..2fa529be --- /dev/null +++ b/apps/ios/EdgeEver/DesignSystem/Motion.swift @@ -0,0 +1,112 @@ +import SwiftUI +import Pow + +/// Shared motion language for EdgeEver iOS. +/// +/// Strategy: +/// - **Native SwiftUI** springs / transitions for layout, sheets, navigation +/// - **Pow** (Emerge Tools) for polished micro-interactions (press feedback, ping, shake, shine) +/// +/// Mirrors Android Reanimated usage: card press scale, subtle feedback — not heavy list enter animations +/// (Android intentionally avoids Fabric layout enter effects on list cards). +enum Motion { + /// Card press-in: Android `withTiming(0.985, { duration: 100 })` + static let cardPressIn = Animation.easeOut(duration: 0.10) + /// Card press-out: Android `withTiming(1, { duration: 160 })` + static let cardPressOut = Animation.easeOut(duration: 0.16) + + /// Filter / chip selection + static let chip = Animation.spring(response: 0.32, dampingFraction: 0.78) + + /// Bottom-nav create button + static let createButton = Animation.spring(response: 0.28, dampingFraction: 0.72) + + /// Search bar expand / constraint banner + static let search = Animation.spring(response: 0.36, dampingFraction: 0.86) + + /// List content changes (filter/search/sync refresh) + static let listContent = Animation.spring(response: 0.40, dampingFraction: 0.88) + + /// Settings / sheet presentation polish + static let sheet = Animation.spring(response: 0.42, dampingFraction: 0.90) + + // MARK: - Pow transitions (list / panel) + + /// Subtle move+fade when a card appears after filter/search — not a full enter cascade. + static var cardAppear: AnyTransition { + .movingParts.move(angle: .degrees(8)).combined(with: .opacity) + } + + static var panelAppear: AnyTransition { + .movingParts.anvil.combined(with: .opacity) + } + + static var softFade: AnyTransition { + .opacity.combined(with: .scale(scale: 0.98)) + } +} + +// MARK: - Reusable modifiers + +/// Android-style memo card press scale using explicit timing curves. +struct MemoCardPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.985 : 1) + .animation( + configuration.isPressed ? Motion.cardPressIn : Motion.cardPressOut, + value: configuration.isPressed + ) + } +} + +/// Bottom-nav create (+) bounce on press. +struct CreateButtonPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(Motion.createButton, value: configuration.isPressed) + } +} + +/// Circular filter chip with Pow ping when activated. +struct FilterChipButtonStyle: ButtonStyle { + var active: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(Motion.chip, value: configuration.isPressed) + .animation(Motion.chip, value: active) + } +} + +extension View { + /// Haptic selection tick when `value` changes (Pow). + func edgeEverSelectionFeedback(_ value: V) -> some View { + changeEffect(.feedbackHapticSelection, value: value) + } + + /// Soft shine when a boolean toggles true (e.g. pin). + func edgeEverSuccessShine(trigger: Bool) -> some View { + changeEffect(.shine, value: trigger, isEnabled: trigger) + } + + /// Jump micro-bounce when value changes (e.g. create success / sync done). + func edgeEverJump(on value: some Equatable, height: CGFloat = 6) -> some View { + changeEffect(.jump(height: height), value: value) + } + + /// Shake on error flag rising. + func edgeEverErrorShake(on error: String?) -> some View { + changeEffect(.shake, value: error ?? "", isEnabled: error != nil && !(error?.isEmpty ?? true)) + } + + /// Ping ring on the create button when tapped count increases. + func edgeEverCreatePing(count: Int) -> some View { + changeEffect( + .ping(shape: Circle(), style: AppTheme.accentBright.opacity(0.45), count: 2), + value: count + ) + } +} diff --git a/apps/ios/EdgeEver/DesignSystem/Theme.swift b/apps/ios/EdgeEver/DesignSystem/Theme.swift new file mode 100644 index 00000000..53e268a0 --- /dev/null +++ b/apps/ios/EdgeEver/DesignSystem/Theme.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Exact visual tokens from Android `workspace-styles.ts`. +enum AppTheme { + // Surfaces + static let background = Color(hex: 0xF8FAFC) + static let card = Color.white + static let cardBorder = Color(hex: 0xF1F5F9) + static let border = Color(hex: 0xE2E8F0) + static let searchFill = Color(hex: 0xF1F5F9) + static let searchActiveFill = Color(hex: 0xECFDF5) + + // Text + static let title = Color(hex: 0x0F172A) + static let body = Color(hex: 0x0F172A) + static let secondary = Color(hex: 0x64748B) + static let meta = Color(hex: 0x334155) + static let muted = Color(hex: 0x94A3B8) + static let slate = Color(hex: 0x475569) + static let filterActive = Color(hex: 0x334155) + + // Brand + static let accent = Color(hex: 0x059669) + static let accentStrong = Color(hex: 0x047857) + static let accentBright = Color(hex: 0x10B981) + static let accentSoft = Color(hex: 0xECFDF5) + static let accentBorder = Color(hex: 0xA7F3D0) + static let accentText = Color(hex: 0x065F46) + static let danger = Color(hex: 0xDC2626) + + // Tag chip + static let tagBackground = Color(hex: 0xF1F5F9) + + static let fabShadow = Color(hex: 0x10B981).opacity(0.28) + + // Typography helpers matching RN sizes + static let notebookTitleFont = Font.system(size: 17, weight: .bold) + static let memoTitleFont = Font.system(size: 16, weight: .bold) + static let memoExcerptFont = Font.system(size: 14) + static let memoDateFont = Font.system(size: 12, weight: .medium) + static let tagFont = Font.system(size: 12, weight: .medium) + static let searchFont = Font.system(size: 14) + static let bottomNavFont = Font.system(size: 11, weight: .bold) +} + +extension Color { + init(hex: UInt32, opacity: Double = 1) { + let r = Double((hex >> 16) & 0xFF) / 255 + let g = Double((hex >> 8) & 0xFF) / 255 + let b = Double(hex & 0xFF) / 255 + self.init(.sRGB, red: r, green: g, blue: b, opacity: opacity) + } +} diff --git a/apps/ios/EdgeEver/DesignSystem/Typography.swift b/apps/ios/EdgeEver/DesignSystem/Typography.swift new file mode 100644 index 00000000..0be81ee0 --- /dev/null +++ b/apps/ios/EdgeEver/DesignSystem/Typography.swift @@ -0,0 +1,7 @@ +import SwiftUI + +enum EdgeEverTypography { + static let title = Font.title2.weight(.semibold) + static let body = Font.body + static let caption = Font.caption +} diff --git a/apps/ios/EdgeEver/Editor/ResourceActions.swift b/apps/ios/EdgeEver/Editor/ResourceActions.swift new file mode 100644 index 00000000..ed2a6972 --- /dev/null +++ b/apps/ios/EdgeEver/Editor/ResourceActions.swift @@ -0,0 +1,90 @@ +import Foundation +import SwiftUI + +struct ResourceTarget: Equatable, Hashable { + var kind: String + var href: String + var filename: String + var resourceId: String + + static func parse(_ json: String) -> ResourceTarget? { + guard + let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let href = obj["href"] as? String, + let filename = obj["filename"] as? String, + let kind = obj["kind"] as? String + else { return nil } + let id = (obj["resourceId"] as? String) ?? href.split(separator: "/").map(String.init).last ?? "" + return ResourceTarget(kind: kind, href: href, filename: filename, resourceId: id) + } +} + +struct IdentifiedResource: Identifiable { + let id = UUID() + let value: ResourceTarget + init(_ value: ResourceTarget) { self.value = value } +} + +struct ResourceActionSheet: View { + @Environment(AppEnvironment.self) private var env + let target: ResourceTarget + var onDone: () -> Void + + @State private var renameTo = "" + @State private var error: String? + @State private var busy = false + + var body: some View { + NavigationStack { + Form { + LabeledContent("类型", value: target.kind) + LabeledContent("文件名", value: target.filename) + TextField("重命名", text: $renameTo) + if let error { + Text(error).foregroundStyle(.red).font(.caption) + } + Button("重命名") { + Task { await rename() } + } + .disabled(busy || renameTo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("删除资源", role: .destructive) { + Task { await delete() } + } + .disabled(busy) + } + .navigationTitle("附件") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { onDone() } + } + } + .onAppear { renameTo = target.filename } + } + } + + private func rename() async { + busy = true + defer { busy = false } + do { + _ = try await env.session.client.renameResource( + id: target.resourceId, + filename: renameTo.trimmingCharacters(in: .whitespacesAndNewlines) + ) + onDone() + } catch { + self.error = error.localizedDescription + } + } + + private func delete() async { + busy = true + defer { busy = false } + do { + try await env.session.client.deleteResource(id: target.resourceId) + onDone() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/apps/ios/EdgeEver/Editor/TipTapWebView.swift b/apps/ios/EdgeEver/Editor/TipTapWebView.swift new file mode 100644 index 00000000..5b6478de --- /dev/null +++ b/apps/ios/EdgeEver/Editor/TipTapWebView.swift @@ -0,0 +1,327 @@ +import SwiftUI +import WebKit + +enum TipTapMode: String { + case viewer + case editor +} + +struct TipTapWebView: UIViewRepresentable { + let mode: TipTapMode + let documentJSON: String + let markdown: String + let baseURL: URL? + let token: String? + let onChange: ((String, String) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + let userContent = config.userContentController + userContent.add(context.coordinator, name: "edgeever") + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + webView.isOpaque = false + webView.backgroundColor = .systemBackground + // Keep scroll gestures inside the editor so overlay FAB taps are not swallowed. + webView.scrollView.clipsToBounds = true + webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.scrollView.delaysContentTouches = false + context.coordinator.webView = webView + context.coordinator.loadEditor(into: webView) + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + context.coordinator.parent = self + context.coordinator.applyMode() + context.coordinator.pushContentIfNeeded() + } + + final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + var parent: TipTapWebView + weak var webView: WKWebView? + private var ready = false + /// Last content fingerprint we pushed INTO the web editor (native → JS). + private var lastPushedJSON: String? + /// Last content fingerprint the editor emitted TO native (JS → native). + /// Used to avoid re-setContent on every keystroke (which jumps the caret). + private var lastEditorEmittedFingerprint: String? + private var lastAppliedMode: String? + private let resourceCache = ResourceCache() + + init(_ parent: TipTapWebView) { + self.parent = parent + } + + func loadEditor(into webView: WKWebView) { + // Prefer packaged TipTap bundle (EditorBundle/ or root index.html). + // If missing, Markdown will not render (fallback is plain-text only). + if let bundleURL = Self.packagedEditorHTMLURL() { + let dir = bundleURL.deletingLastPathComponent() + #if DEBUG + print("TipTapWebView: loading packaged editor \(bundleURL.path) size=\((try? Data(contentsOf: bundleURL))?.count ?? -1)") + #endif + webView.loadFileURL(bundleURL, allowingReadAccessTo: dir) + } else { + #if DEBUG + print("TipTapWebView: WARNING packaged EditorBundle missing — using plain-text fallback") + #endif + webView.loadHTMLString(Self.fallbackHTML, baseURL: Bundle.main.bundleURL) + } + } + + /// Resolves the shipped TipTap `index.html` (must be >100KB real bundle, not a stub). + static func packagedEditorHTMLURL() -> URL? { + let candidates: [URL?] = [ + Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "EditorBundle"), + Bundle.main.url(forResource: "index", withExtension: "html"), + ] + for case let url? in candidates { + if let values = try? url.resourceValues(forKeys: [.fileSizeKey]), + let size = values.fileSize, + size > 100_000 + { + return url + } + // Size unknown — still try if path exists + if FileManager.default.fileExists(atPath: url.path) { + return url + } + } + return nil + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + ready = true + applyMode() + pushContentIfNeeded(force: true) + } + + func applyMode() { + guard ready, let webView else { return } + let mode = parent.mode.rawValue + // Only re-configure when mode actually changes — avoid re-focus storms. + if lastAppliedMode == mode { return } + lastAppliedMode = mode + let js = """ + (function(){ + if (!window.EdgeEverEditor) return; + window.EdgeEverEditor.configure({ mode: '\(mode)', locale: 'zh-CN', theme: 'light' }); + })(); + """ + webView.evaluateJavaScript(js, completionHandler: nil) + } + + private func contentFingerprint() -> (fingerprint: String, useJSON: Bool) { + let emptyStub = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}" + let json = parent.documentJSON.trimmingCharacters(in: .whitespacesAndNewlines) + let useJSON = !json.isEmpty && json != emptyStub + if useJSON { + return ("json:\(json)", true) + } + return ("md:\(parent.markdown)", false) + } + + func pushContentIfNeeded(force: Bool = false) { + guard ready, let webView else { return } + let (fingerprint, useJSON) = contentFingerprint() + // Skip if we already pushed this, OR if the editor just told us this content + // (typing path: SwiftUI state updates must not re-setContent and kill the caret). + if !force { + if fingerprint == lastPushedJSON { return } + if fingerprint == lastEditorEmittedFingerprint { return } + } + lastPushedJSON = fingerprint + + // Ensure mode is applied once before first content (no focusEnd — user places caret). + if lastAppliedMode != parent.mode.rawValue { + applyMode() + } + + let js: String + if useJSON { + js = Self.jsCall(fn: "setDocumentFromJSON", arg: parent.documentJSON) + } else { + js = Self.jsCall(fn: "setMarkdown", arg: parent.markdown) + } + webView.evaluateJavaScript(js, completionHandler: { _, error in + #if DEBUG + if let error { + print("TipTapWebView pushContent error: \(error)") + } + #endif + }) + } + + /// Call `window.EdgeEverEditor.fn(string)` with base64 payload (UTF-8 safe). + private static func jsCall(fn: String, arg: String) -> String { + let b64 = Data(arg.utf8).base64EncodedString() + return """ + (function(){ + try { + if (!window.EdgeEverEditor) return; + var bin = atob('\(b64)'); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + var text = new TextDecoder('utf-8').decode(bytes); + window.EdgeEverEditor.\(fn)(text); + } catch (e) { + try { window.webkit.messageHandlers.edgeever.postMessage({type:'error', message: String(e)}); } catch (_) {} + } + })(); + """ + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == "edgeever", + let body = message.body as? [String: Any], + let type = body["type"] as? String + else { return } + + switch type { + case "ready": + ready = true + lastAppliedMode = nil // force re-apply mode after reload + pushContentIfNeeded(force: true) + case "change": + let md = body["contentMarkdown"] as? String ?? "" + let json = body["contentJson"] as? String ?? parent.documentJSON + // Mark as editor-originated so updateUIView does not re-push and reset caret. + let emptyStub = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}" + let j = json.trimmingCharacters(in: .whitespacesAndNewlines) + let emitted = (!j.isEmpty && j != emptyStub) ? "json:\(j)" : "md:\(md)" + lastEditorEmittedFingerprint = emitted + lastPushedJSON = emitted + parent.onChange?(md, json) + case "loadResource": + let requestId = body["requestId"] as? String ?? "" + let source = body["source"] as? String ?? "" + Task { await resolveResource(requestId: requestId, source: source) } + case "resourcePress": + // Surface to native via notification; detail/edit can observe later. + if let targetJson = body["targetJson"] as? String { + NotificationCenter.default.post( + name: .edgeEverResourcePress, + object: nil, + userInfo: ["targetJson": targetJson] + ) + } + case "error": + break + default: + break + } + } + + private func resolveResource(requestId: String, source: String) async { + guard let webView else { return } + var dataURL: String? + if source.hasPrefix("data:") || source.hasPrefix("http://") || source.hasPrefix("https://") { + // Remote absolute — try fetch with token if relative API path style + dataURL = source + } + if ResourceCache.isProtectedResourceSource(source, baseURL: parent.baseURL), + let token = parent.token, + let base = parent.baseURL + { + let client = APIClient(baseURL: base, token: token) + let path = ResourceCache.normalizeProtectedResourcePath(source, baseURL: base) + let id = ResourceCache.resourceId(from: path) ?? path + if let cached = await resourceCache.cachedData(for: id) { + dataURL = try? await resourceCache.dataURL(for: id, data: cached, mimeType: "image/jpeg") + } else if let data = try? await client.getResourceData(path: path) { + dataURL = try? await resourceCache.dataURL(for: id, data: data, mimeType: "image/jpeg") + } + } + let payload = dataURL ?? "" + let escaped = payload + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + let js = "window.EdgeEverEditor && window.EdgeEverEditor.resolveResource('\(requestId)', '\(escaped)');" + await MainActor.run { + webView.evaluateJavaScript(js, completionHandler: nil) + } + } + + /// Minimal contenteditable + markdown-ish bridge when EditorBundle is missing. + static let fallbackHTML = """ + + + + + + + +
+ + + + """ + } +} diff --git a/apps/ios/EdgeEver/Features/Auth/LoginView.swift b/apps/ios/EdgeEver/Features/Auth/LoginView.swift new file mode 100644 index 00000000..615291be --- /dev/null +++ b/apps/ios/EdgeEver/Features/Auth/LoginView.swift @@ -0,0 +1,201 @@ +import SwiftUI +import Pow + +struct LoginView: View { + @Environment(AppEnvironment.self) private var env + @State private var baseUrl = "" + @State private var username = "admin" + @State private var password = "" + @State private var error: String? + @State private var submitting = false + @State private var showHTTPWarning = false + @State private var pendingHTTPLogin = false + @State private var loginShake = 0 + + private var canSubmit: Bool { + !baseUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !password.isEmpty + && !submitting + } + + var body: some View { + ZStack(alignment: .topTrailing) { + // Android login uses mint background #ecfdf5 + AppTheme.accentSoft + .ignoresSafeArea() + + Link(destination: URL(string: "https://github.com/tianma-if/edgeever")!) { + Image(systemName: "arrow.up.right.square") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + .frame(width: 42, height: 42) + .background(Color.white) + .clipShape(Circle()) + .overlay(Circle().stroke(AppTheme.border, lineWidth: 1)) + } + .padding(.trailing, 18) + .padding(.top, 18) + .zIndex(2) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 72) + + // Header row: logo + title + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AppTheme.accent) + .frame(width: 48, height: 48) + Image(systemName: "lock.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 4) { + Text("EdgeEver") + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(AppTheme.title) + Text(env.preferences.t("连接你的自托管笔记空间", en: "Connect your self-hosted notes")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.secondary) + } + } + .padding(.bottom, 28) + + field( + env.preferences.t("实例地址", en: "Instance URL"), + placeholder: "https://notes.example.com", + text: $baseUrl, + keyboard: .URL, + secure: false + ) + .padding(.bottom, 14) + + field( + env.preferences.t("用户名", en: "Username"), + placeholder: "owner", + text: $username, + keyboard: .default, + secure: false + ) + .padding(.bottom, 14) + + field( + env.preferences.t("密码", en: "Password"), + placeholder: env.preferences.t("首次登录密码", en: "Initial password"), + text: $password, + keyboard: .default, + secure: true + ) + .padding(.bottom, 16) + + if let error { + Text(error) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.danger) + .padding(.bottom, 10) + .transition(Motion.softFade) + .edgeEverErrorShake(on: error) + } + + Button { + Task { await submit() } + } label: { + HStack(spacing: 8) { + if submitting { + ProgressView().tint(.white) + } + Text(submitting + ? env.preferences.t("登录中…", en: "Signing in…") + : env.preferences.t("登录", en: "Sign in")) + .font(.system(size: 16, weight: .bold)) + } + .frame(maxWidth: .infinity) + .frame(height: 48) + .foregroundStyle(.white) + .background(canSubmit ? AppTheme.title : Color(hex: 0xCBD5E1)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(CreateButtonPressStyle()) + .disabled(!canSubmit) + .changeEffect(.shake, value: loginShake) + } + .padding(.horizontal, 22) + .padding(.bottom, 40) + } + } + .alert(env.preferences.t("使用 HTTP 连接?", en: "Use HTTP?"), isPresented: $showHTTPWarning) { + Button(env.preferences.t("继续", en: "Continue"), role: .destructive) { + pendingHTTPLogin = true + Task { await submit(forceHTTP: true) } + } + Button(env.preferences.t("取消", en: "Cancel"), role: .cancel) {} + } message: { + Text(env.preferences.t( + "HTTP 不会加密传输凭证,仅建议用于可信局域网自托管实例。", + en: "HTTP does not encrypt credentials. Only use on trusted LAN instances." + )) + } + .preferredColorScheme(env.preferences.colorScheme) + } + + private func field( + _ title: String, + placeholder: String, + text: Binding, + keyboard: UIKeyboardType, + secure: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(AppTheme.meta) + Group { + if secure { + SecureField(placeholder, text: text) + } else { + TextField(placeholder, text: text) + .keyboardType(keyboard) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + .font(.system(size: 15)) + .foregroundStyle(AppTheme.title) + .padding(.horizontal, 14) + .frame(height: 48) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + } + } + + private func submit(forceHTTP: Bool = false) async { + error = nil + let trimmed = baseUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if !forceHTTP && !pendingHTTPLogin { + if let url = try? EdgeEverURLNormalizer.normalizeInstanceURL(trimmed), + url.scheme?.lowercased() == "http" + { + showHTTPWarning = true + return + } + } + pendingHTTPLogin = false + submitting = true + defer { submitting = false } + do { + try await env.session.signIn(baseUrl: trimmed, username: username, password: password) + await env.runSyncCycle() + } catch { + withAnimation(Motion.chip) { + self.error = error.localizedDescription + } + loginShake += 1 + } + } +} diff --git a/apps/ios/EdgeEver/Features/Share/ShareHandoffStore.swift b/apps/ios/EdgeEver/Features/Share/ShareHandoffStore.swift new file mode 100644 index 00000000..751ad12e --- /dev/null +++ b/apps/ios/EdgeEver/Features/Share/ShareHandoffStore.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Reads share payloads written by Share Extension via App Group. +@MainActor +final class ShareHandoffStore { + static let appGroupId = "group.org.edgeever.mobile" + static let payloadKey = "edgeever.share.payload" + + struct SharePayload: Codable, Equatable { + var text: String? + var url: String? + var title: String? + } + + func consumePending() -> [SharePayload] { + guard let defaults = UserDefaults(suiteName: Self.appGroupId) else { return [] } + guard let data = defaults.data(forKey: Self.payloadKey) else { return [] } + defaults.removeObject(forKey: Self.payloadKey) + if let typed = try? EdgeEverJSON.decoder.decode([SharePayload].self, from: data) { + return typed + } + // Share extension may write JSONSerialization dictionaries with optional nulls. + guard let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [] + } + return raw.map { dict in + SharePayload( + text: dict["text"] as? String, + url: dict["url"] as? String, + title: dict["title"] as? String + ) + } + } + + static func writeForExtension(_ payloads: [SharePayload]) { + guard let defaults = UserDefaults(suiteName: appGroupId) else { return } + guard let data = try? EdgeEverJSON.encoder.encode(payloads) else { return } + defaults.set(data, forKey: payloadKey) + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/ConflictResolutionView.swift b/apps/ios/EdgeEver/Features/Workspace/ConflictResolutionView.swift new file mode 100644 index 00000000..2a20d48d --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/ConflictResolutionView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// Minimal conflict UI: discard local queue item and rehydrate from server. +struct ConflictResolutionView: View { + @Environment(AppEnvironment.self) private var env + let item: OutboxItem + var onResolved: () -> Void + + @State private var busy = false + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("同步冲突").font(.headline) + Text("本地草稿与服务器版本不一致。你可以丢弃本地更改并采用云端内容,或稍后再处理。") + .font(.subheadline) + .foregroundStyle(.secondary) + if let err = item.lastError { + Text(err).font(.caption).foregroundStyle(.red) + } + if let error { + Text(error).font(.caption).foregroundStyle(.red) + } + HStack { + Button("丢弃本地") { + Task { await discardLocal() } + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(busy) + Button("稍后") { onResolved() } + .disabled(busy) + } + } + .padding() + } + + private func discardLocal() async { + guard let scope = env.session.dataScope else { return } + busy = true + defer { busy = false } + do { + let remote = try await env.session.client.getMemo(id: item.memoId, includeDeleted: true) + _ = try env.outbox.remove(scope: scope, id: item.id, expectedVersion: nil) + try env.mirror.upsertMemo(scope: scope, memo: remote) + try env.drafts.clear(scope: scope, key: DraftRepository.memoKey(item.memoId)) + onResolved() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/ListActionsSheet.swift b/apps/ios/EdgeEver/Features/Workspace/ListActionsSheet.swift new file mode 100644 index 00000000..8fd3ae90 --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/ListActionsSheet.swift @@ -0,0 +1,202 @@ +import SwiftUI + +/// Android `NotesActionsModal` parity: floating sheet with handle, subtitle, density + sort options. +struct ListActionsSheet: View { + @Environment(AppEnvironment.self) private var env + @Bindable var store: WorkspaceStore + @Environment(\.dismiss) private var dismiss + + private var listTitle: String { store.titleLabel } + private var listDescription: String { + env.preferences.t("\(store.totalCount) 条笔记", en: "\(store.totalCount) notes") + } + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(hex: 0xCBD5E1)) + .frame(width: 42, height: 4) + .padding(.top, 10) + .padding(.bottom, 8) + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(env.preferences.t("列表选项", en: "List options")) + .font(.system(size: 15, weight: .heavy)) + .foregroundStyle(AppTheme.title) + Text("\(listTitle) · \(listDescription)") + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .frame(width: 38, height: 38) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .frame(minHeight: 48) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !store.selectionMode { + sheetItem( + icon: "checkmark.square", + label: env.preferences.t("选择笔记", en: "Select notes"), + disabled: store.memos.isEmpty + ) { + store.enterSelection() + dismiss() + } + divider + } + + sectionTitle(env.preferences.t("显示方式", en: "Display")) + optionRow( + active: env.preferences.listDensity == .preview, + icon: "doc.text", + label: env.preferences.t("预览列表", en: "Preview list") + ) { + env.preferences.listDensity = .preview + dismiss() + } + optionRow( + active: env.preferences.listDensity == .compact, + icon: "list.bullet", + label: env.preferences.t("紧凑列表", en: "Compact list") + ) { + env.preferences.listDensity = .compact + dismiss() + } + + divider + sectionTitle(env.preferences.t("排序方式", en: "Sort by")) + optionRow( + active: store.sort == .updatedDesc, + icon: nil, + label: env.preferences.t("最近更新", en: "Recently updated") + ) { + store.sort = .updatedDesc + store.reload(env: env) + dismiss() + } + optionRow( + active: store.sort == .createdDesc, + icon: nil, + label: env.preferences.t("创建时间", en: "Created time") + ) { + store.sort = .createdDesc + store.reload(env: env) + dismiss() + } + optionRow( + active: store.sort == .titleAsc, + icon: nil, + label: env.preferences.t("标题 A-Z", en: "Title A-Z") + ) { + store.sort = .titleAsc + store.reload(env: env) + dismiss() + } + + divider + sheetItem( + icon: "arrow.clockwise", + label: env.preferences.t("立即同步", en: "Sync now"), + disabled: false + ) { + Task { + await env.runSyncCycle() + store.reload(env: env) + dismiss() + } + } + } + .padding(8) + } + } + .background(Color.white) + .presentationDetents([.medium]) + .presentationDragIndicator(.hidden) + .presentationCornerRadius(MobileUIMetrics.floatingSheetCornerRadius) + } + + private var divider: some View { + Rectangle() + .fill(AppTheme.cardBorder) + .frame(height: 1) + .padding(.vertical, 8) + } + + private func sectionTitle(_ text: String) -> some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + private func sheetItem(icon: String, label: String, disabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .frame(width: 22) + Text(label) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.title) + Spacer() + } + .padding(.horizontal, 10) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .opacity(disabled ? 0.4 : 1) + } + + private func optionRow(active: Bool, icon: String?, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 10) { + if let icon { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(active ? AppTheme.accentBright : AppTheme.secondary) + .frame(width: 22) + } + Text(label) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(active ? AppTheme.accentStrong : AppTheme.title) + Spacer() + ZStack { + Circle() + .fill(active ? AppTheme.accentBright : Color.clear) + .frame(width: 22, height: 22) + if active { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } + } + .opacity(active ? 1 : 0) + } + .padding(.horizontal, 10) + .frame(minHeight: 44) + .background(active ? AppTheme.accentSoft.opacity(0.55) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift b/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift new file mode 100644 index 00000000..e01a51dd --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/MemoCreateCommit.swift @@ -0,0 +1,134 @@ +import Foundation + +/// Shipped create-Done path for `MemoEditView` (Android `createMutation` parity). +/// +/// After image upload, `materializeForImage` already creates a **server** memo and sets +/// `memoId`. Done must **update** that memo — never mint a second `local:` + create. +enum MemoCreateCommit { + enum Outcome: Equatable, Sendable { + case createdLocal(memoId: String) + case updatedMaterialized(memoId: String) + } + + /// Server id from materialize (not offline `local:` placeholder). + static func isMaterializedServerId(_ memoId: String?) -> Bool { + guard let memoId else { return false } + let trimmed = memoId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return !trimmed.hasPrefix("local:") + } + + /// Commit create modal content. + /// - If `memoId` is a materialized server id → optimistic mirror update + outbox update. + /// - Else → offline-first `local:` create + outbox create (Android temporaryId path). + @discardableResult + static func commit( + scope: String, + memoId: String?, + expectedRevision: Int?, + expectedContentHash: String?, + notebookId: String, + title: String, + contentMarkdown: String, + contentJSON: String?, + tags: [String], + mirror: LocalMirrorRepository, + outbox: SyncOutboxRepository, + drafts: DraftRepository, + now: String = EdgeEverDate.nowString() + ) throws -> Outcome { + guard !notebookId.isEmpty else { + throw APIError(status: 400, code: nil, message: "请选择笔记本") + } + let displayTitle = title.isEmpty ? "无标题笔记" : title + + if isMaterializedServerId(memoId), let serverId = memoId { + guard var memo = try mirror.resolveMemo(scope: scope, id: serverId) else { + // Mirror miss: still queue update against known server id (Android upserts optimistic). + var placeholder = MemoDetail.localPlaceholder( + id: serverId, + notebookId: notebookId, + title: displayTitle, + contentMarkdown: contentMarkdown, + tags: tags, + createdAt: now + ) + placeholder.revision = expectedRevision ?? 0 + placeholder.contentHash = expectedContentHash ?? "local:\(serverId)" + if let contentJSON, let json = try? JSONValue.parse(contentJSON) { + placeholder.contentJson = json + } + try mirror.upsertMemo(scope: scope, memo: placeholder) + try outbox.enqueueUpdate( + scope: scope, + payload: MemoUpdatePayload( + memoId: serverId, + expectedRevision: expectedRevision ?? 0, + expectedContentHash: expectedContentHash ?? placeholder.contentHash, + title: displayTitle, + contentMarkdown: contentMarkdown, + notebookId: notebookId, + tags: tags + ) + ) + try drafts.clear(scope: scope, key: DraftRepository.newKey) + try? drafts.clear(scope: scope, key: DraftRepository.memoKey(serverId)) + return .updatedMaterialized(memoId: serverId) + } + + memo.title = displayTitle + memo.contentMarkdown = contentMarkdown + memo.contentText = contentMarkdown + memo.tags = tags + memo.notebookId = notebookId + memo.updatedAt = now + memo.excerpt = String(contentMarkdown.prefix(160)) + if let contentJSON, let json = try? JSONValue.parse(contentJSON) { + memo.contentJson = json + } + try mirror.upsertMemo(scope: scope, memo: memo) + + let rev = expectedRevision ?? memo.revision + let hash = expectedContentHash ?? memo.contentHash + try outbox.enqueueUpdate( + scope: scope, + payload: MemoUpdatePayload( + memoId: memo.id, + expectedRevision: rev, + expectedContentHash: hash, + title: displayTitle, + contentMarkdown: contentMarkdown, + notebookId: notebookId, + tags: tags + ) + ) + try drafts.clear(scope: scope, key: DraftRepository.newKey) + try? drafts.clear(scope: scope, key: DraftRepository.memoKey(serverId)) + return .updatedMaterialized(memoId: serverId) + } + + let localId = "local:\(UUID().uuidString.lowercased())" + let placeholder = MemoDetail.localPlaceholder( + id: localId, + notebookId: notebookId, + title: title, + contentMarkdown: contentMarkdown, + tags: tags, + createdAt: now + ) + try mirror.upsertMemo(scope: scope, memo: placeholder) + try outbox.enqueueCreate( + scope: scope, + payload: MemoCreatePayload( + memoId: localId, + title: displayTitle, + contentMarkdown: contentMarkdown, + notebookId: notebookId, + tags: tags, + createdAt: now + ) + ) + try drafts.clear(scope: scope, key: DraftRepository.newKey) + return .createdLocal(memoId: localId) + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift new file mode 100644 index 00000000..714e8b72 --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift @@ -0,0 +1,663 @@ +import SwiftUI +import Pow + +/// Android WorkspaceMemoDetail shell parity (detailHeader*, detailMeta*, detailEditFab). +struct MemoDetailView: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + let memoId: String + /// Present editor from the parent `WorkspaceView` (more reliable than cover on a pushed page). + var onEdit: (String) -> Void = { _ in } + + @State private var memo: MemoDetail? + @State private var showRevisions = false + @State private var showShareAlert = false + @State private var shareURL: String? + @State private var error: String? + @State private var conflictItem: OutboxItem? + @State private var outboxStatus: OutboxStatus? + @State private var lastOutboxError: String? + @State private var pinPulse = false + @State private var searchOpen = false + @State private var searchQuery = "" + @State private var showDeleteConfirm = false + @State private var showMoreMenu = false + + var body: some View { + VStack(spacing: 0) { + detailHeader + if syncStatus == .conflict, memo != nil { + conflictBanner + } else if syncStatus == .error || syncStatus == .pending, memo != nil { + syncBanner + } + if let memo { + detailBody(memo) + } else if let error { + ContentUnavailableView( + env.preferences.t("加载失败", en: "Failed to load"), + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(Color.white) + // UIKit FAB in overlay — SwiftUI Button over WKWebView often receives zero taps. + .overlay(alignment: .bottomTrailing) { + if let memo, !memo.isDeleted { + EditFabButton( + accessibilityLabel: env.preferences.t("编辑笔记", en: "Edit note") + ) { + onEdit(memo.id) + } + .frame(width: 56, height: 56) + .padding(.trailing, 12) + .padding(.bottom, 12) + } + } + .background(Color.white.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .accessibilityIdentifier(DetailMemoChrome.root) + .sheet(isPresented: $showRevisions) { + if let memo { + RevisionsView(memoId: memo.id) { + showRevisions = false + load() + } + } + } + .sheet(item: $conflictItem) { item in + ConflictResolutionView(item: item) { + conflictItem = nil + load() + refreshSyncStatus() + } + } + .confirmationDialog( + env.preferences.t("笔记操作", en: "Note actions"), + isPresented: $showMoreMenu, + titleVisibility: .visible + ) { + if let memo { + Button(env.preferences.t("编辑", en: "Edit")) { onEdit(memo.id) } + Button( + memo.isPinned + ? env.preferences.t("取消置顶", en: "Unpin") + : env.preferences.t("置顶", en: "Pin") + ) { + Task { + await togglePin(memo) + pinPulse.toggle() + } + } + Button(env.preferences.t("分享链接", en: "Share link")) { + Task { await shareMemo(memo) } + } + Button(env.preferences.t("修订历史", en: "Revisions")) { showRevisions = true } + Button(env.preferences.t("删除", en: "Delete"), role: .destructive) { + showDeleteConfirm = true + } + } + } + .alert(env.preferences.t("删除笔记", en: "Delete note"), isPresented: $showDeleteConfirm) { + Button(env.preferences.t("删除", en: "Delete"), role: .destructive) { + if let memo { Task { await deleteMemo(memo) } } + } + Button(env.preferences.t("取消", en: "Cancel"), role: .cancel) {} + } message: { + Text(env.preferences.t("笔记将移入回收站。", en: "The note will move to trash.")) + } + .alert(env.preferences.t("分享链接", en: "Share link"), isPresented: $showShareAlert) { + Button(env.preferences.t("复制", en: "Copy")) { + if let shareURL { + UIPasteboard.general.string = shareURL + } + } + Button(env.preferences.t("关闭", en: "Close"), role: .cancel) {} + } message: { + Text(shareURL ?? "") + } + .task { + load() + refreshSyncStatus() + } + .onChange(of: env.isSyncing) { _, _ in + refreshSyncStatus() + } + .preferredColorScheme(env.preferences.colorScheme) + } + + // MARK: - Header + + private var detailHeader: some View { + HStack(spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(env.preferences.t("返回列表", en: "Back to list")) + .accessibilityIdentifier(DetailMemoChrome.back) + + Spacer(minLength: 8) + + HStack(spacing: 2) { + Button { + handleSyncStatusPress() + } label: { + Text(syncLabel) + .font(.system(size: 11, weight: syncStatus == .conflict || syncStatus == .error ? .bold : .medium)) + .foregroundStyle(syncStatus.foreground) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(syncStatus.background) + .clipShape(Capsule()) + .lineLimit(1) + .frame(maxWidth: 88) + } + .buttonStyle(.plain) + .disabled(!syncStatus.isInteractive || memo == nil) + .accessibilityLabel(syncLabel) + .accessibilityIdentifier(DetailMemoChrome.syncStatus) + + if let memo, !memo.isDeleted { + headerIconButton( + systemImage: "square.and.arrow.up", + label: env.preferences.t("分享笔记", en: "Share note"), + id: DetailMemoChrome.share + ) { + Task { await shareMemo(memo) } + } + headerIconButton( + systemImage: "clock.arrow.circlepath", + label: env.preferences.t("版本历史", en: "Version history"), + id: DetailMemoChrome.history + ) { + showRevisions = true + } + headerIconButton( + systemImage: "magnifyingglass", + label: env.preferences.t("搜索当前笔记", en: "Search in note"), + id: DetailMemoChrome.search + ) { + withAnimation(Motion.chip) { searchOpen.toggle() } + } + headerIconButton( + systemImage: "ellipsis", + label: env.preferences.t("笔记操作", en: "Note actions"), + id: DetailMemoChrome.more + ) { + showMoreMenu = true + } + } + } + .accessibilityIdentifier(DetailMemoChrome.header) + } + .padding(.horizontal, 12) + .frame(minHeight: 48) + .background(Color.white) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.cardBorder).frame(height: 1) + } + .accessibilityIdentifier(DetailMemoChrome.header) + } + + private func headerIconButton( + systemImage: String, + label: String, + id: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityIdentifier(id) + } + + // MARK: - Banners + + private var conflictBanner: some View { + VStack(alignment: .leading, spacing: 10) { + Text(env.preferences.t( + "云端笔记已在其他标签页、设备,或离线期间被更新。可先复制本地草稿,再采用云端版本后继续编辑。", + en: "This note changed on another device or while offline. Copy the local draft, then adopt the cloud version to continue." + )) + .font(.system(size: 12)) + .foregroundStyle(Color(hex: 0x9F1239)) + .fixedSize(horizontal: false, vertical: true) + + if let lastOutboxError, !lastOutboxError.isEmpty { + Text(lastOutboxError) + .font(.system(size: 12)) + .foregroundStyle(Color(hex: 0x9F1239)) + } + + HStack(spacing: 8) { + Button { + if let item = conflictItem { + // Open full conflict resolution (Android "更多") + conflictItem = item + } else { + detectConflict() + } + } label: { + Text(env.preferences.t("处理冲突", en: "Resolve")) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color(hex: 0xBE123C)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + Button { + Task { await copyLocalDraft() } + } label: { + Text(env.preferences.t("复制本地草稿", en: "Copy local draft")) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color(hex: 0x9F1239)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: 0xFECDD3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(hex: 0xFFF1F2)) + .overlay(alignment: .bottom) { + Rectangle().fill(Color(hex: 0xFECDD3)).frame(height: 0.5) + } + } + + private var syncBanner: some View { + let isError = syncStatus == .error + return VStack(alignment: .leading, spacing: 10) { + Text( + lastOutboxError?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ?? (isError + ? env.preferences.t("本地改动未能上传到云端。内容仍保存在本机,可立即重试。", en: "Local changes could not upload. Content is still on device; retry anytime.") + : env.preferences.t("本地改动待上传。下拉刷新或点此可立即同步。", en: "Local changes pending upload. Pull to refresh or tap to sync now.")) + ) + .font(.system(size: 12)) + .foregroundStyle(isError ? Color(hex: 0x991B1B) : Color(hex: 0x1E3A8A)) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Button { + Task { + await env.runSyncCycle() + refreshSyncStatus() + load() + } + } label: { + Text(env.preferences.t("立即同步", en: "Sync now")) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(isError ? Color(hex: 0xB91C1C) : Color(hex: 0x1D4ED8)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + if isError { + Button { + Task { await copyLocalDraft() } + } label: { + Text(env.preferences.t("复制本地草稿", en: "Copy local draft")) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color(hex: 0x991B1B)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: 0xFECACA), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isError ? Color(hex: 0xFEF2F2) : Color(hex: 0xEFF6FF)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isError ? Color(hex: 0xFECACA) : Color(hex: 0xBFDBFE)) + .frame(height: 0.5) + } + } + + // MARK: - Body + + private func detailBody(_ memo: MemoDetail) -> some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + if memo.isPinned { + Text("★") + .font(.system(size: 16)) + .foregroundStyle(AppTheme.secondary) + } + Text(memo.displayTitle) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(AppTheme.title) + .lineLimit(4) + .textSelection(.enabled) + .accessibilityIdentifier(DetailMemoChrome.title) + } + .padding(.top, 16) + .edgeEverSuccessShine(trigger: pinPulse) + + HStack(spacing: 8) { + HStack(spacing: 4) { + Text(notebookName(for: memo)) + .font(.system(size: 14)) + .foregroundStyle(AppTheme.secondary) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AppTheme.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(0) + .accessibilityIdentifier(DetailMemoChrome.notebook) + + HStack(spacing: 8) { + Image(systemName: "tag") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + Text( + memo.tags.isEmpty + ? env.preferences.t("添加标签,用逗号分隔", en: "Add tags, comma separated") + : memo.tags.joined(separator: ", ") + ) + .font(.system(size: 14)) + .foregroundStyle(memo.tags.isEmpty ? AppTheme.muted : AppTheme.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier(DetailMemoChrome.tags) + } + .frame(minHeight: 32) + .padding(.top, 12) + .accessibilityIdentifier(DetailMemoChrome.metaRow) + + if searchOpen { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + TextField( + env.preferences.t("在当前笔记内搜索", en: "Search in this note"), + text: $searchQuery + ) + .font(.system(size: 14)) + .textFieldStyle(.plain) + Button { + searchOpen = false + searchQuery = "" + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.title) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + } + .padding(10) + .background(AppTheme.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(AppTheme.border, lineWidth: 1) + ) + .padding(.top, 14) + } + + Rectangle() + .fill(AppTheme.border) + .frame(height: 1) + .padding(.top, 16) + .padding(.bottom, 8) + } + .padding(.horizontal, 16) + + TipTapWebView( + mode: .viewer, + documentJSON: (try? memo.contentJson.jsonString()) + ?? "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}", + markdown: memo.contentMarkdown, + baseURL: env.session.session.flatMap { URL(string: $0.baseUrl) }, + token: env.session.session?.token, + onChange: nil + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier(DetailMemoChrome.body) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + // MARK: - Sync helpers + + private var syncStatus: DetailSyncStatus { + DetailSyncStatus.derive(outboxStatus: outboxStatus, isGlobalSyncing: env.isSyncing) + } + + private var syncLabel: String { + env.preferences.isEnglish ? syncStatus.labelEN : syncStatus.labelZH + } + + private func notebookName(for memo: MemoDetail) -> String { + let notebooks = (try? env.mirror.listNotebooks(scope: env.session.dataScope ?? "")) ?? [] + return notebooks.first(where: { $0.id == memo.notebookId })?.name + ?? env.preferences.t("笔记本", en: "Notebook") + } + + private func handleSyncStatusPress() { + switch syncStatus { + case .conflict: + detectConflict() + case .error, .pending: + Task { + await env.runSyncCycle() + refreshSyncStatus() + load() + } + case .synced, .syncing: + break + } + } + + private func load() { + guard let scope = env.session.dataScope else { return } + do { + memo = try env.mirror.resolveMemo(scope: scope, id: memoId) + if memo == nil { + error = env.preferences.t("本地未找到该笔记,请先同步。", en: "Note not in local cache. Sync first.") + } + } catch { + self.error = error.localizedDescription + } + } + + private func refreshSyncStatus() { + guard let scope = env.session.dataScope else { + outboxStatus = nil + lastOutboxError = nil + return + } + let items = (try? env.outbox.listItems(scope: scope)) ?? [] + if let item = items.first(where: { $0.memoId == memoId }) { + outboxStatus = item.status + lastOutboxError = item.lastError + if item.status == .conflict { + conflictItem = item + } + } else { + outboxStatus = nil + lastOutboxError = nil + } + } + + private func detectConflict() { + guard let scope = env.session.dataScope else { return } + let items = (try? env.outbox.listItems(scope: scope)) ?? [] + conflictItem = items.first { $0.memoId == memoId && $0.status == .conflict } + refreshSyncStatus() + } + + private func copyLocalDraft() async { + guard let memo else { return } + let text = [memo.displayTitle, memo.contentMarkdown].filter { !$0.isEmpty }.joined(separator: "\n\n") + UIPasteboard.general.string = text + } + + private func togglePin(_ memo: MemoDetail) async { + guard let scope = env.session.dataScope else { return } + do { + let updated = try await env.session.client.updateMemo( + id: memo.id, + expectedRevision: nil, + expectedContentHash: nil, + editSessionId: nil, + notebookId: nil, + title: nil, + isPinned: !memo.isPinned, + contentMarkdown: nil, + tags: nil + ) + try env.mirror.upsertMemo(scope: scope, memo: updated) + self.memo = updated + } catch { + self.error = error.localizedDescription + } + } + + private func shareMemo(_ memo: MemoDetail) async { + do { + let share = try await env.session.client.createMemoShare(memoId: memo.id) + let base = env.session.session?.baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? "" + shareURL = "\(base)/share/\(share.token)" + showShareAlert = true + } catch { + self.error = error.localizedDescription + } + } + + private func deleteMemo(_ memo: MemoDetail) async { + guard let scope = env.session.dataScope else { return } + do { + if memo.id.hasPrefix("local:") { + try env.outbox.cancelMemo(scope: scope, memoId: memo.id) + try env.mirror.deleteMemo(scope: scope, id: memo.id) + } else { + _ = try env.mirror.softDeleteMemo(scope: scope, id: memo.id) + try await env.session.client.deleteMemo(id: memo.id, permanent: false) + } + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} + +// MARK: - String helper + +private extension String { + var nilIfEmpty: String? { + let t = trimmingCharacters(in: .whitespacesAndNewlines) + return t.isEmpty ? nil : t + } +} + +struct RevisionsView: View { + @Environment(AppEnvironment.self) private var env + let memoId: String + var onRestored: () -> Void + + @State private var revisions: [MemoRevision] = [] + @State private var error: String? + @State private var busyId: String? + + var body: some View { + NavigationStack { + List { + if let error { + Text(error).foregroundStyle(AppTheme.danger) + } + ForEach(revisions) { rev in + VStack(alignment: .leading, spacing: 4) { + Text(env.preferences.t("修订 \(rev.revision)", en: "Revision \(rev.revision)")) + .font(.headline) + Text(MemoPreviewDate.format(rev.createdAt, locale: env.preferences.resolvedLocale)) + .font(.caption) + .foregroundStyle(AppTheme.secondary) + Text(rev.contentText.prefix(120)) + .font(.caption) + .foregroundStyle(AppTheme.secondary) + .lineLimit(2) + Button(env.preferences.t("恢复此版本", en: "Restore")) { + Task { await restore(rev) } + } + .disabled(busyId != nil) + } + .padding(.vertical, 4) + } + } + .navigationTitle(env.preferences.t("修订历史", en: "Revisions")) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(env.preferences.t("关闭", en: "Close")) { onRestored() } + } + } + .task { await load() } + } + } + + private func load() async { + do { + revisions = try await env.session.client.listMemoRevisions(memoId: memoId) + } catch { + self.error = error.localizedDescription + } + } + + private func restore(_ rev: MemoRevision) async { + guard let scope = env.session.dataScope else { return } + busyId = rev.id + defer { busyId = nil } + do { + let memo = try await env.session.client.restoreMemoRevision(memoId: memoId, revisionId: rev.id) + try env.mirror.upsertMemo(scope: scope, memo: memo) + onRestored() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift new file mode 100644 index 00000000..e5223600 --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -0,0 +1,603 @@ +import PhotosUI +import SwiftUI + +enum MemoEditMode: Equatable { + case create(notebookId: String) + case edit(memoId: String) +} + +/// Android CreateMemoModal / rich-edit shell parity (createMemo* tokens). +struct MemoEditView: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + + let mode: MemoEditMode + + @State private var title = "" + @State private var tagsText = "" + @State private var notebookId = "" + @State private var contentMarkdown = "" + @State private var contentJSON = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}" + @State private var expectedRevision: Int? + @State private var expectedContentHash: String? + @State private var memoId: String? + @State private var error: String? + @State private var saveTask: Task? + @State private var photoItem: PhotosPickerItem? + @State private var isMaterializing = false + @State private var isDirty = false + @State private var isSaving = false + @State private var isCreating = false + @State private var isUploading = false + @State private var editorReady = false + @State private var showNotebookPicker = false + + var body: some View { + VStack(spacing: 0) { + createHeader + createMain + } + .background(Color.white.ignoresSafeArea()) + .accessibilityIdentifier(CreateMemoChrome.root) + .sheet(isPresented: $showNotebookPicker) { + EditNotebookPickerSheet( + notebooks: availableNotebooks, + selectedId: notebookId + ) { id in + notebookId = id + markDirtyAndScheduleSave() + showNotebookPicker = false + } + .presentationDetents([.medium, .large]) + } + .onChange(of: photoItem) { _, item in + guard let item else { return } + Task { await insertImage(item) } + } + .task { + await loadInitial() + // TipTap loads async; mark ready after first paint window + try? await Task.sleep(nanoseconds: 400_000_000) + if !Task.isCancelled { editorReady = true } + } + .onDisappear { + saveTask?.cancel() + if !isCreate { + Task { await flushPending() } + } + } + .preferredColorScheme(env.preferences.colorScheme) + } + + // MARK: - Header (createMemoHeader) + + private var createHeader: some View { + HStack(spacing: 8) { + Button { + Task { await handleBack() } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(busyChrome ? AppTheme.muted : AppTheme.title) + .frame(width: 38, height: 38) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(busyChrome) + .accessibilityLabel(env.preferences.t("返回", en: "Back")) + .accessibilityIdentifier(CreateMemoChrome.back) + + Spacer(minLength: 0) + + HStack(spacing: 8) { + Text(statusLabel) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(saveStatus.isActive ? AppTheme.accentStrong : AppTheme.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(saveStatus.isActive ? AppTheme.accentSoft : AppTheme.searchFill) + .clipShape(Capsule()) + .lineLimit(1) + .accessibilityIdentifier(CreateMemoChrome.status) + + Button { + Task { await handleDone() } + } label: { + Group { + if isCreating || isSaving && isCreate { + ProgressView() + .controlSize(.small) + .tint(AppTheme.secondary) + } else { + Text(env.preferences.t("完成", en: "Done")) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(canSubmitDone ? Color.white : AppTheme.secondary) + } + } + .frame(minWidth: 58, minHeight: 36) + .padding(.horizontal, 12) + .background(canSubmitDone ? AppTheme.title : Color(hex: 0xE2E8F0)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!canSubmitDone) + .accessibilityLabel(env.preferences.t("完成", en: "Done")) + .accessibilityIdentifier(CreateMemoChrome.done) + } + .accessibilityIdentifier(CreateMemoChrome.header) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 52) + .background(Color.white) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.cardBorder).frame(height: 1) + } + .accessibilityIdentifier(CreateMemoChrome.header) + } + + // MARK: - Main (createMemoMain) + + private var createMain: some View { + VStack(alignment: .leading, spacing: 0) { + TextField( + env.preferences.t("无标题笔记", en: "Untitled note"), + text: $title + ) + .font(.system(size: 28, weight: .heavy)) + .foregroundStyle(AppTheme.title) + .textFieldStyle(.plain) + .padding(.top, 14) + .padding(.bottom, 8) + .onChange(of: title) { _, _ in markDirtyAndScheduleSave() } + .accessibilityLabel(env.preferences.t("笔记标题", en: "Note title")) + .accessibilityIdentifier(CreateMemoChrome.title) + + HStack(spacing: 10) { + Button { + showNotebookPicker = true + } label: { + HStack(spacing: 3) { + Text(selectedNotebookName) + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(AppTheme.secondary) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .frame(maxWidth: 160, alignment: .leading) + .frame(minHeight: 30) + .layoutPriority(1) + .accessibilityLabel(env.preferences.t("所在笔记本", en: "Notebook")) + .accessibilityIdentifier(CreateMemoChrome.notebook) + + TextField( + env.preferences.t("添加标签,用逗号分隔", en: "Add tags, comma separated"), + text: $tagsText + ) + .font(.system(size: 15)) + .foregroundStyle(AppTheme.secondary) + .textFieldStyle(.plain) + .frame(minHeight: 36) + .onChange(of: tagsText) { _, _ in markDirtyAndScheduleSave() } + .accessibilityLabel(env.preferences.t("笔记标签", en: "Tags")) + .accessibilityIdentifier(CreateMemoChrome.tags) + + PhotosPicker(selection: $photoItem, matching: .images) { + Image(systemName: "photo") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + .frame(width: 36, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(env.preferences.t("插入图片", en: "Insert image")) + .accessibilityIdentifier(CreateMemoChrome.imageTool) + } + .frame(minHeight: 40) + .accessibilityIdentifier(CreateMemoChrome.metaRow) + + ZStack { + TipTapWebView( + mode: .editor, + documentJSON: contentJSON, + markdown: contentMarkdown, + baseURL: env.session.session.map { URL(string: $0.baseUrl) } ?? nil, + token: env.session.session?.token, + onChange: { md, json in + contentMarkdown = md + contentJSON = json + markDirtyAndScheduleSave() + } + ) + .opacity(editorReady ? 1 : 0.01) + + if !editorReady { + VStack(spacing: 10) { + ProgressView() + .tint(AppTheme.title) + Text(env.preferences.t("正在启动本地编辑器", en: "Starting local editor")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(AppTheme.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + .padding(.top, 4) + .padding(.horizontal, -4) + .accessibilityIdentifier(CreateMemoChrome.editorFrame) + + if let error { + Text(error) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.danger) + .padding(.top, 8) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + // MARK: - Derived state + + private var isCreate: Bool { + if case .create = mode { return true } + return false + } + + private var busyChrome: Bool { + isCreating || isUploading + } + + private var saveStatus: CreateSaveStatus { + CreateSaveStatus.derive( + editorReady: editorReady, + isDirty: isDirty, + isSaving: isSaving, + isCreating: isCreating, + isUploading: isUploading, + hasError: error != nil + ) + } + + private var statusLabel: String { + env.preferences.isEnglish ? saveStatus.labelEN : saveStatus.labelZH + } + + private var canSubmitDone: Bool { + if isCreate { + return !notebookId.isEmpty && !isCreating && !isUploading + } + return !isSaving && !isUploading && editorReady + } + + private var availableNotebooks: [Notebook] { + (try? env.mirror.listNotebooks(scope: env.session.dataScope ?? "")) ?? [] + } + + private var selectedNotebookName: String { + if let name = availableNotebooks.first(where: { $0.id == notebookId })?.name { + return name + } + return env.preferences.t("选择笔记本", en: "Choose notebook") + } + + private var tags: [String] { + tagsText + .split(whereSeparator: { ",, ".contains($0) }) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + // MARK: - Actions + + private func markDirtyAndScheduleSave() { + isDirty = true + scheduleSave() + } + + private func handleBack() async { + if isCreate { + // Draft retained; close without materialize (Android requestClose) + dismiss() + } else { + await flushPending() + dismiss() + } + } + + private func handleDone() async { + if isCreate { + await commitCreate() + } else { + await flushPending() + dismiss() + } + } + + private func loadInitial() async { + guard let scope = env.session.dataScope else { return } + switch mode { + case .create(let nb): + notebookId = nb + if let draft = try? env.drafts.read(scope: scope, key: DraftRepository.newKey) { + title = draft.title + tagsText = draft.tagsText + contentMarkdown = draft.contentMarkdown + contentJSON = draft.contentJson ?? contentJSON + if !draft.notebookId.isEmpty { notebookId = draft.notebookId } + } + case .edit(let id): + memoId = id + if let draft = try? env.drafts.read(scope: scope, key: DraftRepository.memoKey(id)) { + title = draft.title + tagsText = draft.tagsText + contentMarkdown = draft.contentMarkdown + contentJSON = draft.contentJson ?? contentJSON + notebookId = draft.notebookId + expectedRevision = draft.expectedRevision + } else if let memo = try? env.mirror.resolveMemo(scope: scope, id: id) { + title = memo.title ?? "" + tagsText = memo.tags.joined(separator: ", ") + contentMarkdown = memo.contentMarkdown + contentJSON = (try? memo.contentJson.jsonString()) ?? contentJSON + notebookId = memo.notebookId + expectedRevision = memo.revision + expectedContentHash = memo.contentHash + memoId = memo.id + } + } + } + + private func scheduleSave() { + saveTask?.cancel() + saveTask = Task { + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled else { return } + await persistDraftOrQueue() + } + } + + private func persistDraftOrQueue() async { + guard let scope = env.session.dataScope else { return } + isSaving = true + defer { + isSaving = false + isDirty = false + } + let now = EdgeEverDate.nowString() + switch mode { + case .create: + try? env.drafts.write( + scope: scope, + draft: MemoDraft( + draftKey: DraftRepository.newKey, + title: title, + contentMarkdown: contentMarkdown, + contentJson: contentJSON, + notebookId: notebookId, + tagsText: tagsText, + expectedRevision: nil, + updatedAt: now + ) + ) + case .edit: + guard let memoId else { return } + guard var memo = try? env.mirror.resolveMemo(scope: scope, id: memoId) else { return } + memo.title = title + memo.contentMarkdown = contentMarkdown + memo.contentText = contentMarkdown + memo.tags = tags + memo.notebookId = notebookId + memo.updatedAt = now + memo.excerpt = String(contentMarkdown.prefix(160)) + if let json = try? JSONValue.parse(contentJSON) { + memo.contentJson = json + } + try? env.mirror.upsertMemo(scope: scope, memo: memo) + + let rev = expectedRevision ?? memo.revision + let hash = expectedContentHash ?? memo.contentHash + try? env.outbox.enqueueUpdate( + scope: scope, + payload: MemoUpdatePayload( + memoId: memo.id, + expectedRevision: rev, + expectedContentHash: hash, + title: title, + contentMarkdown: contentMarkdown, + notebookId: notebookId, + tags: tags + ) + ) + try? env.drafts.write( + scope: scope, + draft: MemoDraft( + draftKey: DraftRepository.memoKey(memo.id), + title: title, + contentMarkdown: contentMarkdown, + contentJson: contentJSON, + notebookId: notebookId, + tagsText: tagsText, + expectedRevision: rev, + updatedAt: now + ) + ) + Task { await env.runSyncCycle() } + } + } + + private func commitCreate() async { + guard let scope = env.session.dataScope else { return } + guard !notebookId.isEmpty else { + error = env.preferences.t("请选择笔记本", en: "Choose a notebook") + return + } + isCreating = true + defer { isCreating = false } + do { + // Android createMutation: if image materialize already created a server memo, + // Done updates that memo — never mint a second local: create. + let outcome = try MemoCreateCommit.commit( + scope: scope, + memoId: memoId, + expectedRevision: expectedRevision, + expectedContentHash: expectedContentHash, + notebookId: notebookId, + title: title, + contentMarkdown: contentMarkdown, + contentJSON: contentJSON, + tags: tags, + mirror: env.mirror, + outbox: env.outbox, + drafts: env.drafts + ) + if case .updatedMaterialized(let id) = outcome { + memoId = id + } + await env.runSyncCycle() + dismiss() + } catch { + self.error = error.localizedDescription + } + } + + private func flushPending() async { + await persistDraftOrQueue() + await env.runSyncCycle() + } + + /// K24 materialize: ensure a server memo id before image upload. + private func materializeForImage() async throws -> String { + if let memoId, !memoId.hasPrefix("local:") { + return memoId + } + guard let scope = env.session.dataScope else { + throw APIError(status: 0, code: nil, message: "未登录") + } + if isMaterializing { + try await Task.sleep(nanoseconds: 300_000_000) + if let memoId, !memoId.hasPrefix("local:") { return memoId } + } + isMaterializing = true + defer { isMaterializing = false } + + if let localId = memoId, localId.hasPrefix("local:"), + let pending = try env.outbox.pendingCreate(scope: scope, memoId: localId) + { + if pending.status == .syncing { + await env.runSyncCycle() + if let resolved = try env.mirror.resolveMemo(scope: scope, id: localId), !resolved.id.hasPrefix("local:") { + memoId = resolved.id + expectedRevision = resolved.revision + expectedContentHash = resolved.contentHash + return resolved.id + } + } + try env.outbox.cancelMemo(scope: scope, memoId: localId) + try env.mirror.deleteMemo(scope: scope, id: localId) + } + + let memo = try await env.session.client.createMemo( + notebookId: notebookId.isEmpty ? (availableNotebooks.first?.id ?? "") : notebookId, + title: title.isEmpty ? "无标题笔记" : title, + contentMarkdown: contentMarkdown, + tags: tags + ) + try env.mirror.upsertMemo(scope: scope, memo: memo) + try env.drafts.clear(scope: scope, key: DraftRepository.newKey) + memoId = memo.id + expectedRevision = memo.revision + expectedContentHash = memo.contentHash + notebookId = memo.notebookId + return memo.id + } + + private func insertImage(_ item: PhotosPickerItem) async { + isUploading = true + defer { isUploading = false } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { return } + let compress = env.preferences.useCompression + let prepared = compress + ? ImageCompressor.compressIfNeeded(data) + : (data: data, mimeType: "image/jpeg", filename: "image.jpg") + let serverId = try await materializeForImage() + let resource = try await env.session.client.uploadMemoResource( + memoId: serverId, + filename: prepared.filename, + mimeType: prepared.mimeType, + data: prepared.data + ) + contentMarkdown += "\n\n![](\(resource.url))\n" + markDirtyAndScheduleSave() + photoItem = nil + } catch { + self.error = error.localizedDescription + } + } +} + +// MARK: - Compact notebook picker for create/edit + +private struct EditNotebookPickerSheet: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + let notebooks: [Notebook] + let selectedId: String + var onSelect: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(env.preferences.t("选择笔记本", en: "Choose notebook")) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(AppTheme.title) + Spacer() + Button(env.preferences.t("关闭", en: "Close")) { dismiss() } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + + List { + ForEach(notebooks, id: \.id) { nb in + Button { + onSelect(nb.id) + dismiss() + } label: { + HStack { + Text(nb.name) + .foregroundStyle(AppTheme.title) + Spacer() + if nb.id == selectedId { + Image(systemName: "checkmark") + .foregroundStyle(AppTheme.accent) + } + } + } + } + } + .listStyle(.plain) + } + .background(Color.white) + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/NotebookPickerSheet.swift b/apps/ios/EdgeEver/Features/Workspace/NotebookPickerSheet.swift new file mode 100644 index 00000000..4b971084 --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/NotebookPickerSheet.swift @@ -0,0 +1,265 @@ +import SwiftUI + +/// Android `NotebookPickerModal` parity: handle, header, search, All notes, tree with expand/collapse. +struct NotebookPickerSheet: View { + @Environment(AppEnvironment.self) private var env + @Bindable var store: WorkspaceStore + @Environment(\.dismiss) private var dismiss + + @State private var query = "" + @State private var collapsedIds: Set = [] + + private var parentIds: Set { + NotebookHierarchy.parentIdsWithChildren(from: store.notebooks) + } + + private var allBranchesExpanded: Bool { + !parentIds.isEmpty && parentIds.allSatisfy { !collapsedIds.contains($0) } + } + + private var activeName: String { + if let id = store.selectedNotebookId, + let name = store.notebooks.first(where: { $0.id == id })?.name + { + return name + } + return env.preferences.t("全部笔记", en: "All notes") + } + + private var visibleItems: [NotebookTreeItem] { + let tree = NotebookHierarchy.treeItems(from: store.notebooks) + let search = query.trimmingCharacters(in: .whitespacesAndNewlines) + if !search.isEmpty { + let visible = NotebookHierarchy.searchVisibleIds(notebooks: store.notebooks, searchText: search) + return tree.filter { visible.contains($0.id) } + } + return NotebookHierarchy.filterCollapsed(items: tree, collapsedIds: collapsedIds) + } + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(hex: 0xCBD5E1)) + .frame(width: 42, height: 4) + .padding(.top, 10) + .padding(.bottom, 8) + + // Header + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(env.preferences.t("切换笔记本", en: "Switch notebook")) + .font(.system(size: 15, weight: .heavy)) + .foregroundStyle(AppTheme.title) + Text(env.preferences.t("当前:\(activeName)", en: "Current: \(activeName)")) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + } + Spacer(minLength: 8) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .frame(minHeight: 56) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Search + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + TextField(env.preferences.t("搜索笔记本", en: "Search notebooks"), text: $query) + .font(.system(size: 14)) + .foregroundStyle(AppTheme.title) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .frame(height: 36) + .background(AppTheme.searchFill) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.horizontal, 8) + .padding(.top, 8) + .padding(.bottom, 8) + + // All notes + notebookRow( + title: env.preferences.t("全部笔记", en: "All notes"), + selected: store.selectedNotebookId == nil, + depth: 0, + hasChildren: false, + collapsed: false, + onToggle: nil + ) { + select(nil) + } + .padding(.bottom, 4) + + // Section header + HStack { + Text( + query.trimmingCharacters(in: .whitespaces).isEmpty + ? env.preferences.t("笔记本", en: "Notebooks") + : env.preferences.t("匹配的笔记本", en: "Matching notebooks") + ) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + Spacer() + if query.trimmingCharacters(in: .whitespaces).isEmpty, !parentIds.isEmpty { + Button { + withAnimation(Motion.chip) { + if allBranchesExpanded { + collapsedIds = parentIds + } else { + collapsedIds = [] + } + } + } label: { + Text( + allBranchesExpanded + ? env.preferences.t("收起全部", en: "Collapse all") + : env.preferences.t("展开全部", en: "Expand all") + ) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } + } + .frame(minHeight: 32) + .padding(.horizontal, 12) + + // Tree + ForEach(visibleItems) { item in + let hasChildren = parentIds.contains(item.id) + let isCollapsed = collapsedIds.contains(item.id) + notebookRow( + title: item.name, + selected: store.selectedNotebookId == item.id, + depth: item.depth, + hasChildren: hasChildren && query.trimmingCharacters(in: .whitespaces).isEmpty, + collapsed: isCollapsed, + onToggle: hasChildren && query.trimmingCharacters(in: .whitespaces).isEmpty + ? { + withAnimation(Motion.chip) { + if collapsedIds.contains(item.id) { + collapsedIds.remove(item.id) + } else { + collapsedIds.insert(item.id) + } + } + } + : nil + ) { + select(item.id) + } + } + + if visibleItems.isEmpty { + VStack(spacing: 8) { + Image(systemName: "folder") + .font(.system(size: 28)) + .foregroundStyle(AppTheme.muted) + Text(env.preferences.t("没有匹配的笔记本", en: "No matching notebooks")) + .font(.system(size: 13)) + .foregroundStyle(AppTheme.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } + } + .padding(.bottom, 12) + } + } + .background(Color.white) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .onAppear { + // Match Android: collapse branches not on the path to the active notebook. + let parents = parentIds + if let selected = store.selectedNotebookId { + let keepOpen = NotebookHierarchy.ancestorIds(notebooks: store.notebooks, notebookId: selected) + collapsedIds = parents.filter { !keepOpen.contains($0) } + } else { + collapsedIds = parents + } + query = "" + } + } + + private func select(_ id: String?) { + store.selectedNotebookId = id + store.reload(env: env) + dismiss() + } + + @ViewBuilder + private func notebookRow( + title: String, + selected: Bool, + depth: Int, + hasChildren: Bool, + collapsed: Bool, + onToggle: (() -> Void)?, + onSelect: @escaping () -> Void + ) -> some View { + HStack(spacing: 8) { + if hasChildren, let onToggle { + Button(action: onToggle) { + Image(systemName: collapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + } else { + Color.clear.frame(width: 28, height: 28) + } + + Button(action: onSelect) { + HStack { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if selected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppTheme.title) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.leading, CGFloat(min(depth * 18, 54))) + .padding(.horizontal, 12) + .frame(minHeight: 48) + .background(selected ? AppTheme.searchFill : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .padding(.horizontal, 8) + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift new file mode 100644 index 00000000..da326ec0 --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift @@ -0,0 +1,261 @@ +import SwiftUI +import Pow + +struct NotesListView: View { + @Environment(AppEnvironment.self) private var env + @Bindable var store: WorkspaceStore + @Binding var path: NavigationPath + + var body: some View { + Group { + if store.notebooks.isEmpty && store.memos.isEmpty && !store.isLoadingList { + emptyCard( + title: env.preferences.t("暂无笔记本", en: "No notebooks"), + description: env.preferences.t( + "同步完成后,笔记本会出现在这里。可在桌面/Web 端创建笔记本。", + en: "Notebooks appear after sync. Create them on desktop/web." + ), + showCreate: false + ) + .transition(Motion.softFade) + } else if store.memos.isEmpty && !store.isLoadingList { + emptyCard(title: emptyTitle, description: emptyDescription, showCreate: store.searchText.isEmpty && store.filter == .all) + .transition(Motion.softFade) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.memos) { memo in + memoCard(for: memo) + .padding(.horizontal, 12) + .padding(.bottom, env.preferences.listDensity.cardBottomMargin) + // Identity-based transition when filter/search reshuffles the set. + .transition(Motion.cardAppear) + .onAppear { + if memo.id == store.memos.last?.id { + store.loadMore(env: env) + } + } + } + if store.isLoadingMore { + ProgressView() + .tint(AppTheme.title) + .padding(.vertical, 18) + } + } + .padding(.top, 12) + // Only a little air above the solid bar; elevated + overlaps content (Android). + // Large padding here paints an empty #f8fafc strip above the white chrome. + .padding(.bottom, 8) + .animation(Motion.listContent, value: store.memos.map(\.id)) + .animation(Motion.listContent, value: store.filter) + .animation(Motion.listContent, value: store.searchText) + } + // Parent owns bottom safe area via bottom chrome — do not leave a second gap strip. + .contentMargins(.bottom, 0, for: .scrollContent) + .background(AppTheme.background) + } + } + .animation(Motion.listContent, value: store.memos.isEmpty) + .overlay(alignment: .bottom) { + if let err = store.listError { + Text(err) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.danger) + .padding(10) + .frame(maxWidth: .infinity) + .background(Color.white.opacity(0.96)) + .transition(Motion.softFade) + .edgeEverErrorShake(on: err) + } + } + .animation(Motion.search, value: store.listError) + } + + private var emptyTitle: String { + if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty { + return env.preferences.t("没有找到匹配笔记", en: "No matching notes") + } + if store.filter != .all { + return env.preferences.t("没有符合筛选的笔记", en: "No notes match this filter") + } + return env.preferences.t("暂无笔记", en: "No notes yet") + } + + private var emptyDescription: String { + if !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty { + return env.preferences.t("换个关键词再试", en: "Try another keyword") + } + if store.filter != .all { + return env.preferences.t("试试切换筛选条件,或调整搜索关键词。", en: "Try another filter or search.") + } + return env.preferences.t( + "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。", + en: "Create a note to preview, search, and batch-organize here." + ) + } + + private func emptyCard(title: String, description: String, showCreate: Bool) -> some View { + VStack(spacing: 10) { + Text(title) + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(AppTheme.meta) + Text(description) + .font(.system(size: 13)) + .foregroundStyle(AppTheme.secondary) + .multilineTextAlignment(.center) + if showCreate { + // Create is primarily the bottom-nav center button (Android parity). + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.vertical, 34) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 4])) + .foregroundStyle(Color(hex: 0xCBD5E1)) + ) + .padding(.horizontal, 12) + .padding(.top, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(AppTheme.background) + } + + @ViewBuilder + private func memoCard(for memo: MemoSummary) -> some View { + let selected = store.selectedMemoIds.contains(memo.id) + let density = env.preferences.listDensity + + Button { + if store.selectionMode { + store.toggleSelected(memo.id) + } else { + path.append(memo.id) + } + } label: { + HStack(alignment: .center, spacing: 0) { + if store.selectionMode { + ZStack { + Circle() + .stroke(selected ? AppTheme.title : Color(hex: 0xCBD5E1), lineWidth: 1) + .background(Circle().fill(selected ? AppTheme.title : Color.clear)) + .frame(width: 24, height: 24) + if selected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } + } + .frame(width: 44) + .padding(.leading, 8) + .animation(Motion.chip, value: selected) + } + + MemoCardContent( + memo: memo, + density: density, + locale: env.preferences.resolvedLocale, + isEnglish: env.preferences.isEnglish + ) + .padding(density.cardPadding) + .padding(.leading, store.selectionMode ? 12 : density.cardPadding) + } + .frame(maxWidth: .infinity, minHeight: density.cardMinHeight, alignment: .leading) + .background(selected ? AppTheme.background : Color.white) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(selected ? AppTheme.border : AppTheme.cardBorder, lineWidth: 1) + ) + .animation(Motion.chip, value: selected) + } + .buttonStyle(MemoCardPressStyle()) + .edgeEverSelectionFeedback(selected) + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.52).onEnded { _ in + if !store.selectionMode { + store.enterSelection(memoId: memo.id) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + ) + .contextMenu { + Button { + Task { await store.togglePin(env: env, memo: memo) } + } label: { + Label( + memo.isPinned + ? env.preferences.t("取消置顶", en: "Unpin") + : env.preferences.t("置顶", en: "Pin"), + systemImage: memo.isPinned ? "pin.slash" : "pin" + ) + } + Button(role: .destructive) { + Task { await store.softDelete(env: env, memoId: memo.id) } + } label: { + Label(env.preferences.t("删除", en: "Delete"), systemImage: "trash") + } + } + } +} + +/// Android MemoCard content: title (+ pin star) → optional excerpt → date + tag chips. +struct MemoCardContent: View { + let memo: MemoSummary + var density: ListDensity = .preview + var locale: Locale = .current + var isEnglish: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 6) { + if memo.isPinned { + Text("★") + .font(.system(size: 16)) + .foregroundStyle(AppTheme.secondary) + .frame(width: 16, height: 16) + } + Text(displayTitle) + .font(AppTheme.memoTitleFont) + .foregroundStyle(AppTheme.title) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if density.showsExcerpt { + Text(memo.excerpt.isEmpty ? (isEnglish ? "Empty note" : "空笔记") : memo.excerpt) + .font(AppTheme.memoExcerptFont) + .foregroundStyle(AppTheme.body) + .lineLimit(2) + .lineSpacing(2) + .frame(minHeight: 40, alignment: .topLeading) + .padding(.top, 8) + } + + HStack(alignment: .center, spacing: 8) { + Text(MemoPreviewDate.format(memo.updatedAt, locale: locale, isEnglish: isEnglish)) + .font(AppTheme.memoDateFont) + .foregroundStyle(AppTheme.meta) + ForEach(Array(memo.tags.prefix(3)), id: \.self) { tag in + Text("#\(tag)") + .font(AppTheme.tagFont) + .foregroundStyle(AppTheme.title) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(AppTheme.tagBackground) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + } + } + .padding(.top, density.metaTop) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var displayTitle: String { + let t = memo.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return t.isEmpty ? "无标题笔记" : t + } +} + +// MemoCardPressStyle lives in DesignSystem/Motion.swift (shared with Android timing curves). diff --git a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift new file mode 100644 index 00000000..02bf2ca5 --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift @@ -0,0 +1,1027 @@ +import SwiftUI + +/// Android `WorkspaceSettingsView` parity: full-screen “我的”, not a system Form/List. +struct SettingsView: View { + @Environment(AppEnvironment.self) private var env + @Environment(\.dismiss) private var dismiss + + private enum RootTab: Hashable { + case general + case account + case system + // Extended (not on Android root Me, but reachable from system/account extras) + case tags + case tokens + case devices + case users + } + + @State private var tab: RootTab? + @State private var showLocalePicker = false + + private var title: String { + switch tab { + case .general: return env.preferences.t("常规设置", en: "General") + case .account: return env.preferences.t("登录设置", en: "Account") + case .system: return env.preferences.t("系统信息", en: "System info") + case .tags: return env.preferences.t("标签管理", en: "Tags") + case .tokens: return "API Token" + case .devices: return env.preferences.t("登录设备", en: "Devices") + case .users: return env.preferences.t("用户管理", en: "Users") + case nil: return env.preferences.t("我的", en: "Me") + } + } + + private var headerIcon: String { + switch tab { + case .general: return "slider.horizontal.3" + case .account: return "shield.checkered" + case .system: return "info.circle" + case .tags: return "tag" + case .tokens: return "key" + case .devices: return "iphone" + case .users: return "person.2" + case nil: return "person" + } + } + + var body: some View { + VStack(spacing: 0) { + settingsHeader + ScrollView { + Group { + if let tab { + detailContent(tab) + .transition(.opacity.combined(with: .move(edge: .trailing))) + } else { + rootMenu + .transition(.opacity) + } + } + .padding(16) + .padding(.bottom, 96) + .animation(Motion.listContent, value: tab) + } + .background(AppTheme.background) + } + .background(AppTheme.background.ignoresSafeArea()) + .preferredColorScheme(env.preferences.colorScheme) + } + + // MARK: - Header (Android settingsHeader) + + private var settingsHeader: some View { + HStack(spacing: 0) { + Button { + if tab != nil { + withAnimation(Motion.chip) { tab = nil } + } else { + dismiss() + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + Image(systemName: headerIcon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AppTheme.accentStrong) + Text(title) + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(AppTheme.title) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + + Button { + withAnimation(Motion.chip) { + // cycle: system → light → dark → system + switch env.preferences.theme { + case "light": env.preferences.theme = "dark" + case "dark": env.preferences.theme = "system" + default: env.preferences.theme = "light" + } + } + } label: { + HStack(spacing: 8) { + Image(systemName: env.preferences.theme == "dark" ? "sun.max" : "moon") + .font(.system(size: 16, weight: .semibold)) + Text(themeToggleLabel) + .font(.system(size: 14, weight: .bold)) + .lineLimit(1) + } + .foregroundStyle(AppTheme.slate) + .padding(.horizontal, 8) + .frame(height: 36) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .frame(minHeight: 56) + .background(Color.white) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + } + + private var themeToggleLabel: String { + // Android shows the *action* text: switch to light when dark, else switch to dark + if env.preferences.theme == "dark" || (env.preferences.theme == "system" && env.preferences.colorScheme == .dark) { + return env.preferences.t("切换到浅色模式", en: "Light mode") + } + return env.preferences.t("切换到深色模式", en: "Dark mode") + } + + // MARK: - Root Me menu (Android activeTab === null) + + private var rootMenu: some View { + VStack(spacing: 16) { + // Card 1: general + account + settingsMenuCard { + menuRow( + icon: "slider.horizontal.3", + iconTint: AppTheme.accent, + iconBg: AppTheme.accentSoft, + title: env.preferences.t("常规设置", en: "General"), + showBorder: false + ) { + withAnimation(Motion.chip) { tab = .general } + } + menuRow( + icon: "shield.checkered", + iconTint: AppTheme.accent, + iconBg: AppTheme.accentSoft, + title: env.preferences.t("登录设置", en: "Account"), + showBorder: true + ) { + withAnimation(Motion.chip) { tab = .account } + } + } + + // Card 2: system + feedback + settingsMenuCard { + menuRow( + icon: "info.circle", + iconTint: AppTheme.accent, + iconBg: AppTheme.accentSoft, + title: env.preferences.t("系统信息", en: "System info"), + subtitle: env.preferences.t( + "查看版本与运行环境信息。", + en: "View version and runtime info." + ), + showBorder: false + ) { + withAnimation(Motion.chip) { tab = .system } + } + menuRow( + icon: "bubble.left.and.bubble.right", + iconTint: AppTheme.secondary, + iconBg: AppTheme.searchFill, + title: env.preferences.t("意见反馈", en: "Feedback"), + subtitle: env.preferences.t( + "报告问题或提出功能建议", + en: "Report issues or suggest features" + ), + trailing: .external, + showBorder: true + ) { + if let url = URL(string: "https://github.com/tianma-if/edgeever/issues") { + UIApplication.shared.open(url) + } + } + } + } + } + + private enum TrailingKind { case chevron, external } + + private func settingsMenuCard(@ViewBuilder _ content: () -> Content) -> some View { + VStack(spacing: 0) { + content() + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + } + + private func menuRow( + icon: String, + iconTint: Color, + iconBg: Color, + title: String, + subtitle: String? = nil, + trailing: TrailingKind = .chevron, + showBorder: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(iconBg) + .frame(width: 32, height: 32) + Image(systemName: icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(iconTint) + } + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(Color(hex: 0x1E293B)) + if let subtitle { + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: trailing == .external ? "arrow.up.right" : "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.muted) + } + .padding(.horizontal, 16) + .frame(minHeight: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .overlay(alignment: .top) { + if showBorder { + Rectangle().fill(AppTheme.cardBorder).frame(height: 1) + .padding(.leading, 16) + } + } + } + + // MARK: - Detail tabs + + @ViewBuilder + private func detailContent(_ tab: RootTab) -> some View { + switch tab { + case .general: + generalContent + case .account: + accountContent + case .system: + systemContent + case .tags: + TagsManagementView() + case .tokens: + ApiTokensView() + case .devices: + DevicesView() + case .users: + UsersManagementView() + } + } + + private var generalContent: some View { + VStack(spacing: 16) { + settingsGroup( + title: env.preferences.t("偏好设置", en: "Preferences"), + icon: "photo" + ) { + preferenceBlock( + title: env.preferences.t("界面语言", en: "Language"), + description: env.preferences.t("切换产品界面的显示语言。", en: "Switch the product UI language.") + ) { + Menu { + Button(env.preferences.t("跟随系统", en: "System")) { env.preferences.localeCode = "system" } + Button("简体中文") { env.preferences.localeCode = "zh-CN" } + Button("English") { env.preferences.localeCode = "en-US" } + } label: { + HStack { + Text(localeLabel) + .font(.system(size: 14)) + .foregroundStyle(AppTheme.title) + Spacer() + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + } + .padding(.horizontal, 12) + .frame(minHeight: 40) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8).stroke(AppTheme.border, lineWidth: 1) + ) + } + } + + preferenceBlock( + title: env.preferences.t("压缩笔记内图片", en: "Compress note images"), + description: env.preferences.t( + "上传大图时在本地压缩,节省资源占用。", + en: "Compress large images locally before upload to reduce storage use." + ), + showTopBorder: true + ) { + Toggle("", isOn: Bindable(env.preferences).useCompression) + .labelsHidden() + .tint(AppTheme.accent) + .frame(maxWidth: .infinity, alignment: .leading) + } + // List density lives in list-options sheet (Android NotesActionsModal), not here. + } + } + } + + private var accountContent: some View { + VStack(spacing: 16) { + // Account summary card + HStack(spacing: 12) { + ZStack { + Circle().fill(AppTheme.accentSoft).frame(width: 40, height: 40) + Image(systemName: "person") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(AppTheme.accentStrong) + } + VStack(alignment: .leading, spacing: 2) { + Text(env.preferences.t("当前账户", en: "Current account")) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.secondary) + Text(env.session.session?.user?.displayName ?? env.session.session?.user?.username ?? "—") + .font(.system(size: 15, weight: .heavy)) + .foregroundStyle(AppTheme.title) + let role = env.session.session?.user?.role == "owner" + ? env.preferences.t("实例管理员", en: "Owner") + : env.preferences.t("成员", en: "Member") + Text("@\(env.session.session?.user?.username ?? "—") · \(role)") + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .padding(.top, 1) + } + Spacer(minLength: 0) + } + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + + // Change password panel (Android AccountSecurityPanel) + AccountPasswordPanel() + + // Extended links (tokens/devices/users/tags) — compact rows, not on Android root Me + settingsMenuCard { + menuRow(icon: "iphone", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft, + title: env.preferences.t("登录设备", en: "Devices"), showBorder: false) { + withAnimation(Motion.chip) { tab = .devices } + } + menuRow(icon: "key", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft, + title: "API Token", showBorder: true) { + withAnimation(Motion.chip) { tab = .tokens } + } + menuRow(icon: "tag", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft, + title: env.preferences.t("标签管理", en: "Tags"), showBorder: true) { + withAnimation(Motion.chip) { tab = .tags } + } + if env.session.session?.user?.role == "owner" { + menuRow(icon: "person.2", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft, + title: env.preferences.t("用户管理", en: "Users"), showBorder: true) { + withAnimation(Motion.chip) { tab = .users } + } + } + } + + // Logout card + VStack(alignment: .leading, spacing: 0) { + Button { + Task { + await env.session.signOut() + dismiss() + } + } label: { + HStack(spacing: 8) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 15, weight: .bold)) + Text(env.preferences.t("退出登录", en: "Sign out")) + .font(.system(size: 14, weight: .heavy)) + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .frame(minHeight: 40) + .background(Color(hex: 0xE11D48)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color(hex: 0xFFF1F2)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(hex: 0xFECACA), lineWidth: 1) + ) + } + } + + private var systemContent: some View { + VStack(spacing: 16) { + settingsGroup(title: env.preferences.t("系统信息", en: "System info"), icon: "info.circle") { + infoRow(env.preferences.t("版本", en: "Version"), + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—", + showBorder: false) + infoRow("Build", + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—", + showBorder: true) + infoRow(env.preferences.t("实例", en: "Instance"), + env.session.session?.baseUrl ?? "—", + showBorder: true) + infoRow(env.preferences.t("运行环境", en: "Runtime"), + "Native SwiftUI", + showBorder: true) + } + + Button { + Task { await env.runSyncCycle() } + } label: { + HStack { + Image(systemName: "arrow.clockwise") + Text(env.preferences.t("立即同步", en: "Sync now")) + .font(.system(size: 14, weight: .bold)) + if env.isSyncing { ProgressView() } + } + .frame(maxWidth: .infinity) + .frame(minHeight: 44) + .foregroundStyle(AppTheme.title) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + } + .buttonStyle(.plain) + + if let err = env.lastSyncError { + Text(err) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + // MARK: - Building blocks + + private var localeLabel: String { + switch env.preferences.localeCode { + case "zh-CN": return "简体中文" + case "en-US": return "English" + default: return env.preferences.t("跟随系统", en: "System") + } + } + + private func settingsGroup( + title: String, + icon: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.accentStrong) + Text(title) + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(AppTheme.title) + } + .padding(16) + content() + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + } + + private func preferenceBlock( + title: String, + description: String, + showTopBorder: Bool = false, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppTheme.title) + Text(description) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .fixedSize(horizontal: false, vertical: true) + } + content() + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .top) { + if showTopBorder { + Rectangle().fill(AppTheme.cardBorder).frame(height: 1) + } + } + } + + private func infoRow(_ title: String, _ value: String, showBorder: Bool) -> some View { + HStack(alignment: .top) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + Spacer() + Text(value) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .multilineTextAlignment(.trailing) + } + .padding(16) + .overlay(alignment: .top) { + if showBorder { + Rectangle().fill(AppTheme.cardBorder).frame(height: 1) + } + } + } +} + +// MARK: - Password panel (Android AccountSecurityPanel) + +struct AccountPasswordPanel: View { + @Environment(AppEnvironment.self) private var env + @State private var currentPassword = "" + @State private var newPassword = "" + @State private var confirmPassword = "" + @State private var message: String? + @State private var isError = false + @State private var busy = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "key.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color(hex: 0x15803D)) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 3) { + Text(env.preferences.t("修改密码", en: "Change password")) + .font(.system(size: 16, weight: .heavy)) + .foregroundStyle(Color(hex: 0x17211A)) + Text(env.preferences.t( + "修改后会保留当前设备登录,并退出其他设备上的登录会话。", + en: "Keeps this device signed in and signs out other sessions." + )) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + passwordField(env.preferences.t("当前密码", en: "Current password"), text: $currentPassword) + passwordField(env.preferences.t("新密码", en: "New password"), text: $newPassword) + passwordField(env.preferences.t("确认新密码", en: "Confirm password"), text: $confirmPassword) + + if let message { + Text(message) + .font(.system(size: 13, weight: isError ? .regular : .bold)) + .foregroundStyle(isError ? Color(hex: 0xBE123C) : Color(hex: 0x15803D)) + } + + Button { + Task { await submit() } + } label: { + Text(busy + ? env.preferences.t("正在修改…", en: "Updating…") + : env.preferences.t("修改密码", en: "Change password")) + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(minHeight: 48) + .background(Color(hex: 0x15803D)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .opacity(busy ? 0.45 : 1) + } + .buttonStyle(.plain) + .disabled(busy) + } + .padding(16) + .padding(.bottom, 8) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + } + + private func passwordField(_ label: String, text: Binding) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(label) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(AppTheme.meta) + SecureField("", text: text) + .font(.system(size: 15)) + .padding(.horizontal, 13) + .frame(minHeight: 48) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(hex: 0xCAD8CC), lineWidth: 1) + ) + } + } + + private func submit() async { + message = nil + isError = false + if newPassword.count < 8 { + isError = true + message = env.preferences.t("新密码至少需要 8 个字符", en: "Password must be at least 8 characters") + return + } + if newPassword != confirmPassword { + isError = true + message = env.preferences.t("两次输入的新密码不一致", en: "Passwords do not match") + return + } + busy = true + defer { busy = false } + do { + try await env.session.client.changePassword( + current: currentPassword, + newPassword: newPassword, + confirm: confirmPassword + ) + currentPassword = "" + newPassword = "" + confirmPassword = "" + isError = false + message = env.preferences.t("密码已修改成功。", en: "Password updated.") + } catch { + isError = true + message = error.localizedDescription + } + } +} + +// MARK: - Subpages (still used from account extras) + +struct TagsManagementView: View { + @Environment(AppEnvironment.self) private var env + @State private var tags: [TagSummary] = [] + @State private var renameFrom = "" + @State private var renameTo = "" + @State private var error: String? + + var body: some View { + VStack(spacing: 12) { + if let error { + Text(error).font(.system(size: 13)).foregroundStyle(AppTheme.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + settingsListCard { + ForEach(Array(tags.enumerated()), id: \.element.name) { index, tag in + HStack { + Text("#\(tag.name)") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppTheme.title) + Spacer() + Text("\(tag.memoCount)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + } + .padding(.horizontal, 16) + .frame(minHeight: 52) + .overlay(alignment: .top) { + if index > 0 { Rectangle().fill(AppTheme.cardBorder).frame(height: 1) } + } + .contextMenu { + Button(env.preferences.t("重命名", en: "Rename")) { + renameFrom = tag.name + renameTo = tag.name + } + Button(role: .destructive) { + Task { await deleteTag(tag.name) } + } label: { + Text(env.preferences.t("删除", en: "Delete")) + } + } + } + } + } + .task { await load() } + .alert(env.preferences.t("重命名标签", en: "Rename tag"), isPresented: Binding( + get: { !renameFrom.isEmpty }, + set: { if !$0 { renameFrom = "" } } + )) { + TextField(env.preferences.t("新名称", en: "New name"), text: $renameTo) + Button(env.preferences.t("保存", en: "Save")) { Task { await rename() } } + Button(env.preferences.t("取消", en: "Cancel"), role: .cancel) { renameFrom = "" } + } + } + + private func settingsListCard(@ViewBuilder _ content: () -> Content) -> some View { + VStack(spacing: 0) { content() } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + } + + private func load() async { + do { tags = try await env.session.client.listTags() } + catch let err { error = err.localizedDescription } + } + + private func rename() async { + do { + try await env.session.client.renameTag(tag: renameFrom, name: renameTo) + renameFrom = "" + await load() + await env.runSyncCycle() + } catch let err { error = err.localizedDescription } + } + + private func deleteTag(_ name: String) async { + do { + try await env.session.client.deleteTag(tag: name) + await load() + await env.runSyncCycle() + } catch let err { error = err.localizedDescription } + } +} + +struct ApiTokensView: View { + @Environment(AppEnvironment.self) private var env + @State private var tokens: [ApiToken] = [] + @State private var scopes: [String] = [] + @State private var newName = "" + @State private var createdToken: String? + @State private var error: String? + + var body: some View { + VStack(spacing: 16) { + if let error { + Text(error).font(.system(size: 13)).foregroundStyle(AppTheme.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + VStack(spacing: 0) { + ForEach(Array(tokens.enumerated()), id: \.element.id) { index, token in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(token.name).font(.system(size: 14, weight: .bold)) + Text(token.scopes.joined(separator: ", ")) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + } + Spacer() + Button(role: .destructive) { + Task { + try? await env.session.client.revokeApiToken(id: token.id) + await load() + } + } label: { + Text(env.preferences.t("吊销", en: "Revoke")) + .font(.system(size: 13, weight: .bold)) + } + } + .padding(16) + .overlay(alignment: .top) { + if index > 0 { Rectangle().fill(AppTheme.cardBorder).frame(height: 1) } + } + } + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + + VStack(alignment: .leading, spacing: 10) { + Text(env.preferences.t("新建", en: "Create")) + .font(.system(size: 14, weight: .heavy)) + TextField(env.preferences.t("名称", en: "Name"), text: $newName) + .padding(.horizontal, 12) + .frame(height: 44) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(AppTheme.border, lineWidth: 1)) + Button { + Task { await create() } + } label: { + Text(env.preferences.t("创建 Token", en: "Create token")) + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(AppTheme.title) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + if let createdToken { + Text(createdToken) + .font(.system(size: 12, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + } + .task { await load() } + } + + private func load() async { + do { + let res = try await env.session.client.listApiTokens() + tokens = res.apiTokens + scopes = res.availableScopes + } catch { self.error = error.localizedDescription } + } + + private func create() async { + guard !newName.isEmpty else { return } + do { + let created = try await env.session.client.createApiToken( + name: newName, + scopes: scopes.isEmpty ? ["memos:read", "memos:write"] : scopes + ) + createdToken = created.token + newName = "" + await load() + } catch { self.error = error.localizedDescription } + } +} + +struct DevicesView: View { + @Environment(AppEnvironment.self) private var env + @State private var sessions: [LoginDeviceSession] = [] + + var body: some View { + VStack(spacing: 12) { + VStack(spacing: 0) { + ForEach(Array(sessions.enumerated()), id: \.element.id) { index, s in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(s.label ?? s.userAgent ?? s.id) + .font(.system(size: 14, weight: .bold)) + .lineLimit(2) + Text("\(s.isCurrent ? env.preferences.t("当前 · ", en: "Current · ") : "")\(s.lastSeenAt)") + .font(.system(size: 11)) + .foregroundStyle(AppTheme.secondary) + } + Spacer() + } + .padding(16) + .overlay(alignment: .top) { + if index > 0 { Rectangle().fill(AppTheme.cardBorder).frame(height: 1) } + } + .swipeActions { + if !s.isCurrent { + Button(role: .destructive) { + Task { + try? await env.session.client.revokeLoginDeviceSession(id: s.id) + sessions = (try? await env.session.client.listLoginDeviceSessions()) ?? [] + } + } label: { + Text(env.preferences.t("注销", en: "Revoke")) + } + } + } + } + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + + Button { + Task { + try? await env.session.client.revokeOtherLoginDeviceSessions() + sessions = (try? await env.session.client.listLoginDeviceSessions()) ?? [] + } + } label: { + Text(env.preferences.t("注销其他设备", en: "Sign out other devices")) + .font(.system(size: 14, weight: .bold)) + .frame(maxWidth: .infinity) + .frame(height: 44) + .foregroundStyle(AppTheme.title) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + } + .buttonStyle(.plain) + } + .task { sessions = (try? await env.session.client.listLoginDeviceSessions()) ?? [] } + } +} + +struct UsersManagementView: View { + @Environment(AppEnvironment.self) private var env + @State private var users: [InstanceUser] = [] + @State private var username = "" + @State private var password = "" + @State private var error: String? + + var body: some View { + VStack(spacing: 16) { + if let error { + Text(error).font(.system(size: 13)).foregroundStyle(AppTheme.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + VStack(spacing: 0) { + ForEach(Array(users.enumerated()), id: \.element.id) { index, user in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(user.displayName ?? user.username) + .font(.system(size: 14, weight: .bold)) + Text("@\(user.username) · \(user.role)") + .font(.system(size: 12)) + .foregroundStyle(AppTheme.secondary) + } + Spacer() + if user.isDisabled { + Text(env.preferences.t("已禁用", en: "Disabled")) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.danger) + } + if user.role != "owner" { + Button { + Task { + _ = try? await env.session.client.updateUser( + userId: user.id, + displayName: nil, + password: nil, + isDisabled: !user.isDisabled + ) + users = (try? await env.session.client.listUsers()) ?? [] + } + } label: { + Text(user.isDisabled + ? env.preferences.t("启用", en: "Enable") + : env.preferences.t("禁用", en: "Disable")) + .font(.system(size: 13, weight: .bold)) + } + } + } + .padding(16) + .overlay(alignment: .top) { + if index > 0 { Rectangle().fill(AppTheme.cardBorder).frame(height: 1) } + } + } + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + + VStack(alignment: .leading, spacing: 10) { + Text(env.preferences.t("创建成员", en: "Create member")) + .font(.system(size: 14, weight: .heavy)) + TextField(env.preferences.t("用户名", en: "Username"), text: $username) + .textInputAutocapitalization(.never) + .padding(.horizontal, 12) + .frame(height: 44) + .background(AppTheme.searchFill) + .clipShape(RoundedRectangle(cornerRadius: 8)) + SecureField(env.preferences.t("密码", en: "Password"), text: $password) + .padding(.horizontal, 12) + .frame(height: 44) + .background(AppTheme.searchFill) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Button { + Task { + do { + _ = try await env.session.client.createUser( + username: username, displayName: nil, password: password + ) + username = "" + password = "" + users = try await env.session.client.listUsers() + } catch { self.error = error.localizedDescription } + } + } label: { + Text(env.preferences.t("创建", en: "Create")) + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(AppTheme.title) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1)) + } + .task { users = (try? await env.session.client.listUsers()) ?? [] } + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift new file mode 100644 index 00000000..9e451a59 --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift @@ -0,0 +1,196 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class WorkspaceStore { + var notebooks: [Notebook] = [] + var memos: [MemoSummary] = [] + var selectedNotebookId: String? + var searchText = "" + var sort: MemoSortMode = .updatedDesc + var filter: MobileMemoFilterMode = .all + var totalCount = 0 + var nextOffset: Int? + var isLoadingList = false + var isLoadingMore = false + var listError: String? + var selectionMode = false + var selectedMemoIds: Set = [] + var showNotebookPicker = false + var showActions = false + + private var searchTask: Task? + + var activeNotebook: Notebook? { + guard let selectedNotebookId else { return nil } + return notebooks.first { $0.id == selectedNotebookId } + } + + var titleLabel: String { + activeNotebook?.name ?? "全部笔记" + } + + func reload(env: AppEnvironment, resetOffset: Bool = true) { + guard let scope = env.session.dataScope else { + notebooks = [] + memos = [] + return + } + do { + notebooks = try env.mirror.listNotebooks(scope: scope) + try loadMemos(env: env, scope: scope, resetOffset: resetOffset) + listError = nil + } catch { + listError = error.localizedDescription + } + } + + func scheduleSearch(env: AppEnvironment) { + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + reload(env: env) + } + } + + func loadMemos(env: AppEnvironment, scope: String, resetOffset: Bool) throws { + isLoadingList = resetOffset + defer { isLoadingList = false } + let offset = resetOffset ? 0 : (nextOffset ?? memos.count) + let notebookIds: [String]? = { + guard let selectedNotebookId else { return nil } + return NotebookHierarchy.descendantIds(notebooks: notebooks, targetNotebookId: selectedNotebookId) + }() + let result = try env.mirror.listMemos( + scope: scope, + params: LocalMemoListParams( + notebookId: nil, + notebookIds: notebookIds, + q: searchText, + sort: sort, + filter: filter.toMemoFilterMode(), + limit: 50, + offset: offset + ) + ) + if resetOffset { + memos = result.memos + } else { + let existing = Set(memos.map(\.id)) + memos.append(contentsOf: result.memos.filter { !existing.contains($0.id) }) + } + totalCount = result.totalCount + nextOffset = result.nextOffset + } + + func loadMore(env: AppEnvironment) { + guard let scope = env.session.dataScope, nextOffset != nil, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + try? loadMemos(env: env, scope: scope, resetOffset: false) + } + + func toggleFilter(_ requested: MobileMemoFilterMode) { + filter = MobileUI.toggleMemoFilterMode(current: filter, requested: requested) + } + + func enterSelection(memoId: String? = nil) { + selectionMode = true + if let memoId { + selectedMemoIds = [memoId] + } + } + + func clearSelection() { + selectionMode = false + selectedMemoIds = [] + } + + func toggleSelected(_ memoId: String) { + selectedMemoIds = MobileUI.toggleMemoSelection(current: selectedMemoIds, memoId: memoId) + } + + func softDelete(env: AppEnvironment, memoId: String) async { + guard let scope = env.session.dataScope else { return } + do { + if memoId.hasPrefix("local:") { + try env.outbox.cancelMemo(scope: scope, memoId: memoId) + try env.mirror.deleteMemo(scope: scope, id: memoId) + } else { + _ = try env.mirror.softDeleteMemo(scope: scope, id: memoId) + try await env.session.client.deleteMemo(id: memoId, permanent: false) + } + selectedMemoIds.remove(memoId) + reload(env: env) + } catch { + listError = error.localizedDescription + } + } + + func softDeleteSelection(env: AppEnvironment) async { + let ids = Array(selectedMemoIds) + for id in ids { + await softDelete(env: env, memoId: id) + } + clearSelection() + } + + func togglePin(env: AppEnvironment, memo: MemoSummary) async { + guard let scope = env.session.dataScope else { return } + do { + let updated = try await env.session.client.updateMemo( + id: memo.id, + expectedRevision: nil, + expectedContentHash: nil, + editSessionId: nil, + notebookId: nil, + title: nil, + isPinned: !memo.isPinned, + contentMarkdown: nil, + tags: nil + ) + try env.mirror.upsertMemo(scope: scope, memo: updated) + reload(env: env) + } catch { + listError = error.localizedDescription + } + } + + func moveSelection(env: AppEnvironment, notebookId: String) async { + guard let scope = env.session.dataScope else { return } + for memoId in selectedMemoIds { + guard var memo = try? env.mirror.resolveMemo(scope: scope, id: memoId) else { continue } + memo.notebookId = notebookId + memo.updatedAt = EdgeEverDate.nowString() + try? env.mirror.upsertMemo(scope: scope, memo: memo) + try? env.outbox.enqueueUpdate( + scope: scope, + payload: MemoUpdatePayload( + memoId: memo.id, + expectedRevision: memo.revision, + expectedContentHash: memo.contentHash, + title: memo.title ?? "", + contentMarkdown: memo.contentMarkdown, + notebookId: notebookId, + tags: memo.tags + ) + ) + } + await env.runSyncCycle() + clearSelection() + reload(env: env) + } +} + +extension MobileMemoFilterMode { + func toMemoFilterMode() -> MemoFilterMode { + switch self { + case .all: return .all + case .tagged: return .tagged + case .untagged: return .untagged + case .pinned: return .pinned + } + } +} diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift new file mode 100644 index 00000000..86e0d9cf --- /dev/null +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift @@ -0,0 +1,646 @@ +import SwiftUI +import Pow + +/// Wrapper so `fullScreenCover(item:)` can present edit for a memo id. +struct EditingMemoRoute: Identifiable, Hashable { + let id: String +} + +struct WorkspaceView: View { + @Environment(AppEnvironment.self) private var env + @State private var store = WorkspaceStore() + @State private var path = NavigationPath() + @State private var showSettings = false + @State private var showNewNote = false + @State private var showMoveSheet = false + @State private var conflictItem: OutboxItem? + @State private var createTapCount = 0 + @State private var syncPulse = 0 + /// Edit is presented from the workspace root — more reliable than cover on a pushed detail page. + @State private var editingMemo: EditingMemoRoute? + + /// Android SafeAreaView edges=[top,left,right] — bottom chrome owns home indicator. + private var showsBottomChrome: Bool { + store.selectionMode || path.isEmpty + } + + /// Android: `height: bottomNavigationHeight + safeAreaInsets.bottom` only. + /// Create button `marginTop: -16` overlaps list content — do NOT add 16pt list padding + /// (that paints a second empty strip above the white bar). + private var bottomChromeHeight: CGFloat { + MobileUIMetrics.bottomChromeHeight + } + + var body: some View { + NavigationStack(path: $path) { + // List pads exactly the solid white chrome height; elevated + draws over list (Android). + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + syncBanner + listHeader + NotesListView(store: store, path: $path) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(AppTheme.background) + .padding(.bottom, showsBottomChrome ? bottomChromeHeight : 0) + + if store.selectionMode { + selectionBar + } else if path.isEmpty { + bottomNav + } + } + .ignoresSafeArea(.container, edges: .bottom) + .navigationBarHidden(true) + .navigationDestination(for: String.self) { memoId in + MemoDetailView(memoId: memoId) { editId in + editingMemo = EditingMemoRoute(id: editId) + } + } + // Android Me is full-screen (activeView === "settings"), not a half-sheet Form. + .fullScreenCover(isPresented: $showSettings) { + SettingsView() + } + // Android CreateMemoModal is fullScreen — not a half sheet / Form. + .fullScreenCover(isPresented: $showNewNote) { + MemoEditView(mode: .create(notebookId: store.selectedNotebookId ?? store.notebooks.first?.id ?? "")) + .onDisappear { store.reload(env: env) } + } + // Edit from detail FAB — present at root so NavigationStack destination covers always work. + .fullScreenCover(item: $editingMemo) { route in + MemoEditView(mode: .edit(memoId: route.id)) + .onDisappear { + store.reload(env: env) + } + } + .sheet(isPresented: $store.showNotebookPicker) { + NotebookPickerSheet(store: store) + } + .sheet(isPresented: $store.showActions) { + ListActionsSheet(store: store) + } + .sheet(isPresented: $showMoveSheet) { + MoveNotebookSheet(notebooks: store.notebooks) { notebookId in + Task { + await store.moveSelection(env: env, notebookId: notebookId) + showMoveSheet = false + } + } + } + .sheet(item: $conflictItem) { item in + ConflictResolutionView(item: item) { + conflictItem = nil + store.reload(env: env) + } + } + .onAppear { + store.reload(env: env) + consumeShare() + detectConflicts() + } + .onReceive(NotificationCenter.default.publisher(for: .edgeEverShareReceived)) { _ in + consumeShare() + } + .onChange(of: env.session.session?.token) { _, _ in + store.reload(env: env) + } + .onChange(of: env.isSyncing) { wasSyncing, isSyncing in + // Refresh list when a background/bootstrap sync finishes. + if wasSyncing && !isSyncing { + store.reload(env: env) + syncPulse += 1 + } + } + .refreshable { + await env.runSyncCycle() + store.reload(env: env) + detectConflicts() + } + } + .preferredColorScheme(env.preferences.colorScheme) + } + + // MARK: - Header (Android NotesView parity) + + private var listHeader: some View { + let searchActive = !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty + return VStack(alignment: .leading, spacing: 0) { + if store.selectionMode { + HStack(spacing: 0) { + Button { + store.clearSelection() + } label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + .frame(width: 38, height: 38) + } + Text(store.selectedMemoIds.isEmpty + ? env.preferences.t("选择笔记", en: "Select notes") + : env.preferences.t("已选择 \(store.selectedMemoIds.count) 条", en: "\(store.selectedMemoIds.count) selected")) + .font(.system(size: 17, weight: .heavy)) + .foregroundStyle(AppTheme.title) + .padding(.horizontal, 8) + Spacer(minLength: 0) + } + .frame(minHeight: 44) + } + + HStack(alignment: .center) { + Button { + store.showNotebookPicker = true + } label: { + HStack(spacing: 4) { + Text(store.titleLabel) + .font(AppTheme.notebookTitleFont) + .foregroundStyle(AppTheme.title) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.secondary) + } + .frame(minHeight: MobileUIMetrics.compactControlHeight) + } + .buttonStyle(.plain) + Spacer(minLength: 8) + Button { + store.showActions = true + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + .frame(width: MobileUIMetrics.compactControlHeight, height: MobileUIMetrics.compactControlHeight) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(env.preferences.t("列表选项", en: "List options")) + } + .padding(.bottom, 12) + + HStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(searchActive ? AppTheme.accent : AppTheme.secondary) + .symbolEffect(.bounce, value: searchActive) + TextField(env.preferences.t("搜索笔记", en: "Search notes"), text: $store.searchText) + .font(AppTheme.searchFont) + .foregroundStyle(AppTheme.title) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if searchActive { + Button { + withAnimation(Motion.search) { + store.searchText = "" + } + store.reload(env: env) + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(AppTheme.secondary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 12) + .frame(height: MobileUIMetrics.compactControlHeight) + .background(searchActive ? AppTheme.searchActiveFill : AppTheme.searchFill) + .clipShape(Capsule()) + .overlay( + Capsule().stroke(searchActive ? AppTheme.accentBright : Color.clear, lineWidth: 1) + ) + .animation(Motion.search, value: searchActive) + + filterChip( + active: store.filter == .pinned, + // Match Android pin filter glyph (star outline / filled when active) + systemImage: store.filter == .pinned ? "star.fill" : "star", + label: env.preferences.t("置顶", en: "Pinned") + ) { + withAnimation(Motion.chip) { + store.toggleFilter(.pinned) + } + store.reload(env: env) + } + filterChip( + active: store.filter == .tagged, + systemImage: "tag", + label: env.preferences.t("有标签", en: "Tagged") + ) { + withAnimation(Motion.chip) { + store.toggleFilter(.tagged) + } + store.reload(env: env) + } + filterChip( + active: store.filter == .untagged, + systemImage: "tag.fill", + label: env.preferences.t("无标签", en: "Untagged") + ) { + withAnimation(Motion.chip) { + store.toggleFilter(.untagged) + } + store.reload(env: env) + } + } + .edgeEverSelectionFeedback(store.filter) + .onChange(of: store.searchText) { _, _ in + store.scheduleSearch(env: env) + } + + if searchActive || store.filter != .all { + constraintBar + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + .background(AppTheme.background) + .overlay(alignment: .bottom) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + .animation(Motion.search, value: searchActive || store.filter != .all) + } + + private var constraintBar: some View { + let searchActive = !store.searchText.trimmingCharacters(in: .whitespaces).isEmpty + return HStack(spacing: 8) { + if searchActive { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + .font(.system(size: 10, weight: .bold)) + Text(env.preferences.t("正在搜索", en: "Searching")) + .font(.system(size: 12, weight: .bold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(AppTheme.accent) + .clipShape(Capsule()) + + Text(env.preferences.t("\(store.totalCount) 条结果", en: "\(store.totalCount) results")) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.accentText) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(env.preferences.t("退出搜索", en: "Exit")) { + store.searchText = "" + store.reload(env: env) + } + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.accentText) + } else { + Text(env.preferences.t("筛选:\(filterLabel) · \(store.totalCount) 条", en: "Filter: \(filterLabelEN) · \(store.totalCount)")) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Button(env.preferences.t("重置", en: "Reset")) { + store.filter = .all + store.reload(env: env) + } + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppTheme.slate) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(minHeight: 32) + .background(searchActive ? AppTheme.searchActiveFill : Color.white) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(searchActive ? AppTheme.accentBorder : AppTheme.border, lineWidth: 1) + ) + .overlay(alignment: .leading) { + if searchActive { + RoundedRectangle(cornerRadius: 1.5) + .fill(AppTheme.accentBright) + .frame(width: 3) + .padding(.vertical, 1) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private var filterLabel: String { + switch store.filter { + case .pinned: return "置顶" + case .tagged: return "有标签" + case .untagged: return "无标签" + case .all: return "全部" + } + } + + private var filterLabelEN: String { + switch store.filter { + case .pinned: return "Pinned" + case .tagged: return "Tagged" + case .untagged: return "Untagged" + case .all: return "All" + } + } + + private func filterChip(active: Bool, systemImage: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(active ? .white : AppTheme.slate) + .frame(width: MobileUIMetrics.compactControlHeight, height: MobileUIMetrics.compactControlHeight) + .background(active ? AppTheme.filterActive : Color.white) + .clipShape(Circle()) + .overlay(Circle().stroke(active ? AppTheme.filterActive : AppTheme.border, lineWidth: 1)) + .accessibilityLabel(label) + // Pow ping when filter becomes active + .changeEffect( + .ping(shape: Circle(), style: AppTheme.filterActive.opacity(0.35), count: 1), + value: active, + isEnabled: active + ) + } + .buttonStyle(FilterChipButtonStyle(active: active)) + } + + /// Android `bottomNav`: + /// - solid white height = 52 + safeAreaInsets.bottom + /// - create `marginTop: -16` overlaps the list (no extra empty strip above the bar) + private var bottomNav: some View { + let canCreate = !store.notebooks.isEmpty + let bottomInset = MobileUIMetrics.bottomSafeInset + // Android marginTop: -16 + let createLift: CGFloat = 16 + + return VStack(spacing: 0) { + ZStack { + HStack { + bottomNavItem( + systemImage: "house.fill", + label: env.preferences.t("首页", en: "Home"), + active: true + ) { + withAnimation(Motion.chip) { + path = NavigationPath() + } + } + + bottomNavItem( + systemImage: "person", + label: env.preferences.t("我的", en: "Me"), + active: false + ) { + showSettings = true + } + } + .padding(.horizontal, 36) + + // Floats above the white bar into list content (Android). Hit target moves with offset. + Button { + createTapCount += 1 + showNewNote = true + } label: { + Image(systemName: "plus") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(canCreate ? .white : Color(hex: 0xE2E8F0)) + .frame( + width: MobileUIMetrics.floatingCreateButtonSize, + height: MobileUIMetrics.floatingCreateButtonSize + ) + .background(canCreate ? AppTheme.accentBright : Color(hex: 0xCBD5E1)) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white, lineWidth: 4)) + .shadow( + color: AppTheme.fabShadow.opacity(canCreate ? 0.35 : 0), + radius: 4, + y: 2 + ) + .contentShape(Circle()) + } + .buttonStyle(CreateButtonPressStyle()) + .edgeEverCreatePing(count: createTapCount) + .offset(y: -createLift) + .zIndex(1) + .disabled(!canCreate) + .accessibilityLabel(env.preferences.t("新建笔记", en: "New note")) + .accessibilityIdentifier("bottomCreateButton") + } + .frame(height: MobileUIMetrics.bottomNavigationHeight) + .frame(maxWidth: .infinity) + // Do not clip: elevated + must paint/hit above the bar into the list (Android marginTop -16). + + // Home indicator — same white surface, continuous with the 52pt band. + Color.white + .frame(height: bottomInset) + .frame(maxWidth: .infinity) + } + .background(Color.white) + .overlay(alignment: .top) { + // Border on the top edge of the solid white bar (not above a ghost strip). + Rectangle() + .fill(AppTheme.border) + .frame(height: 1) + } + .accessibilityIdentifier("bottomNav") + } + + private func bottomNavItem( + systemImage: String, + label: String, + active: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(active ? AppTheme.title : AppTheme.secondary) + Text(label) + .font(AppTheme.bottomNavFont) + .foregroundStyle(active ? AppTheme.title : AppTheme.secondary) + } + .frame(minWidth: 58, minHeight: MobileUIMetrics.minimumTouchTarget) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } + + private var selectionBar: some View { + let bottomInset = MobileUIMetrics.bottomSafeInset + return VStack(spacing: 0) { + HStack(spacing: 24) { + Button { + showMoveSheet = true + } label: { + VStack(spacing: 4) { + Image(systemName: "folder") + Text(env.preferences.t("移动", en: "Move")).font(.caption2.weight(.bold)) + } + } + Button(role: .destructive) { + Task { await store.softDeleteSelection(env: env) } + } label: { + VStack(spacing: 4) { + Image(systemName: "trash") + Text(env.preferences.t("删除", en: "Delete")).font(.caption2.weight(.bold)) + } + } + Spacer() + Text(env.preferences.t("已选 \(store.selectedMemoIds.count)", en: "\(store.selectedMemoIds.count) selected")) + .font(.caption.weight(.bold)) + .foregroundStyle(AppTheme.secondary) + } + .padding(.horizontal, 32) + .padding(.top, 4) + .frame(height: MobileUIMetrics.bottomNavigationHeight, alignment: .top) + .frame(maxWidth: .infinity) + + Color.white + .frame(height: bottomInset) + .frame(maxWidth: .infinity) + } + .background(Color.white) + .overlay(alignment: .top) { + Rectangle().fill(AppTheme.border).frame(height: 1) + } + .accessibilityIdentifier("selectionBar") + } + + private var syncBanner: some View { + Group { + if env.isSyncing { + VStack(alignment: .leading, spacing: 4) { + ProgressView(value: progressValue) + .tint(AppTheme.accent) + if let p = env.bootstrapProgress, p.totalCount > 0 { + Text(env.preferences.t("已加载 \(p.loadedCount) / \(p.totalCount) 条笔记", en: "Loaded \(p.loadedCount) / \(p.totalCount) notes")) + .font(.caption2) + .foregroundStyle(AppTheme.secondary) + } else { + Text(env.preferences.t("正在同步笔记", en: "Syncing notes")) + .font(.caption2) + .foregroundStyle(AppTheme.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.white) + .transition(.move(edge: .top).combined(with: .opacity)) + } + if let err = env.lastSyncError { + Text(err) + .font(.caption) + .foregroundStyle(AppTheme.danger) + .padding(.horizontal, 12) + .edgeEverErrorShake(on: err) + .transition(Motion.softFade) + } + } + .animation(Motion.search, value: env.isSyncing) + .animation(Motion.search, value: env.lastSyncError) + } + + private var progressValue: Double { + guard let p = env.bootstrapProgress, p.totalCount > 0 else { return 0 } + return Double(p.loadedCount) / Double(p.totalCount) + } + + private func consumeShare() { + let payloads = env.shareHandoff.consumePending() + guard let first = payloads.first, let scope = env.session.dataScope else { return } + var bodyParts: [String] = [] + if let title = first.title, !title.isEmpty { bodyParts.append(title) } + if let url = first.url, !url.isEmpty { bodyParts.append(url) } + if let text = first.text, !text.isEmpty { bodyParts.append(text) } + let body = bodyParts.joined(separator: "\n\n") + try? env.drafts.write( + scope: scope, + draft: MemoDraft( + draftKey: DraftRepository.newKey, + title: first.title ?? "", + contentMarkdown: body, + contentJson: nil, + notebookId: store.selectedNotebookId ?? store.notebooks.first?.id ?? "", + tagsText: "", + expectedRevision: nil, + updatedAt: EdgeEverDate.nowString() + ) + ) + showNewNote = true + } + + private func detectConflicts() { + guard let scope = env.session.dataScope else { return } + let items = (try? env.outbox.listItems(scope: scope)) ?? [] + conflictItem = items.first { $0.status == .conflict } + } +} + +// NotebookPickerSheet / ListActionsSheet live in their own files for Android parity. + +struct MoveNotebookSheet: View { + @Environment(AppEnvironment.self) private var env + let notebooks: [Notebook] + var onPick: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var query = "" + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(hex: 0xCBD5E1)) + .frame(width: 42, height: 4) + .padding(.top, 10) + .padding(.bottom, 8) + + HStack { + Text(env.preferences.t("移动到", en: "Move to")) + .font(.system(size: 15, weight: .heavy)) + .foregroundStyle(AppTheme.title) + Spacer() + Button(env.preferences.t("取消", en: "Cancel")) { dismiss() } + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.slate) + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(AppTheme.secondary) + TextField(env.preferences.t("搜索笔记本", en: "Search notebooks"), text: $query) + .font(.system(size: 14)) + } + .padding(.horizontal, 12) + .frame(height: 36) + .background(AppTheme.searchFill) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, 12) + .padding(.bottom, 8) + + ScrollView { + let items = NotebookHierarchy.treeItems(from: notebooks) + let visible = NotebookHierarchy.searchVisibleIds(notebooks: notebooks, searchText: query) + VStack(spacing: 0) { + ForEach(items.filter { visible.contains($0.id) }) { item in + Button { + onPick(item.id) + } label: { + Text(item.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(AppTheme.title) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, CGFloat(min(item.depth * 18, 54))) + .padding(.horizontal, 12) + .frame(minHeight: 48) + } + .buttonStyle(.plain) + } + } + } + } + .background(Color.white) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + } +} diff --git a/apps/ios/EdgeEver/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/EdgeEver/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..bd2b9662 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.451", + "green" : "0.675", + "red" : "0.098" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/EdgeEver/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/EdgeEver/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a657e336 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/EdgeEver/Resources/Assets.xcassets/AppIcon.appiconset/icon.png b/apps/ios/EdgeEver/Resources/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 00000000..72ddc4fd Binary files /dev/null and b/apps/ios/EdgeEver/Resources/Assets.xcassets/AppIcon.appiconset/icon.png differ diff --git a/apps/ios/EdgeEver/Resources/Assets.xcassets/Contents.json b/apps/ios/EdgeEver/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/EdgeEver/Resources/EdgeEver.entitlements b/apps/ios/EdgeEver/Resources/EdgeEver.entitlements new file mode 100644 index 00000000..4cdca9c9 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/EdgeEver.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.edgeever.mobile + + + diff --git a/apps/ios/EdgeEver/Resources/EditorBundle/index.html b/apps/ios/EdgeEver/Resources/EditorBundle/index.html new file mode 100644 index 00000000..f0dbb1e4 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/EditorBundle/index.html @@ -0,0 +1,3795 @@ + + + + + + EdgeEver Editor + + + + +
+
+ + diff --git a/apps/ios/EdgeEver/Resources/Info.plist b/apps/ios/EdgeEver/Resources/Info.plist new file mode 100644 index 00000000..f745b3a6 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + EdgeEver + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + EdgeEver + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + org.edgeever.mobile + CFBundleURLSchemes + + edgeever + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + NSCameraUsageDescription + EdgeEver only uses the camera if content inside a note explicitly requests it. + NSMicrophoneUsageDescription + EdgeEver only uses the microphone if content inside a note explicitly requests it. + NSPhotoLibraryUsageDescription + EdgeEver needs photo library access to insert images into notes. + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/apps/ios/EdgeEver/Resources/Localizable.xcstrings b/apps/ios/EdgeEver/Resources/Localizable.xcstrings new file mode 100644 index 00000000..fc42abb2 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/Localizable.xcstrings @@ -0,0 +1,54 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "EdgeEver" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "EdgeEver" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "EdgeEver" + } + } + } + }, + "Native iOS shell (SwiftUI)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Native iOS shell (SwiftUI)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "原生 iOS 壳(SwiftUI)" + } + } + } + }, + "RN iOS path abandoned — see docs/ios-swift-rewrite.md" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RN iOS path abandoned — see docs/ios-swift-rewrite.md" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已放弃 RN iOS 路径 — 见 docs/ios-swift-rewrite.md" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/apps/ios/EdgeEver/Resources/PrivacyInfo.xcprivacy b/apps/ios/EdgeEver/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..bad32761 --- /dev/null +++ b/apps/ios/EdgeEver/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,37 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/apps/ios/EdgeEver/Supporting/JSONCoding.swift b/apps/ios/EdgeEver/Supporting/JSONCoding.swift new file mode 100644 index 00000000..42a5c547 --- /dev/null +++ b/apps/ios/EdgeEver/Supporting/JSONCoding.swift @@ -0,0 +1,16 @@ +import Foundation + +enum EdgeEverJSON { + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() +} diff --git a/apps/ios/EdgeEver/Supporting/URL+Normalize.swift b/apps/ios/EdgeEver/Supporting/URL+Normalize.swift new file mode 100644 index 00000000..5eda6609 --- /dev/null +++ b/apps/ios/EdgeEver/Supporting/URL+Normalize.swift @@ -0,0 +1,47 @@ +import Foundation + +extension URL { + /// Strip trailing slashes for stable instance keys / API base URLs. + var edgeEverNormalizedBase: URL { + var absolute = absoluteString + while absolute.hasSuffix("/") { + absolute.removeLast() + } + return URL(string: absolute) ?? self + } +} + +enum EdgeEverURLNormalizer { + static func normalizeInstanceURL(_ raw: String) throws -> URL { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw EdgeEverURLError.empty + } + if !trimmed.contains("://") { + trimmed = "https://\(trimmed)" + } + guard var components = URLComponents(string: trimmed), let host = components.host, !host.isEmpty else { + throw EdgeEverURLError.invalid + } + components.fragment = nil + components.query = nil + guard let url = components.url else { + throw EdgeEverURLError.invalid + } + return url.edgeEverNormalizedBase + } +} + +enum EdgeEverURLError: LocalizedError { + case empty + case invalid + + var errorDescription: String? { + switch self { + case .empty: + return "Instance URL is required." + case .invalid: + return "Instance URL is invalid." + } + } +} diff --git a/apps/ios/EditorBundle/index.html b/apps/ios/EditorBundle/index.html new file mode 100644 index 00000000..f0dbb1e4 --- /dev/null +++ b/apps/ios/EditorBundle/index.html @@ -0,0 +1,3795 @@ + + + + + + EdgeEver Editor + + + + +
+
+ + diff --git a/apps/ios/EditorSource/bun.lock b/apps/ios/EditorSource/bun.lock new file mode 100644 index 00000000..e4cadfc6 --- /dev/null +++ b/apps/ios/EditorSource/bun.lock @@ -0,0 +1,486 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@edgeever/ios-editor", + "dependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/extension-code-block": "3.27.1", + "@tiptap/extension-image": "3.27.1", + "@tiptap/extension-placeholder": "3.27.1", + "@tiptap/extension-table": "3.27.1", + "@tiptap/markdown": "3.27.1", + "@tiptap/pm": "3.27.1", + "@tiptap/starter-kit": "3.27.1", + "mermaid": "^11.16.0", + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^6.3.5", + "vite-plugin-singlefile": "^2.2.0", + }, + }, + }, + "packages": { + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], + + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="], + + "@tiptap/core": ["@tiptap/core@3.27.1", "", { "peerDependencies": { "@tiptap/pm": "3.27.1" } }, "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.29.2", "", { "peerDependencies": { "@tiptap/extensions": "3.29.2" } }, "sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.29.2", "", { "peerDependencies": { "@tiptap/extensions": "3.29.2" } }, "sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw=="], + + "@tiptap/extension-image": ["@tiptap/extension-image@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-+JTahgQT+NxiGjduaB3qJVyhU/wh4m3pVkht1Earioku2bm/apj5Lb8rSowa/NJYP3B+oQgV/V4YLw5dtDgBoA=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.29.2", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.29.2", "", { "peerDependencies": { "@tiptap/extension-list": "3.29.2" } }, "sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ=="], + + "@tiptap/extension-placeholder": ["@tiptap/extension-placeholder@3.27.1", "", { "peerDependencies": { "@tiptap/extensions": "3.27.1" } }, "sha512-lhcNDcczQ75yJOSywCHb58Hmtg1aPL/2TdYmeLPxVrP048D7rRs133sfONcgyyw0AvhnfmPOkHLTv3QtKSowhw=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg=="], + + "@tiptap/extension-table": ["@tiptap/extension-table@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-tNB8kjxo0+XPremnWkd6NUikpeJ+JQrss7HntL8GgIxX4t0gkdnLn9yUWb0JzcaYP7Y8iTZZHdkPs0P154DG8Q=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2" } }, "sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.29.2", "", { "peerDependencies": { "@tiptap/core": "3.29.2", "@tiptap/pm": "3.29.2" } }, "sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ=="], + + "@tiptap/markdown": ["@tiptap/markdown@3.27.1", "", { "dependencies": { "marked": "^17.0.1" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-4m2LZcj/uN0uLlGnXr3i7sfdxbQNNmeMn4wFyFrP2xpshcKPL5WujUAcqT2rgKfaDjKQ8gIK/GpgSQKuRENFwg=="], + + "@tiptap/pm": ["@tiptap/pm@3.27.1", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.7", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.0", "prosemirror-transform": "^1.12.0", "prosemirror-view": "^1.41.8" } }, "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.27.1", "", { "dependencies": { "@tiptap/core": "^3.27.1", "@tiptap/extension-blockquote": "^3.27.1", "@tiptap/extension-bold": "^3.27.1", "@tiptap/extension-bullet-list": "^3.27.1", "@tiptap/extension-code": "^3.27.1", "@tiptap/extension-code-block": "^3.27.1", "@tiptap/extension-document": "^3.27.1", "@tiptap/extension-dropcursor": "^3.27.1", "@tiptap/extension-gapcursor": "^3.27.1", "@tiptap/extension-hard-break": "^3.27.1", "@tiptap/extension-heading": "^3.27.1", "@tiptap/extension-horizontal-rule": "^3.27.1", "@tiptap/extension-italic": "^3.27.1", "@tiptap/extension-link": "^3.27.1", "@tiptap/extension-list": "^3.27.1", "@tiptap/extension-list-item": "^3.27.1", "@tiptap/extension-list-keymap": "^3.27.1", "@tiptap/extension-ordered-list": "^3.27.1", "@tiptap/extension-paragraph": "^3.27.1", "@tiptap/extension-strike": "^3.27.1", "@tiptap/extension-text": "^3.27.1", "@tiptap/extension-underline": "^3.27.1", "@tiptap/extensions": "^3.27.1", "@tiptap/pm": "^3.27.1" } }, "sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + + "dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="], + + "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + + "linkifyjs": ["linkifyjs@4.3.3", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="], + + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + + "mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], + + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.2", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="], + + "prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-model": ["prosemirror-model@1.25.11", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], + + "prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], + + "prosemirror-view": ["prosemirror-view@1.42.2", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ=="], + + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + + "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], + + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + + "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.3", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + } +} diff --git a/apps/ios/EditorSource/index.html b/apps/ios/EditorSource/index.html new file mode 100644 index 00000000..53fe8f5c --- /dev/null +++ b/apps/ios/EditorSource/index.html @@ -0,0 +1,16 @@ + + + + + + EdgeEver Editor + + +
+
+ + + diff --git a/apps/ios/EditorSource/package.json b/apps/ios/EditorSource/package.json new file mode 100644 index 00000000..049e0588 --- /dev/null +++ b/apps/ios/EditorSource/package.json @@ -0,0 +1,27 @@ +{ + "name": "@edgeever/ios-editor", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@tiptap/core": "3.27.1", + "@tiptap/extension-code-block": "3.27.1", + "@tiptap/extension-image": "3.27.1", + "@tiptap/extension-placeholder": "3.27.1", + "@tiptap/extension-table": "3.27.1", + "@tiptap/markdown": "3.27.1", + "@tiptap/pm": "3.27.1", + "@tiptap/starter-kit": "3.27.1", + "mermaid": "^11.16.0" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vite": "^6.3.5", + "vite-plugin-singlefile": "^2.2.0" + } +} diff --git a/apps/ios/EditorSource/src/main.ts b/apps/ios/EditorSource/src/main.ts new file mode 100644 index 00000000..321e22af --- /dev/null +++ b/apps/ios/EditorSource/src/main.ts @@ -0,0 +1,402 @@ +import "./styles.css"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Image from "@tiptap/extension-image"; +import Placeholder from "@tiptap/extension-placeholder"; +import CodeBlock from "@tiptap/extension-code-block"; +import { TableKit } from "@tiptap/extension-table"; +import { Markdown } from "@tiptap/markdown"; +import mermaid from "mermaid"; + +type BridgeMessage = + | { type: "ready"; startupMs: number } + | { type: "change"; contentMarkdown: string; contentJson: string } + | { type: "loadResource"; requestId: string; source: string } + | { type: "resourcePress"; targetJson: string } + | { type: "activeFlags"; flags: number } + | { type: "log"; message: string } + | { type: "error"; message: string }; + +type ConfigureOptions = { + mode?: "viewer" | "editor"; + locale?: string; + theme?: "light" | "dark"; + placeholder?: string; +}; + +const startedAt = performance.now(); +let mode: "viewer" | "editor" = "viewer"; +let suppressChange = false; +const resourceResolvers = new Map void>(); +let resourceSeq = 0; + +function post(msg: BridgeMessage) { + try { + (window as unknown as { webkit?: { messageHandlers?: { edgeever?: { postMessage: (m: unknown) => void } } } }) + .webkit?.messageHandlers?.edgeever?.postMessage(msg); + } catch { + // native host unavailable (browser preview) + } +} + +function isProtectedResource(src: string): boolean { + return src.startsWith("/api/") || src.includes("/api/v1/resources/"); +} + +function requestResource(source: string): Promise { + return new Promise((resolve) => { + const requestId = `r${++resourceSeq}`; + resourceResolvers.set(requestId, resolve); + post({ type: "loadResource", requestId, source }); + // Timeout so broken resources don't hang forever. + setTimeout(() => { + if (resourceResolvers.delete(requestId)) resolve(null); + }, 15_000); + }); +} + +async function hydrateProtectedImages(root: HTMLElement) { + const images = Array.from(root.querySelectorAll("img[src]")); + await Promise.all( + images.map(async (img) => { + const src = img.getAttribute("src") || ""; + if (!isProtectedResource(src)) return; + if (!img.dataset.originalSrc) img.dataset.originalSrc = src; + const dataUrl = await requestResource(src); + if (dataUrl) img.setAttribute("src", dataUrl); + }) + ); +} + +async function renderMermaidBlocks(root: HTMLElement, theme: "light" | "dark") { + const codeBlocks = Array.from(root.querySelectorAll("pre code.language-mermaid, pre code[class*='mermaid']")); + // Also treat fenced mermaid paragraphs produced as codeBlock with language attr via data + const preBlocks = Array.from(root.querySelectorAll("pre")).filter((pre) => { + const text = pre.textContent || ""; + return pre.querySelector("code") && /^(graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|mindmap|flowchart)/m.test(text.trim()); + }); + + const targets = new Set([...codeBlocks.map((c) => c.parentElement!).filter(Boolean), ...preBlocks]); + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: theme === "dark" ? "dark" : "default", + }); + + let i = 0; + for (const pre of targets) { + const source = (pre.textContent || "").trim(); + if (!source) continue; + try { + const id = `mmd-${Date.now()}-${i++}`; + const { svg } = await mermaid.render(id, source); + const wrap = document.createElement("div"); + wrap.className = "edgeever-mermaid"; + wrap.innerHTML = svg; + pre.replaceWith(wrap); + } catch { + // leave code block as-is + } + } +} + +function buildExtensions(placeholder: string) { + return [ + StarterKit.configure({ + codeBlock: false, + }), + CodeBlock.configure({ + languageClassPrefix: "language-", + }), + Image.configure({ + inline: false, + allowBase64: true, + }), + TableKit.configure({ + table: { resizable: false }, + }), + Placeholder.configure({ + placeholder, + }), + Markdown.configure({ + markedOptions: { gfm: true }, + }), + ]; +} + +const editorEl = document.getElementById("editor")!; +const toolbarEl = document.getElementById("toolbar")!; + +const editor = new Editor({ + element: editorEl, + extensions: buildExtensions("开始书写…"), + editable: false, + content: { type: "doc", content: [{ type: "paragraph" }] }, + onUpdate: ({ editor: ed }) => { + if (suppressChange || mode !== "editor") return; + emitChange(ed); + }, + editorProps: { + attributes: { + class: "edgeever-prose", + spellcheck: "true", + }, + handleClick(_view, _pos, event) { + const target = event.target as HTMLElement | null; + const img = target?.closest("img"); + if (img instanceof HTMLImageElement) { + const src = img.dataset.originalSrc || img.getAttribute("src") || ""; + if (src && isProtectedResource(src)) { + post({ + type: "resourcePress", + targetJson: JSON.stringify({ + kind: "image", + href: src, + filename: img.getAttribute("alt") || "image", + resourceId: src.split("/").filter(Boolean).pop() || "", + }), + }); + return true; + } + } + const link = target?.closest("a"); + if (link instanceof HTMLAnchorElement) { + const href = link.getAttribute("href") || ""; + if (isProtectedResource(href)) { + post({ + type: "resourcePress", + targetJson: JSON.stringify({ + kind: "attachment", + href, + filename: link.textContent || "file", + resourceId: href.split("/").filter(Boolean).pop() || "", + }), + }); + return true; + } + } + return false; + }, + }, +}); + +function emitChange(ed: Editor) { + try { + const contentJson = JSON.stringify(ed.getJSON()); + // @tiptap/markdown storage + const storage = ed.storage as { markdown?: { getMarkdown?: () => string } }; + const contentMarkdown = + storage.markdown?.getMarkdown?.() ?? + // fallback: plain text + ed.getText({ blockSeparator: "\n\n" }); + post({ type: "change", contentMarkdown, contentJson }); + } catch (error) { + post({ type: "error", message: error instanceof Error ? error.message : String(error) }); + } +} + +function setToolbarVisible(visible: boolean) { + toolbarEl.classList.toggle("editor-mode", visible); + toolbarEl.innerHTML = ""; + if (!visible) return; + const actions: Array<{ id: string; label: string; run: () => void }> = [ + { id: "bold", label: "B", run: () => editor.chain().focus().toggleBold().run() }, + { + id: "bullet", + label: "•", + run: () => editor.chain().focus().toggleBulletList().run(), + }, + { + id: "quote", + label: "❝", + run: () => editor.chain().focus().toggleBlockquote().run(), + }, + { + id: "hr", + label: "—", + run: () => editor.chain().focus().setHorizontalRule().run(), + }, + { + id: "h2", + label: "H2", + run: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + }, + { + id: "code", + label: "", + run: () => editor.chain().focus().toggleCodeBlock().run(), + }, + ]; + for (const action of actions) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = action.label; + btn.dataset.action = action.id; + btn.addEventListener("click", () => { + action.run(); + emitChange(editor); + }); + toolbarEl.appendChild(btn); + } +} + +async function afterContentSet(theme: "light" | "dark" = "light") { + await hydrateProtectedImages(editorEl); + if (mode === "viewer") { + await renderMermaidBlocks(editorEl, theme); + } +} + +export type EdgeEverEditorAPI = { + configure: (opts: ConfigureOptions) => void; + setMarkdown: (md: string) => void; + setDocumentFromJSON: (json: string) => void; + resolveResource: (requestId: string, dataUrl: string | null) => void; + getMarkdown: () => string; + getDocument: () => string; + focusEnd: () => void; + flush: () => void; + exec: (actionId: string) => void; + beginImageUpload: (uploadId: string, previewDataUrl: string) => void; + completeImageUpload: (uploadId: string, imageUrl: string, alt: string) => void; + cancelImageUpload: (uploadId: string) => void; +}; + +const api: EdgeEverEditorAPI = { + configure(opts) { + const nextMode = opts.mode === "editor" ? "editor" : "viewer"; + const modeChanged = nextMode !== mode; + mode = nextMode; + editor.setEditable(mode === "editor"); + setToolbarVisible(mode === "editor"); + document.documentElement.dataset.theme = opts.theme || "light"; + if (opts.placeholder) { + // placeholder is extension config; update via meta class + editorEl.setAttribute("data-placeholder", opts.placeholder); + } + // Only when switching into editor: make the surface focusable. + // Do NOT force caret to document end — that makes mid-doc editing unusable. + if (mode === "editor" && modeChanged) { + requestAnimationFrame(() => { + try { + editor.commands.focus(); + } catch { + /* ignore */ + } + }); + } + }, + + setMarkdown(md) { + suppressChange = true; + try { + editor.commands.setContent(md || "", { contentType: "markdown" } as never); + } catch { + try { + const manager = (editor.storage as { markdown?: { manager?: { parse: (s: string) => unknown } } }).markdown + ?.manager; + if (manager) { + editor.commands.setContent(manager.parse(md || "") as never); + } else { + throw new Error("no markdown manager"); + } + } catch { + editor.commands.setContent({ + type: "doc", + content: [{ type: "paragraph", content: md ? [{ type: "text", text: md }] : [] }], + }); + } + } + // Keep editability; do not steal selection / force caret to end. + editor.setEditable(mode === "editor"); + suppressChange = false; + void afterContentSet((document.documentElement.dataset.theme as "light" | "dark") || "light"); + }, + + setDocumentFromJSON(json) { + suppressChange = true; + try { + const doc = JSON.parse(json); + editor.commands.setContent(doc); + } catch { + editor.commands.setContent({ type: "doc", content: [{ type: "paragraph" }] }); + } + editor.setEditable(mode === "editor"); + suppressChange = false; + void afterContentSet((document.documentElement.dataset.theme as "light" | "dark") || "light"); + }, + + resolveResource(requestId, dataUrl) { + const resolver = resourceResolvers.get(requestId); + if (resolver) { + resourceResolvers.delete(requestId); + resolver(dataUrl); + } + }, + + getMarkdown() { + const storage = editor.storage as { markdown?: { getMarkdown?: () => string } }; + return storage.markdown?.getMarkdown?.() ?? editor.getText({ blockSeparator: "\n\n" }); + }, + + getDocument() { + return JSON.stringify(editor.getJSON()); + }, + + focusEnd() { + editor.commands.focus("end"); + }, + + flush() { + emitChange(editor); + }, + + exec(actionId) { + const map: Record void> = { + bold: () => editor.chain().focus().toggleBold().run(), + bulletList: () => editor.chain().focus().toggleBulletList().run(), + blockquote: () => editor.chain().focus().toggleBlockquote().run(), + horizontalRule: () => editor.chain().focus().setHorizontalRule().run(), + heading2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + codeBlock: () => editor.chain().focus().toggleCodeBlock().run(), + }; + map[actionId]?.(); + emitChange(editor); + }, + + beginImageUpload(uploadId, previewDataUrl) { + editor + .chain() + .focus() + .setImage({ src: previewDataUrl, alt: uploadId }) + .run(); + // mark last image + const imgs = editorEl.querySelectorAll("img"); + const last = imgs[imgs.length - 1] as HTMLImageElement | undefined; + if (last) last.dataset.uploadId = uploadId; + emitChange(editor); + }, + + completeImageUpload(uploadId, imageUrl, alt) { + const img = editorEl.querySelector(`img[data-upload-id="${uploadId}"]`) as HTMLImageElement | null; + if (img) { + img.setAttribute("src", imageUrl); + img.dataset.originalSrc = imageUrl; + if (alt) img.setAttribute("alt", alt); + delete img.dataset.uploadId; + } else { + editor.chain().focus().setImage({ src: imageUrl, alt }).run(); + } + emitChange(editor); + }, + + cancelImageUpload(uploadId) { + const img = editorEl.querySelector(`img[data-upload-id="${uploadId}"]`); + img?.remove(); + emitChange(editor); + }, +}; + +(window as unknown as { EdgeEverEditor: EdgeEverEditorAPI }).EdgeEverEditor = api; +setToolbarVisible(false); +post({ type: "ready", startupMs: Math.round(performance.now() - startedAt) }); diff --git a/apps/ios/EditorSource/src/styles.css b/apps/ios/EditorSource/src/styles.css new file mode 100644 index 00000000..aa1ce956 --- /dev/null +++ b/apps/ios/EditorSource/src/styles.css @@ -0,0 +1,122 @@ +:root { + color-scheme: light dark; + --accent: #19ac73; + --text: CanvasText; + --muted: color-mix(in srgb, CanvasText 55%, transparent); + --border: color-mix(in srgb, CanvasText 14%, transparent); + --toolbar-bg: color-mix(in srgb, Canvas 92%, CanvasText 8%); + --code-bg: color-mix(in srgb, CanvasText 7%, transparent); +} + +html, +body { + margin: 0; + padding: 0; + background: transparent; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); +} + +#toolbar { + display: none; + position: sticky; + top: 0; + z-index: 5; + gap: 4px; + flex-wrap: wrap; + padding: 8px 12px; + background: var(--toolbar-bg); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(8px); +} + +#toolbar.editor-mode { + display: flex; +} + +#toolbar button { + border: 0; + background: transparent; + padding: 6px 10px; + border-radius: 8px; + font-size: 14px; + color: var(--text); + min-width: 36px; +} + +#toolbar button:active { + background: color-mix(in srgb, CanvasText 12%, transparent); +} + +#editor { + min-height: 50vh; +} + +.edgeever-prose { + outline: none; + padding: 12px 16px 64px; + line-height: 1.55; + word-break: break-word; + min-height: 50vh; +} + +.edgeever-prose p.is-editor-empty:first-child::before { + color: #94a3b8; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.edgeever-prose img { + max-width: 100%; + height: auto; + border-radius: 8px; +} + +.edgeever-prose pre { + background: var(--code-bg); + padding: 10px 12px; + border-radius: 8px; + overflow-x: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; +} + +.edgeever-prose blockquote { + margin: 0.5em 0; + padding-left: 12px; + border-left: 3px solid var(--accent); + color: var(--muted); +} + +.edgeever-prose table { + border-collapse: collapse; + width: 100%; + margin: 8px 0; +} + +.edgeever-prose th, +.edgeever-prose td { + border: 1px solid var(--border); + padding: 6px 8px; +} + +.edgeever-prose h1, +.edgeever-prose h2, +.edgeever-prose h3 { + line-height: 1.25; +} + +.edgeever-mermaid { + overflow-x: auto; + margin: 12px 0; + padding: 8px; + border-radius: 8px; + background: var(--code-bg); +} + +.edgeever-mermaid svg { + max-width: 100%; + height: auto; +} diff --git a/apps/ios/EditorSource/tsconfig.json b/apps/ios/EditorSource/tsconfig.json new file mode 100644 index 00000000..08174b4d --- /dev/null +++ b/apps/ios/EditorSource/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/apps/ios/EditorSource/vite.config.ts b/apps/ios/EditorSource/vite.config.ts new file mode 100644 index 00000000..fbe83ac5 --- /dev/null +++ b/apps/ios/EditorSource/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + outDir: "../EditorBundle", + emptyOutDir: true, + assetsInlineLimit: 100_000_000, + cssCodeSplit: false, + rollupOptions: { + input: "index.html", + }, + }, +}); diff --git a/apps/ios/README.md b/apps/ios/README.md new file mode 100644 index 00000000..bbfd0508 --- /dev/null +++ b/apps/ios/README.md @@ -0,0 +1,89 @@ +# EdgeEver iOS (Swift) + +Native SwiftUI client for EdgeEver. This replaces the React Native / Expo iOS target. + +**Design document:** [`docs/ios-swift-rewrite.md`](../../docs/ios-swift-rewrite.md) + +## Status + +UI/interaction is aligned with the Android RN app (`apps/mobile`): + +- Login / session (Keychain with UserDefaults fallback) / device id +- GRDB local mirror + bootstrap / incremental sync +- SQLite outbox (create/update, create-absorbs-update, conflict/retry) +- Workspace chrome: notebook title picker, search (250ms debounce), filter chips, list options (sort/density/batch), FAB create, long-press multi-select, swipe pin/delete +- Memo detail + create/edit autosave, share link, revisions, soft delete +- EditorBundle (WKWebView body + toolbar) +- Image pick + materialize-for-upload (K24) + protected resource blob loading +- Settings: general / account / tags / API tokens / devices / users (owner) / about +- Share Extension (text/URL → App Group → new draft) +- Locale (system/zh/en) + theme + list density + +Android remains Expo / React Native in `apps/mobile`. Do not retire EAS iOS builds until a shippable TestFlight build exists. + +## Motion / animations + +- **Native SwiftUI** springs for layout, search bar, filter chips, list content changes +- **[Pow](https://github.com/EmergeTools/Pow)** (Emerge Tools) for polished micro-interactions: + - create button ping / jump on sync + - filter chip ping when activated + - selection / error haptics + shake + - pin shine, list card transitions + +Shared curves live in `EdgeEver/DesignSystem/Motion.swift` (card press timing matches Android Reanimated: 0.985 @ 100ms / release 160ms). + +## Requirements + +- Xcode 16+ (Xcode 27 beta OK for local dev) +- iOS 17+ deployment target +- [XcodeGen](https://github.com/yonaskolb/XcodeGen) to regenerate the project + +```sh +brew install xcodegen +``` + +## Generate & open + +```sh +cd apps/ios +xcodegen generate +open EdgeEver.xcodeproj +``` + +## Tests + +```sh +# Unit + live API tests (live tests skip if local server is down) +xcodebuild test \ + -project EdgeEver.xcodeproj \ + -scheme EdgeEver \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' +``` + +Live API tests login to `http://127.0.0.1:8787` using `EDGE_EVER_AUTH_PASSWORD` or monorepo `.env.wrangler.generated.local`. + +## Simulator auto-login (QA) + +With local `bun run dev` running: + +```sh +xcrun simctl launch booted org.edgeever.mobile \ + -EdgeEverAutoLoginURL "http://127.0.0.1:8787" \ + -EdgeEverAutoLoginUser admin \ + -EdgeEverAutoLoginPassword "$EDGE_EVER_AUTH_PASSWORD" +``` + +## Versioning + +| Field | File | Notes | +| --- | --- | --- | +| `MARKETING_VERSION` | `Config/Version.xcconfig` | Align with monorepo release `X.Y.Z` on store submissions | +| `CURRENT_PROJECT_VERSION` | `Config/Version.xcconfig` | Monotonic build number for every TestFlight / App Store upload | + +## Regenerating the Xcode project + +Edit `project.yml`, then: + +```sh +xcodegen generate +``` diff --git a/apps/ios/Scripts/build-editor-bundle.sh b/apps/ios/Scripts/build-editor-bundle.sh new file mode 100755 index 00000000..606e7514 --- /dev/null +++ b/apps/ios/Scripts/build-editor-bundle.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/EditorSource" +OUT="$ROOT/EditorBundle" + +cd "$SRC" +if ! command -v bun >/dev/null 2>&1 && ! command -v npm >/dev/null 2>&1; then + echo "Need bun or npm to build EditorSource" >&2 + exit 1 +fi + +if command -v bun >/dev/null 2>&1; then + bun install + bun run build +else + npm install + npm run build +fi + +# Ensure index.html exists at EditorBundle root for iOS resource load. +if [[ ! -f "$OUT/index.html" ]]; then + echo "Editor bundle build did not produce index.html" >&2 + exit 1 +fi + +# XcodeGen only packages paths under EdgeEver/Resources reliably — mirror there. +RES_OUT="$ROOT/EdgeEver/Resources/EditorBundle" +mkdir -p "$RES_OUT" +cp -f "$OUT/index.html" "$RES_OUT/index.html" +echo "Built EditorBundle → $OUT (mirrored to $RES_OUT)" diff --git a/apps/ios/Scripts/selftest-sim.sh b/apps/ios/Scripts/selftest-sim.sh new file mode 100755 index 00000000..0da5468f --- /dev/null +++ b/apps/ios/Scripts/selftest-sim.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Simulator smoke self-test for EdgeEver iOS (agent / CI local loop). +# Usage: from apps/ios: ./Scripts/selftest-sim.sh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DEST="${SIM_DEST:-}" +if [[ -z "$DEST" ]]; then + DEST="$(xcrun simctl list devices booted | grep -E 'iPhone' | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/' || true)" +fi +if [[ -z "$DEST" ]]; then + echo "No booted simulator. Boot one first." >&2 + exit 1 +fi + +echo "== unit tests on $DEST ==" +xcodegen generate +xcodebuild -project EdgeEver.xcodeproj -scheme EdgeEver \ + -destination "platform=iOS Simulator,id=$DEST" \ + -only-testing:EdgeEverTests/EditorBundleTests \ + -only-testing:EdgeEverTests/ChromeParityTests \ + -only-testing:EdgeEverTests/MemoCreateCommitTests \ + test + +APP="$(find ~/Library/Developer/Xcode/DerivedData -path '*/Build/Products/Debug-iphonesimulator/EdgeEver.app' 2>/dev/null | head -1)" +if [[ -z "$APP" || ! -d "$APP" ]]; then + echo "EdgeEver.app not found after test build" >&2 + exit 1 +fi + +HTML="$APP/index.html" +if [[ ! -f "$HTML" ]]; then + HTML="$(find "$APP" -name index.html | head -1 || true)" +fi +SIZE=0 +[[ -f "$HTML" ]] && SIZE=$(wc -c <"$HTML" | tr -d ' ') +echo "packaged editor html: ${HTML:-MISSING} size=$SIZE" +if [[ "$SIZE" -lt 100000 ]]; then + echo "FAIL: TipTap index.html missing or too small (would use plain-text fallback)" >&2 + exit 1 +fi + +echo "OK selftest-sim unit+package checks passed" +echo "Tip: full UI path is driven by the agent with simctl+cliclick screenshots." diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist new file mode 100644 index 00000000..75f02378 --- /dev/null +++ b/apps/ios/ShareExtension/Info.plist @@ -0,0 +1,41 @@ + + + + + CFBundleDisplayName + EdgeEver + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/apps/ios/ShareExtension/ShareExtension.entitlements b/apps/ios/ShareExtension/ShareExtension.entitlements new file mode 100644 index 00000000..4cdca9c9 --- /dev/null +++ b/apps/ios/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.edgeever.mobile + + + diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 00000000..d94924f7 --- /dev/null +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,58 @@ +import UIKit +import UniformTypeIdentifiers + +/// Lightweight share extension: text + web URL → App Group → open main app. +class ShareViewController: UIViewController { + private let appGroupId = "group.org.edgeever.mobile" + private let hostScheme = "edgeever" + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + Task { await handleShare() } + } + + private func handleShare() async { + guard let items = extensionContext?.inputItems as? [NSExtensionItem] else { + extensionContext?.completeRequest(returningItems: nil) + return + } + var text: String? + var urlString: String? + var title: String? + + for item in items { + title = item.attributedContentText?.string + for provider in item.attachments ?? [] { + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), + let url = try? await provider.loadItem(forTypeIdentifier: UTType.url.identifier) as? URL + { + urlString = url.absoluteString + } else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier), + let str = try? await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) as? String + { + text = str + } + } + } + + let payload: [[String: Any]] = [[ + "text": text as Any, + "url": urlString as Any, + "title": title as Any, + ]] + if let data = try? JSONSerialization.data(withJSONObject: payload), + let defaults = UserDefaults(suiteName: appGroupId) + { + defaults.set(data, forKey: "edgeever.share.payload") + } + + if let url = URL(string: "\(hostScheme)://share") { + extensionContext?.open(url) { [weak self] _ in + self?.extensionContext?.completeRequest(returningItems: nil) + } + return + } + extensionContext?.completeRequest(returningItems: nil) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift b/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift new file mode 100644 index 00000000..8a573dd9 --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/ChromeParityTests.swift @@ -0,0 +1,197 @@ +import XCTest +@testable import EdgeEver + +/// Drives shipped create/detail chrome helpers + structural source markers (Android token roles). +final class ChromeParityTests: XCTestCase { + // MARK: - Create save status (shipped CreateSaveStatus.derive) + + func testCreateSaveStatusPriorityMatchesAndroid() { + XCTAssertEqual( + CreateSaveStatus.derive( + editorReady: false, isDirty: false, isSaving: false, + isCreating: false, isUploading: false, hasError: false + ), + .starting + ) + XCTAssertEqual( + CreateSaveStatus.derive( + editorReady: true, isDirty: false, isSaving: false, + isCreating: false, isUploading: false, hasError: false + ), + .saved + ) + XCTAssertEqual( + CreateSaveStatus.derive( + editorReady: true, isDirty: true, isSaving: false, + isCreating: false, isUploading: false, hasError: false + ), + .saving + ) + XCTAssertEqual( + CreateSaveStatus.derive( + editorReady: true, isDirty: false, isSaving: true, + isCreating: false, isUploading: false, hasError: false + ), + .saving + ) + // Upload / create win over dirty + XCTAssertEqual( + CreateSaveStatus.derive( + editorReady: true, isDirty: true, isSaving: false, + isCreating: true, isUploading: false, hasError: false + ), + .creating + ) + XCTAssertEqual( + CreateSaveStatus.derive( + editorReady: true, isDirty: true, isSaving: false, + isCreating: false, isUploading: true, hasError: false + ), + .uploading + ) + XCTAssertEqual( + CreateSaveStatus.derive( + editorReady: true, isDirty: false, isSaving: false, + isCreating: false, isUploading: false, hasError: true + ), + .error + ) + } + + func testCreateSaveStatusLabels() { + XCTAssertEqual(CreateSaveStatus.saving.labelZH, "保存中") + XCTAssertEqual(CreateSaveStatus.saved.labelZH, "已保存") + XCTAssertTrue(CreateSaveStatus.saving.isActive) + XCTAssertFalse(CreateSaveStatus.saved.isActive) + } + + // MARK: - Detail sync status (shipped DetailSyncStatus.derive) + + func testDetailSyncStatusFromOutbox() { + XCTAssertEqual(DetailSyncStatus.derive(outboxStatus: nil, isGlobalSyncing: false), .synced) + XCTAssertEqual(DetailSyncStatus.derive(outboxStatus: .pending, isGlobalSyncing: false), .pending) + XCTAssertEqual(DetailSyncStatus.derive(outboxStatus: .syncing, isGlobalSyncing: false), .syncing) + XCTAssertEqual(DetailSyncStatus.derive(outboxStatus: .conflict, isGlobalSyncing: false), .conflict) + XCTAssertEqual(DetailSyncStatus.derive(outboxStatus: .error, isGlobalSyncing: false), .error) + // Global sync elevates chip to syncing + XCTAssertEqual(DetailSyncStatus.derive(outboxStatus: .pending, isGlobalSyncing: true), .syncing) + } + + func testDetailSyncStatusLabelsAndInteraction() { + XCTAssertEqual(DetailSyncStatus.synced.labelZH, "已同步") + XCTAssertEqual(DetailSyncStatus.conflict.labelZH, "同步冲突") + XCTAssertEqual(DetailSyncStatus.pending.labelZH, "待同步") + XCTAssertTrue(DetailSyncStatus.pending.isInteractive) + XCTAssertTrue(DetailSyncStatus.conflict.isInteractive) + XCTAssertFalse(DetailSyncStatus.synced.isInteractive) + } + + // MARK: - Structural source parity (read shipped Swift files) + + func testMemoEditViewUsesCreateChromeNotFormToolbar() throws { + let src = try readShippedSource("Features/Workspace/MemoEditView.swift") + // Must implement Android create shell roles + XCTAssertTrue(src.contains("CreateMemoChrome.header") || src.contains("createMemoHeader"), src) + XCTAssertTrue(src.contains("CreateMemoChrome.back"), "missing back chrome") + XCTAssertTrue(src.contains("CreateMemoChrome.status"), "missing status chip") + XCTAssertTrue(src.contains("CreateMemoChrome.done"), "missing Done button") + XCTAssertTrue(src.contains("CreateMemoChrome.title"), "missing large title") + XCTAssertTrue(src.contains("CreateMemoChrome.metaRow"), "missing meta row") + XCTAssertTrue(src.contains("CreateMemoChrome.notebook"), "missing notebook control") + XCTAssertTrue(src.contains("CreateMemoChrome.tags"), "missing tags field") + XCTAssertTrue(src.contains("CreateMemoChrome.editorFrame"), "missing bordered editor") + XCTAssertTrue(src.contains("TipTapWebView"), "editor wiring must remain") + XCTAssertTrue(src.contains("commitCreate") || src.contains("persistDraftOrQueue"), "save path must remain") + // Done must route through MemoCreateCommit (materialize-safe: update vs create) + XCTAssertTrue(src.contains("MemoCreateCommit.commit"), "create Done must use MemoCreateCommit") + // Must NOT use Form as primary create chrome (nested picker sheets may use List/nav). + XCTAssertFalse(src.contains("Form {"), "Form-based primary chrome is not Android parity") + // Primary shell is createHeader / createMain, not NavigationStack+toolbar chrome. + XCTAssertTrue(src.contains("createHeader"), "custom createHeader required") + XCTAssertTrue(src.contains("createMain"), "custom createMain required") + XCTAssertFalse(src.contains("ToolbarItem(placement: .confirmationAction)"), "system Done toolbar not Android create chrome") + XCTAssertFalse(src.contains("ToolbarItem(placement: .cancellationAction)"), "system Close toolbar not Android create chrome") + } + + func testMemoDetailViewUsesDetailChromeAndEditFab() throws { + let src = try readShippedSource("Features/Workspace/MemoDetailView.swift") + XCTAssertTrue(src.contains("DetailMemoChrome.header") || src.contains("detailHeader"), src) + XCTAssertTrue(src.contains("DetailMemoChrome.syncStatus"), "missing sync chip") + XCTAssertTrue(src.contains("DetailMemoChrome.share"), "missing share action") + XCTAssertTrue(src.contains("DetailMemoChrome.history"), "missing history action") + XCTAssertTrue(src.contains("DetailMemoChrome.metaRow"), "missing meta row") + XCTAssertTrue(src.contains("DetailMemoChrome.title"), "missing detail title") + XCTAssertTrue(src.contains("TipTapWebView"), "viewer wiring must remain") + // Edit is requested via callback; presentation is owned by WorkspaceView (reliable). + XCTAssertTrue(src.contains("onEdit"), "detail requests edit via onEdit callback") + XCTAssertTrue(src.contains("EditFabButton"), "UIKit FAB so WebView cannot steal taps") + XCTAssertTrue(src.contains(".overlay(alignment: .bottomTrailing)"), "FAB overlay above WebView") + // UIKit FAB file wires green + a11y id + let fabSrc = try readShippedSource("DesignSystem/EditFabButton.swift") + XCTAssertTrue(fabSrc.contains("0x10") || fabSrc.contains("B9") || fabSrc.contains("10B981") || fabSrc.contains("0x10 / 255"), fabSrc) + XCTAssertTrue(fabSrc.contains("DetailMemoChrome.editFab") || fabSrc.contains("detailEditFab"), "FAB a11y id") + } + + func testWorkspacePresentsEditFromRootCover() throws { + let src = try readShippedSource("Features/Workspace/WorkspaceView.swift") + XCTAssertTrue(src.contains("editingMemo"), "workspace holds editing route") + XCTAssertTrue(src.contains("fullScreenCover(item: $editingMemo)"), "edit presented at workspace root") + XCTAssertTrue(src.contains("MemoEditView(mode: .edit(memoId:"), "edit cover builds MemoEditView") + } + + func testWorkspacePresentsCreateAsFullScreenCover() throws { + let src = try readShippedSource("Features/Workspace/WorkspaceView.swift") + XCTAssertTrue(src.contains("fullScreenCover(isPresented: $showNewNote)"), src) + XCTAssertTrue(src.contains("MemoEditView(mode: .create"), "create wiring") + XCTAssertTrue(src.contains("fullScreenCover(isPresented: $showSettings)"), "Me full screen") + } + + /// Bottom chrome must be one bar: nav height + home-indicator padding (Android parity). + func testBottomNavUsesSingleChromeWithSafeInset() throws { + let src = try readShippedSource("Features/Workspace/WorkspaceView.swift") + XCTAssertTrue(src.contains("bottomSafeInset") || src.contains("bottomChromeHeight"), src) + XCTAssertTrue(src.contains("bottomNavigationHeight"), "52pt nav band") + // Overlay chrome pinned to physical bottom (RN edges without bottom). + XCTAssertTrue(src.contains("ZStack(alignment: .bottom)"), "overlay bottom chrome") + XCTAssertTrue(src.contains("ignoresSafeArea(.container, edges: .bottom)"), "shell owns home indicator") + XCTAssertTrue(src.contains("accessibilityIdentifier(\"bottomNav\")"), "a11y id for bottom nav") + // Home-indicator is an explicit white slab under the 52pt band — one continuous surface. + XCTAssertTrue( + src.contains("Color.white") && src.contains("bottomInset"), + "home-indicator slab must be white continuous with nav band" + ) + // Create lift is offset only — must NOT add 16pt list padding (that was the gray ghost strip). + XCTAssertTrue(src.contains("createLift") || src.contains("offset(y:"), "create elevated via offset") + XCTAssertTrue(src.contains("bottomCreateButton"), "create a11y id") + // List pad must use bottomChromeHeight without +16 ghost strip. + XCTAssertFalse( + src.contains("bottomChromeHeight + 16"), + "must not pad list by createLift — causes empty strip above white bar" + ) + + // Metrics must expose the Android height formula + XCTAssertEqual(MobileUIMetrics.bottomNavigationHeight, 52) + XCTAssertEqual( + MobileUIMetrics.bottomChromeHeight, + MobileUIMetrics.bottomNavigationHeight + MobileUIMetrics.bottomSafeInset + ) + } + + func testChromeRoleConstantsStable() { + // Stable ids used by UI tests / a11y — keep aligned with Android token names. + XCTAssertEqual(CreateMemoChrome.done, "createMemoDoneButton") + XCTAssertEqual(CreateMemoChrome.editorFrame, "createMemoEditorFrame") + XCTAssertEqual(DetailMemoChrome.editFab, "detailEditFab") + XCTAssertEqual(DetailMemoChrome.syncStatus, "detailSyncStatus") + } + + // MARK: - Helpers + + private func readShippedSource(_ relativeUnderEdgeEver: String) throws -> String { + // Tests/EdgeEverTests/ThisFile.swift → apps/ios/ + let testsDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let iosRoot = testsDir.deletingLastPathComponent().deletingLastPathComponent() + let url = iosRoot.appendingPathComponent("EdgeEver").appendingPathComponent(relativeUnderEdgeEver) + return try String(contentsOf: url, encoding: .utf8) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift b/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift new file mode 100644 index 00000000..b26073d7 --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/EditorBundleTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import EdgeEver + +/// Ensures the TipTap editor HTML is actually shipped in the app (not the plain-text fallback). +final class EditorBundleTests: XCTestCase { + func testPackagedEditorHTMLIsPresentAndLarge() throws { + let url = TipTapWebView.Coordinator.packagedEditorHTMLURL() + XCTAssertNotNil(url, "EditorBundle/index.html must be in the app bundle so Markdown renders") + let data = try Data(contentsOf: try XCTUnwrap(url)) + // Real Vite TipTap bundle is multi-MB; fallback stub is a few KB. + XCTAssertGreaterThan(data.count, 100_000, "packaged editor is too small — likely missing TipTap build") + let head = String(data: data.prefix(800), encoding: .utf8) ?? "" + XCTAssertTrue( + head.contains("EdgeEver") || head.contains("tiptap") || head.contains("module"), + "expected TipTap editor HTML, got: \(head.prefix(120))" + ) + } + + func testPackagedEditorIsNotPlainFallbackStub() throws { + let url = try XCTUnwrap(TipTapWebView.Coordinator.packagedEditorHTMLURL()) + let html = try String(contentsOf: url, encoding: .utf8) + // Fallback embeds a tiny mdToHtml with contenteditable div only. + XCTAssertFalse(html.contains("Minimal contenteditable"), "must not ship the Swift fallback HTML") + XCTAssertTrue( + html.contains("EdgeEverEditor") || html.contains("setMarkdown"), + "TipTap bridge API should be present" + ) + } + + /// Caret must not be forced to document end on every content set / keystroke path. + func testNativeBridgeDoesNotForceCaretToEndOnPush() throws { + // Tests/EdgeEverTests/ThisFile.swift → apps/ios/ + let iosRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // EdgeEverTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // ios + let tipTap = try String( + contentsOf: iosRoot.appendingPathComponent("EdgeEver/Editor/TipTapWebView.swift"), + encoding: .utf8 + ) + // Must not invoke focusEnd after setMarkdown / setDocumentFromJSON. + XCTAssertFalse( + tipTap.contains("focusEnd()"), + "TipTapWebView must not call focusEnd() — jumps caret to bottom while editing" + ) + XCTAssertTrue( + tipTap.contains("lastEditorEmittedFingerprint"), + "must ignore editor-originated updates so typing does not re-setContent" + ) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/LiveLocalAPITests.swift b/apps/ios/Tests/EdgeEverTests/LiveLocalAPITests.swift new file mode 100644 index 00000000..205d7f64 --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/LiveLocalAPITests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import EdgeEver + +/// Integration tests against a running local EdgeEver instance (`bun run dev`). +/// Skips cleanly when the local server is offline — never fakes the response. +final class LiveLocalAPITests: XCTestCase { + private let baseURL = URL(string: "http://127.0.0.1:8787")! + + private var localPassword: String? { + if let env = ProcessInfo.processInfo.environment["EDGE_EVER_AUTH_PASSWORD"], !env.isEmpty { + return env + } + // Discover from monorepo local env files (repo root relative to test host cwd varies). + let candidates = [ + ".env.wrangler.generated.local", + ".env.local", + "../../.env.wrangler.generated.local", + "../../../.env.wrangler.generated.local", + "/Users/tianma/Developer/Projects/edgeever/.env.wrangler.generated.local", + ] + for path in candidates { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue } + for line in text.split(separator: "\n") { + if line.hasPrefix("EDGE_EVER_AUTH_PASSWORD=") { + return String(line.dropFirst("EDGE_EVER_AUTH_PASSWORD=".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + } + } + } + return nil + } + + private func requireLocalServer() async throws { + var request = URLRequest(url: baseURL.appending(path: "/api/health")) + request.timeoutInterval = 3 + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw XCTSkip("Local EdgeEver not running at \(baseURL)") + } + } + + func testLoginBootstrapListAndCreateMemoThroughShippedClient() async throws { + try await requireLocalServer() + guard let password = localPassword, !password.isEmpty else { + throw XCTSkip("No EDGE_EVER_AUTH_PASSWORD available for live login") + } + + let client = APIClient(baseURL: baseURL) + let session = try await client.login( + LoginInput(username: "admin", password: password, deviceId: "mobile-ios-live-test-01") + ) + XCTAssertTrue(session.authenticated, "login must authenticate against real server") + XCTAssertNotNil(session.sessionToken) + XCTAssertEqual(session.user?.username, "admin") + + await client.update(baseURL: baseURL, token: session.sessionToken) + + let db = try AppDatabase.makeEmpty() + let mirror = LocalMirrorRepository(dbQueue: db) + let outbox = SyncOutboxRepository(dbQueue: db) + let scope = SyncProtocol.createDataScope(baseURL: baseURL, userId: session.user?.id) + let engine = SyncEngine(mirror: mirror, client: client) + let flusher = OutboxFlusher(outbox: outbox, mirror: mirror, client: client) + + let cursor = try await engine.sync(scope: scope) + XCTAssertGreaterThanOrEqual(cursor, 0) + + let notebooks = try mirror.listNotebooks(scope: scope) + XCTAssertFalse(notebooks.isEmpty, "bootstrap should leave at least one notebook in the mirror") + + let notebookId = notebooks[0].id + let localId = "local:\(UUID().uuidString.lowercased())" + let now = EdgeEverDate.nowString() + let title = "iOS live test \(Int(Date().timeIntervalSince1970))" + let markdown = "Created by LiveLocalAPITests at \(now)" + try mirror.upsertMemo( + scope: scope, + memo: .localPlaceholder( + id: localId, + notebookId: notebookId, + title: title, + contentMarkdown: markdown, + tags: ["ios-live"], + createdAt: now + ) + ) + try outbox.enqueueCreate( + scope: scope, + payload: MemoCreatePayload( + memoId: localId, + title: title, + contentMarkdown: markdown, + notebookId: notebookId, + tags: ["ios-live"], + createdAt: now + ) + ) + let result = try await flusher.flush(scope: scope) + XCTAssertEqual(result.synced, 1, "outbox flush must create memo on server: \(result)") + XCTAssertEqual(result.failed, 0) + XCTAssertEqual(result.conflicted, 0) + + let resolved = try mirror.resolveMemo(scope: scope, id: localId) + XCTAssertNotNil(resolved) + XCTAssertFalse(resolved!.id.hasPrefix("local:"), "local id must remap to server id") + XCTAssertEqual(resolved!.title, title) + + // Verify server has the note via shipped getMemo + let remote = try await client.getMemo(id: resolved!.id) + XCTAssertEqual(remote.id, resolved!.id) + XCTAssertTrue(remote.contentMarkdown.contains("LiveLocalAPITests") || remote.contentText.contains("LiveLocalAPITests") || remote.contentMarkdown == markdown) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/LocalMirrorTests.swift b/apps/ios/Tests/EdgeEverTests/LocalMirrorTests.swift new file mode 100644 index 00000000..7b8659b7 --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/LocalMirrorTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import EdgeEver + +final class LocalMirrorTests: XCTestCase { + func testUpsertListAndSearch() throws { + let db = try AppDatabase.makeEmpty() + let mirror = LocalMirrorRepository(dbQueue: db) + let scope = "https://demo.example|u1" + let now = EdgeEverDate.nowString() + let notebook = Notebook( + id: "nb1", + parentId: nil, + name: "Inbox", + slug: nil, + icon: nil, + color: nil, + sortOrder: 0, + memoCount: 0, + lastMemoUpdatedAt: nil, + createdAt: now, + updatedAt: now + ) + try mirror.applyBootstrapBatch(scope: scope, notebooks: [notebook], memos: [ + MemoDetail.localPlaceholder( + id: "m1", + notebookId: "nb1", + title: "Hello", + contentMarkdown: "world content", + tags: ["a"], + createdAt: now + ), + ]) + try mirror.setCursor(scope: scope, cursor: 1) + try mirror.setIdentity(scope: scope, identity: "id1") + XCTAssertTrue(try mirror.isInitialized(scope: scope)) + + let notebooks = try mirror.listNotebooks(scope: scope) + XCTAssertEqual(notebooks.count, 1) + XCTAssertEqual(notebooks[0].memoCount, 1) + + let listed = try mirror.listMemos(scope: scope, params: .init(q: "world")) + XCTAssertEqual(listed.totalCount, 1) + XCTAssertEqual(listed.memos.first?.title, "Hello") + } + + func testOutboxCreateAbsorbsUpdate() throws { + let db = try AppDatabase.makeEmpty() + let outbox = SyncOutboxRepository(dbQueue: db) + let scope = "s" + try outbox.enqueueCreate( + scope: scope, + payload: MemoCreatePayload( + memoId: "local:1", + title: "A", + contentMarkdown: "one", + notebookId: "nb", + tags: [], + createdAt: EdgeEverDate.nowString() + ) + ) + try outbox.enqueueUpdate( + scope: scope, + payload: MemoUpdatePayload( + memoId: "local:1", + expectedRevision: 0, + expectedContentHash: "", + title: "B", + contentMarkdown: "two", + notebookId: "nb", + tags: ["t"] + ) + ) + let items = try outbox.listItems(scope: scope) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].kind, .memoCreate) + let payload = try items[0].createPayload() + XCTAssertEqual(payload.title, "B") + XCTAssertEqual(payload.contentMarkdown, "two") + XCTAssertEqual(payload.tags, ["t"]) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/MemoCreateCommitTests.swift b/apps/ios/Tests/EdgeEverTests/MemoCreateCommitTests.swift new file mode 100644 index 00000000..41549969 --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/MemoCreateCommitTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import EdgeEver + +/// Drives the shipped create-Done path (`MemoCreateCommit.commit`). +/// Guards Android parity: after image materialize, Done must update — not second create. +final class MemoCreateCommitTests: XCTestCase { + private var mirror: LocalMirrorRepository! + private var outbox: SyncOutboxRepository! + private var drafts: DraftRepository! + private let scope = "https://demo|user" + + override func setUpWithError() throws { + let db = try AppDatabase.makeEmpty() + mirror = LocalMirrorRepository(dbQueue: db) + outbox = SyncOutboxRepository(dbQueue: db) + drafts = DraftRepository(dbQueue: db) + try mirror.applyBootstrapBatch( + scope: scope, + notebooks: [ + Notebook( + id: "nb1", + parentId: nil, + name: "Inbox", + slug: nil, + icon: nil, + color: nil, + sortOrder: 0, + memoCount: 0, + lastMemoUpdatedAt: nil, + createdAt: EdgeEverDate.nowString(), + updatedAt: EdgeEverDate.nowString() + ), + ], + memos: [] + ) + } + + func testIsMaterializedServerId() { + XCTAssertFalse(MemoCreateCommit.isMaterializedServerId(nil)) + XCTAssertFalse(MemoCreateCommit.isMaterializedServerId("")) + XCTAssertFalse(MemoCreateCommit.isMaterializedServerId("local:abc")) + XCTAssertTrue(MemoCreateCommit.isMaterializedServerId("srv-memo-1")) + XCTAssertTrue(MemoCreateCommit.isMaterializedServerId("uuid-without-local-prefix")) + } + + func testDoneWithoutMaterializeEnqueuesCreateNotUpdate() throws { + let outcome = try MemoCreateCommit.commit( + scope: scope, + memoId: nil, + expectedRevision: nil, + expectedContentHash: nil, + notebookId: "nb1", + title: "Draft title", + contentMarkdown: "body", + contentJSON: nil, + tags: ["a"], + mirror: mirror, + outbox: outbox, + drafts: drafts + ) + guard case .createdLocal(let id) = outcome else { + return XCTFail("expected createdLocal, got \(outcome)") + } + XCTAssertTrue(id.hasPrefix("local:"), "offline create uses local: id") + + let items = try outbox.listItems(scope: scope) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].kind, .memoCreate) + XCTAssertEqual(items[0].memoId, id) + XCTAssertFalse(items.contains { $0.kind == .memoUpdate }) + + let memo = try mirror.resolveMemo(scope: scope, id: id) + XCTAssertEqual(memo?.title, "Draft title") + XCTAssertEqual(memo?.contentMarkdown, "body") + } + + /// Regression: insert image → materialize sets server memoId → Done must update, not second create. + func testDoneAfterMaterializeUpdatesExistingMemoDoesNotCreateSecond() throws { + let serverId = "server-memo-materialized" + let now = EdgeEverDate.nowString() + // Simulate materializeForImage: server memo already in mirror with revision/hash. + var materialized = MemoDetail.localPlaceholder( + id: serverId, + notebookId: "nb1", + title: "Before image", + contentMarkdown: "with image later", + tags: [], + createdAt: now + ) + materialized.revision = 3 + materialized.contentHash = "hash-rev-3" + try mirror.upsertMemo(scope: scope, memo: materialized) + + // New-note draft may still exist; commit must clear it. + try drafts.write( + scope: scope, + draft: MemoDraft( + draftKey: DraftRepository.newKey, + title: "Before image", + contentMarkdown: "with image later", + contentJson: nil, + notebookId: "nb1", + tagsText: "", + expectedRevision: nil, + updatedAt: now + ) + ) + + let outcome = try MemoCreateCommit.commit( + scope: scope, + memoId: serverId, + expectedRevision: 3, + expectedContentHash: "hash-rev-3", + notebookId: "nb1", + title: "After Done", + contentMarkdown: "with image later\n\n![](/api/v1/resources/x/blob)\n", + contentJSON: nil, + tags: ["img"], + mirror: mirror, + outbox: outbox, + drafts: drafts + ) + XCTAssertEqual(outcome, .updatedMaterialized(memoId: serverId)) + + let items = try outbox.listItems(scope: scope) + XCTAssertEqual(items.count, 1, "only one outbox op for the materialized memo") + XCTAssertEqual(items[0].kind, .memoUpdate, "must update, not create a second note") + XCTAssertEqual(items[0].memoId, serverId) + XCTAssertFalse(items.contains { $0.kind == .memoCreate }, "no second create after materialize") + XCTAssertFalse(items.contains { $0.memoId.hasPrefix("local:") }, "must not mint local: twin") + + let updated = try mirror.resolveMemo(scope: scope, id: serverId) + XCTAssertEqual(updated?.title, "After Done") + XCTAssertTrue(updated?.contentMarkdown.contains("resources/x") == true) + XCTAssertEqual(updated?.tags, ["img"]) + + // No twin local: memo left in mirror from commit + let all = try mirror.listMemos(scope: scope, params: LocalMemoListParams(filter: .all)) + XCTAssertEqual(all.memos.count, 1) + XCTAssertEqual(all.memos.first?.id, serverId) + + let draft = try drafts.read(scope: scope, key: DraftRepository.newKey) + XCTAssertNil(draft, "new-note draft cleared on Done") + } + + func testDoneRejectsEmptyNotebook() { + XCTAssertThrowsError( + try MemoCreateCommit.commit( + scope: scope, + memoId: nil, + expectedRevision: nil, + expectedContentHash: nil, + notebookId: "", + title: "x", + contentMarkdown: "y", + contentJSON: nil, + tags: [], + mirror: mirror, + outbox: outbox, + drafts: drafts + ) + ) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift b/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift new file mode 100644 index 00000000..1adfdbd1 --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/MobileUIParityTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import EdgeEver + +/// Exercises shipped parity helpers (same semantics as Android `@edgeever/shared/mobile-ui` + notebooks). +final class MobileUIParityTests: XCTestCase { + func testToggleFilterReturnsToAllWhenPressedAgain() { + XCTAssertEqual( + MobileUI.toggleMemoFilterMode(current: .all, requested: .pinned), + .pinned + ) + XCTAssertEqual( + MobileUI.toggleMemoFilterMode(current: .pinned, requested: .pinned), + .all + ) + XCTAssertEqual( + MobileUI.toggleMemoFilterMode(current: .pinned, requested: .tagged), + .tagged + ) + } + + func testToggleSelectionAddAndRemove() { + let once = MobileUI.toggleMemoSelection(current: [], memoId: "a") + XCTAssertEqual(once, ["a"]) + let twice = MobileUI.toggleMemoSelection(current: once, memoId: "a") + XCTAssertTrue(twice.isEmpty) + let multi = MobileUI.toggleMemoSelection(current: once, memoId: "b") + XCTAssertEqual(multi, ["a", "b"]) + } + + func testNotebookDescendantsMatchTree() { + let notebooks = [ + makeNotebook(id: "root", parent: nil, name: "Root", order: 0), + makeNotebook(id: "child", parent: "root", name: "Child", order: 0), + makeNotebook(id: "grand", parent: "child", name: "Grand", order: 0), + makeNotebook(id: "other", parent: nil, name: "Other", order: 1), + ] + let ids = NotebookHierarchy.descendantIds(notebooks: notebooks, targetNotebookId: "root") + XCTAssertEqual(Set(ids), Set(["root", "child", "grand"])) + } + + func testFilterCollapsedHidesDescendants() { + let notebooks = [ + makeNotebook(id: "root", parent: nil, name: "Root", order: 0), + makeNotebook(id: "child", parent: "root", name: "Child", order: 0), + makeNotebook(id: "grand", parent: "child", name: "Grand", order: 0), + makeNotebook(id: "other", parent: nil, name: "Other", order: 1), + ] + let tree = NotebookHierarchy.treeItems(from: notebooks) + let filtered = NotebookHierarchy.filterCollapsed(items: tree, collapsedIds: ["root"]) + XCTAssertEqual(filtered.map(\.id), ["root", "other"]) + } + + func testNotebookSearchIncludesAncestorsAndDescendants() { + let notebooks = [ + makeNotebook(id: "work", parent: nil, name: "Work", order: 0), + makeNotebook(id: "proj", parent: "work", name: "Project Alpha", order: 0), + makeNotebook(id: "note", parent: "proj", name: "Daily", order: 0), + makeNotebook(id: "home", parent: nil, name: "Home", order: 1), + ] + let visible = NotebookHierarchy.searchVisibleIds(notebooks: notebooks, searchText: "alpha") + XCTAssertTrue(visible.contains("proj")) + XCTAssertTrue(visible.contains("work")) // ancestor + XCTAssertTrue(visible.contains("note")) // descendant + XCTAssertFalse(visible.contains("home")) + } + + func testResourcePathNormalizationAddsBlob() { + let base = URL(string: "https://demo.edgeever.org")! + XCTAssertEqual( + ResourceCache.normalizeProtectedResourcePath("/api/v1/resources/abc123", baseURL: base), + "/api/v1/resources/abc123/blob" + ) + XCTAssertEqual( + ResourceCache.normalizeProtectedResourcePath("/api/v1/resources/abc123/blob", baseURL: base), + "/api/v1/resources/abc123/blob" + ) + XCTAssertEqual( + ResourceCache.normalizeProtectedResourcePath("https://demo.edgeever.org/api/v1/resources/xyz", baseURL: base), + "/api/v1/resources/xyz/blob" + ) + XCTAssertTrue(ResourceCache.isProtectedResourceSource("/api/v1/resources/x", baseURL: base)) + XCTAssertFalse(ResourceCache.isProtectedResourceSource("https://cdn.example/img.png", baseURL: base)) + } + + func testListMemosFilterPinnedUsesShippedRepository() throws { + let db = try AppDatabase.makeEmpty() + let mirror = LocalMirrorRepository(dbQueue: db) + let scope = "https://demo|user" + let now = EdgeEverDate.nowString() + var pinned = MemoDetail.localPlaceholder( + id: "p1", notebookId: "nb", title: "Pinned", contentMarkdown: "x", tags: [], createdAt: now + ) + pinned.isPinned = true + let plain = MemoDetail.localPlaceholder( + id: "p2", notebookId: "nb", title: "Plain", contentMarkdown: "y", tags: ["t"], createdAt: now + ) + try mirror.applyBootstrapBatch( + scope: scope, + notebooks: [makeNotebook(id: "nb", parent: nil, name: "N", order: 0)], + memos: [pinned, plain] + ) + let pinnedOnly = try mirror.listMemos( + scope: scope, + params: LocalMemoListParams(filter: .pinned) + ) + XCTAssertEqual(pinnedOnly.memos.map(\.id), ["p1"]) + let tagged = try mirror.listMemos( + scope: scope, + params: LocalMemoListParams(filter: .tagged) + ) + XCTAssertEqual(tagged.memos.map(\.id), ["p2"]) + } + + private func makeNotebook(id: String, parent: String?, name: String, order: Int) -> Notebook { + Notebook( + id: id, + parentId: parent, + name: name, + slug: nil, + icon: nil, + color: nil, + sortOrder: order, + memoCount: 0, + lastMemoUpdatedAt: nil, + createdAt: EdgeEverDate.nowString(), + updatedAt: EdgeEverDate.nowString() + ) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/SyncProtocolTests.swift b/apps/ios/Tests/EdgeEverTests/SyncProtocolTests.swift new file mode 100644 index 00000000..4a78375d --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/SyncProtocolTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import EdgeEver + +final class SyncProtocolTests: XCTestCase { + func testCursorRewound() { + XCTAssertTrue(SyncProtocol.hasCursorRewound(localCursor: 10, serverCursor: 3)) + XCTAssertFalse(SyncProtocol.hasCursorRewound(localCursor: 10, serverCursor: 10)) + XCTAssertFalse(SyncProtocol.hasCursorRewound(localCursor: 10, serverCursor: nil)) + } + + func testIdentityChanged() { + XCTAssertTrue(SyncProtocol.hasIdentityChanged(localIdentity: "a", serverIdentity: "b")) + XCTAssertFalse(SyncProtocol.hasIdentityChanged(localIdentity: "a", serverIdentity: "a")) + XCTAssertFalse(SyncProtocol.hasIdentityChanged(localIdentity: "a", serverIdentity: nil)) + } + + func testMetadataInitialized() { + XCTAssertTrue(SyncProtocol.isMetadataInitialized(cursorValue: "0", identityValue: "id")) + XCTAssertFalse(SyncProtocol.isMetadataInitialized(cursorValue: nil, identityValue: "id")) + XCTAssertFalse(SyncProtocol.isMetadataInitialized(cursorValue: "x", identityValue: "id")) + } + + func testBootstrapBatches() { + let batches = SyncProtocol.splitBootstrapWriteBatches(Array(0 ..< 5), batchSize: 2) + XCTAssertEqual(batches.count, 3) + XCTAssertEqual(batches[0], [0, 1]) + XCTAssertEqual(batches[2], [4]) + } + + func testDataScope() throws { + let url = try EdgeEverURLNormalizer.normalizeInstanceURL("https://demo.edgeever.org/") + XCTAssertEqual( + SyncProtocol.createDataScope(baseURL: url, userId: "u1"), + "https://demo.edgeever.org|u1" + ) + } +} diff --git a/apps/ios/Tests/EdgeEverTests/URLNormalizeTests.swift b/apps/ios/Tests/EdgeEverTests/URLNormalizeTests.swift new file mode 100644 index 00000000..f585deda --- /dev/null +++ b/apps/ios/Tests/EdgeEverTests/URLNormalizeTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import EdgeEver + +final class URLNormalizeTests: XCTestCase { + func testAddsHTTPS() throws { + let url = try EdgeEverURLNormalizer.normalizeInstanceURL("demo.edgeever.org") + XCTAssertEqual(url.absoluteString, "https://demo.edgeever.org") + } + + func testStripsTrailingSlash() throws { + let url = try EdgeEverURLNormalizer.normalizeInstanceURL("https://demo.edgeever.org/") + XCTAssertEqual(url.absoluteString, "https://demo.edgeever.org") + } + + func testRejectsEmpty() { + XCTAssertThrowsError(try EdgeEverURLNormalizer.normalizeInstanceURL(" ")) + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml new file mode 100644 index 00000000..1f5a426f --- /dev/null +++ b/apps/ios/project.yml @@ -0,0 +1,131 @@ +name: EdgeEver +options: + bundleIdPrefix: org.edgeever + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + groupSortPosition: top + developmentLanguage: en + xcodeVersion: "16.0" +packages: + GRDB: + url: https://github.com/groue/GRDB.swift + from: 7.0.0 + Pow: + url: https://github.com/EmergeTools/Pow + from: 1.0.0 +settings: + base: + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: false + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: 1.0.0 + DEVELOPMENT_TEAM: "" + CODE_SIGN_STYLE: Automatic +configs: + Debug: debug + Release: release +configFiles: + Debug: Config/Debug.xcconfig + Release: Config/Release.xcconfig +targets: + EdgeEver: + type: application + platform: iOS + sources: + - path: EdgeEver + excludes: + - Resources/Info.plist + resources: + - path: EdgeEver/Resources/Assets.xcassets + - path: EdgeEver/Resources/Localizable.xcstrings + - path: EdgeEver/Resources/PrivacyInfo.xcprivacy + - path: EdgeEver/Resources/EditorBundle + dependencies: + - package: GRDB + product: GRDB + - package: Pow + product: Pow + - target: ShareExtension + embed: + - path: ShareExtension.appex + buildPhase: embedAppExtensions + codeSign: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.edgeever.mobile + PRODUCT_NAME: EdgeEver + INFOPLIST_FILE: EdgeEver/Resources/Info.plist + CODE_SIGN_ENTITLEMENTS: EdgeEver/Resources/EdgeEver.entitlements + GENERATE_INFOPLIST_FILE: false + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + SWIFT_STRICT_CONCURRENCY: complete + ENABLE_TESTABILITY: true + info: + path: EdgeEver/Resources/Info.plist + properties: + CFBundleDisplayName: EdgeEver + CFBundleName: EdgeEver + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + UIRequiresFullScreen: true + CFBundleURLTypes: + - CFBundleURLName: org.edgeever.mobile + CFBundleURLSchemes: + - edgeever + ITSAppUsesNonExemptEncryption: false + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + NSAllowsArbitraryLoads: true + NSAllowsArbitraryLoadsInWebContent: true + NSPhotoLibraryUsageDescription: EdgeEver needs photo library access to insert images into notes. + NSCameraUsageDescription: EdgeEver only uses the camera if content inside a note explicitly requests it. + NSMicrophoneUsageDescription: EdgeEver only uses the microphone if content inside a note explicitly requests it. + entitlements: + path: EdgeEver/Resources/EdgeEver.entitlements + properties: + com.apple.security.application-groups: + - group.org.edgeever.mobile + scheme: + testTargets: + - EdgeEverTests + ShareExtension: + type: app-extension + platform: iOS + sources: + - path: ShareExtension + excludes: + - Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.edgeever.mobile.share-extension + PRODUCT_NAME: ShareExtension + INFOPLIST_FILE: ShareExtension/Info.plist + CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements + GENERATE_INFOPLIST_FILE: false + SKIP_INSTALL: true + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks" + entitlements: + path: ShareExtension/ShareExtension.entitlements + properties: + com.apple.security.application-groups: + - group.org.edgeever.mobile + EdgeEverTests: + type: bundle.unit-test + platform: iOS + sources: + - path: Tests/EdgeEverTests + dependencies: + - target: EdgeEver + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.edgeever.mobile.tests + GENERATE_INFOPLIST_FILE: true + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/EdgeEver.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EdgeEver" + BUNDLE_LOADER: "$(TEST_HOST)" diff --git a/docs/ios-swift-rewrite.md b/docs/ios-swift-rewrite.md new file mode 100644 index 00000000..9a125c93 --- /dev/null +++ b/docs/ios-swift-rewrite.md @@ -0,0 +1,1381 @@ +# EdgeEver iOS Swift Rewrite + +| Field | Value | +| --- | --- | +| **Document** | EdgeEver iOS native rewrite (drop React Native on iOS) | +| **Author** | TBD | +| **Date** | 2026-08-06 | +| **Status** | Approved (rev 4 — design review consensus; scaffold PR 1 landed) | +| **Audience** | Senior engineers implementing `apps/ios` | +| **Related** | `docs/mobile-native-parity.md`, `apps/mobile`, `packages/client`, `packages/shared` | + +--- + +## Overview + +EdgeEver’s mobile client today is a single Expo / React Native app in `apps/mobile` targeting both Android and iOS. Android is on Google Play and is healthy. iOS has been under App Store review but fails in production with hard crashes inside React Native’s JS runtime and Fabric attributed-string paths (`EXC_BREAKPOINT` / `SIGTRAP` on `com.facebook.react.runtime.JavaScript`, malloc aborts near Hermes / AttributedString). These are not ordinary app-logic bugs; further RN crash debugging on iOS is an open-ended cost with low confidence of a durable fix. + +**Product decision (final):** abandon the React Native iOS path entirely and ship a pure Swift / SwiftUI native app under `apps/ios`, keeping the same App Store listing and bundle identifier `org.edgeever.mobile`. Android remains Expo / RN in `apps/mobile`. The first App Store submission of the Swift binary **must include full editing** (login, list, sync, create/edit/autosave, offline queue, TipTap body)—not a read-only MVP. + +This document specifies architecture, API surface, SQLite schema, sync state machine, TipTap WKWebView integration, save lifecycle, directory layout, acceptance criteria, migration from RN iOS, risks, key decisions, and an ordered PR / work-package plan suitable to scaffold `apps/ios` immediately after approval. + +--- + +## Background & Motivation + +### Current mobile architecture (RN) + +| Layer | Location | Role | +| --- | --- | --- | +| Shell UI | `apps/mobile/src/screens/*` | Login, workspace, notes list, detail chrome, settings | +| Local mirror | `apps/mobile/src/lib/local-mirror.ts` | SQLite: notebooks, memos, sync meta, id mappings | +| Sync protocol helpers | `apps/mobile/src/lib/mobile-sync-protocol.ts` | Cursor rewind / identity change / bootstrap batching | +| Offline outbox | `apps/mobile/src/lib/sync-queue.ts` | `memo.create` / `memo.update` queue (AsyncStorage) | +| Session | `apps/mobile/src/lib/session.tsx` | SecureStore session + deviceId; Bearer token | +| API client | `packages/client/src/index.ts` | Full REST surface against `/api/v1/*` | +| Editor | `apps/mobile/src/components/LocalTiptapEditor.tsx` | TipTap in Expo DOM / WebView; shared toolbar copy in `@edgeever/shared/mobile-editor` | +| Share extension | `apps/mobile/ios/expo-sharing-extension/ShareIntoViewController.swift` | Already native Swift; App Group + open host app | +| Parity rules | `docs/mobile-native-parity.md` | Native shell for lists/nav/settings; TipTap WebView for note body; exclude ZIP import/export and trash browse from app core | + +### Pain points + +1. **iOS RN instability under review:** crash logs under `.tmp-app-review-crashes/` point at RN Fabric / Hermes, not EdgeEver business logic. +2. **Dual-platform RN cost on a broken half:** energy spent on iOS RN does not improve Android and blocks App Store continuity. +3. **Editor already hybrid:** TipTap is hosted in a WebView on mobile today. A native Swift shell with the same WebView document model is a smaller leap than rewriting the editor in native text views. +4. **Share extension is already Swift:** host-app rewrite can absorb and simplify the extension without Expo plugin constraints. + +### Product constraints (confirmed) + +- Minimum deployment target: **iOS 17** (SwiftUI-first). +- **Editing required** before first App Store submission of the Swift binary. +- Bundle ID continuity: **`org.edgeever.mobile`** (see `apps/mobile/app.json`). +- Android path: **unchanged** Expo/RN in `apps/mobile`. +- Repo policy (`AGENTS.md`): work on **`main` only** (no new git branches); bilingual docs when changing Chinese docs. +- Soft delete only on mobile (no trash browse UI); recovery remains desktop/PWA. + +--- + +## Goals & Non-Goals + +### Goals + +1. Ship a store-ready native iOS app with: + - Login to self-hosted or cloud instance (`baseUrl` + username/password + `deviceId`). + - Notebook hierarchy browse/select + memo list (filter, sort, pin, search, soft delete); memo move-to-notebook via content outbox. + - Local SQLite mirror + incremental sync (`/api/v1/sync/bootstrap`, `/api/v1/sync/changes`). + - Create / rich-edit / autosave with TipTap body (same `TiptapDoc` schema as web/RN). + - Offline create/update queue with revision conflict handling and `local:` id remap. + - Protected resource loading (images/attachments) with Bearer token. + - Image upload with optional compression and placeholder UX. + - Share Extension (text / URL → open app and create draft). + - Settings subset: locale, theme, image compression, account security (password), API tokens, device sessions, sign out. +2. Keep API contracts identical to `packages/client` (no backend fork for iOS). +3. Preserve App Store listing continuity (same bundle id, ASC app id `6792625631`). +4. Stop EAS iOS builds and RN iOS maintenance; document Android-only evolution of `apps/mobile`. +5. Structure `apps/ios` so an engineer can scaffold and implement module-by-module from this doc. + +### Non-Goals (v1 submission) + +- ZIP import/export, trash browse / empty trash UI. +- Multi-user **admin** UI (`listUsers` / create/disable users). RN mobile only shows the current user's role label in settings—it does **not** ship a user-management console. Do not invent one for iOS. +- Full desktop feature parity (templates library management, merge UI polish, object-storage admin). +- Replacing Android RN with Kotlin/Compose (out of scope). +- OTA JS updates on iOS (EAS Update ends for iOS; native releases only). +- Shared Swift package for Android (impossible); do **not** invent a third shared runtime. +- Offline-first for **every** mutation: content create/update always use the outbox (K6/K14); pin and soft-delete of already-synced notes remain **online-first** (match RN). Notebook **create/rename/delete** is **Deferred** (RN mobile has no CRUD UI). +- Rewriting TipTap in native Swift text views. +- Share Extension media (image/video/audio/file) in v1 (text + web URL only; intentional reduction vs current RN Share extension). + +### v1 vs current RN parity matrix + +Signed-off scope relative to `docs/mobile-native-parity.md` and today's `apps/mobile` implementation. **Required** = must pass store acceptance. **Deferred** = post-v1 / may land if schedule allows without blocking review. **Out of scope** = not in mobile app core. + +| Capability | RN today | iOS Swift v1 | +| --- | --- | --- | +| Login + self-hosted instance | Implemented | **Required** | +| Notebook hierarchy browse + select/filter | Implemented (select/hierarchy only; **no** create/rename/delete UI—`createNotebook`/`updateNotebook`/`deleteNotebook` unused in `apps/mobile/src`) | **Required** (select/filter/tree from mirror; empty set → “请先创建一个笔记本” / create elsewhere) | +| Notebook create / rename / delete | Not in RN app UI | **Deferred** (desktop/PWA; not Gate A/B) | +| Move memo to notebook | Implemented (content path / picker) | **Required** (via memo edit notebook field + outbox `memo.update` / create payload) | +| Memo list, filter, sort, search | Implemented | **Required** | +| Pin / unpin | Implemented (online `updateMemo`) | **Required** (online-first) | +| Batch move / multi-select | Implemented | **Deferred** (single-note move OK if easy) | +| Soft delete | Implemented | **Required** | +| Local TipTap create/edit + autosave | Implemented | **Required** (gate) | +| Offline drafts + mirror + outbox | Implemented | **Required** (gate) | +| Note body viewer (TipTap) | Implemented | **Required** | +| In-note image load (Bearer) | Implemented | **Required** | +| Image upload + compression | Implemented | **Required** for App Review; **not** required for internal text-only edit gate | +| In-note attachment open / rename / delete | Implemented (`handleRenameResource` / `handleDeleteResource`) | **Required** for Gate B (body actions—not the full resource library screen) | +| Resource library screen | Implemented | **Deferred** | +| Tag management (rename/delete global tags) | Implemented | **Deferred** (tags on memo edit Required) | +| Revision history + restore | Implemented | **Deferred** (prefer include if time; not edit-gate) | +| Memo share links | Implemented | **Deferred** | +| MCP/API token management | Implemented | **Required** | +| Password change | Implemented | **Required** | +| Device sessions revoke | Implemented | **Required** | +| Multi-user admin console | Parity doc “Implemented” overstates; settings show role only | **Out of scope** | +| Share Extension text/URL | Implemented | **Required** | +| Share Extension image/video/file | Implemented in Swift extension | **Deferred** (call out in release notes) | +| ZIP import/export, trash browse | Excluded from core | **Out of scope** | +| EAS Update OTA | iOS+Android | Android only; iOS **Out of scope** | + +--- + +## Key Decisions + +| # | Decision | Rationale | +| --- | --- | --- | +| K1 | **Pure Swift / SwiftUI app in `apps/ios`; abandon RN iOS entirely** | RN Fabric/Hermes crashes are not fixable with app logic. Native shell is the product requirement for store reliability. `app.json` has `newArchEnabled: true`; crash stacks align with Fabric/Hermes, reinforcing abandon over patching. | +| K2 | **iOS 17+ only** | Unlocks modern SwiftUI navigation, Observation, and simpler concurrency without UIKit-heavy polyfills. Acceptable given crash recovery priority. | +| K3 | **Same bundle id `org.edgeever.mobile` and ASC app** | Continuity of reviews, ratings, and existing App Store Connect configuration (`eas.json` `ascAppId: 6792625631`). | +| K4 | **Editing + offline queue required before first Swift submission** | Product decision: no read-only MVP. Match RN mobile core capture loop. | +| K5 | **GRDB + SQLite local mirror with same semantic tables as RN** | Proven protocol in `local-mirror.ts`; GRDB is the mature Swift SQLite layer with migrations and observation. | +| K6 | **Committed content create/update always go through optimistic mirror + SQLite outbox; never bypass the queue for body/title/tags/notebook on the content path** | Matches RN: edits of existing memos use `localUpdateMemoMutation` (queue + mirror); first commit of a new note uses `local:` + `memo.create` or, after materialize, `memo.update`. **Uncommitted new-note composer** matches RN create-sheet: draft-only (`mobile_drafts` / `new`) until Done **or** materialize-for-image—no parallel bare `createMemo` + pending `memo.create`. Online-first is **rejected** for content saves of committed memos. | +| K24 | **At most one server create per new-note session; materialize never races outbox create** | RN `materializeMemoForImage` only runs when create-sheet has no pending `memo.create` (drafts only). iOS uses the same draft-until-commit/materialize model for new notes, plus an explicit lock algorithm if a `local:` create is already queued (see Materialize algorithm). | +| K7 | **URLSession-based API client port of `packages/client` subset** | No dependency on JS packages at runtime. Hand-port Codable models from `packages/shared/src/types.ts` + OpenAPI. | +| K8 | **Keychain for session token + deviceId; App Group `group.org.edgeever.mobile` for Share Extension** | Matches SecureStore semantics; group id already in `EdgeEver.entitlements` and share extension (confirm Apple Developer portal still matches). | +| K9 | **WKWebView hosts TipTap for viewer + editor; native chrome around it** | Same document schema (`TiptapDoc` JSON), Mermaid, tables, images as web/RN. Avoid dual editors. | +| K10 | **Editor assets: versioned static `EditorBundle` built from monorepo `EditorSource` (Vite)** | Load via `loadFileURL`. Incremental milestones (viewer → editor → resources → mermaid). Smoke-test with Vitest where feasible. No remote editor at runtime. | +| K11 | **Message bridge: typed JSON over `WKScriptMessageHandler` + `evaluateJavaScript`** | Replaces Expo DOM imperative handle (`LocalTiptapEditorRef`). | +| K12 | **Resource images: native fetch with Bearer → data URL or temp file injected into WebView** | Mirrors RN `getResourceBlob` → `blobToDataUrl` / `onLoadResource`. Do not rely on cookie sessions. | +| K13 | **Offline ids: `local:` prefix; survive bootstrap rebuild; remap via `mobile_id_mappings`** | Exact RN semantics in `local-mirror.ts` / `sync-queue.ts`. | +| K14 | **Outbox kinds v1: `memo.create`, `memo.update` only for committed content; materialize is a separate single online create (K24)** | Matches RN queue for Done/offline create and all updates. Soft delete of remote notes is online; local-only pending creates cancel queue + delete row. Pin online-first. Notebook create/rename/delete **Deferred** (not RN mobile). Materialize may call `createMemo` **only** under K24 (never while a live `memo.create` remains queued). | +| K15 | **Conflict model: revision + contentHash + edit sessions** | Flush: `createMemoEditSession` then compare `baseRevision`/`baseContentHash` to expected; mismatch → `revision_conflict` **before** PATCH. On 409 / precheck conflict, mark queue `conflict` and offer discard-local / keep draft. | +| K16 | **Android stays Expo/RN in `apps/mobile`; stop iOS EAS builds only after Swift TestFlight validates edit AC** | Ordering hazard: do not remove EAS iOS job until a Swift binary is shippable. Then CI/release scripts become Android RN + native iOS pipelines. | +| K17 | **Share Extension rewritten under `apps/ios` control** | Drop Expo sharing plugin; keep App Group payload pattern. v1 = text + web URL only. | +| K18 | **No new git branches** | `AGENTS.md`: ordered **work packages / commit series on `main`**. “PR” in this doc means logical reviewable package, **not** a mandate to open long-lived GitHub branches. | +| K19 | **Minimum TipTap feature set for store = RN mobile toolbar** | Bold, bullet list, indent, blockquote, HR, image; tables/Mermaid render; not full desktop toolbar. | +| K20 | **Markdown conversion runs inside EditorBundle only; native Swift does not reimplement markdown** | Wire format for create/update remains `contentMarkdown` (RN). Editor works in `TiptapDoc` JSON; `docToMarkdown` / `markdownToDoc` use the same TipTap Markdown extension path as `@edgeever/shared` content, validated by shared golden fixtures. Autosave payloads are markdown strings produced by the bundle (or via bridge `getMarkdown()`), not a second Swift converter. | +| K21 | **Cross-client sync contract fixtures are shared and required** | Android RN and iOS Swift both consume golden fixtures under `tests/mobile-sync-fixtures/` (or `packages/shared`); Android mirror/queue changes update fixtures in the same commit series. | +| K22 | **HTTPS default; optional user-confirmed `http://` for LAN self-host** | Match Android cleartext practicality with explicit warning; limited ATS exception + App Review notes. | +| K23 | **Freeze/remove `apps/mobile/ios` generation once Android-only prebuild is configured** | Prevent accidental EAS iOS builds from the RN tree after cutover. | + +--- + +## Proposed Design + +### High-level architecture + +```mermaid +flowchart TB + subgraph iosApp ["apps/ios EdgeEver.app"] + UI[SwiftUI Shell
Login / List / Detail / Settings] + Session[SessionStore
Keychain] + API[APIClient
URLSession] + Mirror[LocalMirror
GRDB SQLite] + Outbox[SyncOutbox
SQLite] + SyncEngine[SyncEngine
bootstrap + changes + flush] + Editor[TipTapWebView
WKWebView + bridge] + ShareHost[ShareHandoff
App Group] + UI --> Session + UI --> Mirror + UI --> SyncEngine + UI --> Editor + SyncEngine --> API + SyncEngine --> Mirror + SyncEngine --> Outbox + Editor --> API + Session --> API + ShareHost --> UI + end + + subgraph server ["EdgeEver API"] + Auth["/api/v1/auth/*"] + Sync["/api/v1/sync/*"] + Memos["/api/v1/memos/*"] + Res["/api/v1/resources/*"] + end + + API --> Auth + API --> Sync + API --> Memos + API --> Res + + subgraph shareExt ["Share Extension"] + SE[ShareViewController] + SE --> ShareHost + end +``` + +### Layering + +| Layer | Responsibility | Tech | +| --- | --- | --- | +| **Presentation** | Navigation, lists, forms, sheets, accessibility | SwiftUI, iOS 17+ | +| **Application** | Use cases: sign-in, open memo, save draft, run sync | Actors / `@Observable` stores | +| **Domain models** | `Notebook`, `MemoDetail`, `SyncChange`, queue items | Codable structs (parity with `packages/shared`) | +| **Data** | Mirror queries, outbox mutations, preferences | GRDB, FileManager cache | +| **Network** | REST, multipart upload, blob download | URLSession | +| **Editor host** | Load TipTap bundle, bridge messages, inject resources | WKWebView | +| **Security** | Tokens, App Group, ATS | Keychain, entitlements | + +### Navigation / screens (v1) + +```mermaid +flowchart LR + Launch --> SessionCheck + SessionCheck -->|no session| Login + SessionCheck -->|session| Workspace + Login --> Workspace + Workspace --> NotesList + Workspace --> Settings + NotesList --> MemoDetail + MemoDetail --> MemoEditor + NotesList --> CreateMemo + CreateMemo --> MemoEditor + Settings --> AccountSecurity + Settings --> ApiTokens + Settings --> DeviceSessions +``` + +| Screen | RN source of truth | Notes | +| --- | --- | --- | +| Login | `LoginScreen.tsx` | `baseUrl`, username, password; normalize URL like `session.tsx` | +| Workspace shell | `WorkspaceScreen.tsx` | Notebook sidebar/sheet + notes stack | +| Notes list | `WorkspaceNotesView.tsx` | Search debounce 250ms; sort/filter; density | +| Memo detail (viewer) | `WorkspaceMemoDetail.tsx` | Native chrome + TipTap `mode: viewer` | +| Create / edit | create sheet + rich edit session in `WorkspaceScreen.tsx` | TipTap `mode: editor` + autosave | +| Settings | `WorkspaceSettingsView.tsx` | Locale, theme, compression, sync status, sign out | +| Account security | `AccountSecurityModal.tsx` | Change password | +| Sync conflicts | inline in workspace | Discard local / copy draft (port `discardMobileMemoConflict`) | + +### Data scope + +Mirror and outbox are partitioned by: + +```text +scope = lowercased(trimmed baseUrl) + "|" + (userId ?? "anonymous") +``` + +Same as `createMobileDataScope` in `local-mirror.ts`. Signing out or switching account must not leak rows across scopes. Identity change / full rebuild clears remote rows for the scope but **preserves `id LIKE 'local:%'`** memos. + +### Sync engine state machine + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> CheckingMeta: appActive / pullRequested + CheckingMeta --> Bootstrap: cursor missing OR identity missing + CheckingMeta --> Incremental: meta OK + Bootstrap --> WritingSnapshot: pages from /sync/bootstrap + WritingSnapshot --> Bootstrap: has nextAfterId + WritingSnapshot --> Incremental: snapshot complete, set cursor+identity + Incremental --> ApplyingChanges: /sync/changes + ApplyingChanges --> Rebuild: cursorRewound OR identityChanged + Rebuild --> Bootstrap: clear cursor+identity meta + ApplyingChanges --> Incremental: hasMore + ApplyingChanges --> FlushOutbox: !hasMore + FlushOutbox --> Idle: done + FlushOutbox --> FlushOutbox: next pending item + FlushOutbox --> ConflictItem: 409 revision_conflict + ConflictItem --> Idle: user resolves or leaves +``` + +**Rules (match RN):** + +1. Single-flight per scope (RN uses `syncPromises` map). +2. Bootstrap page size **200** (RN passes 200; `packages/client` default is 100—do not use the client default); write batches of 50. +3. Changes page size 200. +4. On rewind (`serverCursor < localCursor`) or identity mismatch: delete `cursor`+`identity` meta keys and re-bootstrap; delete non-`local:` memos; re-upsert notebooks from snapshot. +5. **Outbox flush triggers** (any of these; not only “after pull”): + - Queue becomes non-empty after enqueue (create/update/autosave). + - App becomes active / foreground (`scenePhase == .active`). + - After a successful mirror pull completes (opportunistic). + - Retry timer fires for `error` items (`nextAttemptAt`). + - Match RN `useMobileAutomaticSync`: if already running, coalesce a follow-up run (`requestedRef` pattern). +6. Flush order: oldest `createdAt` among `pending` / `error` / `syncing` with `nextAttemptAt == null || nextAttemptAt <= now`. +7. Retry backoff: `min(5min, 2^min(attempt,6) * 1s)` — port `getSyncRetryDelayMs` from `packages/shared/src/sync.ts`. +8. Create absorbs subsequent updates for the same memo id (RN `queueMobileMemoUpdate` folds title/markdown/notebook/tags into pending `memo.create`). +9. On successful create: `replaceLocalMemoId(temporaryId, remoteMemo)` + insert id mapping; if queue version moved during flight, promote remaining create payload to `memo.update` against new id (`promoteQueuedMemoCreate`). +10. On successful update while a newer version was enqueued: rebase `expectedRevision`/`expectedContentHash` from server memo (`rebaseQueuedMemoUpdate`). +11. On successful create flush when the queue item was **removed mid-flight** (user cancelled/deleted local note): soft-delete the remote orphan (`deleteMemo(id, permanent: false)`) — RN best-effort cleanup in `syncMobileQueuedChanges`. +12. Edit-session precheck on flush of `memo.update`: after `createMemoEditSession`, if `editSession.baseRevision != expectedRevision` **or** `editSession.baseContentHash != expectedContentHash`, treat as `revision_conflict` **before** calling `updateMemo` (RN `syncMobileQueueItem`). + +#### Soft delete algorithm (port `mobile-memo-delete.ts`) + +For each memo id in a delete request: + +1. Classify **local-only** vs **remote**: + - Local-only if `id` starts with `local:` **or** there is a pending `memo.create` for that id. + - Exception: if `local:` maps via `mobile_id_mappings` / `resolveLocalMemo` to a non-local remote id without pending create, treat as remote. +2. **Local-only path:** `cancelMobileMemoQueueItems` (all kinds for that memoId) → hard `DELETE` mirror row → clear draft. **No API call.** +3. **Remote path:** require online client; `deleteMemo` (single) or `deleteMemos` (batch) with `permanent: false` for ordinary mobile delete → `softDeleteLocalMemo` (set `isDeleted`/`deletedAt` on mirror) → clear draft. Permanent delete only if explicitly requested (not default UI). +4. In-flight create race: if create flush already succeeded and user deleted local-only id that was remapped, resolve mapping and soft-delete remote; if create flush completes after cancel and finds item removed, soft-delete remote orphan (rule 11). + +### Editing save lifecycle + +**Hard decision (K6/K14/K24):** + +1. **Committed content** create/update always optimistic mirror + outbox. Online-first content save for committed memos is rejected. +2. **New-note composer** matches RN create-sheet (`WorkspaceScreen` create path): **draft-until-Done-or-materialize**—no `memo.create` and no mirror list row until the user commits (Done) **or** materializes for image upload. This prevents double server creates. +3. Direct online `updateMemo` remains reserved for **non-content** paths such as pin. + +RN reference: + +| Situation | RN behavior | iOS | +| --- | --- | --- | +| New note typing before Done | `writeMobileNewMemoDraft` only (~350 ms); **no** outbox | Same → `mobile_drafts` key `new` | +| Done offline / no prior materialize | `local:` + `upsertLocalMemo` + `queueMobileMemoCreate` | Same | +| Done after materialize | optimistic update + `queueMobileMemoUpdate` on **server** id | Same | +| Image on create sheet | `materializeMemoForImage` → online `createMemo` once (no pending create); then upload | Same algorithm below | +| Edit existing memo | `localUpdateMemoMutation` always outbox | Same | + +```mermaid +sequenceDiagram + participant User + participant Editor as TipTapWebView + participant Store as MemoEditStore + participant Drafts as mobile_drafts + participant Mirror as LocalMirror + participant Outbox as SyncOutbox + participant Sync as SyncEngine + participant API as APIClient + + alt New note before commit + User->>Editor: type + Editor->>Store: change + Store->>Drafts: write draft key new + Note over Drafts: No memo.create yet (RN create-sheet) + else Done without materialize + Store->>Mirror: upsert local: MemoDetail + Store->>Outbox: memo.create + else Edit committed / after materialize + Store->>Mirror: upsert optimistic + Store->>Outbox: memo.update or absorb into pending create + end + Sync->>Outbox: flush + Outbox->>API: createMemo OR editSession + updateMemo + API-->>Outbox: MemoDetail + Outbox->>Mirror: replaceLocalMemoId / upsert +``` + +**Mutation classes:** + +| Class | Examples | Path | +| --- | --- | --- | +| **Uncommitted new draft** | Create-sheet typing before Done/materialize | `mobile_drafts` only—**not** outbox, **not** list row | +| **Content (outbox)** | Done on new note (`memo.create`); edits after materialize or of existing memo (`memo.update`); create-absorbs-update while `local:` pending | Optimistic mirror + outbox → flush | +| **Materialize (online create, once)** | First image on uncommitted new note | Online `createMemo` under K24 rules; then content path is `memo.update` only | +| **Online-first** | pin/unpin; soft-delete remote note | API immediately; on failure alert + keep prior UI; mirror on success | +| **Deferred online-first** | notebook create/rename/delete | Not in RN mobile UI; post-v1 if needed | +| **Local-only** | discard unsynced `local:` create or new draft | Cancel outbox if any + delete mirror/draft (no API) | + +#### New-note create path (RN-aligned) + +1. Require a target `notebookId` (default notebook from mirror; block if none with “请先创建一个笔记本”). +2. While composing: autosave **only** to `mobile_drafts` (`draft_key = new` for scope) at ~350 ms—**do not** allocate `local:`, **do not** enqueue `memo.create`, **do not** call `createMemo`. +3. **Done without prior materialize:** + - Allocate `temporaryId = "local:" + base36(time) + ":" + random`. + - Build `MemoDetail` (`revision: 0`, `contentHash: "local:" + temporaryId`, full content fields). + - `upsertLocalMemo` + `queue memo.create` (title, contentMarkdown, notebookId, tags, createdAt). + - Clear draft `new`. List shows the note; flush creates server row later. +4. **Done after materialize:** treat as update path on server memo id (`memo.update` outbox + mirror upsert); clear memo draft; **never** enqueue `memo.create` for that session. +5. Subsequent autosaves while `memo.create` still pending for a `local:` id **fold into** the create payload (create-absorbs-update)—never parallel `memo.update` for the same `local:` id. +6. Images: run **Materialize algorithm** (below) before upload; offline → disable image control with clear copy. + +#### Materialize algorithm (at most one server create) — K24 + +Invariant: for a given new-note composer session / `local:` id, **exactly one** of: (a) pending or completed outbox `memo.create`, or (b) a successful online materialize `createMemo`—never both, and never two online creates. + +Implement as a single actor method `materializeForImage(session)` with exclusive lock on `(scope, sessionId)`: + +```text +materializeForImage(session): + // session holds: optional localId, optional serverMemo, current editor payload + + if session.serverMemo != null: + return session.serverMemo // already materialized (RN materializedMemoRef) + + if session.localId != null: + resolved = resolveLocalMemo(scope, session.localId) + if resolved != null AND not resolved.id.hasPrefix("local:"): + session.serverMemo = resolved + return resolved // outbox create already remapped + + require online client else throw needNetworkForImages + + with exclusiveTransaction(scope, memoKey: session.localId ?? session.draftKey): + createItem = outbox.find(kind: memo.create, memoId: session.localId) if localId else nil + + if createItem == null AND session.localId == null: + // RN common case: draft-only create-sheet + response = API.createMemo(currentPayload) + session.serverMemo = response.memo + upsertLocalMemo(response.memo) + clearDraft(new) + // NO memo.create enqueued + return response.memo + + if createItem == null AND session.localId != null: + // Mirror row exists without outbox create (unexpected); still only one create + response = API.createMemo(currentPayload) + replaceLocalMemoId(session.localId, response.memo) // or upsert + delete local + mapping + session.serverMemo = response.memo + session.localId = response.memo.id + return response.memo + + if createItem.status == "syncing": + // Create already in flight — MUST NOT call createMemo again + await flushWaitFor(createItem) // or wait for SyncEngine completion for this id + resolved = resolveLocalMemo(scope, session.localId) + if resolved == null OR resolved.id.hasPrefix("local:"): + throw materializeFailed + session.serverMemo = resolved + session.localId = resolved.id + return resolved + + if createItem.status == "pending" OR createItem.status == "error": + // Outbox would also create — cancel first, then single online create (or wait-for-flush) + // Preferred for image latency: cancel + online create with latest editor payload + cancelOutboxItem(createItem) // version-checked remove; no API + response = API.createMemo(currentPayloadFromEditor) // includes latest title/body/tags + replaceLocalMemoId(session.localId, response.memo) + session.serverMemo = response.memo + session.localId = response.memo.id + // Do NOT re-queue memo.create; further edits → memo.update only + return response.memo + + if createItem.status == "conflict": + // memo.create should not 409; surface error, do not create second memo + throw materializeFailed +``` + +**Forbidden:** + +- Calling `API.createMemo` while a `memo.create` for the same local id remains in outbox (`pending`/`error`/`syncing`). +- Enqueueing `memo.create` after a successful materialize for that session. +- Leaving both a remapped server row and a live `memo.create` for the old `local:` id. + +**After materialize:** image upload uses `session.serverMemo.id`; editor autosave uses `memo.update` with that id’s revision/hash; Done uses update path (RN createMutation materialized branch). + +**Unit tests (required for Gate B / PR 7):** draft-only materialize → one `createMemo`; materialize after Done with pending create → cancel then one `createMemo` OR wait-flush zero extra creates; materialize while `syncing` → zero extra `createMemo`; concurrent double-tap image → single create (lock). + +**Update path (content) — existing or post-materialize memo:** + +1. Base revision/hash from current mirror row (`resolveLocalMemo`), not stale UI-only state. +2. Optimistic upsert of title, tags, notebookId, contentJson/markdown/text/excerpt + `queue memo.update` with `expectedRevision` / `expectedContentHash` and full content fields. +3. Title, tags, and notebook changes participate in the **same** autosave/outbox path as body (not body-only). +4. Flush: `POST /memos/:id/edit-sessions` → precheck base revision/hash → `PATCH` with `editSessionId` + expectations + contentMarkdown/title/tags/notebookId. +5. Autosave debounce: **500 ms** idle after editor body change (`CHANGE_IDLE_MS`); title/tags ~**350 ms**. Force flush on `scenePhase` background / resign active (for **committed** mirror rows / outbox items; uncommitted new drafts only force-write `mobile_drafts`). + +**Drafts (process death):** + +- New uncommitted: `mobile_drafts` key `new` (RN `edgeever.mobile.newMemoDraft:`). +- Committed / editing existing: `mobile_drafts` key `memo:` (RN `edgeever.mobile.memoDraft:`). +- On editor open, rehydrate draft if newer than mirror; clear after successful commit enqueue or discard. + +### TipTap / WKWebView integration + +#### Hosting model and extraction reality + +`LocalTiptapEditor.tsx` is a large DOM surface (viewer + editor modes, Mermaid via `beautiful-mermaid` + mermaid, resource press/load, image upload placeholders, search/replace, toolbar active flags, `MEMO_CONTENT_STYLE`). Treat extraction as a **multi-milestone** project, not a “small Vite page.” + +Ship a static package produced at monorepo build time: + +```text +apps/ios/EditorSource/ # TypeScript source (Vite) + package.json + vite.config.ts + src/main.ts + src/editor.ts + src/bridge.ts + src/markdown.ts # TipTap Markdown; sole doc↔md converter (K20) + src/mermaid.ts +apps/ios/EditorBundle/ # build output committed or CI-produced + index.html + assets/*.js + assets/*.css +``` + +Build: `Scripts/build-editor-bundle.sh` → Vite. Prefer smoke tests with Vitest on bridge message shapes and markdown round-trips using shared fixtures. + +**Milestones (map to PR 6a–6d):** + +| Milestone | Deliverable | Unlocks | +| --- | --- | --- | +| **6a Viewer** | Offline `setDocument` + render paragraphs/lists/quotes/tables; no network | Detail body without images | +| **6b Editor** | Toolbar, `change` events, `getMarkdown()` / doc JSON, focus/flush | **Text edit-ready gate** (with outbox PR) | +| **6c Resources** | `loadResource` bridge, placeholders, image upload hooks, resource press | Images + attachments | +| **6d Mermaid** | Mermaid render in viewer/editor | Full store body AC | + +Load: + +```swift +webView.loadFileURL(indexURL, allowingReadAccessTo: bundleDirectory) +``` + +#### Bridge protocol (typed JSON) + +**Native → Web** (`evaluateJavaScript` calling `window.EdgeEverEditor.*`): + +| Method | Purpose | +| --- | --- | +| `configure({ mode, locale, theme, placeholder })` | viewer vs editor | +| `setDocument(docJson)` | replace content | +| `getDocument()` | return current doc (promise via callback id) | +| `focusEnd()` | caret | +| `flush()` | force change emit | +| `exec(actionId)` | toolbar: bold, lists, etc. | +| `beginImageUpload(uploadId, previewDataUrl)` | placeholder image | +| `completeImageUpload(uploadId, imageUrl, alt)` | swap placeholder | +| `cancelImageUpload(uploadId)` | remove placeholder | +| `appendAttachment(url, filename)` | attachment link node | +| `removeResource(targetJson)` / `renameResource(...)` | resource ops | +| `search(query, index)` / `replaceAll(query, replacement)` | find | + +**Web → Native** (`webkit.messageHandlers.edgeever.postMessage`): + +| Event | Payload | +| --- | --- | +| `ready` | `{ startupMs }` | +| `change` | `{ contentJson, contentMarkdown }` (editor only; markdown from EditorBundle converter—K20) | +| `loadResource` | `{ requestId, source }` → native replies with data URL | +| `resourcePress` | `{ targetJson }` → native sheet: open / rename / delete (Required for store) | +| `imagePreview` | `{ alt, source }` (viewer) | +| `activeFlags` | bitfield matching `MOBILE_EDITOR_ACTIVE_FLAGS` | +| `searchResult` | `{ count, index }` | +| `log` / `error` | diagnostics | + +Resource reply path: native completes `loadResource` by evaluating `EdgeEverEditor.resolveResource(requestId, dataUrlOrNull)`. + +Native must **not** re-derive markdown from JSON; trust `contentMarkdown` from the bundle on `change` (or call `getMarkdown()` on flush). + +#### Protected resources + +1. Detect `/api/v1/resources/:id` and `/blob` variants (port `getResourceIdFromUrl` / `normalizeMobileResourceHref`). +2. `APIClient.getResourceData(path)` with `Authorization: Bearer `. +3. Convert to data URL (images) or temp file URL (attachments open-in-place / share sheet). +4. Cache on disk under Application Support `resource-cache/` with size cap (e.g. 200 MB LRU) — port spirit of `cacheMobileResource` in `mobile-attachments.ts`. + +#### Mermaid + +Keep rendering **inside** the WebView (same as RN editor) to avoid dual implementations. Optional: separate lightweight mermaid renderer HTML for list previews is **not** required for v1 if list uses excerpt text only (RN list uses excerpt). + +#### Image upload + +1. `PHPicker` / `PhotosUI` → optional compression (max edge 2560, quality ~0.82 WebP) matching `mobile-image-upload.ts`. +2. Resolve target memo id via **Materialize algorithm (K24)**—never bare `createMemo` without outbox coordination. +3. `multipart/form-data` `POST /api/v1/memos/:id/resources` using the **single** server memo id returned by materialize. +4. Placeholder → complete with returned `resource.url`. +5. Offline or materialize failure: cancel placeholder; no second create attempts without user retry. + +### Session & security + +| Item | Storage | Notes | +| --- | --- | --- | +| Session JSON `{ baseUrl, token, user }` | Keychain (kSecClassGenericPassword) | Key: `org.edgeever.mobile.session` | +| `deviceId` | Keychain | `mobile-