feat(ios): add native SwiftUI client with Android UI parity

Replace the abandoned RN iOS path with a SwiftUI app (iOS 17+) that reuses
the shared TipTap editor bundle, GRDB local mirror/outbox, and Android-aligned
shell chrome for list, create, detail, and Me. Package the editor HTML in the
app resources so Markdown renders, present edit from the workspace root with a
UIKit FAB, and keep caret stable while typing.
This commit is contained in:
tianma-if committed 2026-08-07 02:52:01 +08:00
1 parent b4e8be688b
commit cd4f010d8f
83 files changed
+20540 -1

No files matched your search

+82
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#include "Version.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
DEBUG_INFORMATION_FORMAT = dwarf
SWIFT_OPTIMIZATION_LEVEL = -Onone
+5
View File
@@ -0,0 +1,5 @@
#include "Version.xcconfig"
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
+4
View File
@@ -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
+957
View File
@@ -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 = "<group>"; };
0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = "<group>"; };
12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocol.swift; sourceTree = "<group>"; };
12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipTapWebView.swift; sourceTree = "<group>"; };
1606EF8ADEA4921421EB0690 /* MobileUIParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUIParityTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
213837D2B2F025307A5EBB69 /* ChromeParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeParityTests.swift; sourceTree = "<group>"; };
22941D02A07C4B75AE89B053 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
243811C4B4CBF13982976A56 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
2829CE5DF5C15ACF41CC17DA /* EditFabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFabButton.swift; sourceTree = "<group>"; };
2A8662720DE42695E29F5741 /* URL+Normalize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Normalize.swift"; sourceTree = "<group>"; };
34A3C0399C910B150A02D06E /* ResourceCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceCache.swift; sourceTree = "<group>"; };
3674473015F8EB6863B69B0F /* MemoEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoEditView.swift; sourceTree = "<group>"; };
387980E9CCA7C1C52EAAB6A0 /* MemoDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoDetailView.swift; sourceTree = "<group>"; };
38B2C459C9903FC6EBA4E478 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
40C1F4BC7DB150AE8F85AA13 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
410470CB94CB0505A3EF5801 /* ChromeParity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeParity.swift; sourceTree = "<group>"; };
41B15501968A29C372E7B3BB /* URLNormalizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLNormalizeTests.swift; sourceTree = "<group>"; };
4341E86DEF60272AE37A8704 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
4BEF7AFA15A0972ED3475F23 /* EditorBundleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorBundleTests.swift; sourceTree = "<group>"; };
57312B99FFBD9A2186A8C0D0 /* LiveLocalAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocalAPITests.swift; sourceTree = "<group>"; };
574912976CB43DD9821678EE /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = "<group>"; };
58C065FED3C78404D4D8DD19 /* Motion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Motion.swift; sourceTree = "<group>"; };
5CB54673617E14F28B7608BD /* Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typography.swift; sourceTree = "<group>"; };
62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncOutboxRepository.swift; sourceTree = "<group>"; };
6CB16CA1499554463E1927E7 /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = "<group>"; };
776B0012B526A65B99921B4B /* ConflictResolutionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictResolutionView.swift; sourceTree = "<group>"; };
82E057D0906932A00DF62EBB /* MemoCreateCommitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoCreateCommitTests.swift; sourceTree = "<group>"; };
8691859094F8C08AE49FE27D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
86F590FE9371571DC7456154 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxFlusher.swift; sourceTree = "<group>"; };
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 = "<group>"; };
9B02E8D28111E1E732F3BF36 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
9D65FED94BCD836BDD5CB752 /* DeviceId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceId.swift; sourceTree = "<group>"; };
A375D81573943A98C5732F81 /* MobileUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileUI.swift; sourceTree = "<group>"; };
AB02424D0412FE36C4BCD116 /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = index.html; sourceTree = "<group>"; };
AD904708FC1AD8971439BD90 /* JSONCoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoding.swift; sourceTree = "<group>"; };
B1D94BB61519CA2A345D99C1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B30C95F4EABCD08555D97C6B /* NotesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesListView.swift; sourceTree = "<group>"; };
B812AD3502D649CE7B84DEBE /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
B9CEA2902CDE0D7C4137D173 /* EdgeEverApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeEverApp.swift; sourceTree = "<group>"; };
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 = "<group>"; };
C693439A39274372B39F10DE /* EdgeEver.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EdgeEver.entitlements; sourceTree = "<group>"; };
C6B15550738F6080C23DE15F /* ImageCompressor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCompressor.swift; sourceTree = "<group>"; };
D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareHandoffStore.swift; sourceTree = "<group>"; };
D06F83AE844C342D3F6EAD7F /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
D2C2945A52C2957CCFC09FCF /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = "<group>"; };
D6F510BF90B418266F363EBD /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
D87A8CED8498AE654EF8CD1F /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
DA3D6EDCC2D76EEC0E51DD26 /* DraftRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftRepository.swift; sourceTree = "<group>"; };
DBC0AD00435CCEFD7934201C /* MemoCreateCommit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoCreateCommit.swift; sourceTree = "<group>"; };
E3328D871910187ED827B6CE /* NotebookPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotebookPickerSheet.swift; sourceTree = "<group>"; };
E6159DBF27CC86920005E25D /* SyncProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocolTests.swift; sourceTree = "<group>"; };
EFF10CDC8FB8C97DA0103AA0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
F080AD98FDF3AADDD90B9931 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
F50D91D8505FE6446ABD647C /* WorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceView.swift; sourceTree = "<group>"; };
F9182F742B06FB1713E27C2A /* ResourceActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceActions.swift; sourceTree = "<group>"; };
FDEF3A661FA08F338EC96C74 /* LocalMirrorRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMirrorRepository.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
1161CF1FFAC898B00498D97D /* Database */ = {
isa = PBXGroup;
children = (
0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */,
DA3D6EDCC2D76EEC0E51DD26 /* DraftRepository.swift */,
FDEF3A661FA08F338EC96C74 /* LocalMirrorRepository.swift */,
62B460B6BFE4CFB23073C7E1 /* SyncOutboxRepository.swift */,
);
path = Database;
sourceTree = "<group>";
};
15C520F3EAEBE355CFFF9C41 /* ShareExtension */ = {
isa = PBXGroup;
children = (
22941D02A07C4B75AE89B053 /* ShareExtension.entitlements */,
F080AD98FDF3AADDD90B9931 /* ShareViewController.swift */,
);
path = ShareExtension;
sourceTree = "<group>";
};
1C9B7294934FF01F82DDBEB1 /* Share */ = {
isa = PBXGroup;
children = (
D045B340A984E17B3B0047BF /* ShareHandoffStore.swift */,
);
path = Share;
sourceTree = "<group>";
};
2377E3F57CD40951D1F3AA40 /* EdgeEver */ = {
isa = PBXGroup;
children = (
B0AF73B8D61DFCCD37B124B1 /* App */,
8BCFBC0CDC794B02EAD907EC /* Data */,
8A404C1D0F3056A4C32DDE63 /* DesignSystem */,
BEFB911E3106AB68356141B7 /* Editor */,
49ADF38F8269841510F1C638 /* Features */,
7404AFC10807CAA326C34580 /* Resources */,
A6CFB6D6E7BFE63D698DFE51 /* Supporting */,
);
path = EdgeEver;
sourceTree = "<group>";
};
297E35F57A6FB4C490FEE440 /* Session */ = {
isa = PBXGroup;
children = (
9D65FED94BCD836BDD5CB752 /* DeviceId.swift */,
D2C2945A52C2957CCFC09FCF /* KeychainStore.swift */,
574912976CB43DD9821678EE /* SessionStore.swift */,
);
path = Session;
sourceTree = "<group>";
};
2E86C2538BFD6FD31BF84916 /* Cache */ = {
isa = PBXGroup;
children = (
C6B15550738F6080C23DE15F /* ImageCompressor.swift */,
34A3C0399C910B150A02D06E /* ResourceCache.swift */,
);
path = Cache;
sourceTree = "<group>";
};
2EE88A37487842A849874933 /* Products */ = {
isa = PBXGroup;
children = (
1CD75D20D94A27E982103AB3 /* EdgeEver.app */,
94DF0091B1FC2783AEC2D9F6 /* EdgeEverTests.xctest */,
BF956A8F1A951AA5547675AD /* ShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
47F79BD2D97384A3B7EB9A7D /* Tests */ = {
isa = PBXGroup;
children = (
F82E0DDA9B1E6FCB6CB7E083 /* EdgeEverTests */,
);
path = Tests;
sourceTree = "<group>";
};
49ADF38F8269841510F1C638 /* Features */ = {
isa = PBXGroup;
children = (
D1C1EF697B501DEE7E568338 /* Auth */,
1C9B7294934FF01F82DDBEB1 /* Share */,
AE31B21B9B3513CC73436E46 /* Workspace */,
);
path = Features;
sourceTree = "<group>";
};
7404AFC10807CAA326C34580 /* Resources */ = {
isa = PBXGroup;
children = (
821234A55C8B7CF544B28EE6 /* EditorBundle */,
8691859094F8C08AE49FE27D /* Assets.xcassets */,
C693439A39274372B39F10DE /* EdgeEver.entitlements */,
D87A8CED8498AE654EF8CD1F /* Localizable.xcstrings */,
EFF10CDC8FB8C97DA0103AA0 /* PrivacyInfo.xcprivacy */,
);
path = Resources;
sourceTree = "<group>";
};
802936642DF4142908AF3184 /* Sync */ = {
isa = PBXGroup;
children = (
8720E0D6F00D1509B76A374B /* OutboxFlusher.swift */,
40C1F4BC7DB150AE8F85AA13 /* SyncEngine.swift */,
12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */,
);
path = Sync;
sourceTree = "<group>";
};
821234A55C8B7CF544B28EE6 /* EditorBundle */ = {
isa = PBXGroup;
children = (
AB02424D0412FE36C4BCD116 /* index.html */,
);
path = EditorBundle;
sourceTree = "<group>";
};
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 = "<group>";
};
8BCFBC0CDC794B02EAD907EC /* Data */ = {
isa = PBXGroup;
children = (
2E86C2538BFD6FD31BF84916 /* Cache */,
1161CF1FFAC898B00498D97D /* Database */,
B905FCD37DFCE6B112511261 /* Models */,
BB746E5BDC1029DD2ECF3850 /* Network */,
9F7C048A5E535F8CAB41F56E /* Preferences */,
297E35F57A6FB4C490FEE440 /* Session */,
802936642DF4142908AF3184 /* Sync */,
);
path = Data;
sourceTree = "<group>";
};
9F7C048A5E535F8CAB41F56E /* Preferences */ = {
isa = PBXGroup;
children = (
6CB16CA1499554463E1927E7 /* PreferencesStore.swift */,
);
path = Preferences;
sourceTree = "<group>";
};
A22DC90C50256855231B9F51 = {
isa = PBXGroup;
children = (
0C073569B955CD568840A35D /* Config */,
2377E3F57CD40951D1F3AA40 /* EdgeEver */,
15C520F3EAEBE355CFFF9C41 /* ShareExtension */,
47F79BD2D97384A3B7EB9A7D /* Tests */,
2EE88A37487842A849874933 /* Products */,
);
sourceTree = "<group>";
};
A6CFB6D6E7BFE63D698DFE51 /* Supporting */ = {
isa = PBXGroup;
children = (
AD904708FC1AD8971439BD90 /* JSONCoding.swift */,
2A8662720DE42695E29F5741 /* URL+Normalize.swift */,
);
path = Supporting;
sourceTree = "<group>";
};
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 = "<group>";
};
B0AF73B8D61DFCCD37B124B1 /* App */ = {
isa = PBXGroup;
children = (
D06F83AE844C342D3F6EAD7F /* AppEnvironment.swift */,
B9CEA2902CDE0D7C4137D173 /* EdgeEverApp.swift */,
38B2C459C9903FC6EBA4E478 /* RootView.swift */,
);
path = App;
sourceTree = "<group>";
};
B905FCD37DFCE6B112511261 /* Models */ = {
isa = PBXGroup;
children = (
C5126510AFB9AFBF7ABADB45 /* Models.swift */,
);
path = Models;
sourceTree = "<group>";
};
BB746E5BDC1029DD2ECF3850 /* Network */ = {
isa = PBXGroup;
children = (
D6F510BF90B418266F363EBD /* APIClient.swift */,
);
path = Network;
sourceTree = "<group>";
};
BEFB911E3106AB68356141B7 /* Editor */ = {
isa = PBXGroup;
children = (
F9182F742B06FB1713E27C2A /* ResourceActions.swift */,
12DCA98C14CC6F28C22C41A1 /* TipTapWebView.swift */,
);
path = Editor;
sourceTree = "<group>";
};
D1C1EF697B501DEE7E568338 /* Auth */ = {
isa = PBXGroup;
children = (
B812AD3502D649CE7B84DEBE /* LoginView.swift */,
);
path = Auth;
sourceTree = "<group>";
};
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 = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -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
}
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E55B18ED6114D23320B85FA0"
BuildableName = "EdgeEver.app"
BlueprintName = "EdgeEver"
ReferencedContainer = "container:EdgeEver.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E55B18ED6114D23320B85FA0"
BuildableName = "EdgeEver.app"
BlueprintName = "EdgeEver"
ReferencedContainer = "container:EdgeEver.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "06BD8BFE81996E1AAAACD634"
BuildableName = "EdgeEverTests.xctest"
BlueprintName = "EdgeEverTests"
ReferencedContainer = "container:EdgeEver.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E55B18ED6114D23320B85FA0"
BuildableName = "EdgeEver.app"
BlueprintName = "EdgeEver"
ReferencedContainer = "container:EdgeEver.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E55B18ED6114D23320B85FA0"
BuildableName = "EdgeEver.app"
BlueprintName = "EdgeEver"
ReferencedContainer = "container:EdgeEver.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -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
}
}
}
+52
View File
@@ -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")
}
+35
View File
@@ -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)
}
}
}
}
}
@@ -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")
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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]
)
}
}
}
@@ -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())
}
}
@@ -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)
}
}
+443
View File
@@ -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
)
}
}
@@ -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<T: Decodable>(
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) }
}
@@ -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"
}
}
@@ -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
}
}
@@ -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)
}
@@ -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) }
}
}
@@ -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
)
}
}
}
@@ -0,0 +1,88 @@
import Foundation
actor SyncEngine {
private let mirror: LocalMirrorRepository
private let client: APIClient
private var inFlight: [String: Task<Int, Error>] = [:]
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
}
}
}
}
@@ -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<T>(_ 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
}
@@ -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)
}
}
}
@@ -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)
}
@@ -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() }
}
}
@@ -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<String>, memoId: String) -> Set<String> {
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<String>()
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<String> {
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<String>()
func includeDescendants(_ id: String, visited: inout Set<String>) {
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<String>()
includeDescendants(notebook.id, visited: &visited)
var parentId = notebook.parentId
var seen: Set<String> = [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<String> {
var parents = Set<String>()
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<String> {
let byId = Dictionary(uniqueKeysWithValues: notebooks.map { ($0.id, $0) })
var ancestors = Set<String>()
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<String>) -> [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)
}
}
+112
View File
@@ -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<V: Equatable>(_ 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
)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
}
}
@@ -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 = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
html,body{margin:0;padding:12px;font: -apple-system-body; font-family: -apple-system, sans-serif; background: transparent; color: #111;}
#editor{min-height:50vh;outline:none;line-height:1.55;white-space:pre-wrap;}
#editor:empty:before{content:attr(data-placeholder);color:#94a3b8;}
img{max-width:100%;}
</style>
</head>
<body>
<div id="editor" contenteditable="false" data-placeholder="开始书写…"></div>
<script>
(function(){
const editor = document.getElementById('editor');
let mode = 'viewer';
let suppress = false;
function post(msg){
try { window.webkit.messageHandlers.edgeever.postMessage(msg); } catch(e) {}
}
function mdToHtml(md){
if(!md) return '<p><br></p>';
return md
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/!\\[([^\\]]*)\\]\\(([^)]+)\\)/g, '<img alt="$1" src="$2">')
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\\/li>\\n?)+/g, m => '<ul>'+m+'</ul>')
.replace(/\\n\\n/g, '</p><p>')
.replace(/\\n/g, '<br>');
}
function htmlToMd(html){
const tmp = document.createElement('div');
tmp.innerHTML = html;
// Prefer plain text with newlines for reliability
let text = tmp.innerText || tmp.textContent || '';
return text;
}
window.EdgeEverEditor = {
configure(opts){
mode = opts.mode || 'viewer';
editor.contentEditable = mode === 'editor' ? 'true' : 'false';
},
setMarkdown(md){
suppress = true;
editor.innerHTML = mdToHtml(md || '');
suppress = false;
},
setDocumentFromJSON(json){
// Fallback ignores rich JSON structure beyond empty check
try {
const doc = JSON.parse(json);
// leave as-is if already set via markdown
} catch(e) {}
},
resolveResource(){},
getMarkdown(){ return htmlToMd(editor.innerHTML); }
};
editor.addEventListener('input', () => {
if (suppress || mode !== 'editor') return;
const md = htmlToMd(editor.innerHTML);
post({ type: 'change', contentMarkdown: md, contentJson: JSON.stringify({type:'doc',content:[{type:'paragraph',content:[{type:'text',text:md}]}]}) });
});
post({ type: 'ready', startupMs: 0 });
})();
</script>
</body>
</html>
"""
}
}
@@ -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<String>,
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
}
}
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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<Void, Never>?
@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)
}
}
@@ -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<String> = []
private var parentIds: Set<String> {
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)
}
}
@@ -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).
File diff suppressed because it is too large. Load diff
@@ -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<String> = []
var showNotebookPicker = false
var showActions = false
private var searchTask: Task<Void, Never>?
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
}
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.edgeever.mobile</string>
</array>
</dict>
</plist>
File diff suppressed because it is too large. Load diff
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>EdgeEver</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>EdgeEver</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>org.edgeever.mobile</string>
<key>CFBundleURLSchemes</key>
<array>
<string>edgeever</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>EdgeEver only uses the camera if content inside a note explicitly requests it.</string>
<key>NSMicrophoneUsageDescription</key>
<string>EdgeEver only uses the microphone if content inside a note explicitly requests it.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>EdgeEver needs photo library access to insert images into notes.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>
@@ -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"
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
@@ -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
}()
}
@@ -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."
}
}
}
File diff suppressed because it is too large. Load diff
+486
View File
@@ -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=="],
}
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<title>EdgeEver Editor</title>
</head>
<body>
<div id="toolbar"></div>
<div id="editor"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -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"
}
}
+402
View File
@@ -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<string, (dataUrl: string | null) => 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<string | null> {
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<HTMLImageElement>("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<string, () => 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) });
+122
View File
@@ -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;
}
+13
View File
@@ -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"]
}
+15
View File
@@ -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",
},
},
});
+89
View File
@@ -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
```
+31
View File
@@ -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)"
+45
View File
@@ -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."
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>EdgeEver</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.org.edgeever.mobile</string>
</array>
</dict>
</plist>
@@ -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)
}
}
@@ -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)
}
}
@@ -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"
)
}
}
@@ -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)
}
}
@@ -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"])
}
}
@@ -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
)
)
}
}
@@ -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()
)
}
}
@@ -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"
)
}
}
@@ -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(" "))
}
}
+131
View File
@@ -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)"
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -6,7 +6,8 @@ edgeever/
│ ├── web/ Vite + React product UI, PWA, offline drafts, sync queue
│ ├── extension/ Chrome/Edge/Firefox Manifest V3 web clipper
│ ├── api/ Cloudflare Worker + Hono API, OpenAPI, MCP endpoint
│ ├── mobile/ Expo + React Native mobile app
│ ├── mobile/ Expo + React Native mobile app (Android production path)
│ ├── ios/ Native SwiftUI iOS app (TipTap EditorBundle; see docs/ios-swift-rewrite.md)
│ ├── desktop/ Electron shell, preload bridge, and packaging
│ └── site/ Astro official website
├── packages/