mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
36 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 |
@@ -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
+200
-15
@@ -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,17 +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",
|
||||
]
|
||||
|
||||
@@ -576,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"
|
||||
@@ -1537,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"
|
||||
@@ -1544,6 +1591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1584,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"
|
||||
@@ -1806,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"
|
||||
@@ -1824,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"
|
||||
@@ -2013,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"
|
||||
@@ -2324,6 +2425,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2375,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"
|
||||
@@ -2408,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"
|
||||
@@ -3625,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",
|
||||
@@ -4352,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"
|
||||
@@ -4391,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"
|
||||
@@ -4752,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"
|
||||
@@ -4836,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"
|
||||
@@ -5334,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"
|
||||
@@ -5699,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"
|
||||
@@ -6040,7 +6226,6 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -6050,7 +6235,6 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7815,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"
|
||||
@@ -7853,7 +8047,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots 1.0.6",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8215,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"] }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,18 +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
+47
@@ -169,6 +169,8 @@ 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
|
||||
@@ -475,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'|
|
||||
@@ -635,6 +641,47 @@ 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>
|
||||
|
||||
@@ -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,13 @@ 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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -412,11 +412,25 @@ fn build_pinned_client(url: &Url, addrs: &[SocketAddr], timeout: Duration) -> An
|
||||
.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 {
|
||||
@@ -623,6 +637,13 @@ mod tests {
|
||||
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
|
||||
|
||||
+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.217.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.217.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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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!.notificationCount, 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');
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -86,7 +86,10 @@ test('should cleanup expired pending blobs', async t => {
|
||||
],
|
||||
});
|
||||
|
||||
const abortSpy = Sinon.spy(t.context.storage, 'abortMultipartUpload');
|
||||
const abortSpy = Sinon.stub(
|
||||
t.context.storage,
|
||||
'abortMultipartUpload'
|
||||
).resolves();
|
||||
const deleteSpy = Sinon.spy(t.context.storage, 'delete');
|
||||
t.teardown(() => {
|
||||
abortSpy.restore();
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TestingApp } from './utils';
|
||||
type TestContext = {
|
||||
app: TestingApp;
|
||||
};
|
||||
const test = ava as TestFn<TestContext>;
|
||||
const test = ava.serial as TestFn<TestContext>;
|
||||
|
||||
let safeFetchStub: Sinon.SinonStub | undefined;
|
||||
let safeFetchHandler:
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createHash } from 'node:crypto';
|
||||
import test from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Config, StorageProviderFactory } from '../../base';
|
||||
import { Config, ConfigFactory, StorageProviderFactory } from '../../base';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
|
||||
import { BlobModel, WorkspaceFeatureModel } from '../../models';
|
||||
import {
|
||||
@@ -35,6 +36,18 @@ let model: WorkspaceFeatureModel;
|
||||
test.before(async () => {
|
||||
app = await createTestingApp();
|
||||
model = app.get(WorkspaceFeatureModel);
|
||||
app.get(ConfigFactory).override({
|
||||
storages: {
|
||||
blob: {
|
||||
storage: {
|
||||
provider: 'fs',
|
||||
bucket: 'test',
|
||||
config: { path: '/tmp/affine-test-storage' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.get(WorkspaceBlobStorage).onConfigInit();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
@@ -45,6 +58,26 @@ test.after.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function withRestrictedWorkspaceQuota(workspaceId: string) {
|
||||
const quotaState = app.get(QuotaStateService);
|
||||
const blobModel = app.get(BlobModel);
|
||||
const base = await quotaState.reconcileWorkspaceQuotaState(workspaceId);
|
||||
return Sinon.stub(quotaState, 'reconcileWorkspaceQuotaState').callsFake(
|
||||
async id => {
|
||||
if (id !== workspaceId) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
blobLimit: BigInt(RESTRICTED_QUOTA.blobLimit),
|
||||
storageQuota: BigInt(RESTRICTED_QUOTA.storageQuota),
|
||||
usedStorageQuota: BigInt(await blobModel.totalSize(workspaceId)),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test('should set blobs', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
@@ -233,7 +266,8 @@ test('should reject blob exceeded limit', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
const workspace1 = await createWorkspace(app);
|
||||
await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
|
||||
const quotaStub = await withRestrictedWorkspaceQuota(workspace1.id);
|
||||
t.teardown(() => quotaStub.restore());
|
||||
|
||||
const buffer1 = Buffer.from(
|
||||
Array.from({ length: RESTRICTED_QUOTA.blobLimit + 1 }, () => 0)
|
||||
@@ -247,7 +281,8 @@ test('should reject blob exceeded storage quota', async t => {
|
||||
await app.signupV1('u1@affine.pro');
|
||||
|
||||
const workspace = await createWorkspace(app);
|
||||
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
|
||||
const quotaStub = await withRestrictedWorkspaceQuota(workspace.id);
|
||||
t.teardown(() => quotaStub.restore());
|
||||
|
||||
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import Sinon from 'sinon';
|
||||
import supertest from 'supertest';
|
||||
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
|
||||
|
||||
import { ConfigFactory } from '../../base';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||
import { PermissionReadModel } from '../../core/permission/config';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
|
||||
import {
|
||||
@@ -152,6 +154,31 @@ test('should be able to get private workspace with public pages', async t => {
|
||||
t.is(res.text, 'blob');
|
||||
});
|
||||
|
||||
test('should be able to get private workspace with public pages using new permission model', async t => {
|
||||
const { app, storage } = t.context;
|
||||
const config = app.get(ConfigFactory);
|
||||
|
||||
config.override({
|
||||
permission: {
|
||||
readModel: PermissionReadModel.Projection,
|
||||
},
|
||||
});
|
||||
try {
|
||||
storage.get.resolves(blob());
|
||||
const res = await app.GET('/api/workspaces/private/blobs/test');
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'text/plain');
|
||||
t.is(res.text, 'blob');
|
||||
} finally {
|
||||
config.override({
|
||||
permission: {
|
||||
readModel: PermissionReadModel.Legacy,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should not be able to get private workspace with no public pages', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -185,7 +185,10 @@ export function buildAppModule(env: Env) {
|
||||
.useIf(
|
||||
() => env.flavors.sync || env.flavors.front,
|
||||
SyncModule,
|
||||
TelemetryModule,
|
||||
TelemetryModule
|
||||
)
|
||||
.useIf(
|
||||
() => !env.flavors.graphql && (env.flavors.sync || env.flavors.front),
|
||||
CopilotRealtimeModule
|
||||
)
|
||||
// graphql server only
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ import { CacheInterceptor } from './interceptor';
|
||||
})
|
||||
export class CacheModule {}
|
||||
export { Cache, SessionCache };
|
||||
|
||||
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
|
||||
export { isValidCacheTtl } from './provider';
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface CacheSetOptions {
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export function isValidCacheTtl(ttl: unknown): ttl is number {
|
||||
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
|
||||
}
|
||||
|
||||
export class CacheProvider {
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
Cache,
|
||||
CacheInterceptor,
|
||||
isValidCacheTtl,
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
SessionCache,
|
||||
|
||||
@@ -62,6 +62,7 @@ export type KnownMetricScopes =
|
||||
| 'queue'
|
||||
| 'storage'
|
||||
| 'process'
|
||||
| 'permission'
|
||||
| 'workspace';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden } from '../../base';
|
||||
import { ActionForbidden, EventBus } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { UserType } from '../user';
|
||||
@@ -26,7 +26,10 @@ class GenerateAccessTokenInput {
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Query(() => [RevealedAccessToken], {
|
||||
deprecationReason: 'use currentUser.revealedAccessTokens',
|
||||
@@ -42,11 +45,13 @@ export class AccessTokenResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
return await this.models.accessToken.create({
|
||||
const token = await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
this.event.emit('user.access_token.created', { userId: user.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -55,6 +60,7 @@ export class AccessTokenResolver {
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
this.event.emit('user.access_token.revoked', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { decodeWithJson, encodeWithJson } from '../../base/graphql';
|
||||
import { AccessController } from '../permission';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import {
|
||||
realtimeCommentRoom,
|
||||
type RealtimePublisher,
|
||||
type RealtimeRegistry,
|
||||
RealtimePublisher,
|
||||
RealtimeRegistry,
|
||||
registerRealtimeLiveQuery,
|
||||
} from '../realtime';
|
||||
import type { CommentCursor } from './resolver';
|
||||
@@ -20,8 +20,8 @@ export function commentRoom(workspaceId: string, docId: string) {
|
||||
export class CommentRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
@Optional() private readonly registry?: RealtimeRegistry
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly registry: RealtimeRegistry
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Optional } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Mutation,
|
||||
@@ -26,8 +25,8 @@ import {
|
||||
import { Comment, DocMode, Models, Reply } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { ServerFeature, ServerService } from '../config';
|
||||
import { AccessController, DocAction } from '../permission';
|
||||
import type { RealtimePublisher } from '../realtime';
|
||||
import { DocAction, PermissionAccess } from '../permission';
|
||||
import { RealtimePublisher } from '../realtime';
|
||||
import { CommentAttachmentStorage } from '../storage';
|
||||
import { UserType } from '../user';
|
||||
import { WorkspaceType } from '../workspaces';
|
||||
@@ -55,12 +54,12 @@ export interface CommentCursor {
|
||||
export class CommentResolver {
|
||||
constructor(
|
||||
private readonly service: CommentService,
|
||||
private readonly ac: AccessController,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly commentAttachmentStorage: CommentAttachmentStorage,
|
||||
private readonly queue: JobQueue,
|
||||
private readonly models: Models,
|
||||
private readonly server: ServerService,
|
||||
@Optional() private readonly realtime?: RealtimePublisher
|
||||
private readonly realtime: RealtimePublisher
|
||||
) {
|
||||
// enable comment feature by default
|
||||
this.server.enableFeature(ServerFeature.Comment);
|
||||
@@ -470,11 +469,7 @@ export class CommentResolver {
|
||||
|
||||
private async assertPermission(
|
||||
me: UserType,
|
||||
item: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId?: string;
|
||||
},
|
||||
item: { workspaceId: string; docId: string; userId?: string },
|
||||
action: DocAction
|
||||
) {
|
||||
// the owner of the comment/reply can update, delete, resolve it
|
||||
|
||||
@@ -173,7 +173,7 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
description: 'Workspace features available for admin configuration',
|
||||
})
|
||||
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
|
||||
return ['unlimited_workspace', 'team_plan_v1'];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Models } from '../../models';
|
||||
import { htmlSanitize } from '../../native';
|
||||
import { Public } from '../auth';
|
||||
import { DocReader } from '../doc';
|
||||
import { WorkspacePolicyService } from '../permission';
|
||||
import { PermissionService } from '../permission';
|
||||
|
||||
interface RenderOptions {
|
||||
title: string;
|
||||
@@ -61,7 +61,7 @@ export class DocRendererController {
|
||||
private readonly doc: DocReader,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
private readonly permission: PermissionService
|
||||
) {
|
||||
this.webAssets = this.readHtmlAssets(join(env.projectRoot, 'static'));
|
||||
this.mobileAssets = this.readHtmlAssets(
|
||||
@@ -99,10 +99,11 @@ export class DocRendererController {
|
||||
req.accepts().some(t => markdownType.has(t.toLowerCase()))
|
||||
) {
|
||||
try {
|
||||
const canReadMarkdown = await this.policy.canReadSharedDoc(
|
||||
const canReadMarkdown = await this.permission.canDoc({
|
||||
workspaceId,
|
||||
sub
|
||||
);
|
||||
docId: sub,
|
||||
action: 'Doc.Read',
|
||||
});
|
||||
if (!canReadMarkdown) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
@@ -162,7 +163,7 @@ export class DocRendererController {
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
if (await this.policy.canPreviewDoc(workspaceId, docId)) {
|
||||
if (await this.permission.canPreviewDoc({ workspaceId, docId })) {
|
||||
return this.doc.getDocContent(workspaceId, docId);
|
||||
}
|
||||
|
||||
@@ -172,8 +173,9 @@ export class DocRendererController {
|
||||
private async getWorkspaceContent(
|
||||
workspaceId: string
|
||||
): Promise<RenderOptions | null> {
|
||||
const canPreviewWorkspace =
|
||||
await this.policy.canPreviewWorkspace(workspaceId);
|
||||
const canPreviewWorkspace = await this.permission.canPreviewWorkspace({
|
||||
workspaceId,
|
||||
});
|
||||
if (!canPreviewWorkspace) return null;
|
||||
|
||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||
|
||||
@@ -73,7 +73,7 @@ export class DocStorageOptions implements IDocStorageOptions {
|
||||
|
||||
historyMaxAge = async (spaceId: string) => {
|
||||
const quota = await this.quota.getWorkspaceQuota(spaceId);
|
||||
return quota.historyPeriod;
|
||||
return quota.historyPeriod * 1000;
|
||||
};
|
||||
|
||||
historyMinInterval = (_spaceId: string) => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { Models } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import {
|
||||
EntitlementModule,
|
||||
EntitlementProjectionChecker,
|
||||
EntitlementService,
|
||||
} from '../index';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
models: Models;
|
||||
entitlement: EntitlementService;
|
||||
checker: EntitlementProjectionChecker;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({ imports: [EntitlementModule] });
|
||||
t.context.module = module;
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.models = module.get(Models);
|
||||
t.context.entitlement = module.get(EntitlementService);
|
||||
t.context.checker = module.get(EntitlementProjectionChecker);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('checker distinguishes valid projection from dirty legacy features', async t => {
|
||||
const cleanUser = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: cleanUser.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const dirtyUser = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.models.userFeature.add(
|
||||
dirtyUser.id,
|
||||
'pro_plan_v1',
|
||||
'dirty legacy feature'
|
||||
);
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.dirtyLegacyUserFeatures, 1);
|
||||
t.is(report.missingUserFeatureProjection, 0);
|
||||
});
|
||||
|
||||
test('checker reports missing legacy projection and stale state', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.entitlement.upsertFromCloudSubscription({
|
||||
targetId: user.id,
|
||||
plan: 'pro',
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: 'active',
|
||||
});
|
||||
await t.context.db.subscription.delete({
|
||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||
});
|
||||
await t.context.db.effectiveUserQuotaState.update({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
staleAfter: new Date('2020-01-01T00:00:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.cloudSubscriptionProjectionMissing, 1);
|
||||
t.is(report.staleEffectiveUserState, 1);
|
||||
});
|
||||
|
||||
test('checker reports legal legacy facts missing entitlements', async t => {
|
||||
const user = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await t.context.db.subscription.create({
|
||||
data: {
|
||||
targetId: user.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const owner = await t.context.models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
const workspace = await t.context.models.workspace.create(owner.id);
|
||||
await t.context.db.installedLicense.create({
|
||||
data: {
|
||||
key: 'legacy-verifiable-key',
|
||||
workspaceId: workspace.id,
|
||||
quantity: 5,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
validateKey: 'validate-key',
|
||||
validatedAt: new Date(),
|
||||
license: Buffer.from('raw-license'),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await t.context.checker.checkEntitlementProjection();
|
||||
|
||||
t.is(report.cloudSubscriptionEntitlementMissing, 1);
|
||||
t.is(report.selfhostLicenseEntitlementMissing, 1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user