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:
1 parent
b4e8be688b
commit
cd4f010d8f
83 files changed
+20540
-1
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
#include "Version.xcconfig"
|
||||
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
|
||||
DEBUG_INFORMATION_FORMAT = dwarf
|
||||
SWIFT_OPTIMIZATION_LEVEL = -Onone
|
||||
@@ -0,0 +1,5 @@
|
||||
#include "Version.xcconfig"
|
||||
|
||||
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
|
||||
SWIFT_OPTIMIZATION_LEVEL = -O
|
||||
SWIFT_COMPILATION_MODE = wholemodule
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<')
|
||||
.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)\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
@@ -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
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
Executable
+31
@@ -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)"
|
||||
Executable
+45
@@ -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."
|
||||
@@ -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\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(" "))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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/
|
||||
|
||||
Reference in new issue
Block a user