mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
Compare commits
7 Commits
v0.26.3-be
...
v2026.2.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744c78abbb | ||
|
|
91c5869053 | ||
|
|
6d805b302c | ||
|
|
fb9f49b948 | ||
|
|
ef6717e59a | ||
|
|
ad988dbd1e | ||
|
|
3d01766f55 |
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,6 +3,6 @@ contact_links:
|
||||
- name: Something else?
|
||||
url: https://github.com/toeverything/AFFiNE/discussions
|
||||
about: Feel free to ask and answer questions over in GitHub Discussions
|
||||
- name: AFFiNE Community Support
|
||||
url: https://community.affine.pro
|
||||
- name: AFFiNE Community Support (Discord)
|
||||
url: https://affine.pro/redirect/discord
|
||||
about: AFFiNE Community - a place to ask, learn and engage with others
|
||||
|
||||
20
.github/actionlint.yaml
vendored
Normal file
20
.github/actionlint.yaml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
self-hosted-runner:
|
||||
# Labels of self-hosted runner in array of strings.
|
||||
labels:
|
||||
- win-signer
|
||||
|
||||
# Configuration variables in array of strings defined in your repository or
|
||||
# organization. `null` means disabling configuration variables check.
|
||||
# Empty array means no configuration variable is allowed.
|
||||
config-variables: null
|
||||
|
||||
# Configuration for file paths. The keys are glob patterns to match to file
|
||||
# paths relative to the repository root. The values are the configurations for
|
||||
# the file paths. Note that the path separator is always '/'.
|
||||
# The following configurations are available.
|
||||
#
|
||||
# "ignore" is an array of regular expression patterns. Matched error messages
|
||||
# are ignored. This is similar to the "-ignore" command line option.
|
||||
paths:
|
||||
# .github/workflows/**/*.yml:
|
||||
# ignore: []
|
||||
1
.github/actions/setup-version/action.yml
vendored
1
.github/actions/setup-version/action.yml
vendored
@@ -7,7 +7,6 @@ inputs:
|
||||
ios-app-version:
|
||||
description: 'iOS App Store Version (Optional, use App version if empty)'
|
||||
required: false
|
||||
type: string
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
|
||||
100
.github/workflows/build-test.yml
vendored
100
.github/workflows/build-test.yml
vendored
@@ -68,9 +68,26 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Go (for actionlint)
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11
|
||||
- name: Run actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"$(go env GOPATH)/bin/actionlint"
|
||||
- name: Run oxlint
|
||||
# oxlint is fast, so wrong code will fail quickly
|
||||
run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'].replace('oxlint', 'oxlint@' + require('./package.json').devDependencies.oxlint))")
|
||||
run: |
|
||||
set -euo pipefail
|
||||
oxlint_version="$(node -e "console.log(require('./package.json').devDependencies.oxlint)")"
|
||||
yarn dlx "oxlint@${oxlint_version}" --deny-warnings
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
@@ -108,20 +125,45 @@ jobs:
|
||||
run: |
|
||||
yarn affine bs-docs build
|
||||
git checkout packages/frontend/i18n/src/i18n-completenesses.json
|
||||
git status --porcelain | grep . && {
|
||||
if git status --porcelain | grep -q .; then
|
||||
echo "Run 'yarn typecheck && yarn affine bs-docs build' and make sure all changes are submitted"
|
||||
exit 1
|
||||
} || {
|
||||
else
|
||||
echo "All changes are submitted"
|
||||
}
|
||||
fi
|
||||
|
||||
rust-test-filter:
|
||||
name: Rust test filter
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run-rust: ${{ steps.rust-filter.outputs.rust }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: rust-filter
|
||||
with:
|
||||
filters: |
|
||||
rust:
|
||||
- '**/*.rs'
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
- '.cargo/**'
|
||||
- 'rust-toolchain*'
|
||||
- '.github/actions/build-rust/**'
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
package: 'affine'
|
||||
no-build: 'true'
|
||||
- name: fmt check
|
||||
run: |
|
||||
@@ -159,12 +201,12 @@ jobs:
|
||||
yarn affine i18n build
|
||||
yarn affine server genconfig
|
||||
git checkout packages/frontend/i18n/src/i18n-completenesses.json
|
||||
git status --porcelain | grep . && {
|
||||
if git status --porcelain | grep -q .; then
|
||||
echo "Run 'yarn affine init && yarn affine gql build && yarn affine i18n build && yarn affine server genconfig' and make sure all changes are submitted"
|
||||
exit 1
|
||||
} || {
|
||||
else
|
||||
echo "All changes are submitted"
|
||||
}
|
||||
fi
|
||||
|
||||
check-yarn-binary:
|
||||
name: Check yarn binary
|
||||
@@ -173,7 +215,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run check
|
||||
run: |
|
||||
yarn set version $(node -e "console.log(require('./package.json').packageManager.split('@')[1])")
|
||||
set -euo pipefail
|
||||
yarn_version="$(node -e "console.log(require('./package.json').packageManager.split('@')[1])")"
|
||||
yarn set version "$yarn_version"
|
||||
git diff --exit-code
|
||||
|
||||
e2e-blocksuite-test:
|
||||
@@ -188,6 +232,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: false
|
||||
@@ -215,6 +260,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium,firefox,webkit'
|
||||
electron-install: false
|
||||
@@ -298,6 +344,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-local @affine/web @affine/server
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: false
|
||||
@@ -329,6 +376,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-mobile @affine/mobile
|
||||
playwright-install: true
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
@@ -400,7 +448,7 @@ jobs:
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
run: |
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")"
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
@@ -439,7 +487,7 @@ jobs:
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
run: |
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
@@ -488,7 +536,7 @@ jobs:
|
||||
working-directory: ${{ env.DEV_DRIVE_WORKSPACE }}
|
||||
shell: bash
|
||||
run: |
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
@@ -536,6 +584,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/electron-renderer @affine/nbstore @toeverything/infra
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
- name: Build Electron renderer
|
||||
@@ -617,6 +666,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -697,6 +747,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -713,8 +764,6 @@ jobs:
|
||||
run: yarn affine @affine/server test:coverage "**/*/*elasticsearch.spec.ts" --forbid-only
|
||||
env:
|
||||
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
|
||||
CI_NODE_INDEX: ${{ matrix.node_index }}
|
||||
CI_NODE_TOTAL: ${{ matrix.total_nodes }}
|
||||
|
||||
- name: Upload server test coverage results
|
||||
uses: codecov/codecov-action@v5
|
||||
@@ -761,6 +810,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -787,7 +837,10 @@ jobs:
|
||||
|
||||
miri:
|
||||
name: miri code check
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
RUST_BACKTRACE: full
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -812,7 +865,10 @@ jobs:
|
||||
|
||||
loom:
|
||||
name: loom thread test
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
RUSTFLAGS: --cfg loom
|
||||
RUST_BACKTRACE: full
|
||||
@@ -835,7 +891,10 @@ jobs:
|
||||
|
||||
fuzzing:
|
||||
name: fuzzing
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
@@ -871,7 +930,10 @@ jobs:
|
||||
|
||||
rust-test:
|
||||
name: Run native tests
|
||||
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- rust-test-filter
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
@@ -879,6 +941,7 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
package: 'affine'
|
||||
no-build: 'true'
|
||||
|
||||
@@ -971,6 +1034,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine/server
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -1043,6 +1107,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud-copilot @affine/web @affine/server
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: false
|
||||
@@ -1125,7 +1190,10 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud @affine-test/affine-desktop-cloud @affine/web @affine/server @affine/electron @affine/electron-renderer @affine/nbstore @toeverything/infra
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium'
|
||||
electron-install: ${{ matrix.tests.shard == 'desktop' && 'true' || 'false' }}
|
||||
hard-link-nm: false
|
||||
|
||||
- name: Download server-native.node
|
||||
@@ -1204,7 +1272,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
|
||||
playwright-install: true
|
||||
playwright-install: ${{ matrix.spec.test && 'true' || 'false' }}
|
||||
playwright-platform: 'chromium'
|
||||
hard-link-nm: false
|
||||
enableScripts: false
|
||||
|
||||
@@ -1212,7 +1281,7 @@ jobs:
|
||||
id: filename
|
||||
shell: bash
|
||||
run: |
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
|
||||
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download ${{ steps.filename.outputs.filename }}
|
||||
@@ -1319,6 +1388,7 @@ jobs:
|
||||
- server-test
|
||||
- server-e2e-test
|
||||
- rust-test
|
||||
- rust-test-filter
|
||||
- copilot-test-filter
|
||||
- copilot-api-test
|
||||
- copilot-e2e-test
|
||||
|
||||
10
.github/workflows/release-mobile.yml
vendored
10
.github/workflows/release-mobile.yml
vendored
@@ -128,9 +128,9 @@ jobs:
|
||||
- name: Testflight
|
||||
working-directory: packages/frontend/apps/ios/App
|
||||
run: |
|
||||
echo -n "${{ env.BUILD_PROVISION_PROFILE }}" | base64 --decode -o $PP_PATH
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
printf '%s' "$BUILD_PROVISION_PROFILE" | base64 --decode -o "$PP_PATH"
|
||||
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
cp "$PP_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
fastlane beta
|
||||
env:
|
||||
BUILD_TARGET: distribution
|
||||
@@ -160,7 +160,9 @@ jobs:
|
||||
- name: Load Google Service file
|
||||
env:
|
||||
DATA: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICE_JSON }}
|
||||
run: echo $DATA | base64 -di > packages/frontend/apps/android/App/app/google-services.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "$DATA" | base64 -di > packages/frontend/apps/android/App/app/google-services.json
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
timeout-minutes: 10
|
||||
|
||||
22
README.md
22
README.md
@@ -90,10 +90,10 @@ Thanks for checking us out, we appreciate your interest and sincerely hope that
|
||||
|
||||
## Contributing
|
||||
|
||||
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE Community](https://community.affine.pro) |
|
||||
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
||||
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE's Discord](https://affine.pro/redirect/discord) |
|
||||
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
||||
|
||||
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
||||
|
||||
@@ -101,11 +101,9 @@ Calling all developers, testers, tech writers and more! Contributions of all typ
|
||||
|
||||
For **bug reports**, **feature requests** and other **suggestions** you can also [create a new issue](https://github.com/toeverything/AFFiNE/issues/new/choose) and choose the most appropriate template for your feedback.
|
||||
|
||||
For **translation** and **language support** you can visit our [i18n General Space](https://community.affine.pro/c/i18n-general).
|
||||
For **translation** and **language support** you can visit our [Discord](https://affine.pro/redirect/discord).
|
||||
|
||||
Looking for **other ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide range of support and resources.
|
||||
|
||||
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [AFFiNE Community](https://community.affine.pro) where you can engage with other like-minded individuals.
|
||||
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [Discord](https://affine.pro/redirect/discord) where you can engage with other like-minded individuals.
|
||||
|
||||
## Templates
|
||||
|
||||
@@ -182,20 +180,16 @@ Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiN
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Daffine)
|
||||
|
||||
## Hiring
|
||||
|
||||
Some amazing companies, including AFFiNE, are looking for developers! Are you interested in joining AFFiNE or its partners? Check out our [Discord channel](https://affine.pro/redirect/discord) for some of the latest jobs available.
|
||||
|
||||
## Feature Request
|
||||
|
||||
For feature requests, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
|
||||
For feature requests, please see [discussions](https://github.com/toeverything/AFFiNE/discussions/categories/ideas).
|
||||
|
||||
## Building
|
||||
|
||||
### Codespaces
|
||||
|
||||
From the GitHub repo main page, click the green "Code" button and select "Create codespace on master". This will open a new Codespace with the (supposedly auto-forked
|
||||
AFFiNE repo cloned, built, and ready to go.
|
||||
AFFiNE repo cloned, built, and ready to go).
|
||||
|
||||
### Local
|
||||
|
||||
|
||||
@@ -216,9 +216,13 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
override renderBlock() {
|
||||
const icon = this.model.props.icon$.value;
|
||||
const backgroundColorName = this.model.props.backgroundColorName$.value;
|
||||
const normalizedBackgroundName =
|
||||
backgroundColorName === 'default' || backgroundColorName === ''
|
||||
? 'grey'
|
||||
: backgroundColorName;
|
||||
const backgroundColor = (
|
||||
cssVarV2.block.callout.background as Record<string, string>
|
||||
)[backgroundColorName ?? ''];
|
||||
)[normalizedBackgroundName ?? 'grey'];
|
||||
|
||||
const iconContent = getIcon(icon);
|
||||
|
||||
|
||||
@@ -68,14 +68,14 @@ const backgroundColorAction = {
|
||||
${repeat(colors, color => {
|
||||
const isDefault = color === 'default';
|
||||
const value = isDefault
|
||||
? null
|
||||
? cssVarV2.block.callout.background.grey
|
||||
: `var(--affine-text-highlight-${color})`;
|
||||
const displayName = `${color} Background`;
|
||||
|
||||
return html`
|
||||
<editor-menu-action
|
||||
data-testid="background-${color}"
|
||||
@click=${() => updateBackground(color)}
|
||||
@click=${() => updateBackground(isDefault ? 'grey' : color)}
|
||||
>
|
||||
<affine-text-duotone-icon
|
||||
style=${styleMap({
|
||||
|
||||
@@ -27,6 +27,16 @@ export const codeBlockStyles = css`
|
||||
|
||||
${scrollbarStyle('.affine-code-block-container rich-text')}
|
||||
|
||||
/* In Chromium 121+, non-auto scrollbar-width/color override ::-webkit-scrollbar styles. */
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
.affine-code-block-container rich-text {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')}
|
||||
transparent;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-code-block-container .inline-editor {
|
||||
font-family: var(--affine-font-code-family);
|
||||
font-variant-ligatures: none;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { GroupBy } from '../core/common/types.js';
|
||||
import type { DataSource } from '../core/data-source/base.js';
|
||||
import { DetailSelection } from '../core/detail/selection.js';
|
||||
import type { FilterGroup } from '../core/filter/types.js';
|
||||
import { groupByMatchers } from '../core/group-by/define.js';
|
||||
import { t } from '../core/logical/type-presets.js';
|
||||
import type { DataViewCellLifeCycle } from '../core/property/index.js';
|
||||
@@ -17,7 +18,10 @@ import {
|
||||
pickKanbanGroupColumn,
|
||||
resolveKanbanGroupBy,
|
||||
} from '../view-presets/kanban/group-by-utils.js';
|
||||
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
|
||||
import {
|
||||
KanbanSingleView,
|
||||
materializeKanbanColumns,
|
||||
} from '../view-presets/kanban/kanban-view-manager.js';
|
||||
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
|
||||
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
|
||||
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
|
||||
@@ -270,6 +274,73 @@ describe('kanban', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
const sharedFilter: FilterGroup = {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: {
|
||||
type: 'ref',
|
||||
name: 'status',
|
||||
},
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: 'Done' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sharedTitleProperty = {
|
||||
id: 'title',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Task 1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
it('evaluates filters with hidden columns', () => {
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Done',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: sharedFilter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [sharedTitleProperty] },
|
||||
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
|
||||
} as unknown as KanbanSingleView;
|
||||
|
||||
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when hidden filtered column does not match', () => {
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'In Progress',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: sharedFilter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [sharedTitleProperty] },
|
||||
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
|
||||
} as unknown as KanbanSingleView;
|
||||
|
||||
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag indicator', () => {
|
||||
it('shows drop preview when insert position exists', () => {
|
||||
const controller = createDragController();
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { FilterGroup } from '../core/filter/types.js';
|
||||
import { numberFormats } from '../property-presets/number/utils/formats.js';
|
||||
import {
|
||||
formatNumber,
|
||||
NumberFormatSchema,
|
||||
parseNumber,
|
||||
} from '../property-presets/number/utils/formatter.js';
|
||||
import { DEFAULT_COLUMN_WIDTH } from '../view-presets/table/consts.js';
|
||||
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
|
||||
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
|
||||
import { pcEffects } from '../view-presets/table/pc/effect.js';
|
||||
import type { TableGroup } from '../view-presets/table/pc/group.js';
|
||||
import {
|
||||
materializeTableColumns,
|
||||
TableSingleView,
|
||||
} from '../view-presets/table/table-view-manager.js';
|
||||
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
@@ -41,6 +47,146 @@ describe('TableGroup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('table column materialization', () => {
|
||||
test('appends missing properties while preserving existing order and state', () => {
|
||||
const columns = [
|
||||
{ id: 'status', width: 240, hide: true },
|
||||
{ id: 'title', width: 320 },
|
||||
];
|
||||
|
||||
const next = materializeTableColumns(columns, ['title', 'status', 'date']);
|
||||
|
||||
expect(next).toEqual([
|
||||
{ id: 'status', width: 240, hide: true },
|
||||
{ id: 'title', width: 320 },
|
||||
{ id: 'date', width: DEFAULT_COLUMN_WIDTH },
|
||||
]);
|
||||
});
|
||||
|
||||
test('drops stale columns that no longer exist in data source', () => {
|
||||
const columns = [
|
||||
{ id: 'title', width: 320 },
|
||||
{ id: 'removed', width: 200, hide: true },
|
||||
];
|
||||
|
||||
const next = materializeTableColumns(columns, ['title']);
|
||||
|
||||
expect(next).toEqual([{ id: 'title', width: 320 }]);
|
||||
});
|
||||
|
||||
test('returns original reference when columns are already materialized', () => {
|
||||
const columns = [
|
||||
{ id: 'title', width: 320 },
|
||||
{ id: 'status', width: 240, hide: true },
|
||||
];
|
||||
|
||||
const next = materializeTableColumns(columns, ['title', 'status']);
|
||||
|
||||
expect(next).toBe(columns);
|
||||
});
|
||||
|
||||
test('supports type-aware default width when materializing missing columns', () => {
|
||||
const next = materializeTableColumns([], ['title', 'status'], id =>
|
||||
id === 'title' ? 260 : DEFAULT_COLUMN_WIDTH
|
||||
);
|
||||
|
||||
expect(next).toEqual([
|
||||
{ id: 'title', width: 260 },
|
||||
{ id: 'status', width: DEFAULT_COLUMN_WIDTH },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table filtering', () => {
|
||||
test('evaluates filters with hidden columns', () => {
|
||||
const filter: FilterGroup = {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: {
|
||||
type: 'ref',
|
||||
name: 'status',
|
||||
},
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: 'Done' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const titleProperty = {
|
||||
id: 'title',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Task 1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Done',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: filter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [titleProperty] },
|
||||
propertiesRaw$: { value: [titleProperty, statusProperty] },
|
||||
} as unknown as TableSingleView;
|
||||
|
||||
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when hidden filtered column does not match', () => {
|
||||
const filter: FilterGroup = {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'filter',
|
||||
left: {
|
||||
type: 'ref',
|
||||
name: 'status',
|
||||
},
|
||||
function: 'is',
|
||||
args: [{ type: 'literal', value: 'Done' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const titleProperty = {
|
||||
id: 'title',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'Task 1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
const statusProperty = {
|
||||
id: 'status',
|
||||
cellGetOrCreate: () => ({
|
||||
jsonValue$: {
|
||||
value: 'In Progress',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const view = {
|
||||
filter$: { value: filter },
|
||||
// Simulate status being hidden in current view.
|
||||
properties$: { value: [titleProperty] },
|
||||
propertiesRaw$: { value: [titleProperty, statusProperty] },
|
||||
} as unknown as TableSingleView;
|
||||
|
||||
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number formatter', () => {
|
||||
test('number format menu should expose all schema formats', () => {
|
||||
const menuFormats = numberFormats.map(format => format.type);
|
||||
|
||||
@@ -349,7 +349,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
isShow(rowId: string): boolean {
|
||||
if (this.filter$.value?.conditions.length) {
|
||||
const rowMap = Object.fromEntries(
|
||||
this.properties$.value.map(column => [
|
||||
this.propertiesRaw$.value.map(column => [
|
||||
column.id,
|
||||
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||
])
|
||||
|
||||
@@ -54,7 +54,9 @@ export class DatabaseCellContainer extends SignalWatcher(
|
||||
const selectionView = this.selectionView;
|
||||
if (selectionView) {
|
||||
const selection = selectionView.selection;
|
||||
if (selection && this.isSelected(selection) && editing) {
|
||||
const shouldEnterEditMode =
|
||||
editing && this.cell?.beforeEnterEditMode() !== false;
|
||||
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
|
||||
selectionView.selection = TableViewAreaSelection.create({
|
||||
groupKey: this.groupKey,
|
||||
focus: {
|
||||
|
||||
@@ -57,7 +57,9 @@ export class TableViewCellContainer extends SignalWatcher(
|
||||
const selectionView = this.selectionController;
|
||||
if (selectionView) {
|
||||
const selection = selectionView.selection;
|
||||
if (selection && this.isSelected(selection) && editing) {
|
||||
const shouldEnterEditMode =
|
||||
editing && this.cell?.beforeEnterEditMode() !== false;
|
||||
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
|
||||
selectionView.selection = TableViewAreaSelection.create({
|
||||
groupKey: this.groupKey,
|
||||
focus: {
|
||||
|
||||
@@ -26,6 +26,52 @@ import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js';
|
||||
import type { TableViewData } from './define.js';
|
||||
|
||||
export const materializeColumnsByPropertyIds = (
|
||||
columns: TableColumnData[],
|
||||
propertyIds: string[],
|
||||
getDefaultWidth: (id: string) => number = () => DEFAULT_COLUMN_WIDTH
|
||||
) => {
|
||||
const needShow = new Set(propertyIds);
|
||||
const orderedColumns: TableColumnData[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
if (needShow.has(column.id)) {
|
||||
orderedColumns.push(column);
|
||||
needShow.delete(column.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of needShow) {
|
||||
orderedColumns.push({ id, width: getDefaultWidth(id), hide: undefined });
|
||||
}
|
||||
|
||||
return orderedColumns;
|
||||
};
|
||||
|
||||
export const materializeTableColumns = (
|
||||
columns: TableColumnData[],
|
||||
propertyIds: string[],
|
||||
getDefaultWidth?: (id: string) => number
|
||||
) => {
|
||||
const nextColumns = materializeColumnsByPropertyIds(
|
||||
columns,
|
||||
propertyIds,
|
||||
getDefaultWidth
|
||||
);
|
||||
const unchanged =
|
||||
columns.length === nextColumns.length &&
|
||||
columns.every((column, index) => {
|
||||
const nextColumn = nextColumns[index];
|
||||
return (
|
||||
nextColumn != null &&
|
||||
column.id === nextColumn.id &&
|
||||
column.hide === nextColumn.hide
|
||||
);
|
||||
});
|
||||
|
||||
return unchanged ? columns : nextColumns;
|
||||
};
|
||||
|
||||
export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
propertiesRaw$ = computed(() => {
|
||||
const needShow = new Set(this.dataSource.properties$.value);
|
||||
@@ -220,14 +266,10 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
return this.data$.value?.mode ?? 'table';
|
||||
}
|
||||
|
||||
constructor(viewManager: ViewManager, viewId: string) {
|
||||
super(viewManager, viewId);
|
||||
}
|
||||
|
||||
isShow(rowId: string): boolean {
|
||||
if (this.filter$.value?.conditions.length) {
|
||||
const rowMap = Object.fromEntries(
|
||||
this.properties$.value.map(column => [
|
||||
this.propertiesRaw$.value.map(column => [
|
||||
column.id,
|
||||
column.cellGetOrCreate(rowId).jsonValue$.value,
|
||||
])
|
||||
@@ -290,6 +332,33 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private materializeColumns() {
|
||||
const data = this.data$.value;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextColumns = materializeTableColumns(
|
||||
data.columns,
|
||||
this.dataSource.properties$.value,
|
||||
id => this.propertyGetOrCreate(id).width$.value
|
||||
);
|
||||
if (nextColumns === data.columns) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataUpdate(() => ({ columns: nextColumns }));
|
||||
}
|
||||
|
||||
constructor(viewManager: ViewManager, viewId: string) {
|
||||
super(viewManager, viewId);
|
||||
// Materialize view columns on view activation so newly added properties
|
||||
// can participate in hide/order operations in table.
|
||||
queueMicrotask(() => {
|
||||
this.materializeColumns();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type TableColumnData = TableViewData['columns'][number];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
import { unsafeCSSVarV2 } from '../theme/css-variables';
|
||||
|
||||
/**
|
||||
* You should add a container before the scrollbar style to prevent the style pollution of the whole doc.
|
||||
*/
|
||||
@@ -28,7 +30,7 @@ export const scrollbarStyle = (container: string) => {
|
||||
}
|
||||
${unsafeCSS(container)}::-webkit-scrollbar-thumb {
|
||||
border-radius: 2px;
|
||||
background-color: #b1b1b1;
|
||||
background-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')};
|
||||
}
|
||||
${unsafeCSS(container)}::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
|
||||
@@ -220,9 +220,7 @@ export class ImportDoc extends WithDisposable(LitElement) {
|
||||
</header>
|
||||
<div>
|
||||
AFFiNE will gradually support more file formats for import.
|
||||
<a
|
||||
href="https://community.affine.pro/c/feature-requests/import-export"
|
||||
target="_blank"
|
||||
<a href="https://affine.pro/redirect/discord" target="_blank"
|
||||
>Provide feedback.</a
|
||||
>
|
||||
</div>
|
||||
|
||||
119
blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts
Normal file
119
blocksuite/framework/std/src/__tests__/keymap.unit.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { bindKeymap } from '../event/keymap.js';
|
||||
|
||||
const createKeyboardEvent = (options: {
|
||||
key: string;
|
||||
keyCode: number;
|
||||
altKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: options.key,
|
||||
altKey: options.altKey ?? false,
|
||||
ctrlKey: options.ctrlKey ?? false,
|
||||
metaKey: options.metaKey ?? false,
|
||||
shiftKey: options.shiftKey ?? false,
|
||||
});
|
||||
|
||||
Object.defineProperty(event, 'keyCode', {
|
||||
configurable: true,
|
||||
get: () => options.keyCode,
|
||||
});
|
||||
Object.defineProperty(event, 'which', {
|
||||
configurable: true,
|
||||
get: () => options.keyCode,
|
||||
});
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
const createCtx = (event: KeyboardEvent) => {
|
||||
return {
|
||||
get(name: string) {
|
||||
if (name === 'keyboardState') {
|
||||
return { raw: event };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
describe('bindKeymap', () => {
|
||||
test('falls back to physical key for ctrl shortcuts on non-US layouts', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Ctrl-f': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'а',
|
||||
keyCode: 70,
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(true);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
test('does not fallback for Alt+locale-character letter input', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Alt-s': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'ś',
|
||||
keyCode: 83,
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(false);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps Alt+digit fallback for non-ASCII key outputs', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'Alt-0': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'º',
|
||||
keyCode: 48,
|
||||
altKey: true,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(true);
|
||||
expect(handled).toBe(true);
|
||||
});
|
||||
|
||||
test('does not fallback on non-ASCII input without modifiers', () => {
|
||||
let handled = false;
|
||||
const handler = bindKeymap({
|
||||
'[': () => {
|
||||
handled = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const event = createKeyboardEvent({
|
||||
key: 'х',
|
||||
keyCode: 219,
|
||||
});
|
||||
|
||||
expect(handler(createCtx(event))).toBe(false);
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -90,9 +90,21 @@ export function bindKeymap(
|
||||
// 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 hasModifier =
|
||||
event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
|
||||
const baseName = base[event.keyCode];
|
||||
if (hasModifier && baseName && baseName !== name) {
|
||||
const isSingleAscii = name.length === 1 && name.charCodeAt(0) <= 0x7e;
|
||||
const isAltInputChar = event.altKey && !event.ctrlKey && !isSingleAscii;
|
||||
// Keep supporting existing Alt+digit shortcuts (e.g. Alt-0/1/2 in edgeless)
|
||||
// while preventing Alt-based locale input characters from triggering letter shortcuts.
|
||||
const isDigitBaseKey =
|
||||
baseName != null && baseName.length === 1 && /[0-9]/.test(baseName);
|
||||
if (
|
||||
hasModifier &&
|
||||
baseName &&
|
||||
baseName !== name &&
|
||||
!(isAltInputChar && !isDigitBaseKey)
|
||||
) {
|
||||
const fromCode = map[modifiers(baseName, event)];
|
||||
if (fromCode && fromCode(ctx)) {
|
||||
return true;
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Please visit https://docs.affine.pro/docs/contributing
|
||||
# Please visit https://docs.affine.pro/contributing
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Contributor Add
|
||||
|
||||
- https://allcontributors.org/docs/en/cli/usage
|
||||
- https://allcontributors.org/docs/en/emoji-key
|
||||
|
||||
```shell
|
||||
all-contributors check
|
||||
all-contributors add tzhangchi code,doc
|
||||
all-contributors generate
|
||||
```
|
||||
@@ -26,4 +26,4 @@ You may be able to find additional help and information on our social media plat
|
||||
|
||||
### :earth_asia: Translations
|
||||
|
||||
AFFiNE is internationalized and available in multiple languages. The source content in this repository is written in English. We integrate with an external localization platform to work with the community in localizing the English content. You can find more info on our community page, in our [i18n General Space ](https://community.affine.pro/c/i18n-general).
|
||||
AFFiNE is internationalized and available in multiple languages. The source content in this repository is written in English. We integrate with an external localization platform to work with the community in localizing the English content. You can find more info on our community page, in our [Discord](https://affine.pro/redirect/discord).
|
||||
|
||||
99
packages/backend/server/src/base/cors.ts
Normal file
99
packages/backend/server/src/base/cors.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { URLHelper } from './helpers';
|
||||
|
||||
const DEV_LOOPBACK_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
const DEV_LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
const MOBILE_CLIENT_ORIGINS = new Set([
|
||||
'https://localhost',
|
||||
'capacitor://localhost',
|
||||
'ionic://localhost',
|
||||
]);
|
||||
const DESKTOP_CLIENT_ORIGINS = new Set(['assets://.', 'assets://another-host']);
|
||||
|
||||
export const CORS_ALLOWED_METHODS = [
|
||||
'GET',
|
||||
'HEAD',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'DELETE',
|
||||
'OPTIONS',
|
||||
];
|
||||
|
||||
export const CORS_ALLOWED_HEADERS = [
|
||||
'accept',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'x-affine-version',
|
||||
'x-operation-name',
|
||||
'x-request-id',
|
||||
'x-captcha-token',
|
||||
'x-captcha-challenge',
|
||||
'x-affine-csrf-token',
|
||||
'x-requested-with',
|
||||
'range',
|
||||
];
|
||||
|
||||
export const CORS_EXPOSED_HEADERS = [
|
||||
'content-length',
|
||||
'content-range',
|
||||
'x-request-id',
|
||||
];
|
||||
|
||||
function normalizeHostname(hostname: string) {
|
||||
return hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
||||
}
|
||||
|
||||
function isDevLoopbackOrigin(origin: string) {
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
return (
|
||||
DEV_LOOPBACK_PROTOCOLS.has(parsed.protocol) &&
|
||||
DEV_LOOPBACK_HOSTS.has(normalizeHostname(parsed.hostname))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCorsAllowedOrigins(url: URLHelper) {
|
||||
return new Set<string>([
|
||||
...url.allowedOrigins,
|
||||
...MOBILE_CLIENT_ORIGINS,
|
||||
...DESKTOP_CLIENT_ORIGINS,
|
||||
]);
|
||||
}
|
||||
|
||||
export function isCorsOriginAllowed(
|
||||
origin: string | undefined | null,
|
||||
allowedOrigins: Set<string>
|
||||
) {
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowedOrigins.has(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((env.dev || env.testing) && isDevLoopbackOrigin(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function corsOriginCallback(
|
||||
origin: string | undefined,
|
||||
allowedOrigins: Set<string>,
|
||||
onBlocked: (origin: string) => void,
|
||||
callback: (error: Error | null, allow?: boolean) => void
|
||||
) {
|
||||
if (isCorsOriginAllowed(origin, allowedOrigins)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockedOrigin = origin ?? '<empty>';
|
||||
onBlocked(blockedOrigin);
|
||||
callback(null, false);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
defineModuleConfig,
|
||||
type JSONSchema,
|
||||
} from './config';
|
||||
export * from './cors';
|
||||
export * from './error';
|
||||
export { EventBus, OnEvent } from './event';
|
||||
export {
|
||||
|
||||
@@ -4,7 +4,15 @@ import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
import { Config } from '../config';
|
||||
import {
|
||||
buildCorsAllowedOrigins,
|
||||
CORS_ALLOWED_HEADERS,
|
||||
CORS_ALLOWED_METHODS,
|
||||
corsOriginCallback,
|
||||
} from '../cors';
|
||||
import { AuthenticationRequired } from '../error';
|
||||
import { URLHelper } from '../helpers';
|
||||
import { AFFiNELogger } from '../logger';
|
||||
import { SocketIoRedis } from '../redis';
|
||||
import { WEBSOCKET_OPTIONS } from './options';
|
||||
|
||||
@@ -14,17 +22,34 @@ export class SocketIoAdapter extends IoAdapter {
|
||||
}
|
||||
|
||||
override createIOServer(port: number, options?: any): Server {
|
||||
const logger = this.app.get(AFFiNELogger);
|
||||
const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'] & {
|
||||
canActivate: (socket: Socket) => Promise<boolean>;
|
||||
};
|
||||
const url = this.app.get(URLHelper);
|
||||
const allowedOrigins = buildCorsAllowedOrigins(url);
|
||||
|
||||
const server: Server = super.createIOServer(port, {
|
||||
...config,
|
||||
...options,
|
||||
// Enable CORS for Socket.IO
|
||||
cors: {
|
||||
origin: true, // Allow all origins
|
||||
credentials: true, // Allow credentials (cookies, auth headers)
|
||||
methods: ['GET', 'POST'],
|
||||
origin: (
|
||||
origin: string | undefined,
|
||||
callback: (error: Error | null, allow?: boolean) => void
|
||||
) => {
|
||||
corsOriginCallback(
|
||||
origin,
|
||||
allowedOrigins,
|
||||
blockedOrigin =>
|
||||
logger.warn(
|
||||
`Blocked WebSocket CORS request from origin: ${blockedOrigin}`
|
||||
),
|
||||
callback
|
||||
);
|
||||
},
|
||||
credentials: true,
|
||||
methods: CORS_ALLOWED_METHODS,
|
||||
allowedHeaders: CORS_ALLOWED_HEADERS,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -276,22 +276,16 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.models.history.create(
|
||||
{
|
||||
spaceId: snapshot.spaceId,
|
||||
docId: snapshot.docId,
|
||||
timestamp: snapshot.timestamp,
|
||||
blob: Buffer.from(snapshot.bin),
|
||||
editorId: snapshot.editor,
|
||||
},
|
||||
historyMaxAge
|
||||
);
|
||||
} catch (e) {
|
||||
// safe to ignore
|
||||
// only happens when duplicated history record created in multi processes
|
||||
this.logger.error('Failed to create history record', e);
|
||||
}
|
||||
await this.models.history.create(
|
||||
{
|
||||
spaceId: snapshot.spaceId,
|
||||
docId: snapshot.docId,
|
||||
timestamp: snapshot.timestamp,
|
||||
blob: Buffer.from(snapshot.bin),
|
||||
editorId: snapshot.editor,
|
||||
},
|
||||
historyMaxAge
|
||||
);
|
||||
|
||||
metrics.doc
|
||||
.counter('history_created_counter', {
|
||||
|
||||
@@ -74,6 +74,27 @@ test('should create a history record', async t => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should not fail on duplicated history record', async t => {
|
||||
const snapshot = {
|
||||
spaceId: workspace.id,
|
||||
docId: randomUUID(),
|
||||
blob: Uint8Array.from([1, 2, 3]),
|
||||
timestamp: Date.now(),
|
||||
editorId: user.id,
|
||||
};
|
||||
|
||||
const created1 = await t.context.history.create(snapshot, 1000);
|
||||
const created2 = await t.context.history.create(snapshot, 1000);
|
||||
t.deepEqual(created1.timestamp, snapshot.timestamp);
|
||||
t.deepEqual(created2.timestamp, snapshot.timestamp);
|
||||
|
||||
const histories = await t.context.history.findMany(
|
||||
snapshot.spaceId,
|
||||
snapshot.docId
|
||||
);
|
||||
t.is(histories.length, 1);
|
||||
});
|
||||
|
||||
test('should return null when history timestamp not match', async t => {
|
||||
const snapshot = {
|
||||
spaceId: workspace.id,
|
||||
|
||||
@@ -33,22 +33,33 @@ export class HistoryModel extends BaseModel {
|
||||
* Create a doc history with a max age.
|
||||
*/
|
||||
async create(snapshot: Doc, maxAge: number): Promise<DocHistorySimple> {
|
||||
const row = await this.db.snapshotHistory.create({
|
||||
select: {
|
||||
timestamp: true,
|
||||
createdByUser: { select: publicUserSelect },
|
||||
const timestamp = new Date(snapshot.timestamp);
|
||||
const expiredAt = new Date(Date.now() + maxAge);
|
||||
|
||||
// This method may be called concurrently by multiple processes for the same
|
||||
// (workspaceId, docId, timestamp). Using upsert avoids duplicate key errors
|
||||
// that would otherwise abort the surrounding transaction.
|
||||
const row = await this.db.snapshotHistory.upsert({
|
||||
where: {
|
||||
workspaceId_id_timestamp: {
|
||||
workspaceId: snapshot.spaceId,
|
||||
id: snapshot.docId,
|
||||
timestamp,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
select: { timestamp: true, createdByUser: { select: publicUserSelect } },
|
||||
create: {
|
||||
workspaceId: snapshot.spaceId,
|
||||
id: snapshot.docId,
|
||||
timestamp: new Date(snapshot.timestamp),
|
||||
timestamp,
|
||||
blob: snapshot.blob,
|
||||
createdBy: snapshot.editorId,
|
||||
expiredAt: new Date(Date.now() + maxAge),
|
||||
expiredAt,
|
||||
},
|
||||
update: { expiredAt },
|
||||
});
|
||||
this.logger.debug(
|
||||
`Created history ${row.timestamp} for ${snapshot.docId} in ${snapshot.spaceId}`
|
||||
`Upserted history ${row.timestamp} for ${snapshot.docId} in ${snapshot.spaceId}`
|
||||
);
|
||||
return {
|
||||
timestamp: row.timestamp.getTime(),
|
||||
|
||||
@@ -5,9 +5,14 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||
|
||||
import {
|
||||
AFFiNELogger,
|
||||
buildCorsAllowedOrigins,
|
||||
CacheInterceptor,
|
||||
CloudThrottlerGuard,
|
||||
Config,
|
||||
CORS_ALLOWED_HEADERS,
|
||||
CORS_ALLOWED_METHODS,
|
||||
CORS_EXPOSED_HEADERS,
|
||||
corsOriginCallback,
|
||||
GlobalExceptionFilter,
|
||||
URLHelper,
|
||||
} from './base';
|
||||
@@ -16,12 +21,11 @@ import { AuthGuard } from './core/auth';
|
||||
import { serverTimingAndCache } from './middleware/timing';
|
||||
|
||||
const OneMB = 1024 * 1024;
|
||||
|
||||
export async function run() {
|
||||
const { AppModule } = await import('./app.module');
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
cors: true,
|
||||
cors: false,
|
||||
rawBody: true,
|
||||
bodyParser: true,
|
||||
bufferLogs: true,
|
||||
@@ -32,6 +36,27 @@ export async function run() {
|
||||
const logger = app.get(AFFiNELogger);
|
||||
app.useLogger(logger);
|
||||
const config = app.get(Config);
|
||||
const url = app.get(URLHelper);
|
||||
|
||||
const allowedOrigins = buildCorsAllowedOrigins(url);
|
||||
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
corsOriginCallback(
|
||||
origin,
|
||||
allowedOrigins,
|
||||
blockedOrigin =>
|
||||
logger.warn(`Blocked CORS request from origin: ${blockedOrigin}`),
|
||||
callback
|
||||
);
|
||||
},
|
||||
credentials: true,
|
||||
methods: CORS_ALLOWED_METHODS,
|
||||
allowedHeaders: CORS_ALLOWED_HEADERS,
|
||||
exposedHeaders: CORS_EXPOSED_HEADERS,
|
||||
maxAge: 86400,
|
||||
optionsSuccessStatus: 204,
|
||||
});
|
||||
|
||||
if (config.server.path) {
|
||||
app.setGlobalPrefix(config.server.path);
|
||||
@@ -74,8 +99,6 @@ export async function run() {
|
||||
});
|
||||
}
|
||||
|
||||
const url = app.get(URLHelper);
|
||||
|
||||
await app.listen(config.server.port, config.server.listenAddr);
|
||||
|
||||
const formattedAddr = config.server.listenAddr.includes(':')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cp from 'node:child_process';
|
||||
import { rm, symlink } from 'node:fs/promises';
|
||||
import { readdir, rm, symlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -25,6 +25,144 @@ const fromBuildIdentifier = utils.fromBuildIdentifier;
|
||||
const linuxMimeTypes = [`x-scheme-handler/${productName.toLowerCase()}`];
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
const DEFAULT_ELECTRON_LOCALES_KEEP = new Set([
|
||||
'en',
|
||||
'en_US',
|
||||
'en_GB',
|
||||
'zh_CN',
|
||||
'zh_TW',
|
||||
'fr',
|
||||
'es',
|
||||
'es_419',
|
||||
'pl',
|
||||
'de',
|
||||
'ru',
|
||||
'ja',
|
||||
'it',
|
||||
'ca',
|
||||
'da',
|
||||
'hi',
|
||||
'sv',
|
||||
'ur',
|
||||
'ar',
|
||||
'uk',
|
||||
'ko',
|
||||
'pt_BR',
|
||||
'fa',
|
||||
'nb',
|
||||
]);
|
||||
|
||||
const getElectronLocalesKeep = () => {
|
||||
const raw = process.env.ELECTRON_LOCALES_KEEP?.trim();
|
||||
if (!raw) return DEFAULT_ELECTRON_LOCALES_KEEP;
|
||||
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized === 'all' || normalized === '*') return null;
|
||||
|
||||
const keep = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
// Always keep English as a safe fallback.
|
||||
keep.add('en');
|
||||
keep.add('en_US');
|
||||
keep.add('en_GB');
|
||||
return keep;
|
||||
};
|
||||
|
||||
const getElectronPakLocalesKeep = keep => {
|
||||
const pakKeep = new Set();
|
||||
for (const locale of keep) {
|
||||
if (locale === 'en') {
|
||||
pakKeep.add('en-US');
|
||||
continue;
|
||||
}
|
||||
pakKeep.add(locale.replaceAll('_', '-'));
|
||||
}
|
||||
|
||||
// Always keep English (US) as a safe fallback for Chromium/Electron locales.
|
||||
pakKeep.add('en');
|
||||
pakKeep.add('en-US');
|
||||
pakKeep.add('en-GB');
|
||||
return pakKeep;
|
||||
};
|
||||
|
||||
const trimElectronFrameworkLocales = async (
|
||||
resourcesAppDir,
|
||||
targetPlatform
|
||||
) => {
|
||||
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
|
||||
if (targetPlatform !== 'darwin' && targetPlatform !== 'mas') return;
|
||||
|
||||
const keep = getElectronLocalesKeep();
|
||||
if (!keep) return;
|
||||
|
||||
const contentsDir = path.resolve(resourcesAppDir, '..', '..');
|
||||
const frameworkResourcesDir = path.join(
|
||||
contentsDir,
|
||||
'Frameworks',
|
||||
'Electron Framework.framework',
|
||||
'Versions',
|
||||
'A',
|
||||
'Resources'
|
||||
);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(frameworkResourcesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const localeDirs = entries
|
||||
.filter(entry => entry.isDirectory() && entry.name.endsWith('.lproj'))
|
||||
.map(entry => entry.name);
|
||||
|
||||
await Promise.all(
|
||||
localeDirs.map(async dirName => {
|
||||
const locale = dirName.slice(0, -'.lproj'.length);
|
||||
if (keep.has(locale)) return;
|
||||
await rm(path.join(frameworkResourcesDir, dirName), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const trimElectronPakLocales = async (resourcesAppDir, targetPlatform) => {
|
||||
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
|
||||
if (targetPlatform !== 'win32' && targetPlatform !== 'linux') return;
|
||||
|
||||
const keep = getElectronLocalesKeep();
|
||||
if (!keep) return;
|
||||
|
||||
const rootDir = path.resolve(resourcesAppDir, '..', '..');
|
||||
const localesDir = path.join(rootDir, 'locales');
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(localesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const pakKeep = getElectronPakLocalesKeep(keep);
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter(entry => entry.isFile() && entry.name.endsWith('.pak'))
|
||||
.map(async entry => {
|
||||
const locale = entry.name.slice(0, -'.pak'.length);
|
||||
if (pakKeep.has(locale)) return;
|
||||
await rm(path.join(localesDir, entry.name), { force: true });
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const makers = [
|
||||
!process.env.SKIP_BUNDLE &&
|
||||
platform === 'darwin' && {
|
||||
@@ -204,7 +342,27 @@ export default {
|
||||
},
|
||||
],
|
||||
executableName: productName,
|
||||
ignore: [/\.map$/],
|
||||
ignore: [
|
||||
/\.map$/,
|
||||
/\/test($|\/)/,
|
||||
/\/scripts($|\/)/,
|
||||
/\/examples($|\/)/,
|
||||
/\/docs($|\/)/,
|
||||
/\/README\.md$/,
|
||||
/\/forge\.config\.mjs$/,
|
||||
/\/dev-app-update\.yml$/,
|
||||
/\/resources\/app-update\.yml$/,
|
||||
],
|
||||
afterCopy: [
|
||||
(buildPath, _electronVersion, targetPlatform, _arch, done) => {
|
||||
Promise.all([
|
||||
trimElectronFrameworkLocales(buildPath, targetPlatform),
|
||||
trimElectronPakLocales(buildPath, targetPlatform),
|
||||
])
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
],
|
||||
asar: true,
|
||||
extendInfo: {
|
||||
NSAudioCaptureUsageDescription:
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<project_license>MIT AND LicenseRef-proprietary=https://github.com/toeverything/AFFiNE/blob/canary/LICENSE</project_license>
|
||||
<url type="homepage">https://affine.pro</url>
|
||||
<url type="bugtracker">https://github.com/toeverything/AFFiNE/issues</url>
|
||||
<url type="help">https://docs.affine.pro/docs</url>
|
||||
<url type="help">https://docs.affine.pro/</url>
|
||||
<url type="vcs-browser">https://github.com/toeverything/AFFiNE</url>
|
||||
<url type="contribute">https://docs.affine.pro/docs/contributing</url>
|
||||
<url type="contribute">https://docs.affine.pro/contributing</url>
|
||||
<developer id="pro.affine.app">
|
||||
<name>affine</name>
|
||||
</developer>
|
||||
|
||||
@@ -106,9 +106,17 @@ export async function listLocalWorkspaceIds(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deletedWorkspaceBasePath = await getDeletedWorkspacesBasePath();
|
||||
const deletedWorkspaceIds = new Set<string>(
|
||||
(await fs.readdir(deletedWorkspaceBasePath).catch(() => [])).filter(Boolean)
|
||||
);
|
||||
|
||||
const entries = await fs.readdir(localWorkspaceBasePath);
|
||||
const ids = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
if (deletedWorkspaceIds.has(entry)) {
|
||||
return null;
|
||||
}
|
||||
const workspacePath = path.join(localWorkspaceBasePath, entry);
|
||||
const stat = await fs.stat(workspacePath).catch(() => null);
|
||||
if (!stat?.isDirectory()) {
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('workspace db management', () => {
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const validWorkspaceId = v4();
|
||||
const noDbWorkspaceId = v4();
|
||||
const deletedWorkspaceId = v4();
|
||||
const fileEntry = 'README.txt';
|
||||
|
||||
const validWorkspacePath = path.join(
|
||||
@@ -52,6 +53,17 @@ describe('workspace db management', () => {
|
||||
'local',
|
||||
noDbWorkspaceId
|
||||
);
|
||||
const deletedWorkspacePath = path.join(
|
||||
appDataPath,
|
||||
'workspaces',
|
||||
'local',
|
||||
deletedWorkspaceId
|
||||
);
|
||||
const deletedWorkspaceTrashPath = path.join(
|
||||
appDataPath,
|
||||
'deleted-workspaces',
|
||||
deletedWorkspaceId
|
||||
);
|
||||
const nonDirectoryPath = path.join(
|
||||
appDataPath,
|
||||
'workspaces',
|
||||
@@ -62,11 +74,15 @@ describe('workspace db management', () => {
|
||||
await fs.ensureDir(validWorkspacePath);
|
||||
await fs.ensureFile(path.join(validWorkspacePath, 'storage.db'));
|
||||
await fs.ensureDir(noDbWorkspacePath);
|
||||
await fs.ensureDir(deletedWorkspacePath);
|
||||
await fs.ensureFile(path.join(deletedWorkspacePath, 'storage.db'));
|
||||
await fs.ensureDir(deletedWorkspaceTrashPath);
|
||||
await fs.outputFile(nonDirectoryPath, 'not-a-workspace');
|
||||
|
||||
const ids = await listLocalWorkspaceIds();
|
||||
expect(ids).toContain(validWorkspaceId);
|
||||
expect(ids).not.toContain(noDbWorkspaceId);
|
||||
expect(ids).not.toContain(deletedWorkspaceId);
|
||||
expect(ids).not.toContain(fileEntry);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ImportPage = ({
|
||||
<span>
|
||||
AFFiNE will gradually support more and more file types for import.
|
||||
<a
|
||||
href="https://community.affine.pro/c/feature-requests/import-export"
|
||||
href="https://affine.pro/redirect/discord"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const EditorLoading = ({
|
||||
1: (
|
||||
<a
|
||||
style={{ color: 'var(--affine-primary-color)' }}
|
||||
href="https://community.affine.pro"
|
||||
href="https://affine.pro/redirect/discord"
|
||||
target="__blank"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -40,14 +40,7 @@ interface CodeArtifactToolResult {
|
||||
toolCallId: string;
|
||||
toolName: string; // 'code_artifact'
|
||||
args: { title: string };
|
||||
result:
|
||||
| {
|
||||
title: string;
|
||||
html: string;
|
||||
size: number;
|
||||
}
|
||||
| ToolError
|
||||
| null;
|
||||
result: { title: string; html: string; size: number } | ToolError | null;
|
||||
}
|
||||
|
||||
export class CodeHighlighter extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
|
||||
@@ -142,7 +142,7 @@ export function ContactUS() {
|
||||
1: (
|
||||
<a
|
||||
style={{ color: 'var(--affine-primary-color)' }}
|
||||
href="https://community.affine.pro"
|
||||
href="https://affine.pro/redirect/discord"
|
||||
target="__blank"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -25,7 +25,7 @@ export const property = style({
|
||||
selectors: {
|
||||
'&[data-show="false"]': {
|
||||
backgroundColor: cssVarV2.button.emptyIconBackground,
|
||||
color: cssVarV2.icon.disable,
|
||||
color: cssVarV2.text.secondary,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { Button, notify } from '@affine/component';
|
||||
import {
|
||||
AuthContainer,
|
||||
AuthContent,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { ServersService } from '@affine/core/modules/cloud';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import {
|
||||
@@ -35,12 +36,14 @@ export const AddSelfhostedStep = ({
|
||||
state: SignInState;
|
||||
changeState: Dispatch<SetStateAction<SignInState>>;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const serversService = useService(ServersService);
|
||||
const [baseURL, setBaseURL] = useState(state.initialServerBaseUrl ?? '');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const t = useI18n();
|
||||
const [errorHint, setErrorHint] = useState(
|
||||
t['com.affine.auth.sign.add-selfhosted.error']()
|
||||
);
|
||||
|
||||
const urlValid = useMemo(() => {
|
||||
try {
|
||||
@@ -51,10 +54,14 @@ export const AddSelfhostedStep = ({
|
||||
}
|
||||
}, [baseURL]);
|
||||
|
||||
const onBaseURLChange = useCallback((value: string) => {
|
||||
setBaseURL(value);
|
||||
setError(false);
|
||||
}, []);
|
||||
const onBaseURLChange = useCallback(
|
||||
(value: string) => {
|
||||
setBaseURL(value);
|
||||
setError(false);
|
||||
setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']());
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const onConnect = useAsyncCallback(async () => {
|
||||
setIsConnecting(true);
|
||||
@@ -69,11 +76,33 @@ export const AddSelfhostedStep = ({
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const userFriendlyError = UserFriendlyError.fromAny(err);
|
||||
setError(true);
|
||||
}
|
||||
if (userFriendlyError.is('TOO_MANY_REQUEST')) {
|
||||
setErrorHint(t['error.TOO_MANY_REQUEST']());
|
||||
} else if (
|
||||
userFriendlyError.is('NETWORK_ERROR') ||
|
||||
userFriendlyError.is('REQUEST_ABORTED')
|
||||
) {
|
||||
setErrorHint(t['error.NETWORK_ERROR']());
|
||||
} else {
|
||||
setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']());
|
||||
}
|
||||
|
||||
setIsConnecting(false);
|
||||
}, [baseURL, changeState, serversService]);
|
||||
notify.error({
|
||||
title: t['com.affine.auth.toast.title.failed'](),
|
||||
message:
|
||||
userFriendlyError.is('REQUEST_ABORTED') ||
|
||||
userFriendlyError.is('NETWORK_ERROR')
|
||||
? t['error.NETWORK_ERROR']()
|
||||
: userFriendlyError.is('TOO_MANY_REQUEST')
|
||||
? t['error.TOO_MANY_REQUEST']()
|
||||
: t[`error.${userFriendlyError.name}`](userFriendlyError.data),
|
||||
});
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [baseURL, changeState, serversService, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.initialServerBaseUrl) {
|
||||
@@ -101,7 +130,7 @@ export const AddSelfhostedStep = ({
|
||||
placeholder="https://your-server.com"
|
||||
error={!!error}
|
||||
disabled={isConnecting}
|
||||
errorHint={t['com.affine.auth.sign.add-selfhosted.error']()}
|
||||
errorHint={errorHint}
|
||||
onEnter={onConnect}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@affine/core/modules/cloud';
|
||||
import type { AuthSessionStatus } from '@affine/core/modules/cloud/entities/session';
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { ServerDeploymentType } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
@@ -46,6 +47,7 @@ export const SignInWithPasswordStep = ({
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
const [passwordErrorHint, setPasswordErrorHint] = useState('');
|
||||
const captchaService = useService(CaptchaService);
|
||||
const serverService = useService(ServerService);
|
||||
const isSelfhosted = useLiveData(
|
||||
@@ -74,6 +76,10 @@ export const SignInWithPasswordStep = ({
|
||||
onAuthenticated?.(loginStatus);
|
||||
}, [loginStatus, onAuthenticated, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
}, [t]);
|
||||
|
||||
const onSignIn = useAsyncCallback(async () => {
|
||||
if (isLoading || (!verifyToken && needCaptcha)) return;
|
||||
setIsLoading(true);
|
||||
@@ -88,7 +94,23 @@ export const SignInWithPasswordStep = ({
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setPasswordError(true);
|
||||
const error = UserFriendlyError.fromAny(err);
|
||||
|
||||
if (
|
||||
error.is('WRONG_SIGN_IN_CREDENTIALS') ||
|
||||
error.is('PASSWORD_REQUIRED')
|
||||
) {
|
||||
setPasswordError(true);
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
} else {
|
||||
setPasswordError(false);
|
||||
notify.error({
|
||||
title: t['com.affine.auth.toast.title.failed'](),
|
||||
message: error.is('REQUEST_ABORTED')
|
||||
? t['error.NETWORK_ERROR']()
|
||||
: t[`error.${error.name}`](error.data),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -101,6 +123,7 @@ export const SignInWithPasswordStep = ({
|
||||
email,
|
||||
password,
|
||||
challenge,
|
||||
t,
|
||||
]);
|
||||
|
||||
const sendMagicLink = useCallback(() => {
|
||||
@@ -126,11 +149,15 @@ export const SignInWithPasswordStep = ({
|
||||
label={t['com.affine.auth.password']()}
|
||||
value={password}
|
||||
type="password"
|
||||
onChange={useCallback((value: string) => {
|
||||
onChange={(value: string) => {
|
||||
setPassword(value);
|
||||
}, [])}
|
||||
if (passwordError) {
|
||||
setPasswordError(false);
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
}
|
||||
}}
|
||||
error={passwordError}
|
||||
errorHint={t['com.affine.auth.password.error']()}
|
||||
errorHint={passwordErrorHint}
|
||||
onEnter={onSignIn}
|
||||
/>
|
||||
{!isSelfhosted && (
|
||||
|
||||
@@ -128,7 +128,7 @@ export const AboutAffine = () => {
|
||||
<a
|
||||
className={styles.link}
|
||||
rel="noreferrer"
|
||||
href="https://community.affine.pro"
|
||||
href="https://affine.pro/redirect/discord"
|
||||
target="_blank"
|
||||
>
|
||||
{t['com.affine.aboutAFFiNE.contact.community']()}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
gqlFetcherFactory,
|
||||
type OauthProvidersQuery,
|
||||
@@ -11,6 +12,45 @@ import { Store } from '@toeverything/infra';
|
||||
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
|
||||
OauthProvidersQuery['serverConfig'];
|
||||
|
||||
const NETWORK_ERROR_PATTERNS = [
|
||||
/failed to fetch/i,
|
||||
/network request failed/i,
|
||||
/fetch failed/i,
|
||||
/load failed/i,
|
||||
/networkerror/i,
|
||||
/cors/i,
|
||||
/certificate/i,
|
||||
/ssl/i,
|
||||
/err_[a-z_]+/i,
|
||||
];
|
||||
|
||||
function mapServerConfigError(error: unknown) {
|
||||
const userFriendlyError = UserFriendlyError.fromAny(error);
|
||||
if (
|
||||
userFriendlyError.is('NETWORK_ERROR') ||
|
||||
userFriendlyError.is('REQUEST_ABORTED') ||
|
||||
userFriendlyError.is('TOO_MANY_REQUEST')
|
||||
) {
|
||||
return userFriendlyError;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const detail = `${error.name}: ${error.message}`;
|
||||
if (NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(detail))) {
|
||||
return new UserFriendlyError({
|
||||
status: 504,
|
||||
code: 'NETWORK_ERROR',
|
||||
type: 'NETWORK_ERROR',
|
||||
name: 'NETWORK_ERROR',
|
||||
message: detail,
|
||||
stacktrace: error.stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return userFriendlyError;
|
||||
}
|
||||
|
||||
export class ServerConfigStore extends Store {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -20,19 +60,13 @@ export class ServerConfigStore extends Store {
|
||||
serverBaseUrl: string,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<ServerConfigType> {
|
||||
const gql = gqlFetcherFactory(`${serverBaseUrl}/graphql`, globalThis.fetch);
|
||||
const serverConfigData = await gql({
|
||||
query: serverConfigQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)) {
|
||||
const oauthProvidersData = await gql({
|
||||
query: oauthProvidersQuery,
|
||||
try {
|
||||
const gql = gqlFetcherFactory(
|
||||
`${serverBaseUrl}/graphql`,
|
||||
globalThis.fetch
|
||||
);
|
||||
const serverConfigData = await gql({
|
||||
query: serverConfigQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
@@ -40,11 +74,26 @@ export class ServerConfigStore extends Store {
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...serverConfigData.serverConfig,
|
||||
...oauthProvidersData.serverConfig,
|
||||
};
|
||||
if (
|
||||
serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)
|
||||
) {
|
||||
const oauthProvidersData = await gql({
|
||||
query: oauthProvidersQuery,
|
||||
context: {
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...serverConfigData.serverConfig,
|
||||
...oauthProvidersData.serverConfig,
|
||||
};
|
||||
}
|
||||
return { ...serverConfigData.serverConfig, oauthProviders: [] };
|
||||
} catch (error) {
|
||||
throw mapServerConfigError(error);
|
||||
}
|
||||
return { ...serverConfigData.serverConfig, oauthProviders: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"test": "yarn playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/integration-test": "workspace:*",
|
||||
"@playwright/test": "=1.52.0",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"include": ["e2e"],
|
||||
"references": [
|
||||
{ "path": "../kit" },
|
||||
{ "path": "../../blocksuite/affine/all" },
|
||||
{ "path": "../../blocksuite/integration-test" }
|
||||
]
|
||||
|
||||
@@ -1444,6 +1444,7 @@ export const PackageList = [
|
||||
location: 'tests/blocksuite',
|
||||
name: '@affine-test/blocksuite',
|
||||
workspaceDependencies: [
|
||||
'tests/kit',
|
||||
'blocksuite/affine/all',
|
||||
'blocksuite/integration-test',
|
||||
],
|
||||
|
||||
@@ -84,6 +84,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@affine-test/blocksuite@workspace:tests/blocksuite"
|
||||
dependencies:
|
||||
"@affine-test/kit": "workspace:*"
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
"@blocksuite/integration-test": "workspace:*"
|
||||
"@playwright/test": "npm:=1.52.0"
|
||||
|
||||
Reference in New Issue
Block a user