Compare commits

..

74 Commits

Author SHA1 Message Date
himself65
549dddc65f v0.5.4-canary.15 2023-04-27 23:50:26 -05:00
himself65
9f8b38f9f3 fix(electron): drag window behavior in header 2023-04-27 23:18:00 -05:00
Himself65
3a5a66a5a3 feat: init auth service (#2180) 2023-04-27 22:49:44 -05:00
liuyi
b4bb57b2a5 feat(server): port resolvers to node server (#2026)
Co-authored-by: Himself65 <himself65@outlook.com>
2023-04-27 18:02:05 -05:00
Himself65
3df3498523 chore: bump version (#2178) 2023-04-27 17:59:54 -05:00
himself65
567092a1ff v0.5.4-canary.14 2023-04-27 16:54:12 -05:00
himself65
f3e1c1eb08 docs: update releases.md 2023-04-27 16:53:20 -05:00
himself65
a04cfe2b68 chore: update desktop icons 2023-04-27 16:52:21 -05:00
Himself65
c1a65b6b76 feat(component): init app sidebar (#2135) 2023-04-27 16:46:08 -05:00
JimmFly
f3cbe54625 chore: update menu background color (#2170) 2023-04-27 18:06:17 +00:00
JimmFly
dcf7e83eec chore: update shadow and color (#2171) 2023-04-27 12:57:25 -05:00
JimmFly
50006efb57 chore: update workspace setting button color (#2169) 2023-04-27 12:55:58 -05:00
Himself65
606f6652ac chore: bump version (#2162) 2023-04-27 00:23:34 -05:00
himself65
afff15c435 fix: background warning color syntax 2023-04-26 22:30:33 -05:00
himself65
f7b8797bb2 v0.5.4-canary.13 2023-04-26 19:33:41 -05:00
Whitewater
2b05a1254b chore: hide pinboard (#2149)
Co-authored-by: himself65 <himself65@outlook.com>
2023-04-26 19:31:01 -05:00
himself65
40e7074475 fix(component): remove css import from blocksuite 2023-04-26 02:29:34 -05:00
himself65
e1ad3e38b9 v0.5.4-canary.12 2023-04-26 01:55:32 -05:00
himself65
f03fdde770 chore(electron): update canary icons 2023-04-26 01:55:05 -05:00
Himself65
d2eba54550 chore: bump version (#2146) 2023-04-26 01:54:44 -05:00
himself65
fa7baaf5c1 docs: add the ecosystem section in README.md 2023-04-25 19:22:47 -05:00
himself65
a4d8b65eef v0.5.4-canary.11 2023-04-25 19:00:03 -05:00
himself65
83dafa149c build: add set-version.sh 2023-04-25 18:59:37 -05:00
himself65
3a25f13734 docs: download page redirect to affine.pro 2023-04-25 18:48:39 -05:00
Himself65
db52c63d25 feat: init @toeverything/theme (#2136) 2023-04-25 18:44:17 -05:00
himself65
80f4578f76 v0.5.4-canary.10 2023-04-25 11:44:23 -05:00
JimmFly
15a7e93058 fix: text overflow problem in <a> tag (#2126) 2023-04-25 11:40:14 -05:00
JimmFly
1c41731b4e fix: theme color (#2124) 2023-04-25 11:37:22 -05:00
Himself65
a807647639 fix(component): editor component style (#2120) 2023-04-25 01:58:30 -05:00
JimmFly
3f1293ca3c chore: add changeLog to storybook (#2118)
Co-authored-by: himself65 <himself65@outlook.com>
2023-04-25 06:24:46 +00:00
Himself65
ad58b4d1e9 feat: improve build config (#2115) 2023-04-24 22:33:09 -05:00
Himself65
7e61708850 test: move playwright test suite to top level (#2113) 2023-04-24 22:12:48 -05:00
LongYinan
5c673a8ffc feat(graphql): generate types from graphql files (#2014)
Co-authored-by: forehalo <forehalo@gmail.com>
Co-authored-by: Himself65 <himself65@outlook.com>
2023-04-25 10:13:52 +08:00
himself65
4528df07a5 v0.5.4-canary.9 2023-04-24 19:59:21 -05:00
himself65
b6eb017bd4 docs: add linux badge 2023-04-24 19:55:05 -05:00
Himself65
9d3b9e9848 chore: bump version (#2111) 2023-04-24 19:46:46 -05:00
himself65
04fc619f52 test: fix flaky 2023-04-24 19:33:35 -05:00
himself65
06ef6da370 ci: remove unused 2023-04-24 19:26:30 -05:00
Himself65
d3ce90e721 test: add electron test (#1840) 2023-04-24 18:53:36 -05:00
himself65
9c94d05dd8 docs: format jobs.md 2023-04-24 17:47:41 -05:00
Himself65
ef8dea8cb2 test: fix flaky in customElements (#2109) 2023-04-24 13:18:37 -05:00
Peng Xiao
c27c241482 fix: some improvements to electron app (#2089) 2023-04-24 12:53:21 -05:00
Flrande
b73e9189ef chore: fix color (#2083)
Co-authored-by: Himself65 <himself65@outlook.com>
2023-04-24 11:49:34 -05:00
JimmFly
c95b8e9d71 fix: incorrect text color (#2107) 2023-04-24 11:49:22 -05:00
Peng Xiao
ab8669882a fix: closing modal sometimes covered by header (#2097) 2023-04-23 23:43:40 -05:00
himself65
7ff12a6d0f build: reduce the sample rate to 0.1 2023-04-23 23:40:59 -05:00
himself65
339b133e3f v0.5.4-canary.8 2023-04-23 21:41:43 -05:00
Peng Xiao
be9095ec19 build: fix electron build gain focus on reloading in dev (#2088) 2023-04-23 01:42:52 -05:00
Himself65
33261558f6 chore: bump version (#2087) 2023-04-23 01:42:27 -05:00
Himself65
2ad1b770d0 fix(y-indexeddb): alert user when write operation unfinished (#2085) 2023-04-22 17:32:57 -05:00
Himself65
74e21311dc refactor(y-indexeddb): move migrate function separate (#2086) 2023-04-22 17:25:25 -05:00
Chi Zhang
bf83bfcf63 feat: add short cuts for sidebar (#2075) 2023-04-22 17:24:44 -05:00
Chi Zhang
70d8f9a0a7 feat: add shared page empty tip (#2077)
Co-authored-by: himself65 <himself65@outlook.com>
2023-04-22 17:24:18 -05:00
Moeyua Evod
7d246f87e7 docs: sign CLA (#2079) 2023-04-22 00:05:13 -05:00
Himself65
1ca9fb8ff4 fix(workspace): check affine login auth (#2070) 2023-04-21 20:44:29 -05:00
Moeyua Evod
2c95a0a757 feat: center align button text (#2056)
Co-authored-by: Himself65 <himself65@outlook.com>
2023-04-21 19:45:23 -05:00
himself65
a49d5ea1e2 fix(workspace): load first workspace in index page 2023-04-21 13:46:01 -05:00
三咲智子 Kevin Deng
84e2710e87 docs: fix typo (#2063) 2023-04-21 12:07:44 -05:00
Peng Xiao
044e6da00d build: beta build (#2069) 2023-04-21 11:52:55 -05:00
himself65
023cbc30ea fix(workspace): cloud workspace blob uploading 2023-04-21 11:34:18 -05:00
Peng Xiao
7094385d8b fix: try to sign macos (#2066) 2023-04-21 23:30:49 +08:00
himself65
f66d402cf7 v0.5.4-beta.0 2023-04-21 06:09:38 -05:00
Peng Xiao
971e256cd3 fix: osxSign in build 2023-04-21 18:25:46 +08:00
Peng Xiao
88a297c3c1 chore: bump version 0.5.4-canary.7 2023-04-21 18:10:12 +08:00
Peng Xiao
4bb50e8c25 feat: store local data to local db (#2037) 2023-04-21 18:06:54 +08:00
zuomeng wang
acc5afdd4f fix(web): remove edgeless mode padding (#2061) 2023-04-21 17:56:29 +08:00
Qi
9ec6768272 fix: modify with new blocksuite version about subpage (#2060) 2023-04-21 08:34:32 +00:00
Peng Xiao
5a124831b8 fix: some minor ui issues (#2058) 2023-04-21 00:56:42 -05:00
Flrande
01115f8957 fix: color variable (#2059) 2023-04-20 23:41:43 -05:00
Qi
a5a6203a95 feat: replace react-dnd to dnd-kit (#2028)
Co-authored-by: Himself65 <himself65@outlook.com>
2023-04-20 23:27:32 -05:00
himself65
4a473f5518 Revert "chore: bump version"
This reverts commit 44011b4695.
2023-04-20 22:53:32 -05:00
himself65
6cddacb953 Revert "fix: api compatibility with blocksuite"
This reverts commit 00f44c72ce.
2023-04-20 22:53:32 -05:00
himself65
00f44c72ce fix: api compatibility with blocksuite 2023-04-20 22:29:11 -05:00
himself65
44011b4695 chore: bump version 2023-04-20 21:58:09 -05:00
287 changed files with 9219 additions and 4918 deletions

View File

@@ -12,6 +12,7 @@
"component",
"workspace",
"env",
"graphql",
"cli",
"hooks",
"i18n",
@@ -19,7 +20,8 @@
"octobase-node",
"templates",
"y-indexeddb",
"debug"
"debug",
"theme"
]
]
}

View File

@@ -75,6 +75,12 @@ const config = {
'@typescript-eslint/consistent-type-imports': 0,
},
},
{
files: '*.cjs',
rules: {
'@typescript-eslint/no-var-requires': 0,
},
},
],
};

2
.github/CLA.md vendored
View File

@@ -56,3 +56,5 @@ Example:
- Skye Sun, @skyesun, 2023/04/14
- Jordy Delgado, @Jdelgad8, 2023/04/17
- Howard Do, @howarddo2208, 2023/04/20
- Kevin Deng, @sxzz, 2023/04/21
- Moeyua, @moeyua, 2023/04/22

2
.github/labeler.yml vendored
View File

@@ -15,6 +15,8 @@ mod:dev:
mod:workspace: 'packages/workspace/**/*'
mod:theme: 'packages/theme/**/*'
mod:i18n: 'packages/i18n/**/*'
mod:env: 'packages/env/**/*'

View File

@@ -1,354 +0,0 @@
name: Build & Test
on:
push:
branches: [master]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- run: yarn lint --max-warnings=0
build-storybook:
name: Build Storybook
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- run: yarn build:storybook
- name: Upload storybook artifact
uses: actions/upload-artifact@v3
with:
name: storybook
path: ./packages/component/storybook-static
if-no-files-found: error
build-frontend:
name: Build @affine/web
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Cache Next.js
uses: actions/cache@v3
with:
path: |
${{ github.workspace }}/apps/web/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-
- name: Build
run: yarn build
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
- name: Export
run: yarn export
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: next-js
path: ./apps/web/out
if-no-files-found: error
publish-frontend:
name: Push frontend image
runs-on: ubuntu-latest
needs: build-frontend
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: 'toeverything/affine-pathfinder'
IMAGE_TAG: canary-${{ github.sha }}
IMAGE_TAG_LATEST: nightly-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: next-js
path: ./apps/web/out
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
${{ env.IMAGE_TAG }}
${{ env.IMAGE_TAG_LATEST }}
- name: Build Docker image
uses: docker/build-push-action@v3
with:
context: .
push: true
file: ./.github/deployment/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-frontend-dev:
name: Build @affine/web dev
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Cache Next.js
uses: actions/cache@v3
with:
path: |
${{ github.workspace }}/apps/web/.next/cache
key: ${{ runner.os }}-nextjs-dev-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-dev-${{ hashFiles('**/yarn.lock') }}-
- name: Build
run: yarn build
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
API_SERVER_PROFILE: local
ENABLE_DEBUG_PAGE: true
COVERAGE: true
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: next-js-dev
path: ./apps/web/.next
if-no-files-found: error
storybook-test:
name: Storybook Test
runs-on: ubuntu-latest
environment: development
needs: [build-storybook]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
- name: Download storybook artifact
uses: actions/download-artifact@v3
with:
name: storybook
path: ./packages/component/storybook-static
- name: Run storybook tests
working-directory: ./packages/component
run: |
yarn exec concurrently -k -s first -n "SB,TEST" -c "magenta,blue" "yarn exec serve ./storybook-static -l 6006" "yarn exec wait-on tcp:6006 && yarn test-storybook --coverage"
- name: Upload storybook test coverage results
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/component/coverage/storybook/coverage-storybook.json
flags: storybook-test
name: affine
fail_ci_if_error: true
server-test:
name: Server Test
runs-on: ubuntu-latest
environment: development
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: affine
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Initialize database
run: |
psql -h localhost -U postgres -c "CREATE DATABASE affine;"
psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';"
psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;"
env:
PGPASSWORD: affine
- name: Generate prisma client
run: |
yarn exec prisma generate
yarn exec prisma db push
working-directory: apps/server
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run init-db script
run: yarn exec ts-node-esm ./scripts/init-db.ts
working-directory: apps/server
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run server tests
run: yarn test:coverage
working-directory: apps/server
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Upload server test coverage results
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./apps/server/.coverage/lcov.info
flags: server-test
name: affine
fail_ci_if_error: true
e2e-test:
name: E2E Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
environment: development
needs: [build-frontend-dev, build-storybook]
services:
octobase:
image: ghcr.io/toeverything/cloud-self-hosted:nightly-latest
ports:
- 3000:3000
env:
SIGN_KEY: 'test123'
RUST_LOG: 'debug'
JWST_DEV: '1'
credentials:
username: ${{ github.actor }}
password: ${{ secrets.ACTIONS_PACKAGE_PUBLISH }}
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: next-js-dev
path: ./apps/web/.next
- name: Download storybook artifact
uses: actions/download-artifact@v3
with:
name: storybook
path: ./packages/component/storybook-static
- name: Wait for Octobase Ready
run: |
node ./scripts/wait-3000-healthz.mjs
- name: Run playwright tests
run: yarn test --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
env:
COVERAGE: true
- name: Collect code coverage report
run: yarn exec nyc report -t .nyc_output --report-dir .coverage --reporter=lcov
- name: Upload e2e test coverage results
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./.coverage/lcov.info
flags: e2etest
name: affine
fail_ci_if_error: true
- name: Upload test results
if: ${{ failure() }}
uses: actions/upload-artifact@v2
with:
name: test-results-e2e-${{ matrix.shard }}
path: ./test-results
if-no-files-found: ignore
unit-test:
name: Unit Test
runs-on: ubuntu-latest
environment: development
needs: build-frontend
services:
octobase:
image: ghcr.io/toeverything/cloud-self-hosted:nightly-latest
ports:
- 3000:3000
env:
SIGN_KEY: 'test123'
RUST_LOG: 'debug'
JWST_DEV: '1'
credentials:
username: ${{ github.actor }}
password: ${{ secrets.ACTIONS_PACKAGE_PUBLISH }}
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: next-js
path: ./apps/web/.next
- name: Unit Test
run: yarn run test:unit:coverage
- name: Upload unit test coverage results
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./.coverage/store/lcov.info
flags: unittest
name: affine
fail_ci_if_error: true

View File

@@ -1,105 +0,0 @@
name: Build Test Version
on:
workflow_dispatch:
inputs:
tag:
description: 'Custom Tag. Set nightly-latest will publish to development.'
required: true
type: string
# Cancels all previous workflow runs for pull requests that have not completed.
# See https://docs.github.com/en/actions/using-jobs/using-concurrency
concurrency:
# The concurrency group contains the workflow name and the branch name for
# pull requests or the commit hash for any other events.
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true
jobs:
build:
name: Lint and Build
runs-on: self-hosted
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Lint
run: |
yarn lint --max-warnings=0
# - name: Test
# run: yarn test
- name: Build
run: yarn build
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
- name: Export
run: yarn export
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
path: ./apps/web/out
push_to_registry:
# See https://docs.github.com/en/actions/publishing-packages/publishing-docker-images
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: 'toeverything/affine-pathfinder-testing'
IMAGE_TAG: canary-${{ github.sha }}
IMAGE_TAG_LATEST: nightly-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: artifact
path: apps/web/out/
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
${{ env.IMAGE_TAG }}
${{ inputs.tag }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: true
file: ./.github/deployment/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,6 +1,9 @@
name: Build & Test
on:
push:
branches:
- master
pull_request:
branches:
- master
@@ -17,16 +20,6 @@ jobs:
uses: ./.github/actions/setup-node
- run: yarn lint --max-warnings=0
install-all:
name: Install All Dependencies
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Install All Dependencies
uses: ./.github/actions/setup-node
build-storybook:
name: Build Storybook
runs-on: ubuntu-latest
@@ -44,6 +37,23 @@ jobs:
path: ./packages/component/storybook-static
if-no-files-found: error
build-electron:
name: Build @affine/electron
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Electron
working-directory: apps/electron
run: yarn exec ts-node-esm ./scripts/build-ci.mts
- name: Upload Ubuntu desktop artifact
uses: actions/upload-artifact@v3
with:
name: affine-ubuntu
path: ./apps/electron/dist
build:
name: Build @affine/web
runs-on: ubuntu-latest
@@ -73,6 +83,7 @@ jobs:
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
API_SERVER_PROFILE: local
ENABLE_DEBUG_PAGE: true
ENABLE_LEGACY_PROVIDER: true
COVERAGE: true
- name: Upload artifact
@@ -82,6 +93,32 @@ jobs:
path: ./apps/web/.next
if-no-files-found: error
- name: Build @affine/web for desktop
run: yarn build
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
API_SERVER_PROFILE: affine
ENABLE_DEBUG_PAGE: true
ENABLE_LEGACY_PROVIDER: false
COVERAGE: true
- name: Export static resources
run: yarn export
working-directory: apps/web
- name: Upload static resources artifact
uses: actions/upload-artifact@v3
with:
name: next-js-static
path: ./apps/web/out
if-no-files-found: error
server-test:
name: Server Test
runs-on: ubuntu-latest
@@ -230,6 +267,41 @@ jobs:
path: ./test-results
if-no-files-found: ignore
dekstop-test:
name: Desktop Test
runs-on: ubuntu-latest
environment: development
needs: [build, build-electron]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
- name: Download Ubuntu desktop artifact
uses: actions/download-artifact@v3
with:
name: affine-ubuntu
path: ./apps/electron/dist
- name: Download static resource artifact
uses: actions/download-artifact@v3
with:
name: next-js-static
path: ./apps/electron/resources/web-static
- name: Run desktop tests
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn test
working-directory: apps/electron
- name: Upload test results
if: ${{ failure() }}
uses: actions/upload-artifact@v2
with:
name: test-results-e2e-${{ matrix.shard }}
path: ./test-results
if-no-files-found: ignore
unit-test:
name: Unit Test
runs-on: ubuntu-latest

View File

@@ -17,11 +17,11 @@ on:
type: boolean
required: true
default: true
is-canary:
description: 'Canary Release? The app will be named as "AFFiNE Canary"'
type: boolean
build-type:
description: 'Build Type (canary, beta or stable)'
type: string
required: true
default: true
default: canary
permissions:
actions: write
@@ -35,7 +35,7 @@ concurrency:
cancel-in-progress: true
env:
BUILD_TYPE: ${{ github.event.inputs.is-canary == 'true' && 'canary' || 'stable' }}
BUILD_TYPE: ${{ github.event.inputs.build-type }}
jobs:
before-make:

View File

@@ -1,2 +1 @@
pnpm-lock.yaml
apps/electron/layers/preload/preload.d.ts

View File

@@ -25,9 +25,10 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![AFFiNE Web](<https://img.shields.io/badge/-Try%20It%20Online%20%E2%86%92-rgb(84,56,255)?style=flat-square&logoColor=white&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAADAAAAAwAEwd99eAAABjElEQVRYhe1W0U3DMBB9RfyTDeoNyAYNG2QDOgJsECYgGxA26AZ4hIxgJqCZ4PjIGV+tUxK7raqiPsmKdXe5e3fOs7IiIlwSdxetfiNw7QRKAD0Ax/ssrI5QgQOw5v03AJOTJHcCL1x84LVmWzJyJlBg7P4BwCvb3pmIAbBPykZEqaulEU7YHNva1HypxUsKqIS9EvbynASs0n3ss+ciUIsuO8VvhL9emjdFBa3YO8XvALwpsZNYSqBB0PwUWgRZNksSL5GhlN0ngGd+dkpsD6AG8IGlslxwTh2fa09EBc3Dir32rRysuQlUAL54/wTAcpePPAXHPsOTGXhSEv69rAlYpZOt6DSO29J4D/TRRLJk6AvtaZSY9PkCFYVLqI9i/NF5YkkECgrXa6P4fVEn4iolrhNxRQqBZu7FqMNdZiMqAUPj2KdGZyicu1dHzlGqBHxn2sdTR53bmeJ+ebJd7LtXhGH4uQEwd0ttAPzMxGi5/6BdxTuMej41Bs59gGP+CU+Cq/4tvxH4HwR+Ab3Uqr/VGbqEAAAAAElFTkSuQmCC>)](https://app.affine.pro)
[![AFFiNE macOS M1/M2 Chip](https://img.shields.io/badge/-macOS_M_Chip%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://github.com/toeverything/AFFiNE/releases/latest)
[![AFFiNE macOS x64](https://img.shields.io/badge/-macOS_x86%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://github.com/toeverything/AFFiNE/releases/latest)
[![AFFiNE Window x64](https://img.shields.io/badge/-Windows%20%E2%86%92-blue?style=flat-square&logo=windows&logoColor=white)](https://github.com/toeverything/AFFiNE/releases/latest)
[![AFFiNE macOS M1/M2 Chip](https://img.shields.io/badge/-macOS_M_Chip%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://affine.pro/download)
[![AFFiNE macOS x64](https://img.shields.io/badge/-macOS_x86%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://affine.pro/download)
[![AFFiNE Window x64](https://img.shields.io/badge/-Windows%20%E2%86%92-blue?style=flat-square&logo=windows&logoColor=white)](https://affine.pro/download)
[![AFFiNE Linux](https://img.shields.io/badge/-Linux%20%E2%86%92-yellow?style=flat-square&logo=linux&logoColor=white)](https://affine.pro/download)
[![stars-icon]](https://github.com/toeverything/AFFiNE)
[![All Contributors][all-contributors-badge]](#contributors)
@@ -110,6 +111,14 @@ Looking for **others ways to contribute** and wondering where to start? Check ou
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.
## Ecosystem
| Name | | |
| --------------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| [@affine/component](https://affine-storybook.vercel.app/) | AFFiNE Component Resources | [![](https://img.shields.io/codecov/c/github/toeverything/affine?style=flat-square)](https://affine-storybook.vercel.app/) |
| [@toeverything/y-indexeddb](packages/y-indexeddb) | IndexedDB database adapter for Yjs | [![](https://img.shields.io/npm/dm/@toeverything/y-indexeddb?style=flat-square&color=eee)](https://www.npmjs.com/package/@toeverything/y-indexeddb) |
| [@toeverything/theme](packages/theme) | AFFiNE theme | [![](https://img.shields.io/npm/dm/@toeverything/theme?style=flat-square&color=eee)](https://www.npmjs.com/package/@toeverything/theme) |
## Thanks
We would also like to give thanks to open-source projects that make AFFiNE possible:
@@ -134,7 +143,6 @@ We would like to express our gratitude to all the individuals who have already c
<img src="https://user-images.githubusercontent.com/5910926/233382206-312428ca-094a-4579-ae06-213961ed7eab.svg" />
</a>
## Self-Host
Get started with Docker and deploy your own feature-rich, restriction-free deployment of AFFiNE - check the [latest packages].

View File

@@ -1,8 +0,0 @@
cacheFolder: '../../.yarn/cache'
deferredVersionFolder: '../../.yarn/versions'
globalFolder: '../../.yarn/global'
installStatePath: '../../.yarn/install-state.gz'
patchFolder: '../../.yarn/patches'
pnpUnpluggedFolder: '../../.yarn/unplugged'
yarnPath: '../../.yarn/releases/yarn-3.5.0.cjs'
virtualFolder: '../../.yarn/__virtual__'

View File

@@ -14,6 +14,24 @@ yarn generate-assets
yarn dev # or yarn prod for production build
```
## Troubleshooting
### better-sqlite3 error
When running tests or starting electron, you may encounter the following error:
> Error: The module 'apps/electron/node_modules/better-sqlite3/build/Release/better_sqlite3.node'
This is due to the fact that the `better-sqlite3` package is built for the Node.js version in Electron & in your machine. To fix this, run the following command based on different cases:
```sh
# for running unit tests, we are not using Electron's node:
yarn rebuild better-sqlite3
# for running Electron, we are using Electron's node:
yarn postinstall
```
## Credits
Most of the boilerplate code is generously borrowed from the following

View File

@@ -3,25 +3,33 @@ const {
utils: { fromBuildIdentifier },
} = require('@electron-forge/core');
const isCanary = process.env.BUILD_TYPE === 'canary';
const path = require('node:path');
const productName = isCanary ? 'AFFiNE-Canary' : 'AFFiNE';
const icoPath = isCanary
? './resources/icons/icon_canary.ico'
const buildType = (process.env.BUILD_TYPE || 'stable').trim().toLowerCase();
const stableBuild = buildType === 'stable';
const productName = !stableBuild ? `AFFiNE-${buildType}` : 'AFFiNE';
const icoPath = !stableBuild
? `./resources/icons/icon_${buildType}.ico`
: './resources/icons/icon.ico';
const icnsPath = isCanary
? './resources/icons/icon_canary.icns'
const icnsPath = !stableBuild
? `./resources/icons/icon_${buildType}.icns`
: './resources/icons/icon.icns';
const arch =
process.argv.indexOf('--arch') > 0
? process.argv[process.argv.indexOf('--arch') + 1]
: process.arch;
/**
* @type {import('@electron-forge/shared-types').ForgeConfig}
*/
module.exports = {
buildIdentifier: isCanary ? 'canary' : 'stable',
buildIdentifier: buildType,
packagerConfig: {
name: productName,
appBundleId: fromBuildIdentifier({
canary: 'pro.affine.canary',
beta: 'pro.affine.beta',
stable: 'pro.affine.app',
}),
icon: icnsPath,
@@ -45,6 +53,23 @@ module.exports = {
format: 'ULFO',
icon: icnsPath,
name: 'AFFiNE',
'icon-size': 128,
background: './resources/icons/dmg-background.png',
contents: [
{
x: 176,
y: 192,
type: 'file',
path: path.resolve(
__dirname,
'out',
buildType,
`${productName}-darwin-${arch}`,
`${productName}.app`
),
},
{ x: 432, y: 192, type: 'link', path: '/Applications' },
],
},
},
{

View File

@@ -0,0 +1,3 @@
import log from 'electron-log';
export const logger = log;

View File

@@ -0,0 +1,5 @@
// This file contains the main process events
// It will guide preload and main process on the correct event types and payloads
export interface MainEventMap {
'main:on-db-update': (workspaceId: string) => void;
}

View File

@@ -0,0 +1 @@
tmp

View File

@@ -0,0 +1,226 @@
import assert from 'node:assert';
import path from 'node:path';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
const registeredHandlers = new Map<string, (...args: any[]) => any>();
// common mock dispatcher for ipcMain.handle and app.on
async function dispatch(key: string, ...args: any[]) {
const handler = registeredHandlers.get(key);
assert(handler);
return await handler(null, ...args);
}
const APP_PATH = path.join(__dirname, './tmp');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (v: boolean) => {
// will be stubbed later
},
webContents: {
send: (type: string, ...args: any[]) => {
// ...
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => any) => {
registeredHandlers.set(key, callback);
},
};
const nativeTheme = {
themeSource: 'light',
};
function compareBuffer(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return {
app: {
getPath: (name: string) => {
assert(name === 'appData');
return APP_PATH;
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
registeredHandlers.set(name, callback);
},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
nativeTheme: nativeTheme,
ipcMain,
};
});
beforeEach(async () => {
// clean up tmp folder
const { registerHandlers } = await import('../handlers');
registerHandlers();
});
afterEach(async () => {
const { cleanupWorkspaceDBs } = await import('../handlers');
cleanupWorkspaceDBs();
await fs.remove(APP_PATH);
});
describe('ensureWorkspaceDB', () => {
test('should create db file on connection if it does not exist', async () => {
const id = 'test-workspace-id';
const { ensureWorkspaceDB } = await import('../handlers');
const workspaceDB = await ensureWorkspaceDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
});
});
describe('workspace handlers', () => {
test('list all workspace ids', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureWorkspaceDB } = await import('../handlers');
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
const list = await dispatch('workspace:list');
expect(list).toEqual(ids);
});
test('delete workspace', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureWorkspaceDB } = await import('../handlers');
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
await dispatch('workspace:delete', 'test-workspace-id-2');
const list = await dispatch('workspace:list');
expect(list).toEqual(['test-workspace-id']);
});
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui:theme-change', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui:theme-change', 'light');
expect(nativeTheme.themeSource).toBe('light');
});
test('sidebar-visibility-change (macOS)', async () => {
vi.stubGlobal('process', { platform: 'darwin' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui:sidebar-visibility-change', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui:sidebar-visibility-change', false);
expect(setWindowButtonVisibility).toBeCalledWith(false);
vi.unstubAllGlobals();
});
test('sidebar-visibility-change (non-macOS)', async () => {
vi.stubGlobal('process', { platform: 'linux' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui:sidebar-visibility-change', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('db handlers', () => {
test('will reconnect on activate', async () => {
const { ensureWorkspaceDB } = await import('../handlers');
const workspaceDB = await ensureWorkspaceDB('test-workspace-id');
const instance = vi.spyOn(workspaceDB, 'reconnectDB');
await dispatch('activate');
expect(instance).toBeCalled();
});
test('apply doc and get doc updates', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db:get-doc', workspaceId);
// ? is this a good test?
expect(bin.every((byte: number) => byte === 0)).toBe(true);
const ydoc = new Y.Doc();
const ytext = ydoc.getText('test');
ytext.insert(0, 'hello world');
const bin2 = Y.encodeStateAsUpdate(ydoc);
await dispatch('db:apply-doc-update', workspaceId, bin2);
const bin3 = await dispatch('db:get-doc', workspaceId);
const ydoc2 = new Y.Doc();
Y.applyUpdate(ydoc2, bin3);
const ytext2 = ydoc2.getText('test');
expect(ytext2.toString()).toBe('hello world');
});
test('get non existent doc', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db:get-blob', workspaceId, 'non-existent-id');
expect(bin).toBeNull();
});
test('list blobs (empty)', async () => {
const workspaceId = 'test-workspace-id';
const list = await dispatch('db:get-persisted-blobs', workspaceId);
expect(list).toEqual([]);
});
test('CRUD blobs', async () => {
const testBin = new Uint8Array([1, 2, 3, 4, 5]);
const testBin2 = new Uint8Array([6, 7, 8, 9, 10]);
const workspaceId = 'test-workspace-id';
// add blob
await dispatch('db:add-blob', workspaceId, 'testBin', testBin);
// get blob
expect(
compareBuffer(
await dispatch('db:get-blob', workspaceId, 'testBin'),
testBin
)
).toBe(true);
// add another blob
await dispatch('db:add-blob', workspaceId, 'testBin2', testBin2);
expect(
compareBuffer(
await dispatch('db:get-blob', workspaceId, 'testBin2'),
testBin2
)
).toBe(true);
// list blobs
let lists = await dispatch('db:get-persisted-blobs', workspaceId);
expect(lists).toHaveLength(2);
expect(lists).toContain('testBin');
expect(lists).toContain('testBin2');
// delete blob
await dispatch('db:delete-blob', workspaceId, 'testBin');
lists = await dispatch('db:get-persisted-blobs', workspaceId);
expect(lists).toEqual(['testBin2']);
});
});

View File

@@ -1,69 +0,0 @@
import * as os from 'node:os';
import path from 'node:path';
import { app, shell } from 'electron';
import { BrowserWindow, ipcMain, nativeTheme } from 'electron';
import fs from 'fs-extra';
import { parse } from 'url';
import { isMacOS } from '../../../utils';
import { getExchangeTokenParams, oauthEndpoint } from './google-auth';
const AFFINE_ROOT = path.join(os.homedir(), '.affine');
fs.ensureDirSync(AFFINE_ROOT);
const logger = console;
export const registerHandlers = () => {
ipcMain.handle('ui:theme-change', async (_, theme) => {
nativeTheme.themeSource = theme;
logger.info('theme change', theme);
});
ipcMain.handle('ui:sidebar-visibility-change', async (_, visible) => {
// todo
// detect if os is macos
if (isMacOS()) {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
logger.info('sidebar visibility change', visible);
}
});
ipcMain.handle('ui:get-google-oauth-code', async () => {
logger.info('starting google sign in ...');
shell.openExternal(oauthEndpoint);
return new Promise((resolve, reject) => {
const handleOpenUrl = async (_: any, url: string) => {
const mainWindow = BrowserWindow.getAllWindows().find(
w => !w.isDestroyed()
);
const urlObj = parse(url.replace('??', '?'), true);
if (!mainWindow || !url.startsWith('affine://auth-callback')) return;
const code = urlObj.query['code'] as string;
if (!code) return;
logger.info('google sign in code received from callback', code);
app.removeListener('open-url', handleOpenUrl);
resolve(getExchangeTokenParams(code));
};
app.on('open-url', handleOpenUrl);
setTimeout(() => {
reject(new Error('Timed out'));
app.removeListener('open-url', handleOpenUrl);
}, 30000);
});
});
ipcMain.handle('main:env-update', async (_, env, value) => {
process.env[env] = value;
});
};

View File

@@ -0,0 +1,9 @@
import { app } from 'electron';
import path from 'path';
export const appContext = {
appName: app.name,
appDataPath: path.join(app.getPath('appData'), app.name),
};
export type AppContext = typeof appContext;

View File

@@ -0,0 +1,34 @@
import fs from 'fs-extra';
import { logger } from '../../../logger';
import type { WorkspaceDatabase } from './sqlite';
/**
* Start a backup of the database to the given destination.
*/
export async function exportDatabase(db: WorkspaceDatabase, dest: string) {
await fs.copyFile(db.path, dest);
logger.log('export: ', dest);
}
// export async function startBackup(db: WorkspaceDatabase, dest: string) {
// let timeout: NodeJS.Timeout | null;
// async function backup() {
// await fs.copyFile(db.path, dest);
// logger.log('backup: ', dest);
// }
// backup();
// const _db = await db.sqliteDB$;
// _db.on('change', () => {
// if (timeout) {
// clearTimeout(timeout);
// }
// timeout = setTimeout(async () => {
// await backup();
// timeout = null;
// }, 1000);
// });
// }

View File

@@ -0,0 +1,7 @@
import type { WatchListener } from 'fs-extra';
import fs from 'fs-extra';
export function watchFile(path: string, callback: WatchListener<string>) {
const watcher = fs.watch(path, callback);
return () => watcher.close();
}

View File

@@ -0,0 +1,174 @@
import path from 'node:path';
import type { Database } from 'better-sqlite3';
import sqlite from 'better-sqlite3';
import fs from 'fs-extra';
import * as Y from 'yjs';
import { logger } from '../../../logger';
import type { AppContext } from '../context';
const schemas = [
`CREATE TABLE IF NOT EXISTS "updates" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data BLOB NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS "blobs" (
key TEXT PRIMARY KEY NOT NULL,
data BLOB NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)`,
];
interface UpdateRow {
id: number;
data: Buffer;
timestamp: string;
}
interface BlobRow {
key: string;
data: Buffer;
timestamp: string;
}
export class WorkspaceDatabase {
sqliteDB: Database;
ydoc = new Y.Doc();
firstConnect = false;
constructor(public path: string) {
this.sqliteDB = this.reconnectDB();
}
// release resources
destroy = () => {
this.sqliteDB?.close();
this.ydoc.destroy();
};
reconnectDB = () => {
logger.log('open db', this.path);
if (this.sqliteDB) {
this.sqliteDB.close();
}
// use cached version?
const db = (this.sqliteDB = sqlite(this.path));
db.exec(schemas.join(';'));
if (!this.firstConnect) {
this.ydoc.on('update', this.addUpdateToSQLite);
}
const updates = this.getUpdates();
updates.forEach(update => {
Y.applyUpdate(this.ydoc, update.data);
});
this.firstConnect = true;
return db;
};
getEncodedDocUpdates = () => {
return Y.encodeStateAsUpdate(this.ydoc);
};
// non-blocking and use yDoc to validate the update
// after that, the update is added to the db
applyUpdate = (data: Uint8Array) => {
Y.applyUpdate(this.ydoc, data);
// todo: trim the updates when the number of records is too large
// 1. store the current ydoc state in the db
// 2. then delete the old updates
// yjs-idb will always trim the db for the first time after DB is loaded
};
addBlob = (key: string, data: Uint8Array) => {
try {
const statement = this.sqliteDB.prepare(
'INSERT INTO blobs (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = ?'
);
statement.run(key, data, data);
} catch (error) {
logger.error('addBlob', error);
}
};
getBlob = (key: string) => {
try {
const statement = this.sqliteDB.prepare(
'SELECT data FROM blobs WHERE key = ?'
);
const row = statement.get(key) as BlobRow;
if (!row) {
return null;
}
return row.data;
} catch (error) {
logger.error('getBlob', error);
return null;
}
};
deleteBlob = (key: string) => {
try {
const statement = this.sqliteDB.prepare(
'DELETE FROM blobs WHERE key = ?'
);
statement.run(key);
} catch (error) {
logger.error('deleteBlob', error);
}
};
getPersistentBlobKeys = () => {
try {
const statement = this.sqliteDB.prepare('SELECT key FROM blobs');
const rows = statement.all() as BlobRow[];
return rows.map(row => row.key);
} catch (error) {
logger.error('getPersistentBlobKeys', error);
return [];
}
};
private getUpdates = () => {
try {
const statement = this.sqliteDB.prepare('SELECT * FROM updates');
const rows = statement.all() as UpdateRow[];
return rows;
} catch (error) {
logger.error('getUpdates', error);
return [];
}
};
// batch write instead write per key stroke?
private addUpdateToSQLite = (data: Uint8Array) => {
try {
const start = performance.now();
const statement = this.sqliteDB.prepare(
'INSERT INTO updates (data) VALUES (?)'
);
statement.run(data);
logger.debug('addUpdateToSQLite', performance.now() - start, 'ms');
} catch (error) {
logger.error('addUpdateToSQLite', error);
}
};
}
export async function openWorkspaceDatabase(
context: AppContext,
workspaceId: string
) {
const basePath = path.join(context.appDataPath, 'workspaces', workspaceId);
// hmmm.... blocking api but it should be fine, right?
await fs.ensureDir(basePath);
const dbPath = path.join(basePath, 'storage.db');
return new WorkspaceDatabase(dbPath);
}

View File

@@ -0,0 +1,30 @@
import path from 'node:path';
import fs from 'fs-extra';
import { logger } from '../../../logger';
import type { AppContext } from '../context';
export async function listWorkspaces(context: AppContext) {
const basePath = path.join(context.appDataPath, 'workspaces');
try {
return fs.readdir(basePath);
} catch (error) {
logger.error('listWorkspaces', error);
return [];
}
}
export async function deleteWorkspace(context: AppContext, id: string) {
const basePath = path.join(context.appDataPath, 'workspaces', id);
const movedPath = path.join(
context.appDataPath,
'delete-workspaces',
`${id}`
);
try {
return fs.move(basePath, movedPath);
} catch (error) {
logger.error('deleteWorkspace', error);
}
}

View File

@@ -0,0 +1,227 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
nativeTheme,
shell,
} from 'electron';
import { parse } from 'url';
import { logger } from '../../logger';
import { isMacOS } from '../../utils';
import { appContext } from './context';
import { exportDatabase } from './data/export';
import { watchFile } from './data/fs-watch';
import type { WorkspaceDatabase } from './data/sqlite';
import { openWorkspaceDatabase } from './data/sqlite';
import { deleteWorkspace, listWorkspaces } from './data/workspace';
import { getExchangeTokenParams, oauthEndpoint } from './google-auth';
import { sendMainEvent } from './send-main-event';
let currentWorkspaceId = '';
const dbMapping = new Map<string, WorkspaceDatabase>();
const dbWatchers = new Map<string, () => void>();
const dBLastUse = new Map<string, number>();
export async function ensureWorkspaceDB(id: string) {
let workspaceDB = dbMapping.get(id);
if (!workspaceDB) {
// hmm... potential race condition?
workspaceDB = await openWorkspaceDatabase(appContext, id);
dbMapping.set(id, workspaceDB);
logger.info('watch db file', workspaceDB.path);
dbWatchers.set(
id,
watchFile(workspaceDB.path, (event, filename) => {
const minTime = 1000;
logger.debug(
'db file changed',
event,
filename,
Date.now() - dBLastUse.get(id)!
);
if (Date.now() - dBLastUse.get(id)! < minTime || !filename) {
logger.debug('skip db update');
return;
}
sendMainEvent('main:on-db-update', id);
// handle DB file update by other process
dbWatchers.get(id)?.();
dbMapping.delete(id);
dbWatchers.delete(id);
ensureWorkspaceDB(id);
})
);
}
dBLastUse.set(id, Date.now());
return workspaceDB;
}
export async function cleanupWorkspaceDBs() {
for (const [id, db] of dbMapping) {
logger.info('close db connection', id);
db.destroy();
dbWatchers.get(id)?.();
}
dbMapping.clear();
dbWatchers.clear();
dBLastUse.clear();
}
function registerWorkspaceHandlers() {
ipcMain.handle('workspace:list', async _ => {
logger.info('list workspaces');
return listWorkspaces(appContext);
});
ipcMain.handle('workspace:delete', async (_, id) => {
logger.info('delete workspace', id);
return deleteWorkspace(appContext, id);
});
}
function registerUIHandlers() {
ipcMain.handle('ui:theme-change', async (_, theme) => {
nativeTheme.themeSource = theme;
logger.info('theme change', theme);
});
ipcMain.handle('ui:sidebar-visibility-change', async (_, visible) => {
// todo
// detect if os is macos
if (isMacOS()) {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
logger.info('sidebar visibility change', visible);
}
});
ipcMain.handle('ui:workspace-change', async (_, workspaceId) => {
logger.info('workspace change', workspaceId);
currentWorkspaceId = workspaceId;
});
// @deprecated
ipcMain.handle('ui:get-google-oauth-code', async () => {
logger.info('starting google sign in ...');
shell.openExternal(oauthEndpoint);
return new Promise((resolve, reject) => {
const handleOpenUrl = async (_: any, url: string) => {
const mainWindow = BrowserWindow.getAllWindows().find(
w => !w.isDestroyed()
);
const urlObj = parse(url.replace('??', '?'), true);
if (!mainWindow || !url.startsWith('affine://auth-callback')) return;
const code = urlObj.query['code'] as string;
if (!code) return;
logger.info('google sign in code received from callback', code);
app.removeListener('open-url', handleOpenUrl);
resolve(getExchangeTokenParams(code));
};
app.on('open-url', handleOpenUrl);
setTimeout(() => {
reject(new Error('Timed out'));
app.removeListener('open-url', handleOpenUrl);
}, 30000);
});
});
ipcMain.handle('main:env-update', async (_, env, value) => {
process.env[env] = value;
});
}
function registerDBHandlers() {
app.on('activate', () => {
for (const [_, workspaceDB] of dbMapping) {
workspaceDB.reconnectDB();
}
});
ipcMain.handle('db:get-doc', async (_, id) => {
logger.log('main: get doc', id);
const workspaceDB = await ensureWorkspaceDB(id);
return workspaceDB.getEncodedDocUpdates();
});
ipcMain.handle('db:apply-doc-update', async (_, id, update) => {
logger.log('main: apply doc update', id);
const workspaceDB = await ensureWorkspaceDB(id);
return workspaceDB.applyUpdate(update);
});
ipcMain.handle('db:add-blob', async (_, workspaceId, key, data) => {
logger.log('main: add blob', workspaceId, key);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.addBlob(key, data);
});
ipcMain.handle('db:get-blob', async (_, workspaceId, key) => {
logger.log('main: get blob', workspaceId, key);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.getBlob(key);
});
ipcMain.handle('db:get-persisted-blobs', async (_, workspaceId) => {
logger.log('main: get persisted blob keys', workspaceId);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.getPersistentBlobKeys();
});
ipcMain.handle('db:delete-blob', async (_, workspaceId, key) => {
logger.log('main: delete blob', workspaceId, key);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.deleteBlob(key);
});
ipcMain.handle('ui:open-db-folder', async _ => {
const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId);
logger.log('main: open db folder', workspaceDB.path);
shell.showItemInFolder(workspaceDB.path);
});
ipcMain.handle('ui:open-load-db-file-dialog', async () => {
// todo
});
ipcMain.handle('ui:open-save-db-file-dialog', async () => {
logger.log('main: open save db file dialog', currentWorkspaceId);
const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId);
const ret = await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
buttonLabel: 'Save',
defaultPath: currentWorkspaceId + '.db',
message: 'Save Workspace as SQLite Database',
});
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return null;
}
await exportDatabase(workspaceDB, filePath);
shell.showItemInFolder(filePath);
return filePath;
});
}
export const registerHandlers = () => {
registerWorkspaceHandlers();
registerUIHandlers();
registerDBHandlers();
};

View File

@@ -3,7 +3,8 @@ import './security-restrictions';
import { app } from 'electron';
import path from 'path';
import { registerHandlers } from './app-state';
import { logger } from '../../logger';
import { registerHandlers } from './handlers';
import { restoreOrCreateWindow } from './main-window';
import { registerProtocol } from './protocol';
@@ -22,6 +23,7 @@ if (process.defaultApp) {
*/
const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) {
logger.info('Another instance is running, exiting...');
app.quit();
process.exit(0);
}

View File

@@ -2,11 +2,14 @@ import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { logger } from '../../logger';
import { isMacOS } from '../../utils';
const IS_DEV = process.env.NODE_ENV === 'development';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
async function createWindow() {
logger.info('create window');
const mainWindowState = electronWindowState({
defaultWidth: 1000,
defaultHeight: 800,
@@ -45,7 +48,14 @@ async function createWindow() {
* @see https://github.com/electron/electron/issues/25012
*/
browserWindow.on('ready-to-show', () => {
browserWindow.show();
if (IS_DEV) {
// do not gain focus in dev mode
browserWindow.showInactive();
} else {
browserWindow.show();
}
logger.info('main window is ready to show');
if (IS_DEV) {
browserWindow.webContents.openDevTools();
@@ -61,13 +71,12 @@ async function createWindow() {
/**
* URL for main window.
*/
const pageUrl =
IS_DEV && process.env.DEV_SERVER_URL !== undefined
? process.env.DEV_SERVER_URL
: 'file://./index.html'; // see protocol.ts
const pageUrl = process.env.DEV_SERVER_URL || 'file://./index.html'; // see protocol.ts
await browserWindow.loadURL(pageUrl);
logger.info('main window is loaded at' + pageUrl);
return browserWindow;
}
@@ -85,9 +94,8 @@ export async function restoreOrCreateWindow() {
if (browserWindow.isMinimized()) {
browserWindow.restore();
logger.info('restore main window');
}
browserWindow.focus();
return browserWindow;
}

View File

@@ -30,23 +30,21 @@ function toAbsolutePath(url: string) {
}
export function registerProtocol() {
if (process.env.NODE_ENV === 'production') {
protocol.interceptFileProtocol('file', (request, callback) => {
const url = request.url.replace(/^file:\/\//, '');
const realpath = toAbsolutePath(url);
// console.log('realpath', realpath, 'for', url);
callback(realpath);
return true;
});
protocol.interceptFileProtocol('file', (request, callback) => {
const url = request.url.replace(/^file:\/\//, '');
const realpath = toAbsolutePath(url);
// console.log('realpath', realpath, 'for', url);
callback(realpath);
return true;
});
protocol.registerFileProtocol('assets', (request, callback) => {
const url = request.url.replace(/^assets:\/\//, '');
const realpath = toAbsolutePath(url);
// console.log('realpath', realpath, 'for', url);
callback(realpath);
return true;
});
}
protocol.registerFileProtocol('assets', (request, callback) => {
const url = request.url.replace(/^assets:\/\//, '');
const realpath = toAbsolutePath(url);
// console.log('realpath', realpath, 'for', url);
callback(realpath);
return true;
});
session.defaultSession.webRequest.onHeadersReceived(
(responseDetails, callback) => {

View File

@@ -0,0 +1,14 @@
import { BrowserWindow } from 'electron';
import type { MainEventMap } from '../../main-events';
function getActiveWindows() {
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
}
export function sendMainEvent<T extends keyof MainEventMap>(
type: T,
...args: Parameters<MainEventMap[T]>
) {
getActiveWindows().forEach(win => win.webContents.send(type, ...args));
}

View File

@@ -1,12 +1,6 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
interface Window {
/**
* After analyzing the `exposeInMainWorld` calls,
* `packages/preload/exposedInMainWorld.d.ts` file will be generated.
* It contains all interfaces.
* `packages/preload/exposedInMainWorld.d.ts` file is required for TS is `renderer`
*
* @see https://github.com/cawa-93/dts-for-context-bridge
*/
readonly apis: { workspaceSync: (id: string) => Promise<any>; onThemeChange: (theme: string) => Promise<any>; onSidebarVisibilityChange: (visible: boolean) => Promise<any>; getGoogleOauthCode: () => Promise<{ requestInit: RequestInit; url: string; }>; updateEnv: (env: string, value: string) => void; };
readonly appInfo: { electron: boolean; isMacOS: boolean; };
apis: typeof import('./src/affine-apis').apis;
appInfo: typeof import('./src/affine-apis').appInfo;
}

View File

@@ -0,0 +1,81 @@
// NOTE: we will generate preload types from this file
import { ipcRenderer } from 'electron';
import type { MainEventMap } from '../../main-events';
// main -> renderer
function onMainEvent<T extends keyof MainEventMap>(
eventName: T,
callback: MainEventMap[T]
): () => void {
// @ts-expect-error fix me later
const fn = (_, ...args) => callback(...args);
ipcRenderer.on(eventName, fn);
return () => ipcRenderer.off(eventName, fn);
}
const apis = {
db: {
// workspace providers
getDoc: (id: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-doc', id),
applyDocUpdate: (id: string, update: Uint8Array) =>
ipcRenderer.invoke('db:apply-doc-update', id, update),
addBlob: (workspaceId: string, key: string, data: Uint8Array) =>
ipcRenderer.invoke('db:add-blob', workspaceId, key, data),
getBlob: (workspaceId: string, key: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-blob', workspaceId, key),
deleteBlob: (workspaceId: string, key: string) =>
ipcRenderer.invoke('db:delete-blob', workspaceId, key),
getPersistedBlobs: (workspaceId: string): Promise<string[]> =>
ipcRenderer.invoke('db:get-persisted-blobs', workspaceId),
// listeners
onDBUpdate: (callback: (workspaceId: string) => void) => {
return onMainEvent('main:on-db-update', callback);
},
},
workspace: {
list: (): Promise<string[]> => ipcRenderer.invoke('workspace:list'),
delete: (id: string): Promise<void> =>
ipcRenderer.invoke('workspace:delete', id),
// create will be implicitly called by db functions
},
openLoadDBFileDialog: () => ipcRenderer.invoke('ui:open-load-db-file-dialog'),
openSaveDBFileDialog: () => ipcRenderer.invoke('ui:open-save-db-file-dialog'),
// ui
onThemeChange: (theme: string) =>
ipcRenderer.invoke('ui:theme-change', theme),
onSidebarVisibilityChange: (visible: boolean) =>
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
onWorkspaceChange: (workspaceId: string) =>
ipcRenderer.invoke('ui:workspace-change', workspaceId),
openDBFolder: () => ipcRenderer.invoke('ui:open-db-folder'),
/**
* Try sign in using Google and return a request object to exchange the code for a token
* Not exchange in Node side because it is easier to do it in the renderer with VPN
*/
getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> =>
ipcRenderer.invoke('ui:get-google-oauth-code'),
/**
* Secret backdoor to update environment variables in main process
*/
updateEnv: (env: string, value: string) => {
ipcRenderer.invoke('main:env-update', env, value);
},
};
const appInfo = {
electron: true,
};
export { apis, appInfo };

View File

@@ -2,9 +2,9 @@
* @module preload
*/
import { contextBridge, ipcRenderer } from 'electron';
import { contextBridge } from 'electron';
import { isMacOS } from '../../utils';
import * as affineApis from './affine-apis';
/**
* The "Main World" is the JavaScript context that your main renderer code runs in.
@@ -13,40 +13,5 @@ import { isMacOS } from '../../utils';
* @see https://www.electronjs.org/docs/api/context-bridge
*/
/**
* After analyzing the `exposeInMainWorld` calls,
* `packages/preload/exposedInMainWorld.d.ts` file will be generated.
* It contains all interfaces.
* `packages/preload/exposedInMainWorld.d.ts` file is required for TS is `renderer`
*
* @see https://github.com/cawa-93/dts-for-context-bridge
*/
contextBridge.exposeInMainWorld('apis', {
workspaceSync: (id: string) => ipcRenderer.invoke('octo:workspace-sync', id),
// ui
onThemeChange: (theme: string) =>
ipcRenderer.invoke('ui:theme-change', theme),
onSidebarVisibilityChange: (visible: boolean) =>
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
/**
* Try sign in using Google and return a request object to exchange the code for a token
* Not exchange in Node side because it is easier to do it in the renderer with VPN
*/
getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> =>
ipcRenderer.invoke('ui:get-google-oauth-code'),
/**
* Secret backdoor to update environment variables in main process
*/
updateEnv: (env: string, value: string) => {
ipcRenderer.invoke('main:env-update', env, value);
},
});
contextBridge.exposeInMainWorld('appInfo', {
electron: true,
isMacOS: isMacOS(),
});
contextBridge.exposeInMainWorld('apis', affineApis.apis);
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);

View File

@@ -1,26 +1,30 @@
{
"name": "@affine/electron",
"private": true,
"version": "0.5.4-canary.7",
"version": "0.5.4-canary.15",
"author": "affine",
"description": "AFFiNE App",
"homepage": "https://github.com/toeverything/AFFiNE",
"scripts": {
"dev": "cross-env NODE_ENV=development node scripts/dev.mjs",
"prod": "cross-env NODE_ENV=production node scripts/dev.mjs",
"dev": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs",
"prod": "yarn electron-rebuild && yarn node scripts/dev.mjs",
"generate-assets": "zx scripts/generate-assets.mjs",
"package": "electron-forge package",
"make": "electron-forge make",
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
"make-windows-x64": "electron-forge make --platform=win32 --arch=x64",
"make-linux-x64": "electron-forge make --platform=linux --arch=x64"
"make-linux-x64": "electron-forge make --platform=linux --arch=x64",
"rebuild:for-test": "yarn rebuild better-sqlite3",
"rebuild:for-electron": "yarn electron-rebuild",
"test": "playwright test"
},
"config": {
"forge": "./forge.config.js"
},
"main": "./dist/layers/main/index.js",
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@electron-forge/cli": "^6.1.1",
"@electron-forge/core": "^6.1.1",
"@electron-forge/core-utils": "^6.1.1",
@@ -29,20 +33,25 @@
"@electron-forge/maker-squirrel": "^6.1.1",
"@electron-forge/maker-zip": "^6.1.1",
"@electron-forge/shared-types": "^6.1.1",
"@electron/rebuild": "^3.2.12",
"@electron/rebuild": "^3.2.13",
"@electron/remote": "2.0.9",
"dts-for-context-bridge": "^0.7.1",
"electron": "24.1.2",
"@types/better-sqlite3": "^7.6.4",
"@types/fs-extra": "^11.0.1",
"cross-env": "7.0.3",
"electron": "24.1.3",
"electron-log": "^5.0.0-beta.23",
"electron-squirrel-startup": "1.0.0",
"esbuild": "^0.17.17",
"electron-window-state": "^5.0.3",
"esbuild": "^0.17.18",
"fs-extra": "^11.1.1",
"playwright": "^1.33.0",
"ts-node": "^10.9.1",
"undici": "^5.22.0",
"zx": "^7.2.1"
},
"dependencies": {
"cross-env": "7.0.3",
"electron-window-state": "^5.0.3",
"firebase": "^9.19.1",
"fs-extra": "^11.1.1",
"undici": "^5.21.2"
"better-sqlite3": "^8.3.0",
"yjs": "^13.6.0"
},
"build": {
"protocols": [
@@ -54,6 +63,12 @@
}
]
},
"packageManager": "yarn@3.5.0",
"stableVersion": "0.5.3"
"stableVersion": "0.5.3",
"installConfig": {
"hoistingLimits": "workspaces"
},
"peerDependencies": {
"playwright": "*",
"ts-node": "*"
}
}

View File

@@ -0,0 +1,27 @@
import type { PlaywrightTestConfig } from '@playwright/test';
// import { devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './tests',
fullyParallel: true,
timeout: process.env.CI ? 50_000 : 30_000,
use: {
viewport: { width: 1440, height: 800 },
},
};
if (process.env.CI) {
config.retries = 3;
config.workers = '50%';
}
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env ts-node-esm
import * as esbuild from 'esbuild';
import { config } from './common.mjs';
const common = config();
await esbuild.build(common.preload);
await esbuild.build({
...common.main,
define: {
...common.main.define,
'process.env.NODE_ENV': `"production"`,
},
});
console.log('Compiled successfully.');

View File

@@ -1,5 +1,9 @@
import { resolve } from 'node:path';
const NODE_MAJOR_VERSION = 18;
import { fileURLToPath } from 'url';
export const root = fileURLToPath(new URL('..', import.meta.url));
export const NODE_MAJOR_VERSION = 18;
const nativeNodeModulesPlugin = {
name: 'native-node-modules',
@@ -15,7 +19,7 @@ const nativeNodeModulesPlugin = {
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
export default () => {
export const config = () => {
const define = Object.fromEntries(
ENV_MACROS.map(key => [
'process.env.' + key,
@@ -24,18 +28,18 @@ export default () => {
);
return {
main: {
entryPoints: ['layers/main/src/index.ts'],
outdir: 'dist/layers/main',
entryPoints: [resolve(root, './layers/main/src/index.ts')],
outdir: resolve(root, './dist/layers/main'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron'],
external: ['electron', 'yjs', 'better-sqlite3'],
plugins: [nativeNodeModulesPlugin],
define: define,
},
preload: {
entryPoints: ['layers/preload/src/index.ts'],
outdir: 'dist/layers/preload',
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
outdir: resolve(root, './dist/layers/preload'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',

View File

@@ -1,16 +1,11 @@
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateAsync } from 'dts-for-context-bridge';
import electronPath from 'electron';
import * as esbuild from 'esbuild';
import commonFn from './common.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { config, root } from './common.mjs';
/** @type 'production' | 'development'' */
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
@@ -23,9 +18,9 @@ const stderrFilterPatterns = [
/ExtensionLoadWarning/,
];
// these are set before calling commonFn so we have a chance to override them
// these are set before calling `config`, so we have a chance to override them
try {
const devJson = readFileSync(path.resolve(__dirname, '../dev.json'), 'utf-8');
const devJson = readFileSync(path.resolve(root, './dev.json'), 'utf-8');
const devEnv = JSON.parse(devJson);
Object.assign(process.env, devEnv);
} catch (err) {
@@ -35,8 +30,8 @@ try {
}
// hard-coded for now:
// fixme(xp): report error if app is not running on port 8080
process.env.DEV_SERVER_URL = `http://localhost:8080`;
// fixme(xp): report error if app is not running on DEV_SERVER_URL
const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
/** @type {ChildProcessWithoutNullStreams | null} */
let spawnProcess = null;
@@ -50,10 +45,12 @@ function spawnOrReloadElectron() {
spawnProcess = spawn(String(electronPath), ['.']);
spawnProcess.stdout.on(
'data',
d => d.toString().trim() && console.warn(d.toString())
);
spawnProcess.stdout.on('data', d => {
let str = d.toString().trim();
if (str) {
console.log(str);
}
});
spawnProcess.stderr.on('data', d => {
const data = d.toString().trim();
if (!data) return;
@@ -66,7 +63,7 @@ function spawnOrReloadElectron() {
spawnProcess.on('exit', process.exit);
}
const common = commonFn();
const common = config();
async function main() {
async function watchPreload(onInitialBuild) {
@@ -79,10 +76,6 @@ async function main() {
setup(build) {
let initialBuild = false;
build.onEnd(() => {
generateAsync({
input: 'layers/preload/src/**/*.ts',
output: 'layers/preload/preload.d.ts',
});
if (initialBuild) {
console.log(`[preload] has changed`);
spawnOrReloadElectron();
@@ -99,13 +92,18 @@ async function main() {
}
async function watchMain() {
const define = {
...common.main.define,
'process.env.NODE_ENV': `"${mode}"`,
};
if (DEV_SERVER_URL) {
define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`;
}
const mainBuild = await esbuild.context({
...common.main,
define: {
...common.main.define,
'process.env.NODE_ENV': `"${mode}"`,
'process.env.DEV_SERVER_URL': `"${process.env.DEV_SERVER_URL}"`,
},
define: define,
plugins: [
...(common.main.plugins ?? []),
{

View File

@@ -5,7 +5,7 @@ import path from 'node:path';
import * as esbuild from 'esbuild';
import commonFn from './common.mjs';
import { config } from './common.mjs';
const repoRootDir = path.join(__dirname, '..', '..', '..');
const electronRootDir = path.join(__dirname, '..');
@@ -33,46 +33,51 @@ if (process.platform === 'win32') {
$.shell = 'powershell.exe';
$.prefix = '';
}
// step 1: build web (nextjs) dist
process.env.ENABLE_LEGACY_PROVIDER = 'false';
cd(repoRootDir);
await $`yarn add`;
await $`yarn build`;
await $`yarn export`;
// step 1.5: amend sourceMappingURL to allow debugging in devtools
await glob('**/*.{js,css}', { cwd: affineWebOutDir }).then(files => {
return files.map(async file => {
const dir = path.dirname(file);
const fullpath = path.join(affineWebOutDir, file);
let content = await fs.readFile(fullpath, 'utf-8');
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => {
return `# sourceMappingURL=assets://./${dir}/${p1}.map`;
});
await fs.writeFile(fullpath, content);
});
});
await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true });
// step 2: build electron resources
// step 1: build electron resources
await buildLayers();
echo('Build layers done');
// step 2: build web (nextjs) dist
if (!process.env.SKIP_WEB_BUILD) {
process.env.ENABLE_LEGACY_PROVIDER = 'false';
await $`yarn build`;
await $`yarn export`;
// step 1.5: amend sourceMappingURL to allow debugging in devtools
await glob('**/*.{js,css}', { cwd: affineWebOutDir }).then(files => {
return files.map(async file => {
const dir = path.dirname(file);
const fullpath = path.join(affineWebOutDir, file);
let content = await fs.readFile(fullpath, 'utf-8');
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => {
return `# sourceMappingURL=assets://./${dir}/${p1}.map`;
});
await fs.writeFile(fullpath, content);
});
});
await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true });
}
/// --------
/// --------
/// --------
async function cleanup() {
await fs.emptyDir(publicAffineOutDir);
if (!process.env.SKIP_WEB_BUILD) {
await fs.emptyDir(publicAffineOutDir);
}
await fs.emptyDir(path.join(electronRootDir, 'layers', 'main', 'dist'));
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
await fs.remove(path.join(electronRootDir, 'out'));
}
async function buildLayers() {
const common = commonFn();
const common = config();
await esbuild.build(common.preload);
await esbuild.build({

View File

@@ -0,0 +1,23 @@
import { resolve } from 'node:path';
import { test } from '@affine-test/kit/playwright';
import { expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
test('new page', async () => {
const electronApp = await electron.launch({
args: [resolve(__dirname, '..')],
executablePath: resolve(__dirname, '../node_modules/.bin/electron'),
});
const page = await electronApp.firstWindow();
await page.getByTestId('new-page-button').click({
delay: 100,
});
await page.waitForSelector('v-line');
const flavour = await page.evaluate(
// @ts-expect-error
() => globalThis.currentWorkspace.flavour
);
expect(flavour).toBe('local');
await electronApp.close();
});

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"noEmit": true
},
"include": ["**.spec.ts", "**.test.ts"]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"isolatedModules": false,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "dist",
"noEmit": false
},
"include": ["layers", "types", "package.json"],
"exclude": ["out", "dist", "node_modules"],
"references": [
{
"path": "./tsconfig.node.json"
}
],
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"resolveJsonModule": true,
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["./scripts", "package.json"]
}

View File

@@ -1 +1,2 @@
SECRET_KEY="secret"
DATABASE_URL="postgresql://affine@localhost:5432/affine"

View File

@@ -1,67 +0,0 @@
-- CreateTable
CREATE TABLE "google_users" (
"id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"google_id" VARCHAR NOT NULL,
CONSTRAINT "google_users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"user_id" VARCHAR,
"user_email" TEXT,
"type" SMALLINT NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "seaql_migrations" (
"version" VARCHAR NOT NULL,
"applied_at" BIGINT NOT NULL,
CONSTRAINT "seaql_migrations_pkey" PRIMARY KEY ("version")
);
-- CreateTable
CREATE TABLE "users" (
"id" VARCHAR NOT NULL,
"name" VARCHAR NOT NULL,
"email" VARCHAR NOT NULL,
"avatar_url" VARCHAR,
"token_nonce" SMALLINT DEFAULT 0,
"password" VARCHAR,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspaces" (
"id" VARCHAR NOT NULL,
"public" BOOLEAN NOT NULL,
"type" SMALLINT NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "google_users_google_id_key" ON "google_users"("google_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- AddForeignKey
ALTER TABLE "google_users" ADD CONSTRAINT "google_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "users" (
"id" VARCHAR NOT NULL,
"name" VARCHAR NOT NULL,
"email" VARCHAR NOT NULL,
"token_nonce" SMALLINT NOT NULL DEFAULT 0,
"avatar_url" VARCHAR,
"password" VARCHAR,
"fulfilled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspaces" (
"id" VARCHAR NOT NULL,
"public" BOOLEAN NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "connected_accounts" (
"id" VARCHAR NOT NULL,
"user_id" TEXT NOT NULL,
"provider" VARCHAR NOT NULL,
"provider_user_id" VARCHAR NOT NULL,
CONSTRAINT "connected_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_workspace_permissions" (
"id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"entity_id" VARCHAR NOT NULL,
"type" SMALLINT NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_workspace_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "connected_accounts_provider_user_id_key" ON "connected_accounts"("provider_user_id");
-- AddForeignKey
ALTER TABLE "connected_accounts" ADD CONSTRAINT "connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.5.4-canary.7",
"version": "0.5.4-canary.15",
"description": "Affine Node.js server",
"type": "module",
"bin": {
@@ -10,28 +10,35 @@
"scripts": {
"dev": "nodemon ./src/index.ts",
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all"
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all",
"postinstall": "prisma generate"
},
"dependencies": {
"@apollo/server": "^4.6.0",
"@apollo/server": "^4.7.0",
"@nestjs/apollo": "^11.0.5",
"@nestjs/common": "^9.4.0",
"@nestjs/core": "^9.4.0",
"@nestjs/graphql": "^11.0.5",
"@nestjs/platform-express": "^9.4.0",
"@prisma/client": "^4.12.0",
"@prisma/client": "^4.13.0",
"bcrypt": "^5.1.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"graphql": "^16.6.0",
"graphql-type-json": "^0.3.2",
"jsonwebtoken": "^9.0.0",
"lodash-es": "^4.17.21",
"prisma": "^4.12.0",
"prisma": "^4.13.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/testing": "^9.4.0",
"@types/lodash-es": "^4.14.194",
"@types/node": "^18.15.11",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.1",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.16.2",
"@types/supertest": "^2.0.12",
"c8": "^7.13.0",
"nodemon": "^2.0.22",
@@ -54,6 +61,7 @@
"**/dist/**"
],
"env": {
"TS_NODE_TRANSPILE_ONLY": true,
"TS_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "development",
"DEBUG": "affine:*",

View File

@@ -7,46 +7,57 @@ generator client {
provider = "prisma-client-js"
}
model google_users {
id String @id @db.VarChar
user_id String @db.VarChar
google_id String @unique @db.VarChar
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
model User {
id String @id @default(uuid()) @db.VarChar
name String @db.VarChar
email String @unique @db.VarChar
tokenNonce Int @default(0) @map("token_nonce") @db.SmallInt
avatarUrl String? @map("avatar_url") @db.VarChar
/// Available if user signed up through OAuth providers
password String? @db.VarChar
/// User may created by email collobration invitation before signup.
/// We will precreate a user entity in such senarios but leave fulfilled as false until they signed up
/// This implementation is convenient for handing unregistered user permissoin
fulfilled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
connectedAccounts ConnectedAccount[]
workspaces UserWorkspacePermission[]
@@map("users")
}
model permissions {
id String @id @db.VarChar
workspace_id String @db.VarChar
user_id String? @db.VarChar
user_email String?
type Int @db.SmallInt
accepted Boolean @default(false)
created_at DateTime? @default(now()) @db.Timestamptz(6)
users users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
model Workspace {
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users UserWorkspacePermission[]
@@map("workspaces")
}
model seaql_migrations {
version String @id @db.VarChar
applied_at BigInt
model ConnectedAccount {
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id")
/// the general provider name, e.g. google, github, facebook
provider String @db.VarChar
/// the user id provided by OAuth providers, or other user identitive credential like `username` provided by GitHub
providerUserId String @unique @map("provider_user_id") @db.VarChar
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("connected_accounts")
}
model users {
id String @id @db.VarChar
name String @db.VarChar
email String @unique @db.VarChar
avatar_url String? @db.VarChar
token_nonce Int? @default(0) @db.SmallInt
password String? @db.VarChar
created_at DateTime? @default(now()) @db.Timestamptz(6)
google_users google_users[]
permissions permissions[]
}
model UserWorkspacePermission {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("entity_id") @db.VarChar
/// Read/Write/Admin/Owner
type Int @db.SmallInt
/// Whether the permission invitation is accepted by the user
accepted Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
model workspaces {
id String @id @db.VarChar
public Boolean
type Int @db.SmallInt
created_at DateTime? @default(now()) @db.Timestamptz(6)
permissions permissions[]
@@map("user_workspace_permissions")
}

View File

@@ -0,0 +1,19 @@
import crypto from 'node:crypto';
import { genSalt } from 'bcrypt';
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
console.log('Salt:\n', await genSalt(10));
console.log('ECDSA Public Key:\n', publicKey);
console.log('ECDSA Private Key:\n', privateKey);

View File

@@ -1,15 +1,10 @@
import { randomUUID } from 'node:crypto';
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.users.create({
data: {
id: randomUUID(),
...userA,
},
await prisma.user.create({
data: userA,
});
}

View File

@@ -12,7 +12,7 @@ const root = fileURLToPath(new URL('..', import.meta.url));
const testDir = resolve(root, 'src', 'tests');
const files = await readdir(testDir);
const args = [...pkg.nodemonConfig.nodeArgs, '--test'];
const sharedArgs = [...pkg.nodemonConfig.nodeArgs, '--test'];
const env = {
PATH: process.env.PATH,
@@ -21,7 +21,7 @@ const env = {
};
if (process.argv[2] === 'all') {
const cp = spawn('node', [...args, resolve(testDir, '*')], {
const cp = spawn('node', [...sharedArgs, resolve(testDir, '*')], {
cwd: root,
env,
stdio: 'inherit',
@@ -44,12 +44,21 @@ if (process.argv[2] === 'all') {
const target = resolve(testDir, result.file);
const cp = spawn('node', [...args, target], {
cwd: root,
env,
stdio: 'inherit',
shell: true,
});
const cp = spawn(
'node',
[
...sharedArgs,
'--test-reporter=spec',
'--test-reporter-destination=stdout',
target,
],
{
cwd: root,
env,
stdio: 'inherit',
shell: true,
}
);
cp.on('exit', code => {
process.exit(code ?? 0);
});

View File

@@ -1,3 +1,4 @@
/// <reference types="./global.d.ts" />
import { Module } from '@nestjs/common';
import { ConfigModule } from './config';

View File

@@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
*/
export interface AFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
/**
* Server Identity
*/
readonly serverId: string;
/**
* System version
*/
@@ -165,6 +169,28 @@ export interface AFFiNEConfig {
* authentication config
*/
auth: {
/**
* Application sign key secret
*/
readonly salt: string;
/**
* Application access token expiration time
*/
readonly accessTokenExpiresIn: string;
/**
* Application refresh token expiration time
*/
readonly refreshTokenExpiresIn: string;
/**
* Application public key
*
*/
readonly publicKey: string;
/**
* Application private key
*
*/
readonly privateKey: string;
/**
* whether allow user to signup with email directly
*/

View File

@@ -1,7 +1,23 @@
/// <reference types="../global.d.ts" />
import pkg from '../../package.json' assert { type: 'json' };
import type { AFFiNEConfig } from './def';
// Don't use this in production
export const examplePublicKey = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnxM+GhB6eNKPmTP6uH5Gpr+bmQ87
hHGeOiCsay0w/aPwMqzAOKkZGqX+HZ9BNGy/yiXmnscey5b2vOTzxtRvxA==
-----END PUBLIC KEY-----`;
// Don't use this in production
export const examplePrivateKey = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgWOog5SFXs1Vjh/WP
QCYPQKgf/jsNmWsvD+jYSn6mi3yhRANCAASfEz4aEHp40o+ZM/q4fkamv5uZDzuE
cZ46IKxrLTD9o/AyrMA4qRkapf4dn0E0bL/KJeaexx7Llva85PPG1G/E
-----END PRIVATE KEY-----`;
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
serverId: 'affine-nestjs-server',
version: pkg.version,
ENV_MAP: {},
env: process.env.NODE_ENV ?? 'development',
@@ -40,6 +56,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
debug: true,
},
auth: {
salt: '$2b$10$x4VDo2nmlo74yB5jflNhlu',
accessTokenExpiresIn: '1h',
refreshTokenExpiresIn: '7d',
publicKey: examplePublicKey,
privateKey: examplePrivateKey,
enableSignup: true,
enableOauth: false,
oauthProviders: {},

View File

@@ -66,4 +66,4 @@ export class ConfigModule {
};
}
export { AFFiNEConfig } from './def';
export type { AFFiNEConfig } from './def';

5
apps/server/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace Express {
interface Request {
user?: import('@prisma/client').User | null;
}
}

View File

@@ -17,4 +17,9 @@ const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bodyParser: true,
});
await app.listen(process.env.PORT ?? 3010);
const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ?? 3010;
await app.listen(port, host);
console.log(`Listening on http://${host}:${port}`);

View File

@@ -0,0 +1,82 @@
import {
CanActivate,
createParamDecorator,
ExecutionContext,
Injectable,
UseGuards,
} from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { getRequestResponseFromContext } from '../../utils/nestjs';
import { AuthService } from './service';
export function getUserFromContext(context: ExecutionContext) {
const req = getRequestResponseFromContext(context).req;
return req.user;
}
/**
* Used to fetch current user from the request context.
*
* > The user may be undefined if authorization token is not provided.
*
* @example
*
* ```typescript
* // Graphql Query
* \@Query(() => UserType)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*
* ```typescript
* // HTTP Controller
* \@Get('/user)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*/
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getUserFromContext(context);
}
);
@Injectable()
class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private prisma: PrismaService) {}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
const token = req.headers.authorization;
if (!token) {
return false;
}
const claims = this.auth.verify(token);
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
return !!req.user;
}
}
/**
* This guard is used to protect routes/queries/mutations that require a user to be logged in.
*
* The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
* fast throw if user is not logged in.
*
* @example
*
* ```typescript
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: User) {
* return user;
* }
* ```
*/
export const Auth = () => {
return UseGuards(AuthGuard);
};

View File

@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
@Global()
@Module({
providers: [AuthService, AuthResolver],
exports: [AuthService],
})
export class AuthModule {}
export * from './guard';

View File

@@ -0,0 +1,53 @@
import { ForbiddenException } from '@nestjs/common';
import {
Args,
Context,
Field,
Mutation,
ObjectType,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { Request } from 'express';
import { UserType } from '../users/resolver';
import { CurrentUser } from './guard';
import { AuthService } from './service';
@ObjectType()
export class TokenType {
@Field()
token!: string;
@Field()
refresh!: string;
}
@Resolver(() => UserType)
export class AuthResolver {
constructor(private auth: AuthService) {}
@ResolveField(() => TokenType)
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
if (user !== currentUser) {
throw new ForbiddenException();
}
return {
token: this.auth.sign(user),
refresh: this.auth.refresh(user),
};
}
@Mutation(() => UserType)
async signIn(
@Context() ctx: { req: Request },
@Args('email') email: string,
@Args('password') password: string
) {
const user = await this.auth.signIn(email, password);
ctx.req.user = user;
return user;
}
}

View File

@@ -0,0 +1,92 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { User } from '@prisma/client';
import { compare, hash } from 'bcrypt';
import jwt from 'jsonwebtoken';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
@Injectable()
export class AuthService {
constructor(private config: Config, private prisma: PrismaService) {}
sign(user: UserClaim) {
return jwt.sign(user, this.config.auth.privateKey, {
algorithm: 'ES256',
subject: user.id,
issuer: this.config.serverId,
expiresIn: this.config.auth.accessTokenExpiresIn,
});
}
refresh(user: UserClaim) {
return jwt.sign(user, this.config.auth.privateKey, {
algorithm: 'ES256',
subject: user.id,
issuer: this.config.serverId,
expiresIn: this.config.auth.refreshTokenExpiresIn,
});
}
verify(token: string) {
try {
return jwt.verify(token, this.config.auth.publicKey, {
algorithms: ['ES256'],
}) as UserClaim;
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
}
async signIn(email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
if (!user.password) {
throw new BadRequestException('User has no password');
}
const equal = await compare(password, user.password);
if (!equal) {
throw new UnauthorizedException('Invalid password');
}
return user;
}
async register(name: string, email: string, password: string): Promise<User> {
const hashedPassword = await hash(password, this.config.auth.salt);
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (user) {
throw new BadRequestException('Email already exists');
}
return this.prisma.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
}
}

View File

@@ -1,4 +1,5 @@
import { AuthModule } from './auth';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
export const BusinessModules = [WorkspaceModule, UsersModule];
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];

View File

@@ -1,36 +1,36 @@
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
import type { users } from '@prisma/client';
import type { User } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
@ObjectType()
export class User implements users {
export class UserType implements Partial<User> {
@Field(() => ID)
id!: string;
@Field({ description: 'User name' })
name!: string;
@Field({ description: 'User email' })
email!: string;
@Field({ description: 'User password', nullable: true })
password!: string;
@Field({ description: 'User avatar url', nullable: true })
avatar_url!: string;
@Field({ description: 'User token nonce', nullable: true })
token_nonce!: number;
avatarUrl!: string;
@Field({ description: 'User created date', nullable: true })
created_at!: Date;
createdAt!: Date;
}
@Resolver(() => User)
@Resolver(() => UserType)
export class UserResolver {
constructor(private readonly prisma: PrismaService) {}
@Query(() => User, {
@Query(() => UserType, {
name: 'user',
description: 'Get user by email',
})
async user(@Args('email') email: string) {
return this.prisma.users.findUnique({
return this.prisma.user.findUnique({
where: { email },
});
}

View File

@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
providers: [WorkspaceResolver],
providers: [WorkspaceResolver, PermissionService],
exports: [PermissionService],
})
export class WorkspaceModule {}
export { WorkspaceType } from './resolver';

View File

@@ -0,0 +1,134 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma';
import { Permission } from './types';
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
async get(ws: string, user: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
userId: user,
accepted: true,
},
});
return data?.type as Permission;
}
async check(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
if (!(await this.tryCheck(ws, user, permission))) {
throw new ForbiddenException('Permission denied');
}
}
async tryCheck(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.count({
where: {
workspaceId: ws,
userId: user,
accepted: true,
type: {
gte: permission,
},
},
});
if (data > 0) {
return true;
}
// If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
return data > 0;
}
return false;
}
async grant(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
userId: user,
accepted: true,
},
});
if (data) {
const [p] = await this.prisma.$transaction(
[
this.prisma.userWorkspacePermission.update({
where: {
id: data.id,
},
data: {
type: permission,
},
}),
// If the new permission is owner, we need to revoke old owner
permission === Permission.Owner
? this.prisma.userWorkspacePermission.updateMany({
where: {
workspaceId: ws,
type: Permission.Owner,
userId: {
not: user,
},
},
data: {
type: Permission.Admin,
},
})
: null,
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p;
}
return this.prisma.userWorkspacePermission.create({
data: {
workspaceId: ws,
userId: user,
type: permission,
},
});
}
async revoke(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: Permission.Owner,
},
},
});
return result.count > 0;
}
}

View File

@@ -1,85 +1,206 @@
import { randomUUID } from 'node:crypto';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
ID,
InputType,
Int,
Mutation,
ObjectType,
Parent,
PartialType,
PickType,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { workspaces } from '@prisma/client';
import type { User, Workspace } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
import { PrismaService } from '../../prisma';
import { Auth, CurrentUser } from '../auth';
import { UserType } from '../users/resolver';
import { PermissionService } from './permission';
import { Permission } from './types';
export enum WorkspaceType {
Private = 0,
Normal = 1,
}
registerEnumType(WorkspaceType, {
name: 'WorkspaceType',
description: 'Workspace type',
valuesMap: {
Normal: {
description: 'Normal workspace',
},
Private: {
description: 'Private workspace',
},
},
registerEnumType(Permission, {
name: 'Permission',
description: 'User permission in workspace',
});
@ObjectType()
export class Workspace implements workspaces {
export class WorkspaceType implements Partial<Workspace> {
@Field(() => ID)
id!: string;
@Field({ description: 'is Public workspace' })
public!: boolean;
@Field(() => WorkspaceType, { description: 'Workspace type' })
type!: WorkspaceType;
@Field({ description: 'Workspace created date' })
created_at!: Date;
createdAt!: Date;
}
@Resolver(() => Workspace)
@InputType()
export class UpdateWorkspaceInput extends PickType(
PartialType(WorkspaceType),
['public'],
InputType
) {
@Field(() => ID)
id!: string;
}
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService
) {}
// debug only query should be removed
@Query(() => [Workspace], {
name: 'workspaces',
description: 'Get all workspaces',
@ResolveField(() => Permission, {
description: 'Permission of current signed in user in workspace',
complexity: 2,
})
async workspaces() {
return this.prisma.workspaces.findMany();
async permission(
@CurrentUser() user: User,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
if ('permission' in workspace) {
return workspace.permission;
}
const permission = this.permissionProvider.get(workspace.id, user.id);
if (!permission) {
throw new ForbiddenException();
}
return permission;
}
@Query(() => Workspace, {
name: 'workspace',
description: 'Get workspace by id',
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
async workspace(@Args('id') id: string) {
return this.prisma.workspaces.findUnique({
where: { id },
});
}
// create workspace
@Mutation(() => Workspace, {
name: 'createWorkspace',
description: 'Create workspace',
})
async createWorkspace() {
return this.prisma.workspaces.create({
data: {
id: randomUUID(),
type: WorkspaceType.Private,
public: false,
created_at: new Date(),
memberCount(@Parent() workspace: WorkspaceType) {
return this.prisma.userWorkspacePermission.count({
where: {
workspaceId: workspace.id,
accepted: true,
},
});
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId: workspace.id,
type: Permission.Owner,
},
include: {
user: true,
},
});
return data.user;
}
@Query(() => [WorkspaceType], {
description: 'Get all accessible workspaces for current user',
complexity: 2,
})
async workspaces(@CurrentUser() user: User) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
userId: user.id,
accepted: true,
},
include: {
workspace: true,
},
});
return data.map(({ workspace, type }) => {
return {
...workspace,
permission: type,
};
});
}
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id);
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
if (!workspace) {
throw new NotFoundException("Workspace doesn't exist");
}
return workspace;
}
@Mutation(() => WorkspaceType, {
description: 'Create a new workspace',
})
async createWorkspace(@CurrentUser() user: User) {
return this.prisma.workspace.create({
data: {
public: false,
users: {
create: {
type: Permission.Owner,
user: {
connect: {
id: user.id,
},
},
accepted: true,
},
},
},
});
}
@Mutation(() => WorkspaceType, {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: User,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
await this.permissionProvider.check('id', user.id, Permission.Admin);
return this.prisma.workspace.update({
where: {
id,
},
data: updates,
});
}
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
where: {
id,
},
});
// TODO:
// delete all related data, like websocket connections, blobs, etc.
return true;
}
}

View File

@@ -0,0 +1,6 @@
export enum Permission {
Read = 0,
Write = 1,
Admin = 10,
Owner = 99,
}

View File

@@ -8,3 +8,4 @@ import { PrismaService } from './service';
exports: [PrismaService],
})
export class PrismaModule {}
export { PrismaService } from './service';

View File

@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, test } from 'node:test';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
import request from 'supertest';
import { AppModule } from '../app';
@@ -12,10 +14,24 @@ const gql = '/graphql';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
// please run `ts-node-esm ./scripts/init-db.ts` before running this test
describe('AppModule', () => {
let app: INestApplication;
// cleanup database before each test
beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.user.create({
data: {
id: '1',
name: 'Alex Yang',
email: 'alex.yang@example.org',
password: await hash('123456', globalThis.AFFiNE.auth.salt),
},
});
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
@@ -34,22 +50,46 @@ describe('AppModule', () => {
.post(gql)
.send({
query: `
query {
error
}
`,
query {
error
}
`,
})
.expect(400);
let token;
await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
signIn(email: "alex.yang@example.org", password: "123456") {
token {
token
}
}
}
`,
})
.expect(200)
.expect(res => {
ok(
typeof res.body.data.signIn.token.token === 'string',
'res.body.data.signIn.token.token is not a string'
);
token = res.body.data.signIn.token.token;
});
await request(app.getHttpServer())
.post(gql)
.set({ Authorization: token })
.send({
query: `
mutation {
createWorkspace {
id
type
public
created_at
createdAt
}
}
`,
@@ -64,16 +104,12 @@ describe('AppModule', () => {
typeof res.body.data.createWorkspace.id === 'string',
'res.body.data.createWorkspace.id is not a string'
);
ok(
typeof res.body.data.createWorkspace.type === 'string',
'res.body.data.createWorkspace.type is not a string'
);
ok(
typeof res.body.data.createWorkspace.public === 'boolean',
'res.body.data.createWorkspace.public is not a boolean'
);
ok(
typeof res.body.data.createWorkspace.created_at === 'string',
typeof res.body.data.createWorkspace.createdAt === 'string',
'res.body.data.createWorkspace.created_at is not a string'
);
});
@@ -87,7 +123,7 @@ describe('AppModule', () => {
query {
user(email: "alex.yang@example.org") {
email
avatar_url
avatarUrl
}
}
`,

View File

@@ -0,0 +1,82 @@
import { ok, throws } from 'node:assert';
import { beforeEach, test } from 'node:test';
import { UnauthorizedException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { Config, ConfigModule } from '../config';
import { getDefaultAFFiNEConfig } from '../config/default';
import { GqlModule } from '../graphql.module';
import { AuthModule } from '../modules/auth';
import { AuthService } from '../modules/auth/service';
import { PrismaModule } from '../prisma';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
let auth: AuthService;
let config: Config;
// cleanup database before each test
beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: '1s',
refreshTokenExpiresIn: '3s',
},
}),
PrismaModule,
GqlModule,
AuthModule,
],
}).compile();
config = module.get(Config);
auth = module.get(AuthService);
});
async function sleep(ms: number) {
return new Promise<void>(resolve => {
setTimeout(resolve, ms);
});
}
test('should be able to register and signIn', async () => {
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456');
});
test('should be able to verify', async () => {
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456');
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
};
{
const token = auth.sign(user);
const clain = auth.verify(token);
ok(clain.id === '1');
ok(clain.name === 'Alex Yang');
ok(clain.email === 'alexyang@example.org');
await sleep(1050);
throws(() => auth.verify(token), UnauthorizedException, 'Invalid token');
}
{
const token = auth.refresh(user);
const clain = auth.verify(token);
ok(clain.id === '1');
ok(clain.name === 'Alex Yang');
ok(clain.email === 'alexyang@example.org');
await sleep(3050);
throws(() => auth.verify(token), UnauthorizedException, 'Invalid token');
}
});

View File

@@ -0,0 +1,57 @@
import { ArgumentsHost, ExecutionContext } from '@nestjs/common';
import {
GqlArgumentsHost,
GqlContextType,
GqlExecutionContext,
} from '@nestjs/graphql';
import { Request, Response } from 'express';
export function getRequestResponseFromContext(context: ExecutionContext) {
switch (context.getType<GqlContextType>()) {
case 'graphql': {
const gqlContext = GqlExecutionContext.create(context).getContext<{
req: Request;
}>();
return {
req: gqlContext.req,
res: gqlContext.req.res!,
};
}
case 'http': {
const http = context.switchToHttp();
return {
req: http.getRequest<Request>(),
res: http.getResponse<Response>(),
};
}
default:
throw new Error('Unknown context type for getting request and response');
}
}
export function getRequestResponseFromHost(host: ArgumentsHost) {
switch (host.getType<GqlContextType>()) {
case 'graphql': {
const gqlContext = GqlArgumentsHost.create(host).getContext<{
req: Request;
}>();
return {
req: gqlContext.req,
res: gqlContext.req.res!,
};
}
case 'http': {
const http = host.switchToHttp();
return {
req: http.getRequest<Request>(),
res: http.getResponse<Response>(),
};
}
default:
throw new Error('Unknown host type for getting request and response');
}
}
export function getRequestFromHost(host: ArgumentsHost) {
return getRequestResponseFromHost(host).req;
}

View File

@@ -7,5 +7,5 @@
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["./scripts", "package.json"]
"include": ["scripts", "package.json"]
}

View File

@@ -7,14 +7,15 @@ import { withSentryConfig } from '@sentry/nextjs';
import SentryWebpackPlugin from '@sentry/webpack-plugin';
import debugLocal from 'next-debug-local';
import preset from './preset.config.mjs';
import { blockSuiteFeatureFlags, buildFlags } from './preset.config.mjs';
import { getCommitHash, getGitVersion } from './scripts/gitInfo.mjs';
const require = createRequire(import.meta.url);
const { createVanillaExtractPlugin } = require('@vanilla-extract/next-plugin');
const withVanillaExtract = createVanillaExtractPlugin();
console.info('Runtime Preset', preset);
console.info('Build Flags', buildFlags);
console.info('Editor Flags', blockSuiteFeatureFlags);
const enableDebugLocal = path.isAbsolute(process.env.LOCAL_BLOCK_SUITE ?? '');
@@ -84,10 +85,11 @@ const nextConfig = {
'@affine/debug',
'@affine/env',
'@affine/templates',
'@toeverything/hooks',
'@affine/workspace',
'@affine/jotai',
'@toeverything/hooks',
'@toeverything/y-indexeddb',
'@toeverything/theme',
],
publicRuntimeConfig: {
PROJECT_NAME: process.env.npm_package_name ?? 'AFFiNE',
@@ -98,7 +100,8 @@ const nextConfig = {
profileTarget[process.env.API_SERVER_PROFILE || 'dev'] ??
profileTarget.dev,
editorVersion: require('./package.json').dependencies['@blocksuite/editor'],
...preset,
editorFlags: blockSuiteFeatureFlags,
...buildFlags,
},
webpack: (config, { dev, isServer }) => {
config.experiments = { ...config.experiments, topLevelAwait: true };
@@ -144,7 +147,9 @@ const nextConfig = {
},
basePath: process.env.NEXT_BASE_PATH,
assetPrefix: process.env.NEXT_ASSET_PREFIX,
pageExtensions: [...(preset.enableDebugPage ? ['tsx', 'dev.tsx'] : ['tsx'])],
pageExtensions: [
...(buildFlags.enableDebugPage ? ['tsx', 'dev.tsx'] : ['tsx']),
],
};
const baseDir = process.env.LOCAL_BLOCK_SUITE ?? '/';

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/web",
"private": true,
"version": "0.5.4-canary.7",
"version": "0.5.4-canary.15",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -14,56 +14,58 @@
"@affine/component": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*",
"@blocksuite/blocks": "0.0.0-20230420160324-857b396c-nightly",
"@blocksuite/editor": "0.0.0-20230420160324-857b396c-nightly",
"@blocksuite/global": "0.0.0-20230420160324-857b396c-nightly",
"@blocksuite/icons": "^2.1.10",
"@blocksuite/store": "0.0.0-20230420160324-857b396c-nightly",
"@blocksuite/blocks": "0.0.0-20230427041825-7fff957d-nightly",
"@blocksuite/editor": "0.0.0-20230427041825-7fff957d-nightly",
"@blocksuite/global": "0.0.0-20230427041825-7fff957d-nightly",
"@blocksuite/icons": "^2.1.13",
"@blocksuite/store": "0.0.0-20230427041825-7fff957d-nightly",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@emotion/cache": "^11.10.7",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.6",
"@mui/material": "^5.12.0",
"@mui/material": "^5.12.2",
"@react-hookz/web": "^23.0.0",
"@sentry/nextjs": "^7.48.0",
"@sentry/nextjs": "^7.50.0",
"@toeverything/hooks": "workspace:*",
"cmdk": "^0.2.0",
"css-spring": "^4.1.0",
"dayjs": "^1.11.7",
"graphql": "^16.6.0",
"jotai": "^2.0.4",
"jotai-devtools": "^0.4.0",
"lit": "^2.7.2",
"lit": "^2.7.3",
"lottie-web": "^5.11.0",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-is": "^18.2.0",
"swr": "^2.1.3",
"swr": "^2.1.5",
"y-protocols": "^1.0.5",
"yjs": "^13.5.52",
"yjs": "^13.6.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@perfsee/webpack": "^1.5.0",
"@perfsee/webpack": "^1.6.0",
"@redux-devtools/extension": "^3.2.5",
"@rich-data/viewer": "^2.15.6",
"@sentry/webpack-plugin": "^1.20.0",
"@sentry/webpack-plugin": "^1.20.1",
"@swc-jotai/debug-label": "^0.0.9",
"@swc-jotai/react-refresh": "^0.0.7",
"@types/react": "^18.0.35",
"@types/react-dom": "^18.0.11",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.1",
"@types/webpack-env": "^1.18.0",
"@vanilla-extract/css": "^1.11.0",
"@vanilla-extract/next-plugin": "^2.1.2",
"dotenv": "^16.0.3",
"eslint": "^8.38.0",
"eslint-config-next": "^13.3.0",
"eslint": "^8.39.0",
"eslint-config-next": "^13.3.1",
"next": "=13.2.3",
"next-debug-local": "^0.1.5",
"next-router-mock": "^0.9.3",
@@ -71,7 +73,7 @@
"redux": "^4.2.1",
"swc-plugin-coverage-instrument": "=0.0.14",
"typescript": "^5.0.4",
"webpack": "^5.79.0"
"webpack": "^5.81.0"
},
"stableVersion": "0.0.0"
}

View File

@@ -1,6 +1,23 @@
// @ts-check
import 'dotenv/config';
const config = {
/**
* @type {import('@affine/env').BlockSuiteFeatureFlags}
*/
export const blockSuiteFeatureFlags = {
enable_database: true,
enable_slash_menu: true,
enable_edgeless_toolbar: true,
enable_block_hub: true,
enable_drag_handle: true,
enable_surface: true,
enable_linked_page: true,
};
/**
* @type {import('@affine/env').BuildFlags}
*/
export const buildFlags = {
enableLegacyCloud: process.env.ENABLE_LEGACY_PROVIDER
? process.env.ENABLE_LEGACY_PROVIDER === 'true'
: true,
@@ -11,4 +28,3 @@ const config = {
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development'
),
};
export default config;

View File

@@ -4,8 +4,8 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 1.0,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
replaysOnErrorSampleRate: 0.1,
integrations: [new Sentry.Replay()],
});

View File

@@ -4,5 +4,5 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 1.0,
tracesSampleRate: 0.1,
});

View File

@@ -4,5 +4,5 @@ const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 1.0,
tracesSampleRate: 0.1,
});

View File

@@ -1,19 +1,14 @@
import { atomWithSyncStorage } from '@affine/jotai';
import { atomWithStorage } from 'jotai/utils';
export type Visibility = Record<string, boolean>;
const DEFAULT_VALUE = '0.0.0';
export const lastVersionAtom = atomWithSyncStorage(
'lastVersion',
DEFAULT_VALUE
);
export const guideHiddenAtom = atomWithSyncStorage<Visibility>(
'guideHidden',
{}
);
export const lastVersionAtom = atomWithStorage('lastVersion', DEFAULT_VALUE);
export const guideHiddenUntilNextUpdateAtom = atomWithSyncStorage<Visibility>(
export const guideHiddenAtom = atomWithStorage<Visibility>('guideHidden', {});
export const guideHiddenUntilNextUpdateAtom = atomWithStorage<Visibility>(
'guideHiddenUntilNextUpdate',
{}
);

View File

@@ -7,6 +7,7 @@ import {
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom';
import { WorkspaceFlavour } from '@affine/workspace/type';
import type { Page } from '@blocksuite/store';
import { atom } from 'jotai';
@@ -46,6 +47,21 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
}
return metadata;
});
if (environment.isDesktop) {
window.apis.workspace.list().then(workspaceIDs => {
const newMetadata = workspaceIDs.map(w => ({
id: w,
flavour: WorkspaceFlavour.LOCAL,
}));
setAtom(metadata => {
return [
...metadata,
...newMetadata.filter(m => !metadata.find(m2 => m2.id === m.id)),
];
});
});
}
};
/**

View File

@@ -1,3 +1,5 @@
import type { BlockSuiteFeatureFlags } from '@affine/env';
import { config } from '@affine/env';
import type { AffinePublicWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
@@ -23,11 +25,14 @@ function createPublicWorkspace(
blockSuiteWorkspace.doc,
new Uint8Array(binary)
);
Object.entries(config.editorFlags).forEach(([key, value]) => {
blockSuiteWorkspace.awarenessStore.setFlag(
key as keyof BlockSuiteFeatureFlags,
value
);
});
// force disable some features
blockSuiteWorkspace.awarenessStore.setFlag('enable_block_hub', false);
blockSuiteWorkspace.awarenessStore.setFlag('enable_set_remote_flag', false);
blockSuiteWorkspace.awarenessStore.setFlag('enable_database', true);
blockSuiteWorkspace.awarenessStore.setFlag('enable_edgeless_toolbar', false);
blockSuiteWorkspace.awarenessStore.setFlag('enable_slash_menu', false);
blockSuiteWorkspace.awarenessStore.setFlag('enable_drag_handle', false);
return {
flavour: WorkspaceFlavour.PUBLIC,

View File

@@ -25,14 +25,19 @@ export const createAffineDownloadProvider = (
);
return;
}
affineApis.downloadWorkspace(id, false).then(binary => {
hashMap.set(id, binary);
providerLogger.debug('applyUpdate');
BlockSuiteWorkspace.Y.applyUpdate(
blockSuiteWorkspace.doc,
new Uint8Array(binary)
);
});
affineApis
.downloadWorkspace(id, false)
.then(binary => {
hashMap.set(id, binary);
providerLogger.debug('applyUpdate');
BlockSuiteWorkspace.Y.applyUpdate(
blockSuiteWorkspace.doc,
new Uint8Array(binary)
);
})
.catch(e => {
providerLogger.error('downloadWorkspace', e);
});
},
disconnect: () => {
providerLogger.info('disconnect download provider', id);

View File

@@ -26,9 +26,9 @@ const createAffineWebSocketProvider = (
blockSuiteWorkspace.doc,
{
params: { token: getLoginStorage()?.token ?? '' },
// @ts-expect-error ignore the type
awareness: blockSuiteWorkspace.awarenessStore.awareness,
// we maintain broadcast channel by ourselves
// @ts-expect-error
disableBc: true,
connect: false,
}

View File

@@ -1,237 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
import type { PageMeta } from '@blocksuite/store';
import matchers from '@testing-library/jest-dom/matchers';
import type { RenderResult } from '@testing-library/react';
import { render, renderHook } from '@testing-library/react';
import { createStore, getDefaultStore, Provider } from 'jotai';
import type { FC, PropsWithChildren } from 'react';
import { beforeEach, describe, expect, test } from 'vitest';
import { workspacesAtom } from '../../atoms';
import { rootCurrentWorkspaceAtom } from '../../atoms/root';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useAppHelper } from '../../hooks/use-workspaces';
import { ThemeProvider } from '../../providers/ThemeProvider';
import type { BlockSuiteWorkspace } from '../../shared';
import type { PinboardProps } from '../pure/workspace-slider-bar/Pinboard';
import Pinboard from '../pure/workspace-slider-bar/Pinboard';
expect.extend(matchers);
let store = getDefaultStore();
beforeEach(async () => {
store = createStore();
await store.get(workspacesAtom);
});
const ProviderWrapper: FC<PropsWithChildren> = ({ children }) => {
return <Provider store={store}>{children}</Provider>;
};
const initPinBoard = async () => {
// create one workspace with 2 root pages and 2 pinboard pages
// - hasPinboardPage
// - hasPinboardPage
// - pinboard1
// - pinboard2
// - noPinboardPage
const mutationHook = renderHook(() => useAppHelper(), {
wrapper: ProviderWrapper,
});
const rootPageIds = ['hasPinboardPage', 'noPinboardPage'];
const pinboardPageIds = ['pinboard1', 'pinboard2'];
const id = await mutationHook.result.current.createLocalWorkspace('test0');
store.set(rootCurrentWorkspaceIdAtom, id);
await store.get(workspacesAtom);
await store.get(rootCurrentWorkspaceAtom);
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace(), {
wrapper: ProviderWrapper,
});
currentWorkspaceHook.result.current[1](id);
const currentWorkspace = await store.get(rootCurrentWorkspaceAtom);
const blockSuiteWorkspace =
currentWorkspace?.blockSuiteWorkspace as BlockSuiteWorkspace;
mutationHook.rerender();
// create root pinboard
mutationHook.result.current.createWorkspacePage(id, 'rootPinboard');
blockSuiteWorkspace.meta.setPageMeta('rootPinboard', {
isRootPinboard: true,
subpageIds: rootPageIds,
});
// create parent
rootPageIds.forEach(rootPageId => {
mutationHook.result.current.createWorkspacePage(id, rootPageId);
blockSuiteWorkspace.meta.setPageMeta(rootPageId, {
subpageIds: rootPageId === rootPageIds[0] ? pinboardPageIds : [],
});
});
// create children to first parent
pinboardPageIds.forEach(pinboardId => {
mutationHook.result.current.createWorkspacePage(id, pinboardId);
blockSuiteWorkspace.meta.setPageMeta(pinboardId, {
title: pinboardId,
});
});
const App = (props: PinboardProps) => {
return (
<ThemeProvider>
<ProviderWrapper>
<Pinboard {...props} />
</ProviderWrapper>
</ThemeProvider>
);
};
const app = render(
<App
blockSuiteWorkspace={blockSuiteWorkspace as BlockSuiteWorkspace}
allMetas={blockSuiteWorkspace.meta.pageMetas as PageMeta[]}
openPage={() => {}}
/>
);
return {
rootPageIds,
pinboardPageIds,
app,
blockSuiteWorkspace,
};
};
const openOperationMenu = async (app: RenderResult, pageId: string) => {
const rootPinboard = await app.findByTestId(`pinboard-${pageId}`);
const operationBtn = (await rootPinboard.querySelector(
'[data-testid="pinboard-operation-button"]'
)) as HTMLElement;
await operationBtn.click();
const menu = await app.findByTestId('pinboard-operation-menu');
expect(menu).toBeInTheDocument();
};
describe('PinBoard', () => {
test('add pinboard', async () => {
const { app, blockSuiteWorkspace, rootPageIds } = await initPinBoard();
const [hasChildrenPageId] = rootPageIds;
await openOperationMenu(app, hasChildrenPageId);
const addBtn = await app.findByTestId('pinboard-operation-add');
await addBtn.click();
const hasChildrenPageMeta =
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
// Page meta have been added
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(6);
// New page meta is added in initial page meta
expect(hasChildrenPageMeta?.subpageIds.length).toBe(3);
app.unmount();
});
test('delete pinboard', async () => {
const {
app,
blockSuiteWorkspace,
rootPageIds: [hasChildrenPageId],
} = await initPinBoard();
await openOperationMenu(app, hasChildrenPageId);
const deleteBtn = await app.findByTestId(
'pinboard-operation-move-to-trash'
);
await deleteBtn.click();
const confirmBtn = await app.findByTestId('move-to-trash-confirm');
expect(confirmBtn).toBeInTheDocument();
await confirmBtn.click();
// Every page should be tagged as trash
expect(blockSuiteWorkspace.meta.pageMetas.filter(m => m.trash).length).toBe(
3
);
app.unmount();
});
test('rename pinboard', async () => {
const {
app,
rootPageIds: [hasChildrenPageId],
} = await initPinBoard();
await openOperationMenu(app, hasChildrenPageId);
const renameBtn = await app.findByTestId('pinboard-operation-rename');
await renameBtn.click();
const input = await app.findByTestId(`pinboard-input-${hasChildrenPageId}`);
expect(input).toBeInTheDocument();
// TODO: Fix this test
// fireEvent.change(input, { target: { value: 'tteesstt' } });
// expect(
// blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId)?.name
// ).toBe('tteesstt');
app.unmount();
});
test('move pinboard', async () => {
const {
app,
blockSuiteWorkspace,
rootPageIds: [hasChildrenPageId],
pinboardPageIds: [pinboardId1, pinboardId2],
} = await initPinBoard();
await openOperationMenu(app, pinboardId1);
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
await moveToBtn.click();
const pinboardMenu = await app.findByTestId('pinboard-menu');
expect(pinboardMenu).toBeInTheDocument();
await (
pinboardMenu.querySelector(
`[data-testid="pinboard-${pinboardId2}"]`
) as HTMLElement
).click();
const hasChildrenPageMeta =
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
expect(hasChildrenPageMeta?.subpageIds.includes(pinboardId2)).toBe(true);
app.unmount();
});
test('remove from pinboard', async () => {
const {
app,
blockSuiteWorkspace,
rootPageIds: [hasChildrenPageId],
pinboardPageIds: [pinboardId1],
} = await initPinBoard();
await openOperationMenu(app, pinboardId1);
const moveToBtn = await app.findByTestId('pinboard-operation-move-to');
await moveToBtn.click();
const removeFromPinboardBtn = await app.findByTestId(
'remove-from-pinboard-button'
);
removeFromPinboardBtn.click();
const hasPinboardPageMeta =
blockSuiteWorkspace.meta.getPageMeta(hasChildrenPageId);
expect(hasPinboardPageMeta?.subpageIds.length).toBe(1);
expect(hasPinboardPageMeta?.subpageIds.includes(pinboardId1)).toBe(false);
app.unmount();
});
});

View File

@@ -1,121 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import {
rootCurrentPageIdAtom,
rootCurrentWorkspaceIdAtom,
} from '@affine/workspace/atom';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { assertExists } from '@blocksuite/store';
import { render, renderHook } from '@testing-library/react';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { createStore, getDefaultStore, Provider, useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import type React from 'react';
import { useCallback } from 'react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { workspacesAtom } from '../../atoms';
import { rootCurrentWorkspaceAtom } from '../../atoms/root';
import {
currentWorkspaceAtom,
useCurrentWorkspace,
} from '../../hooks/current/use-current-workspace';
import { useAppHelper } from '../../hooks/use-workspaces';
import { ThemeProvider } from '../../providers/ThemeProvider';
import { pathGenerator } from '../../shared';
import { WorkSpaceSliderBar } from '../pure/workspace-slider-bar';
vi.mock('../blocksuite/header/editor-mode-switch/CustomLottie', () => ({
default: (props: React.PropsWithChildren) => <>{props.children}</>,
}));
// fetchMocker.enableMocks();
let store = getDefaultStore();
beforeEach(async () => {
store = createStore();
await store.get(workspacesAtom);
});
const ProviderWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
return <Provider store={store}>{children}</Provider>;
};
describe('WorkSpaceSliderBar', () => {
test('basic', async () => {
// fetchMocker.mock
const onOpenWorkspaceListModalFn = vi.fn();
const onOpenQuickSearchModalFn = vi.fn();
const mutationHook = renderHook(() => useAppHelper(), {
wrapper: ProviderWrapper,
});
const id = await mutationHook.result.current.createLocalWorkspace('test0');
await store.get(workspacesAtom);
mutationHook.rerender();
mutationHook.result.current.createWorkspacePage(id, 'test1');
store.set(rootCurrentWorkspaceIdAtom, id);
await store.get(rootCurrentWorkspaceAtom);
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace(), {
wrapper: ProviderWrapper,
});
let i = 0;
const Component = () => {
const [currentWorkspace] = useCurrentWorkspace();
const currentPageId = useAtomValue(rootCurrentPageIdAtom);
assertExists(currentWorkspace);
const helper = useBlockSuiteWorkspaceHelper(
currentWorkspace.blockSuiteWorkspace
);
return (
<WorkSpaceSliderBar
currentWorkspace={currentWorkspace}
currentPageId={currentPageId}
onOpenQuickSearchModal={onOpenQuickSearchModalFn}
onOpenWorkspaceListModal={onOpenWorkspaceListModalFn}
openPage={useCallback(() => {}, [])}
createPage={() => {
i++;
return helper.createPage('page-test-' + i);
}}
currentPath={useRouter().asPath}
paths={pathGenerator}
isPublicWorkspace={false}
/>
);
};
const App = () => {
return (
<ThemeProvider>
<ProviderWrapper>
<Component />
</ProviderWrapper>
</ThemeProvider>
);
};
currentWorkspaceHook.result.current[1](id);
const currentWorkspace = await store.get(currentWorkspaceAtom);
expect(currentWorkspace).toBeDefined();
expect(currentWorkspace?.flavour).toBe(WorkspaceFlavour.LOCAL);
expect(currentWorkspace?.id).toBe(id);
const app = render(<App />);
const card = await app.findByTestId('current-workspace');
expect(onOpenWorkspaceListModalFn).toBeCalledTimes(0);
card.click();
expect(onOpenWorkspaceListModalFn).toBeCalledTimes(1);
const newPageButton = await app.findByTestId('new-page-button');
newPageButton.click();
expect(
currentWorkspaceHook.result.current[0]?.blockSuiteWorkspace.meta
.pageMetas[1].id
).toBe('page-test-1');
expect(onOpenQuickSearchModalFn).toBeCalledTimes(0);
const quickSearchButton = await app.findByTestId(
'slider-bar-quick-search-button'
);
quickSearchButton.click();
expect(onOpenQuickSearchModalFn).toBeCalledTimes(1);
});
});

View File

@@ -20,7 +20,7 @@ export const Export = ({
return (
<Menu
width={248}
placement="left-start"
// placement="left-start"
trigger="click"
content={
<>

View File

@@ -17,6 +17,9 @@ export type MoveToProps = CommonMenuItemProps<{
blockSuiteWorkspace: BlockSuiteWorkspace;
};
/**
* @deprecated
*/
export const MoveTo = ({
metas,
currentMeta,
@@ -46,7 +49,7 @@ export const MoveTo = ({
<PinboardMenu
anchorEl={anchorEl}
open={open}
placement="left-start"
// placement="left-start"
metas={metas}
currentMeta={currentMeta}
blockSuiteWorkspace={blockSuiteWorkspace}

View File

@@ -3,9 +3,9 @@ import { Input, PureMenu, TreeView } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { RemoveIcon, SearchIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
import React, { useCallback, useMemo, useState } from 'react';
import { useReferenceLinkHelper } from '../../../../hooks/affine/use-reference-link-helper';
import { usePinboardData } from '../../../../hooks/use-pinboard-data';
import { usePinboardHandler } from '../../../../hooks/use-pinboard-handler';
import type { BlockSuiteWorkspace } from '../../../../shared';
@@ -20,13 +20,13 @@ import {
} from '../styles';
import { SearchContent } from './SearchContent';
export type PinboardMenuProps = {
export interface PinboardMenuProps extends PureMenuProps {
metas: PageMeta[];
currentMeta: PageMeta;
blockSuiteWorkspace: BlockSuiteWorkspace;
showRemovePinboard?: boolean;
onPinboardClick?: (p: { dragId: string; dropId: string }) => void;
} & PureMenuProps;
}
export const PinboardMenu = ({
metas: propsMetas,
@@ -41,13 +41,13 @@ export const PinboardMenu = ({
[currentMeta.id, propsMetas]
);
const { t } = useTranslation();
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const [query, setQuery] = useState('');
const isSearching = query.length > 0;
const searchResult = metas.filter(
meta => !meta.trash && meta.title.includes(query)
);
const { removeReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace);
const { dropPin } = usePinboardHandler({
blockSuiteWorkspace,
@@ -117,16 +117,7 @@ export const PinboardMenu = ({
<StyledPinboard
data-testid={'remove-from-pinboard-button'}
onClick={() => {
const parentMeta = metas.find(m =>
m.subpageIds.includes(currentMeta.id)
);
if (!parentMeta) return;
const newSubpageIds = [...parentMeta.subpageIds];
const deleteIndex = newSubpageIds.findIndex(
id => id === currentMeta.id
);
newSubpageIds.splice(deleteIndex, 1);
setPageMeta(parentMeta.id, { subpageIds: newSubpageIds });
removeReferenceLink(currentMeta.id);
}}
>
<RemoveIcon />

View File

@@ -0,0 +1,22 @@
import { PlusIcon } from '@blocksuite/icons';
import { StyledOperationButton } from '../styles';
import type { OperationButtonProps } from './OperationButton';
export const AddButton = ({
onAdd,
visible,
}: Pick<OperationButtonProps, 'onAdd' | 'visible'>) => {
return (
<StyledOperationButton
visible={visible}
size="small"
onClick={e => {
e.stopPropagation();
onAdd();
}}
>
<PlusIcon />
</StyledOperationButton>
);
};

View File

@@ -1,9 +1,4 @@
import {
baseTheme,
MenuItem,
MuiClickAwayListener,
PureMenu,
} from '@affine/component';
import { MenuItem, MuiClickAwayListener, PureMenu } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
MoreVerticalIcon,
@@ -12,6 +7,7 @@ import {
PlusIcon,
} from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { baseTheme } from '@toeverything/theme';
import { useMemo, useRef, useState } from 'react';
import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper';
@@ -28,7 +24,7 @@ export type OperationButtonProps = {
metas: PageMeta[];
currentMeta: PageMeta;
blockSuiteWorkspace: BlockSuiteWorkspace;
isHover: boolean;
visible: boolean;
onRename?: () => void;
onMenuClose?: () => void;
};
@@ -39,7 +35,7 @@ export const OperationButton = ({
metas,
currentMeta,
blockSuiteWorkspace,
isHover,
visible,
onMenuClose,
onRename,
}: OperationButtonProps) => {
@@ -61,6 +57,7 @@ export const OperationButton = ({
}}
>
<div
style={{ display: 'flex' }}
onClick={e => {
e.stopPropagation();
}}
@@ -81,7 +78,7 @@ export const OperationButton = ({
onClick={() => {
setOperationMenuOpen(!operationMenuOpen);
}}
visible={isHover}
visible={visible}
>
<MoreVerticalIcon />
</StyledOperationButton>
@@ -91,7 +88,7 @@ export const OperationButton = ({
width={256}
anchorEl={anchorEl}
open={operationMenuOpen}
placement="bottom-start"
// placement="bottom-start"
zIndex={menuIndex}
>
<MenuItem
@@ -146,7 +143,7 @@ export const OperationButton = ({
<PinboardMenu
anchorEl={anchorEl}
open={pinboardMenuOpen}
placement="bottom-start"
// placement="bottom-start"
zIndex={menuIndex}
metas={metas}
currentMeta={currentMeta}

View File

@@ -4,7 +4,7 @@ import {
EdgelessIcon,
LevelIcon,
PageIcon,
PivotsIcon,
PinboardIcon,
} from '@blocksuite/icons';
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue } from 'jotai';
@@ -14,13 +14,14 @@ import { useMemo, useState } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms';
import type { PinboardNode } from '../../../../hooks/use-pinboard-data';
import { StyledCollapsedButton, StyledPinboard } from '../styles';
import { AddButton } from './AddButton';
import EmptyItem from './EmptyItem';
import { OperationButton } from './OperationButton';
const getIcon = (type: 'root' | 'edgeless' | 'page') => {
switch (type) {
case 'root':
return <PivotsIcon className="mode-icon" />;
return <PinboardIcon className="mode-icon" />;
case 'edgeless':
return <EdgelessIcon className="mode-icon" />;
default:
@@ -84,10 +85,8 @@ export const PinboardRender: PinboardNode['render'] = (
<ArrowDownSmallIcon />
</StyledCollapsedButton>
)}
{asPath && !isRoot ? <LevelIcon className="path-icon" /> : null}
{getIcon(isRoot ? 'root' : record[node.id])}
{showRename ? (
<Input
data-testid={`pinboard-input-${node.id}`}
@@ -106,6 +105,7 @@ export const PinboardRender: PinboardNode['render'] = (
) : (
<span>{isRoot ? 'Pinboard' : currentMeta.title || 'Untitled'}</span>
)}
{showOperationButton && <AddButton onAdd={onAdd} visible={isHover} />}
{showOperationButton && (
<OperationButton
@@ -115,7 +115,7 @@ export const PinboardRender: PinboardNode['render'] = (
metas={metas}
currentMeta={currentMeta!}
blockSuiteWorkspace={blockSuiteWorkspace!}
isHover={isHover}
visible={isHover}
onMenuClose={() => setIsHover(false)}
onRename={() => {
setShowRename(true);

View File

@@ -8,7 +8,7 @@ import {
export const StyledCollapsedButton = styled('button')<{
collapse: boolean;
show?: boolean;
}>(({ collapse, show = true, theme }) => {
}>(({ collapse, show = true }) => {
return {
width: '16px',
height: '100%',
@@ -43,7 +43,6 @@ export const StyledPinboard = styled('div')<{
disableCollapse,
disable = false,
active = false,
theme,
isOver,
textWrap = false,
}) => {
@@ -66,7 +65,7 @@ export const StyledPinboard = styled('div')<{
userSelect: 'none',
...(textWrap
? {
wordBreak: 'break-all',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}
: {}),
@@ -106,7 +105,7 @@ export const StyledOperationButton = styled(IconButton, {
};
});
export const StyledSearchContainer = styled('div')(({ theme }) => {
export const StyledSearchContainer = styled('div')(() => {
return {
width: 'calc(100% - 24px)',
margin: '0 auto',
@@ -125,7 +124,7 @@ export const StyledMenuContent = styled('div')(() => {
overflow: 'auto',
};
});
export const StyledMenuSubTitle = styled('div')(({ theme }) => {
export const StyledMenuSubTitle = styled('div')(() => {
return {
color: 'var(--affine-text-secondary-color)',
lineHeight: '36px',
@@ -133,7 +132,7 @@ export const StyledMenuSubTitle = styled('div')(({ theme }) => {
};
});
export const StyledMenuFooter = styled('div')(({ theme }) => {
export const StyledMenuFooter = styled('div')(() => {
return {
width: 'calc(100% - 24px)',
margin: '0 auto',

Some files were not shown because too many files have changed in this diff Show More