mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-31 21:59:10 +08:00
feat(ios): add share to support (#15340)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added iOS sharing for URLs, text, web pages, and images. * Shared content can be reviewed, titled, and saved to an AFFiNE workspace. * Choose destinations including workspaces, tags, and collections. * Added previews, attachment handling, import status, retry support, and success/error feedback. * Pending shares are processed when AFFiNE opens or returns to the foreground. * **Bug Fixes** * Improved workspace profile handling across different workspace types. * Onboarding completion is now recorded immediately after successful sign-in verification. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
@@ -46,13 +46,26 @@
|
||||
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */; };
|
||||
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC59422D323EE00027CEBD /* Mutex.swift */; };
|
||||
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */; };
|
||||
A10100000000000000000001 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A10100000000000000000003 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; };
|
||||
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; };
|
||||
AB0000010000000000000000 /* ShareInboxSafety.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000030000000000000000 /* ShareInboxSafety.swift */; };
|
||||
AB0000020000000000000000 /* ShareInboxSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000040000000000000000 /* ShareInboxSafetyTests.swift */; };
|
||||
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
|
||||
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; };
|
||||
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
A10100000000000000000006 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A1010000000000000000000D;
|
||||
remoteInfo = ShareExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
50E218312E0BE1A700EA4C6E /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
@@ -64,6 +77,17 @@
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A1010000000000000000000B /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
A10100000000000000000001 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -109,8 +133,12 @@
|
||||
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigManager.swift; sourceTree = "<group>"; };
|
||||
9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = "<group>"; };
|
||||
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = "<group>"; };
|
||||
A10100000000000000000003 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A10100000000000000000005 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
|
||||
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
|
||||
AB0000030000000000000000 /* ShareInboxSafety.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxSafety.swift; sourceTree = "<group>"; };
|
||||
AB0000040000000000000000 /* ShareInboxSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInboxSafetyTests.swift; sourceTree = "<group>"; };
|
||||
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AFFiNE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -122,6 +150,17 @@
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
A10100000000000000000007 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
ShareExtension.entitlements,
|
||||
);
|
||||
target = A1010000000000000000000D /* ShareExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
@@ -130,6 +169,21 @@
|
||||
path = Plugins;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10100000000000000000008 /* ShareExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A10100000000000000000007 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */,
|
||||
);
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A10100000000000000000009 /* Shared/ShareInbox */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Shared/ShareInbox;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -147,6 +201,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A1010000000000000000000A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000070000000000000000 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -173,6 +234,8 @@
|
||||
children = (
|
||||
C4C97C722D0307B700BC2AD1 /* uniffi */,
|
||||
9D90BE242CCB9876006677DB /* App */,
|
||||
A10100000000000000000008 /* ShareExtension */,
|
||||
A10100000000000000000009 /* Shared/ShareInbox */,
|
||||
AA00000D0000000000000000 /* AppTests */,
|
||||
50802D5F2D112F7D00694021 /* Packages */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
@@ -187,6 +250,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* AFFiNE.app */,
|
||||
A10100000000000000000003 /* ShareExtension.appex */,
|
||||
AA0000030000000000000000 /* AFFiNETests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -247,6 +311,7 @@
|
||||
9D90BE242CCB9876006677DB /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A10100000000000000000005 /* App.entitlements */,
|
||||
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */,
|
||||
9DEC59422D323EE00027CEBD /* Mutex.swift */,
|
||||
9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */,
|
||||
@@ -277,6 +342,8 @@
|
||||
children = (
|
||||
AA0000010000000000000000 /* AuthDateParser.swift */,
|
||||
AA0000020000000000000000 /* AuthDateParserTests.swift */,
|
||||
AB0000030000000000000000 /* ShareInboxSafety.swift */,
|
||||
AB0000040000000000000000 /* ShareInboxSafetyTests.swift */,
|
||||
);
|
||||
path = AppTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -306,24 +373,48 @@
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
|
||||
50E218312E0BE1A700EA4C6E /* Embed Frameworks */,
|
||||
A1010000000000000000000B /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A1010000000000000000000C /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
|
||||
A10100000000000000000009 /* Shared/ShareInbox */,
|
||||
);
|
||||
name = AFFiNE;
|
||||
packageProductDependencies = (
|
||||
5027D4792E7C5FC100ADD25A /* AffineResources */,
|
||||
5027D4772E7C5FBD00ADD25A /* AffinePaywall */,
|
||||
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */,
|
||||
500D72F62E825064006E553F /* Intelligents */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
A1010000000000000000000D /* ShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A1010000000000000000000E /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||
buildPhases = (
|
||||
A1010000000000000000000F /* Sources */,
|
||||
A1010000000000000000000A /* Frameworks */,
|
||||
A10100000000000000000010 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
|
||||
A10100000000000000000008 /* ShareExtension */,
|
||||
A10100000000000000000009 /* Shared/ShareInbox */,
|
||||
);
|
||||
name = AFFiNE;
|
||||
packageProductDependencies = (
|
||||
500D72F62E825064006E553F /* Intelligents */,
|
||||
5027D4772E7C5FBD00ADD25A /* AffinePaywall */,
|
||||
5027D4792E7C5FC100ADD25A /* AffineResources */,
|
||||
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
name = ShareExtension;
|
||||
productName = ShareExtension;
|
||||
productReference = A10100000000000000000003 /* ShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
AA0000090000000000000000 /* AFFiNETests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
@@ -355,6 +446,19 @@
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1600;
|
||||
SystemCapabilities = {
|
||||
com.apple.ApplicationGroups.iOS = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
A1010000000000000000000D = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
SystemCapabilities = {
|
||||
com.apple.ApplicationGroups.iOS = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
AA0000090000000000000000 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
@@ -381,6 +485,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* AFFiNE */,
|
||||
A1010000000000000000000D /* ShareExtension */,
|
||||
AA0000090000000000000000 /* AFFiNETests */,
|
||||
);
|
||||
};
|
||||
@@ -401,6 +506,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A10100000000000000000010 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000080000000000000000 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -500,17 +612,34 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A1010000000000000000000F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000060000000000000000 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000040000000000000000 /* AuthDateParser.swift in Sources */,
|
||||
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
|
||||
AB0000010000000000000000 /* ShareInboxSafety.swift in Sources */,
|
||||
AB0000020000000000000000 /* ShareInboxSafetyTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
A1010000000000000000000C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A1010000000000000000000D /* ShareExtension */;
|
||||
targetProxy = A10100000000000000000006 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
9D90BE222CCB9876006677DB /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
@@ -656,6 +785,7 @@
|
||||
baseConfigurationReference = 3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
@@ -692,6 +822,7 @@
|
||||
baseConfigurationReference = E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
@@ -723,6 +854,62 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A10100000000000000000011 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2026.7.14;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A10100000000000000000012 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2026.7.14;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA00000A0000000000000000 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -776,6 +963,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A1010000000000000000000E /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A10100000000000000000011 /* Debug */,
|
||||
A10100000000000000000012 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
+30
-3
@@ -15,7 +15,7 @@
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A11F1E000000000000000005"
|
||||
BlueprintIdentifier = "AA0000090000000000000000"
|
||||
BuildableName = "AFFiNETests.xctest"
|
||||
BlueprintName = "AFFiNETests"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
@@ -30,10 +30,11 @@
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference skipped = "NO">
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A11F1E000000000000000005"
|
||||
BlueprintIdentifier = "AA0000090000000000000000"
|
||||
BuildableName = "AFFiNETests.xctest"
|
||||
BlueprintName = "AFFiNETests"
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
@@ -41,4 +42,30 @@
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</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"
|
||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
||||
@@ -71,6 +71,7 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
|
||||
NbStorePlugin(),
|
||||
PayWallPlugin(associatedController: self),
|
||||
PreviewPlugin(),
|
||||
ShareInboxPlugin(),
|
||||
]
|
||||
plugins.forEach { bridge?.registerPluginInstance($0) }
|
||||
}
|
||||
@@ -90,6 +91,14 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
|
||||
}
|
||||
intelligentsButtonTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
processShareInboxIfNeeded()
|
||||
}
|
||||
|
||||
func processShareInboxIfNeeded() {
|
||||
guard let webView, !ShareInboxStore.shared.pendingItems().isEmpty else { return }
|
||||
webView.evaluateJavaScript(
|
||||
"window.dispatchEvent(new Event('affine:share-inbox'))"
|
||||
)
|
||||
}
|
||||
|
||||
private func checkEligibilityOfIntelligent() {
|
||||
|
||||
@@ -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.app.affine.pro</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -26,6 +26,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
func applicationDidBecomeActive(_: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
if let root = window?.rootViewController {
|
||||
findAffineViewController(from: root)?.processShareInboxIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func findAffineViewController(from root: UIViewController) -> AFFiNEViewController? {
|
||||
if let affine = root as? AFFiNEViewController {
|
||||
return affine
|
||||
}
|
||||
if let navigation = root as? UINavigationController {
|
||||
for controller in navigation.viewControllers {
|
||||
if let found = findAffineViewController(from: controller) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in root.children {
|
||||
if let found = findAffineViewController(from: child) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
if let presented = root.presentedViewController {
|
||||
return findAffineViewController(from: presented)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_: UIApplication) {
|
||||
@@ -35,7 +60,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
let handled = ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
let isShareInboxURL = url.scheme == "affine" && url.host == "share-inbox"
|
||||
if isShareInboxURL {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
guard let self, let root = self.window?.rootViewController else { return }
|
||||
self.findAffineViewController(from: root)?.processShareInboxIfNeeded()
|
||||
}
|
||||
}
|
||||
return handled || isShareInboxURL
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import Capacitor
|
||||
import Foundation
|
||||
|
||||
@objc(ShareInboxPlugin)
|
||||
public final class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "ShareInboxPlugin"
|
||||
public let jsName = "ShareInbox"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "listPending", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateTarget", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "resolveAttachment", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "complete", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setError", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
private let store = ShareInboxStore.shared
|
||||
private let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return encoder
|
||||
}()
|
||||
|
||||
@objc func listPending(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let items = try store.pendingItems().compactMap { item -> [String: Any]? in
|
||||
if item.result != nil {
|
||||
try? store.remove(item)
|
||||
return nil
|
||||
}
|
||||
let data = try encoder.encode(item)
|
||||
return try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
}
|
||||
call.resolve(["items": items])
|
||||
} catch {
|
||||
call.reject("Failed to read shared content.", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateTarget(_ call: CAPPluginCall) {
|
||||
do {
|
||||
var item = try item(from: call)
|
||||
guard let target = call.getObject("target"),
|
||||
let workspaceId = target["workspaceId"] as? String,
|
||||
let workspaceFlavour = target["workspaceFlavour"] as? String,
|
||||
let tagIds = target["tagIds"] as? [String]
|
||||
else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
item.target = ShareInboxTarget(
|
||||
workspaceId: workspaceId,
|
||||
workspaceFlavour: workspaceFlavour,
|
||||
tagIds: tagIds,
|
||||
collectionId: target["collectionId"] as? String
|
||||
)
|
||||
item.lastError = nil
|
||||
try store.update(item)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Failed to update the share destination.", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func resolveAttachment(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let item = try item(from: call)
|
||||
guard let attachment = item.attachments.first,
|
||||
let url = store.attachmentURL(for: attachment),
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
else {
|
||||
call.resolve([:])
|
||||
return
|
||||
}
|
||||
call.resolve([
|
||||
"path": url.absoluteString,
|
||||
"mimeType": attachment.mimeType,
|
||||
])
|
||||
} catch {
|
||||
call.reject("Failed to resolve the shared attachment.", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func complete(_ call: CAPPluginCall) {
|
||||
do {
|
||||
var item = try item(from: call)
|
||||
guard let docId = call.getString("docId"), !docId.isEmpty else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
item.result = ShareInboxResult(docId: docId, committedAt: Date())
|
||||
item.lastError = nil
|
||||
try store.update(item)
|
||||
try store.remove(item)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Failed to finish importing shared content.", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setError(_ call: CAPPluginCall) {
|
||||
do {
|
||||
var item = try item(from: call)
|
||||
guard let error = call.getString("error"), !error.isEmpty else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
item.lastError = error
|
||||
try store.update(item)
|
||||
call.resolve()
|
||||
} catch {
|
||||
call.reject("Failed to update shared content.", nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
private func item(from call: CAPPluginCall) throws -> ShareInboxItem {
|
||||
guard let itemId = call.getString("itemId"),
|
||||
let item = store.pendingItems().first(where: { $0.id == itemId })
|
||||
else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ class RootViewController: UINavigationController {
|
||||
showOnboardingAlert(message: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
OnboardingFlag.markCompleted()
|
||||
guard isSignedIn else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import XCTest
|
||||
|
||||
final class ShareInboxSafetyTests: XCTestCase {
|
||||
func testManifestIDsMustBeUUIDs() {
|
||||
let id = UUID().uuidString
|
||||
XCTAssertEqual(ShareInboxSafety.normalizedManifestID(id.lowercased()), id)
|
||||
XCTAssertNil(ShareInboxSafety.normalizedManifestID("../item"))
|
||||
XCTAssertNil(ShareInboxSafety.normalizedManifestID("nested/item"))
|
||||
}
|
||||
|
||||
func testWebURLsRejectCredentialsAndUnsupportedSchemes() {
|
||||
XCTAssertEqual(
|
||||
ShareInboxSafety.normalizedWebURL("https://example.com/page"),
|
||||
"https://example.com/page"
|
||||
)
|
||||
XCTAssertNil(ShareInboxSafety.normalizedWebURL("file:///private/item"))
|
||||
XCTAssertNil(ShareInboxSafety.normalizedWebURL("https://user@example.com/page"))
|
||||
}
|
||||
|
||||
func testSupportedImagesAreDetectedByContent() {
|
||||
XCTAssertEqual(
|
||||
ShareInboxSafety.detectRasterImageMimeType(Data([0xFF, 0xD8, 0xFF, 0x00])),
|
||||
"image/jpeg"
|
||||
)
|
||||
XCTAssertNil(ShareInboxSafety.detectRasterImageMimeType(Data("<svg/>".utf8)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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>AFFiNE</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>NSExtensionActivationDictionaryVersion</key>
|
||||
<integer>2</integer>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>NSExtensionJavaScriptPreprocessingFile</key>
|
||||
<string>SafariPageCapture</string>
|
||||
</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,14 @@
|
||||
var SafariPageCapture = function () {};
|
||||
|
||||
SafariPageCapture.prototype = {
|
||||
run: function (context) {
|
||||
var selection = window.getSelection();
|
||||
context.completionFunction({
|
||||
title: document.title || '',
|
||||
url: document.location.href,
|
||||
selectedText: selection ? selection.toString() : '',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
var ExtensionPreprocessingJS = new SafariPageCapture();
|
||||
@@ -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.app.affine.pro</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ShareExtensionView: View {
|
||||
@ObservedObject var viewModel: ShareViewModel
|
||||
var onCancel: () -> Void
|
||||
var onSave: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Reading shared content…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
.navigationTitle("AFFiNE")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Not now", action: onCancel)
|
||||
.disabled(viewModel.isSaving)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if viewModel.isSaving {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button(viewModel.actionTitle, action: onSave)
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!viewModel.canSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text("Choose a workspace in AFFiNE. This item will stay saved until then.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: viewModel.previewImage == nil ? "doc.text" : "photo")
|
||||
.font(.title2)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.headline)
|
||||
if !viewModel.previewText.isEmpty {
|
||||
Text(viewModel.previewText)
|
||||
.lineLimit(3)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let image = viewModel.previewImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 180)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// ShareViewController.swift
|
||||
// ShareExtension
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class ShareViewController: UIViewController {
|
||||
private let viewModel = ShareViewModel()
|
||||
private var hostingController: UIHostingController<ShareExtensionView>?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
let rootView = ShareExtensionView(
|
||||
viewModel: viewModel,
|
||||
onCancel: { [weak self] in
|
||||
self?.cancel()
|
||||
},
|
||||
onSave: { [weak self] in
|
||||
self?.save()
|
||||
}
|
||||
)
|
||||
let hosting = UIHostingController(rootView: rootView)
|
||||
hostingController = hosting
|
||||
addChild(hosting)
|
||||
view.addSubview(hosting.view)
|
||||
hosting.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
hosting.didMove(toParent: self)
|
||||
|
||||
Task {
|
||||
await viewModel.load(from: extensionContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let success = await viewModel.save()
|
||||
guard success else { return }
|
||||
_ = await openMainAppIfPossible()
|
||||
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func openMainAppIfPossible() async -> Bool {
|
||||
let url = ShareInboxConstants.openInboxURL
|
||||
let openedByContext = await withCheckedContinuation { continuation in
|
||||
extensionContext?.open(url) { success in
|
||||
continuation.resume(returning: success)
|
||||
} ?? continuation.resume(returning: false)
|
||||
}
|
||||
let opened = openedByContext || openMainAppViaResponderChain(url)
|
||||
#if DEBUG
|
||||
NSLog(
|
||||
"[AFFiNE Share] open url=%@ extensionContext=%@ final=%@",
|
||||
url.absoluteString,
|
||||
openedByContext ? "YES" : "NO",
|
||||
opened ? "YES" : "NO"
|
||||
)
|
||||
#endif
|
||||
return opened
|
||||
}
|
||||
|
||||
private func openMainAppViaResponderChain(_ url: URL) -> Bool {
|
||||
var responder: UIResponder? = self
|
||||
while let current = responder {
|
||||
if let application = current as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
return true
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class ShareViewModel: ObservableObject {
|
||||
@Published var title = ""
|
||||
@Published var previewText = ""
|
||||
@Published var previewImage: UIImage?
|
||||
@Published var isLoading = true
|
||||
@Published var isSaving = false
|
||||
@Published var hasSaved = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
var actionTitle: String {
|
||||
"Open AFFiNE"
|
||||
}
|
||||
|
||||
var canSave: Bool {
|
||||
!isLoading
|
||||
&& !isSaving
|
||||
&& !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& draft?.content != nil
|
||||
}
|
||||
|
||||
private var draft: SharePayloadDraft?
|
||||
private let store: ShareInboxStore
|
||||
|
||||
init(store: ShareInboxStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func load(from extensionContext: NSExtensionContext?) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
|
||||
let built = await SharePayloadBuilder.build(from: items)
|
||||
draft = built
|
||||
title = built.title
|
||||
previewText = built.previewText
|
||||
errorMessage = built.errorMessage
|
||||
if let file = built.file {
|
||||
previewImage = UIImage(data: file.data)?
|
||||
.preparingThumbnail(of: CGSize(width: 480, height: 480))
|
||||
}
|
||||
}
|
||||
|
||||
func save() async -> Bool {
|
||||
guard !isSaving, !hasSaved else { return false }
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedTitle.isEmpty else {
|
||||
errorMessage = "Title is required."
|
||||
return false
|
||||
}
|
||||
guard let draft, let content = draft.content else {
|
||||
errorMessage = draft?.errorMessage ?? "Nothing to share."
|
||||
return false
|
||||
}
|
||||
|
||||
let itemId = UUID().uuidString
|
||||
var attachments: [ShareInboxAttachment] = []
|
||||
var attachmentData: [(ShareInboxAttachment, Data)] = []
|
||||
if let file = draft.file {
|
||||
let attachment = ShareInboxAttachment(
|
||||
fileName: file.fileName,
|
||||
mimeType: file.mimeType,
|
||||
relativePath: "\(itemId)/\(file.fileName)"
|
||||
)
|
||||
attachments = [attachment]
|
||||
attachmentData = [(attachment, file.data)]
|
||||
}
|
||||
|
||||
let item = ShareInboxItem(
|
||||
id: itemId,
|
||||
title: trimmedTitle,
|
||||
content: content,
|
||||
previewText: draft.previewText,
|
||||
attachments: attachments
|
||||
)
|
||||
|
||||
do {
|
||||
try store.enqueue(item, attachmentData: attachmentData)
|
||||
hasSaved = true
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = "Failed to save shared content."
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// ShareInboxConstants.swift
|
||||
// Shared between AFFiNE and ShareExtension
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ShareInboxConstants {
|
||||
static let appGroupId = "group.app.affine.pro"
|
||||
static let inboxDirectoryName = "ShareInbox"
|
||||
static let attachmentsDirectoryName = "Attachments"
|
||||
static let invalidDirectoryName = "Invalid"
|
||||
static let openInboxURL = URL(string: "affine://share-inbox")!
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
struct ShareInboxAttachment: Codable, Equatable {
|
||||
var fileName: String
|
||||
var mimeType: String
|
||||
var relativePath: String
|
||||
}
|
||||
|
||||
enum ShareInboxContentKind: String, Codable {
|
||||
case url
|
||||
case text
|
||||
case image
|
||||
}
|
||||
|
||||
struct ShareInboxContent: Codable, Equatable {
|
||||
var kind: ShareInboxContentKind
|
||||
var url: String?
|
||||
var text: String?
|
||||
}
|
||||
|
||||
struct ShareInboxTarget: Codable, Equatable {
|
||||
var workspaceId: String
|
||||
var workspaceFlavour: String
|
||||
var tagIds: [String]
|
||||
var collectionId: String?
|
||||
}
|
||||
|
||||
struct ShareInboxResult: Codable, Equatable {
|
||||
var docId: String
|
||||
var committedAt: Date
|
||||
}
|
||||
|
||||
struct ShareInboxItem: Codable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var documentId: String
|
||||
var createdAt: Date
|
||||
var title: String
|
||||
var content: ShareInboxContent
|
||||
var target: ShareInboxTarget?
|
||||
var previewText: String?
|
||||
var attachments: [ShareInboxAttachment]
|
||||
var result: ShareInboxResult?
|
||||
var lastError: String?
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
documentId: String = UUID().uuidString,
|
||||
createdAt: Date = Date(),
|
||||
title: String,
|
||||
content: ShareInboxContent,
|
||||
target: ShareInboxTarget? = nil,
|
||||
previewText: String? = nil,
|
||||
attachments: [ShareInboxAttachment] = [],
|
||||
result: ShareInboxResult? = nil,
|
||||
lastError: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.documentId = documentId
|
||||
self.createdAt = createdAt
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.target = target
|
||||
self.previewText = previewText
|
||||
self.attachments = attachments
|
||||
self.result = result
|
||||
self.lastError = lastError
|
||||
}
|
||||
}
|
||||
|
||||
struct SharePayloadFile: Equatable {
|
||||
var data: Data
|
||||
var mimeType: String
|
||||
var fileName: String
|
||||
}
|
||||
|
||||
struct SharePayloadDraft: Equatable {
|
||||
var title: String
|
||||
var content: ShareInboxContent?
|
||||
var previewText: String
|
||||
var file: SharePayloadFile?
|
||||
var errorMessage: String?
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
enum ShareInboxSafety {
|
||||
static func normalizedManifestID(_ value: String) -> String? {
|
||||
UUID(uuidString: value)?.uuidString
|
||||
}
|
||||
|
||||
static func normalizedWebURL(_ value: String) -> String? {
|
||||
guard
|
||||
let components = URLComponents(
|
||||
string: value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
components.host?.isEmpty == false,
|
||||
components.user == nil,
|
||||
components.password == nil,
|
||||
let url = components.url
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
static func detectRasterImageMimeType(_ data: Data) -> String? {
|
||||
let bytes = [UInt8](data.prefix(12))
|
||||
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
return "image/png"
|
||||
}
|
||||
if bytes.starts(with: Array("GIF87a".utf8)) || bytes.starts(with: Array("GIF89a".utf8)) {
|
||||
return "image/gif"
|
||||
}
|
||||
if bytes.count >= 12,
|
||||
Array(bytes[0..<4]) == Array("RIFF".utf8),
|
||||
Array(bytes[8..<12]) == Array("WEBP".utf8)
|
||||
{
|
||||
return "image/webp"
|
||||
}
|
||||
if bytes.count >= 12, Array(bytes[4..<8]) == Array("ftyp".utf8) {
|
||||
let brand = String(decoding: bytes[8..<12], as: UTF8.self).lowercased()
|
||||
if ["heic", "heix", "hevc", "hevx", "mif1", "msf1"].contains(brand) {
|
||||
return "image/heic"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// ShareInboxStore.swift
|
||||
// Shared between AFFiNE and ShareExtension
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class ShareInboxStore {
|
||||
static let shared = ShareInboxStore()
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
private let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
var containerURL: URL? {
|
||||
fileManager.containerURL(forSecurityApplicationGroupIdentifier: ShareInboxConstants.appGroupId)
|
||||
}
|
||||
|
||||
private var inboxDirectoryURL: URL? {
|
||||
guard let containerURL else { return nil }
|
||||
return containerURL
|
||||
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
private var attachmentsDirectoryURL: URL? {
|
||||
guard let inboxDirectoryURL else { return nil }
|
||||
return inboxDirectoryURL
|
||||
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
private var invalidDirectoryURL: URL? {
|
||||
guard let inboxDirectoryURL else { return nil }
|
||||
return inboxDirectoryURL
|
||||
.appendingPathComponent(ShareInboxConstants.invalidDirectoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureDirectories() -> Bool {
|
||||
guard let inboxDirectoryURL, let attachmentsDirectoryURL, let invalidDirectoryURL else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try fileManager.createDirectory(at: inboxDirectoryURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: attachmentsDirectoryURL, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: invalidDirectoryURL, withIntermediateDirectories: true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func enqueue(_ item: ShareInboxItem, attachmentData: [(ShareInboxAttachment, Data)] = []) throws {
|
||||
guard ensureDirectories() else {
|
||||
throw ShareInboxError.containerUnavailable
|
||||
}
|
||||
|
||||
var writtenURLs: [URL] = []
|
||||
var writtenParentURLs: [URL] = []
|
||||
do {
|
||||
for (attachment, data) in attachmentData {
|
||||
guard let destination = attachmentURL(for: attachment) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
let parent = destination.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
writtenParentURLs.append(parent)
|
||||
try data.write(to: destination, options: .atomic)
|
||||
writtenURLs.append(destination)
|
||||
}
|
||||
|
||||
guard let fileURL = manifestURL(for: item.id) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
let data = try encoder.encode(item)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
} catch {
|
||||
for url in writtenURLs {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
for url in writtenParentURLs.reversed() {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ item: ShareInboxItem) throws {
|
||||
guard ensureDirectories(), let fileURL = manifestURL(for: item.id) else {
|
||||
throw ShareInboxError.containerUnavailable
|
||||
}
|
||||
try encoder.encode(item).write(to: fileURL, options: .atomic)
|
||||
}
|
||||
|
||||
func pendingItems() -> [ShareInboxItem] {
|
||||
guard ensureDirectories(), let inboxDirectoryURL else { return [] }
|
||||
guard let urls = try? fileManager.contentsOfDirectory(
|
||||
at: inboxDirectoryURL,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return urls
|
||||
.filter { $0.pathExtension.lowercased() == "json" }
|
||||
.compactMap { url -> ShareInboxItem? in
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
quarantine(url)
|
||||
return nil
|
||||
}
|
||||
guard let item = try? decoder.decode(ShareInboxItem.self, from: data),
|
||||
let expectedURL = manifestURL(for: item.id),
|
||||
expectedURL.lastPathComponent.caseInsensitiveCompare(url.lastPathComponent) == .orderedSame
|
||||
else {
|
||||
quarantine(url)
|
||||
return nil
|
||||
}
|
||||
return item
|
||||
}
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
|
||||
func attachmentURL(for attachment: ShareInboxAttachment) -> URL? {
|
||||
guard let attachmentsDirectoryURL else { return nil }
|
||||
guard !attachment.relativePath.isEmpty,
|
||||
!attachment.relativePath.hasPrefix("/"),
|
||||
!attachment.relativePath.split(separator: "/").contains("..")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let base = attachmentsDirectoryURL.standardizedFileURL
|
||||
let candidate = base.appendingPathComponent(attachment.relativePath).standardizedFileURL
|
||||
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
func remove(_ item: ShareInboxItem) throws {
|
||||
guard let fileURL = manifestURL(for: item.id) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
|
||||
for attachment in item.attachments {
|
||||
if let url = attachmentURL(for: attachment) {
|
||||
try? fileManager.removeItem(at: url)
|
||||
try? fileManager.removeItem(at: url.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func manifestURL(for itemId: String) -> URL? {
|
||||
guard let inboxDirectoryURL,
|
||||
let normalizedId = ShareInboxSafety.normalizedManifestID(itemId)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let base = inboxDirectoryURL.standardizedFileURL
|
||||
let candidate = base
|
||||
.appendingPathComponent("\(normalizedId).json")
|
||||
.standardizedFileURL
|
||||
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func quarantine(_ url: URL) {
|
||||
guard let invalidDirectoryURL else { return }
|
||||
let destination = invalidDirectoryURL.appendingPathComponent(url.lastPathComponent)
|
||||
try? fileManager.removeItem(at: destination)
|
||||
try? fileManager.moveItem(at: url, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
enum ShareInboxError: Error {
|
||||
case containerUnavailable
|
||||
case invalidPayload
|
||||
case payloadTooLarge
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SharePayloadBuilder {
|
||||
private static let maxImageBytes = 12 * 1024 * 1024
|
||||
private static let maxTextCharacters = 250_000
|
||||
|
||||
static func build(from extensionItems: [NSExtensionItem]) async -> SharePayloadDraft {
|
||||
var title = "Shared"
|
||||
var url: String?
|
||||
var text: String?
|
||||
var fallbackText: String?
|
||||
var file: SharePayloadFile?
|
||||
var imageProviderCount = 0
|
||||
|
||||
for item in extensionItems {
|
||||
for provider in item.attachments ?? [] {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier),
|
||||
let page = try? await loadSafariPage(from: provider)
|
||||
{
|
||||
title = page.title ?? title
|
||||
url = page.url ?? url
|
||||
text = page.selectedText.map {
|
||||
String($0.prefix(maxTextCharacters))
|
||||
} ?? text
|
||||
}
|
||||
|
||||
if url == nil,
|
||||
provider.hasItemConformingToTypeIdentifier(UTType.url.identifier),
|
||||
let loadedURL = try? await loadURL(from: provider),
|
||||
let normalized = ShareInboxSafety.normalizedWebURL(loadedURL.absoluteString)
|
||||
{
|
||||
url = normalized
|
||||
if title == "Shared" {
|
||||
title = loadedURL.host ?? normalized
|
||||
}
|
||||
}
|
||||
|
||||
if fallbackText == nil,
|
||||
provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier),
|
||||
let loadedText = try? await loadText(from: provider)
|
||||
{
|
||||
let trimmed = loadedText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty, trimmed != url {
|
||||
fallbackText = String(trimmed.prefix(maxTextCharacters))
|
||||
}
|
||||
}
|
||||
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
imageProviderCount += 1
|
||||
if imageProviderCount > 1 {
|
||||
return failure(title: title, message: "Share one image at a time.")
|
||||
}
|
||||
do {
|
||||
file = try await loadImage(from: provider)
|
||||
} catch ShareInboxError.payloadTooLarge {
|
||||
return failure(title: title, message: "The image must be smaller than 12 MB.")
|
||||
} catch {
|
||||
return failure(title: title, message: "This image format is not supported.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let attributedText = nonEmpty(item.attributedContentText?.string),
|
||||
attributedText != url
|
||||
{
|
||||
if title == "Shared" {
|
||||
title = firstNonEmptyLine(attributedText)
|
||||
}
|
||||
if fallbackText == nil {
|
||||
fallbackText = String(attributedText.prefix(maxTextCharacters))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if text == nil, url == nil {
|
||||
text = fallbackText
|
||||
}
|
||||
if title == "Shared" {
|
||||
if let file {
|
||||
title = (file.fileName as NSString).deletingPathExtension
|
||||
} else if let url, let host = URL(string: url)?.host {
|
||||
title = host
|
||||
} else if let fallbackText {
|
||||
title = firstNonEmptyLine(fallbackText)
|
||||
}
|
||||
}
|
||||
|
||||
let content: ShareInboxContent?
|
||||
if file != nil {
|
||||
content = ShareInboxContent(kind: .image, url: url, text: text)
|
||||
} else if let url {
|
||||
content = ShareInboxContent(kind: .url, url: url, text: text)
|
||||
} else if let text {
|
||||
content = ShareInboxContent(kind: .text, url: nil, text: text)
|
||||
} else {
|
||||
content = nil
|
||||
}
|
||||
|
||||
guard let content else {
|
||||
return failure(
|
||||
title: title,
|
||||
message: "AFFiNE can currently save links, text, or one image."
|
||||
)
|
||||
}
|
||||
|
||||
let preview = text ?? url ?? file?.fileName ?? "Shared content"
|
||||
return SharePayloadDraft(
|
||||
title: sanitizeTitle(title),
|
||||
content: content,
|
||||
previewText: String(preview.prefix(280)),
|
||||
file: file,
|
||||
errorMessage: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func failure(title: String, message: String) -> SharePayloadDraft {
|
||||
SharePayloadDraft(
|
||||
title: sanitizeTitle(title),
|
||||
content: nil,
|
||||
previewText: "",
|
||||
file: nil,
|
||||
errorMessage: message
|
||||
)
|
||||
}
|
||||
|
||||
private struct SafariPage {
|
||||
var title: String?
|
||||
var url: String?
|
||||
var selectedText: String?
|
||||
}
|
||||
|
||||
private static func loadSafariPage(from provider: NSItemProvider) async throws -> SafariPage {
|
||||
let item: Any = try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(
|
||||
forTypeIdentifier: UTType.propertyList.identifier,
|
||||
options: nil
|
||||
) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let item {
|
||||
continuation.resume(returning: item)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dictionary: [String: Any]?
|
||||
if let value = item as? [String: Any] {
|
||||
dictionary = value
|
||||
} else if let data = item as? Data {
|
||||
dictionary = try? PropertyListSerialization.propertyList(
|
||||
from: data,
|
||||
options: [],
|
||||
format: nil
|
||||
) as? [String: Any]
|
||||
} else {
|
||||
dictionary = nil
|
||||
}
|
||||
guard let dictionary else { throw ShareInboxError.invalidPayload }
|
||||
let result =
|
||||
dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any]
|
||||
?? dictionary
|
||||
let pageURL = (result["url"] as? String).flatMap(ShareInboxSafety.normalizedWebURL)
|
||||
return SafariPage(
|
||||
title: nonEmpty(result["title"] as? String),
|
||||
url: pageURL,
|
||||
selectedText: nonEmpty(result["selectedText"] as? String)
|
||||
)
|
||||
}
|
||||
|
||||
private static func loadURL(from provider: NSItemProvider) async throws -> URL {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let url = item as? URL {
|
||||
continuation.resume(returning: url)
|
||||
} else if let value = item as? String, let url = URL(string: value) {
|
||||
continuation.resume(returning: url)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadText(from provider: NSItemProvider) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(
|
||||
forTypeIdentifier: UTType.plainText.identifier,
|
||||
options: nil
|
||||
) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let text = item as? String {
|
||||
continuation.resume(returning: text)
|
||||
} else if let attributed = item as? NSAttributedString {
|
||||
continuation.resume(returning: attributed.string)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadImage(from provider: NSItemProvider) async throws -> SharePayloadFile {
|
||||
let item: Any = try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let item {
|
||||
continuation.resume(returning: item)
|
||||
} else {
|
||||
continuation.resume(throwing: ShareInboxError.invalidPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let suggestedName: String?
|
||||
if let value = item as? Data {
|
||||
data = value
|
||||
suggestedName = provider.suggestedName
|
||||
} else if let url = item as? URL, url.isFileURL {
|
||||
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
||||
if let fileSize = values.fileSize, fileSize > maxImageBytes {
|
||||
throw ShareInboxError.payloadTooLarge
|
||||
}
|
||||
data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
suggestedName = url.lastPathComponent
|
||||
} else if let image = item as? UIImage, let jpeg = image.jpegData(compressionQuality: 0.9) {
|
||||
data = jpeg
|
||||
suggestedName = provider.suggestedName
|
||||
} else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
|
||||
guard data.count <= maxImageBytes else { throw ShareInboxError.payloadTooLarge }
|
||||
guard let mimeType = ShareInboxSafety.detectRasterImageMimeType(data) else {
|
||||
throw ShareInboxError.invalidPayload
|
||||
}
|
||||
let fileExtension = fileExtension(for: mimeType)
|
||||
let baseName = nonEmpty(suggestedName)
|
||||
.map { ($0 as NSString).lastPathComponent }
|
||||
.flatMap { nonEmpty(($0 as NSString).deletingPathExtension) }
|
||||
?? "shared-image"
|
||||
return SharePayloadFile(
|
||||
data: data,
|
||||
mimeType: mimeType,
|
||||
fileName: "\(baseName).\(fileExtension)"
|
||||
)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func sanitizeTitle(_ value: String) -> String {
|
||||
String((nonEmpty(value) ?? "Shared").prefix(120))
|
||||
}
|
||||
|
||||
private static func firstNonEmptyLine(_ value: String) -> String {
|
||||
value
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.lazy
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first { !$0.isEmpty }
|
||||
.map { String($0.prefix(120)) }
|
||||
?? "Shared text"
|
||||
}
|
||||
|
||||
private static func fileExtension(for mimeType: String) -> String {
|
||||
switch mimeType {
|
||||
case "image/png": "png"
|
||||
case "image/gif": "gif"
|
||||
case "image/webp": "webp"
|
||||
case "image/heic": "heic"
|
||||
default: "jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
||||
import { AffineContext } from '@affine/core/components/context';
|
||||
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
|
||||
import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider';
|
||||
import { ShareImportController } from '@affine/core/mobile/components/share-import-controller';
|
||||
import { configureMobileModules } from '@affine/core/mobile/modules';
|
||||
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
|
||||
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
|
||||
@@ -89,6 +90,7 @@ import { NavigationGesture } from './plugins/navigation-gesture';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { PayWall } from './plugins/paywall';
|
||||
import { Preview } from './plugins/preview';
|
||||
import { shareInboxProvider } from './plugins/share-inbox';
|
||||
import {
|
||||
authRequestProvider,
|
||||
clearEndpointSession,
|
||||
@@ -543,6 +545,7 @@ const showNativeSignIn = async () => {
|
||||
(window as any).requestSignIn = async () => {
|
||||
return await showNativeSignIn();
|
||||
};
|
||||
|
||||
(window as any).getCurrentDocContentInMarkdown = async () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
const globalContext = globalContextService.globalContext;
|
||||
@@ -565,6 +568,7 @@ const showNativeSignIn = async () => {
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const docRef = currentDocId ? docsService.open(currentDocId) : null;
|
||||
if (!docRef) {
|
||||
disposeWorkspace();
|
||||
return;
|
||||
}
|
||||
const { doc, release: disposeDoc } = docRef;
|
||||
@@ -625,7 +629,7 @@ const showNativeSignIn = async () => {
|
||||
}
|
||||
|
||||
const workbench = workspace.scope.get(WorkbenchService).workbench;
|
||||
await workspace.engine.doc.waitForDocReady(workspace.id); // wait for root doc ready
|
||||
await workspace.engine.doc.waitForDocReady(workspace.id);
|
||||
const docId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
collection: workspace.docCollection,
|
||||
schema: getAFFiNEWorkspaceSchema(),
|
||||
@@ -633,15 +637,13 @@ const showNativeSignIn = async () => {
|
||||
extensions: getStoreManager().config.init().value.get('store'),
|
||||
});
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
if (docId) {
|
||||
// only support page mode for now
|
||||
await docsService.changeDocTitle(docId, title);
|
||||
docsService.list.setPrimaryMode(docId, 'page');
|
||||
workbench.openDoc(docId);
|
||||
return docId;
|
||||
} else {
|
||||
if (!docId) {
|
||||
throw new Error('Failed to import doc');
|
||||
}
|
||||
await docsService.changeDocTitle(docId, title);
|
||||
docsService.list.setPrimaryMode(docId, 'page');
|
||||
workbench.openDoc(docId);
|
||||
return docId;
|
||||
} finally {
|
||||
workspaceRef?.dispose();
|
||||
}
|
||||
@@ -876,6 +878,7 @@ export function App() {
|
||||
<AffineContext store={getCurrentStore()}>
|
||||
<KeyboardThemeProvider />
|
||||
<IOSBackAdapter />
|
||||
<ShareImportController provider={shareInboxProvider} />
|
||||
<BlocksuiteMenuConfigProvider>
|
||||
<RouterProvider
|
||||
fallbackElement={<AppFallback />}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
PendingShareItem,
|
||||
ShareImportTarget,
|
||||
} from '@affine/core/mobile/components/share-import-controller/types';
|
||||
|
||||
export interface ShareInboxPlugin {
|
||||
listPending(): Promise<{ items: PendingShareItem[] }>;
|
||||
updateTarget(options: {
|
||||
itemId: string;
|
||||
target: ShareImportTarget;
|
||||
}): Promise<void>;
|
||||
resolveAttachment(options: {
|
||||
itemId: string;
|
||||
}): Promise<{ path?: string; mimeType?: string }>;
|
||||
complete(options: { itemId: string; docId: string }): Promise<void>;
|
||||
setError(options: { itemId: string; error: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ShareInboxProvider } from '@affine/core/mobile/components/share-import-controller';
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { ShareInboxPlugin } from './definitions';
|
||||
|
||||
const plugin = registerPlugin<ShareInboxPlugin>('ShareInbox');
|
||||
|
||||
const blobToDataURL = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
export const shareInboxProvider: ShareInboxProvider = {
|
||||
async listPending() {
|
||||
return (await plugin.listPending()).items;
|
||||
},
|
||||
async updateTarget(itemId, target) {
|
||||
await plugin.updateTarget({ itemId, target });
|
||||
},
|
||||
async resolveAttachment(itemId) {
|
||||
const { path, mimeType } = await plugin.resolveAttachment({ itemId });
|
||||
if (!path) return undefined;
|
||||
const response = await fetch(Capacitor.convertFileSrc(path));
|
||||
if (!response.ok) return undefined;
|
||||
const blob = await response.blob();
|
||||
return blobToDataURL(
|
||||
mimeType && blob.type !== mimeType
|
||||
? new Blob([blob], { type: mimeType })
|
||||
: blob
|
||||
);
|
||||
},
|
||||
async complete(itemId, docId) {
|
||||
await plugin.complete({ itemId, docId });
|
||||
},
|
||||
async setError(itemId, error) {
|
||||
await plugin.setError({ itemId, error });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,549 @@
|
||||
import { Button, Modal, notify, SafeArea, Scrollable } from '@affine/component';
|
||||
import {
|
||||
ImportClipperService,
|
||||
type ShareDestinationOptions,
|
||||
} from '@affine/core/modules/import-clipper';
|
||||
import {
|
||||
type WorkspaceMetadata,
|
||||
WorkspacesService,
|
||||
} from '@affine/core/modules/workspace';
|
||||
import { ImageIcon, LinkIcon, TextIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { PageHeader } from '../page-header';
|
||||
import { SelectionPage, type SelectionPageOption } from './selection-page';
|
||||
import * as styles from './style.css';
|
||||
import type {
|
||||
PendingShareItem,
|
||||
ShareImportTarget,
|
||||
ShareInboxProvider,
|
||||
} from './types';
|
||||
|
||||
export type { ShareInboxProvider } from './types';
|
||||
|
||||
type Page = 'main' | 'workspace' | 'tags' | 'collection' | 'offline';
|
||||
|
||||
const errorMessage = (error?: string) => {
|
||||
switch (error) {
|
||||
case 'workspace-not-found':
|
||||
return 'The selected workspace is no longer available. Choose another workspace.';
|
||||
case 'permission-denied':
|
||||
return 'You no longer have permission to create documents in this workspace.';
|
||||
case 'destination-not-found':
|
||||
return 'One or more selected tags or the collection no longer exist.';
|
||||
case 'offline-confirmation-required':
|
||||
return 'AFFiNE could not confirm the latest workspace state.';
|
||||
case 'attachment-missing':
|
||||
return 'The shared image is no longer available.';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const workspaceKey = (workspace: WorkspaceMetadata) =>
|
||||
`${workspace.flavour}:${workspace.id}`;
|
||||
|
||||
const sourceDetails = (item: PendingShareItem) => {
|
||||
if (item.content.kind === 'url') {
|
||||
return {
|
||||
title: item.title,
|
||||
detail: item.content.url?.replace(/^https?:\/\//, '').split('/')[0],
|
||||
};
|
||||
}
|
||||
if (item.content.kind === 'image') {
|
||||
return {
|
||||
title: item.title,
|
||||
detail: item.attachments?.[0]?.fileName ?? 'Shared image',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: item.title,
|
||||
detail: `${item.content.text?.length ?? 0} characters`,
|
||||
};
|
||||
};
|
||||
|
||||
const SourceIcon = ({
|
||||
kind,
|
||||
}: {
|
||||
kind: PendingShareItem['content']['kind'];
|
||||
}) => {
|
||||
switch (kind) {
|
||||
case 'url':
|
||||
return <LinkIcon />;
|
||||
case 'image':
|
||||
return <ImageIcon />;
|
||||
case 'text':
|
||||
return <TextIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
export const ShareImportController = ({
|
||||
provider,
|
||||
}: {
|
||||
provider: ShareInboxProvider;
|
||||
}) => {
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
const importer = useService(ImportClipperService);
|
||||
const workspaces = useLiveData(workspacesService.list.workspaces$);
|
||||
const [item, setItem] = useState<PendingShareItem>();
|
||||
const [page, setPage] = useState<Page>('main');
|
||||
const [selectedWorkspaceKey, setSelectedWorkspaceKey] = useState('');
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [collectionId, setCollectionId] = useState('');
|
||||
const [destinations, setDestinations] = useState<ShareDestinationOptions>();
|
||||
const [isLoadingDestinations, setIsLoadingDestinations] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [attachmentPreview, setAttachmentPreview] = useState<string>();
|
||||
const refreshing = useRef(false);
|
||||
const itemId = item?.id;
|
||||
|
||||
const selectedWorkspace = workspaces.find(
|
||||
workspace => workspaceKey(workspace) === selectedWorkspaceKey
|
||||
);
|
||||
const selectedWorkspaceName = selectedWorkspace
|
||||
? workspacesService.getProfile(selectedWorkspace).name$.value ||
|
||||
selectedWorkspace.id
|
||||
: undefined;
|
||||
|
||||
const setManualItem = useCallback((next: PendingShareItem) => {
|
||||
setItem(next);
|
||||
setPage('main');
|
||||
setSelectedWorkspaceKey(
|
||||
next.target
|
||||
? `${next.target.workspaceFlavour}:${next.target.workspaceId}`
|
||||
: ''
|
||||
);
|
||||
setTagIds(next.target?.tagIds ?? []);
|
||||
setCollectionId(next.target?.collectionId ?? '');
|
||||
}, []);
|
||||
|
||||
const importItem = useCallback(
|
||||
async (
|
||||
pending: PendingShareItem,
|
||||
target: ShareImportTarget,
|
||||
allowOffline: boolean
|
||||
) => {
|
||||
await provider.updateTarget(pending.id, target);
|
||||
const workspace = workspacesService.list.workspaces$.value.find(
|
||||
metadata =>
|
||||
metadata.id === target.workspaceId &&
|
||||
metadata.flavour === target.workspaceFlavour
|
||||
);
|
||||
if (!workspace) {
|
||||
await provider.setError(pending.id, 'workspace-not-found');
|
||||
return false;
|
||||
}
|
||||
const attachmentUrl =
|
||||
pending.content.kind === 'image'
|
||||
? await provider.resolveAttachment(pending.id)
|
||||
: undefined;
|
||||
if (pending.content.kind === 'image' && !attachmentUrl) {
|
||||
await provider.setError(pending.id, 'attachment-missing');
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await importer.importShareToWorkspace(
|
||||
workspace,
|
||||
{
|
||||
documentId: pending.documentId,
|
||||
title: pending.title,
|
||||
content: pending.content,
|
||||
attachmentUrl,
|
||||
tagIds: target.tagIds,
|
||||
collectionId: target.collectionId,
|
||||
},
|
||||
{ allowOffline }
|
||||
);
|
||||
if (result.status !== 'imported') {
|
||||
await provider.setError(pending.id, result.status);
|
||||
return false;
|
||||
}
|
||||
await provider.complete(pending.id, result.docId);
|
||||
return true;
|
||||
},
|
||||
[importer, provider, workspacesService]
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
try {
|
||||
const pending = await provider.listPending();
|
||||
let importedCount = 0;
|
||||
let nextItem: PendingShareItem | undefined;
|
||||
for (const candidate of pending) {
|
||||
if (candidate.target && !candidate.lastError) {
|
||||
const imported = await importItem(candidate, candidate.target, false);
|
||||
if (imported) {
|
||||
importedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const latest = await provider.listPending();
|
||||
nextItem = latest.find(item => item.id === candidate.id);
|
||||
break;
|
||||
}
|
||||
nextItem = candidate;
|
||||
break;
|
||||
}
|
||||
if (importedCount > 0) {
|
||||
notify.success({
|
||||
title: `${importedCount} shared ${importedCount === 1 ? 'item' : 'items'} saved`,
|
||||
});
|
||||
}
|
||||
if (nextItem) {
|
||||
setManualItem(nextItem);
|
||||
} else {
|
||||
setItem(undefined);
|
||||
}
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, [importItem, provider, setManualItem]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().catch(console.error);
|
||||
const handleRefresh = () => {
|
||||
void refresh().catch(console.error);
|
||||
};
|
||||
window.addEventListener('affine:share-inbox', handleRefresh);
|
||||
return () =>
|
||||
window.removeEventListener('affine:share-inbox', handleRefresh);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setAttachmentPreview(undefined);
|
||||
if (item?.content.kind === 'image') {
|
||||
void provider
|
||||
.resolveAttachment(item.id)
|
||||
.then(preview => {
|
||||
if (active) setAttachmentPreview(preview);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [item?.content.kind, item?.id, provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedWorkspace) {
|
||||
setDestinations(undefined);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setDestinations(undefined);
|
||||
setIsLoadingDestinations(true);
|
||||
void importer
|
||||
.getShareDestinationOptions(selectedWorkspace)
|
||||
.then(async options => {
|
||||
if (!active) return;
|
||||
if (!options) {
|
||||
if (itemId) {
|
||||
await provider.setError(itemId, 'workspace-not-found');
|
||||
setItem(current =>
|
||||
current?.id === itemId &&
|
||||
current.lastError !== 'workspace-not-found'
|
||||
? { ...current, lastError: 'workspace-not-found' }
|
||||
: current
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDestinations(options);
|
||||
const validTags = new Set(options.tags.map(tag => tag.id));
|
||||
setTagIds(ids => ids.filter(id => validTags.has(id)));
|
||||
setCollectionId(id =>
|
||||
id && options.collections.some(collection => collection.id === id)
|
||||
? id
|
||||
: ''
|
||||
);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
if (active) setIsLoadingDestinations(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [importer, itemId, provider, selectedWorkspace]);
|
||||
|
||||
const save = async (allowOffline: boolean) => {
|
||||
if (!item || !selectedWorkspace || isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const imported = await importItem(
|
||||
item,
|
||||
{
|
||||
workspaceId: selectedWorkspace.id,
|
||||
workspaceFlavour: selectedWorkspace.flavour,
|
||||
tagIds,
|
||||
collectionId: collectionId || undefined,
|
||||
},
|
||||
allowOffline
|
||||
);
|
||||
if (imported) {
|
||||
notify.success({ title: 'Shared content saved' });
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const workspaceOptions: SelectionPageOption[] = workspaces.map(workspace => ({
|
||||
id: workspaceKey(workspace),
|
||||
label: workspacesService.getProfile(workspace).name$.value || workspace.id,
|
||||
detail: workspace.flavour === 'local' ? 'On this device' : 'Cloud',
|
||||
}));
|
||||
const tagOptions: SelectionPageOption[] =
|
||||
destinations?.tags.map(tag => ({
|
||||
id: tag.id,
|
||||
label: tag.name,
|
||||
color: tag.color,
|
||||
})) ?? [];
|
||||
const collectionOptions: SelectionPageOption[] = [
|
||||
{ id: '', label: 'No collection' },
|
||||
...(destinations?.collections.map(collection => ({
|
||||
id: collection.id,
|
||||
label: collection.name,
|
||||
})) ?? []),
|
||||
];
|
||||
|
||||
const selectedTagNames =
|
||||
destinations?.tags
|
||||
.filter(tag => tagIds.includes(tag.id))
|
||||
.map(tag => tag.name) ?? [];
|
||||
const collectionName =
|
||||
destinations?.collections.find(collection => collection.id === collectionId)
|
||||
?.name ?? 'None';
|
||||
const requiresOfflineConfirmation =
|
||||
item.lastError === 'offline-confirmation-required' ||
|
||||
destinations?.verification === 'unavailable';
|
||||
const source = sourceDetails(item);
|
||||
|
||||
const content = (() => {
|
||||
if (page === 'workspace') {
|
||||
return (
|
||||
<SelectionPage
|
||||
title="Workspace"
|
||||
options={workspaceOptions}
|
||||
selectedIds={selectedWorkspaceKey ? [selectedWorkspaceKey] : []}
|
||||
onBack={() => setPage('main')}
|
||||
onSelect={id => {
|
||||
setSelectedWorkspaceKey(id);
|
||||
setTagIds([]);
|
||||
setCollectionId('');
|
||||
setItem(current =>
|
||||
current ? { ...current, lastError: undefined } : current
|
||||
);
|
||||
setPage('main');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (page === 'tags') {
|
||||
return (
|
||||
<SelectionPage
|
||||
title="Tags"
|
||||
multiple
|
||||
options={tagOptions}
|
||||
selectedIds={tagIds}
|
||||
onBack={() => setPage('main')}
|
||||
onSelect={id =>
|
||||
setTagIds(ids =>
|
||||
ids.includes(id)
|
||||
? ids.filter(current => current !== id)
|
||||
: [...ids, id]
|
||||
)
|
||||
}
|
||||
onConfirm={() => setPage('main')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (page === 'collection') {
|
||||
return (
|
||||
<SelectionPage
|
||||
title="Collection"
|
||||
options={collectionOptions}
|
||||
selectedIds={[collectionId]}
|
||||
onBack={() => setPage('main')}
|
||||
onSelect={id => {
|
||||
setCollectionId(id);
|
||||
setPage('main');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (page === 'offline') {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader back backAction={() => setPage('main')}>
|
||||
<span className={styles.headerTitle}>
|
||||
Use local workspace data?
|
||||
</span>
|
||||
</PageHeader>
|
||||
<main className={styles.confirmation}>
|
||||
<h2 className={styles.confirmationTitle}>
|
||||
{selectedWorkspaceName}
|
||||
</h2>
|
||||
<p className={styles.confirmationText}>
|
||||
AFFiNE could not confirm that this workspace, your permissions,
|
||||
and its destinations are current online. Saving will use the most
|
||||
recent data available on this device.
|
||||
</p>
|
||||
</main>
|
||||
<SafeArea bottom className={styles.footer}>
|
||||
<Button
|
||||
className={styles.action}
|
||||
variant="primary"
|
||||
disabled={isSaving}
|
||||
onClick={() => void save(true).catch(console.error)}
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save using local data'}
|
||||
</Button>
|
||||
</SafeArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader
|
||||
suffix={
|
||||
<Button variant="plain" onClick={() => setItem(undefined)}>
|
||||
Not now
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<span className={styles.headerTitle}>Choose where to save</span>
|
||||
</PageHeader>
|
||||
|
||||
<Scrollable.Root className={styles.scrollArea}>
|
||||
<Scrollable.Scrollbar />
|
||||
<Scrollable.Viewport>
|
||||
<main className={styles.main}>
|
||||
<section className={styles.source}>
|
||||
<div className={styles.sourceIcon}>
|
||||
{attachmentPreview ? (
|
||||
<img
|
||||
className={styles.sourceImage}
|
||||
src={attachmentPreview}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<SourceIcon kind={item.content.kind} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sourceContent}>
|
||||
<div className={styles.sourceTitle}>{source.title}</div>
|
||||
{source.detail ? (
|
||||
<div className={styles.sourceDetail}>{source.detail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.destinationGroup}>
|
||||
<button
|
||||
className={styles.destinationRow}
|
||||
type="button"
|
||||
onClick={() => setPage('workspace')}
|
||||
>
|
||||
<span className={styles.rowLabel}>Workspace</span>
|
||||
<span className={styles.rowValue}>
|
||||
{selectedWorkspaceName ?? 'Choose'}
|
||||
<span className={styles.rowArrow}>›</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.destinationRow}
|
||||
type="button"
|
||||
disabled={!destinations || isLoadingDestinations}
|
||||
onClick={() => setPage('tags')}
|
||||
>
|
||||
<span className={styles.rowLabel}>
|
||||
Tags <span className={styles.optional}>Optional</span>
|
||||
</span>
|
||||
<span className={styles.rowValue}>
|
||||
{selectedTagNames.length
|
||||
? `${selectedTagNames.length} selected`
|
||||
: 'None'}
|
||||
<span className={styles.rowArrow}>›</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.destinationRow}
|
||||
type="button"
|
||||
disabled={!destinations || isLoadingDestinations}
|
||||
onClick={() => setPage('collection')}
|
||||
>
|
||||
<span className={styles.rowLabel}>
|
||||
Collection <span className={styles.optional}>Optional</span>
|
||||
</span>
|
||||
<span className={styles.rowValue}>
|
||||
{collectionName}
|
||||
<span className={styles.rowArrow}>›</span>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isLoadingDestinations ? (
|
||||
<div className={styles.status}>Checking workspace…</div>
|
||||
) : requiresOfflineConfirmation ? (
|
||||
<div className={styles.warning}>
|
||||
The latest online workspace state could not be confirmed.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage(item.lastError) ? (
|
||||
<div className={styles.error}>
|
||||
{errorMessage(item.lastError)}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
|
||||
<SafeArea bottom className={styles.footer}>
|
||||
<Button
|
||||
className={styles.action}
|
||||
variant="primary"
|
||||
disabled={
|
||||
!selectedWorkspace ||
|
||||
!destinations ||
|
||||
isSaving ||
|
||||
isLoadingDestinations
|
||||
}
|
||||
onClick={() => {
|
||||
if (requiresOfflineConfirmation) {
|
||||
setPage('offline');
|
||||
} else {
|
||||
void save(false).catch(console.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</SafeArea>
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
fullScreen
|
||||
animation="slideBottom"
|
||||
open
|
||||
withoutCloseButton
|
||||
onOpenChange={() => setItem(undefined)}
|
||||
contentOptions={{ style: { padding: 0 } }}
|
||||
>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { Button, SafeArea, Scrollable } from '@affine/component';
|
||||
|
||||
import { PageHeader } from '../page-header';
|
||||
import * as styles from './style.css';
|
||||
|
||||
export interface SelectionPageOption {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const SelectionPage = ({
|
||||
title,
|
||||
options,
|
||||
selectedIds,
|
||||
multiple = false,
|
||||
onBack,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: {
|
||||
title: string;
|
||||
options: SelectionPageOption[];
|
||||
selectedIds: string[];
|
||||
multiple?: boolean;
|
||||
onBack: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
onConfirm?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader back backAction={onBack}>
|
||||
<span className={styles.headerTitle}>{title}</span>
|
||||
</PageHeader>
|
||||
<Scrollable.Root className={styles.scrollArea}>
|
||||
<Scrollable.Scrollbar />
|
||||
<Scrollable.Viewport>
|
||||
<ul className={styles.selectionList}>
|
||||
{options.map(option => {
|
||||
const selected = selectedIds.includes(option.id);
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
className={styles.selectionRow}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(option.id)}
|
||||
>
|
||||
{option.color ? (
|
||||
<span
|
||||
className={styles.colorDot}
|
||||
style={{ backgroundColor: option.color }}
|
||||
/>
|
||||
) : null}
|
||||
<span className={styles.selectionLabel}>
|
||||
{option.label}
|
||||
{option.detail ? (
|
||||
<span className={styles.selectionDetail}>
|
||||
{option.detail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className={styles.checkmark} aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={styles.checkmarkPlaceholder}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
{multiple ? (
|
||||
<SafeArea bottom className={styles.footer}>
|
||||
<Button
|
||||
className={styles.action}
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</SafeArea>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
bodyEmphasized,
|
||||
bodyRegular,
|
||||
footnoteRegular,
|
||||
subHeadlineRegular,
|
||||
} from '@toeverything/theme/typography';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const page = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
|
||||
export const headerTitle = style([
|
||||
bodyEmphasized,
|
||||
{ color: cssVarV2('text/primary') },
|
||||
]);
|
||||
|
||||
export const scrollArea = style({
|
||||
height: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const main = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
padding: '20px 16px',
|
||||
});
|
||||
|
||||
export const source = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
export const sourceIcon = style({
|
||||
width: 40,
|
||||
height: 40,
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
fontSize: 20,
|
||||
color: cssVarV2('icon/primary'),
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const sourceImage = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
});
|
||||
|
||||
export const sourceContent = style({
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const sourceTitle = style([
|
||||
bodyEmphasized,
|
||||
{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: cssVarV2('text/primary'),
|
||||
},
|
||||
]);
|
||||
|
||||
export const sourceDetail = style([
|
||||
footnoteRegular,
|
||||
{
|
||||
marginTop: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
]);
|
||||
|
||||
export const destinationGroup = style({
|
||||
overflow: 'hidden',
|
||||
borderRadius: 12,
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
export const destinationRow = style({
|
||||
width: '100%',
|
||||
minHeight: 52,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '0 12px',
|
||||
border: 0,
|
||||
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
color: cssVarV2('text/primary'),
|
||||
background: 'transparent',
|
||||
textAlign: 'left',
|
||||
selectors: {
|
||||
'&:last-child': { borderBottom: 0 },
|
||||
'&:disabled': { opacity: 0.5 },
|
||||
},
|
||||
});
|
||||
|
||||
export const rowLabel = style([
|
||||
bodyRegular,
|
||||
{ display: 'flex', alignItems: 'baseline', gap: 6 },
|
||||
]);
|
||||
|
||||
export const optional = style([
|
||||
footnoteRegular,
|
||||
{ color: cssVarV2('text/tertiary') },
|
||||
]);
|
||||
|
||||
export const rowValue = style([
|
||||
bodyRegular,
|
||||
{
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
overflow: 'hidden',
|
||||
color: cssVarV2('text/secondary'),
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
]);
|
||||
|
||||
export const rowArrow = style({
|
||||
fontSize: 24,
|
||||
lineHeight: 1,
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
|
||||
export const status = style([
|
||||
footnoteRegular,
|
||||
{ color: cssVarV2('text/secondary') },
|
||||
]);
|
||||
|
||||
export const warning = style([
|
||||
footnoteRegular,
|
||||
{
|
||||
padding: 12,
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 8,
|
||||
color: cssVarV2('text/primary'),
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
},
|
||||
]);
|
||||
|
||||
export const error = style([
|
||||
footnoteRegular,
|
||||
{ color: cssVarV2('status/error') },
|
||||
]);
|
||||
|
||||
export const footer = style({
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
export const action = style({
|
||||
width: '100%',
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
fontSize: 17,
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const selectionList = style({
|
||||
margin: 0,
|
||||
padding: '8px 16px',
|
||||
listStyle: 'none',
|
||||
});
|
||||
|
||||
export const selectionRow = style({
|
||||
width: '100%',
|
||||
minHeight: 52,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '6px 8px',
|
||||
border: 0,
|
||||
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
color: cssVarV2('text/primary'),
|
||||
background: 'transparent',
|
||||
textAlign: 'left',
|
||||
});
|
||||
|
||||
export const selectionLabel = style([
|
||||
bodyRegular,
|
||||
{
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
]);
|
||||
|
||||
export const selectionDetail = style([
|
||||
footnoteRegular,
|
||||
{ color: cssVarV2('text/secondary') },
|
||||
]);
|
||||
|
||||
export const colorDot = style({
|
||||
width: 12,
|
||||
height: 12,
|
||||
flex: '0 0 auto',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
|
||||
export const checkmark = style({
|
||||
width: 32,
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
color: cssVarV2('button/primary'),
|
||||
});
|
||||
|
||||
export const checkmarkPlaceholder = style({
|
||||
width: 32,
|
||||
});
|
||||
|
||||
export const confirmation = style({
|
||||
padding: '32px 20px',
|
||||
});
|
||||
|
||||
export const confirmationTitle = style([
|
||||
subHeadlineRegular,
|
||||
{
|
||||
margin: 0,
|
||||
color: cssVarV2('text/primary'),
|
||||
},
|
||||
]);
|
||||
|
||||
export const confirmationText = style([
|
||||
bodyRegular,
|
||||
{
|
||||
margin: '12px 0 0',
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface PendingShareItem {
|
||||
id: string;
|
||||
documentId: string;
|
||||
title: string;
|
||||
content: {
|
||||
kind: 'url' | 'text' | 'image';
|
||||
url?: string;
|
||||
text?: string;
|
||||
};
|
||||
target?: ShareImportTarget;
|
||||
previewText?: string;
|
||||
attachments?: { fileName: string; mimeType: string }[];
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface ShareImportTarget {
|
||||
workspaceId: string;
|
||||
workspaceFlavour: string;
|
||||
tagIds: string[];
|
||||
collectionId?: string;
|
||||
}
|
||||
|
||||
export interface ShareInboxProvider {
|
||||
listPending(): Promise<PendingShareItem[]>;
|
||||
updateTarget(itemId: string, target: ShareImportTarget): Promise<void>;
|
||||
resolveAttachment(itemId: string): Promise<string | undefined>;
|
||||
complete(itemId: string, docId: string): Promise<void>;
|
||||
setError(itemId: string, error: string): Promise<void>;
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import { type Framework } from '@toeverything/infra';
|
||||
import { WorkspacesService } from '../workspace';
|
||||
import { ImportClipperService } from './services/import';
|
||||
|
||||
export { type ClipperInput, ImportClipperService } from './services/import';
|
||||
export {
|
||||
type ClipperInput,
|
||||
ImportClipperService,
|
||||
type ShareDestinationOptions,
|
||||
type ShareImportInput,
|
||||
type ShareImportResult,
|
||||
} from './services/import';
|
||||
|
||||
export function configureImportClipperModule(framework: Framework) {
|
||||
framework.service(ImportClipperService, [WorkspacesService]);
|
||||
|
||||
@@ -2,7 +2,10 @@ import { getStoreManager } from '@affine/core/blocksuite/manager/store';
|
||||
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { CollectionService } from '../../collection';
|
||||
import { DocsService } from '../../doc';
|
||||
import { GuardService } from '../../permissions';
|
||||
import { TagService } from '../../tag';
|
||||
import {
|
||||
getAFFiNEWorkspaceSchema,
|
||||
type WorkspaceMetadata,
|
||||
@@ -17,11 +20,308 @@ export interface ClipperInput {
|
||||
workspace?: 'select-by-user' | 'last-open-workspace';
|
||||
}
|
||||
|
||||
export interface ShareImportInput {
|
||||
documentId: string;
|
||||
title: string;
|
||||
content: {
|
||||
kind: 'url' | 'text' | 'image';
|
||||
url?: string;
|
||||
text?: string;
|
||||
};
|
||||
attachmentUrl?: string;
|
||||
tagIds: string[];
|
||||
collectionId?: string;
|
||||
}
|
||||
|
||||
export type ShareImportResult =
|
||||
| { status: 'imported'; docId: string }
|
||||
| {
|
||||
status:
|
||||
| 'workspace-not-found'
|
||||
| 'permission-denied'
|
||||
| 'destination-not-found'
|
||||
| 'offline-confirmation-required';
|
||||
missingTagIds?: string[];
|
||||
};
|
||||
|
||||
export interface ShareDestinationOptions {
|
||||
verification: 'confirmed' | 'unavailable';
|
||||
tags: { id: string; name: string; color: string }[];
|
||||
collections: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
type WorkspaceVerification = 'confirmed' | 'missing' | 'unavailable';
|
||||
|
||||
export class ImportClipperService extends Service {
|
||||
constructor(private readonly workspacesService: WorkspacesService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async importShareToWorkspace(
|
||||
workspaceMetadata: WorkspaceMetadata,
|
||||
input: ShareImportInput,
|
||||
options: { allowOffline?: boolean } = {}
|
||||
): Promise<ShareImportResult> {
|
||||
const verification = await this.revalidateWorkspace(workspaceMetadata);
|
||||
if (verification === 'missing') {
|
||||
return { status: 'workspace-not-found' };
|
||||
}
|
||||
const currentMetadata = this.workspacesService.list.workspaces$.value.find(
|
||||
workspace =>
|
||||
workspace.id === workspaceMetadata.id &&
|
||||
workspace.flavour === workspaceMetadata.flavour
|
||||
);
|
||||
if (!currentMetadata) {
|
||||
return { status: 'workspace-not-found' };
|
||||
}
|
||||
|
||||
const workspaceRef = this.workspacesService.open({
|
||||
metadata: currentMetadata,
|
||||
});
|
||||
if (!workspaceRef) {
|
||||
return { status: 'workspace-not-found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { workspace } = workspaceRef;
|
||||
await workspace.engine.doc.waitForDocReady(workspace.id);
|
||||
const rootSynced =
|
||||
workspace.meta.flavour === 'local' ||
|
||||
(verification === 'confirmed' &&
|
||||
(await this.waitForRootSync(workspace)));
|
||||
if (!rootSynced && !options.allowOffline) {
|
||||
return { status: 'offline-confirmation-required' };
|
||||
}
|
||||
|
||||
const guard = workspace.scope.get(GuardService);
|
||||
if (!(await guard.can('Workspace_CreateDoc'))) {
|
||||
return { status: 'permission-denied' };
|
||||
}
|
||||
|
||||
const tagService = workspace.scope.get(TagService);
|
||||
const tags = tagService.tagList.tags$.value;
|
||||
const missingTagIds = input.tagIds.filter(
|
||||
id => !tags.some(tag => tag.id === id)
|
||||
);
|
||||
const collectionService = workspace.scope.get(CollectionService);
|
||||
if (
|
||||
missingTagIds.length > 0 ||
|
||||
(input.collectionId &&
|
||||
!collectionService.collectionMetas$.value.some(
|
||||
collection => collection.id === input.collectionId
|
||||
))
|
||||
) {
|
||||
return { status: 'destination-not-found', missingTagIds };
|
||||
}
|
||||
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
let record = docsService.list.doc$(input.documentId).value;
|
||||
if (!record) {
|
||||
record = docsService.createDoc({
|
||||
id: input.documentId,
|
||||
primaryMode: 'page',
|
||||
});
|
||||
}
|
||||
const { doc, release } = docsService.open(record.id);
|
||||
try {
|
||||
await doc.waitForSyncReady();
|
||||
const page = doc.blockSuiteDoc.getBlocksByFlavour('affine:page')[0];
|
||||
if (!page) {
|
||||
throw new Error('Failed to initialize shared doc');
|
||||
}
|
||||
page.model.children.forEach(child => {
|
||||
doc.blockSuiteDoc.deleteBlock(child);
|
||||
});
|
||||
const noteId = doc.blockSuiteDoc.addBlock('affine:note', {}, page.id);
|
||||
if (input.content.kind === 'url' && input.content.url) {
|
||||
doc.blockSuiteDoc.addBlock(
|
||||
'affine:bookmark',
|
||||
{ url: input.content.url, style: 'horizontal' },
|
||||
noteId
|
||||
);
|
||||
}
|
||||
const markdown = this.shareMarkdown(input);
|
||||
if (markdown) {
|
||||
await MarkdownTransformer.importMarkdownToBlock({
|
||||
doc: doc.blockSuiteDoc,
|
||||
blockId: noteId,
|
||||
markdown,
|
||||
extensions: getStoreManager().config.init().value.get('store'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await docsService.changeDocTitle(input.documentId, input.title);
|
||||
const existingTagIds = new Set(record.meta$.value.tags ?? []);
|
||||
const selectedTagIds = new Set(input.tagIds);
|
||||
for (const tagId of existingTagIds) {
|
||||
if (!selectedTagIds.has(tagId)) {
|
||||
tagService.tagList.tagByTagId$(tagId).value?.untag(input.documentId);
|
||||
}
|
||||
}
|
||||
for (const tagId of input.tagIds) {
|
||||
if (!existingTagIds.has(tagId)) {
|
||||
tagService.tagList.tagByTagId$(tagId).value?.tag(input.documentId);
|
||||
}
|
||||
}
|
||||
for (const collection of collectionService.collections$.value.values()) {
|
||||
if (
|
||||
collection.id !== input.collectionId &&
|
||||
collection.allowList$.value.includes(input.documentId)
|
||||
) {
|
||||
collectionService.removeDocFromCollection(
|
||||
collection.id,
|
||||
input.documentId
|
||||
);
|
||||
}
|
||||
}
|
||||
if (input.collectionId) {
|
||||
collectionService.addDocToCollection(
|
||||
input.collectionId,
|
||||
input.documentId
|
||||
);
|
||||
}
|
||||
|
||||
workspace.engine.doc.addPriority(workspace.id, 100);
|
||||
workspace.engine.doc.addPriority(input.documentId, 100);
|
||||
await Promise.all([
|
||||
workspace.engine.doc.waitForSynced(workspace.id),
|
||||
workspace.engine.doc.waitForSynced(input.documentId),
|
||||
]);
|
||||
return { status: 'imported', docId: input.documentId };
|
||||
} finally {
|
||||
workspaceRef.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async getShareDestinationOptions(
|
||||
workspaceMetadata: WorkspaceMetadata
|
||||
): Promise<ShareDestinationOptions | null> {
|
||||
const verification = await this.revalidateWorkspace(workspaceMetadata);
|
||||
if (verification === 'missing') return null;
|
||||
const currentMetadata = this.workspacesService.list.workspaces$.value.find(
|
||||
workspace =>
|
||||
workspace.id === workspaceMetadata.id &&
|
||||
workspace.flavour === workspaceMetadata.flavour
|
||||
);
|
||||
if (!currentMetadata) return null;
|
||||
|
||||
const workspaceRef = this.workspacesService.open({
|
||||
metadata: currentMetadata,
|
||||
});
|
||||
if (!workspaceRef) return null;
|
||||
try {
|
||||
const { workspace } = workspaceRef;
|
||||
await workspace.engine.doc.waitForDocReady(workspace.id);
|
||||
const rootConfirmed =
|
||||
workspace.meta.flavour === 'local' ||
|
||||
(verification === 'confirmed' &&
|
||||
(await this.waitForRootSync(workspace)));
|
||||
return {
|
||||
verification: rootConfirmed ? 'confirmed' : 'unavailable',
|
||||
tags: workspace.scope
|
||||
.get(TagService)
|
||||
.tagList.tagMetas$.value.map(tag => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
})),
|
||||
collections: workspace.scope
|
||||
.get(CollectionService)
|
||||
.collectionMetas$.value.map(collection => ({
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
})),
|
||||
};
|
||||
} finally {
|
||||
workspaceRef.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private shareMarkdown(input: ShareImportInput) {
|
||||
const parts: string[] = [];
|
||||
if (input.content.kind === 'url') {
|
||||
if (input.content.text) {
|
||||
parts.push(
|
||||
`> ${this.escapeMarkdown(input.content.text).replaceAll('\n', '\n> ')}`
|
||||
);
|
||||
}
|
||||
} else if (input.content.kind === 'image') {
|
||||
if (input.attachmentUrl) {
|
||||
parts.push(``);
|
||||
}
|
||||
if (input.content.text) {
|
||||
parts.push(this.escapeMarkdown(input.content.text));
|
||||
}
|
||||
if (input.content.url) {
|
||||
parts.push(`[Source](<${input.content.url}>)`);
|
||||
}
|
||||
} else if (input.content.text) {
|
||||
parts.push(this.escapeMarkdown(input.content.text));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
private escapeMarkdown(value: string) {
|
||||
return value.replace(/[\\`*_{}[\]()#+\-.!|<>]/g, '\\$&');
|
||||
}
|
||||
|
||||
private async revalidateWorkspace(
|
||||
metadata: WorkspaceMetadata
|
||||
): Promise<WorkspaceVerification> {
|
||||
if (metadata.flavour === 'local') {
|
||||
return this.hasWorkspace(metadata) ? 'confirmed' : 'missing';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
await this.workspacesService.list.waitForRevalidation(controller.signal);
|
||||
if (!this.hasWorkspace(metadata)) return 'missing';
|
||||
|
||||
const provider =
|
||||
this.workspacesService.getWorkspaceFlavourProvider(metadata);
|
||||
if (!provider) return 'unavailable';
|
||||
const profile = await provider.getWorkspaceProfile(
|
||||
metadata.id,
|
||||
controller.signal
|
||||
);
|
||||
return profile ? 'confirmed' : 'missing';
|
||||
} catch {
|
||||
return this.hasWorkspace(metadata) ? 'unavailable' : 'missing';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private hasWorkspace(metadata: WorkspaceMetadata) {
|
||||
return this.workspacesService.list.workspaces$.value.some(
|
||||
workspace =>
|
||||
workspace.id === metadata.id && workspace.flavour === metadata.flavour
|
||||
);
|
||||
}
|
||||
|
||||
private async waitForRootSync(workspace: {
|
||||
id: string;
|
||||
engine: { doc: { waitForSynced(id: string): Promise<unknown> } };
|
||||
}) {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
workspace.engine.doc.waitForSynced(workspace.id),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Sync timed out')), 5000);
|
||||
}),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async importToWorkspace(
|
||||
workspaceMetadata: WorkspaceMetadata,
|
||||
clipperInput: ClipperInput
|
||||
|
||||
@@ -40,7 +40,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
|
||||
}
|
||||
|
||||
profile$ = LiveData.from<WorkspaceProfileInfo | null>(
|
||||
this.cache.watchProfileCache(this.props.metadata.id),
|
||||
this.cache.watchProfileCache(this.props.metadata),
|
||||
null
|
||||
);
|
||||
|
||||
@@ -65,7 +65,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
|
||||
if (isEqual(this.profile$.value, info)) {
|
||||
return;
|
||||
}
|
||||
this.cache.setProfileCache(this.props.metadata.id, info);
|
||||
this.cache.setProfileCache(this.props.metadata, info);
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
|
||||
@@ -3,3 +3,6 @@ export type WorkspaceMetadata = {
|
||||
flavour: string;
|
||||
initialized?: boolean;
|
||||
};
|
||||
|
||||
export const workspaceMetadataKey = (metadata: WorkspaceMetadata) =>
|
||||
`${metadata.flavour}:${metadata.id}`;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ObjectPool, Service } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceProfile } from '../entities/profile';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import { type WorkspaceMetadata, workspaceMetadataKey } from '../metadata';
|
||||
|
||||
export class WorkspaceProfileService extends Service {
|
||||
pool = new ObjectPool<string, WorkspaceProfile>();
|
||||
|
||||
getProfile = (metadata: WorkspaceMetadata): WorkspaceProfile => {
|
||||
const exists = this.pool.get(metadata.id);
|
||||
const key = workspaceMetadataKey(metadata);
|
||||
const exists = this.pool.get(key);
|
||||
if (exists) {
|
||||
return exists.obj;
|
||||
}
|
||||
@@ -16,6 +17,6 @@ export class WorkspaceProfileService extends Service {
|
||||
metadata,
|
||||
});
|
||||
|
||||
return this.pool.put(metadata.id, profile).obj;
|
||||
return this.pool.put(key, profile).obj;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ObjectPool, Service } from '@toeverything/infra';
|
||||
|
||||
import type { Workspace } from '../entities/workspace';
|
||||
import { WorkspaceInitialized } from '../events';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import { workspaceMetadataKey } from '../metadata';
|
||||
import type { WorkspaceOpenOptions } from '../open-options';
|
||||
import { WorkspaceScope } from '../scopes/workspace';
|
||||
import type { WorkspaceFlavoursService } from './flavours';
|
||||
@@ -14,9 +14,6 @@ import { WorkspaceService } from './workspace';
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-repository');
|
||||
|
||||
const getWorkspacePoolKey = (metadata: WorkspaceMetadata) =>
|
||||
`${metadata.flavour}:${metadata.id}`;
|
||||
|
||||
export class WorkspaceRepositoryService extends Service {
|
||||
constructor(
|
||||
private readonly flavoursService: WorkspaceFlavoursService,
|
||||
@@ -62,7 +59,7 @@ export class WorkspaceRepositoryService extends Service {
|
||||
};
|
||||
}
|
||||
|
||||
const exist = this.pool.get(getWorkspacePoolKey(options.metadata));
|
||||
const exist = this.pool.get(workspaceMetadataKey(options.metadata));
|
||||
if (exist) {
|
||||
return {
|
||||
workspace: exist.obj,
|
||||
@@ -72,7 +69,7 @@ export class WorkspaceRepositoryService extends Service {
|
||||
|
||||
const workspace = this.instantiate(options, customEngineWorkerInitOptions);
|
||||
|
||||
const ref = this.pool.put(getWorkspacePoolKey(workspace.meta), workspace);
|
||||
const ref = this.pool.put(workspaceMetadataKey(workspace.meta), workspace);
|
||||
|
||||
return {
|
||||
workspace: ref.obj,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { map } from 'rxjs';
|
||||
|
||||
import type { GlobalCache } from '../../storage';
|
||||
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||
import { type WorkspaceMetadata, workspaceMetadataKey } from '../metadata';
|
||||
|
||||
const WORKSPACE_PROFILE_CACHE_KEY = 'workspace-information:';
|
||||
|
||||
@@ -11,20 +12,25 @@ export class WorkspaceProfileCacheStore extends Store {
|
||||
super();
|
||||
}
|
||||
|
||||
watchProfileCache(workspaceId: string) {
|
||||
return this.cache.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceId).pipe(
|
||||
map(data => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
watchProfileCache(metadata: WorkspaceMetadata) {
|
||||
return this.cache
|
||||
.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceMetadataKey(metadata))
|
||||
.pipe(
|
||||
map(data => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = data as WorkspaceProfileInfo;
|
||||
return info;
|
||||
})
|
||||
const info = data as WorkspaceProfileInfo;
|
||||
return info;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setProfileCache(metadata: WorkspaceMetadata, info: WorkspaceProfileInfo) {
|
||||
this.cache.set(
|
||||
WORKSPACE_PROFILE_CACHE_KEY + workspaceMetadataKey(metadata),
|
||||
info
|
||||
);
|
||||
}
|
||||
|
||||
setProfileCache(workspaceId: string, info: WorkspaceProfileInfo) {
|
||||
this.cache.set(WORKSPACE_PROFILE_CACHE_KEY + workspaceId, info);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user