Compare commits

..

16 Commits

Author SHA1 Message Date
github-actions[bot]
7c440686ad chore(i18n): sync translations (#14148)
New Crowdin translations by [Crowdin GH
Action](https://github.com/crowdin/github-action)

---------

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: DarkSky <darksky2048@gmail.com>
2026-01-13 22:09:42 +08:00
DarkSky
b331a08744 feat: native update merge (#14250)
#### PR Dependency Tree


* **PR #14250** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Backend Optimization**
  * Faster document retrieval via a native binary fetch path.
* Native-accelerated merging of document updates for improved
performance and consistency.
* **Indexing & Reliability**
* Indexing now only proceeds on valid parse results, with clearer
warnings and richer metadata on failures.
* More consistent sync behavior and enhanced diagnostic logging for
indexing operations.
* **Tests**
  * Expanded tests to cover native binary retrieval error handling.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-13 22:03:55 +08:00
DarkSky
279b7bb64f feat(core): integrate google calendar sync (#14248)
fix #14170 
fix #13893 
fix #13673 
fix #13543 
fix #13308 
fix #7607




#### PR Dependency Tree


* **PR #14247**
  * **PR #14248** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Integrations panel in Account Settings to link/unlink calendar
providers.
  * Collapsible settings wrapper for improved layout.

* **Improvements**
* Calendar system reworked: per-account calendar groups, simplified
toggles with explicit Save, richer event display (multi-dot date
indicators), improved event time/title handling across journal views.

* **Localization**
* Added calendar keys: save-error, no-journal, no-calendar; removed
legacy duplicate-error keys.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-13 02:38:16 +08:00
DarkSky
89f0430242 fix: race conditions (y-crdt/y-octo#51) 2026-01-13 01:10:07 +08:00
DarkSky
0bd8160ed4 feat: init cloud calendar support (#14247)
#### PR Dependency Tree


* **PR #14247** 👈
  * **PR #14248**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Google Calendar integration (disabled by default): link/unlink
accounts, OAuth flow, webhooks, real-time push, background sync,
workspace calendars with customizable items and date-range event
viewing.
* **GraphQL / Client**
* New queries & mutations for accounts, subscriptions, events,
providers, and workspace calendar management.
* **Localization**
* Added localized error message for calendar provider request failures.
* **Tests**
* Backend tests covering sync, webhook renewal, and error/error-recovery
scenarios.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-12 23:17:43 +08:00
DarkSky
a5b60cf679 fix: index calc & detached node handle (y-crdt/y-octo#50) 2026-01-11 18:44:55 +08:00
DarkSky
ca2462f987 feat(native): sync yocto codes (#14243)
#### PR Dependency Tree


* **PR #14243** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Batch management API for coordinated document mutations and change
tracking.
* New document accessors (IDs, state snapshots, change/delete set
queries) and subscriber count.

* **Chores**
  * Upgraded Rust edition across packages to 2024.
  * Repository-wide formatting, stylistic cleanups and test adjustments.

* **Breaking Changes**
* Removed the Node native bindings package and its JS/TS declarations
and tests (no longer published/available).

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-11 06:08:33 +08:00
Adit Syed Afnan
d515d295ce feat(editor): enhance string comparison handling in eval.ts (#14233)
Refactors the compareString function to safely handle null and undefined
inputs and improves overall string comparison logic. This prevents
incorrect sort behavior and ensures consistent ordering when comparing
mixed or missing values, particularly in table view sorting scenarios.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved string comparison used for sorting: empty values are
consistently placed last, numeric parts sort numerically before
non-numeric parts, and mixed-type and case variations are handled more
predictably for stable, consistent ordering across data views.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-09 02:58:00 +00:00
DarkSky
e4dc82ee35 chore: bump deps (#14227)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated backend service dependencies to the latest stable versions for
improved performance and security.
* Upgraded UI component library dependencies to the latest minor
releases.

* **Improvements**
* Enhanced web search functionality for better search results on
standard AI models.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-07 13:15:17 +08:00
renovate[bot]
aa6f26b1a5 chore: bump up opentelemetry (#14208)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@opentelemetry/instrumentation-ioredis](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-ioredis#readme)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-ioredis))
| [`^0.56.0` →
`^0.57.0`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-ioredis/0.56.0/0.57.0)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation-ioredis/0.57.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation-ioredis/0.56.0/0.57.0?slim=true)
|
|
[@opentelemetry/instrumentation-socket.io](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-socket.io#readme)
([source](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/tree/HEAD/packages/instrumentation-socket.io))
| [`0.55.0` →
`0.55.1`](https://renovatebot.com/diffs/npm/@opentelemetry%2finstrumentation-socket.io/0.55.0/0.55.1)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@opentelemetry%2finstrumentation-socket.io/0.55.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@opentelemetry%2finstrumentation-socket.io/0.55.0/0.55.1?slim=true)
|

---

### Release Notes

<details>
<summary>open-telemetry/opentelemetry-js-contrib
(@&#8203;opentelemetry/instrumentation-ioredis)</summary>

###
[`v0.57.0`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-ioredis/CHANGELOG.md#0570-2025-12-17)

[Compare
Source](94e5b7da45...66935ac724)

##### Features

- **instrumentations-ioredis:** support `net.*` and database semconv
migration
([#&#8203;3266](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/issues/3266))
([9f92c8b](9f92c8b5b1))

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
-
[@&#8203;opentelemetry/contrib-test-utils](https://redirect.github.com/opentelemetry/contrib-test-utils)
bumped from ^0.55.0 to ^0.56.0

</details>

<details>
<summary>open-telemetry/opentelemetry-js-contrib
(@&#8203;opentelemetry/instrumentation-socket.io)</summary>

###
[`v0.55.1`](https://redirect.github.com/open-telemetry/opentelemetry-js-contrib/blob/HEAD/packages/instrumentation-socket.io/CHANGELOG.md#0551-2025-12-17)

[Compare
Source](94e5b7da45...66935ac724)

##### Dependencies

- The following workspace dependencies were updated
  - devDependencies
-
[@&#8203;opentelemetry/contrib-test-utils](https://redirect.github.com/opentelemetry/contrib-test-utils)
bumped from ^0.55.0 to ^0.56.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi42OS4xIiwidXBkYXRlZEluVmVyIjoiNDIuNjkuMSIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 11:45:15 +08:00
likljn
c1d43b9b18 fix(editor): keep slash menu alive on text input when no_result (#14141)
**Problem**
Slash menu can be prematurely aborted when the query is still in
`no_result`
due to async query updates after deletion.

**Solution**
Keep the slash menu alive on text input while in `no_result`,
preventing aborts based on a stale query state.

**Repro**
1. Type `/eeee`
2. Delete to `/`
3. Type `h`
4. Slash menu should recover and show results


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced slash-menu keyboard interaction: users can now continue
typing to refine queries when no results are displayed, instead of the
menu closing unexpectedly. Keyboard navigation and other controls remain
responsive.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2026-01-07 02:13:13 +00:00
DarkSky
b8e597fa1d fix: hide search local label if need 2026-01-07 10:42:51 +08:00
Cats Juice
cf98afb32e chore: bump theme@1.1.23 (#14222)
close #13952

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Upgraded the shared theme library from v1.1.16 to v1.1.23 across the
project (core components, UI widgets, content blocks, and frontend
apps), delivering the latest styling and design refinements
platform-wide.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
2026-01-06 20:48:44 +08:00
Yiding Jia
a11e9fe8ca feat(server): add LISTEN_ADDR env var for allowing server to listen on ipv6 (#14211)
The old code hardcoded 0.0.0.0 which means the server only listened for
ipv4 connections, making it not work on ipv6-only networks.

This change adds a LISTEN_ADDR env var which allows the server to bind
to ipv6 as well.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Server listen address is now configurable via the LISTEN_ADDR
environment variable (default: 0.0.0.0), enabling IPv4/IPv6 or
interface-specific binding.
* Configuration schemas and admin UI now expose the listen address
option so deployments can view and override it.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-05 09:31:47 +00:00
DarkSky
f42246aba1 fix: allow method for cors 2026-01-05 13:14:56 +08:00
Whitewater
f5394b7450 fix: refine handling for non-standard keyboards to avoid incorrect keyCode fallback (#14206)
Fix https://github.com/toeverything/AFFiNE/issues/14059

With the help of Claude Opus 4.5

Improve handling of keyCode fallback for non-standard keyboards by only
applying it when modifier keys are pressed. This change prevents
incorrect fallback behavior for non-ASCII characters, ensuring users can
type intended characters without triggering shortcuts.

After


https://github.com/user-attachments/assets/00ab4fb2-4bc2-4ca7-a284-9782686d298c

Event dump for Cyrillic x

```json

{
 "key": "х",
 "keyCode": 219,
 "which": 219,
 "code": "BracketLeft",
 "location": 0,
 "altKey": false,
 "ctrlKey": false,
 "metaKey": false,
 "shiftKey": false,
 "repeat": false
}
```

blocksuite commit
4c0d39890f (diff-68c46455e0eece88312235df85f8ce27ae254efccde6fb987f2505180730bd8c)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Refined keyboard input handling to properly support non-ASCII
characters (e.g., Cyrillic, Greek) by ensuring user-typed characters are
preserved instead of inadvertently triggering keyboard shortcuts. The
fix maintains keyboard shortcut functionality while improving
compatibility with international keyboards and input methods.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-04 09:18:03 +00:00
320 changed files with 9967 additions and 9146 deletions

View File

@@ -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",

View File

@@ -1,6 +1,8 @@
# Editor configuration, see http://editorconfig.org
root = true
[*.rs]
max_line_length = 120
[*]
charset = utf-8
indent_style = space

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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"

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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') {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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",

View File

@@ -8,7 +8,6 @@
".",
"blocksuite/**/*",
"packages/*/*",
"packages/common/y-octo/node",
"packages/frontend/apps/*",
"tools/*",
"docs/reference",

View File

@@ -1,5 +1,5 @@
[package]
edition = "2021"
edition = "2024"
license-file = "LICENSE"
name = "affine_server_native"
version = "1.0.0"

View File

@@ -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)

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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 })
}

View File

@@ -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");

View File

@@ -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)]

View File

@@ -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;

View File

@@ -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"
},

View File

@@ -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")
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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.',

View File

@@ -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(

View File

@@ -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,

View 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),
};
}
}

View File

@@ -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 } },
});
}
}

View 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' }],
});
}
}

View 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,
});
}
}

View File

@@ -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';

View 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 },
});
}
}

View File

@@ -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);
});

View 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',
},
});

View 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.';
}
}

View 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