mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 17:43:51 +00:00
Compare commits
16 Commits
darksky/na
...
v0.26.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c440686ad | ||
|
|
b331a08744 | ||
|
|
279b7bb64f | ||
|
|
89f0430242 | ||
|
|
0bd8160ed4 | ||
|
|
a5b60cf679 | ||
|
|
ca2462f987 | ||
|
|
d515d295ce | ||
|
|
e4dc82ee35 | ||
|
|
aa6f26b1a5 | ||
|
|
c1d43b9b18 | ||
|
|
b8e597fa1d | ||
|
|
cf98afb32e | ||
|
|
a11e9fe8ca | ||
|
|
f42246aba1 | ||
|
|
f5394b7450 |
@@ -595,6 +595,11 @@
|
||||
"description": "Multiple hosts the server will accept requests from.\n@default []",
|
||||
"default": []
|
||||
},
|
||||
"listenAddr": {
|
||||
"type": "string",
|
||||
"description": "The address to listen on (e.g., 0.0.0.0 for IPv4, :: for IPv6).\n@default \"0.0.0.0\"\n@environment `LISTEN_ADDR`",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",
|
||||
@@ -645,6 +650,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"type": "object",
|
||||
"description": "Configuration for calendar module",
|
||||
"properties": {
|
||||
"google": {
|
||||
"type": "object",
|
||||
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\"}\n@link https://developers.google.com/calendar/api/guides/push",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"clientId": {
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalWebhookUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"webhookVerificationToken": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"enabled": false,
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"externalWebhookUrl": "",
|
||||
"webhookVerificationToken": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
"type": "object",
|
||||
"description": "Configuration for captcha module",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*.rs]
|
||||
max_line_length = 120
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
|
||||
44
.github/workflows/build-test.yml
vendored
44
.github/workflows/build-test.yml
vendored
@@ -798,49 +798,6 @@ jobs:
|
||||
name: fuzz-artifact
|
||||
path: packages/common/y-octo/utils/fuzz/artifacts/**/*
|
||||
|
||||
y-octo-binding-test:
|
||||
name: y-octo binding test on ${{ matrix.settings.target }}
|
||||
runs-on: ${{ matrix.settings.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- { target: 'x86_64-unknown-linux-gnu', os: 'ubuntu-latest' }
|
||||
- { target: 'aarch64-unknown-linux-gnu', os: 'ubuntu-24.04-arm' }
|
||||
- { target: 'x86_64-apple-darwin', os: 'macos-15-intel' }
|
||||
- { target: 'aarch64-apple-darwin', os: 'macos-latest' }
|
||||
- { target: 'x86_64-pc-windows-msvc', os: 'windows-latest' }
|
||||
- { target: 'aarch64-pc-windows-msvc', os: 'windows-11-arm' }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine-tools/cli @affine/monorepo @y-octo/node
|
||||
electron-install: false
|
||||
- name: Install rustup (Windows 11 ARM)
|
||||
if: matrix.settings.os == 'windows-11-arm'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://static.rust-lang.org/rustup/dist/aarch64-pc-windows-msvc/rustup-init.exe" -OutFile rustup-init.exe
|
||||
.\rustup-init.exe --default-toolchain none -y
|
||||
"$env:USERPROFILE\.cargo\bin" | Out-File -Append -Encoding ascii $env:GITHUB_PATH
|
||||
"CARGO_HOME=$env:USERPROFILE\.cargo" | Out-File -Append -Encoding ascii $env:GITHUB_ENV
|
||||
- name: Install Rust (Windows 11 ARM)
|
||||
if: matrix.settings.os == 'windows-11-arm'
|
||||
shell: pwsh
|
||||
run: |
|
||||
rustup install stable
|
||||
rustup target add ${{ matrix.settings.target }}
|
||||
cargo --version
|
||||
- name: Build Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: ${{ matrix.settings.target }}
|
||||
package: '@y-octo/node'
|
||||
- name: Run tests
|
||||
run: yarn affine @y-octo/node test
|
||||
|
||||
rust-test:
|
||||
name: Run native tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1387,7 +1344,6 @@ jobs:
|
||||
- miri
|
||||
- loom
|
||||
- fuzzing
|
||||
- y-octo-binding-test
|
||||
- server-test
|
||||
- server-e2e-test
|
||||
- rust-test
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
exclude = ["node_modules/**/*.toml", "target/**/*.toml"]
|
||||
exclude = [
|
||||
"node_modules/**/*.toml",
|
||||
"target/**/*.toml",
|
||||
"packages/frontend/apps/ios/App/Packages/AffineGraphQL/**/*.toml",
|
||||
]
|
||||
|
||||
# https://taplo.tamasfe.dev/configuration/formatter-options.html
|
||||
[formatting]
|
||||
|
||||
1559
Cargo.lock
generated
1559
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ members = [
|
||||
"./packages/backend/native",
|
||||
"./packages/common/native",
|
||||
"./packages/common/y-octo/core",
|
||||
"./packages/common/y-octo/node",
|
||||
"./packages/common/y-octo/utils",
|
||||
"./packages/frontend/mobile-native",
|
||||
"./packages/frontend/native",
|
||||
@@ -64,7 +63,6 @@ resolver = "3"
|
||||
notify = { version = "8", features = ["serde"] }
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = "0.3"
|
||||
ogg = "0.9"
|
||||
once_cell = "1"
|
||||
ordered-float = "5"
|
||||
parking_lot = "0.12"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"file-type": "^21.0.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
"rxjs": "^7.8.2",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"lit": "^3.2.0",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"date-fns": "^4.0.0",
|
||||
"lit": "^3.2.0",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
"rxjs": "^7.8.2",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"file-type": "^21.0.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"katex": "^0.16.27",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"dompurify": "^3.3.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"lit": "^3.2.0",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.0.0",
|
||||
|
||||
@@ -48,32 +48,41 @@ const compareList = <T>(
|
||||
return 0;
|
||||
};
|
||||
const compareString = (a: unknown, b: unknown): CompareType => {
|
||||
if (typeof a != 'string' || a === '') {
|
||||
return Compare.GT;
|
||||
const strA = String(a ?? '');
|
||||
const strB = String(b ?? '');
|
||||
|
||||
if (strA === '' && strB !== '') {
|
||||
return Compare.GT; // Empty strings come last
|
||||
}
|
||||
if (typeof b != 'string' || b === '') {
|
||||
return Compare.LT;
|
||||
if (strA !== '' && strB === '') {
|
||||
return Compare.LT; // Empty strings come last
|
||||
}
|
||||
const listA = a.split('.');
|
||||
const listB = b.split('.');
|
||||
if (strA === '' && strB === '') {
|
||||
return 0; // Both empty, equal
|
||||
}
|
||||
|
||||
const listA = strA.split('.');
|
||||
const listB = strB.split('.');
|
||||
return compareList(listA, listB, (a, b) => {
|
||||
const lowA = a.toLowerCase();
|
||||
const lowB = b.toLowerCase();
|
||||
const lowA = String(a).toLowerCase(); // Ensure 'a' and 'b' from split are strings too
|
||||
const lowB = String(b).toLowerCase();
|
||||
|
||||
const numberA = Number.parseInt(lowA);
|
||||
const numberB = Number.parseInt(lowB);
|
||||
const aIsNaN = Number.isNaN(numberA);
|
||||
const bIsNaN = Number.isNaN(numberB);
|
||||
|
||||
if (aIsNaN && !bIsNaN) {
|
||||
return 1;
|
||||
return 1; // Non-numeric part comes after numeric part
|
||||
}
|
||||
if (!aIsNaN && bIsNaN) {
|
||||
return -1;
|
||||
return -1; // Numeric part comes before non-numeric part
|
||||
}
|
||||
if (!aIsNaN && !bIsNaN && numberA !== numberB) {
|
||||
return numberA - numberB;
|
||||
return numberA - numberB; // Numeric comparison for numeric parts
|
||||
}
|
||||
|
||||
return lowA.localeCompare(lowB);
|
||||
return lowA.localeCompare(lowB); // Lexicographical comparison for string parts
|
||||
});
|
||||
};
|
||||
const compareNumber = (a: unknown, b: unknown) => {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
"rxjs": "^7.8.2",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.1.1",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.15",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lit-html": "^3.2.1",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"collapse-white-space": "^2.1.0",
|
||||
"date-fns": "^4.0.0",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"collapse-white-space": "^2.1.0",
|
||||
"date-fns": "^4.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"collapse-white-space": "^2.1.0",
|
||||
"date-fns": "^4.0.0",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"collapse-white-space": "^2.1.0",
|
||||
"date-fns": "^4.0.0",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"collapse-white-space": "^2.1.0",
|
||||
"date-fns": "^4.0.0",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"yjs": "^13.6.27"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"yjs": "^13.6.27"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fflate": "^0.8.2",
|
||||
"lit": "^3.2.0",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"yjs": "^13.6.27"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"fflate": "^0.8.2",
|
||||
"lit": "^3.2.0",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -46,6 +46,22 @@ import {
|
||||
parseGroup,
|
||||
slashItemClassName,
|
||||
} from './utils.js';
|
||||
const isTextInputKey = (e: KeyboardEvent) => {
|
||||
// Keys combined with modifiers are not considered text input
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return false;
|
||||
|
||||
// During IME composition, do not treat keydown as text input.
|
||||
// Query updates are handled by input/composition hooks.
|
||||
if (e.isComposing) return false;
|
||||
|
||||
// Only allow single-character keys as text input
|
||||
if (e.key.length !== 1) return false;
|
||||
|
||||
// Keep existing behavior: space closes the slash menu
|
||||
if (e.key === ' ') return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
type InnerSlashMenuContext = SlashMenuContext & {
|
||||
onClickItem: (item: SlashMenuActionItem) => void;
|
||||
searching: boolean;
|
||||
@@ -228,10 +244,12 @@ export class SlashMenu extends WithDisposable(LitElement) {
|
||||
}
|
||||
|
||||
if (key !== 'Backspace' && this._queryState === 'no_result') {
|
||||
// if the following key is not the backspace key,
|
||||
// the slash menu will be closed
|
||||
this.abortController.abort();
|
||||
return;
|
||||
if (isTextInputKey(event)) {
|
||||
// allow typing to change query; don't abort here
|
||||
} else {
|
||||
this.abortController.abort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'Escape') {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -86,14 +86,13 @@ export function bindKeymap(
|
||||
}
|
||||
}
|
||||
|
||||
// none standard keyboard, fallback to keyCode
|
||||
const special =
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.metaKey ||
|
||||
name.charCodeAt(0) > 127;
|
||||
// For non-standard keyboards, fallback to keyCode only when modifier keys are pressed.
|
||||
// Do NOT fallback when the key produces a non-ASCII character (e.g., Cyrillic 'х' on Russian keyboard),
|
||||
// because the user intends to type that character, not trigger a shortcut bound to the physical key.
|
||||
// See: https://github.com/toeverything/AFFiNE/issues/14059
|
||||
const hasModifier = event.shiftKey || event.altKey || event.metaKey;
|
||||
const baseName = base[event.keyCode];
|
||||
if (special && baseName && baseName !== name) {
|
||||
if (hasModifier && baseName && baseName !== name) {
|
||||
const fromCode = map[modifiers(baseName, event)];
|
||||
if (fromCode && fromCode(ctx)) {
|
||||
return true;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@lit/context": "^1.1.3",
|
||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.16",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"lit": "^3.2.0",
|
||||
"rxjs": "^7.8.2",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
".",
|
||||
"blocksuite/**/*",
|
||||
"packages/*/*",
|
||||
"packages/common/y-octo/node",
|
||||
"packages/frontend/apps/*",
|
||||
"tools/*",
|
||||
"docs/reference",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
license-file = "LICENSE"
|
||||
name = "affine_server_native"
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use affine_common::doc_parser::{
|
||||
self, BlockInfo, CrawlResult, MarkdownResult, PageDocContent, WorkspaceDocContent,
|
||||
};
|
||||
use affine_common::doc_parser::{self, BlockInfo, CrawlResult, MarkdownResult, PageDocContent, WorkspaceDocContent};
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
@@ -103,10 +101,7 @@ pub fn parse_doc_from_binary(doc_bin: Buffer, doc_id: String) -> Result<NativeCr
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn parse_page_doc(
|
||||
doc_bin: Buffer,
|
||||
max_summary_length: Option<i32>,
|
||||
) -> Result<Option<NativePageDocContent>> {
|
||||
pub fn parse_page_doc(doc_bin: Buffer, max_summary_length: Option<i32>) -> Result<Option<NativePageDocContent>> {
|
||||
let result = doc_parser::parse_page_doc(doc_bin.into(), max_summary_length.map(|v| v as isize))
|
||||
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
|
||||
Ok(result.map(Into::into))
|
||||
@@ -114,8 +109,8 @@ pub fn parse_page_doc(
|
||||
|
||||
#[napi]
|
||||
pub fn parse_workspace_doc(doc_bin: Buffer) -> Result<Option<NativeWorkspaceDocContent>> {
|
||||
let result = doc_parser::parse_workspace_doc(doc_bin.into())
|
||||
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
|
||||
let result =
|
||||
doc_parser::parse_workspace_doc(doc_bin.into()).map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
|
||||
Ok(result.map(Into::into))
|
||||
}
|
||||
|
||||
@@ -126,21 +121,13 @@ pub fn parse_doc_to_markdown(
|
||||
ai_editable: Option<bool>,
|
||||
doc_url_prefix: Option<String>,
|
||||
) -> Result<NativeMarkdownResult> {
|
||||
let result = doc_parser::parse_doc_to_markdown(
|
||||
doc_bin.into(),
|
||||
doc_id,
|
||||
ai_editable.unwrap_or(false),
|
||||
doc_url_prefix,
|
||||
)
|
||||
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
|
||||
let result = doc_parser::parse_doc_to_markdown(doc_bin.into(), doc_id, ai_editable.unwrap_or(false), doc_url_prefix)
|
||||
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
|
||||
Ok(result.into())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn read_all_doc_ids_from_root_doc(
|
||||
doc_bin: Buffer,
|
||||
include_trash: Option<bool>,
|
||||
) -> Result<Vec<String>> {
|
||||
pub fn read_all_doc_ids_from_root_doc(doc_bin: Buffer, include_trash: Option<bool>) -> Result<Vec<String>> {
|
||||
let result = doc_parser::get_doc_ids_from_binary(doc_bin.into(), include_trash.unwrap_or(false))
|
||||
.map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
|
||||
Ok(result)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use affine_common::doc_loader::Doc;
|
||||
use napi::{
|
||||
Env, Result, Task,
|
||||
anyhow::anyhow,
|
||||
bindgen_prelude::{AsyncTask, Buffer},
|
||||
Env, Result, Task,
|
||||
};
|
||||
|
||||
#[napi(object)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use mp4parse::{read_mp4, TrackType};
|
||||
use mp4parse::{TrackType, read_mp4};
|
||||
use napi_derive::napi;
|
||||
|
||||
#[napi]
|
||||
@@ -6,9 +6,7 @@ pub fn get_mime(input: &[u8]) -> String {
|
||||
let mimetype = if let Some(kind) = infer::get(&input[..4096.min(input.len())]) {
|
||||
kind.mime_type().to_string()
|
||||
} else {
|
||||
file_format::FileFormat::from_bytes(input)
|
||||
.media_type()
|
||||
.to_string()
|
||||
file_format::FileFormat::from_bytes(input).media_type().to_string()
|
||||
};
|
||||
if mimetype == "video/mp4" {
|
||||
detect_mp4_flavor(input)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use affine_common::hashcash::Stamp;
|
||||
use napi::{bindgen_prelude::AsyncTask, Env, Result as NapiResult, Task};
|
||||
use napi::{Env, Result as NapiResult, Task, bindgen_prelude::AsyncTask};
|
||||
use napi_derive::napi;
|
||||
|
||||
pub struct AsyncVerifyChallengeResponse {
|
||||
@@ -61,9 +61,6 @@ impl Task for AsyncMintChallengeResponse {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn mint_challenge_response(
|
||||
resource: String,
|
||||
bits: Option<u32>,
|
||||
) -> AsyncTask<AsyncMintChallengeResponse> {
|
||||
pub fn mint_challenge_response(resource: String, bits: Option<u32>) -> AsyncTask<AsyncMintChallengeResponse> {
|
||||
AsyncTask::new(AsyncMintChallengeResponse { bits, resource })
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod tiktoken;
|
||||
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use napi::{bindgen_prelude::*, Error, Result, Status};
|
||||
use napi::{Error, Result, Status, bindgen_prelude::*};
|
||||
use y_octo::Doc;
|
||||
|
||||
#[cfg(not(target_arch = "arm"))]
|
||||
@@ -58,5 +58,4 @@ pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
|
||||
pub const AFFINE_PRO_PUBLIC_KEY: Option<&'static str> = std::option_env!("AFFINE_PRO_PUBLIC_KEY");
|
||||
|
||||
#[napi]
|
||||
pub const AFFINE_PRO_LICENSE_AES_KEY: Option<&'static str> =
|
||||
std::option_env!("AFFINE_PRO_LICENSE_AES_KEY");
|
||||
pub const AFFINE_PRO_LICENSE_AES_KEY: Option<&'static str> = std::option_env!("AFFINE_PRO_LICENSE_AES_KEY");
|
||||
|
||||
@@ -57,11 +57,11 @@ fn try_remove_label(s: &str, i: usize) -> Option<usize> {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(ch) = s[next_idx..].chars().next() {
|
||||
if ch == '.' {
|
||||
next_idx += ch.len_utf8();
|
||||
return Some(next_idx);
|
||||
}
|
||||
if let Some(ch) = s[next_idx..].chars().next()
|
||||
&& ch == '.'
|
||||
{
|
||||
next_idx += ch.len_utf8();
|
||||
return Some(next_idx);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -84,9 +84,7 @@ fn remove_label(s: &str) -> String {
|
||||
|
||||
pub fn clean_content(content: &str) -> String {
|
||||
let content = content.replace("\x00", "");
|
||||
remove_label(&collapse_whitespace(&content))
|
||||
.trim()
|
||||
.to_string()
|
||||
remove_label(&collapse_whitespace(&content)).trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_accounts" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"provider" VARCHAR NOT NULL,
|
||||
"provider_account_id" VARCHAR NOT NULL,
|
||||
"display_name" VARCHAR,
|
||||
"email" VARCHAR,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"expires_at" TIMESTAMPTZ(3),
|
||||
"scope" TEXT,
|
||||
"status" VARCHAR NOT NULL DEFAULT 'active',
|
||||
"last_error" TEXT,
|
||||
"refresh_interval_minutes" INTEGER NOT NULL DEFAULT 30,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "calendar_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_subscriptions" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"account_id" VARCHAR NOT NULL,
|
||||
"provider" VARCHAR NOT NULL,
|
||||
"external_calendar_id" VARCHAR NOT NULL,
|
||||
"display_name" VARCHAR,
|
||||
"timezone" VARCHAR,
|
||||
"color" VARCHAR,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sync_token" TEXT,
|
||||
"last_sync_at" TIMESTAMPTZ(3),
|
||||
"custom_channel_id" VARCHAR,
|
||||
"custom_resource_id" VARCHAR,
|
||||
"channel_expiration" TIMESTAMPTZ(3),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "calendar_subscriptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspace_calendars" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"created_by_user_id" VARCHAR NOT NULL,
|
||||
"display_name_override" VARCHAR,
|
||||
"color_override" VARCHAR,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "workspace_calendars_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspace_calendar_items" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"workspace_calendar_id" VARCHAR NOT NULL,
|
||||
"subscription_id" VARCHAR NOT NULL,
|
||||
"sort_order" INTEGER,
|
||||
"color_override" VARCHAR,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "workspace_calendar_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_events" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"subscription_id" VARCHAR NOT NULL,
|
||||
"external_event_id" VARCHAR NOT NULL,
|
||||
"recurrence_id" VARCHAR,
|
||||
"etag" VARCHAR,
|
||||
"status" VARCHAR,
|
||||
"title" VARCHAR,
|
||||
"description" TEXT,
|
||||
"location" VARCHAR,
|
||||
"start_at_utc" TIMESTAMPTZ(3) NOT NULL,
|
||||
"end_at_utc" TIMESTAMPTZ(3) NOT NULL,
|
||||
"original_timezone" VARCHAR,
|
||||
"all_day" BOOLEAN NOT NULL DEFAULT false,
|
||||
"provider_updated_at" TIMESTAMPTZ(3),
|
||||
"raw" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "calendar_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "calendar_event_instances" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"calendar_event_id" VARCHAR NOT NULL,
|
||||
"recurrence_id" VARCHAR NOT NULL,
|
||||
"start_at_utc" TIMESTAMPTZ(3) NOT NULL,
|
||||
"end_at_utc" TIMESTAMPTZ(3) NOT NULL,
|
||||
"original_timezone" VARCHAR,
|
||||
"all_day" BOOLEAN NOT NULL DEFAULT false,
|
||||
"provider_updated_at" TIMESTAMPTZ(3),
|
||||
"raw" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "calendar_event_instances_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_accounts_user_id_idx" ON "calendar_accounts"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_accounts_provider_provider_account_id_idx" ON "calendar_accounts"("provider", "provider_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "calendar_accounts_user_id_provider_provider_account_id_key" ON "calendar_accounts"("user_id", "provider", "provider_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_subscriptions_account_id_idx" ON "calendar_subscriptions"("account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_subscriptions_provider_external_calendar_id_idx" ON "calendar_subscriptions"("provider", "external_calendar_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "calendar_subscriptions_account_id_external_calendar_id_key" ON "calendar_subscriptions"("account_id", "external_calendar_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "workspace_calendars_workspace_id_idx" ON "workspace_calendars"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "workspace_calendar_items_subscription_id_idx" ON "workspace_calendar_items"("subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "workspace_calendar_items_workspace_calendar_id_subscription_key" ON "workspace_calendar_items"("workspace_calendar_id", "subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_events_subscription_id_start_at_utc_idx" ON "calendar_events"("subscription_id", "start_at_utc");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_events_subscription_id_end_at_utc_idx" ON "calendar_events"("subscription_id", "end_at_utc");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "calendar_events_subscription_id_external_event_id_recurrenc_key" ON "calendar_events"("subscription_id", "external_event_id", "recurrence_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "calendar_event_instances_calendar_event_id_start_at_utc_idx" ON "calendar_event_instances"("calendar_event_id", "start_at_utc");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "calendar_event_instances_calendar_event_id_recurrence_id_key" ON "calendar_event_instances"("calendar_event_id", "recurrence_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_accounts" ADD CONSTRAINT "calendar_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_subscriptions" ADD CONSTRAINT "calendar_subscriptions_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "calendar_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_calendars" ADD CONSTRAINT "workspace_calendars_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_calendars" ADD CONSTRAINT "workspace_calendars_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_calendar_items" ADD CONSTRAINT "workspace_calendar_items_workspace_calendar_id_fkey" FOREIGN KEY ("workspace_calendar_id") REFERENCES "workspace_calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_calendar_items" ADD CONSTRAINT "workspace_calendar_items_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "calendar_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_events" ADD CONSTRAINT "calendar_events_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "calendar_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "calendar_event_instances" ADD CONSTRAINT "calendar_event_instances_calendar_event_id_fkey" FOREIGN KEY ("calendar_event_id") REFERENCES "calendar_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -62,7 +62,7 @@
|
||||
"@opentelemetry/instrumentation": "^0.208.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.56.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.208.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.56.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.55.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.55.0",
|
||||
"@opentelemetry/resources": "^2.2.0",
|
||||
@@ -75,7 +75,7 @@
|
||||
"@queuedash/api": "^3.14.0",
|
||||
"@react-email/components": "0.0.38",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ai": "^5.0.108",
|
||||
"ai": "^5.0.118",
|
||||
"bullmq": "^5.40.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cross-env": "^10.1.0",
|
||||
@@ -152,7 +152,7 @@
|
||||
"c8": "^10.1.3",
|
||||
"nodemon": "^3.1.11",
|
||||
"react-email": "4.0.11",
|
||||
"sinon": "^21.0.0",
|
||||
"sinon": "^21.0.1",
|
||||
"supertest": "^7.1.4",
|
||||
"why-is-node-running": "^3.2.2"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ model User {
|
||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
calendarAccounts CalendarAccount[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
appConfigs AppConfig[]
|
||||
@@ -48,6 +49,7 @@ model User {
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
workspaceCalendars WorkspaceCalendar[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -129,6 +131,7 @@ model Workspace {
|
||||
embedFiles AiWorkspaceFiles[]
|
||||
comments Comment[]
|
||||
commentAttachments CommentAttachment[]
|
||||
workspaceCalendars WorkspaceCalendar[]
|
||||
workspaceAdminStats WorkspaceAdminStats[]
|
||||
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
|
||||
|
||||
@@ -911,3 +914,140 @@ model AccessToken {
|
||||
@@index([userId])
|
||||
@@map("access_tokens")
|
||||
}
|
||||
|
||||
model CalendarAccount {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
provider String @db.VarChar
|
||||
providerAccountId String @map("provider_account_id") @db.VarChar
|
||||
displayName String? @map("display_name") @db.VarChar
|
||||
email String? @db.VarChar
|
||||
accessToken String? @map("access_token") @db.Text
|
||||
refreshToken String? @map("refresh_token") @db.Text
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
scope String? @db.Text
|
||||
status String @default("active") @db.VarChar
|
||||
lastError String? @map("last_error") @db.Text
|
||||
refreshIntervalMinutes Int @default(30) @map("refresh_interval_minutes")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subscriptions CalendarSubscription[]
|
||||
|
||||
@@unique([userId, provider, providerAccountId])
|
||||
@@index([userId])
|
||||
@@index([provider, providerAccountId])
|
||||
@@map("calendar_accounts")
|
||||
}
|
||||
|
||||
model CalendarSubscription {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
accountId String @map("account_id") @db.VarChar
|
||||
provider String @db.VarChar
|
||||
externalCalendarId String @map("external_calendar_id") @db.VarChar
|
||||
displayName String? @map("display_name") @db.VarChar
|
||||
timezone String? @db.VarChar
|
||||
color String? @db.VarChar
|
||||
enabled Boolean @default(true)
|
||||
syncToken String? @map("sync_token") @db.Text
|
||||
lastSyncAt DateTime? @map("last_sync_at") @db.Timestamptz(3)
|
||||
customChannelId String? @map("custom_channel_id") @db.VarChar
|
||||
customResourceId String? @map("custom_resource_id") @db.VarChar
|
||||
channelExpiration DateTime? @map("channel_expiration") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
account CalendarAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
workspaceItems WorkspaceCalendarItem[]
|
||||
events CalendarEvent[]
|
||||
|
||||
@@unique([accountId, externalCalendarId])
|
||||
@@index([accountId])
|
||||
@@index([provider, externalCalendarId])
|
||||
@@map("calendar_subscriptions")
|
||||
}
|
||||
|
||||
model WorkspaceCalendar {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
createdByUserId String @map("created_by_user_id") @db.VarChar
|
||||
displayNameOverride String? @map("display_name_override") @db.VarChar
|
||||
colorOverride String? @map("color_override") @db.VarChar
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
items WorkspaceCalendarItem[]
|
||||
|
||||
@@index([workspaceId])
|
||||
@@map("workspace_calendars")
|
||||
}
|
||||
|
||||
model WorkspaceCalendarItem {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceCalendarId String @map("workspace_calendar_id") @db.VarChar
|
||||
subscriptionId String @map("subscription_id") @db.VarChar
|
||||
sortOrder Int? @map("sort_order")
|
||||
colorOverride String? @map("color_override") @db.VarChar
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspaceCalendar WorkspaceCalendar @relation(fields: [workspaceCalendarId], references: [id], onDelete: Cascade)
|
||||
subscription CalendarSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceCalendarId, subscriptionId])
|
||||
@@index([subscriptionId])
|
||||
@@map("workspace_calendar_items")
|
||||
}
|
||||
|
||||
model CalendarEvent {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
subscriptionId String @map("subscription_id") @db.VarChar
|
||||
externalEventId String @map("external_event_id") @db.VarChar
|
||||
recurrenceId String? @map("recurrence_id") @db.VarChar
|
||||
etag String? @db.VarChar
|
||||
status String? @db.VarChar
|
||||
title String? @db.VarChar
|
||||
description String? @db.Text
|
||||
location String? @db.VarChar
|
||||
startAtUtc DateTime @map("start_at_utc") @db.Timestamptz(3)
|
||||
endAtUtc DateTime @map("end_at_utc") @db.Timestamptz(3)
|
||||
originalTimezone String? @map("original_timezone") @db.VarChar
|
||||
allDay Boolean @default(false) @map("all_day")
|
||||
providerUpdatedAt DateTime? @map("provider_updated_at") @db.Timestamptz(3)
|
||||
raw Json @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
subscription CalendarSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
instances CalendarEventInstance[]
|
||||
|
||||
@@unique([subscriptionId, externalEventId, recurrenceId])
|
||||
@@index([subscriptionId, startAtUtc])
|
||||
@@index([subscriptionId, endAtUtc])
|
||||
@@map("calendar_events")
|
||||
}
|
||||
|
||||
model CalendarEventInstance {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
calendarEventId String @map("calendar_event_id") @db.VarChar
|
||||
recurrenceId String @map("recurrence_id") @db.VarChar
|
||||
startAtUtc DateTime @map("start_at_utc") @db.Timestamptz(3)
|
||||
endAtUtc DateTime @map("end_at_utc") @db.Timestamptz(3)
|
||||
originalTimezone String? @map("original_timezone") @db.VarChar
|
||||
allDay Boolean @default(false) @map("all_day")
|
||||
providerUpdatedAt DateTime? @map("provider_updated_at") @db.Timestamptz(3)
|
||||
raw Json @db.JsonB
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
calendarEvent CalendarEvent @relation(fields: [calendarEventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([calendarEventId, recurrenceId])
|
||||
@@index([calendarEventId, startAtUtc])
|
||||
@@map("calendar_event_instances")
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import { VersionModule } from './core/version';
|
||||
import { WorkspaceModule } from './core/workspaces';
|
||||
import { Env } from './env';
|
||||
import { ModelsModule } from './models';
|
||||
import { CalendarModule } from './plugins/calendar';
|
||||
import { CaptchaModule } from './plugins/captcha';
|
||||
import { CopilotModule } from './plugins/copilot';
|
||||
import { CustomerIoModule } from './plugins/customerio';
|
||||
@@ -188,6 +189,7 @@ export function buildAppModule(env: Env) {
|
||||
CopilotModule,
|
||||
CaptchaModule,
|
||||
OAuthModule,
|
||||
CalendarModule,
|
||||
CustomerIoModule,
|
||||
CommentModule,
|
||||
AccessTokenModule,
|
||||
|
||||
@@ -643,6 +643,14 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
'This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.',
|
||||
},
|
||||
|
||||
// Calendar errors
|
||||
calendar_provider_request_error: {
|
||||
type: 'internal_server_error',
|
||||
args: { status: 'number', message: 'string' },
|
||||
message: ({ status, message }) =>
|
||||
`Calendar provider request error, status: ${status}, message: ${message}`,
|
||||
},
|
||||
|
||||
// Copilot errors
|
||||
copilot_session_not_found: {
|
||||
type: 'resource_not_found',
|
||||
|
||||
@@ -656,6 +656,17 @@ export class ManagedByAppStoreOrPlay extends UserFriendlyError {
|
||||
super('action_forbidden', 'managed_by_app_store_or_play', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class CalendarProviderRequestErrorDataType {
|
||||
@Field() status!: number
|
||||
@Field() message!: string
|
||||
}
|
||||
|
||||
export class CalendarProviderRequestError extends UserFriendlyError {
|
||||
constructor(args: CalendarProviderRequestErrorDataType, message?: string | ((args: CalendarProviderRequestErrorDataType) => string)) {
|
||||
super('internal_server_error', 'calendar_provider_request_error', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotSessionNotFound extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
@@ -1196,6 +1207,7 @@ export enum ErrorNames {
|
||||
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
|
||||
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
|
||||
MANAGED_BY_APP_STORE_OR_PLAY,
|
||||
CALENDAR_PROVIDER_REQUEST_ERROR,
|
||||
COPILOT_SESSION_NOT_FOUND,
|
||||
COPILOT_SESSION_INVALID_INPUT,
|
||||
COPILOT_SESSION_DELETED,
|
||||
@@ -1262,5 +1274,5 @@ registerEnumType(ErrorNames, {
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ declare global {
|
||||
https: boolean;
|
||||
host: string;
|
||||
hosts: ConfigItem<string[]>;
|
||||
listenAddr: string;
|
||||
port: number;
|
||||
path: string;
|
||||
name?: string;
|
||||
@@ -58,6 +59,11 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
|
||||
default: [],
|
||||
shape: z.array(z.string()),
|
||||
},
|
||||
listenAddr: {
|
||||
desc: 'The address to listen on (e.g., 0.0.0.0 for IPv4, :: for IPv6).',
|
||||
default: '0.0.0.0',
|
||||
env: 'LISTEN_ADDR',
|
||||
},
|
||||
port: {
|
||||
desc: 'Which port the server will listen on.',
|
||||
default: 3010,
|
||||
|
||||
@@ -100,6 +100,9 @@ test('should throw error when doc service internal error', async t => {
|
||||
mock.method(adapter, 'getDoc', async () => {
|
||||
throw new Error('mock doc service internal error');
|
||||
});
|
||||
mock.method(adapter, 'getDocBinNative', async () => {
|
||||
throw new Error('mock doc service internal error');
|
||||
});
|
||||
let err = await t.throwsAsync(docReader.getDoc(workspace.id, docId), {
|
||||
instanceOf: UserFriendlyError,
|
||||
message: 'An internal error occurred.',
|
||||
|
||||
@@ -213,11 +213,9 @@ export class DatabaseDocReader extends DocReader {
|
||||
guid: string,
|
||||
fullContent?: boolean
|
||||
): Promise<PageDocContent | null> {
|
||||
const docRecord = await this.workspace.getDoc(workspaceId, guid);
|
||||
if (!docRecord) {
|
||||
return null;
|
||||
}
|
||||
return this.parseDocContent(docRecord.bin, fullContent ? -1 : 150);
|
||||
const docBinary = await this.workspace.getDocBinNative(workspaceId, guid);
|
||||
if (!docBinary) return null;
|
||||
return this.parseDocContent(docBinary, fullContent ? -1 : 150);
|
||||
}
|
||||
|
||||
protected override async getWorkspaceContentWithoutCache(
|
||||
|
||||
@@ -13,9 +13,15 @@ import {
|
||||
} from 'yjs';
|
||||
|
||||
import { CallMetric } from '../../../base';
|
||||
import { mergeUpdatesInApplyWay } from '../../../native';
|
||||
import { Connection } from './connection';
|
||||
import { SingletonLocker } from './lock';
|
||||
|
||||
async function nativeMergeUpdates(updates: Uint8Array[]): Promise<Uint8Array> {
|
||||
// use native module to merge updates
|
||||
return mergeUpdatesInApplyWay(updates.map(u => Buffer.from(u)));
|
||||
}
|
||||
|
||||
export interface DocRecord {
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
@@ -95,6 +101,27 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// get final binary only but not updating the snapshot in database
|
||||
async getDocBinNative(
|
||||
spaceId: string,
|
||||
docId: string
|
||||
): Promise<Uint8Array | undefined> {
|
||||
await using _lock = await this.lockDocForUpdate(spaceId, docId);
|
||||
|
||||
const snapshot = await this.getDocSnapshot(spaceId, docId);
|
||||
const updates = await this.getDocUpdates(spaceId, docId);
|
||||
|
||||
if (updates.length) {
|
||||
const docUpdate = await this.squash(
|
||||
snapshot ? [snapshot, ...updates] : updates,
|
||||
nativeMergeUpdates
|
||||
);
|
||||
return docUpdate.bin;
|
||||
}
|
||||
|
||||
return snapshot?.bin;
|
||||
}
|
||||
|
||||
@Transactional<TransactionalAdapterPrisma>({ timeout: 60000 })
|
||||
private async squashUpdatesToSnapshot(
|
||||
spaceId: string,
|
||||
@@ -223,8 +250,11 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
): Promise<boolean>;
|
||||
|
||||
@CallMetric('doc', 'squash')
|
||||
protected async squash(updates: DocUpdate[]): Promise<DocUpdate> {
|
||||
const merge = this.options?.mergeUpdates ?? mergeUpdates;
|
||||
protected async squash(
|
||||
updates: DocUpdate[],
|
||||
merge?: (updates: Uint8Array[]) => Promise<Uint8Array>
|
||||
): Promise<DocUpdate> {
|
||||
const mergeFn = merge ?? this.options?.mergeUpdates ?? mergeUpdates;
|
||||
const lastUpdate = updates.at(-1);
|
||||
if (!lastUpdate) {
|
||||
throw new Error('No updates to be squashed.');
|
||||
@@ -235,7 +265,7 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
return lastUpdate;
|
||||
}
|
||||
|
||||
const finalUpdate = await merge(updates.map(u => u.bin));
|
||||
const finalUpdate = await mergeFn(updates.map(u => u.bin));
|
||||
|
||||
return {
|
||||
bin: finalUpdate,
|
||||
|
||||
172
packages/backend/server/src/models/calendar-account.ts
Normal file
172
packages/backend/server/src/models/calendar-account.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { CalendarAccount, Prisma } from '@prisma/client';
|
||||
|
||||
import { CryptoHelper } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export interface CalendarAccountTokens {
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
expiresAt?: Date | null;
|
||||
scope?: string | null;
|
||||
}
|
||||
|
||||
export interface UpsertCalendarAccountInput extends CalendarAccountTokens {
|
||||
userId: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
status?: string | null;
|
||||
lastError?: string | null;
|
||||
refreshIntervalMinutes?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateCalendarAccountTokensInput extends CalendarAccountTokens {
|
||||
status?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarAccountModel extends BaseModel {
|
||||
constructor(private readonly crypto: CryptoHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
private encryptToken(token?: string | null) {
|
||||
return token ? this.crypto.encrypt(token) : null;
|
||||
}
|
||||
|
||||
private decryptToken(token?: string | null) {
|
||||
return token ? this.crypto.decrypt(token) : null;
|
||||
}
|
||||
|
||||
async listByUser(userId: string) {
|
||||
return await this.db.calendarAccount.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return await this.db.calendarAccount.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async getByProviderAccount(
|
||||
userId: string,
|
||||
provider: string,
|
||||
providerAccountId: string
|
||||
) {
|
||||
return await this.db.calendarAccount.findFirst({
|
||||
where: { userId, provider, providerAccountId },
|
||||
});
|
||||
}
|
||||
|
||||
async upsert(input: UpsertCalendarAccountInput) {
|
||||
const accessToken = this.encryptToken(input.accessToken);
|
||||
const refreshToken = this.encryptToken(input.refreshToken);
|
||||
const data: Prisma.CalendarAccountUncheckedCreateInput = {
|
||||
userId: input.userId,
|
||||
provider: input.provider,
|
||||
providerAccountId: input.providerAccountId,
|
||||
displayName: input.displayName ?? null,
|
||||
email: input.email ?? null,
|
||||
accessToken: accessToken ?? null,
|
||||
refreshToken: refreshToken ?? null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
scope: input.scope ?? null,
|
||||
status: input.status ?? 'active',
|
||||
lastError: input.lastError ?? null,
|
||||
refreshIntervalMinutes: input.refreshIntervalMinutes ?? 60,
|
||||
};
|
||||
|
||||
const updateData: Prisma.CalendarAccountUncheckedUpdateInput = {
|
||||
displayName: data.displayName,
|
||||
email: data.email,
|
||||
expiresAt: data.expiresAt,
|
||||
scope: data.scope,
|
||||
status: data.status,
|
||||
lastError: data.lastError,
|
||||
refreshIntervalMinutes: data.refreshIntervalMinutes,
|
||||
};
|
||||
|
||||
if (!!accessToken) {
|
||||
updateData.accessToken = accessToken;
|
||||
}
|
||||
if (!!refreshToken) {
|
||||
updateData.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
return await this.db.calendarAccount.upsert({
|
||||
where: {
|
||||
userId_provider_providerAccountId: {
|
||||
userId: input.userId,
|
||||
provider: input.provider,
|
||||
providerAccountId: input.providerAccountId,
|
||||
},
|
||||
},
|
||||
create: data,
|
||||
update: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
async updateTokens(id: string, input: UpdateCalendarAccountTokensInput) {
|
||||
const data: Prisma.CalendarAccountUncheckedUpdateInput = {};
|
||||
if (input.accessToken !== undefined) {
|
||||
data.accessToken = this.encryptToken(input.accessToken);
|
||||
}
|
||||
if (input.refreshToken !== undefined) {
|
||||
data.refreshToken = this.encryptToken(input.refreshToken);
|
||||
}
|
||||
if (input.expiresAt !== undefined) {
|
||||
data.expiresAt = input.expiresAt ?? null;
|
||||
}
|
||||
if (input.scope !== undefined) {
|
||||
data.scope = input.scope ?? null;
|
||||
}
|
||||
if (input.status !== undefined) {
|
||||
data.status = input.status ?? undefined;
|
||||
}
|
||||
if (input.lastError !== undefined) {
|
||||
data.lastError = input.lastError ?? null;
|
||||
}
|
||||
|
||||
return await this.db.calendarAccount.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string, lastError?: string | null) {
|
||||
return await this.db.calendarAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
lastError: lastError ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateRefreshInterval(id: string, refreshIntervalMinutes: number) {
|
||||
return await this.db.calendarAccount.update({
|
||||
where: { id },
|
||||
data: { refreshIntervalMinutes },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
return await this.db.calendarAccount.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
decryptTokens(account: CalendarAccount) {
|
||||
return {
|
||||
...account,
|
||||
accessToken: this.decryptToken(account.accessToken),
|
||||
refreshToken: this.decryptToken(account.refreshToken),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarEventInstanceModel extends BaseModel {
|
||||
async deleteByEventIds(eventIds: string[]) {
|
||||
if (eventIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.calendarEventInstance.deleteMany({
|
||||
where: { calendarEventId: { in: eventIds } },
|
||||
});
|
||||
}
|
||||
}
|
||||
119
packages/backend/server/src/models/calendar-event.ts
Normal file
119
packages/backend/server/src/models/calendar-event.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export interface UpsertCalendarEventInput {
|
||||
subscriptionId: string;
|
||||
externalEventId: string;
|
||||
recurrenceId?: string | null;
|
||||
etag?: string | null;
|
||||
status?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
startAtUtc: Date;
|
||||
endAtUtc: Date;
|
||||
originalTimezone?: string | null;
|
||||
allDay: boolean;
|
||||
providerUpdatedAt?: Date | null;
|
||||
raw: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarEventModel extends BaseModel {
|
||||
async upsert(input: UpsertCalendarEventInput) {
|
||||
const recurrenceId = input.recurrenceId ?? input.externalEventId;
|
||||
return await this.db.calendarEvent.upsert({
|
||||
where: {
|
||||
subscriptionId_externalEventId_recurrenceId: {
|
||||
subscriptionId: input.subscriptionId,
|
||||
externalEventId: input.externalEventId,
|
||||
recurrenceId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
subscriptionId: input.subscriptionId,
|
||||
externalEventId: input.externalEventId,
|
||||
recurrenceId,
|
||||
etag: input.etag ?? null,
|
||||
status: input.status ?? null,
|
||||
title: input.title ?? null,
|
||||
description: input.description ?? null,
|
||||
location: input.location ?? null,
|
||||
startAtUtc: input.startAtUtc,
|
||||
endAtUtc: input.endAtUtc,
|
||||
originalTimezone: input.originalTimezone ?? null,
|
||||
allDay: input.allDay,
|
||||
providerUpdatedAt: input.providerUpdatedAt ?? null,
|
||||
raw: input.raw,
|
||||
},
|
||||
update: {
|
||||
etag: input.etag ?? null,
|
||||
status: input.status ?? null,
|
||||
title: input.title ?? null,
|
||||
description: input.description ?? null,
|
||||
location: input.location ?? null,
|
||||
startAtUtc: input.startAtUtc,
|
||||
endAtUtc: input.endAtUtc,
|
||||
originalTimezone: input.originalTimezone ?? null,
|
||||
allDay: input.allDay,
|
||||
providerUpdatedAt: input.providerUpdatedAt ?? null,
|
||||
raw: input.raw,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBySubscription(subscriptionId: string) {
|
||||
return await this.db.calendarEvent.deleteMany({
|
||||
where: { subscriptionId },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBySubscriptionIds(subscriptionIds: string[]) {
|
||||
return await this.db.calendarEvent.deleteMany({
|
||||
where: { subscriptionId: { in: subscriptionIds } },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByIds(ids: string[]) {
|
||||
return await this.db.calendarEvent.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByExternalIds(
|
||||
subscriptionId: string,
|
||||
externalEventIds: string[]
|
||||
) {
|
||||
if (externalEventIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.calendarEvent.deleteMany({
|
||||
where: {
|
||||
subscriptionId,
|
||||
externalEventId: { in: externalEventIds },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listBySubscriptionsInRange(
|
||||
subscriptionIds: string[],
|
||||
from: Date,
|
||||
to: Date
|
||||
) {
|
||||
if (subscriptionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.db.calendarEvent.findMany({
|
||||
where: {
|
||||
subscriptionId: { in: subscriptionIds },
|
||||
startAtUtc: { lt: to },
|
||||
endAtUtc: { gt: from },
|
||||
},
|
||||
orderBy: [{ startAtUtc: 'asc' }, { endAtUtc: 'asc' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
194
packages/backend/server/src/models/calendar-subscription.ts
Normal file
194
packages/backend/server/src/models/calendar-subscription.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { CalendarSubscription, Prisma } from '@prisma/client';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export interface UpsertCalendarSubscriptionInput {
|
||||
accountId: string;
|
||||
provider: string;
|
||||
externalCalendarId: string;
|
||||
displayName?: string | null;
|
||||
timezone?: string | null;
|
||||
color?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCalendarSubscriptionSyncInput {
|
||||
syncToken?: string | null;
|
||||
lastSyncAt?: Date | null;
|
||||
}
|
||||
|
||||
export interface UpdateCalendarSubscriptionChannelInput {
|
||||
customChannelId?: string | null;
|
||||
customResourceId?: string | null;
|
||||
channelExpiration?: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalendarSubscriptionModel extends BaseModel {
|
||||
async listByAccount(accountId: string) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { accountId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listByAccountIds(accountIds: string[]) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { accountId: { in: accountIds } },
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return await this.db.calendarSubscription.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async getByChannelId(customChannelId: string) {
|
||||
return await this.db.calendarSubscription.findFirst({
|
||||
where: { customChannelId },
|
||||
});
|
||||
}
|
||||
|
||||
async upsert(input: UpsertCalendarSubscriptionInput) {
|
||||
const data: Prisma.CalendarSubscriptionUncheckedCreateInput = {
|
||||
accountId: input.accountId,
|
||||
provider: input.provider,
|
||||
externalCalendarId: input.externalCalendarId,
|
||||
displayName: input.displayName ?? null,
|
||||
timezone: input.timezone ?? null,
|
||||
color: input.color ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
};
|
||||
|
||||
return await this.db.calendarSubscription.upsert({
|
||||
where: {
|
||||
accountId_externalCalendarId: {
|
||||
accountId: input.accountId,
|
||||
externalCalendarId: input.externalCalendarId,
|
||||
},
|
||||
},
|
||||
create: data,
|
||||
update: {
|
||||
displayName: data.displayName,
|
||||
timezone: data.timezone,
|
||||
color: data.color,
|
||||
enabled: data.enabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSync(id: string, input: UpdateCalendarSubscriptionSyncInput) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
syncToken: input.syncToken ?? null,
|
||||
lastSyncAt: input.lastSyncAt ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateChannel(
|
||||
id: string,
|
||||
input: UpdateCalendarSubscriptionChannelInput
|
||||
) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
customChannelId: input.customChannelId ?? null,
|
||||
customResourceId: input.customResourceId ?? null,
|
||||
channelExpiration: input.channelExpiration ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateEnabled(id: string, enabled: boolean) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: { enabled },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByAccount(accountId: string) {
|
||||
return await this.db.calendarSubscription.deleteMany({
|
||||
where: { accountId },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByIds(ids: string[]) {
|
||||
return await this.db.calendarSubscription.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
}
|
||||
|
||||
async listActiveByAccount(accountId: string) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { accountId, enabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
async listWithAccount(id: string) {
|
||||
return await this.db.calendarSubscription.findUnique({
|
||||
where: { id },
|
||||
include: { account: true },
|
||||
});
|
||||
}
|
||||
|
||||
async listWithAccounts(ids: string[]) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: { account: true },
|
||||
});
|
||||
}
|
||||
|
||||
async listAccountSubscriptions(
|
||||
accountId: string,
|
||||
subscriptionIds?: string[]
|
||||
) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: {
|
||||
accountId,
|
||||
...(subscriptionIds ? { id: { in: subscriptionIds } } : undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listAllWithAccountForSync() {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { enabled: true },
|
||||
include: { account: true },
|
||||
});
|
||||
}
|
||||
|
||||
async listByAccountForSync(accountId: string) {
|
||||
return await this.db.calendarSubscription.findMany({
|
||||
where: { accountId, enabled: true },
|
||||
include: { account: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateLastSyncAt(id: string, lastSyncAt: Date) {
|
||||
return await this.db.calendarSubscription.update({
|
||||
where: { id },
|
||||
data: { lastSyncAt },
|
||||
});
|
||||
}
|
||||
|
||||
async clearSyncTokensByAccount(accountId: string) {
|
||||
return await this.db.calendarSubscription.updateMany({
|
||||
where: { accountId },
|
||||
data: { syncToken: null },
|
||||
});
|
||||
}
|
||||
|
||||
async updateManyStatus(
|
||||
ids: string[],
|
||||
data: Partial<Pick<CalendarSubscription, 'enabled'>>
|
||||
) {
|
||||
return await this.db.calendarSubscription.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { ModuleRef } from '@nestjs/core';
|
||||
import { ApplyType } from '../base';
|
||||
import { AccessTokenModel } from './access-token';
|
||||
import { BlobModel } from './blob';
|
||||
import { CalendarAccountModel } from './calendar-account';
|
||||
import { CalendarEventModel } from './calendar-event';
|
||||
import { CalendarEventInstanceModel } from './calendar-event-instance';
|
||||
import { CalendarSubscriptionModel } from './calendar-subscription';
|
||||
import { CommentModel } from './comment';
|
||||
import { CommentAttachmentModel } from './comment-attachment';
|
||||
import { AppConfigModel } from './config';
|
||||
@@ -29,6 +33,7 @@ import { UserFeatureModel } from './user-feature';
|
||||
import { UserSettingsModel } from './user-settings';
|
||||
import { VerificationTokenModel } from './verification-token';
|
||||
import { WorkspaceModel } from './workspace';
|
||||
import { WorkspaceCalendarModel } from './workspace-calendar';
|
||||
import { WorkspaceFeatureModel } from './workspace-feature';
|
||||
import { WorkspaceUserModel } from './workspace-user';
|
||||
|
||||
@@ -56,6 +61,11 @@ const MODELS = {
|
||||
commentAttachment: CommentAttachmentModel,
|
||||
blob: BlobModel,
|
||||
accessToken: AccessTokenModel,
|
||||
calendarAccount: CalendarAccountModel,
|
||||
calendarSubscription: CalendarSubscriptionModel,
|
||||
calendarEvent: CalendarEventModel,
|
||||
calendarEventInstance: CalendarEventInstanceModel,
|
||||
workspaceCalendar: WorkspaceCalendarModel,
|
||||
};
|
||||
|
||||
type ModelsType = {
|
||||
@@ -108,6 +118,10 @@ const ModelsSymbolProvider: ExistingProvider = {
|
||||
export class ModelsModule {}
|
||||
|
||||
export * from './blob';
|
||||
export * from './calendar-account';
|
||||
export * from './calendar-event';
|
||||
export * from './calendar-event-instance';
|
||||
export * from './calendar-subscription';
|
||||
export * from './comment';
|
||||
export * from './comment-attachment';
|
||||
export * from './common';
|
||||
@@ -127,5 +141,6 @@ export * from './user-feature';
|
||||
export * from './user-settings';
|
||||
export * from './verification-token';
|
||||
export * from './workspace';
|
||||
export * from './workspace-calendar';
|
||||
export * from './workspace-feature';
|
||||
export * from './workspace-user';
|
||||
|
||||
81
packages/backend/server/src/models/workspace-calendar.ts
Normal file
81
packages/backend/server/src/models/workspace-calendar.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceCalendarModel extends BaseModel {
|
||||
async get(id: string) {
|
||||
return await this.db.workspaceCalendar.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async getByWorkspace(workspaceId: string) {
|
||||
return await this.db.workspaceCalendar.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getDefault(workspaceId: string) {
|
||||
return await this.db.workspaceCalendar.findFirst({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getOrCreateDefault(workspaceId: string, createdByUserId: string) {
|
||||
const existing = await this.getDefault(workspaceId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return await this.db.workspaceCalendar.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
createdByUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateItems(
|
||||
workspaceCalendarId: string,
|
||||
items: Array<{
|
||||
subscriptionId: string;
|
||||
sortOrder?: number | null;
|
||||
colorOverride?: string | null;
|
||||
}>
|
||||
) {
|
||||
await this.db.workspaceCalendarItem.deleteMany({
|
||||
where: { workspaceCalendarId },
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.workspaceCalendarItem.createMany({
|
||||
data: items.map((item, index) => ({
|
||||
workspaceCalendarId,
|
||||
subscriptionId: item.subscriptionId,
|
||||
sortOrder: item.sortOrder ?? index,
|
||||
colorOverride: item.colorOverride ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async listItems(workspaceCalendarId: string) {
|
||||
return await this.db.workspaceCalendarItem.findMany({
|
||||
where: { workspaceCalendarId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async listItemsByWorkspace(workspaceId: string) {
|
||||
return await this.db.workspaceCalendarItem.findMany({
|
||||
where: { workspaceCalendar: { workspaceId } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { subscription: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { CryptoHelper } from '../../../base';
|
||||
import { ConfigModule } from '../../../base/config';
|
||||
import { ServerConfigModule } from '../../../core/config';
|
||||
import type {
|
||||
UpsertCalendarAccountInput,
|
||||
UpsertCalendarSubscriptionInput,
|
||||
} from '../../../models';
|
||||
import { Models } from '../../../models';
|
||||
import { CalendarModule } from '..';
|
||||
import {
|
||||
CalendarProvider,
|
||||
CalendarProviderFactory,
|
||||
CalendarProviderName,
|
||||
CalendarSyncTokenInvalid,
|
||||
} from '../providers';
|
||||
import type {
|
||||
CalendarProviderListEventsParams,
|
||||
CalendarProviderStopParams,
|
||||
CalendarProviderWatchParams,
|
||||
} from '../providers/def';
|
||||
import { CalendarService } from '../service';
|
||||
|
||||
class MockCalendarProvider extends CalendarProvider {
|
||||
override provider = CalendarProviderName.Google;
|
||||
|
||||
override getAuthUrl(_state: string, _redirectUri: string) {
|
||||
return 'https://example.com/oauth';
|
||||
}
|
||||
|
||||
override async exchangeCode(_code: string, _redirectUri: string) {
|
||||
return { accessToken: 'access-token' };
|
||||
}
|
||||
|
||||
override async refreshTokens(_refreshToken: string) {
|
||||
return { accessToken: 'access-token' };
|
||||
}
|
||||
|
||||
override async getAccountProfile(_accessToken: string) {
|
||||
return { providerAccountId: 'mock-account' };
|
||||
}
|
||||
|
||||
override async listCalendars(_accessToken: string) {
|
||||
return [];
|
||||
}
|
||||
|
||||
override async listEvents(_params: CalendarProviderListEventsParams) {
|
||||
return { events: [] };
|
||||
}
|
||||
|
||||
override async watchCalendar(_params: CalendarProviderWatchParams) {
|
||||
return {
|
||||
channelId: 'mock-channel',
|
||||
resourceId: 'mock-resource',
|
||||
};
|
||||
}
|
||||
|
||||
override async stopChannel(_params: CalendarProviderStopParams) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const module = await createModule({
|
||||
imports: [
|
||||
ServerConfigModule,
|
||||
CalendarModule,
|
||||
ConfigModule.override({
|
||||
calendar: {
|
||||
google: {
|
||||
enabled: true,
|
||||
clientId: 'calendar-client-id',
|
||||
clientSecret: 'calendar-client-secret',
|
||||
externalWebhookUrl: 'https://calendar.example.com',
|
||||
webhookVerificationToken: 'calendar-webhook-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const calendarService = module.get(CalendarService);
|
||||
const providerFactory = module.get(CalendarProviderFactory);
|
||||
const models = module.get(Models);
|
||||
module.get(CryptoHelper).onConfigInit();
|
||||
|
||||
const createAccount = async (
|
||||
userId: string,
|
||||
overrides: Partial<UpsertCalendarAccountInput> = {}
|
||||
) => {
|
||||
return await models.calendarAccount.upsert({
|
||||
userId,
|
||||
provider: overrides.provider ?? CalendarProviderName.Google,
|
||||
providerAccountId: overrides.providerAccountId ?? randomUUID(),
|
||||
displayName: overrides.displayName ?? 'Test Account',
|
||||
email: overrides.email ?? 'calendar@example.com',
|
||||
accessToken: overrides.accessToken ?? 'access-token',
|
||||
refreshToken: overrides.refreshToken ?? 'refresh-token',
|
||||
expiresAt: overrides.expiresAt ?? new Date(Date.now() + 5 * 60 * 1000),
|
||||
scope: overrides.scope ?? null,
|
||||
status: overrides.status ?? 'active',
|
||||
lastError: overrides.lastError ?? null,
|
||||
refreshIntervalMinutes: overrides.refreshIntervalMinutes ?? 30,
|
||||
});
|
||||
};
|
||||
|
||||
const createSubscription = async (
|
||||
accountId: string,
|
||||
overrides: Partial<UpsertCalendarSubscriptionInput> & {
|
||||
syncToken?: string | null;
|
||||
customChannelId?: string | null;
|
||||
customResourceId?: string | null;
|
||||
channelExpiration?: Date | null;
|
||||
} = {}
|
||||
) => {
|
||||
const subscription = await models.calendarSubscription.upsert({
|
||||
accountId,
|
||||
provider: overrides.provider ?? CalendarProviderName.Google,
|
||||
externalCalendarId: overrides.externalCalendarId ?? randomUUID(),
|
||||
displayName: overrides.displayName ?? 'Test Calendar',
|
||||
timezone: overrides.timezone ?? 'UTC',
|
||||
color: overrides.color ?? null,
|
||||
enabled: overrides.enabled ?? true,
|
||||
});
|
||||
|
||||
if (overrides.syncToken !== undefined) {
|
||||
await models.calendarSubscription.updateSync(subscription.id, {
|
||||
syncToken: overrides.syncToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
overrides.customChannelId !== undefined ||
|
||||
overrides.customResourceId !== undefined ||
|
||||
overrides.channelExpiration !== undefined
|
||||
) {
|
||||
await models.calendarSubscription.updateChannel(subscription.id, {
|
||||
customChannelId: overrides.customChannelId ?? null,
|
||||
customResourceId: overrides.customResourceId ?? null,
|
||||
channelExpiration: overrides.channelExpiration ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return (await models.calendarSubscription.get(subscription.id))!;
|
||||
};
|
||||
|
||||
test.afterEach.always(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('listAccounts includes calendars count', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const accountA = await createAccount(user.id);
|
||||
const accountB = await createAccount(user.id);
|
||||
|
||||
await createSubscription(accountA.id, {
|
||||
externalCalendarId: randomUUID(),
|
||||
});
|
||||
await createSubscription(accountA.id, {
|
||||
externalCalendarId: randomUUID(),
|
||||
});
|
||||
await createSubscription(accountB.id, {
|
||||
externalCalendarId: randomUUID(),
|
||||
});
|
||||
|
||||
const accounts = await calendarService.listAccounts(user.id);
|
||||
t.is(accounts.length, 2);
|
||||
|
||||
const counts = new Map(
|
||||
accounts.map(account => [account.id, account.calendarsCount])
|
||||
);
|
||||
t.is(counts.get(accountA.id), 2);
|
||||
t.is(counts.get(accountB.id), 1);
|
||||
});
|
||||
|
||||
test('syncSubscription resets invalid sync token and maps events', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id);
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'stale-token',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
|
||||
const cancelledId = randomUUID();
|
||||
const allDayId = randomUUID();
|
||||
|
||||
await models.calendarEvent.upsert({
|
||||
subscriptionId: subscription.id,
|
||||
externalEventId: cancelledId,
|
||||
recurrenceId: null,
|
||||
etag: null,
|
||||
status: 'confirmed',
|
||||
title: 'to cancel',
|
||||
description: null,
|
||||
location: null,
|
||||
startAtUtc: new Date('2024-01-10T05:00:00.000Z'),
|
||||
endAtUtc: new Date('2024-01-10T06:00:00.000Z'),
|
||||
originalTimezone: 'UTC',
|
||||
allDay: false,
|
||||
providerUpdatedAt: null,
|
||||
raw: {},
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
let callCount = 0;
|
||||
const listEventsMock = mock.method(provider, 'listEvents', async (_: any) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new CalendarSyncTokenInvalid('sync token expired');
|
||||
}
|
||||
|
||||
return {
|
||||
events: [
|
||||
{
|
||||
id: cancelledId,
|
||||
status: 'cancelled',
|
||||
start: { dateTime: '2024-01-10T05:00:00.000Z' },
|
||||
end: { dateTime: '2024-01-10T06:00:00.000Z' },
|
||||
raw: {},
|
||||
},
|
||||
{
|
||||
id: allDayId,
|
||||
status: 'confirmed',
|
||||
start: { date: '2024-01-10', timeZone: 'UTC' },
|
||||
end: { date: '2024-01-11', timeZone: 'UTC' },
|
||||
raw: { source: 'test' },
|
||||
},
|
||||
],
|
||||
nextSyncToken: 'next-token',
|
||||
};
|
||||
});
|
||||
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
|
||||
t.is(listEventsMock.mock.callCount(), 2);
|
||||
t.is(listEventsMock.mock.calls[0].arguments[0].syncToken, 'stale-token');
|
||||
t.falsy(listEventsMock.mock.calls[0].arguments[0].timeMin);
|
||||
t.truthy(listEventsMock.mock.calls[1].arguments[0].timeMin);
|
||||
t.truthy(listEventsMock.mock.calls[1].arguments[0].timeMax);
|
||||
|
||||
const updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(updated?.syncToken, 'next-token');
|
||||
t.truthy(updated?.lastSyncAt);
|
||||
|
||||
const events = await models.calendarEvent.listBySubscriptionsInRange(
|
||||
[subscription.id],
|
||||
new Date('2024-01-09T00:00:00.000Z'),
|
||||
new Date('2024-01-12T00:00:00.000Z')
|
||||
);
|
||||
const allDayEvent = events.find(event => event.externalEventId === allDayId);
|
||||
t.truthy(allDayEvent);
|
||||
t.is(allDayEvent?.allDay, true);
|
||||
t.is(allDayEvent?.originalTimezone, 'UTC');
|
||||
t.is(allDayEvent?.startAtUtc.toISOString(), '2024-01-10T00:00:00.000Z');
|
||||
t.is(allDayEvent?.endAtUtc.toISOString(), '2024-01-11T00:00:00.000Z');
|
||||
t.is(
|
||||
events.some(event => event.externalEventId === cancelledId),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('syncSubscription invalidates account on invalid grant', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id);
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'sync-token',
|
||||
});
|
||||
|
||||
await models.calendarEvent.upsert({
|
||||
subscriptionId: subscription.id,
|
||||
externalEventId: randomUUID(),
|
||||
recurrenceId: null,
|
||||
etag: null,
|
||||
status: 'confirmed',
|
||||
title: 'existing',
|
||||
description: null,
|
||||
location: null,
|
||||
startAtUtc: new Date('2024-01-02T00:00:00.000Z'),
|
||||
endAtUtc: new Date('2024-01-02T01:00:00.000Z'),
|
||||
originalTimezone: 'UTC',
|
||||
allDay: false,
|
||||
providerUpdatedAt: null,
|
||||
raw: {},
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
mock.method(provider, 'listEvents', async () => {
|
||||
throw new Error('invalid_grant');
|
||||
});
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
|
||||
const updatedAccount = await models.calendarAccount.get(account.id);
|
||||
t.is(updatedAccount?.status, 'invalid');
|
||||
t.truthy(updatedAccount?.lastError);
|
||||
|
||||
const updatedSubscription = await models.calendarSubscription.get(
|
||||
subscription.id
|
||||
);
|
||||
t.is(updatedSubscription?.syncToken, null);
|
||||
|
||||
const events = await models.calendarEvent.listBySubscriptionsInRange(
|
||||
[subscription.id],
|
||||
new Date('2024-01-01T00:00:00.000Z'),
|
||||
new Date('2024-01-03T00:00:00.000Z')
|
||||
);
|
||||
t.is(events.length, 0);
|
||||
});
|
||||
|
||||
test('syncSubscription renews webhook channel when expiring', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const account = await createAccount(user.id);
|
||||
const subscription = await createSubscription(account.id, {
|
||||
syncToken: 'sync-token',
|
||||
customChannelId: 'old-channel',
|
||||
customResourceId: 'old-resource',
|
||||
channelExpiration: new Date(Date.now() + 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const provider = new MockCalendarProvider();
|
||||
mock.method(provider, 'listEvents', async () => ({
|
||||
events: [],
|
||||
nextSyncToken: 'next-sync',
|
||||
}));
|
||||
|
||||
provider.watchCalendar = async () => ({
|
||||
channelId: 'new-channel',
|
||||
resourceId: 'new-resource',
|
||||
expiration: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
provider.stopChannel = async () => {
|
||||
return;
|
||||
};
|
||||
|
||||
const watchMock = mock.method(
|
||||
provider,
|
||||
'watchCalendar',
|
||||
async (_: CalendarProviderWatchParams) => {
|
||||
return {
|
||||
channelId: 'new-channel',
|
||||
resourceId: 'new-resource',
|
||||
expiration: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
);
|
||||
const stopMock = mock.method(provider, 'stopChannel', async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
mock.method(providerFactory, 'get', () => provider);
|
||||
|
||||
await calendarService.syncSubscription(subscription.id);
|
||||
|
||||
t.is(stopMock.mock.callCount(), 1);
|
||||
t.is(watchMock.mock.callCount(), 1);
|
||||
const watchArgs = watchMock.mock.calls[0].arguments[0];
|
||||
t.is(
|
||||
watchArgs.address,
|
||||
'https://calendar.example.com/api/calendar/webhook/google'
|
||||
);
|
||||
t.is(watchArgs.token, 'calendar-webhook-token');
|
||||
t.is(watchArgs.calendarId, subscription.externalCalendarId);
|
||||
|
||||
const updated = await models.calendarSubscription.get(subscription.id);
|
||||
t.is(updated?.customChannelId, 'new-channel');
|
||||
t.is(updated?.customResourceId, 'new-resource');
|
||||
t.truthy(updated?.channelExpiration);
|
||||
});
|
||||
57
packages/backend/server/src/plugins/calendar/config.ts
Normal file
57
packages/backend/server/src/plugins/calendar/config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig, JSONSchema } from '../../base';
|
||||
|
||||
export interface CalendarGoogleConfig {
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
externalWebhookUrl?: string;
|
||||
webhookVerificationToken?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
calendar: {
|
||||
google: ConfigItem<CalendarGoogleConfig>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const schema: JSONSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
clientId: { type: 'string' },
|
||||
clientSecret: { type: 'string' },
|
||||
externalWebhookUrl: { type: 'string' },
|
||||
webhookVerificationToken: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
defineModuleConfig('calendar', {
|
||||
google: {
|
||||
desc: 'Google Calendar integration config',
|
||||
default: {
|
||||
enabled: false,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
externalWebhookUrl: '',
|
||||
webhookVerificationToken: '',
|
||||
},
|
||||
schema,
|
||||
shape: z.object({
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
externalWebhookUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.regex(/^https:\/\//, 'externalWebhookUrl must be https')
|
||||
.or(z.string().length(0))
|
||||
.optional(),
|
||||
webhookVerificationToken: z.string().optional(),
|
||||
}),
|
||||
link: 'https://developers.google.com/calendar/api/guides/push',
|
||||
},
|
||||
});
|
||||
170
packages/backend/server/src/plugins/calendar/controller.ts
Normal file
170
packages/backend/server/src/plugins/calendar/controller.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
CalendarProviderRequestError,
|
||||
MissingOauthQueryParameter,
|
||||
OauthStateExpired,
|
||||
UnknownOauthProvider,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
import { CalendarOAuthService } from './oauth';
|
||||
import { CalendarProviderName } from './providers';
|
||||
import { CalendarService } from './service';
|
||||
|
||||
@Controller('/api/calendar')
|
||||
export class CalendarController {
|
||||
constructor(
|
||||
private readonly calendar: CalendarService,
|
||||
private readonly oauth: CalendarOAuthService,
|
||||
private readonly url: URLHelper
|
||||
) {}
|
||||
|
||||
@Post('/oauth/preflight')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async preflight(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Body('provider') providerName?: CalendarProviderName,
|
||||
@Body('redirect_uri') redirectUri?: string
|
||||
) {
|
||||
if (!providerName) {
|
||||
throw new MissingOauthQueryParameter({ name: 'provider' });
|
||||
}
|
||||
|
||||
if (!this.calendar.isProviderAvailable(providerName)) {
|
||||
throw new UnknownOauthProvider({ name: providerName });
|
||||
}
|
||||
|
||||
const state = await this.oauth.saveOAuthState({
|
||||
provider: providerName,
|
||||
userId: user.id,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
const callbackUrl = this.calendar.getCallbackUrl();
|
||||
const authUrl = this.calendar.getAuthUrl(providerName, state, callbackUrl);
|
||||
|
||||
return { url: authUrl };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/oauth/callback')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async callbackGet(
|
||||
@Res() res: Response,
|
||||
@Query('code') code?: string,
|
||||
@Query('state') stateStr?: string
|
||||
) {
|
||||
return this.handleCallback(res, code, stateStr);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('/oauth/callback')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async callback(
|
||||
@Res() res: Response,
|
||||
@Body('code') code?: string,
|
||||
@Body('state') stateStr?: string
|
||||
) {
|
||||
return this.handleCallback(res, code, stateStr);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('/webhook/google')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async googleWebhook(@Req() req: Request, @Res() res: Response) {
|
||||
if (!this.calendar.getWebhookAddress('google')) {
|
||||
return res.send();
|
||||
}
|
||||
|
||||
const channelId = req.header('x-goog-channel-id');
|
||||
if (!channelId) {
|
||||
return res.send();
|
||||
}
|
||||
|
||||
const token = req.header('x-goog-channel-token');
|
||||
const expectedToken = this.calendar.getWebhookToken();
|
||||
if (expectedToken && token !== expectedToken) {
|
||||
return res.status(401).send();
|
||||
}
|
||||
|
||||
await this.calendar.handleWebhook(CalendarProviderName.Google, channelId);
|
||||
return res.send();
|
||||
}
|
||||
|
||||
private async handleCallback(
|
||||
res: Response,
|
||||
code?: string,
|
||||
stateStr?: string
|
||||
) {
|
||||
if (!code) {
|
||||
throw new MissingOauthQueryParameter({ name: 'code' });
|
||||
}
|
||||
|
||||
if (!stateStr) {
|
||||
throw new MissingOauthQueryParameter({ name: 'state' });
|
||||
}
|
||||
|
||||
if (typeof stateStr !== 'string' || !this.oauth.isValidState(stateStr)) {
|
||||
throw new MissingOauthQueryParameter({ name: 'state' });
|
||||
}
|
||||
|
||||
const state = await this.oauth.getOAuthState(stateStr);
|
||||
if (!state) {
|
||||
throw new OauthStateExpired();
|
||||
}
|
||||
|
||||
const callbackUrl = this.calendar.getCallbackUrl();
|
||||
try {
|
||||
await this.calendar.handleOAuthCallback({
|
||||
provider: state.provider,
|
||||
code,
|
||||
redirectUri: callbackUrl,
|
||||
userId: state.userId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (state.redirectUri) {
|
||||
const message = this.getCallbackErrorMessage(error);
|
||||
const redirectUrl = this.buildErrorRedirect(state.redirectUri, message);
|
||||
return this.url.safeRedirect(res, redirectUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (state.redirectUri) {
|
||||
return this.url.safeRedirect(res, state.redirectUri);
|
||||
}
|
||||
|
||||
return res.status(200).send({ ok: true });
|
||||
}
|
||||
|
||||
private buildErrorRedirect(redirectUri: string, message: string) {
|
||||
const url = new URL(redirectUri, this.url.requestBaseUrl);
|
||||
url.searchParams.set('error', message);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private getCallbackErrorMessage(error: unknown) {
|
||||
if (error instanceof CalendarProviderRequestError) {
|
||||
if (error.status === 403) {
|
||||
return 'Calendar authorization failed: insufficient permissions. Please reauthorize and allow Calendar access.';
|
||||
}
|
||||
return 'Calendar authorization failed. Please try again.';
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Calendar authorization failed.';
|
||||
}
|
||||
}
|
||||
61
packages/backend/server/src/plugins/calendar/cron.ts
Normal file
61
packages/backend/server/src/plugins/calendar/cron.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { Models } from '../../models';
|
||||
import { CalendarService } from './service';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarCronJobs {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly calendar: CalendarService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async pollAccounts() {
|
||||
const subscriptions =
|
||||
await this.models.calendarSubscription.listAllWithAccountForSync();
|
||||
|
||||
const accountDueAt = new Map<
|
||||
string,
|
||||
{ refreshInterval: number; lastSyncAt: Date | null }
|
||||
>();
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const interval = subscription.account.refreshIntervalMinutes ?? 60;
|
||||
const lastSyncAt = subscription.lastSyncAt ?? null;
|
||||
const existing = accountDueAt.get(subscription.accountId);
|
||||
if (!existing) {
|
||||
accountDueAt.set(subscription.accountId, {
|
||||
refreshInterval: interval,
|
||||
lastSyncAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const earliest =
|
||||
existing.lastSyncAt && lastSyncAt
|
||||
? existing.lastSyncAt < lastSyncAt
|
||||
? existing.lastSyncAt
|
||||
: lastSyncAt
|
||||
: (existing.lastSyncAt ?? lastSyncAt);
|
||||
accountDueAt.set(subscription.accountId, {
|
||||
refreshInterval: interval,
|
||||
lastSyncAt: earliest,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await Promise.allSettled(
|
||||
Array.from(accountDueAt.entries()).map(([accountId, info]) => {
|
||||
if (
|
||||
!info.lastSyncAt ||
|
||||
now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
|
||||
) {
|
||||
return this.calendar.syncAccount(accountId);
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user