mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-05 03:25:10 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf06722b7 | |||
| 925c95ce88 | |||
| 3098b3b14b | |||
| dd1cd77ca0 | |||
| d20dbfd6a2 | |||
| 41145961f9 | |||
| 1f2119e273 | |||
| 6e97aff7ba | |||
| 276b0db625 | |||
| bac346f304 | |||
| 9f33d37add | |||
| 3e42bbf4fa | |||
| b5e5f0708a | |||
| f96bf3dd24 | |||
| c53457691d | |||
| 103ad2a810 | |||
| ef4939009f | |||
| 0f5778ac89 | |||
| e9ef3c50c8 | |||
| 661d5d3831 | |||
| 6f55548661 | |||
| c39fa1ff2d | |||
| 3416de1e4d | |||
| d9cebdfc95 | |||
| 97d9ae3183 | |||
| c8cdc488db | |||
| 542da0b347 | |||
| 7280fe33bc | |||
| f626dbd590 | |||
| 419fc5d5e0 | |||
| 1201f7c350 | |||
| 4b4def3a11 | |||
| 2b22fe4692 | |||
| 659072183c | |||
| e222f06e94 | |||
| 322f2ba986 | |||
| f19a922793 | |||
| a1d150a748 | |||
| ac6d0d35af | |||
| 6b720206c6 | |||
| 76d57aa389 | |||
| db0ff0a9df | |||
| 8cf00738c2 | |||
| 417d31cabe | |||
| fcc45a3f44 | |||
| bcbde16c04 | |||
| 32a94d68dc |
@@ -135,17 +135,17 @@
|
||||
},
|
||||
"throttlers.default": {
|
||||
"type": "object",
|
||||
"description": "The config for the default throttler.\n@default {\"ttl\":60,\"limit\":120}",
|
||||
"description": "The config for the default throttler.\n@default {\"ttl\":60000,\"limit\":120}",
|
||||
"default": {
|
||||
"ttl": 60,
|
||||
"ttl": 60000,
|
||||
"limit": 120
|
||||
}
|
||||
},
|
||||
"throttlers.strict": {
|
||||
"type": "object",
|
||||
"description": "The config for the strict throttler.\n@default {\"ttl\":60,\"limit\":20}",
|
||||
"description": "The config for the strict throttler.\n@default {\"ttl\":60000,\"limit\":20}",
|
||||
"default": {
|
||||
"ttl": 60,
|
||||
"ttl": 60000,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
@@ -300,6 +300,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"description": "Configuration for permission module",
|
||||
"properties": {
|
||||
"readModel": {
|
||||
"type": "string",
|
||||
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
|
||||
"default": "projection"
|
||||
},
|
||||
"fallbackLegacyLoader": {
|
||||
"type": "boolean",
|
||||
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"storages": {
|
||||
"type": "object",
|
||||
"description": "Configuration for storages module",
|
||||
|
||||
Generated
+278
-17
@@ -22,6 +22,16 @@ dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
@@ -33,6 +43,20 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "affine_common"
|
||||
version = "0.1.0"
|
||||
@@ -119,7 +143,6 @@ dependencies = [
|
||||
"mermaid-rs-renderer",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"typst",
|
||||
@@ -189,11 +212,13 @@ dependencies = [
|
||||
name = "affine_server_native"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"affine_common",
|
||||
"anyhow",
|
||||
"base64-simd",
|
||||
"chrono",
|
||||
"file-format",
|
||||
"hex",
|
||||
"image",
|
||||
"infer",
|
||||
"jsonschema",
|
||||
@@ -207,15 +232,21 @@ dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"p256",
|
||||
"rand 0.9.4",
|
||||
"rayon",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha3",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"url",
|
||||
"v_htmlescape",
|
||||
"webpki-roots",
|
||||
"y-octo",
|
||||
]
|
||||
|
||||
@@ -574,6 +605,12 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7"
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -1535,6 +1572,18 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -1542,6 +1591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1582,6 +1632,15 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dary_heap"
|
||||
version = "0.3.8"
|
||||
@@ -1804,6 +1863,20 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecow"
|
||||
version = "0.2.6"
|
||||
@@ -1822,6 +1895,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
@@ -2011,6 +2104,16 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-format"
|
||||
version = "0.28.0"
|
||||
@@ -2322,6 +2425,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2373,6 +2477,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.1"
|
||||
@@ -2406,6 +2520,17 @@ dependencies = [
|
||||
"scroll",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -3623,9 +3748,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "llm_adapter"
|
||||
version = "0.2.5"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e139f0a1609d6078293140fb7e281cf2bd5a45a7a29ef39f8606c803be7822"
|
||||
checksum = "332397a6ccde5ac47fc32b29a2eed447135eb4ff6fd05ffb88dfe937ea9c8211"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"jsonschema",
|
||||
@@ -3719,6 +3844,12 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -4344,6 +4475,12 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -4383,6 +4520,18 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.6"
|
||||
@@ -4744,6 +4893,18 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pom"
|
||||
version = "1.1.0"
|
||||
@@ -4828,6 +4989,15 @@ dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -4978,6 +5148,62 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -5232,9 +5458,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -5252,6 +5478,7 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
@@ -5269,6 +5496,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -5459,6 +5696,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -5633,6 +5871,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -5974,7 +6226,6 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -5984,7 +6235,6 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7749,6 +7999,16 @@ dependencies = [
|
||||
"weedle2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
@@ -7787,7 +8047,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots 1.0.6",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8130,6 +8390,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
@@ -8139,15 +8409,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
|
||||
+4
-1
@@ -16,6 +16,7 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
affine_common = { path = "./packages/common/native" }
|
||||
affine_nbstore = { path = "./packages/frontend/native/nbstore" }
|
||||
ahash = "0.8"
|
||||
@@ -39,6 +40,7 @@ resolver = "3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser", rev = "380beea" }
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
hex = "0.4"
|
||||
homedir = "0.3"
|
||||
image = { version = "0.25.9", default-features = false, features = [
|
||||
"bmp",
|
||||
@@ -80,6 +82,7 @@ resolver = "3"
|
||||
ogg = "0.9"
|
||||
once_cell = "1"
|
||||
ordered-float = "5"
|
||||
p256 = { version = "0.13", features = ["ecdsa", "pem"] }
|
||||
parking_lot = "0.12"
|
||||
path-ext = "0.1.2"
|
||||
pdf-extract = { git = "https://github.com/toeverything/pdf-extract", branch = "darksky/improve-font-decoding" }
|
||||
@@ -98,6 +101,7 @@ resolver = "3"
|
||||
screencapturekit = "0.3"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
sha3 = "0.10"
|
||||
smol_str = "0.3"
|
||||
sqlx = { version = "0.8", default-features = false, features = [
|
||||
@@ -106,7 +110,6 @@ resolver = "3"
|
||||
"migrate",
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"tls-rustls",
|
||||
] }
|
||||
strum_macros = "0.27.0"
|
||||
symphonia = { version = "0.5", features = ["all", "opt-simd"] }
|
||||
|
||||
@@ -1,170 +1,225 @@
|
||||
# AFFiNE
|
||||
<div align="center">
|
||||
|
||||
<h1 style="border-bottom: none">
|
||||
<b><a href="https://affine.pro">AFFiNE.Pro</a></b><br />
|
||||
Write, Draw and Plan All at Once
|
||||
<br>
|
||||
</h1>
|
||||
<a href="https://affine.pro/download">
|
||||
<img alt="affine logo" src="https://cdn.affine.pro/Github_hero_image2.png" style="width: 100%">
|
||||
</a>
|
||||
<br/>
|
||||
<p align="center">
|
||||
A privacy-focused, local-first, open-source, and ready-to-use alternative for Notion & Miro. <br />
|
||||
One hyper-fused platform for wildly creative minds.
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<br/>
|
||||
<a href="https://www.producthunt.com/posts/affine-3?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-affine-3" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=440671&theme=light" alt="AFFiNE - One app for all - Where Notion meets Miro | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<p><strong>The open-source, multimodal AI knowledge base for individuals and teams.</strong></p>
|
||||
<a href="https://affine.pro">Home Page</a> |
|
||||
<a href="https://affine.pro/redirect/discord">Discord</a> |
|
||||
<a href="https://app.affine.pro">Live Demo</a> |
|
||||
<a href="https://affine.pro/blog/">Blog</a> |
|
||||
<a href="https://docs.affine.pro/">Documentation</a>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
<a href="https://affine.pro/download">
|
||||
<img alt="AFFiNE — open-source, multimodal AI knowledge base" src="https://cdn.affine.pro/Github_hero_image2.png" style="width: 100%">
|
||||
</a>
|
||||
[](https://github.com/toeverything/AFFiNE/releases/latest)
|
||||
[![All Contributors][all-contributors-badge]](#contributors)
|
||||
[![TypeScript-version-icon]](https://www.typescriptlang.org/)
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/toeverything/AFFiNE/releases/latest"><img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/toeverything/AFFiNE/total?style=flat&color=brightgreen"></a>
|
||||
<a href="https://github.com/toeverything/AFFiNE/graphs/contributors"><img alt="Contributors" src="https://img.shields.io/github/contributors/toeverything/AFFiNE?style=flat"></a>
|
||||
<a href="./LICENSE"><img alt="License: MIT + EE" src="https://img.shields.io/badge/license-MIT%20%2B%20EE-blue?style=flat"></a>
|
||||
<a href="https://github.com/sponsors/toeverything"><img alt="Sponsor AFFiNE" src="https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ea4aaa?style=flat"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://app.affine.pro"><strong>Try AFFiNE</strong></a> ·
|
||||
<a href="https://docs.affine.pro/self-host-affine"><strong>Self-host</strong></a> ·
|
||||
<a href="https://github.com/toeverything/AFFiNE"><strong>Star on GitHub</strong></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://affine.pro/download">Download</a> ·
|
||||
<a href="https://docs.affine.pro">Docs</a> ·
|
||||
<a href="https://affine.pro/redirect/discord">Discord</a> ·
|
||||
<a href="https://github.com/toeverything/AFFiNE/discussions">Discussions</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
> **TL;DR.** Docs, whiteboards, databases, files, collaboration, and AI context in one local-first workspace — synced across Web, Windows, macOS, Linux, Android, and iOS; self-hostable; BYOK Beta for eligible workspaces; and coming-soon programmable workflows for Claude Code and other agentic tools.
|
||||
<br />
|
||||
<div align="center">
|
||||
<em>Docs, canvas and tables are hyper-merged with AFFiNE - just like the word affine (əˈfʌɪn | a-fine).</em>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
## Choose your path
|
||||
<div align="center">
|
||||
<img src="https://github.com/toeverything/AFFiNE/assets/79301703/49a426bb-8d2b-4216-891a-fa5993642253" style="width: 100%"/>
|
||||
</div>
|
||||
|
||||
- Want a multimodal AI knowledge base? Try AFFiNE.
|
||||
- Want an open-source Notion + Miro alternative? Use AFFiNE's docs, canvas, and databases together.
|
||||
- Want private AI workflows? Use AFFiNE AI with BYOK Beta or self-host AFFiNE.
|
||||
- Using Claude Code or AI agents? Track the coming-soon programmable knowledge workflows.
|
||||
- Building collaborative editors? Explore BlockSuite and y-octo.
|
||||
- Evaluating for a team? Start with Cloud, then choose self-host or enterprise controls.
|
||||
## Getting started & staying tuned with us.
|
||||
|
||||
## What is AFFiNE?
|
||||
Star us, and you will receive all release notifications from GitHub without any delay!
|
||||
|
||||
AFFiNE is an open-source, multimodal AI knowledge base for individuals and teams. It combines documents, whiteboards, databases, files, tasks, collaboration, and AI context in one block-based workspace. It syncs across Web, Windows, macOS, Linux, Android, and iOS, while giving users the flexibility of canvas thinking, the structure of documents and databases, and the control of local-first and self-hostable knowledge infrastructure.
|
||||
<img src="https://user-images.githubusercontent.com/79301703/230891830-0110681e-8c7e-483b-b6d9-9e42b291b9ef.gif" style="width: 100%"/>
|
||||
|
||||
## Why AFFiNE is different
|
||||
## What is AFFiNE
|
||||
|
||||
- Docs, whiteboards, databases, and files share the same block-based workspace.
|
||||
- Multimodal AI workflows can use supported workspace context across docs, canvas, attachments, files, and structured knowledge where available.
|
||||
- Local-first design keeps your workspace usable and synced across Web, Windows, macOS, Linux, Android, and iOS.
|
||||
- Self-hosting is a first-class deployment path, not an afterthought.
|
||||
- Bring-Your-Own-Key (Beta) gives eligible workspaces more control over AI provider choice, cost, and policy.
|
||||
[AFFiNE](https://affine.pro) is an open-source, all-in-one workspace and an operating system for all the building blocks that assemble your knowledge base and much more -- wiki, knowledge management, presentation and digital assets. It's a better alternative to Notion and Miro.
|
||||
|
||||
## Key features
|
||||
## Features
|
||||
|
||||
- Docs, whiteboards, databases, and files in one workspace.
|
||||
- Multimodal AI workspace context across docs, canvas, images, attachments, databases, and structured knowledge where supported.
|
||||
- **Bring Your Own Key (Beta).** Route AFFiNE AI through your own provider keys for eligible workspaces, with supported OpenAI, Anthropic, Gemini, and FAL provider routes where configured.
|
||||
- Local-first storage and real-time collaboration.
|
||||
- Cross-platform sync: Web, Windows, macOS, Linux, Android, and iOS.
|
||||
- Self-hosting and private deployment.
|
||||
- Coming-soon programmable knowledge workflows for Claude Code and agentic tools.
|
||||
- Import/export and knowledge portability.
|
||||
**A true canvas for blocks in any form. Docs and whiteboard are now fully merged.**
|
||||
|
||||
## Why developers care
|
||||
- Many editor apps claim to be a canvas for productivity, but AFFiNE is one of the very few which allows you to put any building block on an edgeless canvas -- rich text, sticky notes, any embedded web pages, multi-view databases, linked pages, shapes and even slides. We have it all.
|
||||
|
||||
- Open-source monorepo with a first-class self-hosting path.
|
||||
- Local-first storage with both browser (IndexedDB) and native (SQLite) clients via [`nbstore`](./packages/common/nbstore).
|
||||
- Block-based editor foundation via [`BlockSuite`](./blocksuite).
|
||||
- CRDT-based real-time collaboration on top of Yjs and [`y-octo`](./packages/common/y-octo).
|
||||
- NestJS + GraphQL + Prisma backend powering sync, cloud, self-hosting, and AI copilot.
|
||||
- Cross-platform engineering and sync: Web, Electron desktop for Windows/macOS/Linux, plus Android and iOS clients.
|
||||
- Workspace-aware AI plumbing with BYOK Beta, designed to extend toward programmable, agent-operable knowledge workflows.
|
||||
**Multimodal AI partner ready to kick in any work**
|
||||
|
||||
## Coming soon: Claude Code-ready programmable knowledge workflows
|
||||
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or... draw and code prototype apps and web pages directly all with one prompt? With you, [AFFiNE AI](https://affine.pro/ai) pushes your creativity to the edge of your imagination, just like [Canvas AI](https://affine.pro/blog/best-canvas-ai) to generate mind map for brainstorming.
|
||||
|
||||
**Coming soon: Claude Code-ready programmable knowledge workflows.** We are making AFFiNE operable from terminal scripts and agentic coding tools such as Claude Code. The upcoming CLI-like mode is designed to let AI agents read, search, create, update, import, export, and organize your AFFiNE knowledge base from your computer — turning AFFiNE into a programmable, multimodal knowledge layer for personal and team workflows.
|
||||
**Local-first & Real-time collaborative**
|
||||
|
||||
This is an actively building priority roadmap capability, not a shipped CLI feature yet. We do not publish commands here until they are available and verified.
|
||||
- We love the idea of local-first that you always own your data on your disk, in spite of the cloud. Furthermore, AFFiNE supports real-time sync and collaborations on web and cross-platform clients.
|
||||
|
||||
## Run AFFiNE your way
|
||||
**Self-host & Shape your own AFFiNE**
|
||||
|
||||
- **Cloud** — Fastest way to start. Best for individuals and teams that want zero setup, automatic updates, and managed AFFiNE AI. → [app.affine.pro](https://app.affine.pro)
|
||||
- **Desktop & Mobile** — Local-first daily workspace synced across Web, Windows, macOS, Linux, Android, and iOS. → [affine.pro/download](https://affine.pro/download)
|
||||
- **Self-host** — Own your data and run AFFiNE in your infrastructure, with BYOK Beta for eligible self-hosted AI workflows where supported. → [docs.affine.pro/self-host-affine](https://docs.affine.pro/self-host-affine)
|
||||
- **Team & Enterprise** — Admin, policy, security, and support, with workspace-level BYOK on eligible plans and priority-roadmap programmable workflows for agentic tools. → [affine.pro/pricing](https://affine.pro/pricing)
|
||||
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks are coming soon. More tractions on [Blocksuite](https://blocksuite.io). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/self-host-affine).
|
||||
|
||||
## Get started
|
||||
## Acknowledgement
|
||||
|
||||
- **Try AFFiNE online:** [app.affine.pro](https://app.affine.pro)
|
||||
- **Download apps:** [affine.pro/download](https://affine.pro/download)
|
||||
- **Self-host with Docker:** [Self-host AFFiNE](https://docs.affine.pro/self-host-affine)
|
||||
- **Build from source:** [docs/BUILDING.md](./docs/BUILDING.md)
|
||||
- **Join the community:** [Discord](https://affine.pro/redirect/discord) or [GitHub Discussions](https://github.com/toeverything/AFFiNE/discussions)
|
||||
“We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us along the way, e.g.:
|
||||
|
||||
## Self-hosting
|
||||
- Quip & Notion with their great concept of “everything is a block”
|
||||
- Trello with their Kanban
|
||||
- Airtable & Miro with their no-code programmable datasheets
|
||||
- Miro & Whimiscal with their edgeless visual whiteboard
|
||||
- Remote & Capacities with their object-based tag system
|
||||
|
||||
Want full control? Start with the official Docker-based self-hosting guide. The self-host stack uses the AFFiNE server image, Postgres/pgvector, Redis, and a migration job.
|
||||
There is a large overlap of their atomic “building blocks” between these apps. They are not open source, nor do they have a plugin system like Vscode for contributors to customize. We want to have something that contains all the features we love and also goes one step even further.
|
||||
|
||||
- Read the official guide: [Self-host AFFiNE](https://docs.affine.pro/self-host-affine)
|
||||
- Inspect the Docker Compose stack: [`.docker/selfhost/compose.yml`](./.docker/selfhost/compose.yml)
|
||||
- Review licensing before production deployment: [LICENSE](./LICENSE) and [packages/backend/server/LICENSE](./packages/backend/server/LICENSE)
|
||||
Thanks for checking us out, we appreciate your interest and sincerely hope that AFFiNE resonates with you! 🎵 Checking https://affine.pro/ for more details ions.
|
||||
|
||||
## Contributing
|
||||
|
||||
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE's Discord](https://affine.pro/redirect/discord) |
|
||||
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
||||
|
||||
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
||||
|
||||
**Before you start contributing, please make sure you have read and accepted our [Contributor License Agreement]. To indicate your agreement, simply edit this file and submit a pull request.**
|
||||
|
||||
For **bug reports**, **feature requests** and other **suggestions** you can also [create a new issue](https://github.com/toeverything/AFFiNE/issues/new/choose) and choose the most appropriate template for your feedback.
|
||||
|
||||
For **translation** and **language support** you can visit our [Discord](https://affine.pro/redirect/discord).
|
||||
|
||||
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [Discord](https://affine.pro/redirect/discord) where you can engage with other like-minded individuals.
|
||||
|
||||
## Templates
|
||||
|
||||
AFFiNE now provides pre-built [templates](https://affine.pro/templates) from our team. Following are the Top 10 most popular templates among AFFiNE users,if you want to contribute, you can contribute your own template so other people can use it too.
|
||||
|
||||
- [vision board template](https://affine.pro/templates/category-vision-board-template)
|
||||
- [one pager template](https://affine.pro/templates/category-one-pager-template-free)
|
||||
- [sample lesson plan math template](https://affine.pro/templates/sample-lesson-plan-math-template)
|
||||
- [grr lesson plan template free](https://affine.pro/templates/grr-lesson-plan-template-free)
|
||||
- [free editable lesson plan template for pre k](https://affine.pro/templates/free-editable-lesson-plan-template-for-pre-k)
|
||||
- [high note collection planners](https://affine.pro/templates/high-note-collection-planners)
|
||||
- [digital planner](https://affine.pro/templates/category-digital-planner)
|
||||
- [ADHD Planner](https://affine.pro/templates/adhd-planner)
|
||||
- [Reading Log](https://affine.pro/templates/reading-log)
|
||||
- [Cornell Notes Template](https://affine.pro/templates/category-cornell-notes-template)
|
||||
|
||||
## Blog
|
||||
|
||||
Welcome to the AFFiNE blog section! Here, you’ll find the latest insights, tips, and guides on how to maximize your experience with AFFiNE and AFFiNE AI, the leading Canvas AI tool for flexible note-taking and creative organization.
|
||||
|
||||
- [vision board template](https://affine.pro/blog/8-free-printable-vision-board-templates-examples-2023)
|
||||
- [ai homework helper](https://affine.pro/blog/ai-homework-helper)
|
||||
- [vision board maker](https://affine.pro/blog/vision-board-maker)
|
||||
- [itinerary template](https://affine.pro/blog/free-customized-travel-itinerary-planner-templates)
|
||||
- [one pager template](https://affine.pro/blog/top-12-one-pager-examples-how-to-create-your-own)
|
||||
- [cornell notes template](https://affine.pro/blog/the-cornell-notes-template-and-system-learning-tips)
|
||||
- [swot chart template](https://affine.pro/blog/top-10-free-editable-swot-analysis-template-examples)
|
||||
- [apps like luna task](https://affine.pro/blog/apps-like-luna-task)
|
||||
- [note taking ai from rough notes to mind map](https://affine.pro/blog/dynamic-AI-notes)
|
||||
- [canvas ai](https://affine.pro/blog/best-canvas-ai)
|
||||
- [one pager](https://affine.pro/blog/top-12-one-pager-examples-how-to-create-your-own)
|
||||
- [SOP Template](https://affine.pro/blog/how-to-write-sop-step-by-step-guide-5-best-free-tools-templates)
|
||||
- [Chore Chart](https://affine.pro/blog/10-best-free-chore-chart-templates-kids-adults)
|
||||
|
||||
## Ecosystem
|
||||
|
||||
| Name | | |
|
||||
| ------------------------------------------------ | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [@affine/component](packages/frontend/component) | AFFiNE Component Resources |  |
|
||||
| [@toeverything/theme](packages/common/theme) | AFFiNE theme | [](https://www.npmjs.com/package/@toeverything/theme) |
|
||||
|
||||
## Upstreams
|
||||
|
||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||
|
||||
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
|
||||
- [y-octo](https://github.com/y-crdt/y-octo) - 🐙 y-octo is a native, high-performance, thread-safe YJS CRDT implementation, serving as the core engine enabling the AFFiNE Client/Server to achieve "local-first" functionality.
|
||||
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
|
||||
|
||||
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync on web.
|
||||
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
|
||||
- [React](https://github.com/facebook/react) - The library for web and native user interfaces.
|
||||
- [napi-rs](https://github.com/napi-rs/napi-rs) - A framework for building compiled Node.js add-ons in Rust via Node-API.
|
||||
- [Jotai](https://github.com/pmndrs/jotai) - Primitive and flexible state management for React.
|
||||
- [async-call-rpc](https://github.com/Jack-Works/async-call-rpc) - A lightweight JSON RPC client & server.
|
||||
- [Vite](https://github.com/vitejs/vite) - Next generation frontend tooling.
|
||||
- Other upstream [dependencies](https://github.com/toeverything/AFFiNE/network/dependencies).
|
||||
|
||||
Thanks a lot to the community for providing such powerful and simple libraries, so that we can focus more on the implementation of the product logic, and we hope that in the future our projects will also provide a more easy-to-use knowledge base for everyone.
|
||||
|
||||
## Contributors
|
||||
|
||||
We would like to express our gratitude to all the individuals who have already contributed to AFFiNE! If you have any AFFiNE-related project, documentation, tool or template, please feel free to contribute it by submitting a pull request to our curated list on GitHub: [awesome-affine](https://github.com/toeverything/awesome-affine).
|
||||
|
||||
<a href="https://github.com/toeverything/affine/graphs/contributors">
|
||||
<img alt="contributors" src="https://opencollective.com/affine/contributors.svg?width=890&button=false" />
|
||||
</a>
|
||||
|
||||
## Self-Host
|
||||
|
||||
Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiNE. Our team is diligently updating to the latest version. For more information on how to self-host AFFiNE, please refer to our [documentation](https://docs.affine.pro/self-host-affine).
|
||||
|
||||
[](https://sealos.io/products/app-store/affine)
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Daffine)
|
||||
|
||||
## Development
|
||||
## Feature Request
|
||||
|
||||
Prerequisites: Node.js, Yarn 4, and Rust.
|
||||
For feature requests, please see [discussions](https://github.com/toeverything/AFFiNE/discussions/categories/ideas).
|
||||
|
||||
- Build from source: [docs/BUILDING.md](./docs/BUILDING.md)
|
||||
- Desktop build: [docs/building-desktop-client-app.md](./docs/building-desktop-client-app.md)
|
||||
- Server development: [docs/developing-server.md](./docs/developing-server.md)
|
||||
- Monorepo CLI for contributors: [tools/cli/README.md](./tools/cli/README.md)
|
||||
## Building
|
||||
|
||||
### Open in GitHub Codespaces
|
||||
### Codespaces
|
||||
|
||||
Click the green **Code** button on the GitHub repo main page and select **Create codespace on canary**. This will open a new Codespace with the AFFiNE monorepo cloned and ready to go.
|
||||
From the GitHub repo main page, click the green "Code" button and select "Create codespace on master". This will open a new Codespace with the (supposedly auto-forked
|
||||
AFFiNE repo cloned, built, and ready to go).
|
||||
|
||||
## Contributing, community, and security
|
||||
### Local
|
||||
|
||||
We welcome contributions from developers, testers, designers, technical writers, template creators, and community members.
|
||||
See [BUILDING.md] for instructions on how to build AFFiNE from source code.
|
||||
|
||||
- Bug reports: [create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE)
|
||||
- Feature requests and product ideas: [GitHub Discussions](https://github.com/toeverything/AFFiNE/discussions)
|
||||
- Code contributions: [docs/CONTRIBUTING.md](./docs/CONTRIBUTING.md)
|
||||
- Contribution types: [docs/types-of-contributions.md](./docs/types-of-contributions.md)
|
||||
- Code of Conduct: [docs/CODE_OF_CONDUCT.md](./docs/CODE_OF_CONDUCT.md)
|
||||
- Contributor License Agreement: [.github/CLA.md](./.github/CLA.md)
|
||||
- Security: [SECURITY.md](./SECURITY.md)
|
||||
- Sponsor AFFiNE: [GitHub Sponsors](https://github.com/sponsors/toeverything)
|
||||
## Contributing
|
||||
|
||||
Translations are welcome. Join [Discord](https://affine.pro/redirect/discord) or open a discussion if you want to help localize AFFiNE.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://docs.affine.pro)
|
||||
- [Templates](https://affine.pro/templates)
|
||||
- [Blog](https://affine.pro/blog)
|
||||
- [Roadmap & Discussions](https://github.com/toeverything/AFFiNE/discussions)
|
||||
- [Awesome AFFiNE](https://github.com/toeverything/awesome-affine)
|
||||
We welcome contributions from everyone.
|
||||
See [docs/contributing/tutorial.md](./docs/contributing/tutorial.md) for details.
|
||||
|
||||
## License
|
||||
|
||||
AFFiNE uses mixed licensing. Most source code outside `packages/backend` and `packages/common/native` is MIT-licensed; backend-related code is governed by the AFFiNE EE License. Please review [LICENSE](./LICENSE) and [packages/backend/server/LICENSE](./packages/backend/server/LICENSE) before production self-host deployment.
|
||||
### Editions
|
||||
|
||||
## Upstreams
|
||||
- AFFiNE Community Edition (CE) is the current available version, it's free for self-host under the MIT license.
|
||||
|
||||
We would also like to thank the open-source projects that make AFFiNE possible:
|
||||
- AFFiNE Enterprise Edition (EE) is yet to be published, it will have more advanced features and enterprise-oriented offerings, including but not exclusive to rebranding and SSO, advanced admin and audit, etc., you may refer to https://affine.pro/pricing for more information
|
||||
|
||||
- [BlockSuite](https://github.com/toeverything/BlockSuite) — the open-source collaborative editor project behind AFFiNE.
|
||||
- [y-octo](https://github.com/y-crdt/y-octo) — a native, high-performance, thread-safe Yjs CRDT implementation.
|
||||
- [OctoBase](https://github.com/toeverything/OctoBase) — a local-first collaborative data engine written in Rust.
|
||||
- [Yjs](https://github.com/yjs/yjs) — CRDT support for state management and data sync on the web.
|
||||
- [Electron](https://github.com/electron/electron) — cross-platform desktop apps with JavaScript, HTML, and CSS.
|
||||
See [LICENSE] for details.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
> "We shape our tools and thereafter our tools shape us."
|
||||
|
||||
AFFiNE stands on the shoulders of pioneers like Notion, Miro, Whimsical, Airtable, Trello, Quip, and many others — projects that taught us what blocks, canvases, and structured knowledge can be. Thanks for checking us out; we appreciate your interest. See [Upstreams](#upstreams) and [Contributors](#contributors) for the full list of projects and people behind AFFiNE.
|
||||
|
||||
## Contributors
|
||||
|
||||
We would like to express our gratitude to everyone who has contributed to AFFiNE. If you have an AFFiNE-related project, documentation, tool, or template, please share it with the community through [awesome-affine](https://github.com/toeverything/awesome-affine).
|
||||
|
||||
<a href="https://github.com/toeverything/AFFiNE/graphs/contributors">
|
||||
<img alt="contributors" src="https://opencollective.com/affine/contributors.svg?width=890&button=false" />
|
||||
</a>
|
||||
[all-contributors-badge]: https://img.shields.io/github/contributors/toeverything/AFFiNE
|
||||
[license]: ./LICENSE
|
||||
[building.md]: ./docs/BUILDING.md
|
||||
[update page]: https://affine.pro/blog?tag=Release%20Note
|
||||
[jobs available]: ./docs/jobs.md
|
||||
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
|
||||
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
|
||||
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
|
||||
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
|
||||
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success
|
||||
[typescript-version-icon]: https://img.shields.io/github/package-json/dependency-version/toeverything/affine/dev/typescript
|
||||
[react-version-icon]: https://img.shields.io/github/package-json/dependency-version/toeverything/AFFiNE/react?filename=packages%2Ffrontend%2Fcore%2Fpackage.json&color=rgb(97%2C228%2C251)
|
||||
[blocksuite-icon]: https://img.shields.io/github/package-json/dependency-version/toeverything/AFFiNE/@blocksuite/store?color=6880ff&filename=packages%2Ffrontend%2Fcore%2Fpackage.json&label=blocksuite
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { bilibiliConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/bilibili.js';
|
||||
import { excalidrawConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/excalidraw.js';
|
||||
import { genericConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/generic.js';
|
||||
import { googleDocsConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-docs.js';
|
||||
import { googleDriveConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-drive.js';
|
||||
import { miroConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/miro.js';
|
||||
import { spotifyConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/spotify.js';
|
||||
|
||||
describe('embed iframe provider config', () => {
|
||||
test('validates final iframe URLs from oEmbed providers', () => {
|
||||
expect(
|
||||
spotifyConfig.validateIframeUrl?.(
|
||||
'https://open.spotify.com/embed/track/0TK2YIli7K1leLovkQiNik'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
spotifyConfig.validateIframeUrl?.(
|
||||
'https://example.com/embed/track/0TK2YIli7K1leLovkQiNik'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('validates provider-specific iframe URL shapes', () => {
|
||||
expect(
|
||||
googleDriveConfig.validateIframeUrl?.(
|
||||
'https://drive.google.com/file/d/file-id/preview?usp=embed_googleplus'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
googleDriveConfig.validateIframeUrl?.(
|
||||
'https://drive.google.com/drive/folders/folder-id?usp=sharing'
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
bilibiliConfig.validateIframeUrl?.(
|
||||
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
bilibiliConfig.match(
|
||||
'https://player.bilibili.com/player.html?aid=123&autoplay=0'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
bilibiliConfig.buildOEmbedUrl(
|
||||
'https://player.bilibili.com/video/BV1xx411c7mD'
|
||||
)
|
||||
).toBe(
|
||||
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
|
||||
);
|
||||
expect(
|
||||
bilibiliConfig.validateIframeUrl?.(
|
||||
'https://www.bilibili.com/video/BV1xx411c7mD'
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
googleDocsConfig.validateIframeUrl?.(
|
||||
'https://docs.google.com/document/d/doc-id/edit?usp=sharing'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
miroConfig.validateIframeUrl?.(
|
||||
'https://miro.com/app/live-embed/board-id/'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
excalidrawConfig.validateIframeUrl?.('https://excalidraw.com/#room-id')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('generic iframe validation excludes affine and non-https URLs', () => {
|
||||
expect(genericConfig.validateIframeUrl?.('https://example.com/embed')).toBe(
|
||||
true
|
||||
);
|
||||
expect(genericConfig.validateIframeUrl?.('http://example.com/embed')).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
genericConfig.validateIframeUrl?.('https://app.affine.pro/embed')
|
||||
).toBe(false);
|
||||
expect(genericConfig.validateIframeUrl?.('https://127.0.0.1/embed')).toBe(
|
||||
false
|
||||
);
|
||||
expect(genericConfig.validateIframeUrl?.('https://localhost/embed')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
|
||||
dataSource: this.dataSource,
|
||||
headerWidget: this.headerWidget,
|
||||
clipboard: this.std.clipboard,
|
||||
dnd: this.std.dnd,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
DatabaseKanbanViewIcon,
|
||||
DatabaseTableViewIcon,
|
||||
TodayIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
|
||||
import { insertDatabaseBlockCommand } from '../commands';
|
||||
@@ -47,6 +48,35 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Calendar View',
|
||||
description: 'Display items by date in a calendar.',
|
||||
searchAlias: ['database', 'calendar'],
|
||||
icon: TodayIcon(),
|
||||
group: '7_Database@1',
|
||||
when: ({ model }) =>
|
||||
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertDatabaseBlockCommand, {
|
||||
viewType: viewPresets.calendarViewMeta.type,
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedDatabaseBlockId }) => {
|
||||
if (insertedDatabaseBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:database',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Kanban View',
|
||||
description: 'Visualize data in a dashboard.',
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type SingleView,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { CalendarExternalSourceProvider } from '@blocksuite/data-view/view-presets';
|
||||
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { Rect } from '@blocksuite/global/gfx';
|
||||
@@ -150,6 +151,14 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
config
|
||||
);
|
||||
});
|
||||
this.std.provider
|
||||
.getAll(CalendarExternalSourceProvider)
|
||||
.forEach(source => {
|
||||
dataSource.serviceSet(
|
||||
CalendarExternalSourceProvider(source.id),
|
||||
source
|
||||
);
|
||||
});
|
||||
});
|
||||
const id = currentViewStorage.getCurrentView(this.model.id);
|
||||
if (id && dataSource.viewManager.viewGet(id)) {
|
||||
@@ -293,6 +302,12 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
calendar: [
|
||||
widgetPresets.tools.filter,
|
||||
widgetPresets.tools.search,
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
});
|
||||
|
||||
private readonly viewSelection$ = computed(() => {
|
||||
@@ -427,6 +442,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
headerWidget: this.headerWidget,
|
||||
onDrag: this.onDrag,
|
||||
clipboard: this.std.clipboard,
|
||||
dnd: this.std.dnd,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
export const databaseBlockViews: ViewMeta[] = [
|
||||
viewPresets.tableViewMeta,
|
||||
viewPresets.kanbanViewMeta,
|
||||
viewPresets.calendarViewMeta,
|
||||
];
|
||||
|
||||
export const databaseBlockViewMap = Object.fromEntries(
|
||||
|
||||
+22
-4
@@ -35,7 +35,7 @@ const extractBvid = (url: string) => {
|
||||
|
||||
const buildBiliPlayerEmbedUrl = (url: string) => {
|
||||
// If the user pasted the embed URL directly, keep it
|
||||
if (validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
|
||||
if (isValidBiliPlayerUrl(url)) {
|
||||
return url;
|
||||
}
|
||||
const avid = extractAvid(url);
|
||||
@@ -57,13 +57,31 @@ const buildBiliPlayerEmbedUrl = (url: string) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const bilibiliConfig = {
|
||||
function isValidBiliPlayerUrl(url: string) {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
return (
|
||||
parsedUrl.pathname === '/player.html' &&
|
||||
(!!parsedUrl.searchParams.get('aid') ||
|
||||
!!parsedUrl.searchParams.get('bvid'))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const bilibiliConfig = {
|
||||
name: 'bilibili',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
|
||||
(!!extractAvid(url) || !!extractBvid(url)),
|
||||
isValidBiliPlayerUrl(url) ||
|
||||
(validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
|
||||
(!!extractAvid(url) || !!extractBvid(url))),
|
||||
buildOEmbedUrl: buildBiliPlayerEmbedUrl,
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidBiliPlayerUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: BILIBILI_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: BILIBILI_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+3
-1
@@ -15,7 +15,7 @@ const excalidrawUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['excalidraw.com'],
|
||||
};
|
||||
|
||||
const excalidrawConfig = {
|
||||
export const excalidrawConfig = {
|
||||
name: 'excalidraw',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, excalidrawUrlValidationOptions),
|
||||
@@ -27,6 +27,8 @@ const excalidrawConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
validateEmbedIframeUrl(iframeUrl, excalidrawUrlValidationOptions),
|
||||
options: {
|
||||
widthInSurface: EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GENERIC_DEFAULT_WIDTH_PERCENT = 100;
|
||||
@@ -17,6 +22,11 @@ const AFFINE_DOMAINS = [
|
||||
'apple.getaffineapp.com', // Cloud domain for Apple app
|
||||
];
|
||||
|
||||
const genericUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a URL is suitable for generic iframe embedding
|
||||
* Allows HTTPS URLs but excludes AFFiNE domains
|
||||
@@ -27,8 +37,12 @@ function isValidGenericEmbedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Only allow HTTPS for security
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
if (
|
||||
!validateEmbedIframeUrl(url, {
|
||||
...genericUrlValidationOptions,
|
||||
hostnames: [parsedUrl.hostname],
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -49,7 +63,7 @@ function isValidGenericEmbedUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const genericConfig = {
|
||||
export const genericConfig = {
|
||||
name: 'generic',
|
||||
match: (url: string) => isValidGenericEmbedUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -59,6 +73,7 @@ const genericConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGenericEmbedUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+2
-1
@@ -57,7 +57,7 @@ function isValidGoogleDocsUrl(url: string, strictMode = true): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const googleDocsConfig = {
|
||||
export const googleDocsConfig = {
|
||||
name: 'google-docs',
|
||||
match: (url: string) => isValidGoogleDocsUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -67,6 +67,7 @@ const googleDocsConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGoogleDocsUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+26
-1
@@ -113,6 +113,29 @@ function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidGoogleDriveIframeUrl(url: string): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return pathSegments[3] === 'preview';
|
||||
}
|
||||
|
||||
return (
|
||||
parsedUrl.pathname === '/embeddedfolderview' &&
|
||||
!!parsedUrl.searchParams.get('id')
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Invalid Google Drive iframe URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive files
|
||||
* @param fileId File ID
|
||||
@@ -171,7 +194,7 @@ function buildGoogleDriveEmbedUrl(url: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const googleDriveConfig = {
|
||||
export const googleDriveConfig = {
|
||||
name: 'google-drive',
|
||||
match: (url: string) => isValidGoogleDriveUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -183,6 +206,8 @@ const googleDriveConfig = {
|
||||
return buildGoogleDriveEmbedUrl(url);
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
isValidGoogleDriveIframeUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -18,7 +18,7 @@ const miroUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['miro.com'],
|
||||
};
|
||||
|
||||
const miroConfig = {
|
||||
export const miroConfig = {
|
||||
name: 'miro',
|
||||
match: (url: string) => validateEmbedIframeUrl(url, miroUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -31,6 +31,12 @@ const miroConfig = {
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, miroUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
return new URL(iframeUrl).pathname.startsWith('/app/live-embed/');
|
||||
},
|
||||
options: {
|
||||
widthInSurface: MIRO_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: MIRO_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -18,7 +18,12 @@ const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['open.spotify.com', 'spotify.link'],
|
||||
};
|
||||
|
||||
const spotifyConfig = {
|
||||
const spotifyIframeUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['open.spotify.com'],
|
||||
};
|
||||
|
||||
export const spotifyConfig = {
|
||||
name: 'spotify',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
|
||||
@@ -32,6 +37,13 @@ const spotifyConfig = {
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, spotifyIframeUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(iframeUrl);
|
||||
return parsedUrl.pathname.split('/').find(Boolean) === 'embed';
|
||||
},
|
||||
options: {
|
||||
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -141,7 +141,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(link, '_blank');
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
refreshData = async () => {
|
||||
@@ -183,6 +183,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
|
||||
// update model
|
||||
const iframeUrl = this._getIframeUrl(embedData) ?? currentIframeUrl;
|
||||
if (!this._validateIframeUrl(url, iframeUrl)) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Invalid embed iframe url'
|
||||
);
|
||||
}
|
||||
this.store.updateBlock(this.model, {
|
||||
iframeUrl,
|
||||
title: embedData?.title || previewData?.title,
|
||||
@@ -291,6 +297,19 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _validateIframeUrl = (url: string, iframeUrl?: string) => {
|
||||
if (!iframeUrl) {
|
||||
return false;
|
||||
}
|
||||
const config = this.embedIframeService?.getConfig(url);
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
return config.validateIframeUrl
|
||||
? config.validateIframeUrl(iframeUrl, url)
|
||||
: config.match(iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _handleDoubleClick = () => {
|
||||
this.open();
|
||||
};
|
||||
@@ -329,6 +348,16 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
|
||||
private readonly _renderIframe = () => {
|
||||
const { iframeUrl } = this.model.props;
|
||||
if (!iframeUrl || !this._isIframeUrlAllowed(iframeUrl)) {
|
||||
return html`<embed-iframe-error-card
|
||||
.error=${new Error('Invalid iframe URL')}
|
||||
.model=${this.model}
|
||||
.onRetry=${this._handleRetry}
|
||||
.std=${this.std}
|
||||
.inSurface=${this.inSurface}
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-error-card>`;
|
||||
}
|
||||
const {
|
||||
widthPercent,
|
||||
heightInNote,
|
||||
@@ -368,6 +397,10 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
: nothing}`;
|
||||
};
|
||||
|
||||
private readonly _isIframeUrlAllowed = (iframeUrl: string) => {
|
||||
return this._validateIframeUrl(this.model.props.url, iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _getSourceHost = () => {
|
||||
const url = this.model.props.url ?? this.model.props.iframeUrl;
|
||||
if (!url) return null;
|
||||
@@ -437,7 +470,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
} else {
|
||||
// update iframe options, to ensure the iframe is rendered with the correct options
|
||||
this._updateIframeOptions(this.model.props.url);
|
||||
this.status$.value = 'success';
|
||||
this.status$.value = this._validateIframeUrl(
|
||||
this.model.props.url,
|
||||
this.model.props.iframeUrl
|
||||
)
|
||||
? 'success'
|
||||
: 'error';
|
||||
}
|
||||
|
||||
// refresh data when original url changes
|
||||
|
||||
@@ -9,6 +9,25 @@ export interface EmbedIframeUrlValidationOptions {
|
||||
hostnames: string[]; // Allowed hostnames, e.g. ['docs.google.com']
|
||||
}
|
||||
|
||||
function isLocalOrIpHostname(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
if (
|
||||
lower === 'localhost' ||
|
||||
lower.endsWith('.localhost') ||
|
||||
lower === '0.0.0.0' ||
|
||||
lower === '::' ||
|
||||
lower === '::1'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return lower.startsWith('[') && lower.endsWith(']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the url is allowed to embed in the iframe
|
||||
* @param url URL to validate
|
||||
@@ -23,6 +42,15 @@ export function validateEmbedIframeUrl(
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
const { protocols, hostnames } = options;
|
||||
if (
|
||||
parsedUrl.username ||
|
||||
parsedUrl.password ||
|
||||
parsedUrl.port ||
|
||||
isLocalOrIpHostname(parsedUrl.hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
protocols.includes(parsedUrl.protocol) &&
|
||||
hostnames.includes(parsedUrl.hostname)
|
||||
|
||||
@@ -121,6 +121,38 @@ export const updateBlockType: Command<
|
||||
}
|
||||
return next({ updatedBlocks: [newModel] });
|
||||
};
|
||||
const transformToLatex: Command<{}, { updatedBlocks: BlockModel[] }> = (
|
||||
_,
|
||||
next
|
||||
) => {
|
||||
if (flavour !== 'affine:latex') return;
|
||||
|
||||
const newModels: BlockModel[] = [];
|
||||
blockModels.forEach(model => {
|
||||
if (
|
||||
!matchModels(model, [
|
||||
ParagraphBlockModel,
|
||||
ListBlockModel,
|
||||
CodeBlockModel,
|
||||
])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latex = model.text?.toString() ?? '';
|
||||
const newId = transformModel(model, 'affine:latex', { latex });
|
||||
if (!newId) {
|
||||
return;
|
||||
}
|
||||
const newModel = doc.getModelById(newId);
|
||||
if (newModel) {
|
||||
newModels.push(newModel);
|
||||
}
|
||||
});
|
||||
|
||||
if (newModels.length === 0) return;
|
||||
return next({ updatedBlocks: newModels });
|
||||
};
|
||||
|
||||
const focusText: Command<{ updatedBlocks: BlockModel[] }> = (ctx, next) => {
|
||||
const { updatedBlocks } = ctx;
|
||||
@@ -185,6 +217,27 @@ export const updateBlockType: Command<
|
||||
});
|
||||
return next();
|
||||
};
|
||||
const selectBlocks: Command<{ updatedBlocks: BlockModel[] }> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const { updatedBlocks } = ctx;
|
||||
if (!updatedBlocks || updatedBlocks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
host.selection.setGroup(
|
||||
'note',
|
||||
updatedBlocks.map(model =>
|
||||
host.selection.create(BlockSelection, {
|
||||
blockId: model.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
return next();
|
||||
};
|
||||
|
||||
const [result, resultCtx] = std.command
|
||||
.chain()
|
||||
@@ -196,6 +249,7 @@ export const updateBlockType: Command<
|
||||
.try<{ updatedBlocks: BlockModel[] }>(chain => [
|
||||
chain.pipe(mergeToCode),
|
||||
chain.pipe(appendDivider),
|
||||
chain.pipe(transformToLatex),
|
||||
chain.pipe((_, next) => {
|
||||
const newModels: BlockModel[] = [];
|
||||
blockModels.forEach(model => {
|
||||
@@ -227,6 +281,14 @@ export const updateBlockType: Command<
|
||||
])
|
||||
// focus
|
||||
.try(chain => [
|
||||
chain
|
||||
.pipe((_, next) => {
|
||||
if (flavour === 'affine:latex') {
|
||||
return next();
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.pipe(selectBlocks),
|
||||
chain.pipe((_, next) => {
|
||||
if (['affine:code', 'affine:divider'].includes(flavour)) {
|
||||
return next();
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@blocksuite/affine-gfx-pointer": "workspace:*",
|
||||
"@blocksuite/affine-gfx-shape": "workspace:*",
|
||||
"@blocksuite/affine-gfx-text": "workspace:*",
|
||||
"@blocksuite/affine-inline-latex": "workspace:*",
|
||||
"@blocksuite/affine-inline-preset": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { HighlightType } from '@blocksuite/affine-components/highlight-dropdown-menu';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
|
||||
import {
|
||||
deleteTextCommand,
|
||||
formatBlockCommand,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
LinkedPageIcon,
|
||||
TeXIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type BlockComponent,
|
||||
@@ -199,9 +201,9 @@ const alignActionGroup = {
|
||||
const inlineTextActionGroup = {
|
||||
id: 'b.inline-text',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
actions: textFormatConfigs.map(
|
||||
actions: textFormatConfigs.flatMap(
|
||||
({ id, name, action, activeWhen, icon }, score) => {
|
||||
return {
|
||||
const textAction: ToolbarAction = {
|
||||
id,
|
||||
icon,
|
||||
score,
|
||||
@@ -209,6 +211,28 @@ const inlineTextActionGroup = {
|
||||
run: ({ host }) => action(host),
|
||||
active: ({ host }) => activeWhen(host),
|
||||
};
|
||||
|
||||
if (id !== 'underline') {
|
||||
return [textAction];
|
||||
}
|
||||
|
||||
return [
|
||||
textAction,
|
||||
{
|
||||
id: 'inline-latex',
|
||||
icon: TeXIcon(),
|
||||
score: score + 0.5,
|
||||
tooltip: 'Inline Equation',
|
||||
run: ({ host }) => {
|
||||
host.std.command
|
||||
.chain()
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(insertInlineLatex)
|
||||
.run();
|
||||
},
|
||||
active: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
),
|
||||
} as const satisfies ToolbarActionGroup;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
{ "path": "../../gfx/pointer" },
|
||||
{ "path": "../../gfx/shape" },
|
||||
{ "path": "../../gfx/text" },
|
||||
{ "path": "../../inlines/latex" },
|
||||
{ "path": "../../inlines/preset" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
|
||||
@@ -95,7 +95,9 @@ export class MenuInput extends MenuFocusable {
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.inputRef.select();
|
||||
if (!this.data.disableAutoFocus) {
|
||||
this.inputRef.select();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -223,6 +225,7 @@ export const menuInputItems = {
|
||||
onComplete?: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
disableAutoFocus?: boolean;
|
||||
class?: string;
|
||||
style?: Readonly<StyleInfo>;
|
||||
}) =>
|
||||
@@ -237,6 +240,7 @@ export const menuInputItems = {
|
||||
onComplete: config.onComplete,
|
||||
onChange: config.onChange,
|
||||
onBlur: config.onBlur,
|
||||
disableAutoFocus: config.disableAutoFocus,
|
||||
};
|
||||
const style = styleMap({
|
||||
display: 'flex',
|
||||
|
||||
@@ -111,8 +111,10 @@ export class MenuComponent
|
||||
}
|
||||
const onBack = this.menu.options.title?.onBack;
|
||||
if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) {
|
||||
this.menu.close();
|
||||
onBack(this.menu);
|
||||
const result = onBack(this.menu);
|
||||
if (result !== false) {
|
||||
this.menu.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
@@ -214,8 +216,10 @@ export class MenuComponent
|
||||
${title.onBack
|
||||
? html` <div
|
||||
@click="${() => {
|
||||
title.onBack?.(this.menu);
|
||||
this.menu.close();
|
||||
const result = title.onBack?.(this.menu);
|
||||
if (result !== false) {
|
||||
this.menu.close();
|
||||
}
|
||||
}}"
|
||||
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
|
||||
style="display:flex;"
|
||||
|
||||
@@ -15,7 +15,7 @@ export type MenuOptions = {
|
||||
onClose?: () => void;
|
||||
title?: {
|
||||
text: string;
|
||||
onBack?: (menu: Menu) => void;
|
||||
onBack?: (menu: Menu) => boolean | void;
|
||||
onClose?: () => void;
|
||||
postfix?: () => TemplateResult;
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ export class DatePicker extends WithDisposable(LitElement) {
|
||||
|
||||
private readonly _maxYear = 2099;
|
||||
|
||||
private readonly _minYear = 1970;
|
||||
private readonly _minYear = 1000;
|
||||
|
||||
get _cardStyle() {
|
||||
return {
|
||||
@@ -286,8 +286,18 @@ export class DatePicker extends WithDisposable(LitElement) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _clampCursorYear() {
|
||||
const year = this._cursor.getFullYear();
|
||||
if (year < this._minYear) {
|
||||
this._cursor = new Date(this._minYear, 0, 1);
|
||||
} else if (year > this._maxYear) {
|
||||
this._cursor = new Date(this._maxYear, 11, 31);
|
||||
}
|
||||
}
|
||||
|
||||
private _moveMonth(offset: number) {
|
||||
this._cursor.setMonth(this._cursor.getMonth() + offset);
|
||||
this._clampCursorYear();
|
||||
this._getMatrix();
|
||||
}
|
||||
|
||||
@@ -420,6 +430,7 @@ export class DatePicker extends WithDisposable(LitElement) {
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
this._cursor.setDate(this._cursor.getDate() + 7);
|
||||
}
|
||||
this._clampCursorYear();
|
||||
this._getMatrix();
|
||||
setTimeout(this.focusDateCell.bind(this));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type CalendarEntry,
|
||||
createCalendarMonthLayout,
|
||||
getCalendarDayContentSlots,
|
||||
getCalendarVisibleMonthRange,
|
||||
} from '../view-presets/calendar/index.js';
|
||||
|
||||
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||
|
||||
describe('calendar month layout', () => {
|
||||
it('buckets single day entries', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Task',
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('splits range external entries across weeks', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Trip',
|
||||
startAt: day('2026-05-09'),
|
||||
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 1, startIndex: 6, span: 1 },
|
||||
{ weekIndex: 2, startIndex: 0, span: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats all-day external midnight end as exclusive', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'All day',
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: day('2026-05-16'),
|
||||
allDay: true,
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('treats row midnight end date as inclusive', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Task',
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: day('2026-05-16'),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 2, startIndex: 5, span: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clips range entries to visible month range', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Long trip',
|
||||
startAt: day('2026-04-01'),
|
||||
endAt: day('2026-06-30'),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments[0]).toMatchObject({
|
||||
weekIndex: 0,
|
||||
startIndex: 0,
|
||||
span: 7,
|
||||
});
|
||||
expect(layout.segments.at(-1)).toMatchObject({
|
||||
weekIndex: layout.weeks.length - 1,
|
||||
startIndex: 0,
|
||||
span: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('pads month view to full weeks', () => {
|
||||
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [],
|
||||
});
|
||||
|
||||
expect(new Date(range.from).getDay()).toBe(0);
|
||||
expect(new Date(range.to).getDay()).toBe(6);
|
||||
expect(layout.days).toHaveLength(layout.weeks.length * 7);
|
||||
});
|
||||
|
||||
it('keeps day buckets on local midnight across DST boundaries', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'DST task',
|
||||
startAt: day('2026-03-09'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-03-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.every(item => {
|
||||
const date = new Date(item.date);
|
||||
return (
|
||||
date.getHours() === 0 &&
|
||||
date.getMinutes() === 0 &&
|
||||
date.getSeconds() === 0 &&
|
||||
date.getMilliseconds() === 0
|
||||
);
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-03-09'))?.entries
|
||||
).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('keeps range segment offsets across DST boundaries', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'DST range',
|
||||
startAt: day('2026-03-09'),
|
||||
endAt: new Date('2026-03-10T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-03-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{ weekIndex: 1, startIndex: 1, span: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps all same-day entries in the day bucket', () => {
|
||||
const entries = Array.from(
|
||||
{ length: 4 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'row',
|
||||
id: `database:row-${index}`,
|
||||
sourceId: 'database',
|
||||
rowId: `row-${index}`,
|
||||
title: `Task ${index}`,
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
}) satisfies CalendarEntry
|
||||
);
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
|
||||
expect(
|
||||
layout.days.find(item => item.date === day('2026-05-15'))?.entries
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('assigns each overlapping range segment to its own slot', () => {
|
||||
const entries: CalendarEntry[] = [
|
||||
...Array.from(
|
||||
{ length: 3 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'external',
|
||||
id: `external:full-${index}`,
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: `full-${index}`,
|
||||
title: `Full ${index}`,
|
||||
startAt: day('2026-05-15'),
|
||||
endAt: new Date('2026-05-17T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
}) as const
|
||||
),
|
||||
{
|
||||
kind: 'external',
|
||||
id: 'external:short',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: 'short',
|
||||
title: 'Short',
|
||||
startAt: day('2026-05-18'),
|
||||
endAt: new Date('2026-05-19T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
},
|
||||
];
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
|
||||
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
|
||||
|
||||
expect(getCalendarDayContentSlots(may15)).toBe(3);
|
||||
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
|
||||
expect(getCalendarDayContentSlots(may18)).toBe(1);
|
||||
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
|
||||
});
|
||||
|
||||
it('counts segment and same-day slots for drag preview placement', () => {
|
||||
const entries: CalendarEntry[] = [
|
||||
...Array.from(
|
||||
{ length: 3 },
|
||||
(_, index) =>
|
||||
({
|
||||
kind: 'external',
|
||||
id: `external:range-${index}`,
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: `range-${index}`,
|
||||
title: `Range ${index}`,
|
||||
startAt: day('2026-05-08'),
|
||||
endAt: new Date('2026-05-09T12:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
}) as const
|
||||
),
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:moving',
|
||||
sourceId: 'database',
|
||||
rowId: 'moving',
|
||||
title: 'Moving',
|
||||
startAt: day('2026-05-06'),
|
||||
endAt: new Date('2026-05-08T12:00:00').getTime(),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
},
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:single',
|
||||
sourceId: 'database',
|
||||
rowId: 'single',
|
||||
title: 'Single',
|
||||
startAt: day('2026-05-08'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
},
|
||||
];
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries,
|
||||
});
|
||||
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
|
||||
|
||||
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
|
||||
});
|
||||
|
||||
it('splits row range entries across weeks with continuation metadata', () => {
|
||||
const entry = {
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Project',
|
||||
startAt: day('2026-05-09'),
|
||||
endAt: new Date('2026-05-12T12:00:00').getTime(),
|
||||
cardProperties: [],
|
||||
canResizeRange: true,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toMatchObject([
|
||||
{
|
||||
weekIndex: 1,
|
||||
startIndex: 6,
|
||||
span: 1,
|
||||
startsBeforeWeek: false,
|
||||
endsAfterWeek: true,
|
||||
},
|
||||
{
|
||||
weekIndex: 2,
|
||||
startIndex: 0,
|
||||
span: 3,
|
||||
startsBeforeWeek: true,
|
||||
endsAfterWeek: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips range entries completely outside the visible month range', () => {
|
||||
const entry = {
|
||||
kind: 'external',
|
||||
id: 'external:outside',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: 'outside',
|
||||
title: 'Outside',
|
||||
startAt: day('2026-06-10'),
|
||||
endAt: day('2026-06-12'),
|
||||
canResizeRange: false,
|
||||
} satisfies CalendarEntry;
|
||||
|
||||
const layout = createCalendarMonthLayout({
|
||||
month: day('2026-05-01'),
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
expect(layout.segments).toEqual([]);
|
||||
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,812 @@
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DataSource } from '../core/data-source/base.js';
|
||||
import {
|
||||
CalendarSingleView,
|
||||
type CalendarStoredViewData,
|
||||
calendarViewModel,
|
||||
} from '../view-presets/calendar/index.js';
|
||||
import {
|
||||
formatEntryTime,
|
||||
openCalendarEntry,
|
||||
} from '../view-presets/calendar/pc/actions.js';
|
||||
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
|
||||
import { viewConverts } from '../view-presets/convert.js';
|
||||
|
||||
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
|
||||
|
||||
const createCalendarView = (options?: {
|
||||
startColumnId?: string;
|
||||
endColumnId?: string;
|
||||
datePropertyType?: string;
|
||||
rows?: string[];
|
||||
filterValue?: string;
|
||||
titleValue?: unknown;
|
||||
linkedDocTitles?: Record<string, string>;
|
||||
visiblePropertyIds?: string[];
|
||||
externalFactories?: Map<unknown, unknown>;
|
||||
}) => {
|
||||
const rows = signal(options?.rows ?? ['row-1']);
|
||||
const columns = signal(['title', 'date', 'end-date', 'status']);
|
||||
const viewData = signal<CalendarStoredViewData>({
|
||||
id: 'view-1',
|
||||
name: 'Calendar',
|
||||
mode: 'calendar',
|
||||
filter: options?.filterValue
|
||||
? {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: { type: 'ref', name: 'status' },
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: options.filterValue }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
date: {
|
||||
startColumnId: options?.startColumnId,
|
||||
endColumnId: options?.endColumnId,
|
||||
},
|
||||
card: {
|
||||
titleColumnId: 'title',
|
||||
visiblePropertyIds: options?.visiblePropertyIds ?? [],
|
||||
},
|
||||
sources: {
|
||||
workspaceCalendar: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const values = new Map<string, unknown>([
|
||||
['row-1:date', day('2026-05-15')],
|
||||
['row-1:end-date', day('2026-05-17')],
|
||||
['row-1:status', 'Done'],
|
||||
['row-1:title', options?.titleValue ?? 'Task'],
|
||||
['row-2:date', day('2026-05-16')],
|
||||
['row-2:end-date', day('2026-05-14')],
|
||||
['row-2:status', 'Todo'],
|
||||
['row-2:title', 'Hidden'],
|
||||
]);
|
||||
const types = new Map<string, string>([
|
||||
['title', 'title'],
|
||||
['date', options?.datePropertyType ?? 'date'],
|
||||
['end-date', 'date'],
|
||||
['status', 'text'],
|
||||
]);
|
||||
|
||||
const dataSource = {
|
||||
rows$: rows,
|
||||
properties$: columns,
|
||||
readonly$: signal(false),
|
||||
featureFlags$: signal({ enable_table_virtual_scroll: false }),
|
||||
provider: {
|
||||
getAll: () => options?.externalFactories ?? new Map(),
|
||||
},
|
||||
viewDataGet: () => viewData.value,
|
||||
viewDataUpdate: (
|
||||
_id: string,
|
||||
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
|
||||
) => {
|
||||
viewData.value = { ...viewData.value, ...updater(viewData.value) };
|
||||
},
|
||||
cellValueGet: (rowId: string, propertyId: string) =>
|
||||
values.get(`${rowId}:${propertyId}`),
|
||||
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
|
||||
values.set(`${rowId}:${propertyId}`, value);
|
||||
},
|
||||
rowAdd: () => {
|
||||
const rowId = `row-${rows.value.length + 1}`;
|
||||
rows.value = [...rows.value, rowId];
|
||||
return rowId;
|
||||
},
|
||||
propertyTypeGet: (propertyId: string) => types.get(propertyId),
|
||||
propertyNameGet: (propertyId: string) => propertyId,
|
||||
propertyDataGet: () => ({}),
|
||||
propertyReadonlyGet: () => false,
|
||||
serviceGet: (key: unknown) => {
|
||||
if (key !== DocDisplayMetaProvider) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: (pageId: string, referenceInfo?: { title?: string }) =>
|
||||
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
|
||||
};
|
||||
},
|
||||
propertyMetaGet: (type: string) => ({
|
||||
type,
|
||||
config: {
|
||||
rawValue: {
|
||||
toJson: ({ value }: { value: unknown }) => {
|
||||
const deltas =
|
||||
typeof value === 'object' && value != null && 'deltas$' in value
|
||||
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||
: undefined;
|
||||
if (!Array.isArray(deltas)) {
|
||||
return value;
|
||||
}
|
||||
return deltas
|
||||
.map(delta => {
|
||||
const item = delta as {
|
||||
insert?: unknown;
|
||||
attributes?: {
|
||||
reference?: {
|
||||
type?: string;
|
||||
pageId?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
const pageId = item.attributes?.reference?.pageId;
|
||||
if (
|
||||
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||
typeof pageId === 'string'
|
||||
) {
|
||||
return (
|
||||
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
|
||||
);
|
||||
}
|
||||
return item.insert ?? '';
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
fromJson: ({ value }: { value: unknown }) => value,
|
||||
toString: ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value : '',
|
||||
},
|
||||
jsonValue: {
|
||||
schema: {
|
||||
safeParse: (value: unknown) => ({ success: true, data: value }),
|
||||
},
|
||||
isEmpty: () => false,
|
||||
type: () => undefined,
|
||||
},
|
||||
},
|
||||
renderer: {},
|
||||
}),
|
||||
propertyAdd: () => {
|
||||
columns.value = [...columns.value, 'created-date'];
|
||||
types.set('created-date', 'date');
|
||||
return 'created-date';
|
||||
},
|
||||
propertyCanDelete: () => true,
|
||||
propertyCanDuplicate: () => true,
|
||||
propertyTypeCanSet: () => true,
|
||||
} as unknown as DataSource;
|
||||
const manager = {
|
||||
dataSource,
|
||||
readonly$: signal(false),
|
||||
};
|
||||
return {
|
||||
view: new CalendarSingleView(manager as any, 'view-1'),
|
||||
viewData,
|
||||
values,
|
||||
types,
|
||||
columns,
|
||||
};
|
||||
};
|
||||
|
||||
describe('CalendarSingleView', () => {
|
||||
it('creates default view data without selecting a start date', () => {
|
||||
const data = calendarViewModel.model.defaultData({
|
||||
dataSource: {
|
||||
properties$: signal(['title', 'date']),
|
||||
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(data.date).toEqual({});
|
||||
expect(data.card).toEqual({
|
||||
titleColumnId: 'title',
|
||||
visiblePropertyIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('enters setup state without a start date property', () => {
|
||||
const { view } = createCalendarView();
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('enters setup state when start date column is not date', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
datePropertyType: 'text',
|
||||
});
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('enters setup state after date property deletion', () => {
|
||||
const { view, columns } = createCalendarView({ startColumnId: 'date' });
|
||||
|
||||
columns.value = ['title', 'status'];
|
||||
|
||||
expect(view.dateMapping$.value.status).toBe('setup');
|
||||
});
|
||||
|
||||
it('creates row entries after filtering rows', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
rows: ['row-1', 'row-2'],
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
|
||||
});
|
||||
|
||||
it('updates entry date after row date value changes', () => {
|
||||
const { view, values } = createCalendarView({ startColumnId: 'date' });
|
||||
|
||||
values.set('row-1:date', day('2026-05-20'));
|
||||
|
||||
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
|
||||
});
|
||||
|
||||
it('creates row range entries and falls back when end date is invalid', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
rows: ['row-1', 'row-2'],
|
||||
});
|
||||
|
||||
expect(
|
||||
view.rowEntries$.value.map(entry => [
|
||||
entry.rowId,
|
||||
entry.startAt,
|
||||
entry.endAt,
|
||||
])
|
||||
).toEqual([
|
||||
['row-1', day('2026-05-15'), day('2026-05-17')],
|
||||
['row-2', day('2026-05-16'), undefined],
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
|
||||
});
|
||||
|
||||
it('moves row range while preserving duration', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
|
||||
view.moveRowToDate('row-1', day('2026-05-20'));
|
||||
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
|
||||
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
|
||||
});
|
||||
|
||||
it('resizes row range without crossing start and end', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
|
||||
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
|
||||
|
||||
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
|
||||
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
|
||||
});
|
||||
|
||||
it('creates a row with default filter values and target date', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
const rowId = view.createRowOnDate(day('2026-05-25'));
|
||||
|
||||
expect(rowId).toBe('row-2');
|
||||
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||
expect(values.get('row-2:status')).toBe('Done');
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a dated linked-doc row', () => {
|
||||
const { view, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
filterValue: 'Done',
|
||||
});
|
||||
|
||||
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
|
||||
const title = values.get('row-2:title') as
|
||||
| { toDelta?: () => unknown[] }
|
||||
| undefined;
|
||||
|
||||
expect(rowId).toBe('row-2');
|
||||
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
|
||||
expect(values.get('row-2:status')).toBe('Done');
|
||||
expect(title?.toDelta?.()).toEqual([
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('dismisses the empty month hint on the current calendar view', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(false);
|
||||
|
||||
view.dismissEmptyMonthHint();
|
||||
|
||||
expect(view.emptyMonthHintDismissed$.value).toBe(true);
|
||||
expect('ui' in viewData.value && viewData.value.ui).toEqual({
|
||||
emptyMonthHintDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates workspace calendar settings when legacy view data has no sources', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
viewData.value = {
|
||||
...viewData.value,
|
||||
sources: undefined as unknown as CalendarStoredViewData['sources'],
|
||||
};
|
||||
|
||||
view.setWorkspaceCalendarEnabled(false);
|
||||
|
||||
expect(viewData.value.sources.workspaceCalendar).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('enters setup state when legacy view data has no date config', () => {
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
endColumnId: 'end-date',
|
||||
});
|
||||
viewData.value = {
|
||||
...viewData.value,
|
||||
date: undefined as unknown as CalendarStoredViewData['date'],
|
||||
};
|
||||
|
||||
expect(view.dateMapping$.value).toEqual({
|
||||
status: 'setup',
|
||||
propertyId: undefined,
|
||||
});
|
||||
expect(view.endDateMapping$.value).toEqual({
|
||||
status: 'setup',
|
||||
propertyId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('generates card properties from visible property ids', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
visiblePropertyIds: ['status'],
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
|
||||
{
|
||||
propertyId: 'status',
|
||||
value: 'Done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses single linked doc id from title cell', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Linked doc title',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: 'Doc',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Linked doc title', linkedDoc: true },
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
|
||||
});
|
||||
|
||||
it('uses normal title text for multiple linked doc titles', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Doc 1',
|
||||
'doc-2': 'Doc 2',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: 'Doc 1',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: 'Doc 2',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Doc 1', linkedDoc: true },
|
||||
{ text: 'Doc 2', linkedDoc: true },
|
||||
]);
|
||||
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
|
||||
});
|
||||
|
||||
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
linkedDocTitles: {
|
||||
'doc-1': 'Doc 1',
|
||||
'doc-2': 'Doc 2',
|
||||
},
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: 'doc-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'Doc 1', linkedDoc: true },
|
||||
{ text: 'Doc 2', linkedDoc: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges linked doc placeholders with the following plain title text', () => {
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
titleValue: {
|
||||
deltas$: {
|
||||
value: [
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: {
|
||||
reference: { type: 'LinkedPage', pageId: 'doc-1' },
|
||||
},
|
||||
},
|
||||
{ insert: 'How to use folder and Tags' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
|
||||
{ text: 'How to use folder and Tags', linkedDoc: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates date mapping through setup APIs', () => {
|
||||
const { view, viewData, values } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
});
|
||||
|
||||
view.moveRowToDate('row-1', day('2026-05-21'));
|
||||
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
|
||||
|
||||
view.setDateColumn('date');
|
||||
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||
'date'
|
||||
);
|
||||
|
||||
expect(view.createDateColumn()).toBe('created-date');
|
||||
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
|
||||
'created-date'
|
||||
);
|
||||
});
|
||||
|
||||
it('aggregates external source entries without mutating view data', async () => {
|
||||
const externalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'source',
|
||||
externalId: '1',
|
||||
title: 'External',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const anotherExternalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:2',
|
||||
sourceId: 'another-source',
|
||||
externalId: '2',
|
||||
title: 'Another external',
|
||||
startAt: day('2026-05-16'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const { view, viewData } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries: () => [externalEntry],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
'another-source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'another-source',
|
||||
getEntries: () => Promise.resolve([anotherExternalEntry]),
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const viewDataBefore = JSON.stringify(viewData.value);
|
||||
|
||||
await expect(
|
||||
view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
})
|
||||
).resolves.toEqual([externalEntry, anotherExternalEntry]);
|
||||
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
|
||||
});
|
||||
|
||||
it('keeps successful external entries when another source fails', async () => {
|
||||
const externalEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'source',
|
||||
externalId: '1',
|
||||
title: 'External',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries: () => [externalEntry],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
'failing-source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'failing-source',
|
||||
getEntries: () => Promise.reject(new Error('denied')),
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
})
|
||||
).resolves.toEqual([externalEntry]);
|
||||
});
|
||||
|
||||
it('does not let stale external entry loads overwrite newer entries', async () => {
|
||||
const oldEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:old',
|
||||
sourceId: 'source',
|
||||
externalId: 'old',
|
||||
title: 'Old',
|
||||
startAt: day('2026-05-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
const newEntry = {
|
||||
kind: 'external',
|
||||
id: 'external:new',
|
||||
sourceId: 'source',
|
||||
externalId: 'new',
|
||||
title: 'New',
|
||||
startAt: day('2026-06-15'),
|
||||
canResizeRange: false,
|
||||
} as const;
|
||||
let resolveOld!: (entries: [typeof oldEntry]) => void;
|
||||
let resolveNew!: (entries: [typeof newEntry]) => void;
|
||||
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
const newRequest = new Promise<[typeof newEntry]>(resolve => {
|
||||
resolveNew = resolve;
|
||||
});
|
||||
const getEntries = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(oldRequest)
|
||||
.mockReturnValueOnce(newRequest);
|
||||
const { view } = createCalendarView({
|
||||
startColumnId: 'date',
|
||||
externalFactories: new Map([
|
||||
[
|
||||
'source',
|
||||
{
|
||||
create: () => ({
|
||||
id: 'source',
|
||||
getEntries,
|
||||
}),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const firstLoad = view.loadExternalEntries({
|
||||
from: day('2026-05-01'),
|
||||
to: day('2026-05-31'),
|
||||
});
|
||||
const secondLoad = view.loadExternalEntries({
|
||||
from: day('2026-06-01'),
|
||||
to: day('2026-06-30'),
|
||||
});
|
||||
|
||||
resolveNew([newEntry]);
|
||||
await expect(secondLoad).resolves.toEqual([newEntry]);
|
||||
expect(
|
||||
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||
).toEqual([newEntry]);
|
||||
|
||||
resolveOld([oldEntry]);
|
||||
await expect(firstLoad).resolves.toEqual([oldEntry]);
|
||||
expect(
|
||||
view.entries$.value.filter(entry => entry.kind === 'external')
|
||||
).toEqual([newEntry]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar entry actions', () => {
|
||||
it('formats external event popover time ranges with end time', () => {
|
||||
const label = formatEntryTime({
|
||||
kind: 'external',
|
||||
id: 'external:1',
|
||||
sourceId: 'workspace-calendar',
|
||||
externalId: '1',
|
||||
title: 'Planning',
|
||||
startAt: new Date('2026-05-15T10:00:00').getTime(),
|
||||
endAt: new Date('2026-05-15T11:00:00').getTime(),
|
||||
canResizeRange: false,
|
||||
});
|
||||
|
||||
expect(label).toContain(' - ');
|
||||
expect(label).toContain('2026');
|
||||
});
|
||||
|
||||
it('opens row entries through the detail panel hook', () => {
|
||||
const openDetailPanel = vi.fn();
|
||||
const { view } = createCalendarView({ startColumnId: 'date' });
|
||||
const target = {} as HTMLElement;
|
||||
|
||||
openCalendarEntry(
|
||||
{ openDetailPanel } as any,
|
||||
view,
|
||||
{
|
||||
kind: 'row',
|
||||
id: 'database:row-1',
|
||||
sourceId: 'database',
|
||||
rowId: 'row-1',
|
||||
title: 'Doc',
|
||||
startAt: day('2026-05-15'),
|
||||
cardProperties: [],
|
||||
canResizeRange: false,
|
||||
},
|
||||
target
|
||||
);
|
||||
|
||||
expect(openDetailPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ view, rowId: 'row-1' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar view converts', () => {
|
||||
it('converts header/card semantics without date mapping', () => {
|
||||
const tableToCalendar = viewConverts.find(
|
||||
convert => convert.from === 'table' && convert.to === 'calendar'
|
||||
);
|
||||
const calendarToKanban = viewConverts.find(
|
||||
convert => convert.from === 'calendar' && convert.to === 'kanban'
|
||||
);
|
||||
const filter = { type: 'group', op: 'and', conditions: [] } as const;
|
||||
const sort = { columns: [] };
|
||||
const header = { titleColumn: 'title' };
|
||||
|
||||
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
|
||||
filter,
|
||||
sort,
|
||||
card: { titleColumnId: 'title', visiblePropertyIds: [] },
|
||||
});
|
||||
expect(
|
||||
calendarToKanban?.convert({
|
||||
filter,
|
||||
sort,
|
||||
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
|
||||
date: { startColumnId: 'date' },
|
||||
} as any)
|
||||
).toEqual({ filter, sort, header });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar dnd payload', () => {
|
||||
it('reads calendar entry payloads from blocksuite dnd data', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({
|
||||
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
|
||||
})
|
||||
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
|
||||
});
|
||||
|
||||
it('normalizes affine doc entities for future document drops', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({
|
||||
entity: { type: 'doc', id: 'doc-1' },
|
||||
})
|
||||
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||
});
|
||||
|
||||
it('reads document payloads from blocksuite dnd data', () => {
|
||||
expect(
|
||||
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
|
||||
).toEqual({ type: 'doc', docId: 'doc-1' });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type Clipboard,
|
||||
type DndController,
|
||||
type EventName,
|
||||
ShadowlessElement,
|
||||
type UIEventHandler,
|
||||
@@ -29,6 +30,7 @@ import type { DataViewWidget } from './widget/index.js';
|
||||
|
||||
export type DataViewRendererConfig = {
|
||||
clipboard: Clipboard;
|
||||
dnd?: DndController;
|
||||
onDrag?: (evt: MouseEvent, id: string) => () => void;
|
||||
notification: {
|
||||
toast: (message: string) => void;
|
||||
|
||||
@@ -2,15 +2,10 @@ import {
|
||||
dropdownSubMenuMiddleware,
|
||||
menu,
|
||||
type MenuConfig,
|
||||
type MenuOptions,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
@@ -260,188 +255,183 @@ export class GroupSetting extends SignalWatcher(
|
||||
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
|
||||
}
|
||||
|
||||
export const selectGroupByProperty = (
|
||||
export const buildGroupSelectItems = (
|
||||
group: GroupTrait,
|
||||
ops?: {
|
||||
onSelect?: (id?: string) => void;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
): MenuOptions => {
|
||||
onSelect: (id?: string) => void
|
||||
): MenuConfig[] => {
|
||||
const view = group.view;
|
||||
return {
|
||||
onClose: ops?.onClose,
|
||||
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
|
||||
items: [
|
||||
menu.group({
|
||||
items: view.propertiesRaw$.value
|
||||
.filter(property => {
|
||||
if (property.type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
if (view instanceof KanbanSingleView) {
|
||||
return canGroupable(view.manager.dataSource, property.id);
|
||||
}
|
||||
const dataType = property.dataType$.value;
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
const groupByService = getGroupByService(view.manager.dataSource);
|
||||
return !!groupByService?.matcher.match(dataType);
|
||||
})
|
||||
.map<MenuConfig>(property => {
|
||||
return menu.action({
|
||||
name: property.name$.value,
|
||||
isSelected: group.property$.value?.id === property.id,
|
||||
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||
select: () => {
|
||||
group.changeGroup(property.id);
|
||||
ops?.onSelect?.(property.id);
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
return [
|
||||
menu.group({
|
||||
items: view.propertiesRaw$.value
|
||||
.filter(property => {
|
||||
if (property.type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
if (view instanceof KanbanSingleView) {
|
||||
return canGroupable(view.manager.dataSource, property.id);
|
||||
}
|
||||
const dataType = property.dataType$.value;
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
const groupByService = getGroupByService(view.manager.dataSource);
|
||||
return !!groupByService?.matcher.match(dataType);
|
||||
})
|
||||
.map<MenuConfig>(property =>
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || !group.property$.value,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
name: property.name$.value,
|
||||
isSelected: group.property$.value?.id === property.id,
|
||||
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
ops?.onSelect?.();
|
||||
group.changeGroup(property.id);
|
||||
onSelect(property.id);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
})
|
||||
),
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || !group.property$.value,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
onSelect(undefined);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
export const popSelectGroupByProperty = (
|
||||
target: PopupTarget,
|
||||
export const buildGroupSettingItems = (
|
||||
group: GroupTrait,
|
||||
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
const handler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, ops),
|
||||
middleware,
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
export const popGroupSetting = (
|
||||
target: PopupTarget,
|
||||
group: GroupTrait,
|
||||
onBack: () => void,
|
||||
onClose?: () => void,
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
onGroupByClick: () => void,
|
||||
onGroupRemoved?: () => void
|
||||
): MenuConfig[] => {
|
||||
const view = group.view;
|
||||
const gProp = group.property$.value;
|
||||
if (!gProp) return;
|
||||
if (!gProp) return [];
|
||||
const type = gProp.type$.value;
|
||||
if (!type) return;
|
||||
|
||||
if (!type) return [];
|
||||
const icon = gProp.icon;
|
||||
const menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Group',
|
||||
onBack,
|
||||
onClose,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${gProp.name$.value}
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const subHandler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, {
|
||||
onSelect: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onBack: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onClose,
|
||||
}),
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
...(type === 'date'
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Date by',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
|
||||
>
|
||||
${dateModeLabel(group.groupInfo$.value?.config.name)}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Relative', 'date-relative'],
|
||||
['Day', 'date-day'],
|
||||
return [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${gProp.name$.value}
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
onGroupByClick();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
...(type === 'date'
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Date by',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
|
||||
>
|
||||
${dateModeLabel(group.groupInfo$.value?.config.name)}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Relative', 'date-relative'],
|
||||
['Day', 'date-day'],
|
||||
[
|
||||
'Week',
|
||||
group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'date-week-mon'
|
||||
: 'date-week-sun',
|
||||
],
|
||||
['Month', 'date-month'],
|
||||
['Year', 'date-year'],
|
||||
] as [string, string][]
|
||||
).map(
|
||||
([label, key]): MenuConfig =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config.name === key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name === key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
|
||||
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Start week on',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'Monday'
|
||||
: 'Sunday'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
'Week',
|
||||
group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'date-week-mon'
|
||||
: 'date-week-sun',
|
||||
],
|
||||
['Month', 'date-month'],
|
||||
['Year', 'date-year'],
|
||||
] as [string, string][]
|
||||
).map(
|
||||
([label, key]): MenuConfig =>
|
||||
['Monday', 'date-week-mon'],
|
||||
['Sunday', 'date-week-sun'],
|
||||
] as [string, string][]
|
||||
).map(([label, key]) =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
@@ -462,179 +452,118 @@ export const popGroupSetting = (
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Sort',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Oldest first',
|
||||
label: () => {
|
||||
const isSelected = group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Oldest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(true);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Newest first',
|
||||
label: () => {
|
||||
const isSelected = !group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Newest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: !group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(false);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Start week on',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'Monday'
|
||||
: 'Sunday'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Monday', 'date-week-mon'],
|
||||
['Sunday', 'date-week-sun'],
|
||||
] as [string, string][]
|
||||
).map(([label, key]) =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config
|
||||
.name === key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name ===
|
||||
key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Sort',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.sortAsc$.value
|
||||
? 'Oldest first'
|
||||
: 'Newest first'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Oldest first',
|
||||
label: () => {
|
||||
const isSelected = group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Oldest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(true);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Newest first',
|
||||
label: () => {
|
||||
const isSelected = !group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Newest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: !group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(false);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Hide empty groups',
|
||||
isSelected: group.hideEmpty$.value,
|
||||
select: () => {
|
||||
group.setHideEmpty(!group.hideEmpty$.value);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menuObj => html`
|
||||
<data-view-group-setting
|
||||
@mouseenter=${() => menuObj.closeSubMenu()}
|
||||
.groupTrait=${group}
|
||||
.columnId=${gProp.id}
|
||||
></data-view-group-setting>
|
||||
`,
|
||||
],
|
||||
}),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Hide empty groups',
|
||||
isSelected: group.hideEmpty$.value,
|
||||
select: () => {
|
||||
group.setHideEmpty(!group.hideEmpty$.value);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu => html`
|
||||
<data-view-group-setting
|
||||
@mouseenter=${() => menu.closeSubMenu()}
|
||||
.groupTrait=${group}
|
||||
.columnId=${gProp.id}
|
||||
></data-view-group-setting>
|
||||
`,
|
||||
],
|
||||
}),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Remove grouping',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Remove grouping',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
onGroupRemoved?.();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
middleware,
|
||||
});
|
||||
menuHandler.menu.menuElement.style.minHeight = '550px';
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
|
||||
import { type DeltaInsert, Text } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
import { Doc } from 'yjs';
|
||||
|
||||
import { evalFilter } from '../../core/filter/eval.js';
|
||||
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
|
||||
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
|
||||
import type { FilterGroup } from '../../core/filter/types.js';
|
||||
import { emptyFilterGroup } from '../../core/filter/utils.js';
|
||||
import { fromJson } from '../../core/property/utils';
|
||||
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
|
||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||
import { type Row, RowBase } from '../../core/view-manager/row.js';
|
||||
import {
|
||||
type SingleView,
|
||||
SingleViewBase,
|
||||
} from '../../core/view-manager/single-view.js';
|
||||
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||
import { getCalendarExternalSources } from './source.js';
|
||||
import type {
|
||||
CalendarEntry,
|
||||
CalendarEntryRange,
|
||||
CalendarExternalEntry,
|
||||
CalendarExternalSource,
|
||||
CalendarRowEntry,
|
||||
CalendarStoredViewData,
|
||||
CalendarTitleSegment,
|
||||
} from './types.js';
|
||||
|
||||
export type CalendarDateMapping =
|
||||
| {
|
||||
status: 'ready';
|
||||
propertyId: string;
|
||||
}
|
||||
| {
|
||||
status: 'setup';
|
||||
propertyId?: string;
|
||||
};
|
||||
|
||||
const getStartColumnId = (data?: CalendarStoredViewData) =>
|
||||
data?.date?.startColumnId;
|
||||
|
||||
const getEndColumnId = (data?: CalendarStoredViewData) => {
|
||||
return data?.date?.endColumnId;
|
||||
};
|
||||
|
||||
const getDateData = (data: CalendarStoredViewData) => ({
|
||||
...data.date,
|
||||
startColumnId: getStartColumnId(data),
|
||||
});
|
||||
|
||||
const getCardData = (data?: CalendarStoredViewData) => {
|
||||
if (data) {
|
||||
return data.card;
|
||||
}
|
||||
return {
|
||||
visiblePropertyIds: [],
|
||||
};
|
||||
};
|
||||
|
||||
const toTimestamp = (date: number | Date) =>
|
||||
date instanceof Date ? date.getTime() : date;
|
||||
|
||||
const isValidTimestamp = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const createLinkedDocTitle = (docId: string) => {
|
||||
const text = new Text<AffineTextAttributes>();
|
||||
new Doc().getMap('root').set('text', text.yText);
|
||||
text.applyDelta([
|
||||
{
|
||||
insert: ' ',
|
||||
attributes: { reference: { type: 'LinkedPage', pageId: docId } },
|
||||
},
|
||||
] satisfies DeltaInsert<AffineTextAttributes>[]);
|
||||
return text;
|
||||
};
|
||||
|
||||
const getTitleDeltas = (value: unknown) =>
|
||||
typeof value === 'object' && value != null && 'deltas$' in value
|
||||
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
|
||||
: undefined;
|
||||
|
||||
const getTitleSegments = (
|
||||
value: unknown,
|
||||
title: string,
|
||||
getLinkedDocTitle?: (pageId: string, title?: string) => string | undefined
|
||||
): CalendarTitleSegment[] | undefined => {
|
||||
const deltas = getTitleDeltas(value);
|
||||
if (!Array.isArray(deltas)) {
|
||||
return;
|
||||
}
|
||||
const segments = deltas.flatMap(delta => {
|
||||
const item = delta as {
|
||||
insert?: unknown;
|
||||
attributes?: {
|
||||
reference?: {
|
||||
type?: string;
|
||||
pageId?: unknown;
|
||||
title?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
const linkedDoc =
|
||||
item.attributes?.reference?.type === 'LinkedPage' &&
|
||||
typeof item.attributes.reference.pageId === 'string';
|
||||
const referenceTitle = item.attributes?.reference?.title;
|
||||
const resolvedLinkedDocTitle =
|
||||
linkedDoc && typeof item.attributes?.reference?.pageId === 'string'
|
||||
? getLinkedDocTitle?.(
|
||||
item.attributes.reference.pageId,
|
||||
typeof referenceTitle === 'string' ? referenceTitle : undefined
|
||||
)
|
||||
: undefined;
|
||||
const text =
|
||||
resolvedLinkedDocTitle ||
|
||||
(linkedDoc && typeof referenceTitle === 'string' && referenceTitle
|
||||
? referenceTitle
|
||||
: typeof item.insert === 'string'
|
||||
? item.insert.trim()
|
||||
: '');
|
||||
if (linkedDoc) {
|
||||
return {
|
||||
text,
|
||||
linkedDoc,
|
||||
};
|
||||
}
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
text,
|
||||
};
|
||||
});
|
||||
const normalizedSegments = segments.reduce<CalendarTitleSegment[]>(
|
||||
(result, segment) => {
|
||||
const previous = result.at(-1);
|
||||
if (
|
||||
previous?.linkedDoc &&
|
||||
!previous.text &&
|
||||
!segment.linkedDoc &&
|
||||
segment.text
|
||||
) {
|
||||
previous.text = segment.text;
|
||||
return result;
|
||||
}
|
||||
result.push(segment);
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (!normalizedSegments.some(segment => segment.linkedDoc)) {
|
||||
return;
|
||||
}
|
||||
if (!normalizedSegments.some(segment => segment.text)) {
|
||||
return title
|
||||
? [...normalizedSegments, { text: title }]
|
||||
: normalizedSegments;
|
||||
}
|
||||
return normalizedSegments;
|
||||
};
|
||||
|
||||
export class CalendarSingleView extends SingleViewBase<CalendarStoredViewData> {
|
||||
private readonly externalEntries$ = signal<CalendarExternalEntry[]>([]);
|
||||
|
||||
private externalEntriesRequestId = 0;
|
||||
|
||||
propertiesRaw$ = computed(() => {
|
||||
return this.dataSource.properties$.value.map(id =>
|
||||
this.propertyGetOrCreate(id)
|
||||
);
|
||||
});
|
||||
|
||||
properties$ = this.propertiesRaw$;
|
||||
|
||||
detailProperties$ = computed(() => {
|
||||
return this.propertiesRaw$.value.filter(
|
||||
property => property.type$.value !== 'title'
|
||||
);
|
||||
});
|
||||
|
||||
private readonly filter$ = computed(() => {
|
||||
return this.data$.value?.filter ?? emptyFilterGroup;
|
||||
});
|
||||
|
||||
private readonly sortList$ = computed(() => {
|
||||
return this.data$.value?.sort;
|
||||
});
|
||||
|
||||
emptyMonthHintDismissed$ = computed(() => {
|
||||
return this.data$.value?.ui?.emptyMonthHintDismissed ?? false;
|
||||
});
|
||||
|
||||
private readonly sortManager = this.traitSet(
|
||||
sortTraitKey,
|
||||
new SortManager(this.sortList$, this, {
|
||||
setSortList: sortList => {
|
||||
this.dataUpdate(data => ({
|
||||
sort: {
|
||||
...data.sort,
|
||||
...sortList,
|
||||
},
|
||||
}));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
filterTrait = this.traitSet(
|
||||
filterTraitKey,
|
||||
new FilterTrait(this.filter$, this, {
|
||||
filterSet: (filter: FilterGroup) => {
|
||||
this.dataUpdate(() => ({ filter }));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
mainProperties$ = computed(() => {
|
||||
const card = getCardData(this.data$.value);
|
||||
return {
|
||||
titleColumn:
|
||||
card.titleColumnId ??
|
||||
this.propertiesRaw$.value.find(
|
||||
property => property.type$.value === 'title'
|
||||
)?.id,
|
||||
};
|
||||
});
|
||||
|
||||
readonly$ = computed(() => {
|
||||
return this.manager.readonly$.value;
|
||||
});
|
||||
|
||||
dateProperties$ = computed(() => {
|
||||
return this.propertiesRaw$.value.filter(
|
||||
property => property.type$.value === 'date'
|
||||
);
|
||||
});
|
||||
|
||||
dateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||
const propertyId = getStartColumnId(this.data$.value);
|
||||
if (
|
||||
propertyId &&
|
||||
this.dataSource.properties$.value.includes(propertyId) &&
|
||||
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||
) {
|
||||
return {
|
||||
status: 'ready',
|
||||
propertyId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'setup',
|
||||
propertyId,
|
||||
};
|
||||
});
|
||||
|
||||
startDateMapping$ = this.dateMapping$;
|
||||
|
||||
endDateMapping$: ReadonlySignal<CalendarDateMapping> = computed(() => {
|
||||
const propertyId = getEndColumnId(this.data$.value);
|
||||
if (
|
||||
propertyId &&
|
||||
this.dataSource.properties$.value.includes(propertyId) &&
|
||||
this.dataSource.propertyTypeGet(propertyId) === 'date'
|
||||
) {
|
||||
return {
|
||||
status: 'ready',
|
||||
propertyId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'setup',
|
||||
propertyId,
|
||||
};
|
||||
});
|
||||
|
||||
private readonly visibleCardProperties$ = computed(() => {
|
||||
const card = getCardData(this.data$.value);
|
||||
const visiblePropertyIds = card.visiblePropertyIds ?? [];
|
||||
const titleColumn = card.titleColumnId;
|
||||
return visiblePropertyIds
|
||||
.filter(propertyId => propertyId !== titleColumn)
|
||||
.map(propertyId => this.propertyGetOrCreate(propertyId));
|
||||
});
|
||||
|
||||
rowEntries$ = computed<CalendarRowEntry[]>(() => {
|
||||
const mapping = this.dateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return [];
|
||||
}
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
return this.rows$.value.flatMap(row => {
|
||||
const startAt = this.cellGetOrCreate(row.rowId, mapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
if (!isValidTimestamp(startAt)) {
|
||||
return [];
|
||||
}
|
||||
const endAt =
|
||||
endMapping.status === 'ready'
|
||||
? this.cellGetOrCreate(row.rowId, endMapping.propertyId).jsonValue$
|
||||
.value
|
||||
: undefined;
|
||||
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||
const titleCell = this.cellGetOrCreate(row.rowId, titleColumn);
|
||||
const jsonTitle = titleCell.jsonValue$.value;
|
||||
const title =
|
||||
(typeof jsonTitle === 'string'
|
||||
? jsonTitle
|
||||
: titleCell.stringValue$.value) ?? '';
|
||||
const docDisplayMeta = this.manager.dataSource.serviceGet(
|
||||
DocDisplayMetaProvider
|
||||
);
|
||||
const resolveLinkedDocTitle = (pageId: string, title?: string) =>
|
||||
docDisplayMeta?.title(pageId, { title }).value;
|
||||
const titleSegments = getTitleSegments(
|
||||
titleCell.value$.value,
|
||||
title,
|
||||
resolveLinkedDocTitle
|
||||
);
|
||||
const cardProperties = this.visibleCardProperties$.value.flatMap(
|
||||
property => {
|
||||
const cell = this.cellGetOrCreate(row.rowId, property.id);
|
||||
const value = cell.stringValue$.value;
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
propertyId: property.id,
|
||||
value,
|
||||
};
|
||||
}
|
||||
);
|
||||
return {
|
||||
kind: 'row',
|
||||
id: `database:${row.rowId}`,
|
||||
sourceId: 'database',
|
||||
rowId: row.rowId,
|
||||
title,
|
||||
startAt,
|
||||
endAt: isValidTimestamp(endAt) && endAt >= startAt ? endAt : undefined,
|
||||
titleSegments,
|
||||
cardProperties,
|
||||
canResizeRange: endMapping.status === 'ready' && !this.readonly$.value,
|
||||
} satisfies CalendarRowEntry;
|
||||
});
|
||||
});
|
||||
|
||||
entries$ = computed<CalendarEntry[]>(() => {
|
||||
return [...this.rowEntries$.value, ...this.externalEntries$.value];
|
||||
});
|
||||
|
||||
externalSources$ = computed<CalendarExternalSource[]>(() => {
|
||||
const viewData = this.data$.value;
|
||||
if (!viewData) {
|
||||
return [];
|
||||
}
|
||||
return getCalendarExternalSources(this.dataSource, viewData);
|
||||
});
|
||||
|
||||
get type(): string {
|
||||
return this.data$.value?.mode ?? 'calendar';
|
||||
}
|
||||
|
||||
constructor(viewManager: ViewManager, viewId: string) {
|
||||
super(viewManager, viewId);
|
||||
}
|
||||
|
||||
isShow(rowId: string): boolean {
|
||||
if (this.filter$.value.conditions.length) {
|
||||
const rowMap = Object.fromEntries(
|
||||
this.propertiesRaw$.value.map(column => [
|
||||
column.id,
|
||||
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||
])
|
||||
);
|
||||
return evalFilter(this.filter$.value, rowMap);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override rowsMapping(rows: Row[]) {
|
||||
return this.sortManager.sort(super.rowsMapping(rows));
|
||||
}
|
||||
|
||||
propertyGetOrCreate(propertyId: string): CalendarProperty {
|
||||
return new CalendarProperty(this, propertyId);
|
||||
}
|
||||
|
||||
override rowGetOrCreate(rowId: string): CalendarRow {
|
||||
return new CalendarRow(this, rowId);
|
||||
}
|
||||
|
||||
setStartDateColumn(propertyId: string) {
|
||||
this.dataUpdate(data => ({
|
||||
date: {
|
||||
...getDateData(data),
|
||||
startColumnId: propertyId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setDateColumn(propertyId: string) {
|
||||
this.setStartDateColumn(propertyId);
|
||||
}
|
||||
|
||||
setEndDateColumn(propertyId: string | undefined) {
|
||||
this.dataUpdate(data => ({
|
||||
date: {
|
||||
...getDateData(data),
|
||||
endColumnId: propertyId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setWorkspaceCalendarEnabled(enabled: boolean) {
|
||||
this.dataUpdate(data => ({
|
||||
sources: {
|
||||
...data.sources,
|
||||
workspaceCalendar: {
|
||||
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setWorkspaceCalendarSubscriptionIds(subscriptionIds?: string[]) {
|
||||
this.dataUpdate(data => ({
|
||||
sources: {
|
||||
...data.sources,
|
||||
workspaceCalendar: {
|
||||
...(data.sources?.workspaceCalendar ?? { enabled: true }),
|
||||
subscriptionIds,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
dismissEmptyMonthHint() {
|
||||
this.dataUpdate(data => ({
|
||||
ui: {
|
||||
...data.ui,
|
||||
emptyMonthHintDismissed: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
getDocDisplayTitle(docId: string) {
|
||||
return (
|
||||
this.manager.dataSource.serviceGet(DocDisplayMetaProvider)?.title(docId)
|
||||
.value ?? 'Untitled'
|
||||
);
|
||||
}
|
||||
|
||||
createStartDateColumn() {
|
||||
const id = this.propertyAdd('end', {
|
||||
type: 'date',
|
||||
name: 'Date',
|
||||
});
|
||||
if (id) {
|
||||
this.setStartDateColumn(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createDateColumn() {
|
||||
return this.createStartDateColumn();
|
||||
}
|
||||
|
||||
createEndDateColumn() {
|
||||
const id = this.propertyAdd('end', {
|
||||
type: 'date',
|
||||
name: 'End Date',
|
||||
});
|
||||
if (id) {
|
||||
this.setEndDateColumn(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
createRowOnDate(date: number | Date) {
|
||||
const mapping = this.startDateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const rowId = this.rowAdd('end');
|
||||
const filter = this.filter$.value;
|
||||
if (filter.conditions.length > 0) {
|
||||
const defaultValues = generateDefaultValues(filter, this.vars$.value);
|
||||
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
|
||||
const property = this.propertyGetOrCreate(propertyId);
|
||||
const propertyMeta = property.meta$.value;
|
||||
if (propertyMeta) {
|
||||
const value = fromJson(propertyMeta.config, {
|
||||
value: jsonValue,
|
||||
data: property.data$.value,
|
||||
dataSource: this.dataSource,
|
||||
});
|
||||
this.cellGetOrCreate(rowId, propertyId).valueSet(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(
|
||||
toTimestamp(date)
|
||||
);
|
||||
this.dismissEmptyMonthHint();
|
||||
return rowId;
|
||||
}
|
||||
|
||||
createLinkedDocRowOnDate(date: number | Date, docId: string) {
|
||||
const rowId = this.createRowOnDate(date);
|
||||
if (!rowId) return;
|
||||
const titleColumn = this.mainProperties$.value.titleColumn ?? 'title';
|
||||
this.cellGetOrCreate(rowId, titleColumn).valueSet(
|
||||
createLinkedDocTitle(docId)
|
||||
);
|
||||
return rowId;
|
||||
}
|
||||
|
||||
moveRowToDate(rowId: string, date: number | Date) {
|
||||
const mapping = this.startDateMapping$.value;
|
||||
if (mapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const value = toTimestamp(date);
|
||||
const oldStartAt = this.cellGetOrCreate(rowId, mapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
if (endMapping.status === 'ready' && isValidTimestamp(oldStartAt)) {
|
||||
const oldEndAt = this.cellGetOrCreate(rowId, endMapping.propertyId)
|
||||
.jsonValue$.value;
|
||||
if (isValidTimestamp(oldEndAt) && oldEndAt >= oldStartAt) {
|
||||
this.cellGetOrCreate(rowId, endMapping.propertyId).jsonValueSet(
|
||||
value + (oldEndAt - oldStartAt)
|
||||
);
|
||||
}
|
||||
}
|
||||
this.cellGetOrCreate(rowId, mapping.propertyId).jsonValueSet(value);
|
||||
}
|
||||
|
||||
resizeRowRange(rowId: string, edge: 'start' | 'end', date: number | Date) {
|
||||
const startMapping = this.startDateMapping$.value;
|
||||
const endMapping = this.endDateMapping$.value;
|
||||
if (startMapping.status !== 'ready' || endMapping.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
const startCell = this.cellGetOrCreate(rowId, startMapping.propertyId);
|
||||
const endCell = this.cellGetOrCreate(rowId, endMapping.propertyId);
|
||||
const startAt = startCell.jsonValue$.value;
|
||||
const endAt = endCell.jsonValue$.value;
|
||||
if (!isValidTimestamp(startAt) || !isValidTimestamp(endAt)) {
|
||||
return;
|
||||
}
|
||||
const value = toTimestamp(date);
|
||||
if (edge === 'start') {
|
||||
startCell.jsonValueSet(Math.min(value, endAt));
|
||||
} else {
|
||||
endCell.jsonValueSet(Math.max(value, startAt));
|
||||
}
|
||||
}
|
||||
|
||||
async loadExternalEntries(range: CalendarEntryRange) {
|
||||
const requestId = ++this.externalEntriesRequestId;
|
||||
const viewData = this.data$.value;
|
||||
if (!viewData) {
|
||||
this.externalEntries$.value = [];
|
||||
return [];
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
this.externalSources$.value.map(source =>
|
||||
Promise.resolve(source.getEntries(range))
|
||||
)
|
||||
);
|
||||
const entries = results.flatMap(result =>
|
||||
result.status === 'fulfilled' ? result.value : []
|
||||
);
|
||||
if (requestId === this.externalEntriesRequestId) {
|
||||
this.externalEntries$.value = entries;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
export class CalendarProperty extends PropertyBase {
|
||||
hide$ = computed(() => false);
|
||||
|
||||
constructor(view: CalendarSingleView, propertyId: string) {
|
||||
super(view as SingleView, propertyId);
|
||||
}
|
||||
|
||||
hideSet(_hide: boolean): void {}
|
||||
|
||||
move(_position: InsertToPosition): void {}
|
||||
}
|
||||
|
||||
export class CalendarRow extends RowBase {
|
||||
constructor(
|
||||
readonly calendarView: CalendarSingleView,
|
||||
rowId: string
|
||||
) {
|
||||
super(calendarView, rowId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { viewType } from '../../core/view/data-view.js';
|
||||
import { CalendarSingleView } from './calendar-view-manager.js';
|
||||
import type { CalendarViewData } from './types.js';
|
||||
|
||||
export const calendarViewType = viewType('calendar');
|
||||
|
||||
export const calendarViewModel = calendarViewType.createModel<CalendarViewData>(
|
||||
{
|
||||
defaultName: 'Calendar View',
|
||||
dataViewManager: CalendarSingleView,
|
||||
defaultData: viewManager => {
|
||||
return {
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
date: {},
|
||||
card: {
|
||||
titleColumnId: viewManager.dataSource.properties$.value.find(
|
||||
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||
),
|
||||
visiblePropertyIds: [],
|
||||
},
|
||||
sources: {
|
||||
workspaceCalendar: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
ui: {},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { pcEffects } from './pc/effect.js';
|
||||
|
||||
export function calendarEffects() {
|
||||
pcEffects();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './calendar-view-manager.js';
|
||||
export * from './define.js';
|
||||
export * from './layout.js';
|
||||
export * from './renderer.js';
|
||||
export * from './source.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { CalendarEntry } from './types.js';
|
||||
|
||||
export type CalendarDayLayout = {
|
||||
date: number;
|
||||
inMonth: boolean;
|
||||
entries: CalendarEntry[];
|
||||
segments: CalendarRangeSegment[];
|
||||
};
|
||||
|
||||
export type CalendarRangeSegment = {
|
||||
entry: CalendarEntry;
|
||||
weekIndex: number;
|
||||
startIndex: number;
|
||||
span: number;
|
||||
slot: number;
|
||||
startsBeforeWeek: boolean;
|
||||
endsAfterWeek: boolean;
|
||||
};
|
||||
|
||||
export type CalendarMonthLayout = {
|
||||
from: number;
|
||||
to: number;
|
||||
weeks: CalendarDayLayout[][];
|
||||
days: CalendarDayLayout[];
|
||||
segments: CalendarRangeSegment[];
|
||||
};
|
||||
|
||||
export type CalendarMonthLayoutOptions = {
|
||||
month: number | Date;
|
||||
entries: CalendarEntry[];
|
||||
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
};
|
||||
|
||||
const startOfDay = (date: Date) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
|
||||
const addDays = (date: number, days: number) => {
|
||||
const current = new Date(date);
|
||||
return startOfDay(
|
||||
new Date(
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate() + days
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const endOfDay = (date: number) => addDays(date, 1) - 1;
|
||||
|
||||
const toDate = (value: number | Date) =>
|
||||
value instanceof Date ? value : new Date(value);
|
||||
|
||||
export const getCalendarVisibleMonthRange = (
|
||||
month: number | Date,
|
||||
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0
|
||||
) => {
|
||||
const cursor = toDate(month);
|
||||
const monthStart = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
||||
const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
|
||||
const startOffset = (monthStart.getDay() - weekStartsOn + 7) % 7;
|
||||
const endOffset = (weekStartsOn + 6 - monthEnd.getDay() + 7) % 7;
|
||||
const from = startOfDay(
|
||||
new Date(
|
||||
monthStart.getFullYear(),
|
||||
monthStart.getMonth(),
|
||||
monthStart.getDate() - startOffset
|
||||
)
|
||||
);
|
||||
const to = endOfDay(
|
||||
startOfDay(
|
||||
new Date(
|
||||
monthEnd.getFullYear(),
|
||||
monthEnd.getMonth(),
|
||||
monthEnd.getDate() + endOffset
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
monthStart: startOfDay(monthStart),
|
||||
monthEnd: endOfDay(startOfDay(monthEnd)),
|
||||
};
|
||||
};
|
||||
|
||||
const isRangeEntry = (entry: CalendarEntry) =>
|
||||
entry.endAt != null &&
|
||||
getRangeEndDay(entry) > startOfDay(new Date(entry.startAt));
|
||||
|
||||
const getRangeEndDay = (entry: CalendarEntry) => {
|
||||
const endAt = entry.endAt ?? entry.startAt;
|
||||
const end = new Date(endAt);
|
||||
if (
|
||||
entry.kind === 'external' &&
|
||||
entry.allDay &&
|
||||
endAt > entry.startAt &&
|
||||
end.getHours() === 0 &&
|
||||
end.getMinutes() === 0 &&
|
||||
end.getSeconds() === 0 &&
|
||||
end.getMilliseconds() === 0
|
||||
) {
|
||||
return addDays(startOfDay(end), -1);
|
||||
}
|
||||
return startOfDay(end);
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const getDayOffset = (days: CalendarDayLayout[], date: number) =>
|
||||
days.findIndex(day => day.date === date);
|
||||
|
||||
const assignSegmentSlots = (
|
||||
weeks: CalendarDayLayout[][],
|
||||
segments: CalendarRangeSegment[]
|
||||
) => {
|
||||
for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
|
||||
const weekSegments = segments.filter(
|
||||
segment => segment.weekIndex === weekIndex
|
||||
);
|
||||
const slots: boolean[][] = [];
|
||||
for (const segment of weekSegments) {
|
||||
let slot = 0;
|
||||
while (
|
||||
slots[slot]?.some(
|
||||
(occupied, index) =>
|
||||
occupied &&
|
||||
index >= segment.startIndex &&
|
||||
index < segment.startIndex + segment.span
|
||||
)
|
||||
) {
|
||||
slot++;
|
||||
}
|
||||
const slotDays = (slots[slot] ??= Array.from({ length: 7 }, () => false));
|
||||
for (
|
||||
let index = segment.startIndex;
|
||||
index < segment.startIndex + segment.span;
|
||||
index++
|
||||
) {
|
||||
slotDays[index] = true;
|
||||
}
|
||||
segment.slot = slot;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getCalendarDaySegmentSlots = (
|
||||
day: CalendarDayLayout,
|
||||
ignoredEntryId?: string
|
||||
) => {
|
||||
return Math.max(
|
||||
0,
|
||||
...day.segments
|
||||
.filter(segment => segment.entry.id !== ignoredEntryId)
|
||||
.map(segment => segment.slot + 1)
|
||||
);
|
||||
};
|
||||
|
||||
export const getCalendarDayContentSlots = (
|
||||
day: CalendarDayLayout,
|
||||
ignoredEntryId?: string
|
||||
) => {
|
||||
return (
|
||||
getCalendarDaySegmentSlots(day, ignoredEntryId) +
|
||||
day.entries.filter(entry => entry.id !== ignoredEntryId).length
|
||||
);
|
||||
};
|
||||
|
||||
export const createCalendarMonthLayout = ({
|
||||
month,
|
||||
entries,
|
||||
weekStartsOn = 0,
|
||||
}: CalendarMonthLayoutOptions): CalendarMonthLayout => {
|
||||
const range = getCalendarVisibleMonthRange(month, weekStartsOn);
|
||||
const cursor = toDate(month);
|
||||
const days: CalendarDayLayout[] = [];
|
||||
const dayByTime = new Map<number, CalendarDayLayout>();
|
||||
|
||||
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
|
||||
const day: CalendarDayLayout = {
|
||||
date,
|
||||
inMonth:
|
||||
new Date(date).getMonth() === cursor.getMonth() &&
|
||||
new Date(date).getFullYear() === cursor.getFullYear(),
|
||||
entries: [],
|
||||
segments: [],
|
||||
};
|
||||
days.push(day);
|
||||
dayByTime.set(date, day);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isRangeEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
const day = dayByTime.get(startOfDay(new Date(entry.startAt)));
|
||||
if (day) {
|
||||
day.entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const segments: CalendarRangeSegment[] = [];
|
||||
const rangeEntries = entries.filter(isRangeEntry);
|
||||
const visibleEndDay = startOfDay(new Date(range.to));
|
||||
for (const entry of rangeEntries) {
|
||||
const entryStart = startOfDay(new Date(entry.startAt));
|
||||
const entryEnd = getRangeEndDay(entry);
|
||||
if (entryEnd < range.from || entryStart > visibleEndDay) {
|
||||
continue;
|
||||
}
|
||||
const start = clamp(entryStart, range.from, visibleEndDay);
|
||||
const end = clamp(entryEnd, range.from, visibleEndDay);
|
||||
const startOffset = getDayOffset(days, start);
|
||||
const endOffset = getDayOffset(days, end);
|
||||
if (startOffset < 0 || endOffset < 0) {
|
||||
continue;
|
||||
}
|
||||
let offset = startOffset;
|
||||
while (offset <= endOffset) {
|
||||
const weekIndex = Math.floor(offset / 7);
|
||||
const startIndex = offset % 7;
|
||||
const weekEndOffset = weekIndex * 7 + 6;
|
||||
const span = Math.min(endOffset, weekEndOffset) - offset + 1;
|
||||
const segment = {
|
||||
entry,
|
||||
weekIndex,
|
||||
startIndex,
|
||||
span,
|
||||
slot: 0,
|
||||
startsBeforeWeek: startOffset < weekIndex * 7,
|
||||
endsAfterWeek: endOffset > weekEndOffset,
|
||||
};
|
||||
segments.push(segment);
|
||||
for (let index = 0; index < span; index++) {
|
||||
days[offset + index]?.segments.push(segment);
|
||||
}
|
||||
offset += span;
|
||||
}
|
||||
}
|
||||
|
||||
const weeks: CalendarDayLayout[][] = [];
|
||||
for (let index = 0; index < days.length; index += 7) {
|
||||
weeks.push(days.slice(index, index + 7));
|
||||
}
|
||||
|
||||
assignSegmentSlots(weeks, segments);
|
||||
|
||||
return { from: range.from, to: range.to, weeks, days, segments };
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
CalendarPanelIcon,
|
||||
DateTimeIcon,
|
||||
PinIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
import type { DataViewRootUILogic } from '../../../core/data-view.js';
|
||||
import type { CalendarSingleView } from '../calendar-view-manager.js';
|
||||
import type { CalendarEntry } from '../types.js';
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
});
|
||||
|
||||
export const formatEntryTime = (entry: CalendarEntry) => {
|
||||
const formatter = entry.allDay ? dateFormatter : dateTimeFormatter;
|
||||
const start = formatter.format(new Date(entry.startAt));
|
||||
if (!entry.endAt) {
|
||||
return start;
|
||||
}
|
||||
return `${start} - ${formatter.format(new Date(entry.endAt))}`;
|
||||
};
|
||||
|
||||
export const openCalendarEntry = (
|
||||
root: DataViewRootUILogic,
|
||||
view: CalendarSingleView,
|
||||
entry: CalendarEntry,
|
||||
target: HTMLElement,
|
||||
options?: { selectEntry?: (entryId: string | undefined) => void }
|
||||
) => {
|
||||
if (entry.kind === 'row') {
|
||||
options?.selectEntry?.(entry.id);
|
||||
root.openDetailPanel({
|
||||
view,
|
||||
rowId: entry.rowId,
|
||||
onClose: () => options?.selectEntry?.(undefined),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
popMenu(popupTargetFromElement(target), {
|
||||
options: {
|
||||
items: [
|
||||
() => html`
|
||||
<div class="calendar-event-popover">
|
||||
<div class="calendar-event-popover-title">${entry.title}</div>
|
||||
<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon"
|
||||
>${CalendarPanelIcon()}</span
|
||||
>
|
||||
<span>${entry.calendarName ?? 'Calendar event'}</span>
|
||||
</div>
|
||||
<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${DateTimeIcon()}</span>
|
||||
<span>${formatEntryTime(entry)}</span>
|
||||
</div>
|
||||
${entry.location
|
||||
? html`<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${PinIcon()}</span>
|
||||
<span>${entry.location}</span>
|
||||
</div>`
|
||||
: ''}
|
||||
${entry.description
|
||||
? html`<div class="calendar-event-popover-row">
|
||||
<span class="calendar-event-popover-icon">${TextIcon()}</span>
|
||||
<span class="calendar-event-popover-description"
|
||||
>${entry.description}</span
|
||||
>
|
||||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
`,
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { DndController } from '@blocksuite/std';
|
||||
|
||||
import type { CalendarEntry, CalendarRowEntry } from '../types.js';
|
||||
import { getCalendarDateFromPoint } from './hit-test.js';
|
||||
|
||||
export type CalendarDndEntity =
|
||||
| {
|
||||
type: 'calendar-entry';
|
||||
entryId: string;
|
||||
}
|
||||
| {
|
||||
type: 'doc';
|
||||
docId: string;
|
||||
};
|
||||
|
||||
type CalendarDndData = {
|
||||
bsEntity?: unknown;
|
||||
entity?: unknown;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
export const getCalendarDndEntity = (
|
||||
data: unknown
|
||||
): CalendarDndEntity | undefined => {
|
||||
if (!isRecord(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bsEntity = (data as CalendarDndData).bsEntity;
|
||||
if (isRecord(bsEntity)) {
|
||||
if (
|
||||
bsEntity.type === 'calendar-entry' &&
|
||||
typeof bsEntity.entryId === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'calendar-entry',
|
||||
entryId: bsEntity.entryId,
|
||||
};
|
||||
}
|
||||
if (bsEntity.type === 'doc' && typeof bsEntity.docId === 'string') {
|
||||
return {
|
||||
type: 'doc',
|
||||
docId: bsEntity.docId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const entity = (data as CalendarDndData).entity;
|
||||
if (
|
||||
isRecord(entity) &&
|
||||
entity.type === 'doc' &&
|
||||
typeof entity.id === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'doc',
|
||||
docId: entity.id,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
export type CalendarDndCallbacks = {
|
||||
getEntry: (entryId: string) => CalendarEntry | undefined;
|
||||
canDragEntry: () => boolean;
|
||||
canDrop: (entity: CalendarDndEntity) => boolean;
|
||||
onEntryDragStart: (entry: CalendarRowEntry) => void;
|
||||
onEntryDragEnd: () => void;
|
||||
onDropTargetChange: (
|
||||
date: number | undefined,
|
||||
entity?: CalendarDndEntity
|
||||
) => void;
|
||||
onDrop: (entity: CalendarDndEntity, date: number) => void;
|
||||
};
|
||||
|
||||
type ElementCleanup = {
|
||||
element: HTMLElement;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
export class CalendarDnd {
|
||||
private readonly entryCleanups = new Map<string, ElementCleanup>();
|
||||
|
||||
private rootCleanup?: ElementCleanup;
|
||||
|
||||
constructor(
|
||||
private readonly dnd: DndController | undefined,
|
||||
private readonly callbacks: CalendarDndCallbacks
|
||||
) {}
|
||||
|
||||
bindRoot(element?: Element) {
|
||||
if (!this.dnd || !(element instanceof HTMLElement)) {
|
||||
this.cleanupRoot();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rootCleanup?.element === element) {
|
||||
return;
|
||||
}
|
||||
this.cleanupRoot();
|
||||
|
||||
const cleanup = this.dnd.dropTarget<CalendarDndEntity, { date?: number }>({
|
||||
element,
|
||||
getIsSticky: () => true,
|
||||
setDropData: ({ input }) => ({
|
||||
date: getCalendarDateFromPoint(element, input.clientX, input.clientY),
|
||||
}),
|
||||
canDrop: ({ source, input }) => {
|
||||
const entity = getCalendarDndEntity(source.data);
|
||||
const date = getCalendarDateFromPoint(
|
||||
element,
|
||||
input.clientX,
|
||||
input.clientY
|
||||
);
|
||||
return entity && date !== undefined
|
||||
? this.callbacks.canDrop(entity)
|
||||
: false;
|
||||
},
|
||||
onDrag: ({ source, location }) => {
|
||||
this.updateDropTarget(element, source.data, location.current.input);
|
||||
},
|
||||
onDragEnter: ({ source, location }) => {
|
||||
this.updateDropTarget(element, source.data, location.current.input);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
},
|
||||
onDrop: ({ source, location }) => {
|
||||
const entity = getCalendarDndEntity(source.data);
|
||||
const date = getCalendarDateFromPoint(
|
||||
element,
|
||||
location.current.input.clientX,
|
||||
location.current.input.clientY
|
||||
);
|
||||
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||
this.callbacks.onDrop(entity, date);
|
||||
}
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
this.rootCleanup = { element, cleanup };
|
||||
}
|
||||
|
||||
bindEntry(
|
||||
key: string,
|
||||
entry: CalendarEntry,
|
||||
element?: Element,
|
||||
disabled = false
|
||||
) {
|
||||
if (
|
||||
!this.dnd ||
|
||||
!(element instanceof HTMLElement) ||
|
||||
entry.kind !== 'row' ||
|
||||
disabled
|
||||
) {
|
||||
this.cleanupEntry(key);
|
||||
if (element instanceof HTMLElement) {
|
||||
element.setAttribute('draggable', 'false');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.entryCleanups.get(key);
|
||||
if (current?.element === element) {
|
||||
return;
|
||||
}
|
||||
this.cleanupEntry(key);
|
||||
|
||||
const cleanup = this.dnd.draggable<CalendarDndEntity>({
|
||||
element,
|
||||
canDrag: () => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
return currentEntry?.kind === 'row'
|
||||
? this.callbacks.canDragEntry()
|
||||
: false;
|
||||
},
|
||||
setDragData: () => ({
|
||||
type: 'calendar-entry',
|
||||
entryId: entry.id,
|
||||
}),
|
||||
setDragPreview: ({ container, setOffset }) => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
const preview = document.createElement('div');
|
||||
preview.textContent = currentEntry?.title || 'Untitled';
|
||||
preview.style.cssText =
|
||||
'padding:0 6px;height:22px;line-height:22px;border-radius:4px;' +
|
||||
'font-size:12px;white-space:nowrap;overflow:hidden;' +
|
||||
'background:var(--affine-hover-color,#f5f5f5);' +
|
||||
'color:var(--affine-text-primary-color,#333);' +
|
||||
'max-width:140px;text-overflow:ellipsis;pointer-events:none;';
|
||||
container.append(preview);
|
||||
setOffset({ x: 10, y: 11 });
|
||||
},
|
||||
onDragStart: () => {
|
||||
const currentEntry = this.callbacks.getEntry(entry.id);
|
||||
if (currentEntry?.kind === 'row') {
|
||||
this.callbacks.onEntryDragStart(currentEntry);
|
||||
}
|
||||
},
|
||||
onDrop: () => {
|
||||
this.callbacks.onEntryDragEnd();
|
||||
},
|
||||
});
|
||||
|
||||
this.entryCleanups.set(key, { element, cleanup });
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.cleanupRoot();
|
||||
for (const key of this.entryCleanups.keys()) {
|
||||
this.cleanupEntry(key);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupEntry(key: string) {
|
||||
this.entryCleanups.get(key)?.cleanup();
|
||||
this.entryCleanups.delete(key);
|
||||
}
|
||||
|
||||
private cleanupRoot() {
|
||||
this.rootCleanup?.cleanup();
|
||||
this.rootCleanup = undefined;
|
||||
}
|
||||
|
||||
private updateDropTarget(
|
||||
root: HTMLElement,
|
||||
data: unknown,
|
||||
input: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
) {
|
||||
const entity = getCalendarDndEntity(data);
|
||||
const date = getCalendarDateFromPoint(root, input.clientX, input.clientY);
|
||||
if (entity && date !== undefined && this.callbacks.canDrop(entity)) {
|
||||
this.callbacks.onDropTargetChange(date, entity);
|
||||
} else {
|
||||
this.callbacks.onDropTargetChange(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { CalendarViewUI } from './view.js';
|
||||
|
||||
export function pcEffects() {
|
||||
if (customElements.get('affine-data-view-calendar')) {
|
||||
return;
|
||||
}
|
||||
customElements.define('affine-data-view-calendar', CalendarViewUI);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const getCalendarDateFromPoint = (
|
||||
root: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) => {
|
||||
const doc = root.ownerDocument;
|
||||
const hitStack = doc.elementsFromPoint(clientX, clientY);
|
||||
|
||||
for (const element of hitStack) {
|
||||
const day = element.closest<HTMLElement>('.calendar-day[data-date]');
|
||||
if (day && root.contains(day)) {
|
||||
return Number(day.dataset['date']);
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of hitStack) {
|
||||
const week =
|
||||
element.closest<HTMLElement>('.calendar-week') ??
|
||||
element.closest<HTMLElement>('.calendar-segments')?.parentElement;
|
||||
if (week && root.contains(week)) {
|
||||
const days = week.querySelectorAll<HTMLElement>('.calendar-day');
|
||||
for (const day of days) {
|
||||
const rect = day.getBoundingClientRect();
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX < rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY < rect.bottom &&
|
||||
day.dataset['date']
|
||||
) {
|
||||
return Number(day.dataset['date']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1,708 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const calendarViewStyles = css`
|
||||
affine-data-view-calendar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
--calendar-entry-height: 22px;
|
||||
--calendar-entry-gap: 3px;
|
||||
--calendar-entry-slot-height: calc(
|
||||
var(--calendar-entry-height) + var(--calendar-entry-gap)
|
||||
);
|
||||
--calendar-grid-border-color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-border-color) 58%,
|
||||
transparent
|
||||
);
|
||||
--calendar-entry-bg: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 12%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
--calendar-entry-hover-bg: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 18%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
--calendar-entry-text-color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 72%,
|
||||
var(--affine-text-primary-color)
|
||||
);
|
||||
--calendar-external-fallback-color: #b45309;
|
||||
}
|
||||
|
||||
.calendar-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.calendar-shell {
|
||||
position: relative;
|
||||
min-width: 720px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-title {
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-nav button,
|
||||
.calendar-setup button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--affine-background-primary-color);
|
||||
color: var(--affine-text-primary-color);
|
||||
height: 28px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-nav button svg,
|
||||
.calendar-setup button svg,
|
||||
.calendar-new-row svg,
|
||||
.calendar-empty-month-hint-action svg,
|
||||
.calendar-empty-month-hint-close svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-icon-secondary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-nav .calendar-icon-button {
|
||||
width: 28px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.calendar-nav .calendar-today-button {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-weekdays,
|
||||
.calendar-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.calendar-week {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-segments {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: var(--calendar-entry-slot-height);
|
||||
row-gap: 0;
|
||||
column-gap: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-segments .calendar-entry {
|
||||
align-self: start;
|
||||
height: var(--calendar-entry-height);
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.calendar-segments .calendar-entry-preview {
|
||||
align-self: start;
|
||||
pointer-events: none;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
border-top: 1px solid var(--calendar-grid-border-color);
|
||||
border-left: 1px solid var(--calendar-grid-border-color);
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
position: relative;
|
||||
min-height: 112px;
|
||||
border-right: 1px solid var(--calendar-grid-border-color);
|
||||
border-bottom: 1px solid var(--calendar-grid-border-color);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.calendar-day.is-outside {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-background-secondary-color) 55%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day:not(.is-outside):hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 2%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-drop-target {
|
||||
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||
background: color-mix(in srgb, var(--affine-primary-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.calendar-day.is-today {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 6%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
margin-bottom: 4px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.calendar-day:not(.is-outside) .calendar-day-number {
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.calendar-day.is-outside .calendar-day-number {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--affine-text-secondary-color) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-today .calendar-day-number {
|
||||
color: var(--affine-primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 9%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--calendar-entry-height);
|
||||
margin-top: var(--calendar-entry-gap);
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--calendar-entry-text-color);
|
||||
background: var(--calendar-entry-bg);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-nav button:hover,
|
||||
.calendar-setup button:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.calendar-entry.row:hover {
|
||||
background: var(--calendar-entry-hover-bg);
|
||||
}
|
||||
|
||||
.calendar-entry:focus-visible {
|
||||
outline: 1px solid var(--affine-primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.calendar-entry.external:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.calendar-entry.selected {
|
||||
box-shadow: inset 0 0 0 1px var(--affine-primary-color);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 15%,
|
||||
var(--calendar-entry-bg)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry.continues-left {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-entry.continues-right {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry-title.is-empty {
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.calendar-entry-title.title-segments {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment {
|
||||
gap: 3px;
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-entry-title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry-title-segment.linked-doc-segment .calendar-entry-title-text {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.calendar-entry-properties {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-entry-property {
|
||||
max-width: 72px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--affine-pure-white) 80%, transparent);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.calendar-entry.external {
|
||||
color: var(--affine-pure-white);
|
||||
background: var(
|
||||
--calendar-external-color,
|
||||
var(--calendar-external-fallback-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-entry[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.calendar-entry[draggable='true']:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.calendar-resize-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: ew-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.calendar-resize-handle.left {
|
||||
left: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.calendar-resize-handle.right {
|
||||
right: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.calendar-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 1px;
|
||||
background: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-resize-handle:hover::after {
|
||||
background: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-entry:hover .calendar-resize-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-entry-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: var(--calendar-entry-height);
|
||||
height: var(--calendar-entry-height);
|
||||
margin-top: var(--calendar-entry-gap);
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
border: 1.5px dashed var(--affine-primary-color);
|
||||
background: color-mix(in srgb, var(--affine-primary-color) 6%, transparent);
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-entry-preview svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-entry-preview.continues-left {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: none;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.calendar-entry-preview.continues-right {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: none;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.calendar-day-entries > .calendar-entry:first-child,
|
||||
.calendar-day-entries > .calendar-entry-preview:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.calendar-day-entries {
|
||||
padding-top: calc(
|
||||
var(--calendar-segment-slots, 0) * var(--calendar-entry-slot-height)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-new-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
margin-top: 3px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
padding: 3px 8px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
opacity 0.1s ease,
|
||||
background 0.1s ease;
|
||||
}
|
||||
|
||||
.calendar-new-row svg,
|
||||
.calendar-empty-month-hint-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row,
|
||||
.calendar-new-row:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 10%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day:hover .calendar-new-row:disabled,
|
||||
.calendar-day.is-today:hover .calendar-new-row:disabled,
|
||||
.calendar-new-row:disabled {
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover .calendar-new-row,
|
||||
.calendar-day.is-today .calendar-new-row:focus-visible {
|
||||
background: var(--affine-primary-color);
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
|
||||
.calendar-day.is-today .calendar-new-row:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 88%,
|
||||
var(--affine-pure-white)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-day.is-today:hover .calendar-new-row svg,
|
||||
.calendar-day.is-today .calendar-new-row:focus-visible svg {
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
|
||||
.calendar-new-row:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 16%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--affine-primary-color) 18%, transparent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-background-primary-color) 92%,
|
||||
var(--affine-primary-color)
|
||||
);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-copy {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-title {
|
||||
flex: 0 0 auto;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-body {
|
||||
min-width: 0;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-action,
|
||||
.calendar-empty-month-hint-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 3px 8px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 10%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
color: var(--affine-primary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-close {
|
||||
width: 24px;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.calendar-empty-month-hint-action:hover,
|
||||
.calendar-empty-month-hint-close:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--affine-primary-color) 16%,
|
||||
var(--affine-background-primary-color)
|
||||
);
|
||||
}
|
||||
|
||||
.calendar-setup-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-setup-wrap .calendar-shell {
|
||||
filter: grayscale(1) blur(1px);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-setup {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar-setup button {
|
||||
height: 32px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.calendar-event-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 318px;
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-title {
|
||||
padding: 2px 4px;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 2px 4px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.calendar-event-popover-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 16px;
|
||||
height: 20px;
|
||||
color: var(--affine-icon-secondary);
|
||||
}
|
||||
|
||||
.calendar-event-popover-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.calendar-event-popover-description {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import './pc/effect.js';
|
||||
|
||||
import { createIcon } from '../../core/utils/uni-icon.js';
|
||||
import type { DataViewUILogicBaseConstructor } from '../../core/view/data-view-base.js';
|
||||
import { calendarViewModel } from './define.js';
|
||||
import { CalendarViewUILogic } from './pc/view.js';
|
||||
|
||||
export const calendarViewMeta = calendarViewModel.createMeta({
|
||||
icon: createIcon('TodayIcon'),
|
||||
pcLogic: () =>
|
||||
CalendarViewUILogic as unknown as DataViewUILogicBaseConstructor,
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import type { DataSource } from '../../core/data-source/base.js';
|
||||
import type {
|
||||
CalendarExternalSource,
|
||||
CalendarStoredViewData,
|
||||
} from './types.js';
|
||||
|
||||
export type CalendarExternalSourceFactory = {
|
||||
id: string;
|
||||
create(viewData: CalendarStoredViewData): CalendarExternalSource;
|
||||
};
|
||||
|
||||
export const CalendarExternalSourceProvider =
|
||||
createIdentifier<CalendarExternalSourceFactory>('calendar-external-source');
|
||||
|
||||
export const getCalendarExternalSources = (
|
||||
dataSource: DataSource,
|
||||
viewData: CalendarStoredViewData
|
||||
) =>
|
||||
Array.from(
|
||||
dataSource.provider.getAll(CalendarExternalSourceProvider).values()
|
||||
).map(source => source.create(viewData));
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { FilterGroup } from '../../core/filter/types.js';
|
||||
import type { Sort } from '../../core/sort/types.js';
|
||||
import type { BasicViewDataType } from '../../core/view/data-view.js';
|
||||
|
||||
export type CalendarWorkspaceSourceConfig = {
|
||||
enabled: boolean;
|
||||
subscriptionIds?: string[];
|
||||
};
|
||||
|
||||
export type CalendarUiData = {
|
||||
emptyMonthHintDismissed?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarCardProperty = {
|
||||
propertyId: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CalendarTitleSegment = {
|
||||
text: string;
|
||||
linkedDoc?: boolean;
|
||||
};
|
||||
|
||||
type CalendarViewDataShape = {
|
||||
filter: FilterGroup;
|
||||
sort?: Sort;
|
||||
date: {
|
||||
startColumnId?: string;
|
||||
endColumnId?: string;
|
||||
};
|
||||
card: {
|
||||
titleColumnId?: string;
|
||||
visiblePropertyIds: string[];
|
||||
};
|
||||
sources: {
|
||||
workspaceCalendar?: CalendarWorkspaceSourceConfig;
|
||||
};
|
||||
ui?: CalendarUiData;
|
||||
};
|
||||
|
||||
export type CalendarViewData = BasicViewDataType<
|
||||
'calendar',
|
||||
CalendarViewDataShape
|
||||
>;
|
||||
|
||||
export type CalendarStoredViewData = CalendarViewData;
|
||||
|
||||
export type CalendarEntryBase = {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
startAt: number;
|
||||
endAt?: number;
|
||||
allDay?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarRowEntry = CalendarEntryBase & {
|
||||
kind: 'row';
|
||||
sourceId: 'database';
|
||||
rowId: string;
|
||||
titleSegments?: CalendarTitleSegment[];
|
||||
cardProperties: CalendarCardProperty[];
|
||||
canResizeRange: boolean;
|
||||
};
|
||||
|
||||
export type CalendarExternalEntry = CalendarEntryBase & {
|
||||
kind: 'external';
|
||||
sourceId: string;
|
||||
externalId: string;
|
||||
calendarName?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
canResizeRange: false;
|
||||
};
|
||||
|
||||
export type CalendarEntry = CalendarRowEntry | CalendarExternalEntry;
|
||||
|
||||
export type CalendarEntryRange = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type CalendarExternalSource = {
|
||||
id: string;
|
||||
getSubscriptionOptions?(): CalendarExternalSourceSubscription[];
|
||||
openConnectSettings?(): void;
|
||||
getEntries(
|
||||
range: CalendarEntryRange
|
||||
): CalendarExternalEntry[] | Promise<CalendarExternalEntry[]>;
|
||||
};
|
||||
|
||||
export type CalendarExternalSourceSubscription = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
};
|
||||
@@ -1,13 +1,45 @@
|
||||
import { createViewConvert } from '../core/view/convert.js';
|
||||
import { calendarViewModel } from './calendar/index.js';
|
||||
import { kanbanViewModel } from './kanban/index.js';
|
||||
import { tableViewModel } from './table/index.js';
|
||||
|
||||
const headerToCalendarCard = (header?: { titleColumn?: string }) => ({
|
||||
titleColumnId: header?.titleColumn,
|
||||
visiblePropertyIds: [],
|
||||
});
|
||||
|
||||
const calendarCardToHeader = (card?: { titleColumnId?: string }) => ({
|
||||
titleColumn: card?.titleColumnId,
|
||||
});
|
||||
|
||||
export const viewConverts = [
|
||||
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||
filter: data.filter,
|
||||
header: data.header,
|
||||
})),
|
||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||
filter: data.filter,
|
||||
header: data.header,
|
||||
groupBy: data.groupBy,
|
||||
})),
|
||||
createViewConvert(tableViewModel, calendarViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
card: headerToCalendarCard(data.header),
|
||||
})),
|
||||
createViewConvert(kanbanViewModel, calendarViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
card: headerToCalendarCard(data.header),
|
||||
})),
|
||||
createViewConvert(calendarViewModel, tableViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
header: calendarCardToHeader(data.card),
|
||||
})),
|
||||
createViewConvert(calendarViewModel, kanbanViewModel, data => ({
|
||||
filter: data.filter,
|
||||
sort: data.sort,
|
||||
header: calendarCardToHeader(data.card),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { calendarEffects } from './calendar/effect.js';
|
||||
import { kanbanEffects } from './kanban/effect.js';
|
||||
import { tableEffects } from './table/effect.js';
|
||||
|
||||
export function viewPresetsEffects() {
|
||||
calendarEffects();
|
||||
kanbanEffects();
|
||||
tableEffects();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { calendarViewMeta } from './calendar/index.js';
|
||||
import { kanbanViewMeta } from './kanban/index.js';
|
||||
import { tableViewMeta } from './table/index.js';
|
||||
|
||||
export * from './calendar/index.js';
|
||||
export * from './convert.js';
|
||||
export * from './kanban/index.js';
|
||||
export * from './table/index.js';
|
||||
@@ -8,4 +10,5 @@ export * from './table/index.js';
|
||||
export const viewPresets = {
|
||||
tableViewMeta: tableViewMeta,
|
||||
kanbanViewMeta: kanbanViewMeta,
|
||||
calendarViewMeta: calendarViewMeta,
|
||||
};
|
||||
|
||||
+492
-369
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type Menu,
|
||||
menu,
|
||||
type MenuButtonData,
|
||||
type MenuConfig,
|
||||
@@ -16,22 +17,22 @@ import {
|
||||
InfoIcon,
|
||||
LayoutIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
SortIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { popPropertiesSetting } from '../../../../core/common/properties.js';
|
||||
import { filterTraitKey } from '../../../../core/filter/trait.js';
|
||||
import {
|
||||
popGroupSetting,
|
||||
popSelectGroupByProperty,
|
||||
buildGroupSelectItems,
|
||||
buildGroupSettingItems,
|
||||
} from '../../../../core/group-by/setting.js';
|
||||
import { groupTraitKey } from '../../../../core/group-by/trait.js';
|
||||
import {
|
||||
type DataViewUILogicBase,
|
||||
emptyFilterGroup,
|
||||
popCreateFilter,
|
||||
renderUniLit,
|
||||
} from '../../../../core/index.js';
|
||||
@@ -39,8 +40,6 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js';
|
||||
import { sortTraitKey } from '../../../../core/sort/manager.js';
|
||||
import { createSortUtils } from '../../../../core/sort/utils.js';
|
||||
import { WidgetBase } from '../../../../core/widget/widget-base.js';
|
||||
import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js';
|
||||
import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js';
|
||||
|
||||
const styles = css`
|
||||
.affine-database-toolbar-item.more-action {
|
||||
@@ -95,379 +94,486 @@ declare global {
|
||||
'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions;
|
||||
}
|
||||
}
|
||||
const createSettingMenus = (
|
||||
target: PopupTarget,
|
||||
dataViewLogic: DataViewUILogicBase,
|
||||
reopen: () => void,
|
||||
closeMenu: () => void
|
||||
) => {
|
||||
const view = dataViewLogic.view;
|
||||
const settingItems: MenuConfig[] = [];
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Properties',
|
||||
prefix: InfoIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${view.properties$.value.length} shown
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
popPropertiesSetting(
|
||||
target,
|
||||
{
|
||||
view: view,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (filterTrait) {
|
||||
const filterCount = filterTrait.filter$.value.conditions.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${filterCount === 0
|
||||
? ''
|
||||
: filterCount === 1
|
||||
? '1 filter'
|
||||
: `${filterCount} filters`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
if (!filterTrait.filter$.value.conditions.length) {
|
||||
popCreateFilter(
|
||||
target,
|
||||
{
|
||||
vars: view.vars$,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...(filterTrait.filter$.value ?? emptyFilterGroup),
|
||||
conditions: [
|
||||
...filterTrait.filter$.value.conditions,
|
||||
filter,
|
||||
],
|
||||
});
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
},
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (sortTrait) {
|
||||
const sortCount = sortTrait.sortList$.value.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Sort',
|
||||
prefix: SortIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${sortCount === 0
|
||||
? ''
|
||||
: sortCount === 1
|
||||
? '1 sort'
|
||||
: `${sortCount} sorts`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const sortList = sortTrait.sortList$.value;
|
||||
const sortUtils = createSortUtils(
|
||||
sortTrait,
|
||||
dataViewLogic.eventTrace
|
||||
);
|
||||
if (!sortList.length) {
|
||||
popCreateSort(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popSortRoot(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
title: {
|
||||
text: 'Sort',
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (groupTrait) {
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Group',
|
||||
prefix: GroupingIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${groupTrait.property$.value?.name$.value ?? ''}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const groupBy = groupTrait.property$.value;
|
||||
if (!groupBy) {
|
||||
popSelectGroupByProperty(
|
||||
target,
|
||||
groupTrait,
|
||||
{
|
||||
onSelect: () =>
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]),
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return settingItems;
|
||||
type Page =
|
||||
| 'main'
|
||||
| 'properties'
|
||||
| 'filter'
|
||||
| 'sort'
|
||||
| 'group'
|
||||
| 'group-select'
|
||||
| 'custom';
|
||||
|
||||
const pageTitles: Record<Exclude<Page, 'custom'>, string> = {
|
||||
main: 'View settings',
|
||||
properties: 'Properties',
|
||||
filter: 'Filter',
|
||||
sort: 'Sort',
|
||||
group: 'Group',
|
||||
'group-select': 'Group by',
|
||||
};
|
||||
|
||||
export const popViewOptions = (
|
||||
target: PopupTarget,
|
||||
dataViewLogic: DataViewUILogicBase,
|
||||
onClose?: () => void
|
||||
) => {
|
||||
const view = dataViewLogic.view;
|
||||
const reopen = () => {
|
||||
popViewOptions(target, dataViewLogic);
|
||||
};
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
const items: MenuConfig[] = [];
|
||||
items.push(
|
||||
menu.input({
|
||||
initialValue: view.name$.value,
|
||||
placeholder: 'View name',
|
||||
onChange: text => {
|
||||
view.nameSet(text);
|
||||
},
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menu => {
|
||||
const viewTypeItems = menu.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
return menu => {
|
||||
if (!menu.search(meta.model.defaultName)) {
|
||||
return;
|
||||
}
|
||||
const isSelected =
|
||||
meta.type === view.manager.currentView$.value?.type;
|
||||
const iconStyle = styleMap({
|
||||
fontSize: '24px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-icon-secondary)',
|
||||
});
|
||||
const textStyle = styleMap({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
const buttonData: MenuButtonData = {
|
||||
content: () => html`
|
||||
<div
|
||||
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
||||
>
|
||||
<div style="${iconStyle}">
|
||||
${renderUniLit(meta.renderer.icon)}
|
||||
</div>
|
||||
<div style="${textStyle}">${meta.model.defaultName}</div>
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const id = view.manager.currentViewId$.value;
|
||||
if (!id || meta.type === view.type) {
|
||||
return;
|
||||
}
|
||||
view.manager.viewChangeType(id, meta.type);
|
||||
dataViewLogic.clearSelection();
|
||||
},
|
||||
class: {},
|
||||
};
|
||||
const containerStyle = styleMap({
|
||||
flex: '1',
|
||||
});
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menu}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
})
|
||||
);
|
||||
if (!viewTypeItems.length) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: createSettingMenus(target, dataViewLogic, reopen, () =>
|
||||
handler.close()
|
||||
),
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
const currentPage = signal<Page>('main');
|
||||
const pageStack: Page[] = ['main'];
|
||||
|
||||
let menuHandler!: ReturnType<typeof popMenu>;
|
||||
let mainPageHeight: number | null = null;
|
||||
let customPageTitle = '';
|
||||
let customPageItems: () => MenuConfig[] = () => [];
|
||||
|
||||
const isDesktopMenu = () =>
|
||||
menuHandler.menu.menuElement.tagName.toLowerCase() === 'affine-menu';
|
||||
|
||||
const navigate = (page: Page) => {
|
||||
if (!isDesktopMenu()) {
|
||||
pageStack.push(page);
|
||||
currentPage.value = page;
|
||||
return;
|
||||
}
|
||||
if (mainPageHeight === null) {
|
||||
mainPageHeight =
|
||||
menuHandler.menu.menuElement.getBoundingClientRect().height;
|
||||
}
|
||||
menuHandler.menu.menuElement.style.height = `${mainPageHeight}px`;
|
||||
pageStack.push(page);
|
||||
currentPage.value = page;
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (pageStack.length > 1) {
|
||||
pageStack.pop();
|
||||
const dest = pageStack[pageStack.length - 1] ?? 'main';
|
||||
currentPage.value = dest;
|
||||
if (dest === 'main') {
|
||||
menuHandler.menu.menuElement.style.height = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToCustomPage = (
|
||||
title: string,
|
||||
getItems: () => MenuConfig[]
|
||||
) => {
|
||||
customPageTitle = title;
|
||||
customPageItems = getItems;
|
||||
navigate('custom');
|
||||
};
|
||||
|
||||
const titleConfig = {
|
||||
get text() {
|
||||
if (currentPage.value === 'custom') return customPageTitle;
|
||||
return (
|
||||
pageTitles[currentPage.value as Exclude<Page, 'custom'>] ??
|
||||
'View settings'
|
||||
);
|
||||
},
|
||||
get onBack(): ((menu: Menu) => false) | undefined {
|
||||
return currentPage.value !== 'main'
|
||||
? (_: Menu) => {
|
||||
goBack();
|
||||
return false;
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
get postfix() {
|
||||
if (currentPage.value !== 'properties') return undefined;
|
||||
const items = view.propertiesRaw$.value;
|
||||
const isAllShowed = items.every(p => !p.hide$.value);
|
||||
const clickChangeAll = () => {
|
||||
items.forEach(p => {
|
||||
if (p.hideCanSet) p.hideSet(isAllShowed);
|
||||
});
|
||||
};
|
||||
return () =>
|
||||
html`<div
|
||||
class="properties-group-op"
|
||||
style="padding:4px 8px;font-size:12px;line-height:20px;font-weight:500;border-radius:4px;cursor:pointer;color:var(--affine-primary-color);"
|
||||
@click="${clickChangeAll}"
|
||||
>
|
||||
${isAllShowed ? 'Hide All' : 'Show All'}
|
||||
</div>`;
|
||||
},
|
||||
get onClose() {
|
||||
return () => menuHandler?.menu.close();
|
||||
},
|
||||
};
|
||||
|
||||
const getPropertiesPageItems = (): MenuConfig[] => [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.duplicate();
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.delete();
|
||||
},
|
||||
class: { 'delete-item': true },
|
||||
}),
|
||||
() =>
|
||||
html`<data-view-properties-setting
|
||||
.view="${view}"
|
||||
></data-view-properties-setting>`,
|
||||
],
|
||||
})
|
||||
);
|
||||
handler = popMenu(target, {
|
||||
}),
|
||||
];
|
||||
|
||||
const getFilterPageItems = (): MenuConfig[] => {
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (!filterTrait) return getMainPageItems();
|
||||
return [
|
||||
menu.group({
|
||||
items: [
|
||||
() =>
|
||||
html`<filter-root-view
|
||||
.onBack="${goBack}"
|
||||
.vars="${view.vars$}"
|
||||
.filterGroup="${filterTrait.filter$}"
|
||||
.onChange="${filterTrait.filterSet}"
|
||||
></filter-root-view>`,
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Add',
|
||||
prefix: PlusIcon(),
|
||||
select: ele => {
|
||||
const value = filterTrait.filter$.value;
|
||||
popCreateFilter(popupTargetFromElement(ele), {
|
||||
vars: view.vars$,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...value,
|
||||
conditions: [...value.conditions, filter],
|
||||
});
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
});
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getSortPageItems = (): MenuConfig[] => {
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (!sortTrait) return getMainPageItems();
|
||||
const sortUtils = createSortUtils(sortTrait, dataViewLogic.eventTrace);
|
||||
return [
|
||||
() => html`<sort-root-view .sortUtils="${sortUtils}"></sort-root-view>`,
|
||||
menu.action({
|
||||
name: 'Add sort',
|
||||
prefix: PlusIcon(),
|
||||
select: ele => {
|
||||
popCreateSort(popupTargetFromElement(ele), { sortUtils });
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
class: { 'delete-item': true },
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
sortUtils.removeAll();
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getGroupPageItems = (): MenuConfig[] => {
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (!groupTrait) return getMainPageItems();
|
||||
const gProp = groupTrait.property$.value;
|
||||
if (!gProp) return [];
|
||||
return buildGroupSettingItems(
|
||||
groupTrait,
|
||||
() => navigate('group-select'),
|
||||
() => navigate('main')
|
||||
);
|
||||
};
|
||||
|
||||
const getGroupSelectPageItems = (): MenuConfig[] => {
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (!groupTrait) return getMainPageItems();
|
||||
return buildGroupSelectItems(groupTrait, id => {
|
||||
if (id) {
|
||||
if (pageStack.at(-1) === 'group-select') {
|
||||
pageStack[pageStack.length - 1] = 'group';
|
||||
} else {
|
||||
pageStack.push('group');
|
||||
}
|
||||
currentPage.value = 'group';
|
||||
} else {
|
||||
while (pageStack.length > 1) pageStack.pop();
|
||||
currentPage.value = 'main';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getMainPageItems = (): MenuConfig[] => {
|
||||
const items: MenuConfig[] = [];
|
||||
|
||||
items.push(
|
||||
menu.input({
|
||||
initialValue: view.name$.value,
|
||||
placeholder: 'View name',
|
||||
disableAutoFocus: true,
|
||||
onChange: text => {
|
||||
view.nameSet(text);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menuObj => {
|
||||
const viewTypeItems = menuObj.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
return menuObj => {
|
||||
if (!menuObj.search(meta.model.defaultName)) {
|
||||
return;
|
||||
}
|
||||
const isSelected =
|
||||
meta.type === view.manager.currentView$.value?.type;
|
||||
const iconStyle = styleMap({
|
||||
fontSize: '24px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-icon-secondary)',
|
||||
});
|
||||
const textStyle = styleMap({
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
color: isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
const buttonData: MenuButtonData = {
|
||||
content: () => html`
|
||||
<div
|
||||
style="width:100%;min-width:0;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 4px;white-space: nowrap;box-sizing:border-box;"
|
||||
>
|
||||
<div style="${iconStyle}">
|
||||
${renderUniLit(meta.renderer.icon)}
|
||||
</div>
|
||||
<div style="${textStyle}">
|
||||
${meta.model.defaultName}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
select: () => {
|
||||
const id = view.manager.currentViewId$.value;
|
||||
if (!id || meta.type === view.type) {
|
||||
return;
|
||||
}
|
||||
view.manager.viewChangeType(id, meta.type);
|
||||
dataViewLogic.clearSelection();
|
||||
},
|
||||
class: {},
|
||||
};
|
||||
const containerStyle = styleMap({
|
||||
flex: '1',
|
||||
});
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menuObj}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
})
|
||||
);
|
||||
if (!viewTypeItems.length) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:8px;padding:0 2px;"
|
||||
>
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const settingItems: MenuConfig[] = [];
|
||||
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Properties',
|
||||
prefix: InfoIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${view.properties$.value.length} shown
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('properties');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const filterTrait = view.traitGet(filterTraitKey);
|
||||
if (filterTrait) {
|
||||
const filterCount = filterTrait.filter$.value.conditions.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${filterCount === 0
|
||||
? ''
|
||||
: filterCount === 1
|
||||
? '1 active'
|
||||
: `${filterCount} active`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('filter');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const sortTrait = view.traitGet(sortTraitKey);
|
||||
if (sortTrait) {
|
||||
const sortCount = sortTrait.sortList$.value.length;
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Sort',
|
||||
prefix: SortIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${sortCount === 0
|
||||
? ''
|
||||
: sortCount === 1
|
||||
? '1 active'
|
||||
: `${sortCount} active`}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
navigate('sort');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const groupTrait = view.traitGet(groupTraitKey);
|
||||
if (groupTrait) {
|
||||
settingItems.push(
|
||||
menu.action({
|
||||
name: 'Group',
|
||||
prefix: GroupingIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html`
|
||||
<div style="font-size: 14px;">
|
||||
${groupTrait.property$.value?.name$.value ?? ''}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}
|
||||
`,
|
||||
select: () => {
|
||||
const hasGroup = !!groupTrait.property$.value;
|
||||
navigate(hasGroup ? 'group' : 'group-select');
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
items.push(menu.group({ items: settingItems }));
|
||||
|
||||
const viewSpecificItems =
|
||||
(
|
||||
dataViewLogic as DataViewUILogicBase & {
|
||||
getViewOptionsSettingItems?: (
|
||||
navigateToSubPage?: (
|
||||
title: string,
|
||||
getItems: () => MenuConfig[]
|
||||
) => void,
|
||||
goBack?: () => void
|
||||
) => MenuConfig[];
|
||||
}
|
||||
).getViewOptionsSettingItems?.(navigateToCustomPage, goBack) ?? [];
|
||||
|
||||
if (viewSpecificItems.length) {
|
||||
items.push(menu.group({ items: viewSpecificItems }));
|
||||
}
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate view',
|
||||
prefix: DuplicateIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.duplicate();
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete view',
|
||||
prefix: DeleteIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.delete();
|
||||
},
|
||||
class: { 'delete-item': true },
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getPageItems = (): MenuConfig[] => {
|
||||
switch (currentPage.value) {
|
||||
case 'properties':
|
||||
return getPropertiesPageItems();
|
||||
case 'filter':
|
||||
return getFilterPageItems();
|
||||
case 'sort':
|
||||
return getSortPageItems();
|
||||
case 'group':
|
||||
return getGroupPageItems();
|
||||
case 'group-select':
|
||||
return getGroupSelectPageItems();
|
||||
case 'custom':
|
||||
return customPageItems();
|
||||
default:
|
||||
return getMainPageItems();
|
||||
}
|
||||
};
|
||||
|
||||
menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'View settings',
|
||||
onClose: () => handler.close(),
|
||||
},
|
||||
items,
|
||||
onClose: onClose,
|
||||
title: titleConfig,
|
||||
items: [menu.dynamic(getPageItems)],
|
||||
onClose,
|
||||
},
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['bottom-start'] }),
|
||||
@@ -475,6 +581,23 @@ export const popViewOptions = (
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
return handler;
|
||||
if (isDesktopMenu()) {
|
||||
menuHandler.menu.menuElement.style.minWidth = '380px';
|
||||
menuHandler.menu.menuElement.style.maxWidth = '380px';
|
||||
menuHandler.menu.menuElement.style.borderRadius = '10px';
|
||||
menuHandler.menu.menuElement.style.padding = '12px';
|
||||
menuHandler.menu.menuElement.style.gap = '10px';
|
||||
requestAnimationFrame(() => {
|
||||
const bodyEl =
|
||||
menuHandler.menu.menuElement.querySelector<HTMLElement>(
|
||||
'.affine-menu-body'
|
||||
);
|
||||
if (bodyEl) {
|
||||
bodyEl.style.overflowY = 'auto';
|
||||
bodyEl.style.flex = '1';
|
||||
bodyEl.style.minHeight = '0';
|
||||
}
|
||||
});
|
||||
}
|
||||
return menuHandler;
|
||||
};
|
||||
|
||||
@@ -2,14 +2,48 @@ import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
|
||||
import type { Command, TextSelection } from '@blocksuite/std';
|
||||
import type { InlineRange } from '@blocksuite/std/inline';
|
||||
|
||||
function openInlineLatexEditor(
|
||||
inlineEditor: AffineInlineEditor,
|
||||
index: number
|
||||
) {
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(index);
|
||||
if (!textPoint) return;
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function getSingleBlockInlineRange(
|
||||
textSelection: TextSelection
|
||||
): InlineRange | null {
|
||||
if (textSelection.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index: textSelection.from.index,
|
||||
length: textSelection.from.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const insertInlineLatex: Command<{
|
||||
currentTextSelection?: TextSelection;
|
||||
textSelection?: TextSelection;
|
||||
}> = (ctx, next) => {
|
||||
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
|
||||
if (!textSelection || !textSelection.isCollapsed()) return;
|
||||
if (!textSelection) return;
|
||||
|
||||
const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId);
|
||||
if (!blockComponent) return;
|
||||
@@ -20,24 +54,19 @@ export const insertInlineLatex: Command<{
|
||||
const inlineEditor = richText.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 1,
|
||||
},
|
||||
{
|
||||
latex: '',
|
||||
}
|
||||
);
|
||||
const inlineRange = getSingleBlockInlineRange(textSelection);
|
||||
if (!inlineRange) return;
|
||||
|
||||
const latex = textSelection.isCollapsed()
|
||||
? ''
|
||||
: inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
|
||||
inlineEditor.insertText(inlineRange, ' ', { latex });
|
||||
inlineEditor.setInlineRange({
|
||||
index: textSelection.from.index,
|
||||
index: inlineRange.index,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
@@ -56,19 +85,9 @@ export const insertInlineLatex: Command<{
|
||||
control: 'create inline equation',
|
||||
});
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1);
|
||||
if (!textPoint) return;
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
if (textSelection.isCollapsed()) {
|
||||
openInlineLatexEditor(inlineEditor, inlineRange.index + 1);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import katex from 'katex';
|
||||
import { css, html, render } from 'lit';
|
||||
import { css, html, type PropertyValues, render } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class AffineLatexNode extends SignalWatcher(
|
||||
@@ -85,6 +85,8 @@ export class AffineLatexNode extends SignalWatcher(
|
||||
|
||||
private _editorAbortController: AbortController | null = null;
|
||||
|
||||
private _isEditorOpen = false;
|
||||
|
||||
readonly latex$ = signal('');
|
||||
|
||||
readonly latexEditorSignal = signal('');
|
||||
@@ -174,6 +176,22 @@ export class AffineLatexNode extends SignalWatcher(
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (!changedProperties.has('delta') || this._isEditorOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latex = this.deltaLatex;
|
||||
if (this.latex$.peek() !== latex) {
|
||||
this.latex$.value = latex;
|
||||
}
|
||||
if (this.latexEditorSignal.peek() !== latex) {
|
||||
this.latexEditorSignal.value = latex;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<span class="affine-latex" data-selected=${this.selected}
|
||||
><div class="latex-container"></div>
|
||||
@@ -212,9 +230,11 @@ export class AffineLatexNode extends SignalWatcher(
|
||||
},
|
||||
});
|
||||
|
||||
this._isEditorOpen = true;
|
||||
this._editorAbortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
this._isEditorOpen = false;
|
||||
portal.remove();
|
||||
const latex = this.latexEditorSignal.peek();
|
||||
this.latex$.value = latex;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { TeXIcon } from '@blocksuite/icons/lit';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
/**
|
||||
@@ -119,6 +120,15 @@ export const textConversionConfigs: TextConversionConfig[] = [
|
||||
hotkey: [`Mod-Alt-c`],
|
||||
icon: CodeBlockIcon,
|
||||
},
|
||||
{
|
||||
flavour: 'affine:latex',
|
||||
type: undefined,
|
||||
name: 'Equation',
|
||||
description: 'Formula block with LaTeX rendering.',
|
||||
hotkey: null,
|
||||
icon: TeXIcon(),
|
||||
searchAlias: ['mathBlock', 'equationBlock', 'latexBlock'],
|
||||
},
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'quote',
|
||||
|
||||
@@ -66,6 +66,10 @@ export type EmbedIframeConfig = {
|
||||
* The function to build the oEmbed URL for fetching embed data
|
||||
*/
|
||||
buildOEmbedUrl: (url: string) => string | undefined;
|
||||
/**
|
||||
* Validate the final iframe src before rendering.
|
||||
*/
|
||||
validateIframeUrl?: (iframeUrl: string, originalUrl?: string) => boolean;
|
||||
/**
|
||||
* Use oEmbed URL directly as iframe src without fetching oEmbed data
|
||||
*/
|
||||
|
||||
@@ -222,6 +222,17 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Equation',
|
||||
showWhen: ({ std }) =>
|
||||
std.store.schema.flavourSchemaMap.has('affine:latex'),
|
||||
icon: TeXIcon(),
|
||||
action: ({ std }) => {
|
||||
std.command.exec(updateBlockType, {
|
||||
flavour: 'affine:latex',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Quote',
|
||||
showWhen: ({ std }) =>
|
||||
|
||||
@@ -260,6 +260,17 @@ function convertGfmCallouts(markdown: string): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function stripBearMetadataComments(markdown: string): string {
|
||||
let current = markdown;
|
||||
while (true) {
|
||||
const next = current.replace(/<!--\s*\{[^}]*\}\s*-->/g, '');
|
||||
if (next === current) {
|
||||
return current;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
const HIGHLIGHT_COLOR_MAP: Record<string, string> = {
|
||||
'\uD83D\uDFE2': 'green',
|
||||
'\uD83D\uDD35': 'blue',
|
||||
@@ -426,9 +437,7 @@ async function importBearBackup({
|
||||
entry.bundlePath.split('/').findLast(Boolean) ?? 'Untitled';
|
||||
const title = extractTitle(cleanedMarkdown, bundleDirName);
|
||||
const markdown = convertHighlights(
|
||||
convertGfmCallouts(
|
||||
cleanedMarkdown.replace(/<!--\s*\{[^}]*\}\s*-->/g, '')
|
||||
)
|
||||
convertGfmCallouts(stripBearMetadataComments(cleanedMarkdown))
|
||||
);
|
||||
|
||||
// Read assets on demand (decompress only this bundle's assets)
|
||||
|
||||
+4
-4
@@ -15,7 +15,7 @@
|
||||
"tests/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": "<23.0.0"
|
||||
"node": ">=22.12.0 <23.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"affine": "r affine.ts",
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"@capacitor/cli": "^7.0.0",
|
||||
"@capacitor/cli": "^7.6.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
@@ -74,7 +74,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-lit": "^2.2.1",
|
||||
"eslint-plugin-oxlint": "1.60.0",
|
||||
"eslint-plugin-oxlint": "1.66.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
@@ -84,7 +84,7 @@
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.13.2",
|
||||
"oxlint": "1.58.0",
|
||||
"oxlint-tsgolint": "^0.19.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"prettier": "^3.7.4",
|
||||
"semver": "^7.7.3",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -9,6 +9,7 @@ version = "1.0.0"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = { workspace = true }
|
||||
affine_common = { workspace = true, features = [
|
||||
"doc-loader",
|
||||
"hashcash",
|
||||
@@ -19,6 +20,7 @@ anyhow = { workspace = true }
|
||||
base64-simd = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
file-format = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
image = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
jsonschema = "0.46"
|
||||
@@ -30,13 +32,22 @@ matroska = { workspace = true }
|
||||
mp4parse = { workspace = true }
|
||||
napi = { workspace = true, features = ["async", "serde-json"] }
|
||||
napi-derive = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
reqwest = { version = "0.13.3", default-features = false, features = [
|
||||
"blocking",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = "0.23"
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
sha3 = { workspace = true }
|
||||
tiktoken-rs = { workspace = true }
|
||||
url = { workspace = true }
|
||||
v_htmlescape = { workspace = true }
|
||||
webpki-roots = "1"
|
||||
y-octo = { workspace = true, features = ["large_refs"] }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
|
||||
Vendored
+114
@@ -66,6 +66,12 @@ export const AFFINE_PRO_LICENSE_AES_KEY: string | undefined | null
|
||||
|
||||
export const AFFINE_PRO_PUBLIC_KEY: string | undefined | null
|
||||
|
||||
export declare function assertSafeUrl(request: AssertSafeUrlRequest): void
|
||||
|
||||
export interface AssertSafeUrlRequest {
|
||||
url: string
|
||||
}
|
||||
|
||||
export declare function buildPublicRootDoc(rootDocBin: Buffer, docMetas: Array<PublicDocMetaInput>): Buffer
|
||||
|
||||
export interface BuiltInPromptRenderContract {
|
||||
@@ -163,12 +169,32 @@ export interface Chunk {
|
||||
*/
|
||||
export declare function createDocWithMarkdown(title: string, markdown: string, docId: string): Buffer
|
||||
|
||||
export declare function evaluatePermissionV1(input: any): any
|
||||
|
||||
export declare function fetchRemoteAttachment(request: RemoteAttachmentFetchRequest): Promise<RemoteAttachmentFetchResponse>
|
||||
|
||||
export declare function fromModelName(modelName: string): Tokenizer | null
|
||||
|
||||
export declare function getMime(input: Uint8Array): string
|
||||
|
||||
export declare function htmlSanitize(input: string): string
|
||||
|
||||
export interface ImageInspection {
|
||||
mimeType: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface ImageInspectionOptions {
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
maxPixels?: number
|
||||
}
|
||||
|
||||
export declare function inferRemoteMimeType(request: RemoteMimeTypeRequest): Promise<string>
|
||||
|
||||
export declare function inspectImageForProxy(input: Buffer, options?: ImageInspectionOptions | undefined | null): ImageInspection
|
||||
|
||||
export declare function llmBuildCanonicalRequest(request: CanonicalChatRequestContract): LlmRequestContract
|
||||
|
||||
export declare function llmBuildCanonicalStructuredRequest(request: CanonicalStructuredRequestContract): LlmStructuredRequestContract
|
||||
@@ -451,6 +477,10 @@ export declare function parsePageDoc(docBin: Buffer, maxSummaryLength?: number |
|
||||
|
||||
export declare function parseWorkspaceDoc(docBin: Buffer): NativeWorkspaceDocContent | null
|
||||
|
||||
export declare function permissionActionRoleMatrixV1(): any
|
||||
|
||||
export declare function permissionActionRoleMatrixV1Json(): string
|
||||
|
||||
export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise<Buffer>
|
||||
|
||||
export type PromptBuiltin = 'Date'|
|
||||
@@ -572,6 +602,28 @@ export interface PublicDocMetaInput {
|
||||
|
||||
export declare function readAllDocIdsFromRootDoc(docBin: Buffer, includeTrash?: boolean | undefined | null): Array<string>
|
||||
|
||||
export interface RemoteAttachmentFetchRequest {
|
||||
url: string
|
||||
timeoutMs?: number
|
||||
maxBytes: number
|
||||
allowPrivateTargetOrigin?: boolean
|
||||
expectedContentTypePrefix?: string
|
||||
maxImageWidth?: number
|
||||
maxImageHeight?: number
|
||||
maxImagePixels?: number
|
||||
}
|
||||
|
||||
export interface RemoteAttachmentFetchResponse {
|
||||
finalUrl: string
|
||||
mimeType: string
|
||||
body: Buffer
|
||||
}
|
||||
|
||||
export interface RemoteMimeTypeRequest {
|
||||
url: string
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface RequestedModelMatchRequest {
|
||||
providerIds: Array<string>
|
||||
optionalModels: Array<string>
|
||||
@@ -589,8 +641,70 @@ export interface RerankCandidate {
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ResolvedEntitlement {
|
||||
plan: string
|
||||
valid: boolean
|
||||
status: string
|
||||
quantity?: number
|
||||
expiresAt?: string
|
||||
subjectId?: string
|
||||
targetId?: string
|
||||
recurring?: string
|
||||
issuedAt?: string
|
||||
entity?: string
|
||||
issuer?: string
|
||||
quota: ResolvedQuota
|
||||
flags: Record<string, boolean>
|
||||
errorCode?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface ResolvedQuota {
|
||||
blobLimit: number
|
||||
storageQuota: number
|
||||
seatLimit?: number
|
||||
seatQuota?: number
|
||||
historyPeriod: number
|
||||
copilotActionLimit?: number
|
||||
}
|
||||
|
||||
export interface ResolveEntitlementInput {
|
||||
deploymentType: string
|
||||
targetType: string
|
||||
targetId?: string
|
||||
plan?: string
|
||||
quantity?: number
|
||||
signedPayload?: Buffer
|
||||
publicKey?: string
|
||||
licenseAesKey?: string
|
||||
now: string
|
||||
}
|
||||
|
||||
export declare function resolveEntitlementV1(input: ResolveEntitlementInput): ResolvedEntitlement
|
||||
|
||||
export declare function runNativeActionRecipePreparedStream(input: ActionRuntimeInput, callback: ((err: Error | null, arg: string) => void)): LlmStreamHandle
|
||||
|
||||
export declare function safeFetch(request: SafeFetchRequest): Promise<SafeFetchResponse>
|
||||
|
||||
export type SafeFetchMethod = 'get'|
|
||||
'head';
|
||||
|
||||
export interface SafeFetchRequest {
|
||||
url: string
|
||||
method?: SafeFetchMethod
|
||||
headers?: Record<string, string>
|
||||
timeoutMs?: number
|
||||
maxRedirects?: number
|
||||
maxBytes?: number
|
||||
}
|
||||
|
||||
export interface SafeFetchResponse {
|
||||
status: number
|
||||
finalUrl: string
|
||||
headers: Record<string, string>
|
||||
body: Buffer
|
||||
}
|
||||
|
||||
export interface ToolContract {
|
||||
name: string
|
||||
description?: string
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use aes_gcm::{
|
||||
AesGcm, KeyInit,
|
||||
aead::{
|
||||
Aead,
|
||||
generic_array::{GenericArray, typenum::U12},
|
||||
},
|
||||
aes::Aes256,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use napi::{Error as NapiError, Result, Status, bindgen_prelude::Buffer};
|
||||
use napi_derive::napi;
|
||||
use p256::{
|
||||
ecdsa::{Signature, VerifyingKey, signature::Verifier},
|
||||
pkcs8::DecodePublicKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type Aes256Gcm12 = AesGcm<Aes256, U12, U12>;
|
||||
type LicenseError = (&'static str, &'static str);
|
||||
type LicenseResult<T> = std::result::Result<T, LicenseError>;
|
||||
|
||||
const ONE_MB: i64 = 1024 * 1024;
|
||||
const ONE_GB: i64 = 1024 * ONE_MB;
|
||||
const ONE_DAY_SECONDS: i64 = 24 * 60 * 60;
|
||||
const MAX_SEAT_QUANTITY: i32 = 100_000;
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ResolveEntitlementInput {
|
||||
pub deployment_type: String,
|
||||
pub target_type: String,
|
||||
pub target_id: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
#[napi(ts_type = "number")]
|
||||
pub quantity: Option<Value>,
|
||||
pub signed_payload: Option<Buffer>,
|
||||
pub public_key: Option<String>,
|
||||
pub license_aes_key: Option<String>,
|
||||
pub now: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct ResolvedQuota {
|
||||
pub blob_limit: i64,
|
||||
pub storage_quota: i64,
|
||||
pub seat_limit: Option<i32>,
|
||||
pub seat_quota: Option<i64>,
|
||||
pub history_period: i64,
|
||||
pub copilot_action_limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct ResolvedEntitlement {
|
||||
pub plan: String,
|
||||
pub valid: bool,
|
||||
pub status: String,
|
||||
pub quantity: Option<i32>,
|
||||
pub expires_at: Option<String>,
|
||||
pub subject_id: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub recurring: Option<String>,
|
||||
pub issued_at: Option<String>,
|
||||
pub entity: Option<String>,
|
||||
pub issuer: Option<String>,
|
||||
pub quota: ResolvedQuota,
|
||||
pub flags: HashMap<String, bool>,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LicenseEnvelope {
|
||||
payload: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LicensePayload {
|
||||
entity: String,
|
||||
issuer: String,
|
||||
issued_at: String,
|
||||
expires_at: String,
|
||||
data: LicenseData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LicenseData {
|
||||
id: String,
|
||||
workspace_id: String,
|
||||
plan: String,
|
||||
recurring: String,
|
||||
quantity: i32,
|
||||
end_at: String,
|
||||
}
|
||||
|
||||
struct PlanQuota {
|
||||
name: &'static str,
|
||||
blob_limit: i64,
|
||||
storage_quota: i64,
|
||||
history_period: i64,
|
||||
member_limit: Option<i32>,
|
||||
seat_quota: Option<i64>,
|
||||
copilot_action_limit: Option<i32>,
|
||||
unlimited_copilot: bool,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn resolve_entitlement_v1(input: ResolveEntitlementInput) -> Result<ResolvedEntitlement> {
|
||||
validate_input(&input)?;
|
||||
let now = parse_time(&input.now)?;
|
||||
|
||||
if input.signed_payload.is_some() {
|
||||
if input.deployment_type != "selfhosted" || input.target_type != "workspace" {
|
||||
return invalid_arg("signedPayload is only supported for selfhosted workspace entitlements");
|
||||
}
|
||||
return resolve_selfhost_license(input, now);
|
||||
}
|
||||
|
||||
let plan = input.plan.as_deref().unwrap_or_else(|| {
|
||||
if input.deployment_type == "selfhosted" {
|
||||
"selfhost_free"
|
||||
} else {
|
||||
"free"
|
||||
}
|
||||
});
|
||||
if input.deployment_type == "selfhosted" && plan != "selfhost_free" {
|
||||
return invalid_arg("selfhosted commercial entitlements require signedPayload");
|
||||
}
|
||||
let quantity = parse_quantity(input.quantity.as_ref())?;
|
||||
Ok(active(plan, quantity, None))
|
||||
}
|
||||
|
||||
fn validate_input(input: &ResolveEntitlementInput) -> Result<()> {
|
||||
if !matches!(input.deployment_type.as_str(), "cloud" | "selfhosted") {
|
||||
return invalid_arg("deploymentType must be cloud or selfhosted");
|
||||
}
|
||||
if !matches!(input.target_type.as_str(), "user" | "workspace" | "instance") {
|
||||
return invalid_arg("targetType must be user, workspace, or instance");
|
||||
}
|
||||
parse_quantity(input.quantity.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_quantity(quantity: Option<&Value>) -> Result<Option<i32>> {
|
||||
let Some(quantity) = quantity else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(quantity) = quantity.as_i64() else {
|
||||
return invalid_arg("quantity must be an integer");
|
||||
};
|
||||
if quantity <= 0 || quantity > MAX_SEAT_QUANTITY as i64 {
|
||||
return invalid_arg("quantity must be between 1 and 100000");
|
||||
}
|
||||
Ok(Some(quantity as i32))
|
||||
}
|
||||
|
||||
fn resolve_selfhost_license(input: ResolveEntitlementInput, now: DateTime<Utc>) -> Result<ResolvedEntitlement> {
|
||||
let Some(payload) = input.signed_payload else {
|
||||
return Ok(active("selfhost_free", None, None));
|
||||
};
|
||||
let Some(public_key) = input.public_key else {
|
||||
return invalid_arg("publicKey is required for signed payload verification");
|
||||
};
|
||||
let Some(license_aes_key) = input.license_aes_key else {
|
||||
return invalid_arg("licenseAesKey is required for signed payload verification");
|
||||
};
|
||||
|
||||
let payload = match decrypt_license(payload.as_ref(), &license_aes_key)
|
||||
.and_then(|decrypted| verify_license(&decrypted, &public_key))
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err((code, message)) => return Ok(invalid_license(code, message)),
|
||||
};
|
||||
|
||||
if let Err((code, message)) = validate_license_payload(&payload) {
|
||||
return Ok(invalid_license(code, message));
|
||||
}
|
||||
|
||||
if payload.data.plan != "selfhostedteam" {
|
||||
return Ok(invalid_license("invalid_payload", "license plan is not selfhostedteam"));
|
||||
}
|
||||
|
||||
if let Some(target_id) = input.target_id.as_deref()
|
||||
&& target_id != payload.data.workspace_id.as_str()
|
||||
{
|
||||
return Ok(invalid_license(
|
||||
"workspace_mismatch",
|
||||
"workspace mismatched with license",
|
||||
));
|
||||
}
|
||||
|
||||
if payload.issued_at.is_empty() || payload.entity.is_empty() || payload.issuer.is_empty() {
|
||||
return Ok(invalid_license("invalid_payload", "license payload is incomplete"));
|
||||
}
|
||||
|
||||
let file_expires_at = match parse_time(&payload.expires_at) {
|
||||
Ok(time) => time,
|
||||
Err(_) => return Ok(invalid_license("invalid_payload", "invalid expiresAt")),
|
||||
};
|
||||
let license_expires_at = match parse_time(&payload.data.end_at) {
|
||||
Ok(time) => time,
|
||||
Err(_) => return Ok(invalid_license("invalid_payload", "invalid endAt")),
|
||||
};
|
||||
|
||||
let expires_at = file_expires_at.min(license_expires_at);
|
||||
if expires_at < now {
|
||||
let mut entitlement = expired(
|
||||
"selfhost_team",
|
||||
Some(payload.data.quantity),
|
||||
Some(expires_at.to_rfc3339()),
|
||||
);
|
||||
entitlement.error_code = Some(
|
||||
if license_expires_at < now && license_expires_at <= file_expires_at {
|
||||
"expired_end_at"
|
||||
} else {
|
||||
"expired"
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
fill_license_metadata(&mut entitlement, &payload);
|
||||
return Ok(entitlement);
|
||||
}
|
||||
|
||||
let mut entitlement = active(
|
||||
"selfhost_team",
|
||||
Some(payload.data.quantity),
|
||||
Some(expires_at.to_rfc3339()),
|
||||
);
|
||||
fill_license_metadata(&mut entitlement, &payload);
|
||||
Ok(entitlement)
|
||||
}
|
||||
|
||||
fn fill_license_metadata(entitlement: &mut ResolvedEntitlement, payload: &LicensePayload) {
|
||||
entitlement.subject_id = Some(payload.data.id.clone());
|
||||
entitlement.target_id = Some(payload.data.workspace_id.clone());
|
||||
entitlement.recurring = Some(payload.data.recurring.clone());
|
||||
entitlement.issued_at = Some(payload.issued_at.clone());
|
||||
entitlement.entity = Some(payload.entity.clone());
|
||||
entitlement.issuer = Some(payload.issuer.clone());
|
||||
}
|
||||
|
||||
fn validate_license_payload(payload: &LicensePayload) -> LicenseResult<()> {
|
||||
if payload.data.id.is_empty()
|
||||
|| payload.data.workspace_id.is_empty()
|
||||
|| !matches!(payload.data.recurring.as_str(), "monthly" | "yearly" | "lifetime")
|
||||
|| payload.data.quantity <= 0
|
||||
|| payload.data.quantity > MAX_SEAT_QUANTITY
|
||||
{
|
||||
return Err(("invalid_payload", "license payload is incomplete"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decrypt_license(buf: &[u8], aes_key: &str) -> LicenseResult<(Vec<u8>, Vec<u8>)> {
|
||||
if buf.len() < 2 {
|
||||
return Err(("invalid_file", "invalid license file"));
|
||||
}
|
||||
|
||||
let iv_len = buf[0] as usize;
|
||||
let tag_len = buf[1] as usize;
|
||||
let payload_start = 2 + iv_len + tag_len;
|
||||
if iv_len != 12 || tag_len != 12 || buf.len() <= payload_start {
|
||||
return Err(("invalid_file", "invalid license file"));
|
||||
}
|
||||
|
||||
let iv = &buf[2..2 + iv_len];
|
||||
let tag = &buf[2 + iv_len..payload_start];
|
||||
let payload = &buf[payload_start..];
|
||||
let key = license_aes_key(aes_key)?;
|
||||
let cipher = Aes256Gcm12::new_from_slice(&key).map_err(|_| ("invalid_key", "invalid aes key"))?;
|
||||
let nonce = GenericArray::from_slice(iv);
|
||||
let mut encrypted = Vec::with_capacity(payload.len() + tag.len());
|
||||
encrypted.extend_from_slice(payload);
|
||||
encrypted.extend_from_slice(tag);
|
||||
let decrypted = cipher
|
||||
.decrypt(nonce, encrypted.as_ref())
|
||||
.map_err(|_| ("decrypt_failed", "failed to verify the license"))?;
|
||||
|
||||
Ok((iv.to_vec(), decrypted))
|
||||
}
|
||||
|
||||
fn license_aes_key(aes_key: &str) -> LicenseResult<[u8; 32]> {
|
||||
if aes_key.len() == 64
|
||||
&& let Ok(decoded) = hex::decode(aes_key)
|
||||
&& decoded.len() == 32
|
||||
{
|
||||
let mut key = [0; 32];
|
||||
key.copy_from_slice(&decoded);
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
Ok(Sha256::digest(aes_key.as_bytes()).into())
|
||||
}
|
||||
|
||||
fn verify_license(decrypted: &(Vec<u8>, Vec<u8>), public_key: &str) -> LicenseResult<LicensePayload> {
|
||||
let (iv, decrypted) = decrypted;
|
||||
let envelope: LicenseEnvelope =
|
||||
serde_json::from_slice(decrypted).map_err(|_| ("invalid_file", "invalid license file"))?;
|
||||
let signature = hex::decode(&envelope.signature).map_err(|_| ("invalid_signature", "invalid license signature"))?;
|
||||
let signature = Signature::from_der(&signature).map_err(|_| ("invalid_signature", "invalid license signature"))?;
|
||||
let verifying_key =
|
||||
VerifyingKey::from_public_key_pem(public_key).map_err(|_| ("invalid_public_key", "invalid public key"))?;
|
||||
let mut message = Vec::with_capacity(iv.len() + envelope.payload.len());
|
||||
message.extend_from_slice(iv);
|
||||
message.extend_from_slice(envelope.payload.as_bytes());
|
||||
verifying_key
|
||||
.verify(&message, &signature)
|
||||
.map_err(|_| ("invalid_signature", "invalid license signature"))?;
|
||||
|
||||
serde_json::from_str::<LicensePayload>(&envelope.payload).map_err(|_| ("invalid_payload", "invalid license payload"))
|
||||
}
|
||||
|
||||
fn active(plan: &str, quantity: Option<i32>, expires_at: Option<String>) -> ResolvedEntitlement {
|
||||
let quantity = quantity_for_plan(plan, quantity);
|
||||
let catalog = plan_catalog(plan, quantity);
|
||||
ResolvedEntitlement {
|
||||
plan: catalog.name.to_string(),
|
||||
valid: true,
|
||||
status: "active".to_string(),
|
||||
quantity,
|
||||
expires_at,
|
||||
subject_id: None,
|
||||
target_id: None,
|
||||
recurring: None,
|
||||
issued_at: None,
|
||||
entity: None,
|
||||
issuer: None,
|
||||
quota: quota(&catalog),
|
||||
flags: flags(&catalog),
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expired(plan: &str, quantity: Option<i32>, expires_at: Option<String>) -> ResolvedEntitlement {
|
||||
let quantity = quantity_for_plan(plan, quantity);
|
||||
let catalog = plan_catalog(plan, quantity);
|
||||
ResolvedEntitlement {
|
||||
plan: catalog.name.to_string(),
|
||||
valid: false,
|
||||
status: "expired".to_string(),
|
||||
quantity,
|
||||
expires_at,
|
||||
subject_id: None,
|
||||
target_id: None,
|
||||
recurring: None,
|
||||
issued_at: None,
|
||||
entity: None,
|
||||
issuer: None,
|
||||
quota: quota(&catalog),
|
||||
flags: flags(&catalog),
|
||||
error_code: Some("expired".to_string()),
|
||||
error_message: Some("license expired".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_license(code: &'static str, message: &'static str) -> ResolvedEntitlement {
|
||||
let catalog = plan_catalog("selfhost_free", None);
|
||||
ResolvedEntitlement {
|
||||
plan: catalog.name.to_string(),
|
||||
valid: false,
|
||||
status: "needs_reupload".to_string(),
|
||||
quantity: None,
|
||||
expires_at: None,
|
||||
subject_id: None,
|
||||
target_id: None,
|
||||
recurring: None,
|
||||
issued_at: None,
|
||||
entity: None,
|
||||
issuer: None,
|
||||
quota: quota(&catalog),
|
||||
flags: flags(&catalog),
|
||||
error_code: Some(code.to_string()),
|
||||
error_message: Some(message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn quantity_for_plan(plan: &str, quantity: Option<i32>) -> Option<i32> {
|
||||
if matches!(plan, "team" | "selfhost_team") {
|
||||
quantity
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_catalog(plan: &str, quantity: Option<i32>) -> PlanQuota {
|
||||
let seats = quantity.unwrap_or(1);
|
||||
match plan {
|
||||
"pro" => PlanQuota {
|
||||
name: "pro",
|
||||
blob_limit: 100 * ONE_MB,
|
||||
storage_quota: 100 * ONE_GB,
|
||||
history_period: 30 * ONE_DAY_SECONDS,
|
||||
member_limit: Some(10),
|
||||
seat_quota: None,
|
||||
copilot_action_limit: Some(10),
|
||||
unlimited_copilot: false,
|
||||
},
|
||||
"lifetime_pro" => PlanQuota {
|
||||
name: "lifetime_pro",
|
||||
blob_limit: 100 * ONE_MB,
|
||||
storage_quota: 1024 * ONE_GB,
|
||||
history_period: 30 * ONE_DAY_SECONDS,
|
||||
member_limit: Some(10),
|
||||
seat_quota: None,
|
||||
copilot_action_limit: Some(10),
|
||||
unlimited_copilot: false,
|
||||
},
|
||||
"ai" => PlanQuota {
|
||||
name: "ai",
|
||||
blob_limit: 10 * ONE_MB,
|
||||
storage_quota: 10 * ONE_GB,
|
||||
history_period: 7 * ONE_DAY_SECONDS,
|
||||
member_limit: Some(3),
|
||||
seat_quota: None,
|
||||
copilot_action_limit: None,
|
||||
unlimited_copilot: true,
|
||||
},
|
||||
"team" | "selfhost_team" => {
|
||||
let seat_quota = 20 * ONE_GB;
|
||||
let storage_quota = (seats as i64)
|
||||
.checked_mul(seat_quota)
|
||||
.and_then(|storage| storage.checked_add(100 * ONE_GB))
|
||||
.unwrap_or(i64::MAX);
|
||||
PlanQuota {
|
||||
name: if plan == "team" { "team" } else { "selfhost_team" },
|
||||
blob_limit: 500 * ONE_MB,
|
||||
storage_quota,
|
||||
history_period: 30 * ONE_DAY_SECONDS,
|
||||
member_limit: Some(seats),
|
||||
seat_quota: Some(seat_quota),
|
||||
copilot_action_limit: None,
|
||||
unlimited_copilot: false,
|
||||
}
|
||||
}
|
||||
"selfhost_free" => PlanQuota {
|
||||
name: "selfhost_free",
|
||||
blob_limit: 100 * ONE_MB,
|
||||
storage_quota: 100 * ONE_GB,
|
||||
history_period: 30 * ONE_DAY_SECONDS,
|
||||
member_limit: Some(10),
|
||||
seat_quota: None,
|
||||
copilot_action_limit: Some(10),
|
||||
unlimited_copilot: false,
|
||||
},
|
||||
_ => PlanQuota {
|
||||
name: "free",
|
||||
blob_limit: 10 * ONE_MB,
|
||||
storage_quota: 10 * ONE_GB,
|
||||
history_period: 7 * ONE_DAY_SECONDS,
|
||||
member_limit: Some(3),
|
||||
seat_quota: None,
|
||||
copilot_action_limit: Some(10),
|
||||
unlimited_copilot: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn quota(catalog: &PlanQuota) -> ResolvedQuota {
|
||||
ResolvedQuota {
|
||||
blob_limit: catalog.blob_limit,
|
||||
storage_quota: catalog.storage_quota,
|
||||
seat_limit: catalog.member_limit,
|
||||
seat_quota: catalog.seat_quota,
|
||||
history_period: catalog.history_period,
|
||||
copilot_action_limit: catalog.copilot_action_limit,
|
||||
}
|
||||
}
|
||||
|
||||
fn flags(catalog: &PlanQuota) -> HashMap<String, bool> {
|
||||
let mut flags = HashMap::new();
|
||||
flags.insert("unlimitedCopilot".to_string(), catalog.unlimited_copilot);
|
||||
flags
|
||||
}
|
||||
|
||||
fn parse_time(value: &str) -> Result<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.map_err(|err| NapiError::new(Status::InvalidArg, err.to_string()))
|
||||
}
|
||||
|
||||
fn invalid_arg<T>(message: &'static str) -> Result<T> {
|
||||
Err(NapiError::new(Status::InvalidArg, message))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_WORKSPACE_ID: &str = "d6f52bc7-d62a-4822-804a-335fa7dfe5a6";
|
||||
#[rustfmt::skip]
|
||||
const TEST_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\n\
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqrxlczPknUuj4q4xx1VGr063Cgu7\n\
|
||||
Hc3w7v4FGmoA5MNzzhrkho1ckDYw2wrX6zBnehFzcivURv80HherE2GQjg==\n\
|
||||
-----END PUBLIC KEY-----";
|
||||
const TEST_LICENSE_AES_KEY: &str = "TEST_LICENSE_AES_KEY";
|
||||
|
||||
fn input(plan: Option<&str>, quantity: Option<i32>) -> ResolveEntitlementInput {
|
||||
ResolveEntitlementInput {
|
||||
deployment_type: "cloud".to_string(),
|
||||
target_type: "workspace".to_string(),
|
||||
target_id: Some("workspace".to_string()),
|
||||
plan: plan.map(str::to_string),
|
||||
quantity: quantity.map(Value::from),
|
||||
signed_payload: None,
|
||||
public_key: None,
|
||||
license_aes_key: None,
|
||||
now: "2026-05-14T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn license_input(file: &str, workspace_id: &str) -> ResolveEntitlementInput {
|
||||
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../server/src/__tests__/e2e/license/__fixtures__")
|
||||
.join(file);
|
||||
ResolveEntitlementInput {
|
||||
deployment_type: "selfhosted".to_string(),
|
||||
target_type: "workspace".to_string(),
|
||||
target_id: Some(workspace_id.to_string()),
|
||||
plan: None,
|
||||
quantity: None,
|
||||
signed_payload: Some(std::fs::read(fixture).unwrap().into()),
|
||||
public_key: Some(TEST_PUBLIC_KEY.to_string()),
|
||||
license_aes_key: Some(TEST_LICENSE_AES_KEY.to_string()),
|
||||
now: "2026-05-14T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypted_license(file: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let input = license_input(file, TEST_WORKSPACE_ID);
|
||||
let payload = input.signed_payload.unwrap();
|
||||
decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypts_license_with_raw_or_hashed_aes_key() {
|
||||
let input = license_input("valid.license", TEST_WORKSPACE_ID);
|
||||
let payload = input.signed_payload.unwrap();
|
||||
let hashed_key = hex::encode(Sha256::digest(TEST_LICENSE_AES_KEY.as_bytes()));
|
||||
|
||||
let raw = decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap();
|
||||
let hashed = decrypt_license(payload.as_ref(), &hashed_key).unwrap();
|
||||
|
||||
assert_eq!(raw.0, hashed.0);
|
||||
assert_eq!(raw.1, hashed.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_plan_quota() {
|
||||
let cases = [
|
||||
("free", None, 3, 10 * ONE_GB, Some(10)),
|
||||
("pro", None, 10, 100 * ONE_GB, Some(10)),
|
||||
("lifetime_pro", None, 10, 1024 * ONE_GB, Some(10)),
|
||||
("team", Some(5), 5, 200 * ONE_GB, None),
|
||||
("selfhost_team", Some(20), 20, 500 * ONE_GB, None),
|
||||
("selfhost_free", None, 10, 100 * ONE_GB, Some(10)),
|
||||
];
|
||||
|
||||
for (plan, quantity, seat_limit, storage_quota, copilot_limit) in cases {
|
||||
let mut input = input(Some(plan), quantity);
|
||||
if plan == "selfhost_free" {
|
||||
input.deployment_type = "selfhosted".to_string();
|
||||
}
|
||||
let resolved = resolve_entitlement_v1(input).unwrap();
|
||||
assert!(resolved.valid, "{plan}");
|
||||
assert_eq!(
|
||||
resolved.quantity,
|
||||
if matches!(plan, "team" | "selfhost_team") {
|
||||
quantity
|
||||
} else {
|
||||
None
|
||||
},
|
||||
"{plan}"
|
||||
);
|
||||
assert_eq!(resolved.quota.seat_limit, Some(seat_limit), "{plan}");
|
||||
assert_eq!(resolved.quota.storage_quota, storage_quota, "{plan}");
|
||||
assert_eq!(resolved.quota.copilot_action_limit, copilot_limit, "{plan}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_quantity_for_fixed_catalog_plans() {
|
||||
for plan in ["free", "pro", "lifetime_pro", "ai", "selfhost_free"] {
|
||||
let mut input = input(Some(plan), Some(50));
|
||||
if plan == "selfhost_free" {
|
||||
input.deployment_type = "selfhosted".to_string();
|
||||
}
|
||||
|
||||
let resolved = resolve_entitlement_v1(input).unwrap();
|
||||
|
||||
assert_eq!(resolved.quantity, None, "{plan}");
|
||||
assert_ne!(resolved.quota.seat_limit, Some(50), "{plan}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_quantity() {
|
||||
for quantity in [0, -1, MAX_SEAT_QUANTITY + 1] {
|
||||
let err = resolve_entitlement_v1(input(Some("team"), Some(quantity))).unwrap_err();
|
||||
assert_eq!(err.status, Status::InvalidArg, "{quantity}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsigned_selfhosted_commercial_entitlements() {
|
||||
for plan in ["pro", "lifetime_pro", "ai", "team", "selfhost_team"] {
|
||||
let mut input = input(Some(plan), Some(50));
|
||||
input.deployment_type = "selfhosted".to_string();
|
||||
|
||||
let err = resolve_entitlement_v1(input).unwrap_err();
|
||||
|
||||
assert_eq!(err.status, Status::InvalidArg, "{plan}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_schema_errors() {
|
||||
let mut input = input(Some("free"), None);
|
||||
input.deployment_type = "local".to_string();
|
||||
let err = resolve_entitlement_v1(input).unwrap_err();
|
||||
assert_eq!(err.status, Status::InvalidArg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_signed_payload_outside_selfhost_workspace_boundary() {
|
||||
let cases = [
|
||||
("cloud", "workspace"),
|
||||
("selfhosted", "user"),
|
||||
("selfhosted", "instance"),
|
||||
];
|
||||
|
||||
for (deployment_type, target_type) in cases {
|
||||
let mut input = license_input("valid.license", TEST_WORKSPACE_ID);
|
||||
input.deployment_type = deployment_type.to_string();
|
||||
input.target_type = target_type.to_string();
|
||||
let err = resolve_entitlement_v1(input).unwrap_err();
|
||||
assert_eq!(err.status, Status::InvalidArg, "{deployment_type}/{target_type}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_selfhost_license_files() {
|
||||
let cases = [
|
||||
("valid.license", TEST_WORKSPACE_ID, true, "active", None, Some(20)),
|
||||
(
|
||||
"valid.license",
|
||||
"other-workspace",
|
||||
false,
|
||||
"needs_reupload",
|
||||
Some("workspace_mismatch"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"expired.license",
|
||||
TEST_WORKSPACE_ID,
|
||||
false,
|
||||
"expired",
|
||||
Some("expired"),
|
||||
Some(20),
|
||||
),
|
||||
(
|
||||
"expired-end-at.license",
|
||||
TEST_WORKSPACE_ID,
|
||||
false,
|
||||
"expired",
|
||||
Some("expired_end_at"),
|
||||
Some(20),
|
||||
),
|
||||
];
|
||||
|
||||
for (file, workspace_id, valid, status, error_code, quantity) in cases {
|
||||
let resolved = resolve_entitlement_v1(license_input(file, workspace_id)).unwrap();
|
||||
assert_eq!(resolved.valid, valid, "{file}");
|
||||
assert_eq!(resolved.status, status, "{file}");
|
||||
assert_eq!(resolved.error_code.as_deref(), error_code, "{file}");
|
||||
assert_eq!(resolved.quantity, quantity, "{file}");
|
||||
if valid {
|
||||
assert_eq!(resolved.plan, "selfhost_team", "{file}");
|
||||
assert_eq!(resolved.quota.seat_limit, quantity, "{file}");
|
||||
assert_eq!(resolved.quota.storage_quota, 500 * ONE_GB, "{file}");
|
||||
assert_eq!(resolved.quota.blob_limit, 500 * ONE_MB, "{file}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_signature_branch() {
|
||||
let (iv, decrypted) = decrypted_license("valid.license");
|
||||
let mut envelope: LicenseEnvelope = serde_json::from_slice(&decrypted).unwrap();
|
||||
envelope.signature = "00".to_string();
|
||||
let decrypted = serde_json::to_vec(&envelope).unwrap();
|
||||
let err = verify_license(&(iv, decrypted), TEST_PUBLIC_KEY).unwrap_err();
|
||||
|
||||
assert_eq!(err.0, "invalid_signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_license_payload_schema_and_quantity_errors() {
|
||||
let mut payload: LicensePayload = serde_json::from_str(
|
||||
&serde_json::from_slice::<LicenseEnvelope>(&decrypted_license("valid.license").1)
|
||||
.unwrap()
|
||||
.payload,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for quantity in [0, -1] {
|
||||
payload.data.quantity = quantity;
|
||||
let err = validate_license_payload(&payload).unwrap_err();
|
||||
assert_eq!(err.0, "invalid_payload");
|
||||
}
|
||||
|
||||
payload.data.quantity = 20;
|
||||
payload.data.workspace_id.clear();
|
||||
let err = validate_license_payload(&payload).unwrap_err();
|
||||
assert_eq!(err.0, "invalid_payload");
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ mod utils;
|
||||
|
||||
pub mod doc;
|
||||
pub mod doc_loader;
|
||||
pub mod entitlement;
|
||||
pub mod file_type;
|
||||
pub mod hashcash;
|
||||
pub mod html_sanitize;
|
||||
pub mod image;
|
||||
pub mod llm;
|
||||
pub mod permission;
|
||||
pub mod safe_fetch;
|
||||
pub mod tiktoken;
|
||||
|
||||
use affine_common::napi_utils::map_napi_err;
|
||||
|
||||
@@ -706,8 +706,8 @@
|
||||
"optionalModels": [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
"claude-sonnet-4-5@20250929"
|
||||
"gemini-3.5-flash",
|
||||
"claude-sonnet-4-6"
|
||||
],
|
||||
"config": {
|
||||
"tools": [
|
||||
@@ -722,11 +722,7 @@
|
||||
"codeArtifact",
|
||||
"blobRead"
|
||||
],
|
||||
"proModels": [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-3.1-pro-preview",
|
||||
"claude-sonnet-4-5@20250929"
|
||||
]
|
||||
"proModels": ["gemini-2.5-pro", "gemini-3.5-flash", "claude-sonnet-4-6"]
|
||||
},
|
||||
"builtins": [
|
||||
"date",
|
||||
|
||||
@@ -61,12 +61,12 @@ mod tests {
|
||||
fn should_resolve_backend_scoped_alias() {
|
||||
let response = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
|
||||
backend_kind: Some("anthropic_vertex".to_string()),
|
||||
model_id: "claude-sonnet-4.5".to_string(),
|
||||
model_id: "claude-sonnet-4.6".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.matched_by.as_deref(), Some("canonical"));
|
||||
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-5@20250929");
|
||||
assert_eq!(response.variant.unwrap().raw_model_id, "claude-sonnet-4-6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -111,6 +111,21 @@ mod tests {
|
||||
assert_eq!(response.variant.unwrap().raw_model_id, "gemini-embedding-001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_resolve_gemini_embedding_2() {
|
||||
let response = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
|
||||
backend_kind: Some("gemini_api".to_string()),
|
||||
model_id: "gemini-embedding-2".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let variant = response.variant.unwrap();
|
||||
|
||||
assert_eq!(variant.raw_model_id, "gemini-embedding-2");
|
||||
assert_eq!(variant.protocol.as_deref(), Some("gemini"));
|
||||
assert_eq!(variant.request_layer.as_deref(), Some("gemini_api"));
|
||||
assert_eq!(variant.display_name.as_deref(), Some("Gemini Embedding 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_keep_same_raw_id_as_two_backend_variants() {
|
||||
let api_variant = llm_resolve_model_registry_variant(ModelRegistryResolveRequest {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::types::{DocRole, WorkspaceRole};
|
||||
|
||||
pub(super) const VERSION: u32 = 1;
|
||||
|
||||
const WORKSPACE_EXTERNAL_ACTIONS: &[&str] = &[
|
||||
"Workspace.Read",
|
||||
"Workspace.Organize.Read",
|
||||
"Workspace.Properties.Read",
|
||||
"Workspace.Blobs.Read",
|
||||
];
|
||||
|
||||
const WORKSPACE_MEMBER_ACTIONS: &[&str] = &[
|
||||
"Workspace.Sync",
|
||||
"Workspace.CreateDoc",
|
||||
"Workspace.Users.Read",
|
||||
"Workspace.Settings.Read",
|
||||
"Workspace.Blobs.Write",
|
||||
"Workspace.Blobs.List",
|
||||
"Workspace.Copilot",
|
||||
];
|
||||
|
||||
const WORKSPACE_ADMIN_ACTIONS: &[&str] = &[
|
||||
"Workspace.Users.Manage",
|
||||
"Workspace.Settings.Update",
|
||||
"Workspace.Properties.Create",
|
||||
"Workspace.Properties.Update",
|
||||
"Workspace.Properties.Delete",
|
||||
];
|
||||
|
||||
const WORKSPACE_OWNER_ACTIONS: &[&str] = &[
|
||||
"Workspace.Delete",
|
||||
"Workspace.Administrators.Manage",
|
||||
"Workspace.TransferOwner",
|
||||
"Workspace.Payment.Manage",
|
||||
];
|
||||
|
||||
const DOC_EXTERNAL_ACTIONS: &[&str] = &["Doc.Read", "Doc.Copy", "Doc.Properties.Read", "Doc.Comments.Read"];
|
||||
const DOC_READER_ACTIONS: &[&str] = &["Doc.Users.Read", "Doc.Duplicate"];
|
||||
const DOC_COMMENTER_ACTIONS: &[&str] = &["Doc.Comments.Create"];
|
||||
|
||||
const DOC_EDITOR_ACTIONS: &[&str] = &[
|
||||
"Doc.Trash",
|
||||
"Doc.Restore",
|
||||
"Doc.Delete",
|
||||
"Doc.Properties.Update",
|
||||
"Doc.Update",
|
||||
"Doc.Comments.Update",
|
||||
"Doc.Comments.Resolve",
|
||||
"Doc.Comments.Delete",
|
||||
];
|
||||
|
||||
const DOC_MANAGER_ACTIONS: &[&str] = &["Doc.Publish", "Doc.Users.Manage"];
|
||||
const DOC_OWNER_ACTIONS: &[&str] = &["Doc.TransferOwner"];
|
||||
|
||||
pub(super) const WORKSPACE_PREVIEW_ACTION: &str = "Workspace.Preview";
|
||||
pub(super) const DOC_PREVIEW_ACTION: &str = "Doc.Preview";
|
||||
|
||||
const WORKSPACE_WRITE_ACTIONS: &[&str] = &[
|
||||
"Workspace.Sync",
|
||||
"Workspace.CreateDoc",
|
||||
"Workspace.Delete",
|
||||
"Workspace.TransferOwner",
|
||||
"Workspace.Users.Manage",
|
||||
"Workspace.Administrators.Manage",
|
||||
"Workspace.Properties.Create",
|
||||
"Workspace.Properties.Update",
|
||||
"Workspace.Properties.Delete",
|
||||
"Workspace.Settings.Update",
|
||||
"Workspace.Blobs.Write",
|
||||
"Workspace.Payment.Manage",
|
||||
];
|
||||
|
||||
const DOC_WRITE_ACTIONS: &[&str] = &[
|
||||
"Doc.Duplicate",
|
||||
"Doc.Trash",
|
||||
"Doc.Restore",
|
||||
"Doc.Delete",
|
||||
"Doc.Update",
|
||||
"Doc.Publish",
|
||||
"Doc.TransferOwner",
|
||||
"Doc.Properties.Update",
|
||||
"Doc.Users.Manage",
|
||||
"Doc.Comments.Create",
|
||||
"Doc.Comments.Update",
|
||||
"Doc.Comments.Delete",
|
||||
"Doc.Comments.Resolve",
|
||||
];
|
||||
|
||||
fn action_set(groups: &[&[&str]]) -> BTreeSet<String> {
|
||||
groups
|
||||
.iter()
|
||||
.flat_map(|group| group.iter().copied())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn workspace_actions_for_role(role: WorkspaceRole) -> BTreeSet<String> {
|
||||
match role {
|
||||
WorkspaceRole::External => action_set(&[WORKSPACE_EXTERNAL_ACTIONS]),
|
||||
WorkspaceRole::Member => action_set(&[WORKSPACE_EXTERNAL_ACTIONS, WORKSPACE_MEMBER_ACTIONS]),
|
||||
WorkspaceRole::Admin => action_set(&[
|
||||
WORKSPACE_EXTERNAL_ACTIONS,
|
||||
WORKSPACE_MEMBER_ACTIONS,
|
||||
WORKSPACE_ADMIN_ACTIONS,
|
||||
]),
|
||||
WorkspaceRole::Owner => action_set(&[
|
||||
WORKSPACE_EXTERNAL_ACTIONS,
|
||||
WORKSPACE_MEMBER_ACTIONS,
|
||||
WORKSPACE_ADMIN_ACTIONS,
|
||||
WORKSPACE_OWNER_ACTIONS,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn doc_actions_for_role(role: DocRole) -> BTreeSet<String> {
|
||||
match role {
|
||||
DocRole::None => BTreeSet::new(),
|
||||
DocRole::External => action_set(&[DOC_EXTERNAL_ACTIONS]),
|
||||
DocRole::Reader => action_set(&[DOC_EXTERNAL_ACTIONS, DOC_READER_ACTIONS]),
|
||||
DocRole::Commenter => action_set(&[DOC_EXTERNAL_ACTIONS, DOC_READER_ACTIONS, DOC_COMMENTER_ACTIONS]),
|
||||
DocRole::Editor => action_set(&[
|
||||
DOC_EXTERNAL_ACTIONS,
|
||||
DOC_READER_ACTIONS,
|
||||
DOC_COMMENTER_ACTIONS,
|
||||
DOC_EDITOR_ACTIONS,
|
||||
]),
|
||||
DocRole::Manager => action_set(&[
|
||||
DOC_EXTERNAL_ACTIONS,
|
||||
DOC_READER_ACTIONS,
|
||||
DOC_COMMENTER_ACTIONS,
|
||||
DOC_EDITOR_ACTIONS,
|
||||
DOC_MANAGER_ACTIONS,
|
||||
]),
|
||||
DocRole::Owner => action_set(&[
|
||||
DOC_EXTERNAL_ACTIONS,
|
||||
DOC_READER_ACTIONS,
|
||||
DOC_COMMENTER_ACTIONS,
|
||||
DOC_EDITOR_ACTIONS,
|
||||
DOC_MANAGER_ACTIONS,
|
||||
DOC_OWNER_ACTIONS,
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_write_action(action: &str) -> bool {
|
||||
WORKSPACE_WRITE_ACTIONS.contains(&action) || DOC_WRITE_ACTIONS.contains(&action)
|
||||
}
|
||||
|
||||
pub(super) fn is_readonly_restricted_action(action: &str) -> bool {
|
||||
matches!(
|
||||
action,
|
||||
"Workspace.CreateDoc"
|
||||
| "Workspace.Settings.Update"
|
||||
| "Workspace.Properties.Create"
|
||||
| "Workspace.Properties.Update"
|
||||
| "Workspace.Properties.Delete"
|
||||
| "Workspace.Blobs.Write"
|
||||
| "Doc.Update"
|
||||
| "Doc.Duplicate"
|
||||
| "Doc.Publish"
|
||||
| "Doc.Comments.Create"
|
||||
| "Doc.Comments.Update"
|
||||
| "Doc.Comments.Resolve"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn role_matrix_json() -> Value {
|
||||
let workspace_roles = [
|
||||
("external", WorkspaceRole::External),
|
||||
("member", WorkspaceRole::Member),
|
||||
("admin", WorkspaceRole::Admin),
|
||||
("owner", WorkspaceRole::Owner),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, role)| (name, workspace_actions_for_role(role).into_iter().collect::<Vec<_>>()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let doc_roles = [
|
||||
("none", DocRole::None),
|
||||
("external", DocRole::External),
|
||||
("reader", DocRole::Reader),
|
||||
("commenter", DocRole::Commenter),
|
||||
("editor", DocRole::Editor),
|
||||
("manager", DocRole::Manager),
|
||||
("owner", DocRole::Owner),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, role)| (name, doc_actions_for_role(role).into_iter().collect::<Vec<_>>()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
json!({
|
||||
"version": VERSION,
|
||||
"workspace": {
|
||||
"roles": workspace_roles,
|
||||
"capabilityProfiles": {
|
||||
"workspacePreview": [WORKSPACE_PREVIEW_ACTION],
|
||||
},
|
||||
"readonlyWriteActions": {
|
||||
"restricted": [
|
||||
"Workspace.CreateDoc",
|
||||
"Workspace.Settings.Update",
|
||||
"Workspace.Properties.Create",
|
||||
"Workspace.Properties.Update",
|
||||
"Workspace.Properties.Delete",
|
||||
"Workspace.Blobs.Write",
|
||||
],
|
||||
},
|
||||
},
|
||||
"doc": {
|
||||
"roles": doc_roles,
|
||||
"capabilityProfiles": {
|
||||
"docPreview": [DOC_PREVIEW_ACTION],
|
||||
},
|
||||
"readonlyWriteActions": {
|
||||
"restricted": [
|
||||
"Doc.Update",
|
||||
"Doc.Duplicate",
|
||||
"Doc.Publish",
|
||||
"Doc.Comments.Create",
|
||||
"Doc.Comments.Update",
|
||||
"Doc.Comments.Resolve",
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matrix_artifact_exposes_profiles_and_restrictions() {
|
||||
let artifact = role_matrix_json();
|
||||
assert_eq!(artifact["version"], 1);
|
||||
assert_eq!(
|
||||
artifact["doc"]["capabilityProfiles"]["docPreview"][0],
|
||||
DOC_PREVIEW_ACTION
|
||||
);
|
||||
assert_eq!(
|
||||
artifact["workspace"]["roles"]["owner"][0],
|
||||
"Workspace.Administrators.Manage"
|
||||
);
|
||||
assert!(
|
||||
artifact["doc"]["roles"]["external"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("Doc.Read"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{
|
||||
actions::{
|
||||
DOC_PREVIEW_ACTION, WORKSPACE_PREVIEW_ACTION, doc_actions_for_role, is_readonly_restricted_action, is_write_action,
|
||||
workspace_actions_for_role,
|
||||
},
|
||||
types::{
|
||||
Candidate, DocRole, PermissionDecisionRestrictionV1, PermissionDecisionSourceV1, PermissionDecisionV1,
|
||||
PermissionDocInputV1, PermissionEvaluationInputV1, WorkspaceRole,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) fn parse_workspace_role(role: &str) -> anyhow::Result<WorkspaceRole> {
|
||||
match role {
|
||||
"external" => Ok(WorkspaceRole::External),
|
||||
"member" => Ok(WorkspaceRole::Member),
|
||||
"admin" => Ok(WorkspaceRole::Admin),
|
||||
"owner" => Ok(WorkspaceRole::Owner),
|
||||
_ => anyhow::bail!("unknown workspace role: {role}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_doc_role(role: &str) -> anyhow::Result<DocRole> {
|
||||
match role {
|
||||
"none" => Ok(DocRole::None),
|
||||
"external" => Ok(DocRole::External),
|
||||
"reader" => Ok(DocRole::Reader),
|
||||
"commenter" => Ok(DocRole::Commenter),
|
||||
"editor" => Ok(DocRole::Editor),
|
||||
"manager" => Ok(DocRole::Manager),
|
||||
"owner" => Ok(DocRole::Owner),
|
||||
_ => anyhow::bail!("unknown doc role: {role}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn role_name(role: impl Serialize) -> String {
|
||||
serde_json::to_value(role)
|
||||
.ok()
|
||||
.and_then(|value| value.as_str().map(str::to_string))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn active_workspace_role(input: &PermissionEvaluationInputV1) -> anyhow::Result<Option<WorkspaceRole>> {
|
||||
let Some(role) = input.workspace.role.as_deref() else {
|
||||
if input.workspace.local && input.subject.allow_local {
|
||||
return Ok(Some(WorkspaceRole::Owner));
|
||||
}
|
||||
if input.workspace.public && sharing_enabled(input, None) {
|
||||
return Ok(Some(WorkspaceRole::External));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
if input.workspace.member_state.as_deref().unwrap_or("active") != "active" {
|
||||
return Ok(None);
|
||||
}
|
||||
let role = parse_workspace_role(role)?;
|
||||
if role == WorkspaceRole::External {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(role))
|
||||
}
|
||||
|
||||
fn sharing_enabled(input: &PermissionEvaluationInputV1, doc: Option<&PermissionDocInputV1>) -> bool {
|
||||
doc
|
||||
.and_then(|doc| doc.sharing_enabled)
|
||||
.or(input.runtime.sharing_enabled)
|
||||
.or(input.workspace.sharing_enabled)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn url_preview_enabled(input: &PermissionEvaluationInputV1) -> bool {
|
||||
input
|
||||
.runtime
|
||||
.url_preview_enabled
|
||||
.or(input.workspace.url_preview_enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn restricted_decision(input: &PermissionEvaluationInputV1, action: &str) -> Vec<PermissionDecisionRestrictionV1> {
|
||||
if !is_write_action(action) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut restrictions = Vec::new();
|
||||
if !input.runtime.known {
|
||||
restrictions.push(PermissionDecisionRestrictionV1 {
|
||||
restriction_type: "runtime_unknown",
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
if input.runtime.stale {
|
||||
restrictions.push(PermissionDecisionRestrictionV1 {
|
||||
restriction_type: "runtime_stale",
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
if input.runtime.readonly && is_readonly_restricted_action(action) {
|
||||
restrictions.push(PermissionDecisionRestrictionV1 {
|
||||
restriction_type: "readonly",
|
||||
reason: input.runtime.readonly_reason.clone(),
|
||||
});
|
||||
}
|
||||
restrictions
|
||||
}
|
||||
|
||||
pub(super) fn decide(
|
||||
input: &PermissionEvaluationInputV1,
|
||||
action: &str,
|
||||
candidates: &[Candidate],
|
||||
) -> PermissionDecisionV1 {
|
||||
let sources = candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.actions.contains(action))
|
||||
.map(|candidate| PermissionDecisionSourceV1 {
|
||||
source_type: candidate.source_type,
|
||||
role: Some(candidate.role.clone()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let restrictions = restricted_decision(input, action);
|
||||
|
||||
PermissionDecisionV1 {
|
||||
action: action.to_string(),
|
||||
allowed: !sources.is_empty() && restrictions.is_empty(),
|
||||
sources,
|
||||
restrictions,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decide_doc(
|
||||
input: &PermissionEvaluationInputV1,
|
||||
doc: &PermissionDocInputV1,
|
||||
action: &str,
|
||||
candidates: &[Candidate],
|
||||
) -> PermissionDecisionV1 {
|
||||
let mut decision = decide(input, action, candidates);
|
||||
if action == "Doc.Publish" && !sharing_enabled(input, Some(doc)) {
|
||||
decision.restrictions.push(PermissionDecisionRestrictionV1 {
|
||||
restriction_type: "sharing-disabled",
|
||||
reason: None,
|
||||
});
|
||||
decision.allowed = false;
|
||||
}
|
||||
decision
|
||||
}
|
||||
|
||||
pub(super) fn workspace_candidates(input: &PermissionEvaluationInputV1) -> anyhow::Result<Vec<Candidate>> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(role) = active_workspace_role(input)? {
|
||||
candidates.push(Candidate {
|
||||
source_type: "workspace-member",
|
||||
role: role_name(role),
|
||||
actions: workspace_actions_for_role(role),
|
||||
owner: role == WorkspaceRole::Owner,
|
||||
});
|
||||
}
|
||||
|
||||
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
|
||||
candidates.push(Candidate {
|
||||
source_type: "local-workspace",
|
||||
role: "owner".to_string(),
|
||||
actions: workspace_actions_for_role(WorkspaceRole::Owner),
|
||||
owner: true,
|
||||
});
|
||||
}
|
||||
|
||||
if input.workspace.public && sharing_enabled(input, None) {
|
||||
candidates.push(Candidate {
|
||||
source_type: "workspace-policy",
|
||||
role: "external".to_string(),
|
||||
actions: workspace_actions_for_role(WorkspaceRole::External),
|
||||
owner: false,
|
||||
});
|
||||
}
|
||||
|
||||
if sharing_enabled(input, None) && (input.workspace.public || url_preview_enabled(input)) {
|
||||
candidates.push(Candidate {
|
||||
source_type: "workspace-preview-policy",
|
||||
role: "preview".to_string(),
|
||||
actions: BTreeSet::from([WORKSPACE_PREVIEW_ACTION.to_string()]),
|
||||
owner: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub(super) fn best_doc_role(candidates: &[Candidate]) -> Option<String> {
|
||||
candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| parse_doc_role(&candidate.role).ok())
|
||||
.filter(|role| *role != DocRole::None)
|
||||
.max()
|
||||
.map(role_name)
|
||||
}
|
||||
|
||||
pub(super) fn doc_candidates(
|
||||
input: &PermissionEvaluationInputV1,
|
||||
doc: &PermissionDocInputV1,
|
||||
) -> anyhow::Result<Vec<Candidate>> {
|
||||
let mut candidates = Vec::new();
|
||||
let active_workspace_role = active_workspace_role(input)?;
|
||||
let active_workspace_member = matches!(
|
||||
active_workspace_role,
|
||||
Some(WorkspaceRole::Member | WorkspaceRole::Admin | WorkspaceRole::Owner)
|
||||
);
|
||||
let sharing = sharing_enabled(input, Some(doc));
|
||||
|
||||
match active_workspace_role {
|
||||
Some(WorkspaceRole::Owner) => candidates.push(Candidate {
|
||||
source_type: "inherited-workspace-role",
|
||||
role: "owner".to_string(),
|
||||
actions: doc_actions_for_role(DocRole::Owner),
|
||||
owner: false,
|
||||
}),
|
||||
Some(WorkspaceRole::Admin) => candidates.push(Candidate {
|
||||
source_type: "inherited-workspace-role",
|
||||
role: "manager".to_string(),
|
||||
actions: doc_actions_for_role(DocRole::Manager),
|
||||
owner: false,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let explicit_user_role = doc
|
||||
.explicit_user_role
|
||||
.as_deref()
|
||||
.map(parse_doc_role)
|
||||
.transpose()?
|
||||
.filter(|role| *role != DocRole::None);
|
||||
|
||||
if let Some(mut role) = explicit_user_role {
|
||||
if !active_workspace_member {
|
||||
role = role.min(DocRole::Editor);
|
||||
}
|
||||
if active_workspace_member || sharing {
|
||||
candidates.push(Candidate {
|
||||
source_type: "doc-grant",
|
||||
role: role_name(role),
|
||||
actions: doc_actions_for_role(role),
|
||||
owner: role == DocRole::Owner,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if doc.group_grants_enabled && !input.subject.group_ids.is_empty() {
|
||||
let subject_groups = input.subject.group_ids.iter().collect::<BTreeSet<_>>();
|
||||
for grant in &doc.group_grants {
|
||||
if subject_groups.contains(&grant.group_id) {
|
||||
let role = parse_doc_role(&grant.role)?;
|
||||
candidates.push(Candidate {
|
||||
source_type: "group-grant",
|
||||
role: role_name(role),
|
||||
actions: doc_actions_for_role(role),
|
||||
owner: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(active_workspace_role, Some(role) if role != WorkspaceRole::External)
|
||||
&& explicit_user_role.is_none()
|
||||
&& let Some(role) = doc.member_default_role.as_deref()
|
||||
{
|
||||
let role = parse_doc_role(role)?;
|
||||
candidates.push(Candidate {
|
||||
source_type: "member-default-policy",
|
||||
role: role_name(role),
|
||||
actions: doc_actions_for_role(role),
|
||||
owner: false,
|
||||
});
|
||||
}
|
||||
|
||||
if sharing
|
||||
&& doc.visibility.as_deref() == Some("public")
|
||||
&& let Some(role) = doc.public_role.as_deref()
|
||||
{
|
||||
let role = parse_doc_role(role)?;
|
||||
candidates.push(Candidate {
|
||||
source_type: "public-policy",
|
||||
role: role_name(role),
|
||||
actions: doc_actions_for_role(role),
|
||||
owner: false,
|
||||
});
|
||||
}
|
||||
|
||||
if sharing && (doc.preview_enabled || doc.visibility.as_deref() == Some("public") || url_preview_enabled(input)) {
|
||||
candidates.push(Candidate {
|
||||
source_type: "doc-preview-policy",
|
||||
role: "preview".to_string(),
|
||||
actions: BTreeSet::from([DOC_PREVIEW_ACTION.to_string()]),
|
||||
owner: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !sharing {
|
||||
for candidate in &mut candidates {
|
||||
candidate.actions.remove("Doc.Publish");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
use super::{
|
||||
actions::VERSION,
|
||||
candidates::{
|
||||
best_doc_role, decide, decide_doc, doc_candidates, parse_workspace_role, role_name, workspace_candidates,
|
||||
},
|
||||
types::{
|
||||
PermissionDocEvaluationOutputV1, PermissionEvaluationInputV1, PermissionEvaluationOutputV1,
|
||||
PermissionWorkspaceEvaluationOutputV1,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn evaluate_permission(input: PermissionEvaluationInputV1) -> anyhow::Result<PermissionEvaluationOutputV1> {
|
||||
if input.version != VERSION {
|
||||
anyhow::bail!("unsupported permission evaluation input version: {}", input.version);
|
||||
}
|
||||
|
||||
let workspace_candidates = workspace_candidates(&input)?;
|
||||
let workspace_decisions = input
|
||||
.workspace_actions
|
||||
.iter()
|
||||
.map(|action| decide(&input, action, &workspace_candidates))
|
||||
.collect::<Vec<_>>();
|
||||
let workspace_effective_role = workspace_candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| parse_workspace_role(&candidate.role).ok())
|
||||
.max()
|
||||
.map(role_name);
|
||||
let workspace_resource_owner_role = workspace_candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.owner)
|
||||
.then(|| "owner".to_string());
|
||||
|
||||
let mut docs = Vec::with_capacity(input.docs.len());
|
||||
for doc in &input.docs {
|
||||
let candidates = doc_candidates(&input, doc)?;
|
||||
let decisions = doc
|
||||
.actions
|
||||
.iter()
|
||||
.map(|action| decide_doc(&input, doc, action, &candidates))
|
||||
.collect::<Vec<_>>();
|
||||
let resource_owner_role = candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.owner)
|
||||
.then(|| "owner".to_string());
|
||||
docs.push(PermissionDocEvaluationOutputV1 {
|
||||
doc_id: doc.doc_id.clone(),
|
||||
resource_owner_role,
|
||||
effective_role: best_doc_role(&candidates),
|
||||
decisions,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PermissionEvaluationOutputV1 {
|
||||
version: VERSION,
|
||||
workspace: PermissionWorkspaceEvaluationOutputV1 {
|
||||
resource_owner_role: workspace_resource_owner_role,
|
||||
effective_role: workspace_effective_role,
|
||||
decisions: workspace_decisions,
|
||||
},
|
||||
docs,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::permission::types::{
|
||||
PermissionDecisionV1, PermissionDocInputV1, PermissionGroupGrantInputV1, PermissionRuntimeInputV1,
|
||||
PermissionSubjectInputV1, PermissionWorkspaceInputV1,
|
||||
};
|
||||
|
||||
fn base_input() -> PermissionEvaluationInputV1 {
|
||||
PermissionEvaluationInputV1 {
|
||||
version: 1,
|
||||
legacy_compat_mode: false,
|
||||
subject: PermissionSubjectInputV1::default(),
|
||||
runtime: PermissionRuntimeInputV1 {
|
||||
known: true,
|
||||
sharing_enabled: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
workspace: PermissionWorkspaceInputV1 {
|
||||
role: Some("member".to_string()),
|
||||
member_state: Some("active".to_string()),
|
||||
sharing_enabled: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
workspace_actions: vec!["Workspace.Read".to_string(), "Workspace.CreateDoc".to_string()],
|
||||
docs: vec![PermissionDocInputV1 {
|
||||
doc_id: "doc".to_string(),
|
||||
actions: vec!["Doc.Read".to_string(), "Doc.Update".to_string()],
|
||||
member_default_role: Some("manager".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn decision<'a>(decisions: &'a [PermissionDecisionV1], action: &str) -> &'a PermissionDecisionV1 {
|
||||
decisions.iter().find(|decision| decision.action == action).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_member_role_authorizes_workspace_and_doc_default() {
|
||||
let output = evaluate_permission(base_input()).unwrap();
|
||||
assert!(decision(&output.workspace.decisions, "Workspace.Read").allowed);
|
||||
assert!(decision(&output.workspace.decisions, "Workspace.CreateDoc").allowed);
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_and_waiting_members_do_not_authorize() {
|
||||
for state in ["pending", "waiting_review", "waiting_seat"] {
|
||||
let mut input = base_input();
|
||||
input.workspace.member_state = Some(state.to_string());
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(!decision(&output.workspace.decisions, "Workspace.Read").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_and_admin_inherit_doc_permissions_without_doc_ownership_pollution() {
|
||||
let mut owner_input = base_input();
|
||||
owner_input.workspace.role = Some("owner".to_string());
|
||||
owner_input.docs[0].actions = vec!["Doc.TransferOwner".to_string()];
|
||||
let owner_output = evaluate_permission(owner_input).unwrap();
|
||||
let owner_doc = &owner_output.docs[0];
|
||||
assert!(decision(&owner_doc.decisions, "Doc.TransferOwner").allowed);
|
||||
assert_eq!(owner_doc.resource_owner_role, None);
|
||||
assert_eq!(owner_doc.effective_role.as_deref(), Some("owner"));
|
||||
|
||||
let mut admin_input = base_input();
|
||||
admin_input.workspace.role = Some("admin".to_string());
|
||||
admin_input.docs[0].actions = vec!["Doc.Users.Manage".to_string(), "Doc.TransferOwner".to_string()];
|
||||
let admin_output = evaluate_permission(admin_input).unwrap();
|
||||
assert!(decision(&admin_output.docs[0].decisions, "Doc.Users.Manage").allowed);
|
||||
assert!(!decision(&admin_output.docs[0].decisions, "Doc.TransferOwner").allowed);
|
||||
assert_eq!(admin_output.docs[0].resource_owner_role, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_doc_grant_sets_resource_owner_only_for_owner_grant() {
|
||||
let mut input = base_input();
|
||||
input.docs[0].explicit_user_role = Some("reader".to_string());
|
||||
input.docs[0].member_default_role = Some("manager".to_string());
|
||||
input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Update".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
assert_eq!(output.docs[0].resource_owner_role, None);
|
||||
|
||||
let mut owner_input = base_input();
|
||||
owner_input.docs[0].explicit_user_role = Some("owner".to_string());
|
||||
owner_input.docs[0].actions = vec!["Doc.TransferOwner".to_string()];
|
||||
let owner_output = evaluate_permission(owner_input).unwrap();
|
||||
assert!(decision(&owner_output.docs[0].decisions, "Doc.TransferOwner").allowed);
|
||||
assert_eq!(owner_output.docs[0].resource_owner_role.as_deref(), Some("owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_legacy_row_behaves_like_missing_grant() {
|
||||
let mut input = base_input();
|
||||
input.docs[0].explicit_user_role = Some("none".to_string());
|
||||
input.docs[0].member_default_role = Some("manager".to_string());
|
||||
input.docs[0].actions = vec!["Doc.Update".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
let update = decision(&output.docs[0].decisions, "Doc.Update");
|
||||
assert!(update.allowed);
|
||||
assert_eq!(update.sources[0].source_type, "member-default-policy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_member_explicit_doc_grant_is_capped_at_editor() {
|
||||
let mut input = base_input();
|
||||
input.workspace.role = None;
|
||||
input.docs[0].explicit_user_role = Some("owner".to_string());
|
||||
input.docs[0].actions = vec![
|
||||
"Doc.Update".to_string(),
|
||||
"Doc.Users.Manage".to_string(),
|
||||
"Doc.TransferOwner".to_string(),
|
||||
];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
|
||||
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
|
||||
assert_eq!(output.docs[0].resource_owner_role, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_external_workspace_row_does_not_uncap_explicit_doc_grant() {
|
||||
let mut input = base_input();
|
||||
input.workspace.role = Some("external".to_string());
|
||||
input.docs[0].explicit_user_role = Some("owner".to_string());
|
||||
input.docs[0].actions = vec![
|
||||
"Doc.Update".to_string(),
|
||||
"Doc.Users.Manage".to_string(),
|
||||
"Doc.TransferOwner".to_string(),
|
||||
];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
|
||||
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
|
||||
assert_eq!(output.docs[0].resource_owner_role, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_workspace_policy_does_not_uncap_explicit_doc_grant() {
|
||||
let mut input = base_input();
|
||||
input.workspace.role = None;
|
||||
input.workspace.public = true;
|
||||
input.docs[0].explicit_user_role = Some("owner".to_string());
|
||||
input.docs[0].actions = vec![
|
||||
"Doc.Update".to_string(),
|
||||
"Doc.Users.Manage".to_string(),
|
||||
"Doc.TransferOwner".to_string(),
|
||||
];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
|
||||
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
|
||||
assert_eq!(output.docs[0].resource_owner_role, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_default_none_unions_with_public_policy() {
|
||||
let mut input = base_input();
|
||||
input.docs[0].member_default_role = Some("none".to_string());
|
||||
input.docs[0].visibility = Some("public".to_string());
|
||||
input.docs[0].public_role = Some("external".to_string());
|
||||
input.docs[0].actions = vec![
|
||||
"Doc.Read".to_string(),
|
||||
"Doc.Users.Read".to_string(),
|
||||
"Doc.Duplicate".to_string(),
|
||||
];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Read").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Duplicate").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_doc_external_profile_and_url_preview_do_not_grant_read() {
|
||||
let mut public_input = base_input();
|
||||
public_input.workspace.role = None;
|
||||
public_input.docs[0].visibility = Some("public".to_string());
|
||||
public_input.docs[0].public_role = Some("external".to_string());
|
||||
public_input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Users.Read".to_string()];
|
||||
let public_output = evaluate_permission(public_input).unwrap();
|
||||
assert!(decision(&public_output.docs[0].decisions, "Doc.Read").allowed);
|
||||
assert!(!decision(&public_output.docs[0].decisions, "Doc.Users.Read").allowed);
|
||||
|
||||
let mut preview_input = base_input();
|
||||
preview_input.workspace.role = None;
|
||||
preview_input.runtime.url_preview_enabled = Some(true);
|
||||
preview_input.docs[0].actions = vec!["Doc.Preview".to_string(), "Doc.Read".to_string()];
|
||||
let preview_output = evaluate_permission(preview_input).unwrap();
|
||||
assert!(decision(&preview_output.docs[0].decisions, "Doc.Preview").allowed);
|
||||
assert!(!decision(&preview_output.docs[0].decisions, "Doc.Read").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_workspace_shell_does_not_grant_private_doc_read() {
|
||||
let mut input = base_input();
|
||||
input.workspace.role = None;
|
||||
input.workspace.public = true;
|
||||
input.workspace_actions = vec!["Workspace.Read".to_string()];
|
||||
input.docs[0].member_default_role = Some("manager".to_string());
|
||||
input.docs[0].visibility = Some("private".to_string());
|
||||
input.docs[0].public_role = None;
|
||||
input.docs[0].actions = vec!["Doc.Read".to_string()];
|
||||
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
|
||||
assert!(decision(&output.workspace.decisions, "Workspace.Read").allowed);
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_disabled_blocks_public_and_non_member_explicit_sources() {
|
||||
let mut input = base_input();
|
||||
input.workspace.role = None;
|
||||
input.runtime.sharing_enabled = Some(false);
|
||||
input.docs[0].visibility = Some("public".to_string());
|
||||
input.docs[0].public_role = Some("external".to_string());
|
||||
input.docs[0].explicit_user_role = Some("reader".to_string());
|
||||
input.docs[0].actions = vec!["Doc.Read".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_publish_requires_sharing_enabled() {
|
||||
let mut input = base_input();
|
||||
input.docs[0].member_default_role = Some("manager".to_string());
|
||||
input.docs[0].actions = vec!["Doc.Publish".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Publish").allowed);
|
||||
|
||||
let mut disabled_input = base_input();
|
||||
disabled_input.runtime.sharing_enabled = Some(false);
|
||||
disabled_input.docs[0].member_default_role = Some("manager".to_string());
|
||||
disabled_input.docs[0].actions = vec!["Doc.Publish".to_string()];
|
||||
let disabled_output = evaluate_permission(disabled_input).unwrap();
|
||||
let publish = decision(&disabled_output.docs[0].decisions, "Doc.Publish");
|
||||
assert!(!publish.allowed);
|
||||
assert_eq!(publish.restrictions[0].restriction_type, "sharing-disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_and_unknown_runtime_fail_closed_for_write_actions() {
|
||||
let mut input = base_input();
|
||||
input.runtime.readonly = true;
|
||||
input.runtime.readonly_reason = Some("storage_overflow".to_string());
|
||||
input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Update".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
|
||||
let update = decision(&output.docs[0].decisions, "Doc.Update");
|
||||
assert!(!update.allowed);
|
||||
assert_eq!(update.restrictions[0].restriction_type, "readonly");
|
||||
|
||||
let mut unknown_input = base_input();
|
||||
unknown_input.runtime.known = false;
|
||||
let unknown_output = evaluate_permission(unknown_input).unwrap();
|
||||
assert!(!decision(&unknown_output.workspace.decisions, "Workspace.CreateDoc").allowed);
|
||||
|
||||
let mut stale_input = base_input();
|
||||
stale_input.runtime.stale = true;
|
||||
let stale_output = evaluate_permission(stale_input).unwrap();
|
||||
let create_doc = decision(&stale_output.workspace.decisions, "Workspace.CreateDoc");
|
||||
assert!(!create_doc.allowed);
|
||||
assert_eq!(create_doc.restrictions[0].restriction_type, "runtime_stale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_local_workspace_fallback_is_opt_in() {
|
||||
let mut input = base_input();
|
||||
input.legacy_compat_mode = true;
|
||||
input.subject.allow_local = true;
|
||||
input.workspace = PermissionWorkspaceInputV1 {
|
||||
local: true,
|
||||
..Default::default()
|
||||
};
|
||||
input.runtime.known = false;
|
||||
input.runtime.stale = true;
|
||||
input.workspace_actions = vec!["Workspace.Delete".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(decision(&output.workspace.decisions, "Workspace.Delete").allowed);
|
||||
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_group_ids_do_not_enable_group_grants() {
|
||||
let mut input = base_input();
|
||||
input.docs[0].member_default_role = Some("none".to_string());
|
||||
input.docs[0].group_grants_enabled = true;
|
||||
input.docs[0].group_grants = vec![PermissionGroupGrantInputV1 {
|
||||
group_id: "group".to_string(),
|
||||
role: "manager".to_string(),
|
||||
}];
|
||||
input.docs[0].actions = vec!["Doc.Update".to_string()];
|
||||
let output = evaluate_permission(input).unwrap();
|
||||
assert!(!decision(&output.docs[0].decisions, "Doc.Update").allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
mod actions;
|
||||
mod candidates;
|
||||
mod evaluator;
|
||||
mod types;
|
||||
|
||||
use actions::role_matrix_json;
|
||||
pub use evaluator::evaluate_permission;
|
||||
use napi::{Error as NapiError, Result, Status};
|
||||
use napi_derive::napi;
|
||||
use serde_json::Value;
|
||||
pub use types::*;
|
||||
|
||||
#[napi]
|
||||
pub fn evaluate_permission_v1(input: Value) -> Result<Value> {
|
||||
let input = serde_json::from_value::<PermissionEvaluationInputV1>(input)
|
||||
.map_err(|err| NapiError::new(Status::InvalidArg, err.to_string()))?;
|
||||
evaluate_permission(input)
|
||||
.and_then(|output| serde_json::to_value(output).map_err(Into::into))
|
||||
.map_err(|err| NapiError::new(Status::GenericFailure, err.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn permission_action_role_matrix_v1() -> Value {
|
||||
role_matrix_json()
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn permission_action_role_matrix_v1_json() -> String {
|
||||
serde_json::to_string_pretty(&role_matrix_json()).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(super) enum WorkspaceRole {
|
||||
External,
|
||||
Member,
|
||||
Admin,
|
||||
Owner,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(super) enum DocRole {
|
||||
None,
|
||||
External,
|
||||
Reader,
|
||||
Commenter,
|
||||
Editor,
|
||||
Manager,
|
||||
Owner,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionSubjectInputV1 {
|
||||
#[serde(default)]
|
||||
pub user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub group_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_local: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionRuntimeInputV1 {
|
||||
#[serde(default)]
|
||||
pub known: bool,
|
||||
#[serde(default)]
|
||||
pub stale: bool,
|
||||
#[serde(default)]
|
||||
pub readonly: bool,
|
||||
#[serde(default)]
|
||||
pub readonly_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sharing_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub url_preview_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionWorkspaceInputV1 {
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub member_state: Option<String>,
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
#[serde(default)]
|
||||
pub sharing_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub url_preview_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionGroupGrantInputV1 {
|
||||
pub group_id: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionDocInputV1 {
|
||||
pub doc_id: String,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub explicit_user_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub group_grants: Vec<PermissionGroupGrantInputV1>,
|
||||
#[serde(default)]
|
||||
pub group_grants_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub member_default_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub public_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visibility: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sharing_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub preview_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionEvaluationInputV1 {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub legacy_compat_mode: bool,
|
||||
#[serde(default)]
|
||||
pub subject: PermissionSubjectInputV1,
|
||||
#[serde(default)]
|
||||
pub runtime: PermissionRuntimeInputV1,
|
||||
#[serde(default)]
|
||||
pub workspace: PermissionWorkspaceInputV1,
|
||||
#[serde(default)]
|
||||
pub workspace_actions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub docs: Vec<PermissionDocInputV1>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionDecisionSourceV1 {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionDecisionRestrictionV1 {
|
||||
#[serde(rename = "type")]
|
||||
pub restriction_type: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionDecisionV1 {
|
||||
pub action: String,
|
||||
pub allowed: bool,
|
||||
pub sources: Vec<PermissionDecisionSourceV1>,
|
||||
pub restrictions: Vec<PermissionDecisionRestrictionV1>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionWorkspaceEvaluationOutputV1 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_owner_role: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effective_role: Option<String>,
|
||||
pub decisions: Vec<PermissionDecisionV1>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionDocEvaluationOutputV1 {
|
||||
pub doc_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_owner_role: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effective_role: Option<String>,
|
||||
pub decisions: Vec<PermissionDecisionV1>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PermissionEvaluationOutputV1 {
|
||||
pub version: u32,
|
||||
pub workspace: PermissionWorkspaceEvaluationOutputV1,
|
||||
pub docs: Vec<PermissionDocEvaluationOutputV1>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Candidate {
|
||||
pub source_type: &'static str,
|
||||
pub role: String,
|
||||
pub actions: BTreeSet<String>,
|
||||
pub owner: bool,
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{Cursor, Read},
|
||||
net::{IpAddr, SocketAddr, ToSocketAddrs},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ::image::{ImageFormat, ImageReader};
|
||||
use anyhow::{Context, Result as AnyResult, bail};
|
||||
use napi::{
|
||||
Env, Error, Result, Status, Task,
|
||||
bindgen_prelude::{AsyncTask, Buffer},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use reqwest::{
|
||||
Method,
|
||||
blocking::{Client, Response},
|
||||
header::{HeaderMap, HeaderName, HeaderValue, LOCATION},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u32 = 10_000;
|
||||
const DEFAULT_MAX_REDIRECTS: u32 = 3;
|
||||
const DEFAULT_MAX_BYTES: u32 = 10 * 1024 * 1024;
|
||||
const MAX_IMAGE_DIMENSION: u32 = 16_384;
|
||||
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
|
||||
|
||||
#[napi(string_enum = "snake_case")]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum SafeFetchMethod {
|
||||
Get,
|
||||
Head,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SafeFetchRequest {
|
||||
pub url: String,
|
||||
pub method: Option<SafeFetchMethod>,
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
pub timeout_ms: Option<u32>,
|
||||
pub max_redirects: Option<u32>,
|
||||
pub max_bytes: Option<u32>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AssertSafeUrlRequest {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct SafeFetchResponse {
|
||||
pub status: u16,
|
||||
pub final_url: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub body: Buffer,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageInspectionOptions {
|
||||
pub max_width: Option<u32>,
|
||||
pub max_height: Option<u32>,
|
||||
pub max_pixels: Option<u32>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ImageInspection {
|
||||
pub mime_type: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteAttachmentFetchRequest {
|
||||
pub url: String,
|
||||
pub timeout_ms: Option<u32>,
|
||||
pub max_bytes: u32,
|
||||
pub allow_private_target_origin: Option<bool>,
|
||||
pub expected_content_type_prefix: Option<String>,
|
||||
pub max_image_width: Option<u32>,
|
||||
pub max_image_height: Option<u32>,
|
||||
pub max_image_pixels: Option<u32>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct RemoteAttachmentFetchResponse {
|
||||
pub final_url: String,
|
||||
pub mime_type: String,
|
||||
pub body: Buffer,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteMimeTypeRequest {
|
||||
pub url: String,
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct AsyncSafeFetchTask {
|
||||
request: SafeFetchRequest,
|
||||
}
|
||||
|
||||
pub struct AsyncRemoteAttachmentFetchTask {
|
||||
request: RemoteAttachmentFetchRequest,
|
||||
}
|
||||
|
||||
pub struct AsyncRemoteMimeTypeTask {
|
||||
request: RemoteMimeTypeRequest,
|
||||
}
|
||||
|
||||
pub struct SafeFetchOutput {
|
||||
status: u16,
|
||||
final_url: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
struct SafeFetchParams {
|
||||
url: String,
|
||||
method: Option<SafeFetchMethod>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
timeout_ms: Option<u32>,
|
||||
max_redirects: Option<u32>,
|
||||
max_bytes: Option<u32>,
|
||||
allow_private_origins: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct RemoteAttachmentFetchOutput {
|
||||
final_url: String,
|
||||
mime_type: String,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncSafeFetchTask {
|
||||
type Output = SafeFetchOutput;
|
||||
type JsValue = SafeFetchResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
safe_fetch_inner(&self.request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(SafeFetchResponse {
|
||||
status: output.status,
|
||||
final_url: output.final_url,
|
||||
headers: output.headers,
|
||||
body: output.body.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn safe_fetch(request: SafeFetchRequest) -> AsyncTask<AsyncSafeFetchTask> {
|
||||
AsyncTask::new(AsyncSafeFetchTask { request })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn assert_safe_url(request: AssertSafeUrlRequest) -> Result<()> {
|
||||
assert_safe_url_inner(&request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn inspect_image_for_proxy(input: Buffer, options: Option<ImageInspectionOptions>) -> Result<ImageInspection> {
|
||||
inspect_image_for_proxy_inner(
|
||||
&input,
|
||||
options.unwrap_or(ImageInspectionOptions {
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
max_pixels: None,
|
||||
}),
|
||||
)
|
||||
.map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncRemoteAttachmentFetchTask {
|
||||
type Output = RemoteAttachmentFetchOutput;
|
||||
type JsValue = RemoteAttachmentFetchResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
fetch_remote_attachment_inner(&self.request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(RemoteAttachmentFetchResponse {
|
||||
final_url: output.final_url,
|
||||
mime_type: output.mime_type,
|
||||
body: output.body.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn fetch_remote_attachment(request: RemoteAttachmentFetchRequest) -> AsyncTask<AsyncRemoteAttachmentFetchTask> {
|
||||
AsyncTask::new(AsyncRemoteAttachmentFetchTask { request })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncRemoteMimeTypeTask {
|
||||
type Output = String;
|
||||
type JsValue = String;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
Ok(infer_remote_mime_type_inner(&self.request))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn infer_remote_mime_type(request: RemoteMimeTypeRequest) -> AsyncTask<AsyncRemoteMimeTypeTask> {
|
||||
AsyncTask::new(AsyncRemoteMimeTypeTask { request })
|
||||
}
|
||||
|
||||
fn safe_fetch_inner(request: &SafeFetchRequest) -> AnyResult<SafeFetchOutput> {
|
||||
safe_fetch_params_inner(&SafeFetchParams {
|
||||
url: request.url.clone(),
|
||||
method: request.method,
|
||||
headers: request.headers.clone(),
|
||||
timeout_ms: request.timeout_ms,
|
||||
max_redirects: request.max_redirects,
|
||||
max_bytes: request.max_bytes,
|
||||
allow_private_origins: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn safe_fetch_params_inner(request: &SafeFetchParams) -> AnyResult<SafeFetchOutput> {
|
||||
let timeout = Duration::from_millis(u64::from(request.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)));
|
||||
let max_redirects = request.max_redirects.unwrap_or(DEFAULT_MAX_REDIRECTS);
|
||||
let max_bytes = usize::try_from(request.max_bytes.unwrap_or(DEFAULT_MAX_BYTES)).context("invalid maxBytes")?;
|
||||
let method = request.method.unwrap_or(SafeFetchMethod::Get);
|
||||
let mut current = parse_safe_url(&request.url)?;
|
||||
let headers = build_headers(request.headers.as_ref())?;
|
||||
|
||||
for redirect_count in 0..=max_redirects {
|
||||
let addrs = resolve_safe_socket_addrs(¤t, request.allow_private_origins.as_deref())?;
|
||||
let client = build_pinned_client(¤t, &addrs, timeout)?;
|
||||
let response = send_request(&client, method, current.clone(), headers.clone())?;
|
||||
|
||||
if response.status().is_redirection() {
|
||||
if redirect_count >= max_redirects {
|
||||
bail!("too_many_redirects");
|
||||
}
|
||||
let Some(location) = response.headers().get(LOCATION) else {
|
||||
return response_to_output(response, current, max_bytes, method);
|
||||
};
|
||||
let location = location.to_str().context("invalid redirect location")?;
|
||||
current = parse_safe_url(current.join(location).context("invalid redirect location")?.as_str())?;
|
||||
continue;
|
||||
}
|
||||
|
||||
return response_to_output(response, current, max_bytes, method);
|
||||
}
|
||||
|
||||
bail!("too_many_redirects")
|
||||
}
|
||||
|
||||
fn fetch_remote_attachment_inner(request: &RemoteAttachmentFetchRequest) -> AnyResult<RemoteAttachmentFetchOutput> {
|
||||
let allow_private_origins =
|
||||
private_target_origin_allowlist(&request.url, request.allow_private_target_origin.unwrap_or(false))?;
|
||||
let response = safe_fetch_params_inner(&SafeFetchParams {
|
||||
url: request.url.clone(),
|
||||
method: Some(SafeFetchMethod::Get),
|
||||
headers: None,
|
||||
timeout_ms: request.timeout_ms,
|
||||
max_redirects: Some(DEFAULT_MAX_REDIRECTS),
|
||||
max_bytes: Some(request.max_bytes),
|
||||
allow_private_origins,
|
||||
})?;
|
||||
if !(200..300).contains(&response.status) {
|
||||
bail!("fetch_failed_status: {}", response.status);
|
||||
}
|
||||
let mime_type = normalize_mime_type(response.headers.get("content-type"));
|
||||
if let Some(expected) = request.expected_content_type_prefix.as_deref() {
|
||||
if !mime_type.starts_with(expected) {
|
||||
bail!("content_type_mismatch");
|
||||
}
|
||||
if expected.starts_with("image/") {
|
||||
inspect_image_for_proxy_inner(
|
||||
&response.body,
|
||||
ImageInspectionOptions {
|
||||
max_width: request.max_image_width,
|
||||
max_height: request.max_image_height,
|
||||
max_pixels: request.max_image_pixels,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RemoteAttachmentFetchOutput {
|
||||
final_url: response.final_url,
|
||||
mime_type,
|
||||
body: response.body,
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_remote_mime_type_inner(request: &RemoteMimeTypeRequest) -> String {
|
||||
let Ok(url) = Url::parse(&request.url) else {
|
||||
return "application/octet-stream".to_string();
|
||||
};
|
||||
if let Some(mime_type) = infer_mime_type_from_extension(&url) {
|
||||
return mime_type.to_string();
|
||||
}
|
||||
let Ok(response) = safe_fetch_params_inner(&SafeFetchParams {
|
||||
url: request.url.clone(),
|
||||
method: Some(SafeFetchMethod::Head),
|
||||
headers: None,
|
||||
timeout_ms: request.timeout_ms,
|
||||
max_redirects: Some(DEFAULT_MAX_REDIRECTS),
|
||||
max_bytes: Some(0),
|
||||
allow_private_origins: None,
|
||||
}) else {
|
||||
return "application/octet-stream".to_string();
|
||||
};
|
||||
normalize_mime_type(response.headers.get("content-type"))
|
||||
}
|
||||
|
||||
fn private_target_origin_allowlist(raw_url: &str, allow_private_target_origin: bool) -> AnyResult<Option<Vec<String>>> {
|
||||
if !allow_private_target_origin {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(vec![parse_safe_url(raw_url)?.origin().ascii_serialization()]))
|
||||
}
|
||||
|
||||
fn normalize_mime_type(value: Option<&String>) -> String {
|
||||
value
|
||||
.and_then(|value| value.split(';').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn infer_mime_type_from_extension(url: &Url) -> Option<&'static str> {
|
||||
let extension = url.path_segments()?.next_back()?.rsplit_once('.')?.1;
|
||||
match extension.to_ascii_lowercase().as_str() {
|
||||
"pdf" => Some("application/pdf"),
|
||||
"mp3" => Some("audio/mpeg"),
|
||||
"opus" => Some("audio/opus"),
|
||||
"ogg" => Some("audio/ogg"),
|
||||
"aac" => Some("audio/aac"),
|
||||
"m4a" => Some("audio/aac"),
|
||||
"flac" => Some("audio/flac"),
|
||||
"ogv" => Some("video/ogg"),
|
||||
"wav" => Some("audio/wav"),
|
||||
"png" => Some("image/png"),
|
||||
"jpeg" | "jpg" => Some("image/jpeg"),
|
||||
"webp" => Some("image/webp"),
|
||||
"txt" | "md" => Some("text/plain"),
|
||||
"mov" => Some("video/mov"),
|
||||
"mpeg" => Some("video/mpeg"),
|
||||
"mp4" => Some("video/mp4"),
|
||||
"avi" => Some("video/avi"),
|
||||
"wmv" => Some("video/wmv"),
|
||||
"flv" => Some("video/flv"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_safe_url_inner(request: &AssertSafeUrlRequest) -> AnyResult<()> {
|
||||
let url = parse_safe_url(&request.url)?;
|
||||
resolve_safe_socket_addrs(&url, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_safe_url(raw: &str) -> AnyResult<Url> {
|
||||
let url = Url::parse(raw).context("invalid_url")?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => {}
|
||||
_ => bail!("disallowed_protocol"),
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
bail!("url_has_credentials");
|
||||
}
|
||||
if url.host_str().is_none() {
|
||||
bail!("blocked_hostname");
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn resolve_safe_socket_addrs(url: &Url, allow_private_origins: Option<&[String]>) -> AnyResult<Vec<SocketAddr>> {
|
||||
let host = url.host_str().context("blocked_hostname")?;
|
||||
let port = url.port_or_known_default().context("blocked_hostname")?;
|
||||
let origin = url.origin().ascii_serialization();
|
||||
let allow_private = allow_private_origins
|
||||
.map(|origins| origins.iter().any(|allowed| allowed == &origin))
|
||||
.unwrap_or(false);
|
||||
let addrs: Vec<SocketAddr> = (host, port)
|
||||
.to_socket_addrs()
|
||||
.context("unresolvable_hostname")?
|
||||
.collect();
|
||||
if addrs.is_empty() {
|
||||
bail!("unresolvable_hostname");
|
||||
}
|
||||
for addr in &addrs {
|
||||
if is_blocked_ip(addr.ip()) && !allow_private {
|
||||
bail!("blocked_ip");
|
||||
}
|
||||
}
|
||||
Ok(addrs)
|
||||
}
|
||||
|
||||
fn build_pinned_client(url: &Url, addrs: &[SocketAddr], timeout: Duration) -> AnyResult<Client> {
|
||||
let host = url.host_str().context("blocked_hostname")?;
|
||||
Client::builder()
|
||||
.timeout(timeout)
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.tls_backend_preconfigured(webpki_tls_config()?)
|
||||
.resolve_to_addrs(host, addrs)
|
||||
.build()
|
||||
.context("failed to build http client")
|
||||
}
|
||||
|
||||
fn webpki_tls_config() -> AnyResult<rustls::ClientConfig> {
|
||||
let root_store = rustls::RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
|
||||
};
|
||||
Ok(
|
||||
rustls::ClientConfig::builder_with_provider(rustls::crypto::aws_lc_rs::default_provider().into())
|
||||
.with_safe_default_protocol_versions()
|
||||
.context("failed to build tls protocol config")?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_headers(headers: Option<&HashMap<String, String>>) -> AnyResult<HeaderMap> {
|
||||
let mut out = HeaderMap::new();
|
||||
let Some(headers) = headers else {
|
||||
return Ok(out);
|
||||
};
|
||||
for (name, value) in headers {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if !(lower.starts_with("sec-") || lower.starts_with("accept") || lower == "user-agent") {
|
||||
continue;
|
||||
}
|
||||
out.insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).context("invalid header name")?,
|
||||
HeaderValue::from_str(value).context("invalid header value")?,
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn send_request(client: &Client, method: SafeFetchMethod, url: Url, headers: HeaderMap) -> AnyResult<Response> {
|
||||
let method = match method {
|
||||
SafeFetchMethod::Get => Method::GET,
|
||||
SafeFetchMethod::Head => Method::HEAD,
|
||||
};
|
||||
client
|
||||
.request(method, url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.context("failed to fetch url")
|
||||
}
|
||||
|
||||
fn response_to_output(
|
||||
mut response: Response,
|
||||
url: Url,
|
||||
max_bytes: usize,
|
||||
method: SafeFetchMethod,
|
||||
) -> AnyResult<SafeFetchOutput> {
|
||||
let status = response.status().as_u16();
|
||||
let headers = response_headers(response.headers());
|
||||
if matches!(method, SafeFetchMethod::Head) {
|
||||
return Ok(SafeFetchOutput {
|
||||
status,
|
||||
final_url: url.to_string(),
|
||||
headers,
|
||||
body: Vec::new(),
|
||||
});
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
if let Some(len) = response.content_length()
|
||||
&& len > max_bytes as u64
|
||||
{
|
||||
bail!("response_too_large");
|
||||
}
|
||||
response
|
||||
.by_ref()
|
||||
.take(u64::try_from(max_bytes).unwrap_or(u64::MAX) + 1)
|
||||
.read_to_end(&mut body)
|
||||
.context("failed to read response")?;
|
||||
if body.len() > max_bytes {
|
||||
bail!("response_too_large");
|
||||
}
|
||||
Ok(SafeFetchOutput {
|
||||
status,
|
||||
final_url: url.to_string(),
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn response_headers(headers: &HeaderMap) -> HashMap<String, String> {
|
||||
headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|value| (name.as_str().to_string(), value.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_blocked_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_documentation()
|
||||
|| ip.octets()[0] == 0
|
||||
|| ip.octets()[0] >= 224
|
||||
|| ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1])
|
||||
|| ip.octets()[0] == 169 && ip.octets()[1] == 254
|
||||
|| ip.octets()[0] == 198 && (18..=19).contains(&ip.octets()[1])
|
||||
|| ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return is_blocked_ip(IpAddr::V4(v4));
|
||||
}
|
||||
if let Some(v4) = extract_6to4_ipv4(ip).or_else(|| extract_teredo_client_ipv4(ip)) {
|
||||
return is_blocked_ip(IpAddr::V4(v4));
|
||||
}
|
||||
(ip.segments()[0] & 0xe000 != 0x2000)
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| (ip.segments()[0] & 0xfe00 == 0xfc00)
|
||||
|| (ip.segments()[0] & 0xffc0 == 0xfe80)
|
||||
|| (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_6to4_ipv4(ip: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
let segments = ip.segments();
|
||||
if segments[0] != 0x2002 {
|
||||
return None;
|
||||
}
|
||||
Some(std::net::Ipv4Addr::new(
|
||||
(segments[1] >> 8) as u8,
|
||||
segments[1] as u8,
|
||||
(segments[2] >> 8) as u8,
|
||||
segments[2] as u8,
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_teredo_client_ipv4(ip: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
let segments = ip.segments();
|
||||
if segments[0] != 0x2001 || segments[1] != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(std::net::Ipv4Addr::new(
|
||||
(!(segments[6] >> 8)) as u8,
|
||||
(!segments[6]) as u8,
|
||||
(!(segments[7] >> 8)) as u8,
|
||||
(!segments[7]) as u8,
|
||||
))
|
||||
}
|
||||
|
||||
fn inspect_image_for_proxy_inner(input: &[u8], options: ImageInspectionOptions) -> AnyResult<ImageInspection> {
|
||||
let inspection = parse_image_header(input).context("failed to decode image")?;
|
||||
validate_image_dimensions(&inspection, options)?;
|
||||
Ok(inspection)
|
||||
}
|
||||
|
||||
fn validate_image_dimensions(image: &ImageInspection, options: ImageInspectionOptions) -> AnyResult<()> {
|
||||
let max_width = options.max_width.unwrap_or(MAX_IMAGE_DIMENSION);
|
||||
let max_height = options.max_height.unwrap_or(MAX_IMAGE_DIMENSION);
|
||||
let max_pixels = u64::from(options.max_pixels.unwrap_or(MAX_IMAGE_PIXELS as u32));
|
||||
if image.width == 0 || image.height == 0 {
|
||||
bail!("failed to decode image");
|
||||
}
|
||||
if image.width > max_width || image.height > max_height {
|
||||
bail!("image dimensions exceed limit");
|
||||
}
|
||||
if u64::from(image.width) * u64::from(image.height) > max_pixels {
|
||||
bail!("image pixel count exceeds limit");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_image_header(input: &[u8]) -> AnyResult<ImageInspection> {
|
||||
let format = ::image::guess_format(input).context("unsupported image format")?;
|
||||
let mime_type = image_mime_type(format).context("unsupported image format")?;
|
||||
let (width, height) = ImageReader::with_format(Cursor::new(input), format)
|
||||
.into_dimensions()
|
||||
.context("failed to decode image")?;
|
||||
Ok(ImageInspection {
|
||||
mime_type: mime_type.to_string(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn image_mime_type(format: ImageFormat) -> Option<&'static str> {
|
||||
match format {
|
||||
ImageFormat::Png => Some("image/png"),
|
||||
ImageFormat::Jpeg => Some("image/jpeg"),
|
||||
ImageFormat::Gif => Some("image/gif"),
|
||||
ImageFormat::WebP => Some("image/webp"),
|
||||
ImageFormat::Bmp => Some("image/bmp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_private_ips() {
|
||||
assert!(is_blocked_ip("127.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("10.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("169.254.169.254".parse().unwrap()));
|
||||
assert!(is_blocked_ip("::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("::ffff:127.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("2002:7f00:0001::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("2002:c0a8:0001::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(
|
||||
"2001:0000:4136:e378:8000:63bf:807f:fffe".parse().unwrap()
|
||||
));
|
||||
assert!(!is_blocked_ip("8.8.8.8".parse().unwrap()));
|
||||
assert!(!is_blocked_ip("2002:0808:0808::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_https_client_with_embedded_roots() {
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
let addrs = ["93.184.216.34:443".parse().unwrap()];
|
||||
build_pinned_client(&url, &addrs, Duration::from_secs(1)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_png_dimensions_without_decode() {
|
||||
let png = base64_simd::STANDARD
|
||||
.decode_to_vec(b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfJ8AAAAASUVORK5CYII=")
|
||||
.unwrap();
|
||||
let inspected = inspect_image_for_proxy_inner(
|
||||
&png,
|
||||
ImageInspectionOptions {
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
max_pixels: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inspected.mime_type, "image/png");
|
||||
assert_eq!(inspected.width, 1);
|
||||
assert_eq!(inspected.height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_dimensions() {
|
||||
let png = [
|
||||
b"\x89PNG\r\n\x1a\n".as_slice(),
|
||||
&[0, 0, 0, 13],
|
||||
b"IHDR".as_slice(),
|
||||
&100_000u32.to_be_bytes(),
|
||||
&100_000u32.to_be_bytes(),
|
||||
&[8, 6, 0, 0, 0],
|
||||
]
|
||||
.concat();
|
||||
assert!(
|
||||
inspect_image_for_proxy_inner(
|
||||
&png,
|
||||
ImageInspectionOptions {
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
max_pixels: None,
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
+1077
File diff suppressed because it is too large
Load Diff
+184
@@ -0,0 +1,184 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "entitlements" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"target_type" TEXT NOT NULL,
|
||||
"target_id" VARCHAR,
|
||||
"source" TEXT NOT NULL,
|
||||
"plan" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"subject_id" VARCHAR,
|
||||
"issuer" TEXT,
|
||||
"quantity" INTEGER,
|
||||
"signed_payload" BYTEA,
|
||||
"token_hash" TEXT,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"issued_at" TIMESTAMPTZ(3),
|
||||
"starts_at" TIMESTAMPTZ(3),
|
||||
"expires_at" TIMESTAMPTZ(3),
|
||||
"validated_at" TIMESTAMPTZ(3),
|
||||
"grace_until" TIMESTAMPTZ(3),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "entitlements_target_type_check" CHECK ("target_type" IN ('user', 'workspace', 'instance')),
|
||||
CONSTRAINT "entitlements_source_check" CHECK ("source" IN ('builtin', 'cloud_subscription', 'selfhost_license', 'admin_grant')),
|
||||
CONSTRAINT "entitlements_status_check" CHECK ("status" IN ('active', 'grace', 'expired', 'revoked', 'needs_reupload')),
|
||||
CONSTRAINT "entitlements_quantity_check" CHECK ("quantity" IS NULL OR ("quantity" > 0 AND "quantity" <= 100000))
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "effective_user_quota_states" (
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"plan" TEXT NOT NULL,
|
||||
"source_entitlement_id" VARCHAR,
|
||||
"blob_limit" BIGINT NOT NULL,
|
||||
"storage_quota" BIGINT NOT NULL,
|
||||
"used_storage_quota" BIGINT NOT NULL DEFAULT 0,
|
||||
"history_period_seconds" INTEGER NOT NULL,
|
||||
"copilot_action_limit" INTEGER,
|
||||
"flags" JSONB NOT NULL DEFAULT '{}',
|
||||
"known" BOOLEAN NOT NULL DEFAULT false,
|
||||
"stale" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_reconciled_at" TIMESTAMPTZ(3),
|
||||
"stale_after" TIMESTAMPTZ(3),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "effective_user_quota_states_pkey" PRIMARY KEY ("user_id"),
|
||||
CONSTRAINT "effective_user_quota_states_blob_limit_check" CHECK ("blob_limit" >= 0),
|
||||
CONSTRAINT "effective_user_quota_states_storage_quota_check" CHECK ("storage_quota" >= 0),
|
||||
CONSTRAINT "effective_user_quota_states_used_storage_quota_check" CHECK ("used_storage_quota" >= 0),
|
||||
CONSTRAINT "effective_user_quota_states_history_period_check" CHECK ("history_period_seconds" >= 0),
|
||||
CONSTRAINT "effective_user_quota_states_copilot_limit_check" CHECK ("copilot_action_limit" IS NULL OR "copilot_action_limit" >= 0)
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "effective_workspace_quota_states" (
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"plan" TEXT NOT NULL,
|
||||
"source_entitlement_id" VARCHAR,
|
||||
"owner_user_id" VARCHAR,
|
||||
"uses_owner_quota" BOOLEAN NOT NULL DEFAULT false,
|
||||
"seat_limit" INTEGER NOT NULL,
|
||||
"member_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"overcapacity_member_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"blob_limit" BIGINT NOT NULL,
|
||||
"storage_quota" BIGINT NOT NULL,
|
||||
"used_storage_quota" BIGINT NOT NULL DEFAULT 0,
|
||||
"history_period_seconds" INTEGER NOT NULL,
|
||||
"readonly" BOOLEAN NOT NULL DEFAULT false,
|
||||
"readonly_reasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"flags" JSONB NOT NULL DEFAULT '{}',
|
||||
"known" BOOLEAN NOT NULL DEFAULT false,
|
||||
"stale" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_reconciled_at" TIMESTAMPTZ(3),
|
||||
"stale_after" TIMESTAMPTZ(3),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "effective_workspace_quota_states_pkey" PRIMARY KEY ("workspace_id"),
|
||||
CONSTRAINT "effective_workspace_quota_states_seat_limit_check" CHECK ("seat_limit" >= 0),
|
||||
CONSTRAINT "effective_workspace_quota_states_member_count_check" CHECK ("member_count" >= 0),
|
||||
CONSTRAINT "effective_workspace_quota_states_overcapacity_check" CHECK ("overcapacity_member_count" >= 0),
|
||||
CONSTRAINT "effective_workspace_quota_states_blob_limit_check" CHECK ("blob_limit" >= 0),
|
||||
CONSTRAINT "effective_workspace_quota_states_storage_quota_check" CHECK ("storage_quota" >= 0),
|
||||
CONSTRAINT "effective_workspace_quota_states_used_storage_quota_check" CHECK ("used_storage_quota" >= 0),
|
||||
CONSTRAINT "effective_workspace_quota_states_history_period_check" CHECK ("history_period_seconds" >= 0),
|
||||
CONSTRAINT "effective_workspace_quota_states_readonly_reasons_check" CHECK ("readonly_reasons" <@ ARRAY['member_overflow', 'storage_overflow']::TEXT[])
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "entitlements_target_type_target_id_status_idx" ON "entitlements"("target_type", "target_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "entitlements_status_expires_at_idx" ON "entitlements"("status", "expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "entitlements_active_subject_key" ON "entitlements"("source", "subject_id")
|
||||
WHERE "subject_id" IS NOT NULL AND "status" IN ('active', 'grace');
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "effective_user_quota_states_known_stale_idx" ON "effective_user_quota_states"("known", "stale");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "effective_user_quota_states_stale_after_idx" ON "effective_user_quota_states"("stale_after");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "effective_workspace_quota_states_owner_user_id_idx" ON "effective_workspace_quota_states"("owner_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "effective_workspace_quota_states_known_stale_idx" ON "effective_workspace_quota_states"("known", "stale");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "effective_workspace_quota_states_readonly_stale_idx" ON "effective_workspace_quota_states"("readonly", "stale");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "effective_workspace_quota_states_stale_after_idx" ON "effective_workspace_quota_states"("stale_after");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "effective_user_quota_states" ADD CONSTRAINT "effective_user_quota_states_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "effective_user_quota_states" ADD CONSTRAINT "effective_user_quota_states_source_entitlement_id_fkey" FOREIGN KEY ("source_entitlement_id") REFERENCES "entitlements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_source_entitlement_id_fkey" FOREIGN KEY ("source_entitlement_id") REFERENCES "entitlements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "project_legacy_workspace_readonly_feature"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
DELETE FROM "workspace_features"
|
||||
WHERE "workspace_id" = OLD."workspace_id"
|
||||
AND "name" = 'quota_exceeded_readonly_workspace_v1';
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
IF NEW."readonly" THEN
|
||||
UPDATE "workspace_features"
|
||||
SET "reason" = 'legacy quota state projection trigger',
|
||||
"activated" = true
|
||||
WHERE "workspace_id" = NEW."workspace_id"
|
||||
AND "name" = 'quota_exceeded_readonly_workspace_v1';
|
||||
|
||||
IF NOT FOUND THEN
|
||||
INSERT INTO "workspace_features"(
|
||||
"workspace_id",
|
||||
"name",
|
||||
"type",
|
||||
"configs",
|
||||
"reason",
|
||||
"activated"
|
||||
)
|
||||
VALUES (
|
||||
NEW."workspace_id",
|
||||
'quota_exceeded_readonly_workspace_v1',
|
||||
0,
|
||||
'{}',
|
||||
'legacy quota state projection trigger',
|
||||
true
|
||||
);
|
||||
END IF;
|
||||
ELSE
|
||||
DELETE FROM "workspace_features"
|
||||
WHERE "workspace_id" = NEW."workspace_id"
|
||||
AND "name" = 'quota_exceeded_readonly_workspace_v1';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER "project_legacy_workspace_readonly_feature_trigger"
|
||||
AFTER INSERT OR UPDATE OF "readonly" OR DELETE ON "effective_workspace_quota_states"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "project_legacy_workspace_readonly_feature"();
|
||||
@@ -45,20 +45,20 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@node-rs/crc32": "^1.10.6",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^2.2.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||
"@opentelemetry/exporter-zipkin": "^2.6.0",
|
||||
"@opentelemetry/core": "^2.7.1",
|
||||
"@opentelemetry/exporter-prometheus": "^0.218.0",
|
||||
"@opentelemetry/exporter-zipkin": "^2.7.1",
|
||||
"@opentelemetry/host-metrics": "^0.38.3",
|
||||
"@opentelemetry/instrumentation": "^0.215.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.63.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.215.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.62.0",
|
||||
"@opentelemetry/resources": "^2.2.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.2.0",
|
||||
"@opentelemetry/sdk-node": "^0.215.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@opentelemetry/instrumentation": "^0.218.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.66.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.218.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.66.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.64.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.65.0",
|
||||
"@opentelemetry/resources": "^2.7.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.1",
|
||||
"@opentelemetry/sdk-node": "^0.218.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.38.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@prisma/instrumentation": "^6.7.0",
|
||||
@@ -74,7 +74,7 @@
|
||||
"eventemitter2": "^6.4.9",
|
||||
"exa-js": "^2.4.0",
|
||||
"express": "^5.0.1",
|
||||
"fast-xml-parser": "^5.7.2",
|
||||
"fast-xml-parser": "^5.8.0",
|
||||
"get-stream": "^9.0.1",
|
||||
"google-auth-library": "^10.2.0",
|
||||
"graphql": "^16.13.2",
|
||||
@@ -114,6 +114,7 @@
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/realtime": "workspace:*",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@nestjs/swagger": "^11.2.7",
|
||||
"@nestjs/testing": "patch:@nestjs/testing@npm%3A11.1.18#~/.yarn/patches/@nestjs-testing-npm-11.1.18-32c0f6af12.patch",
|
||||
|
||||
@@ -28,8 +28,11 @@ model User {
|
||||
features UserFeature[]
|
||||
userStripeCustomer UserStripeCustomer?
|
||||
workspaces WorkspaceUserRole[]
|
||||
workspaceMembers WorkspaceMember[]
|
||||
workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee")
|
||||
createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter")
|
||||
// Invite others to join the workspace
|
||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
calendarAccounts CalendarAccount[]
|
||||
@@ -37,20 +40,22 @@ model User {
|
||||
aiSessions AiSession[]
|
||||
appConfigs AppConfig[]
|
||||
userSnapshots UserSnapshot[]
|
||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||
createdUpdate Update[] @relation("createdUpdate")
|
||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||
createdUpdate Update[] @relation("createdUpdate")
|
||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||
// receive notifications
|
||||
notifications Notification[] @relation("user_notifications")
|
||||
notifications Notification[] @relation("user_notifications")
|
||||
settings UserSettings?
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
workspaceCalendars WorkspaceCalendar[]
|
||||
workspaceMemberLastAccesses WorkspaceMemberLastAccess[]
|
||||
quotaState EffectiveUserQuotaState?
|
||||
ownedQuotaStates EffectiveWorkspaceQuotaState[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -157,12 +162,226 @@ model Workspace {
|
||||
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
|
||||
workspaceDocViewDaily WorkspaceDocViewDaily[]
|
||||
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
|
||||
runtimeState WorkspaceRuntimeState?
|
||||
quotaState EffectiveWorkspaceQuotaState?
|
||||
accessPolicy WorkspaceAccessPolicy?
|
||||
projectedMembers WorkspaceMember[]
|
||||
projectedInvitations WorkspaceInvitation[]
|
||||
docAccessPolicies DocAccessPolicy[]
|
||||
docGrants DocGrant[]
|
||||
|
||||
@@index([lastCheckEmbeddings])
|
||||
@@index([createdAt])
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
model WorkspaceRuntimeState {
|
||||
workspaceId String @id @map("workspace_id") @db.VarChar
|
||||
known Boolean @default(false)
|
||||
readonly Boolean @default(false)
|
||||
readonlyReasons String[] @default([]) @map("readonly_reasons")
|
||||
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
|
||||
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("workspace_runtime_states")
|
||||
}
|
||||
|
||||
model Entitlement {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
targetType String @map("target_type") @db.Text
|
||||
targetId String? @map("target_id") @db.VarChar
|
||||
source String @db.Text
|
||||
plan String @db.Text
|
||||
status String @db.Text
|
||||
subjectId String? @map("subject_id") @db.VarChar
|
||||
issuer String? @db.Text
|
||||
quantity Int? @db.Integer
|
||||
signedPayload Bytes? @map("signed_payload") @db.ByteA
|
||||
tokenHash String? @map("token_hash") @db.Text
|
||||
metadata Json @default("{}") @db.JsonB
|
||||
issuedAt DateTime? @map("issued_at") @db.Timestamptz(3)
|
||||
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
validatedAt DateTime? @map("validated_at") @db.Timestamptz(3)
|
||||
graceUntil DateTime? @map("grace_until") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
userQuotaStates EffectiveUserQuotaState[]
|
||||
workspaceQuotaStates EffectiveWorkspaceQuotaState[]
|
||||
|
||||
@@index([targetType, targetId, status])
|
||||
@@index([status, expiresAt])
|
||||
@@map("entitlements")
|
||||
}
|
||||
|
||||
model EffectiveUserQuotaState {
|
||||
userId String @id @map("user_id") @db.VarChar
|
||||
plan String @db.Text
|
||||
sourceEntitlementId String? @map("source_entitlement_id") @db.VarChar
|
||||
blobLimit BigInt @map("blob_limit") @db.BigInt
|
||||
storageQuota BigInt @map("storage_quota") @db.BigInt
|
||||
usedStorageQuota BigInt @default(0) @map("used_storage_quota") @db.BigInt
|
||||
historyPeriodSeconds Int @map("history_period_seconds") @db.Integer
|
||||
copilotActionLimit Int? @map("copilot_action_limit") @db.Integer
|
||||
flags Json @default("{}") @db.JsonB
|
||||
known Boolean @default(false)
|
||||
stale Boolean @default(false)
|
||||
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
|
||||
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
sourceEntitlement Entitlement? @relation(fields: [sourceEntitlementId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([known, stale])
|
||||
@@index([staleAfter])
|
||||
@@map("effective_user_quota_states")
|
||||
}
|
||||
|
||||
model EffectiveWorkspaceQuotaState {
|
||||
workspaceId String @id @map("workspace_id") @db.VarChar
|
||||
plan String @db.Text
|
||||
sourceEntitlementId String? @map("source_entitlement_id") @db.VarChar
|
||||
ownerUserId String? @map("owner_user_id") @db.VarChar
|
||||
usesOwnerQuota Boolean @default(false) @map("uses_owner_quota")
|
||||
seatLimit Int @map("seat_limit") @db.Integer
|
||||
memberCount Int @default(0) @map("member_count") @db.Integer
|
||||
overcapacityMemberCount Int @default(0) @map("overcapacity_member_count") @db.Integer
|
||||
blobLimit BigInt @map("blob_limit") @db.BigInt
|
||||
storageQuota BigInt @map("storage_quota") @db.BigInt
|
||||
usedStorageQuota BigInt @default(0) @map("used_storage_quota") @db.BigInt
|
||||
historyPeriodSeconds Int @map("history_period_seconds") @db.Integer
|
||||
readonly Boolean @default(false)
|
||||
readonlyReasons String[] @default([]) @map("readonly_reasons") @db.Text
|
||||
flags Json @default("{}") @db.JsonB
|
||||
known Boolean @default(false)
|
||||
stale Boolean @default(false)
|
||||
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
|
||||
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||
sourceEntitlement Entitlement? @relation(fields: [sourceEntitlementId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([ownerUserId])
|
||||
@@index([known, stale])
|
||||
@@index([readonly, stale])
|
||||
@@index([staleAfter])
|
||||
@@map("effective_workspace_quota_states")
|
||||
}
|
||||
|
||||
model WorkspaceMember {
|
||||
id String @id @default(dbgenerated()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
role String @db.Text
|
||||
state String @default("active") @db.Text
|
||||
source String @default("legacy") @db.Text
|
||||
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceId, userId, state])
|
||||
@@index([userId, state])
|
||||
@@index([workspaceId, role, state])
|
||||
@@map("workspace_members")
|
||||
}
|
||||
|
||||
model WorkspaceInvitation {
|
||||
id String @id @default(dbgenerated()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
inviteeUserId String? @map("invitee_user_id") @db.VarChar
|
||||
normalizedEmail String? @map("normalized_email") @db.VarChar
|
||||
inviterUserId String? @map("inviter_user_id") @db.VarChar
|
||||
requestedRole String @default("member") @map("requested_role") @db.Text
|
||||
status String @db.Text
|
||||
kind String @default("email") @db.Text
|
||||
// Partial unique index exists in migration: token_hash WHERE token_hash IS NOT NULL.
|
||||
tokenHash String? @map("token_hash") @db.Text
|
||||
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull)
|
||||
inviter User? @relation("workspace_invitation_inviter", fields: [inviterUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([workspaceId, inviteeUserId])
|
||||
@@index([workspaceId, status])
|
||||
@@index([inviteeUserId, status])
|
||||
@@index([workspaceId, normalizedEmail, status])
|
||||
@@map("workspace_invitations")
|
||||
}
|
||||
|
||||
model WorkspaceAccessPolicy {
|
||||
workspaceId String @id @map("workspace_id") @db.VarChar
|
||||
visibility String @default("private") @db.Text
|
||||
sharingEnabled Boolean @default(true) @map("sharing_enabled")
|
||||
urlPreviewEnabled Boolean @default(false) @map("url_preview_enabled")
|
||||
memberDefaultDocRole String @default("manager") @map("member_default_doc_role") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([visibility])
|
||||
@@index([urlPreviewEnabled, sharingEnabled])
|
||||
@@map("workspace_access_policies")
|
||||
}
|
||||
|
||||
model DocAccessPolicy {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
visibility String @default("private") @db.Text
|
||||
publicRole String? @map("public_role") @db.Text
|
||||
memberDefaultRole String? @map("member_default_role") @db.Text
|
||||
urlPreviewEnabled Boolean @default(false) @map("url_preview_enabled")
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, docId])
|
||||
@@index([workspaceId, docId])
|
||||
@@map("doc_access_policies")
|
||||
}
|
||||
|
||||
model DocGrant {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
principalType String @map("principal_type") @db.Text
|
||||
principalId String @map("principal_id") @db.VarChar
|
||||
role String @db.Text
|
||||
grantedBy String? @map("granted_by") @db.VarChar
|
||||
legacyWorkspaceId String? @map("legacy_workspace_id") @db.VarChar
|
||||
legacyDocId String? @map("legacy_doc_id") @db.VarChar
|
||||
legacyUserId String? @map("legacy_user_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, docId, principalType, principalId])
|
||||
// Partial unique index exists in migration for non-null legacy ids.
|
||||
@@index([principalType, principalId, role])
|
||||
@@index([workspaceId, docId, role])
|
||||
@@map("doc_grants")
|
||||
}
|
||||
|
||||
// Table for workspace page meta data
|
||||
// NOTE:
|
||||
// We won't make sure every page has a corresponding record in this table.
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { readResponseBufferWithLimit } from '../../base';
|
||||
|
||||
test('readResponseBufferWithLimit rejects timed out web streams without crashing', async t => {
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
queueMicrotask(() => {
|
||||
controller.error(
|
||||
new DOMException(
|
||||
'The operation was aborted due to timeout',
|
||||
'TimeoutError'
|
||||
)
|
||||
);
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const error = await t.throwsAsync(
|
||||
readResponseBufferWithLimit(response, 1024)
|
||||
);
|
||||
|
||||
t.is(error?.name, 'TimeoutError');
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import ava, { type ExecutionContext, type TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Cache, CryptoHelper } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { Models, WorkspaceRole } from '../../models';
|
||||
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
|
||||
import { ByokService } from '../../plugins/copilot/byok';
|
||||
@@ -14,6 +15,11 @@ import {
|
||||
ByokKeyTestStatus,
|
||||
ByokProvider,
|
||||
} from '../../plugins/copilot/byok/types';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../plugins/payment/types';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
|
||||
interface Context {
|
||||
@@ -24,11 +30,18 @@ interface Context {
|
||||
byok: ByokService;
|
||||
crypto: CryptoHelper;
|
||||
cache: Cache;
|
||||
entitlement: EntitlementService;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
const test = ava.serial as TestFn<Context>;
|
||||
const originalNamespace = globalThis.env.NAMESPACE;
|
||||
const originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
|
||||
test.before(async t => {
|
||||
Object.assign(globalThis.env, {
|
||||
NAMESPACE: 'dev',
|
||||
DEPLOYMENT_TYPE: 'affine',
|
||||
});
|
||||
const module = await createTestingModule();
|
||||
t.context.module = module;
|
||||
t.context.models = module.get(Models);
|
||||
@@ -37,6 +50,7 @@ test.before(async t => {
|
||||
t.context.byok = module.get(ByokService);
|
||||
t.context.crypto = module.get(CryptoHelper);
|
||||
t.context.cache = module.get(Cache);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
@@ -45,6 +59,10 @@ test.beforeEach(async t => {
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
Object.assign(globalThis.env, {
|
||||
NAMESPACE: originalNamespace,
|
||||
DEPLOYMENT_TYPE: originalDeploymentType,
|
||||
});
|
||||
});
|
||||
|
||||
async function createUserWorkspace(t: ExecutionContext<Context>) {
|
||||
@@ -59,6 +77,73 @@ function workspaceHash(workspaceId: string) {
|
||||
return createHash('sha256').update(workspaceId).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
async function grantUserPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
userId: string,
|
||||
feature: ByokUserPlanFeature = 'pro_plan_v1'
|
||||
) {
|
||||
if (feature === 'unlimited_copilot') {
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring:
|
||||
feature === 'lifetime_pro_plan_v1'
|
||||
? SubscriptionRecurring.Lifetime
|
||||
: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeUserPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
userId: string,
|
||||
feature: ByokUserPlanFeature = 'pro_plan_v1'
|
||||
) {
|
||||
if (feature === 'unlimited_copilot') {
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.AI,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
});
|
||||
}
|
||||
|
||||
async function grantTeamPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
workspaceId: string
|
||||
) {
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeTeamPlan(
|
||||
t: ExecutionContext<Context>,
|
||||
workspaceId: string
|
||||
) {
|
||||
await t.context.entitlement.revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
});
|
||||
}
|
||||
|
||||
type ByokMatrixCase = {
|
||||
name: string;
|
||||
role: WorkspaceRole;
|
||||
@@ -110,25 +195,13 @@ async function createByokMatrixWorkspace(
|
||||
);
|
||||
}
|
||||
if (input.team) {
|
||||
await t.context.models.workspaceFeature.add(
|
||||
workspace.id,
|
||||
'team_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantTeamPlan(t, workspace.id);
|
||||
}
|
||||
if (input.ownerPlan) {
|
||||
await t.context.models.userFeature.add(
|
||||
owner.id,
|
||||
input.ownerPlanFeature ?? 'pro_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantUserPlan(t, owner.id, input.ownerPlanFeature);
|
||||
}
|
||||
if (input.actorPlan && actor.id !== owner.id) {
|
||||
await t.context.models.userFeature.add(
|
||||
actor.id,
|
||||
input.actorPlanFeature ?? 'pro_plan_v1',
|
||||
'test'
|
||||
);
|
||||
await grantUserPlan(t, actor.id, input.actorPlanFeature);
|
||||
}
|
||||
|
||||
return { owner, actor, workspace };
|
||||
@@ -252,7 +325,7 @@ for (const matrixCase of byokManagementMatrix) {
|
||||
|
||||
test('byok service persists encrypted server keys and never returns plaintext', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const primary = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -325,7 +398,7 @@ test('byok service persists encrypted server keys and never returns plaintext',
|
||||
|
||||
test('byok service preserves server key fields during partial updates', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -381,7 +454,7 @@ test('byok service preserves server key fields during partial updates', async t
|
||||
|
||||
test('local leases are short lived and do not persist keys to server configs', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const before = Date.now();
|
||||
const lease = await t.context.byok.createLocalLease({
|
||||
@@ -486,7 +559,7 @@ test('local leases persist normalized custom endpoints', async t => {
|
||||
).get(() => true);
|
||||
t.teardown(() => customEndpointSupported.restore());
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const lease = await t.context.byok.createLocalLease({
|
||||
workspaceId: workspace.id,
|
||||
@@ -659,13 +732,10 @@ for (const matrixCase of byokProfileAvailabilityMatrix) {
|
||||
}
|
||||
|
||||
if (matrixCase.revokeOwnerPlan) {
|
||||
await t.context.models.userFeature.remove(owner.id, 'pro_plan_v1');
|
||||
await revokeUserPlan(t, owner.id);
|
||||
}
|
||||
if (matrixCase.revokeTeam) {
|
||||
await t.context.models.workspaceFeature.remove(
|
||||
workspace.id,
|
||||
'team_plan_v1'
|
||||
);
|
||||
await revokeTeamPlan(t, workspace.id);
|
||||
}
|
||||
if (matrixCase.demoteActor) {
|
||||
await t.context.models.workspaceUser.set(
|
||||
@@ -695,7 +765,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const profiles = await t.context.byok.getProfiles({
|
||||
workspaceId: randomUUID(),
|
||||
@@ -707,7 +777,7 @@ test('BYOK profile availability: local-only workspace does not resolve BYOK prof
|
||||
|
||||
test('test key failure disables a saved key and success restores it', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -778,7 +848,7 @@ test('test key failure disables a saved key and success restores it', async t =>
|
||||
|
||||
test('local key test does not mutate saved server config', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -817,7 +887,7 @@ test('local key test does not mutate saved server config', async t => {
|
||||
|
||||
test('Gemini key test sends key in header and returns safe failure message', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
new Response(
|
||||
@@ -852,7 +922,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
|
||||
|
||||
test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
new Response('{}', { status: 200 })
|
||||
@@ -877,7 +947,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
|
||||
test('provider test failures do not return raw provider response body', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const cases = [
|
||||
{
|
||||
body: 'authorization: Bearer token=a+b%2F==',
|
||||
@@ -925,7 +995,7 @@ test('provider test failures do not return raw provider response body', async t
|
||||
|
||||
test('dispatch failure disables server BYOK key by provider id', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -956,7 +1026,7 @@ test('dispatch failure disables server BYOK key by provider id', async t => {
|
||||
|
||||
test('dispatch accounting ignores provider ids from another workspace hash', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const otherWorkspace = await t.context.models.workspace.create(user.id);
|
||||
const key = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
@@ -996,7 +1066,7 @@ test('dispatch accounting ignores provider ids from another workspace hash', asy
|
||||
|
||||
test('effective profiles use local lease before server keys and skip disabled keys', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
const serverKey = await t.context.byok.upsertConfig({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
@@ -1067,7 +1137,7 @@ test('effective profiles use local lease before server keys and skip disabled ke
|
||||
|
||||
test('capability warnings match server Gemini background coverage', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await t.context.models.userFeature.add(user.id, 'pro_plan_v1', 'test');
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const emptySettings = await t.context.byok.getSettings(workspace.id, user.id);
|
||||
t.deepEqual(
|
||||
|
||||
@@ -732,7 +732,7 @@ test('should be able to chat with special image model', async t => {
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(app, sessionId, 'some-tag', [
|
||||
`https://example.com/${promptName}.jpg`,
|
||||
smallestPng,
|
||||
]);
|
||||
const ret3 = await chatWithImages(app, sessionId, messageId);
|
||||
t.is(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { AuthService } from '../../core/auth';
|
||||
import { QuotaModule } from '../../core/quota';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
|
||||
import {
|
||||
ContextCategories,
|
||||
@@ -101,6 +102,7 @@ type Context = {
|
||||
actionBridge: ActionRuntimeBridge;
|
||||
cronJobs: CopilotCronJobs;
|
||||
subscription: SubscriptionService;
|
||||
quotaState: QuotaStateService;
|
||||
};
|
||||
|
||||
const buildTurn = (
|
||||
@@ -199,6 +201,7 @@ test.before(async t => {
|
||||
const workspaceEmbedding = module.get(CopilotWorkspaceService);
|
||||
const cronJobs = module.get(CopilotCronJobs);
|
||||
const subscription = module.get(SubscriptionService);
|
||||
const quotaState = module.get(QuotaStateService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
@@ -225,6 +228,7 @@ test.before(async t => {
|
||||
t.context.workspaceEmbedding = workspaceEmbedding;
|
||||
t.context.cronJobs = cronJobs;
|
||||
t.context.subscription = subscription;
|
||||
t.context.quotaState = quotaState;
|
||||
|
||||
await module.initTestingDB();
|
||||
});
|
||||
@@ -2172,7 +2176,7 @@ test('model selection policy should resolve requested optional models consistent
|
||||
});
|
||||
|
||||
test('capability policy host should gate pro model requests by subscription status', async t => {
|
||||
const { subscription, module } = t.context;
|
||||
const { quotaState, subscription, module } = t.context;
|
||||
const capabilityPolicy = module.get(CapabilityPolicyHost);
|
||||
|
||||
const mockStatus = (status?: SubscriptionStatus) => {
|
||||
@@ -2181,6 +2185,10 @@ test('capability policy host should gate pro model requests by subscription stat
|
||||
// @ts-expect-error mock
|
||||
getSubscription: async () => (status ? { status } : null),
|
||||
}));
|
||||
Sinon.stub(quotaState, 'reconcileUserQuotaState').resolves({
|
||||
plan: status === SubscriptionStatus.Active ? 'pro' : 'free',
|
||||
flags: {},
|
||||
} as Awaited<ReturnType<QuotaStateService['reconcileUserQuotaState']>>);
|
||||
};
|
||||
|
||||
// payment disabled -> allow requested if in optional; pro not blocked
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type PromptMessage,
|
||||
type StreamObject,
|
||||
} from '../../plugins/copilot/providers/types';
|
||||
import { getVertexGoogleBaseUrl } from '../../plugins/copilot/providers/utils';
|
||||
import {
|
||||
buildPromptStructuredResponseFromFields,
|
||||
buildStructuredResponseContract,
|
||||
@@ -1823,6 +1824,17 @@ test('GeminiVertexProvider should prefetch bearer token for native config', asyn
|
||||
t.snapshot(config);
|
||||
});
|
||||
|
||||
test('GeminiVertexProvider should build project scoped Vertex base URL', t => {
|
||||
t.is(
|
||||
getVertexGoogleBaseUrl({
|
||||
project: 'p1',
|
||||
location: 'us-central1',
|
||||
googleAuthOptions: {},
|
||||
}),
|
||||
'https://us-central1-aiplatform.googleapis.com/v1/projects/p1/locations/us-central1/publishers/google'
|
||||
);
|
||||
});
|
||||
|
||||
test('GeminiVertexProvider should materialize remote attachments before native text path', async t => {
|
||||
const cases = [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { DocReader } from '../../core/doc';
|
||||
import type { AccessController } from '../../core/permission';
|
||||
import type { PermissionAccess } from '../../core/permission';
|
||||
import type { Models } from '../../models';
|
||||
import {
|
||||
LlmRequest,
|
||||
@@ -404,7 +404,7 @@ test('doc_read should return specific sync errors for unavailable docs', async t
|
||||
user: () => ({
|
||||
workspace: () => ({ doc: () => ({ can: async () => true }) }),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
for (const testCase of cases) {
|
||||
let docReaderCalled = false;
|
||||
@@ -447,7 +447,7 @@ test('document search tools should return sync error for local workspace', async
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -510,7 +510,7 @@ test('doc_semantic_search should return empty array when nothing matches', async
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -542,7 +542,7 @@ test('doc_semantic_search should pass BYOK route context into embedding matches'
|
||||
docs: async () => [],
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const models = {
|
||||
workspace: {
|
||||
@@ -595,7 +595,7 @@ test('blob_read should return explicit error when attachment context is missing'
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as unknown as AccessController;
|
||||
} as unknown as PermissionAccess;
|
||||
|
||||
const blobTool = createBlobReadTool(
|
||||
buildBlobContentGetter(ac, null).bind(null, {
|
||||
|
||||
@@ -137,6 +137,21 @@ function createSuccessfulTranscriptBridge(
|
||||
};
|
||||
}
|
||||
|
||||
function createCopilotTranscriptionService(...deps: unknown[]) {
|
||||
return new CopilotTranscriptionService(
|
||||
deps[0] as never,
|
||||
deps[1] as never,
|
||||
deps[2] as never,
|
||||
deps[3] as never,
|
||||
deps[4] as never,
|
||||
deps[5] as never,
|
||||
(deps[6] ?? {
|
||||
assertQuotaOrByok: Sinon.stub().resolves(undefined),
|
||||
}) as never,
|
||||
(deps[7] ?? { publish: Sinon.stub() }) as never
|
||||
);
|
||||
}
|
||||
|
||||
test('queryTask hides ready transcript task result until settlement', async t => {
|
||||
const payload = TranscriptPayloadSchema.parse({
|
||||
infos: [
|
||||
@@ -148,7 +163,7 @@ test('queryTask hides ready transcript task result until settlement', async t =>
|
||||
],
|
||||
normalizedTranscript: '00:00:05 A: Kickoff',
|
||||
});
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -181,7 +196,7 @@ test('settleTask unlocks ready transcript task result idempotently', async t =>
|
||||
status: 'settled',
|
||||
protectedResult: payload,
|
||||
});
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -216,7 +231,7 @@ test('settleTask checks copilot quota before unlocking ready task', async t => {
|
||||
protectedResult: payload,
|
||||
});
|
||||
const assertQuotaOrByok = Sinon.stub().rejects(new Error('quota exceeded'));
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -248,7 +263,7 @@ test('settleTask checks copilot quota before unlocking ready task', async t => {
|
||||
});
|
||||
|
||||
test('retryTask rejects ready transcript tasks', async t => {
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -272,7 +287,7 @@ test('retryTask rejects ready transcript tasks', async t => {
|
||||
});
|
||||
|
||||
test('retryTask rejects settled transcript tasks', async t => {
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -306,7 +321,7 @@ test('retryTask reuses failed task and queues a new action attempt', async t =>
|
||||
summaryJson: null,
|
||||
providerMeta: { provider: 'gemini', model: 'gemini-2.5-flash' },
|
||||
});
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -352,7 +367,7 @@ test('retryTask prechecks quota or BYOK before queueing provider work', async t
|
||||
const payload = TranscriptPayloadSchema.parse({
|
||||
normalizedTranscript: '00:00:05 A: Kickoff',
|
||||
});
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -391,7 +406,7 @@ for (const status of ['ready', 'settled']) {
|
||||
test(`submitTask allows a new task for the same blob after ${status} task`, async t => {
|
||||
const createdTasks: unknown[] = [];
|
||||
const queuedJobs: unknown[] = [];
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves({
|
||||
@@ -439,7 +454,7 @@ for (const status of ['ready', 'settled']) {
|
||||
test('submitTask prechecks quota or BYOK before persisting uploads', async t => {
|
||||
const assertQuotaOrByok = Sinon.stub().rejects(new Error('quota exceeded'));
|
||||
const resolveTranscriptionModel = Sinon.stub().resolves('gemini-2.5-flash');
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves(null),
|
||||
@@ -468,7 +483,7 @@ test('submitTask prechecks quota or BYOK before persisting uploads', async t =>
|
||||
});
|
||||
|
||||
test('submitTask rejects unavailable transcript strategy', async t => {
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
getWithUser: Sinon.stub().resolves(null),
|
||||
@@ -515,7 +530,7 @@ test('transcriptTask runs native transcript recipe through action bridge when av
|
||||
const bridgeInputs: unknown[] = [];
|
||||
const markRunning = Sinon.stub().resolves({ id: 'task-1' });
|
||||
const complete = Sinon.stub().resolves({ id: 'task-1', status: 'ready' });
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
get: Sinon.stub().resolves({
|
||||
@@ -586,7 +601,7 @@ test('transcriptTask fails task when native action bridge reports an error event
|
||||
normalizedTranscript: '00:00:05 A: Kickoff',
|
||||
});
|
||||
const complete = Sinon.stub().resolves({ id: 'task-1', status: 'failed' });
|
||||
const service = new CopilotTranscriptionService(
|
||||
const service = createCopilotTranscriptionService(
|
||||
{
|
||||
copilotTranscriptTask: {
|
||||
get: Sinon.stub().resolves({
|
||||
|
||||
@@ -57,6 +57,21 @@ function getSnapshot(timestamp: number = Date.now()): DocRecord {
|
||||
};
|
||||
}
|
||||
|
||||
test('history max age converts quota seconds to milliseconds', async t => {
|
||||
Sinon.restore();
|
||||
const options = m.get(DocStorageOptions);
|
||||
// @ts-expect-error private service boundary is asserted here
|
||||
Sinon.stub(options.quota, 'getWorkspaceQuota').resolves({
|
||||
name: 'Pro',
|
||||
blobLimit: 1,
|
||||
storageQuota: 1,
|
||||
historyPeriod: 30,
|
||||
memberLimit: 1,
|
||||
});
|
||||
|
||||
t.is(await options.historyMaxAge('1'), 30_000);
|
||||
});
|
||||
|
||||
test('should create doc history if never created before', async t => {
|
||||
// @ts-expect-error private method
|
||||
Sinon.stub(adapter, 'lastDocHistory').resolves(null);
|
||||
|
||||
@@ -273,16 +273,64 @@ e2e('should update comment work', async t => {
|
||||
t.truthy(result.updateComment);
|
||||
});
|
||||
|
||||
e2e('should update comment failed by another user', async t => {
|
||||
e2e('should update comment work by doc Editor', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Editor,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const result = await app.gql({
|
||||
query: updateCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: createResult.createComment.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test update' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.updateComment);
|
||||
});
|
||||
|
||||
e2e('should update comment failed without update permission', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
@@ -1145,15 +1193,79 @@ e2e('should update reply work when user is reply owner', async t => {
|
||||
t.truthy(result.updateReply);
|
||||
});
|
||||
|
||||
e2e('should update reply failed when user is not reply owner', async t => {
|
||||
e2e('should update reply work by doc Editor', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Editor,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: workspace.id,
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createReplyResult = await app.gql({
|
||||
query: createReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
commentId: createResult.createComment.id,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const result = await app.gql({
|
||||
query: updateReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
id: createReplyResult.createReply.id,
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test update' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.updateReply);
|
||||
});
|
||||
|
||||
e2e('should update reply failed without update permission', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Reader,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
JobQueue,
|
||||
OneMB,
|
||||
} from '../../base';
|
||||
import { ThrottlerStorage } from '../../base/throttler';
|
||||
import { SocketIoAdapter } from '../../base/websocket';
|
||||
import { AuthGuard, AuthService } from '../../core/auth';
|
||||
import { Mailer } from '../../core/mail';
|
||||
@@ -163,6 +164,10 @@ export class TestingApp extends NestApplication {
|
||||
return await this.create(MockUser, overrides);
|
||||
}
|
||||
|
||||
resetRateLimit() {
|
||||
this.get(ThrottlerStorage, { strict: false }).storage.clear();
|
||||
}
|
||||
|
||||
async signup(overrides?: Partial<MockUserInput>) {
|
||||
const user = await this.create(MockUser, overrides);
|
||||
await this.login(user);
|
||||
@@ -170,6 +175,7 @@ export class TestingApp extends NestApplication {
|
||||
}
|
||||
|
||||
async login(user: MockedUser) {
|
||||
this.resetRateLimit();
|
||||
return await this.POST('/api/auth/sign-in').send({
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
@@ -195,6 +201,7 @@ export class TestingApp extends NestApplication {
|
||||
}
|
||||
|
||||
async logout(userId?: string) {
|
||||
this.resetRateLimit();
|
||||
const res = await this.POST(
|
||||
'/api/auth/sign-out' + (userId ? `?user_id=${userId}` : '')
|
||||
).expect(200);
|
||||
|
||||
@@ -28,37 +28,43 @@ e2e('should render doc share page with apple-itunes-app meta tag', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
e2e(
|
||||
e2e.serial(
|
||||
'should render doc share page without apple-itunes-app meta tag when selfhosted',
|
||||
async t => {
|
||||
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error override
|
||||
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
await using app = await createApp();
|
||||
try {
|
||||
await using app = await createApp();
|
||||
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
const owner = await app.signup();
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
// set public to true
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
public: true,
|
||||
});
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
// set public to true
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
public: true,
|
||||
});
|
||||
|
||||
const res = await app
|
||||
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', 'text/html; charset=utf-8');
|
||||
const res = await app
|
||||
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', 'text/html; charset=utf-8');
|
||||
|
||||
t.notRegex(
|
||||
res.text,
|
||||
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
|
||||
);
|
||||
t.notRegex(
|
||||
res.text,
|
||||
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error restore mutable test env singleton
|
||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -69,6 +69,64 @@ e2e('should get recently updated docs', async t => {
|
||||
t.is(recentlyUpdatedDocs.edges[2].node.title, doc1.title);
|
||||
});
|
||||
|
||||
e2e('should filter recently updated docs by doc read permission', async t => {
|
||||
const owner = await app.signup();
|
||||
const member = await app.createUser();
|
||||
await app.login(member);
|
||||
|
||||
await app.switchUser(owner);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
});
|
||||
|
||||
const privateSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: privateSnapshot.id,
|
||||
title: 'private-doc',
|
||||
defaultRole: DocRole.None,
|
||||
});
|
||||
|
||||
const publicSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
const publicDoc = await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: publicSnapshot.id,
|
||||
title: 'public-doc',
|
||||
defaultRole: DocRole.None,
|
||||
public: true,
|
||||
});
|
||||
|
||||
await app.switchUser(member);
|
||||
const {
|
||||
workspace: { recentlyUpdatedDocs },
|
||||
} = await app.gql({
|
||||
query: getRecentlyUpdatedDocsQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
pagination: {
|
||||
first: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(recentlyUpdatedDocs.totalCount, 1);
|
||||
t.deepEqual(
|
||||
recentlyUpdatedDocs.edges.map(edge => edge.node.id),
|
||||
[publicDoc.docId]
|
||||
);
|
||||
});
|
||||
|
||||
e2e(
|
||||
'should get doc with public attribute when doc snapshot not exists',
|
||||
async t => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
listNotificationsQuery,
|
||||
MentionNotificationBodyType,
|
||||
mentionUserMutation,
|
||||
notificationCountQuery,
|
||||
NotificationObjectType,
|
||||
NotificationType,
|
||||
readAllNotificationsMutation,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
async function init() {
|
||||
@@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => {
|
||||
},
|
||||
});
|
||||
}
|
||||
const count = await app.gql({
|
||||
query: notificationCountQuery,
|
||||
});
|
||||
t.is(count.currentUser!.notifications.totalCount, 0);
|
||||
const socket = await createRealtimeClient(app, member);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const count = await realtimeRequest(socket, 'notification.count.get', {});
|
||||
t.is(count.count, 0);
|
||||
|
||||
// read again should work
|
||||
for (const notification of notifications) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
RealtimeAck,
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
} from '@affine/realtime';
|
||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import type { Response } from 'supertest';
|
||||
|
||||
import type { MockedUser } from '../mocks';
|
||||
import type { TestingApp } from './create-app';
|
||||
|
||||
const REALTIME_CLIENT_VERSION = '0.26.0';
|
||||
const WS_TIMEOUT_MS = 5_000;
|
||||
|
||||
function cookieHeader(res: Response) {
|
||||
return (res.get('Set-Cookie') ?? [])
|
||||
.map(cookie => cookie.split(';')[0])
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
) {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnect(socket: SocketIOClient) {
|
||||
if (socket.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
'realtime socket connect'
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
|
||||
const login = await app.login(user);
|
||||
const socket = io(app.url, {
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
forceNew: true,
|
||||
extraHeaders: {
|
||||
cookie: cookieHeader(login),
|
||||
},
|
||||
});
|
||||
await waitForConnect(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
export async function realtimeRequest<Op extends RealtimeRequestName>(
|
||||
socket: SocketIOClient,
|
||||
op: Op,
|
||||
input: RealtimeRequestInputOf<Op>
|
||||
): Promise<RealtimeRequestOutputOf<Op>> {
|
||||
const ack = await withTimeout(
|
||||
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
|
||||
socket.emit(
|
||||
'realtime:request',
|
||||
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
|
||||
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
|
||||
);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
`realtime request ${op}`
|
||||
);
|
||||
|
||||
if ('error' in ack) {
|
||||
throw new Error(`${ack.error.name}: ${ack.error.message}`);
|
||||
}
|
||||
|
||||
return ack.data;
|
||||
}
|
||||
@@ -15,9 +15,18 @@ import {
|
||||
R2StorageProvider,
|
||||
} from '../../../base/storage/providers/r2';
|
||||
import { SIGNED_URL_EXPIRED } from '../../../base/storage/providers/utils';
|
||||
import { WorkspaceBlobStorage } from '../../../core/storage';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import {
|
||||
CommentAttachmentStorage,
|
||||
WorkspaceBlobStorage,
|
||||
} from '../../../core/storage';
|
||||
import { MULTIPART_THRESHOLD } from '../../../core/storage/constants';
|
||||
import { R2UploadController } from '../../../core/storage/r2-proxy';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { app, e2e, Mockers } from '../test';
|
||||
|
||||
class MockR2Provider extends R2StorageProvider {
|
||||
@@ -160,6 +169,8 @@ async function setBlobStorage(storage: StorageProviderConfig) {
|
||||
configFactory.override({ storages: { blob: { storage } } });
|
||||
const blobStorage = app.get(WorkspaceBlobStorage);
|
||||
await blobStorage.onConfigInit();
|
||||
const commentAttachmentStorage = app.get(CommentAttachmentStorage);
|
||||
await commentAttachmentStorage.onConfigInit();
|
||||
const controller = app.get(R2UploadController);
|
||||
// reset cached provider in controller
|
||||
(controller as any).provider = null;
|
||||
@@ -245,7 +256,13 @@ async function getBlobUploadPartUrl(
|
||||
}
|
||||
|
||||
async function setupWorkspace() {
|
||||
const owner = await app.signup({ feature: 'pro_plan_v1' });
|
||||
const owner = await app.signup();
|
||||
await app.get(EntitlementService).upsertFromCloudSubscription({
|
||||
targetId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
});
|
||||
const workspace = await app.create(Mockers.Workspace, { owner });
|
||||
return { owner, workspace };
|
||||
}
|
||||
@@ -435,7 +452,13 @@ e2e(
|
||||
e2e(
|
||||
'should still fallback to graphql when provider does not support presign',
|
||||
async t => {
|
||||
await setBlobStorage(defaultBlobStorage);
|
||||
await setBlobStorage({
|
||||
provider: 'fs',
|
||||
bucket: 'test-fallback-bucket',
|
||||
config: {
|
||||
path: '/tmp/affine-r2-proxy-test',
|
||||
},
|
||||
});
|
||||
const { workspace } = await setupWorkspace();
|
||||
const buffer = Buffer.from('graph');
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import {
|
||||
Config,
|
||||
ConfigFactory,
|
||||
type StorageProviderConfig,
|
||||
} from '../../../base';
|
||||
import { CommentAttachmentStorage } from '../../../core/storage';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { app, e2e } from '../test';
|
||||
@@ -21,6 +26,11 @@ e2e.afterEach.always(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
async function useCommentAttachmentBlobStorage(storage: StorageProviderConfig) {
|
||||
app.get(ConfigFactory).override({ storages: { blob: { storage } } });
|
||||
await app.get(CommentAttachmentStorage).onConfigInit();
|
||||
}
|
||||
|
||||
// #region comment attachment
|
||||
|
||||
e2e(
|
||||
@@ -61,35 +71,50 @@ e2e(
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should get comment attachment body', async t => {
|
||||
e2e.serial('should get comment attachment body', async t => {
|
||||
const defaultBlobStorage = structuredClone(
|
||||
app.get(Config).storages.blob.storage
|
||||
);
|
||||
await useCommentAttachmentBlobStorage({
|
||||
provider: 'fs',
|
||||
bucket: 'test-comment-attachment',
|
||||
config: {
|
||||
path: '/tmp/affine-test-comment-attachment',
|
||||
},
|
||||
});
|
||||
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
await app.login(owner);
|
||||
|
||||
const docId = randomUUID();
|
||||
const key = randomUUID();
|
||||
const attachment = app.get(CommentAttachmentStorage);
|
||||
await attachment.put(
|
||||
workspace.id,
|
||||
docId,
|
||||
key,
|
||||
'test.txt',
|
||||
Buffer.from('test'),
|
||||
owner.id
|
||||
);
|
||||
try {
|
||||
const docId = randomUUID();
|
||||
const key = randomUUID();
|
||||
const attachment = app.get(CommentAttachmentStorage);
|
||||
await attachment.put(
|
||||
workspace.id,
|
||||
docId,
|
||||
key,
|
||||
'test.txt',
|
||||
Buffer.from('test'),
|
||||
owner.id
|
||||
);
|
||||
|
||||
const res = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
|
||||
);
|
||||
const res = await app.GET(
|
||||
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
|
||||
);
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.is(res.headers['content-type'], 'text/plain');
|
||||
t.is(res.headers['content-length'], '4');
|
||||
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
|
||||
t.regex(
|
||||
res.headers['last-modified'],
|
||||
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
|
||||
);
|
||||
t.is(res.text, 'test');
|
||||
t.is(res.status, 200);
|
||||
t.is(res.headers['content-type'], 'text/plain');
|
||||
t.is(res.headers['content-length'], '4');
|
||||
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
|
||||
t.regex(
|
||||
res.headers['last-modified'],
|
||||
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
|
||||
);
|
||||
t.is(res.text, 'test');
|
||||
} finally {
|
||||
await useCommentAttachmentBlobStorage(defaultBlobStorage);
|
||||
}
|
||||
});
|
||||
|
||||
e2e('should get comment attachment redirect url', async t => {
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
acceptInviteByInviteIdMutation,
|
||||
approveWorkspaceTeamMemberMutation,
|
||||
createInviteLinkMutation,
|
||||
deleteBlobMutation,
|
||||
getInviteInfoQuery,
|
||||
getMembersByWorkspaceIdQuery,
|
||||
inviteByEmailsMutation,
|
||||
leaveWorkspaceMutation,
|
||||
releaseDeletedBlobsMutation,
|
||||
revokeMemberPermissionMutation,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import {
|
||||
WorkspaceMemberSource,
|
||||
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { Models } from '../../../models';
|
||||
import { FeatureConfigs } from '../../../models/common/feature';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../../core/permission';
|
||||
import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
const TWO_BILLION_BYTES = 2_000_000_000;
|
||||
|
||||
async function createWorkspace() {
|
||||
const owner = await app.create(Mockers.User);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
@@ -35,6 +43,23 @@ async function createWorkspace() {
|
||||
};
|
||||
}
|
||||
|
||||
async function grantTeamPlan(workspaceId: string, quantity: number) {
|
||||
await app.get(EntitlementService).upsertFromCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeTeamPlan(workspaceId: string) {
|
||||
await app.get(EntitlementService).revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
});
|
||||
}
|
||||
|
||||
e2e('should invite a user', async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const u2 = await app.create(Mockers.User);
|
||||
@@ -91,19 +116,16 @@ e2e('should invite a user', async t => {
|
||||
e2e('should re-check seat when accepting an email invitation', async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const member = await app.create(Mockers.User);
|
||||
await app.create(Mockers.TeamWorkspace, {
|
||||
id: workspace.id,
|
||||
quantity: 4,
|
||||
});
|
||||
await grantTeamPlan(workspace.id, 12);
|
||||
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }).map(async () => {
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: (await app.create(Mockers.User)).id,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const invite = await app.gql({
|
||||
@@ -116,10 +138,10 @@ e2e('should re-check seat when accepting an email invitation', async t => {
|
||||
|
||||
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
||||
workspaceId: workspace.id,
|
||||
quantity: 4,
|
||||
quantity: 12,
|
||||
});
|
||||
|
||||
await app.models.workspaceFeature.remove(workspace.id, 'team_plan_v1');
|
||||
await revokeTeamPlan(workspace.id);
|
||||
|
||||
await app.login(member);
|
||||
await t.throwsAsync(
|
||||
@@ -147,24 +169,6 @@ e2e.serial(
|
||||
async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
const member = await app.create(Mockers.User);
|
||||
const freeStorageQuota = FeatureConfigs.free_plan_v1.configs.storageQuota;
|
||||
const lifetimeStorageQuota =
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota;
|
||||
|
||||
FeatureConfigs.free_plan_v1.configs.storageQuota = 1;
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota = 2;
|
||||
t.teardown(() => {
|
||||
FeatureConfigs.free_plan_v1.configs.storageQuota = freeStorageQuota;
|
||||
FeatureConfigs.lifetime_pro_plan_v1.configs.storageQuota =
|
||||
lifetimeStorageQuota;
|
||||
});
|
||||
|
||||
await app.models.userFeature.switchQuota(
|
||||
owner.id,
|
||||
'lifetime_pro_plan_v1',
|
||||
'test setup'
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const invite = await app.gql({
|
||||
query: inviteByEmailsMutation,
|
||||
@@ -174,26 +178,26 @@ e2e.serial(
|
||||
},
|
||||
});
|
||||
|
||||
await app.models.blob.upsert({
|
||||
workspaceId: workspace.id,
|
||||
key: 'overflow-blob',
|
||||
mime: 'application/octet-stream',
|
||||
size: 2,
|
||||
status: 'completed',
|
||||
uploadId: null,
|
||||
});
|
||||
|
||||
await app.eventBus.emitAsync('user.subscription.canceled', {
|
||||
userId: owner.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
});
|
||||
const overflowBlobKeys = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `overflow-blob-${index}`
|
||||
);
|
||||
await Promise.all(
|
||||
overflowBlobKeys.map(key =>
|
||||
app.models.blob.upsert({
|
||||
workspaceId: workspace.id,
|
||||
key,
|
||||
mime: 'application/octet-stream',
|
||||
size: TWO_BILLION_BYTES,
|
||||
status: 'completed',
|
||||
uploadId: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
t.true(
|
||||
await app.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||
.isReadonly
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
@@ -216,26 +220,13 @@ e2e.serial(
|
||||
t.is(pendingInvite.status, WorkspaceMemberStatus.Pending);
|
||||
|
||||
await app.login(owner);
|
||||
await app.gql({
|
||||
query: deleteBlobMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
key: 'overflow-blob',
|
||||
permanently: false,
|
||||
},
|
||||
});
|
||||
await app.gql({
|
||||
query: releaseDeletedBlobsMutation,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
for (const key of overflowBlobKeys) {
|
||||
await app.models.blob.delete(workspace.id, key, true);
|
||||
}
|
||||
|
||||
t.false(
|
||||
await app.models.workspaceFeature.has(
|
||||
workspace.id,
|
||||
'quota_exceeded_readonly_workspace_v1'
|
||||
)
|
||||
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||
.isReadonly
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
@@ -393,39 +384,31 @@ e2e('should support pagination for member', async t => {
|
||||
userId: u2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 2);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 2);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 0);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 0);
|
||||
});
|
||||
|
||||
e2e('should limit member count correctly', async t => {
|
||||
@@ -441,17 +424,15 @@ e2e('should limit member count correctly', async t => {
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 11);
|
||||
t.is(result.workspace.members.length, 10);
|
||||
t.is(result.memberCount, 11);
|
||||
t.is(result.members.length, 10);
|
||||
});
|
||||
|
||||
e2e('should get invite link info with status', async t => {
|
||||
@@ -596,10 +577,7 @@ e2e(
|
||||
'should invite by link and send review request notification over quota limit',
|
||||
async t => {
|
||||
const { owner, workspace } = await createWorkspace();
|
||||
await app.create(Mockers.TeamWorkspace, {
|
||||
id: workspace.id,
|
||||
quantity: 3,
|
||||
});
|
||||
await grantTeamPlan(workspace.id, 3);
|
||||
|
||||
await app.login(owner);
|
||||
const { createInviteLink } = await app.gql({
|
||||
@@ -639,10 +617,7 @@ e2e(
|
||||
name: faker.internet.displayName({ firstName: 'Lucy' }),
|
||||
});
|
||||
const user2 = await app.create(Mockers.User, {
|
||||
email: faker.internet.email({
|
||||
firstName: 'Jeanne',
|
||||
lastName: 'Doe',
|
||||
}),
|
||||
email: `jeanne_doe.${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
@@ -653,38 +628,54 @@ e2e(
|
||||
userId: user2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].email, user2.email);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, user2.email);
|
||||
|
||||
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
|
||||
const pendingUser = await app.create(Mockers.User, {
|
||||
email: pendingEmail,
|
||||
});
|
||||
await app
|
||||
.get(Models)
|
||||
.workspaceUser.set(
|
||||
workspace.id,
|
||||
pendingUser.id,
|
||||
ModelWorkspaceRole.Collaborator,
|
||||
{
|
||||
status: PrismaWorkspaceMemberStatus.Pending,
|
||||
source: WorkspaceMemberSource.Email,
|
||||
}
|
||||
);
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'pending_search',
|
||||
});
|
||||
t.is(result.memberCount, 4);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, pendingEmail);
|
||||
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
revokePublicPageMutation,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { QuotaService } from '../../../core/quota/service';
|
||||
import { WorkspaceRole } from '../../../models';
|
||||
@@ -98,7 +99,31 @@ const revokeMember = async (workspaceId: string, userId: string) => {
|
||||
return revokeMember;
|
||||
};
|
||||
|
||||
e2e('should set new invited users to AllocatingSeat', async t => {
|
||||
const cancelTeamWorkspace = async (workspaceId: string) => {
|
||||
const db = app.get(PrismaClient);
|
||||
await db.entitlement.updateMany({
|
||||
where: {
|
||||
targetType: 'workspace',
|
||||
targetId: workspaceId,
|
||||
plan: 'team',
|
||||
},
|
||||
data: { status: 'revoked' },
|
||||
});
|
||||
await db.subscription.updateMany({
|
||||
where: {
|
||||
targetId: workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
},
|
||||
data: { status: 'canceled' },
|
||||
});
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
};
|
||||
|
||||
e2e('should set new invited users to waiting-seat status', async t => {
|
||||
const { owner, workspace } = await createTeamWorkspace();
|
||||
await app.login(owner);
|
||||
|
||||
@@ -117,7 +142,7 @@ e2e('should set new invited users to AllocatingSeat', async t => {
|
||||
const invitationInfo = await getInvitationInfo(
|
||||
result.inviteMembers[0].inviteId!
|
||||
);
|
||||
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
|
||||
t.is(invitationInfo.status, WorkspaceMemberStatus.NeedMoreSeat);
|
||||
});
|
||||
|
||||
e2e('should allocate seats', async t => {
|
||||
@@ -151,11 +176,11 @@ e2e('should allocate seats', async t => {
|
||||
});
|
||||
|
||||
t.is(
|
||||
members.find(m => m.user.id === u1.id)?.status,
|
||||
members.find(m => m.user?.id === u1.id)?.status,
|
||||
WorkspaceMemberStatus.Pending
|
||||
);
|
||||
t.is(
|
||||
members.find(m => m.user.id === u2.id)?.status,
|
||||
members.find(m => m.user?.id === u2.id)?.status,
|
||||
WorkspaceMemberStatus.Accepted
|
||||
);
|
||||
|
||||
@@ -201,11 +226,11 @@ e2e('should set all rests to NeedMoreSeat', async t => {
|
||||
});
|
||||
|
||||
t.is(
|
||||
members.find(m => m.user.id === u2.id)?.status,
|
||||
members.find(m => m.user?.id === u2.id)?.status,
|
||||
WorkspaceMemberStatus.NeedMoreSeat
|
||||
);
|
||||
t.is(
|
||||
members.find(m => m.user.id === u3.id)?.status,
|
||||
members.find(m => m.user?.id === u3.id)?.status,
|
||||
WorkspaceMemberStatus.NeedMoreSeat
|
||||
);
|
||||
});
|
||||
@@ -237,11 +262,7 @@ e2e(
|
||||
status: WorkspaceMemberStatus.UnderReview,
|
||||
});
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
|
||||
first: 20,
|
||||
@@ -265,11 +286,7 @@ e2e(
|
||||
async t => {
|
||||
const { workspace, owner, admin } = await createTeamWorkspace();
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
|
||||
t.false(
|
||||
@@ -306,11 +323,7 @@ e2e(
|
||||
await app.login(owner);
|
||||
await publishDoc(workspace.id, 'published-doc');
|
||||
|
||||
await app.eventBus.emitAsync('workspace.subscription.canceled', {
|
||||
workspaceId: workspace.id,
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
await cancelTeamWorkspace(workspace.id);
|
||||
|
||||
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
|
||||
t.true(
|
||||
@@ -325,7 +338,7 @@ e2e(
|
||||
);
|
||||
|
||||
await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc'));
|
||||
await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc'));
|
||||
await t.throwsAsync(revokePublicDoc(workspace.id, 'published-doc'));
|
||||
|
||||
const quota = await app
|
||||
.get(QuotaService)
|
||||
|
||||
@@ -27,6 +27,16 @@ export class MockTeamWorkspace extends Mocker<
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
await this.db.entitlement.create({
|
||||
data: {
|
||||
targetType: 'workspace',
|
||||
targetId: id,
|
||||
source: 'cloud_subscription',
|
||||
plan: 'team',
|
||||
status: 'active',
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.workspaceFeature.create({
|
||||
data: {
|
||||
|
||||
@@ -45,6 +45,55 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
const runtimeStateColumns = await this.db.$queryRaw<
|
||||
Array<{ exists: boolean }>
|
||||
>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_runtime_states'
|
||||
AND column_name = 'known'
|
||||
) AS "exists"
|
||||
`;
|
||||
if (runtimeStateColumns[0]?.exists) {
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO workspace_runtime_states (
|
||||
workspace_id,
|
||||
known,
|
||||
readonly,
|
||||
readonly_reasons,
|
||||
last_reconciled_at,
|
||||
stale_after,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
known = true,
|
||||
readonly = false,
|
||||
readonly_reasons = ARRAY[]::TEXT[],
|
||||
last_reconciled_at = now(),
|
||||
stale_after = NULL,
|
||||
updated_at = now()
|
||||
`;
|
||||
} else {
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO workspace_runtime_states (
|
||||
workspace_id,
|
||||
readonly,
|
||||
readonly_reasons,
|
||||
stale_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
readonly = false,
|
||||
readonly_reasons = ARRAY[]::TEXT[],
|
||||
stale_at = NULL,
|
||||
updated_at = now()
|
||||
`;
|
||||
}
|
||||
|
||||
// create a rootDoc snapshot
|
||||
if (snapshot) {
|
||||
|
||||
@@ -73,6 +73,24 @@ test('should set doc user role', async t => {
|
||||
t.is(role?.type, DocRole.Manager);
|
||||
});
|
||||
|
||||
test('should batch update existing doc user roles', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
await models.docUser.set(workspace.id, docId, user.id, DocRole.Reader);
|
||||
const count = await models.docUser.batchSetUserRoles(
|
||||
workspace.id,
|
||||
docId,
|
||||
[user.id],
|
||||
DocRole.Editor
|
||||
);
|
||||
const role = await models.docUser.get(workspace.id, docId, user.id);
|
||||
|
||||
t.is(count, 1);
|
||||
t.is(role?.type, DocRole.Editor);
|
||||
});
|
||||
|
||||
test('should not allow setting doc owner through setDocUserRole', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
@@ -96,6 +114,23 @@ test('should delete doc user role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should delete doc grants by user id', async t => {
|
||||
const workspace = await create();
|
||||
const user = await models.user.create({ email: 'u1@affine.pro' });
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
await models.docUser.set(workspace.id, docId, user.id, DocRole.Manager);
|
||||
await models.docUser.deleteByUserId(user.id);
|
||||
|
||||
t.is(await models.docUser.get(workspace.id, docId, user.id), null);
|
||||
t.is(
|
||||
await db.docGrant.count({
|
||||
where: { principalType: 'user', principalId: user.id },
|
||||
}),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('should paginate doc user roles', async t => {
|
||||
const workspace = await create();
|
||||
const docId = 'fake-doc-id';
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { User } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
|
||||
import { AvailableUserFeatureConfig } from '../../core/features/types';
|
||||
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
|
||||
import { Feature } from '../../models/common/feature';
|
||||
import { createTestingModule, TestingModule } from '../utils';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
model: UserFeatureModel;
|
||||
resolver: AdminFeatureManagementResolver;
|
||||
u1: User;
|
||||
}
|
||||
|
||||
@@ -16,6 +20,7 @@ test.before(async t => {
|
||||
const module = await createTestingModule({});
|
||||
|
||||
t.context.model = module.get(UserFeatureModel);
|
||||
t.context.resolver = module.get(AdminFeatureManagementResolver);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
@@ -31,6 +36,21 @@ test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('configurable user features exclude commercial projection features', t => {
|
||||
const config = new AvailableUserFeatureConfig();
|
||||
|
||||
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
|
||||
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
|
||||
});
|
||||
|
||||
test('admin feature resolver rejects commercial projection features', async t => {
|
||||
await t.throwsAsync(
|
||||
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
|
||||
{ message: /not configurable/ }
|
||||
);
|
||||
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
|
||||
});
|
||||
|
||||
test('should get null if user feature not found', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
const userFeature = await model.get(u1.id, 'ai_early_access');
|
||||
@@ -39,12 +59,14 @@ test('should get null if user feature not found', async t => {
|
||||
|
||||
test('should get user feature', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const userFeature = await model.get(u1.id, 'free_plan_v1');
|
||||
t.is(userFeature?.name, 'free_plan_v1');
|
||||
});
|
||||
|
||||
test('should get user quota', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const userQuota = await model.getQuota(u1.id);
|
||||
t.snapshot(userQuota?.configs, 'free plan');
|
||||
});
|
||||
@@ -52,6 +74,7 @@ test('should get user quota', async t => {
|
||||
test('should list user features', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
t.like(await model.list(u1.id), ['free_plan_v1']);
|
||||
});
|
||||
|
||||
@@ -68,6 +91,7 @@ test('should list user features by type', async t => {
|
||||
test('should directly test user feature existence', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
t.true(await model.has(u1.id, 'free_plan_v1'));
|
||||
t.false(await model.has(u1.id, 'ai_early_access'));
|
||||
});
|
||||
@@ -112,6 +136,7 @@ test('should switch user quota', async t => {
|
||||
test('should not switch user quota if the new quota is the same as the current one', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
|
||||
|
||||
// @ts-expect-error private
|
||||
@@ -135,6 +160,7 @@ test('should use pro plan as free for selfhost instance', async t => {
|
||||
registered: true,
|
||||
});
|
||||
|
||||
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
const quota = await models.userFeature.getQuota(u1.id);
|
||||
t.snapshot(
|
||||
quota?.configs,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Workspace } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
|
||||
import {
|
||||
FeatureType,
|
||||
UserModel,
|
||||
@@ -12,6 +13,7 @@ import { createTestingModule, type TestingModule } from '../utils';
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
model: WorkspaceFeatureModel;
|
||||
resolver: AdminWorkspaceResolver;
|
||||
ws: Workspace;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ test.before(async t => {
|
||||
const module = await createTestingModule({});
|
||||
|
||||
t.context.model = module.get(WorkspaceFeatureModel);
|
||||
t.context.resolver = module.get(AdminWorkspaceResolver);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
@@ -44,6 +47,17 @@ test('should get null if workspace feature not found', async t => {
|
||||
t.is(userFeature, null);
|
||||
});
|
||||
|
||||
test('admin workspace update changes workspace flags', async t => {
|
||||
await t.context.resolver.adminUpdateWorkspace({
|
||||
id: t.context.ws.id,
|
||||
name: 'updated',
|
||||
});
|
||||
t.is(
|
||||
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
|
||||
'updated'
|
||||
);
|
||||
});
|
||||
|
||||
test('should directly test workspace feature existence', async t => {
|
||||
const { model, ws } = t.context;
|
||||
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
|
||||
import {
|
||||
DocRole,
|
||||
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
|
||||
PermissionProjectionModel,
|
||||
permissionProjectionTriggerErrorCategory,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../models';
|
||||
import { createModule } from '../create-module';
|
||||
import { Mockers } from '../mocks';
|
||||
|
||||
const module = await createModule({});
|
||||
const db = module.get(PrismaClient);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
class TestPermissionProjectionModel extends PermissionProjectionModel {
|
||||
constructor(private readonly fakeDb: unknown) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected override get db() {
|
||||
return this.fakeDb as never;
|
||||
}
|
||||
}
|
||||
|
||||
let appliedPermissionProjectionTriggerFunctionUpdates = false;
|
||||
async function applyPermissionProjectionTriggerFunctionUpdates() {
|
||||
if (appliedPermissionProjectionTriggerFunctionUpdates) {
|
||||
return;
|
||||
}
|
||||
const migration = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'migrations/20260512133700_workspace_runtime_states/migration.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
for (const name of [
|
||||
'affine_permission_project_new_workspace_member',
|
||||
'affine_permission_project_new_workspace_invitation',
|
||||
'affine_permission_project_new_doc_access_policy',
|
||||
'affine_permission_project_new_doc_grant',
|
||||
]) {
|
||||
const sql = migration.match(
|
||||
new RegExp(
|
||||
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
|
||||
)
|
||||
)?.[0];
|
||||
if (!sql) {
|
||||
throw new Error(`Missing migration function ${name}`);
|
||||
}
|
||||
await db.$executeRawUnsafe(sql);
|
||||
}
|
||||
appliedPermissionProjectionTriggerFunctionUpdates = true;
|
||||
}
|
||||
|
||||
async function hasCurrentWorkspaceInvitationColumns() {
|
||||
const rows = await db.$queryRaw<{ columnName: string }[]>`
|
||||
SELECT column_name AS "columnName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'workspace_invitations'
|
||||
AND column_name IN ('requested_role', 'status', 'kind')
|
||||
`;
|
||||
return rows.length === 3;
|
||||
}
|
||||
|
||||
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
|
||||
const queryResults = [
|
||||
[{ count: 1n }],
|
||||
[{ count: 2n }],
|
||||
[{ count: 3n }],
|
||||
[{ count: 4n }],
|
||||
[{ count: 5n }],
|
||||
[{ count: 6n }],
|
||||
[{ count: 7n }],
|
||||
[{ count: 8n }],
|
||||
[{ count: 9n }],
|
||||
[{ count: 10n }],
|
||||
[
|
||||
{ category: 'legacy_doc_external_row', count: 11n },
|
||||
{ category: 'doc_default_owner', count: 12n },
|
||||
],
|
||||
];
|
||||
const model = new TestPermissionProjectionModel({
|
||||
$queryRaw: async () => queryResults.shift(),
|
||||
});
|
||||
|
||||
t.deepEqual(await model.checkLegacyProjection(), {
|
||||
oldWorkspacePolicyMismatch: 1,
|
||||
oldAcceptedMemberMismatch: 2,
|
||||
extraProjectedMember: 3,
|
||||
oldInvitationMismatch: 4,
|
||||
extraProjectedInvitation: 5,
|
||||
oldDocGrantMismatch: 6,
|
||||
extraProjectedDocGrant: 7,
|
||||
oldDocPolicyMismatch: 8,
|
||||
extraProjectedDocPolicy: 9,
|
||||
runtimeStateMissing: 0,
|
||||
runtimeStateMismatch: 0,
|
||||
ownerConflict: 10,
|
||||
oldNewDecisionMismatch: 0,
|
||||
invalidLegacyRows: {
|
||||
legacy_doc_external_row: 11,
|
||||
doc_default_owner: 12,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => {
|
||||
const executed: unknown[] = [];
|
||||
let transactionOptions: unknown;
|
||||
const model = new TestPermissionProjectionModel({
|
||||
$transaction: async (
|
||||
callback: (tx: unknown) => Promise<void>,
|
||||
options: unknown
|
||||
) => {
|
||||
transactionOptions = options;
|
||||
await callback({
|
||||
$executeRaw: async (query: unknown) => {
|
||||
executed.push(query);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await model.backfillLegacyProjection();
|
||||
|
||||
t.is(executed.length, 11);
|
||||
t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 });
|
||||
t.regex(String(executed[0]), /affine\.permission_sync_origin/);
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
|
||||
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
|
||||
'owner_conflict',
|
||||
'invalid_legacy_role',
|
||||
'foreign_key_missing',
|
||||
'projection_recursion_guard_missing',
|
||||
'unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
test('permission projection migration uses non-recursive origin guard', t => {
|
||||
const migration = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'migrations/20260512133700_workspace_runtime_states/migration.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const guardBody = migration.match(
|
||||
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
|
||||
)?.[0];
|
||||
|
||||
t.truthy(guardBody);
|
||||
t.true(
|
||||
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
|
||||
);
|
||||
t.false(
|
||||
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
|
||||
);
|
||||
t.truthy(
|
||||
migration.match(
|
||||
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger maps legacy workspace permission rows', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const [admin, pending] = await module.create(Mockers.User, 2);
|
||||
|
||||
await db.workspaceUserRole.createMany({
|
||||
data: [
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId: admin.id,
|
||||
type: WorkspaceRole.Admin,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
userId: pending.id,
|
||||
type: WorkspaceRole.Collaborator,
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const member = await db.workspaceMember.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: admin.id,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_inviteeUserId: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: pending.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(member.role, 'admin');
|
||||
t.is(invitation.requestedRole, 'member');
|
||||
t.is(invitation.status, 'pending');
|
||||
});
|
||||
|
||||
test('permission projection trigger maps legacy doc policy rows', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
await db.workspaceDoc.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'public-doc',
|
||||
public: true,
|
||||
defaultRole: DocRole.Reader,
|
||||
},
|
||||
});
|
||||
|
||||
const policy = await db.docAccessPolicy.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'public-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(policy.visibility, 'public');
|
||||
t.is(policy.publicRole, 'external');
|
||||
t.is(policy.memberDefaultRole, 'reader');
|
||||
});
|
||||
|
||||
async function hasDocGrantLegacyProjectionColumns() {
|
||||
const rows = await db.$queryRaw<{ columnName: string }[]>`
|
||||
SELECT column_name AS "columnName"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'doc_grants'
|
||||
AND column_name IN (
|
||||
'legacy_workspace_id',
|
||||
'legacy_doc_id',
|
||||
'legacy_user_id'
|
||||
)
|
||||
`;
|
||||
return rows.length === 3;
|
||||
}
|
||||
|
||||
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
|
||||
if (!(await hasDocGrantLegacyProjectionColumns())) {
|
||||
t.false(
|
||||
Boolean(process.env.CI),
|
||||
'current local test database predates doc_grants legacy columns'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await db.workspaceDocUserRole.createMany({
|
||||
data: [
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'valid-grant',
|
||||
userId: user.id,
|
||||
type: DocRole.Reader,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'dirty-external',
|
||||
userId: user.id,
|
||||
type: DocRole.External,
|
||||
},
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId: 'dirty-none',
|
||||
userId: user.id,
|
||||
type: DocRole.None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const grants = await db.docGrant.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
principalId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
docId: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
t.deepEqual(
|
||||
grants.map(grant => [grant.docId, grant.role]),
|
||||
[['valid-grant', 'reader']]
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const member = await db.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
role: 'member',
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await db.workspaceMember.update({
|
||||
where: { id: member.id },
|
||||
data: { state: 'suspended' },
|
||||
});
|
||||
|
||||
t.is(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
|
||||
if (!(await hasCurrentWorkspaceInvitationColumns())) {
|
||||
t.false(
|
||||
Boolean(process.env.CI),
|
||||
'current local test database predates workspace invitation projection columns'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const [invitation] = await db.$queryRaw<{ id: string }[]>`
|
||||
INSERT INTO workspace_invitations (
|
||||
workspace_id,
|
||||
invitee_user_id,
|
||||
requested_role,
|
||||
status,
|
||||
kind
|
||||
)
|
||||
VALUES (
|
||||
${workspace.id},
|
||||
${user.id},
|
||||
'member',
|
||||
'pending',
|
||||
'email'
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
t.is(
|
||||
(
|
||||
await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
).status,
|
||||
'Pending'
|
||||
);
|
||||
|
||||
await db.$executeRaw`
|
||||
UPDATE workspace_invitations
|
||||
SET status = 'declined'
|
||||
WHERE id = ${invitation.id}
|
||||
`;
|
||||
|
||||
t.is(
|
||||
await db.workspaceUserRole.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
|
||||
await db.workspaceDoc.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
public: true,
|
||||
defaultRole: DocRole.Reader,
|
||||
mode: 1,
|
||||
blocked: true,
|
||||
title: 'Title',
|
||||
summary: 'Summary',
|
||||
publishedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
await db.docAccessPolicy.delete({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const doc = await db.workspaceDoc.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'metadata-doc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(doc.public, false);
|
||||
t.is(doc.defaultRole, DocRole.Manager);
|
||||
t.is(doc.publishedAt, null);
|
||||
t.is(doc.mode, 1);
|
||||
t.is(doc.blocked, true);
|
||||
t.is(doc.title, 'Title');
|
||||
t.is(doc.summary, 'Summary');
|
||||
});
|
||||
|
||||
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
|
||||
await applyPermissionProjectionTriggerFunctionUpdates();
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await db.docGrant.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'user',
|
||||
principalId: user.id,
|
||||
role: 'reader',
|
||||
},
|
||||
});
|
||||
await db.docGrant.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'group',
|
||||
principalId: user.id,
|
||||
role: 'manager',
|
||||
},
|
||||
});
|
||||
await db.docGrant.delete({
|
||||
where: {
|
||||
workspaceId_docId_principalType_principalId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
principalType: 'group',
|
||||
principalId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_docId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
docId: 'group-doc',
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(legacyGrant.type, DocRole.Reader);
|
||||
});
|
||||
|
||||
test('PermissionProjectionModel parses trigger error metric category', t => {
|
||||
t.is(
|
||||
permissionProjectionTriggerErrorCategory(
|
||||
new Error('permission_projection_error:owner_conflict:duplicate owner')
|
||||
),
|
||||
'owner_conflict'
|
||||
);
|
||||
t.is(
|
||||
permissionProjectionTriggerErrorCategory(
|
||||
new Error('permission_projection_error:unexpected:nope')
|
||||
),
|
||||
'unknown'
|
||||
);
|
||||
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
|
||||
});
|
||||
|
||||
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
|
||||
const checker = new PermissionProjectionChecker(
|
||||
{
|
||||
workspace: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
$queryRaw: async () => [
|
||||
{
|
||||
category: 'active_member_doc',
|
||||
workspaceId: 'w1',
|
||||
docId: 'doc1',
|
||||
userId: 'u1',
|
||||
workspaceActions: null,
|
||||
docActions: ['Doc.Read'],
|
||||
},
|
||||
{
|
||||
category: 'explicit_doc_grant',
|
||||
workspaceId: 'w1',
|
||||
docId: 'doc2',
|
||||
userId: 'u1',
|
||||
workspaceActions: null,
|
||||
docActions: ['Doc.Read'],
|
||||
},
|
||||
{
|
||||
category: 'workspace_invitation',
|
||||
workspaceId: 'w1',
|
||||
docId: null,
|
||||
userId: 'u2',
|
||||
workspaceActions: ['Workspace.Read'],
|
||||
docActions: null,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
permissionProjection: {
|
||||
checkLegacyProjection: async () => ({}),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
load: async (input: { docs?: [{ docId: string }] }) => ({
|
||||
version: 1,
|
||||
workspace: { marker: 'legacy' },
|
||||
docs: input.docs
|
||||
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
|
||||
: [],
|
||||
}),
|
||||
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
|
||||
version: 1,
|
||||
workspace: { marker: input.docs ? 'legacy' : 'projection' },
|
||||
docs: input.docs
|
||||
? [
|
||||
{
|
||||
docId: input.docs[0].docId,
|
||||
marker:
|
||||
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
evaluate: (input: unknown) => input,
|
||||
} as never
|
||||
);
|
||||
|
||||
t.deepEqual(await checker.checkLegacyProjection(), {
|
||||
oldNewDecisionMismatch: 2,
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,22 @@ test('should not get inactive workspace role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should not activate a missing workspace invitation', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
await t.throwsAsync(
|
||||
models.workspaceUser.setStatus(
|
||||
workspace.id,
|
||||
user.id,
|
||||
WorkspaceMemberStatus.Accepted
|
||||
),
|
||||
{ message: 'Cannot activate a missing workspace invitation.' }
|
||||
);
|
||||
|
||||
t.is(await models.workspaceUser.get(workspace.id, user.id), null);
|
||||
});
|
||||
|
||||
test('should update user role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const user = await module.create(Mockers.User);
|
||||
@@ -215,6 +231,114 @@ test('should delete workspace user role', async t => {
|
||||
t.is(role, null);
|
||||
});
|
||||
|
||||
test('should delete legacy-only external workspace user role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
|
||||
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
|
||||
|
||||
await models.workspaceUser.delete(workspace.id, u1.id);
|
||||
|
||||
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
|
||||
});
|
||||
|
||||
test('should convert existing workspace user role to legacy-only external role', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
});
|
||||
|
||||
const role = await models.workspaceUser.get(workspace.id, u1.id);
|
||||
t.is(role?.type, WorkspaceRole.External);
|
||||
t.is(
|
||||
await db.workspaceMember.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
state: 'active',
|
||||
},
|
||||
}),
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('should backfill legacy permission id for new workspace member writes', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
}
|
||||
);
|
||||
|
||||
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const member = await db.workspaceMember.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
t.is(member.legacyPermissionId, legacyRole.id);
|
||||
});
|
||||
|
||||
test('should backfill legacy permission id for new workspace invitation writes', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace);
|
||||
const u1 = await module.create(Mockers.User);
|
||||
|
||||
await models.workspaceUser.set(
|
||||
workspace.id,
|
||||
u1.id,
|
||||
WorkspaceRole.Collaborator,
|
||||
{
|
||||
status: WorkspaceMemberStatus.Pending,
|
||||
}
|
||||
);
|
||||
|
||||
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: workspace.id,
|
||||
userId: u1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const invitation = await db.workspaceInvitation.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
inviteeUserId: u1.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.is(invitation.legacyPermissionId, legacyRole.id);
|
||||
});
|
||||
|
||||
test('should get user workspace roles with filter', async t => {
|
||||
const ws1 = await module.create(Mockers.Workspace);
|
||||
const ws2 = await module.create(Mockers.Workspace);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { resolveEntitlementV1 } from '../native';
|
||||
|
||||
test('native entitlement wrapper maps schema errors to invalid argument', t => {
|
||||
const error = t.throws(() =>
|
||||
resolveEntitlementV1({
|
||||
deploymentType: 'local',
|
||||
targetType: 'workspace',
|
||||
now: '2026-05-14T00:00:00Z',
|
||||
})
|
||||
);
|
||||
|
||||
t.is((error as Error & { code?: string })?.code, 'InvalidArg');
|
||||
});
|
||||
|
||||
test('native entitlement wrapper maps unsafe JS quantity to invalid argument', t => {
|
||||
const base = {
|
||||
deploymentType: 'cloud',
|
||||
targetType: 'workspace',
|
||||
plan: 'team',
|
||||
now: '2026-05-14T00:00:00Z',
|
||||
} as const;
|
||||
|
||||
for (const quantity of [4294967297, 1.5, 100001]) {
|
||||
const error = t.throws(() => resolveEntitlementV1({ ...base, quantity }));
|
||||
|
||||
t.is(
|
||||
(error as Error & { code?: string })?.code,
|
||||
'InvalidArg',
|
||||
String(quantity)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('native entitlement wrapper does not trust forged signed payload buffers', t => {
|
||||
const resolved = resolveEntitlementV1({
|
||||
deploymentType: 'selfhosted',
|
||||
targetType: 'workspace',
|
||||
targetId: 'workspace-id',
|
||||
signedPayload: Buffer.from('not-a-valid-license'),
|
||||
publicKey: 'not-a-valid-public-key',
|
||||
licenseAesKey: 'not-a-valid-aes-key',
|
||||
now: '2026-05-14T00:00:00Z',
|
||||
});
|
||||
|
||||
t.false(resolved.valid);
|
||||
t.is(resolved.status, 'needs_reupload');
|
||||
t.is(resolved.plan, 'selfhost_free');
|
||||
});
|
||||
@@ -85,6 +85,18 @@ class NonThrottledController {
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Throttle('strict')
|
||||
@Controller('/strict-throttled')
|
||||
class StrictThrottledController {
|
||||
@Public()
|
||||
@SkipThrottle()
|
||||
@Get('/skip')
|
||||
skip() {
|
||||
return 'skip';
|
||||
}
|
||||
}
|
||||
|
||||
test.before(async t => {
|
||||
const app = await createTestingApp({
|
||||
imports: [
|
||||
@@ -92,7 +104,7 @@ test.before(async t => {
|
||||
throttle: {
|
||||
throttlers: {
|
||||
default: {
|
||||
ttl: 60,
|
||||
ttl: 60_000,
|
||||
limit: 120,
|
||||
},
|
||||
},
|
||||
@@ -100,7 +112,11 @@ test.before(async t => {
|
||||
}),
|
||||
AppModule,
|
||||
],
|
||||
controllers: [ThrottledController, NonThrottledController],
|
||||
controllers: [
|
||||
ThrottledController,
|
||||
NonThrottledController,
|
||||
StrictThrottledController,
|
||||
],
|
||||
});
|
||||
|
||||
t.context.storage = app.get(ThrottlerStorage);
|
||||
@@ -109,6 +125,7 @@ test.before(async t => {
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = t.context;
|
||||
t.context.storage.storage.clear();
|
||||
await app.initTestingDB();
|
||||
});
|
||||
|
||||
@@ -243,6 +260,18 @@ test('should skip throttler for unauthenticated user when specified', async t =>
|
||||
t.is(headers.reset, undefined!);
|
||||
});
|
||||
|
||||
test('should skip class-level strict throttler when specified', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await app.GET('/strict-throttled/skip').expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, undefined!);
|
||||
t.is(headers.remaining, undefined!);
|
||||
t.is(headers.reset, undefined!);
|
||||
});
|
||||
|
||||
test('should use specified throttler for unauthenticated user', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { CryptoHelper, EventBus } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { LicenseService } from '../../plugins/license/service';
|
||||
import { PaymentEventHandlers } from '../../plugins/payment/event';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionVariant,
|
||||
} from '../../plugins/payment/types';
|
||||
|
||||
type Context = Record<string, never>;
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test('workspace subscription activation only sends upgrade notification', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
let reconciled = false;
|
||||
const handler = new PaymentEventHandlers(
|
||||
{
|
||||
isTeamWorkspace: async () => true,
|
||||
sendTeamWorkspaceUpgradedEmail: async () => {},
|
||||
} as unknown as WorkspaceService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => {
|
||||
reconciled = true;
|
||||
},
|
||||
} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 7 }),
|
||||
} as unknown as QuotaStateService,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await handler.onWorkspaceSubscriptionUpdated({
|
||||
workspaceId: 'ws',
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
quantity: 999,
|
||||
});
|
||||
|
||||
t.deepEqual(events, []);
|
||||
t.false(reconciled);
|
||||
});
|
||||
|
||||
test('workspace entitlement change allocates seats from effective quota state', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const handler = new PaymentEventHandlers(
|
||||
{} as unknown as WorkspaceService,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({
|
||||
plan: 'team',
|
||||
seatLimit: 7,
|
||||
}),
|
||||
} as unknown as QuotaStateService,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus
|
||||
);
|
||||
|
||||
await handler.onEntitlementChanged({
|
||||
targetType: 'workspace',
|
||||
targetId: 'ws',
|
||||
});
|
||||
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.members.allocateSeats',
|
||||
payload: { workspaceId: 'ws', quantity: 7 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('onetime selfhost license seat allocation ignores projected license quantity', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const service = new LicenseService(
|
||||
{
|
||||
installedLicense: {
|
||||
findUnique: async () => ({
|
||||
key: 'license-key',
|
||||
workspaceId: 'ws',
|
||||
quantity: 999,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as Models,
|
||||
{} as unknown as CryptoHelper,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{} as unknown as EntitlementService,
|
||||
{
|
||||
reconcileWorkspaceQuotaState: async () => ({ seatLimit: 4 }),
|
||||
} as unknown as QuotaStateService
|
||||
);
|
||||
|
||||
await service.updateTeamSeats({
|
||||
workspaceId: 'ws',
|
||||
} as Events['workspace.members.updated']);
|
||||
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.members.allocateSeats',
|
||||
payload: { workspaceId: 'ws', quantity: 4 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const affineProRequests: string[] = [];
|
||||
const upserts: unknown[] = [];
|
||||
const entitlements: unknown[] = [];
|
||||
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
const service = new LicenseService(
|
||||
{
|
||||
installedLicense: {
|
||||
findUnique: async () => null,
|
||||
upsert: async (input: unknown) => {
|
||||
upserts.push(input);
|
||||
return {
|
||||
workspaceId: 'ws',
|
||||
key: 'license-key',
|
||||
quantity: 3,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaClient,
|
||||
{
|
||||
emit: (name: string, payload: unknown) => events.push({ name, payload }),
|
||||
} as unknown as EventBus,
|
||||
{} as unknown as Models,
|
||||
{} as unknown as CryptoHelper,
|
||||
{} as unknown as WorkspacePolicyService,
|
||||
{
|
||||
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
|
||||
entitlements.push(input);
|
||||
},
|
||||
} as unknown as EntitlementService,
|
||||
{} as unknown as QuotaStateService
|
||||
);
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
fetchAffinePro: (path: string) => Promise<{
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
endAt: number;
|
||||
res: Response;
|
||||
}>;
|
||||
}
|
||||
).fetchAffinePro = async (path: string) => {
|
||||
affineProRequests.push(path);
|
||||
return {
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
endAt: expiresAt,
|
||||
res: new Response(null, {
|
||||
headers: {
|
||||
'x-next-validate-key': 'next-validate-key',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const license = await service.activateTeamLicense('ws', 'license-key');
|
||||
|
||||
t.like(license, {
|
||||
workspaceId: 'ws',
|
||||
key: 'license-key',
|
||||
quantity: 3,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
});
|
||||
t.is(entitlements.length, 1);
|
||||
t.is(upserts.length, 1);
|
||||
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.subscription.activated',
|
||||
payload: {
|
||||
workspaceId: 'ws',
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user