Compare commits

..

11 Commits

Author SHA1 Message Date
DarkSky
72df9cb457 feat: improve editor performance (#14429)
#### PR Dependency Tree


* **PR #14429** 👈

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

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

* **New Features**
* HTML import now splits lines on <br> into separate paragraphs while
preserving inline formatting.

* **Bug Fixes**
* Paste falls back to inserting after the first paragraph when no
explicit target is found.

* **Style**
  * Improved page-mode viewport styling for consistent content layout.

* **Tests**
* Added snapshot tests for <br>-based paragraph splitting; re-enabled an
e2e drag-page test.

* **Chores**
* Deferred/deduplicated font loading, inline text caching,
drag-handle/pointer optimizations, and safer inline render
synchronization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-14 00:43:36 +08:00
DarkSky
98e5747fdc feat: merge service 2026-02-13 21:52:11 +08:00
DarkSky
4460604dd3 fix: migration compatibility 2026-02-13 03:12:26 +08:00
DarkSky
b4be9118ad feat: doc status & share status (#14426)
#### PR Dependency Tree


* **PR #14426** 👈

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

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

* **New Features**
* Admin dashboard: view workspace analytics (storage, sync activity, top
shared links) with charts and configurable windows.
* Document analytics tab: see total/unique/guest views and trends over
selectable time windows.
* Last-accessed members: view who last accessed a document, with
pagination.
* Shared links analytics: browse and paginate all shared links with
view/unique/guest metrics and share URLs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-13 01:01:29 +08:00
Lakr
b46bf91575 fix(ios): add AI privacy consent alert (#14421)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added AI feature consent flow requiring user agreement before enabling
AI capabilities.
* Added calendar integration support including CalDAV account linking
and management.
* Expanded workspace administration capabilities with detailed workspace
analytics and configuration options.

* **Improvements**
  * Enhanced workspace sharing and configuration controls.
  * Added support for calendar provider presets and subscriptions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-12 18:25:18 +08:00
DarkSky
3ad482351b fix: server init (#14412)
#### PR Dependency Tree


* **PR #14412** 👈

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

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

## Summary by CodeRabbit

* **Refactor**
  * Improved internal code organization for better maintainability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-10 16:18:22 +08:00
DarkSky
03b1d15a8f chore: adjust resource 2026-02-10 14:41:43 +08:00
renovate[bot]
52c7b04a01 chore: bump up @vitejs/plugin-react-swc version to v4 (#14405)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@vitejs/plugin-react-swc](https://redirect.github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc#readme)
([source](https://redirect.github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react-swc))
| [`^3.7.2` →
`^4.0.0`](https://renovatebot.com/diffs/npm/@vitejs%2fplugin-react-swc/3.9.0/4.2.3)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@vitejs%2fplugin-react-swc/4.2.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitejs%2fplugin-react-swc/3.9.0/4.2.3?slim=true)
|

---

### Release Notes

<details>
<summary>vitejs/vite-plugin-react
(@&#8203;vitejs/plugin-react-swc)</summary>

###
[`v4.2.3`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#423-2026-02-02)

[Compare
Source](5e600a31ec...12914fa8c1)

###
[`v4.2.2`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#422-2025-11-12)

[Compare
Source](https://redirect.github.com/vitejs/vite-plugin-react/compare/v4.2.1...5e600a31ec27fae54df58a46ef1fffa80238042e)

##### Update code to support newer `rolldown-vite`
([#&#8203;978](https://redirect.github.com/vitejs/vite-plugin-react/pull/978))

`rolldown-vite` will remove `optimizeDeps.rollupOptions` in favor of
`optimizeDeps.rolldownOptions` soon. This plugin now uses
`optimizeDeps.rolldownOptions` to support newer `rolldown-vite`. Please
update `rolldown-vite` to the latest version if you are using an older
version.

###
[`v4.2.1`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#421-2025-11-05)

[Compare
Source](https://redirect.github.com/vitejs/vite-plugin-react/compare/v4.2.0...v4.2.1)

##### Fix `@vitejs/plugin-react-swc/preamble` on build
([#&#8203;962](https://redirect.github.com/vitejs/vite-plugin-react/pull/962))

###
[`v4.2.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#420-2025-10-24)

[Compare
Source](https://redirect.github.com/vitejs/vite-plugin-react/compare/v4.1.0...v4.2.0)

##### Add `@vitejs/plugin-react-swc/preamble` virtual module for SSR HMR
([#&#8203;890](https://redirect.github.com/vitejs/vite-plugin-react/pull/890))

SSR applications can now initialize HMR runtime by importing
`@vitejs/plugin-react-swc/preamble` at the top of their client entry
instead of manually calling `transformIndexHtml`. This simplifies SSR
setup for applications that don't use the `transformIndexHtml` API.

##### Use SWC when useAtYourOwnRisk\_mutateSwcOptions is provided
([#&#8203;951](https://redirect.github.com/vitejs/vite-plugin-react/pull/951))

Previously, this plugin did not use SWC if plugins were not provided
even if `useAtYourOwnRisk_mutateSwcOptions` was provided. This is now
fixed.

###
[`v4.1.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#410-2025-09-17)

[Compare
Source](f21864b102...v4.1.0)

##### Set SWC cacheRoot options

This is set to `{viteCacheDir}/swc` and override the default of `.swc`.

##### Perf: simplify refresh wrapper generation
([#&#8203;835](https://redirect.github.com/vitejs/vite-plugin-react/pull/835))

###
[`v4.0.1`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#401-2025-08-19)

[Compare
Source](590f394c1e...f21864b102d40fca4f70dfe9112a10101ec12f54)

##### Set `optimizeDeps.rollupOptions.transform.jsx` instead of
`optimizeDeps.rollupOptions.jsx` for rolldown-vite
([#&#8203;735](https://redirect.github.com/vitejs/vite-plugin-react/pull/735))

`optimizeDeps.rollupOptions.jsx` is going to be deprecated in favor of
`optimizeDeps.rollupOptions.transform.jsx`.

###
[`v4.0.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#400-2025-08-07)

[Compare
Source](9e0c103895...590f394c1e451987258ed64a4b5fb6207c5e8261)

###
[`v3.11.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3110-2025-07-18)

[Compare
Source](32d49ecf9b...9e0c1038959e828865be810a164a51c3db1ac375)

##### Add HMR support for compound components
([#&#8203;518](https://redirect.github.com/vitejs/vite-plugin-react/pull/518))

HMR now works for compound components like this:

```tsx
const Root = () => <div>Accordion Root</div>
const Item = () => <div>Accordion Item</div>

export const Accordion = { Root, Item }
```

##### Return `Plugin[]` instead of `PluginOption[]`
([#&#8203;537](https://redirect.github.com/vitejs/vite-plugin-react/pull/537))

The return type has changed from `react(): PluginOption[]` to more
specialized type `react(): Plugin[]`. This allows for type-safe
manipulation of plugins, for example:

```tsx
// previously this causes type errors
react()
  .map(p => ({ ...p, applyToEnvironment: e => e.name === 'client' }))
```

###
[`v3.10.2`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3102-2025-06-10)

[Compare
Source](8ce7183265...32d49ecf9b15e3070c7abe5a176252a3fe542e5c)

##### Suggest `@vitejs/plugin-react-oxc` if rolldown-vite is detected
[#&#8203;491](https://redirect.github.com/vitejs/vite-plugin-react/pull/491)

Emit a log which recommends `@vitejs/plugin-react-oxc` when
`rolldown-vite` is detected to improve performance and use Oxc under the
hood. The warning can be disabled by setting `disableOxcRecommendation:
true` in the plugin options.

##### Use `optimizeDeps.rollupOptions` instead of
`optimizeDeps.esbuildOptions` for rolldown-vite
[#&#8203;489](https://redirect.github.com/vitejs/vite-plugin-react/pull/489)

This suppresses the warning about `optimizeDeps.esbuildOptions` being
deprecated in rolldown-vite.

##### Add Vite 7-beta to peerDependencies range
[#&#8203;497](https://redirect.github.com/vitejs/vite-plugin-react/pull/497)

React plugins are compatible with Vite 7, this removes the warning when
testing the beta.

###
[`v3.10.1`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3101-2025-06-03)

[Compare
Source](dcadcfc284...8ce7183265c43f88623655a9cfdcec5282068f9b)

##### Add explicit semicolon in preambleCode
[#&#8203;485](https://redirect.github.com/vitejs/vite-plugin-react/pull/485)

This fixes an edge case when using HTML minifiers that strips line
breaks aggressively.

###
[`v3.10.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3100-2025-05-23)

[Compare
Source](4a944487aa...dcadcfc2841c0bedfe44279c556835c350dfa5fa)

##### Add `filter` for rolldown-vite
[#&#8203;470](https://redirect.github.com/vitejs/vite-plugin-react/pull/470)

Added `filter` so that it is more performant when running this plugin
with rolldown-powered version of Vite.

##### Skip HMR preamble in Vitest browser mode
[#&#8203;478](https://redirect.github.com/vitejs/vite-plugin-react/pull/478)

This was causing annoying `Sourcemap for "/@&#8203;react-refresh" points
to missing source files` and is unnecessary in test mode.

##### Skip HMR for JSX files with hooks
[#&#8203;480](https://redirect.github.com/vitejs/vite-plugin-react/pull/480)

This removes the HMR warning for hooks with JSX.

</details>

---

### Configuration

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

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 03:22:15 +00:00
renovate[bot]
1c0f873c9d chore: bump up RevenueCat/purchases-ios-spm version to from: "5.58.0" (#14399)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[RevenueCat/purchases-ios-spm](https://redirect.github.com/RevenueCat/purchases-ios-spm)
| minor | `from: "5.56.1"` → `from: "5.58.0"` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the Dependency
Dashboard for more information.

---

### Release Notes

<details>
<summary>RevenueCat/purchases-ios-spm
(RevenueCat/purchases-ios-spm)</summary>

###
[`v5.58.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.2...5.58.0)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.2...5.58.0)

###
[`v5.57.2`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5572)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.1...5.57.2)

##### 🔄 Other Changes

- Make networkName nullable in ad event data types
([#&#8203;6229](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/6229))
via Pol Miro ([@&#8203;polmiro](https://redirect.github.com/polmiro))
- Remove networkName from AdFailedToLoad event
([#&#8203;6208](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/6208))
via Pol Miro ([@&#8203;polmiro](https://redirect.github.com/polmiro))
- Excluding xcarchive and separate dSYMs folder from XCFramework in
order to reduce download size
([#&#8203;5967](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/5967))
via Rick ([@&#8203;rickvdl](https://redirect.github.com/rickvdl))

###
[`v5.57.1`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5571)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.0...5.57.1)

#### 5.57.1

###
[`v5.57.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5570)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.56.1...5.57.0)

#### 5.57.0

</details>

---

### Configuration

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

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

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

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

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

---

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

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

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 00:41:34 +08:00
DarkSky
8b68574820 fix: old workspace migration (#14403)
fix #14395 

#### PR Dependency Tree


* **PR #14403** 👈

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

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

* **New Features**
  * Added ability to enumerate and list local workspaces.
* Improved workspace ID persistence with Electron global-state storage,
automatic fallback to legacy storage, and one-time migration to
consolidate IDs.
* **Tests**
* Added unit test validating listing behavior (includes/excludes
workspaces based on presence of workspace DB file).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-09 00:22:37 +08:00
DarkSky
bb01bb1aef fix(editor): database behavier (#14394)
fix #13459
fix #13707
fix #13924

#### PR Dependency Tree


* **PR #14394** 👈

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

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

* **New Features**
* Improved URL paste: text is split into segments, inserted correctly,
and single-URL pastes create linked-page references.

* **UI Improvements**
  * Redesigned layout selector with compact dynamic options.
* Number-format options are always available in table headers and mobile
menus.

* **Bug Fixes**
  * More consistent paste behavior for mixed text+URL content.
  * Prevented recursive selection updates when exiting edit mode.

* **Tests**
* Added tests for URL splitting, paste insertion, number formatting, and
selection behavior.

* **Chores**
* Removed number-formatting feature flag; formatting now applied by
default.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-08 23:31:30 +08:00
155 changed files with 9759 additions and 1042 deletions

View File

@@ -25,30 +25,30 @@ const buildType = BUILD_TYPE || 'canary';
const isProduction = buildType === 'stable';
const isBeta = buildType === 'beta';
const isCanary = buildType === 'canary';
const isInternal = buildType === 'internal';
const isSpotEnabled = isBeta || isCanary;
const replicaConfig = {
stable: {
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
},
beta: {
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
},
canary: { front: 1, graphql: 1, doc: 1 },
canary: { front: 1, graphql: 1 },
};
const cpuConfig = {
beta: { front: '1', graphql: '1', doc: '1' },
canary: { front: '500m', graphql: '1', doc: '500m' },
beta: { front: '1', graphql: '1' },
canary: { front: '500m', graphql: '1' },
};
const memoryConfig = {
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
beta: { front: '2Gi', graphql: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi' },
};
const createHelmCommand = ({ isDryRun }) => {
@@ -72,10 +72,12 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
`--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`,
];
const cloudSqlNodeSelector = isBeta
? `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\", \\"cloud.google.com/gke-spot\\": \\"true\\" }`
: `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }`;
const serviceAnnotations = [
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json doc.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
].concat(
isProduction || isBeta || isInternal
? [
@@ -84,10 +86,17 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-json front.services.renderer.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json graphql.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json cloud-sql-proxy.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`,
`--set-json cloud-sql-proxy.nodeSelector="{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }"`,
`--set-json cloud-sql-proxy.nodeSelector="${cloudSqlNodeSelector}"`,
]
: []
);
const spotNodeSelector = `{ \\"cloud.google.com/gke-spot\\": \\"true\\" }`;
const spotScheduling = isSpotEnabled
? [
`--set-json front.nodeSelector="${spotNodeSelector}"`,
`--set-json graphql.nodeSelector="${spotNodeSelector}"`,
]
: [];
const cpu = cpuConfig[buildType];
const memory = memoryConfig[buildType];
@@ -96,14 +105,12 @@ const createHelmCommand = ({ isDryRun }) => {
resources = resources.concat([
`--set front.resources.requests.cpu="${cpu.front}"`,
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
`--set doc.resources.requests.cpu="${cpu.doc}"`,
]);
}
if (memory) {
resources = resources.concat([
`--set front.resources.requests.memory="${memory.front}"`,
`--set graphql.resources.requests.memory="${memory.graphql}"`,
`--set doc.resources.requests.memory="${memory.doc}"`,
]);
}
@@ -142,10 +149,8 @@ const createHelmCommand = ({ isDryRun }) => {
`--set graphql.replicaCount=${replica.graphql}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set-string graphql.app.host="${primaryHost}"`,
`--set-string doc.image.tag="${imageTag}"`,
`--set-string doc.app.host="${primaryHost}"`,
`--set doc.replicaCount=${replica.doc}`,
...serviceAnnotations,
...spotScheduling,
...resources,
`--timeout 10m`,
flag,

View File

@@ -1,16 +0,0 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "doc.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "doc.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "doc.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "doc.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -1,63 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "doc.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "doc.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "doc.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "doc.labels" -}}
helm.sh/chart: {{ include "doc.chart" . }}
{{ include "doc.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
Selector labels
*/}}
{{- define "doc.selectorLabels" -}}
app.kubernetes.io/name: {{ include "doc.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "doc.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "doc.fullname" .) .Values.global.docService.name }}
{{- else }}
{{- default "default" .Values.global.docService.name }}
{{- end }}
{{- end }}

View File

@@ -1,118 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "doc.fullname" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "doc.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "doc.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "doc.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NODE_OPTIONS
value: "--max-old-space-size=4096"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR
value: "doc"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_INDEXER_SEARCH_PROVIDER
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
- name: AFFINE_SERVER_PORT
value: "{{ .Values.global.docService.port }}"
- name: AFFINE_SERVER_SUB_PATH
value: "{{ .Values.app.path }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
ports:
- name: http
containerPort: {{ .Values.global.docService.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,12 +0,0 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "doc.serviceAccountName" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -1,15 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "doc.fullname" . }}-test-connection"
labels:
{{- include "doc.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "doc.fullname" . }}:{{ .Values.global.docService.port }}']
restartPolicy: Never

View File

@@ -30,9 +30,12 @@ podSecurityContext:
fsGroup: 2000
resources:
requests:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
memory: 2Gi
probe:
initialDelaySeconds: 20

View File

@@ -88,8 +88,6 @@ spec:
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
- name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports:
- name: http
containerPort: {{ .Values.app.port }}

View File

@@ -1,19 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "doc.fullname" . }}
name: {{ .Values.global.docService.name }}
labels:
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
{{- include "front.labels" . | nindent 4 }}
{{- with .Values.services.doc.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
type: {{ .Values.services.doc.type }}
ports:
- port: {{ .Values.global.docService.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "doc.selectorLabels" . | nindent 4 }}
{{- include "front.selectorLabels" . | nindent 4 }}

View File

@@ -29,6 +29,9 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 2Gi
requests:
cpu: '1'
memory: 2Gi
@@ -54,6 +57,9 @@ services:
type: ClusterIP
port: 8080
annotations: {}
doc:
type: ClusterIP
annotations: {}
nodeSelector: {}
tolerations: []

View File

@@ -27,8 +27,11 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '2'
cpu: '1'
memory: 2Gi
probe:

View File

@@ -47,12 +47,6 @@ graphql:
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
doc:
service:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
front:
services:
sync:
@@ -71,3 +65,7 @@ front:
name: affine-web
type: ClusterIP
port: 8080
doc:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'

View File

@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should split into multiple blocks', async () => {
const html = template(`<p>aaa<br>bbb<br>ccc</p>`);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'aaa' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'bbb' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'ccc' }],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should keep inline styles in each split line', async () => {
const html = template(
`<p><strong>aaa</strong><br><a href="https://www.google.com/">bbb</a><br><em>ccc</em></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'aaa',
attributes: {
bold: true,
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'bbb',
attributes: {
link: 'https://www.google.com/',
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'ccc',
attributes: {
italic: true,
},
},
],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('nested list', async () => {
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);

View File

@@ -0,0 +1,95 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { describe, expect, test } from 'vitest';
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
type InsertCall = {
range: {
index: number;
length: number;
};
text: string;
attributes?: AffineTextAttributes;
};
describe('insertUrlTextSegments', () => {
test('should replace selected text on first insert and append remaining segments', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 4, length: 6 };
const segments = [
{ text: 'hi - ' },
{ text: 'https://google.com', link: 'https://google.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 4, length: 6 },
text: 'hi - ',
},
{
range: { index: 9, length: 0 },
text: 'https://google.com',
attributes: {
link: 'https://google.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
});
test('should keep insertion range length zero when there is no selected text', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 2, length: 0 };
const segments = [
{ text: 'prefix ' },
{ text: 'https://a.com', link: 'https://a.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 2, length: 0 },
text: 'prefix ',
},
{
range: { index: 9, length: 0 },
text: 'https://a.com',
attributes: {
link: 'https://a.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
});
});

View File

@@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const enableNumberFormat = featureFlagService.getFlag(
'enable_database_number_formatting'
);
const enableTableVirtualScroll = featureFlagService.getFlag(
'enable_table_virtual_scroll'
);
return {
enable_number_formatting: enableNumberFormat ?? false,
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
};
});

View File

@@ -0,0 +1,56 @@
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
splitTextByUrl,
type UrlTextSegment,
} from '@blocksuite/affine-shared/utils';
import type { InlineRange } from '@blocksuite/std/inline';
type UrlPasteInlineEditor = Pick<
AffineInlineEditor,
'insertText' | 'setInlineRange'
>;
export function analyzeTextForUrlPaste(text: string) {
const segments = splitTextByUrl(text);
const firstSegment = segments[0];
const singleUrl =
segments.length === 1 && firstSegment?.link && firstSegment.text === text
? firstSegment.link
: undefined;
return {
segments,
singleUrl,
};
}
export function insertUrlTextSegments(
inlineEditor: UrlPasteInlineEditor,
inlineRange: InlineRange,
segments: UrlTextSegment[]
) {
let index = inlineRange.index;
let replacedSelection = false;
segments.forEach(segment => {
if (!segment.text) return;
const attributes: AffineTextAttributes | undefined = segment.link
? { link: segment.link }
: undefined;
inlineEditor.insertText(
{
index,
length: replacedSelection ? 0 : inlineRange.length,
},
segment.text,
attributes
);
replacedSelection = true;
index += segment.text.length;
});
inlineEditor.setInlineRange({
index,
length: 0,
});
}

View File

@@ -8,10 +8,7 @@ import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
richTextCellStyle,
richTextContainerStyle,
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (isValidUrl(text)) {
if (singleUrl) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
inlineEditor.insertText(inlineRange, text, {
link: text,
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
return;
}
} else {
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
override connectedCallback() {

View File

@@ -4,10 +4,7 @@ import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import { BaseCellRenderer } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
@@ -20,6 +17,7 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
headerAreaIconStyle,
titleCellStyle,
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange) return;
if (!inlineEditor || !inlineRange) return;
e.preventDefault();
e.stopPropagation();
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
e.preventDefault();
e.stopPropagation();
if (isValidUrl(text)) {
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
if (result) {
const text = ' ';
inlineEditor?.insertText(inlineRange, text, {
inlineEditor.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
},
},
});
inlineEditor?.setInlineRange({
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
inlineEditor?.insertText(inlineRange, text, {
link: text,
});
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
return;
}
} else {
inlineEditor?.insertText(inlineRange, text);
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
insertDelta = (delta: DeltaInsert) => {
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
this.disposables.addFromEvent(
this.richText.value,
'paste',
this._onPaste
this._onPaste,
true
);
const inlineEditor = this.inlineEditor;
if (inlineEditor) {

View File

@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
return false;
};
const splitDeltaByNewline = (delta: DeltaInsert[]) => {
const lines: DeltaInsert[][] = [[]];
const pending = [...delta];
while (pending.length > 0) {
const op = pending.shift();
if (!op) continue;
const insert = op.insert;
if (typeof insert !== 'string') {
lines[lines.length - 1].push(op);
continue;
}
if (!insert.includes('\n')) {
if (insert.length === 0) {
continue;
}
lines[lines.length - 1].push(op);
continue;
}
const splitIndex = insert.indexOf('\n');
const linePart = insert.slice(0, splitIndex);
const remainPart = insert.slice(splitIndex + 1);
if (linePart.length > 0) {
lines[lines.length - 1].push({ ...op, insert: linePart });
}
lines.push([]);
if (remainPart) {
pending.unshift({ ...op, insert: remainPart });
}
}
return lines;
};
const hasBlockElementDescendant = (node: HtmlAST): boolean => {
if (!HastUtils.isElement(node)) {
return false;
}
return node.children.some(child => {
if (!HastUtils.isElement(child)) {
return false;
}
return (
(HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
hasBlockElementDescendant(child)
);
});
};
const getParagraphDeltas = (
node: HtmlAST,
delta: DeltaInsert[]
): DeltaInsert[][] => {
if (!HastUtils.isElement(node)) return [delta];
if (hasBlockElementDescendant(node)) return [delta];
const hasBr = !!HastUtils.querySelector(node, 'br');
if (!hasBr) return [delta];
const hasNewline = delta.some(
op => typeof op.insert === 'string' && op.insert.includes('\n')
);
if (!hasNewline) return [delta];
return splitDeltaByNewline(delta);
};
const openParagraphBlocks = (
deltas: DeltaInsert[][],
type: string,
// AST walker context from html adapter transform pipeline.
walkerContext: any
) => {
for (const delta of deltas) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: { type, text: { '$blocksuite:internal:text$': true, delta } },
children: [],
},
'children'
)
.closeNode();
}
};
const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
'affine:paragraph:multi-emitted-nodes';
const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
const emittedNodes =
(walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined) ?? new WeakSet<object>();
emittedNodes.add(node as object);
walkerContext.setGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
emittedNodes
);
};
const consumeMultiParagraphEmittedMark = (
walkerContext: any,
node: HtmlAST
) => {
const emittedNodes = walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined;
if (!emittedNodes) {
return false;
}
return emittedNodes.delete(node as object);
};
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: ParagraphBlockSchema.model.flavour,
toMatch: o =>
@@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
!tagsInAncestor(o, ['p', 'li']) &&
HastUtils.isParagraphLike(o.node)
) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
},
'children'
)
.closeNode();
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
openParagraphBlocks(deltas, 'text', walkerContext);
walkerContext.skipAllChildren();
}
break;
}
case 'p': {
const type = walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text';
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
if (deltas.length > 1) {
openParagraphBlocks(deltas, type, walkerContext);
markMultiParagraphEmitted(walkerContext, o.node);
walkerContext.skipAllChildren();
break;
}
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text',
type,
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
delta,
},
},
children: [],
@@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
break;
}
case 'p': {
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
break;
}
if (
o.next?.type === 'element' &&
o.next.tagName === 'div' &&

View File

@@ -86,6 +86,7 @@ export class PageClipboard extends ReadOnlyClipboard {
if (this.std.store.readonly) return;
this.std.store.captureSync();
let hasPasteTarget = false;
this.std.command
.chain()
.try<{}>(cmd => [
@@ -144,18 +145,39 @@ export class PageClipboard extends ReadOnlyClipboard {
if (!ctx.parentBlock) {
return;
}
hasPasteTarget = true;
this.std.clipboard
.paste(
e,
this.std.store,
ctx.parentBlock.model.id,
ctx.blockIndex ? ctx.blockIndex + 1 : 1
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
)
.catch(console.error);
return next();
})
.run();
if (hasPasteTarget) return;
// If no valid selection target exists (for example, stale block selection
// right after cut), create/focus the default paragraph and paste after it.
const firstParagraphId = document
.querySelector('affine-page-root')
?.focusFirstParagraph?.()?.id;
const parentModel = firstParagraphId
? this.std.store.getParent(firstParagraphId)
: null;
const paragraphIndex =
firstParagraphId && parentModel
? parentModel.children.findIndex(child => child.id === firstParagraphId)
: -1;
const insertIndex = paragraphIndex >= 0 ? paragraphIndex + 1 : undefined;
this.std.clipboard
.paste(e, this.std.store, parentModel?.id, insertIndex)
.catch(console.error);
};
override mounted() {

View File

@@ -3,8 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
import type { GroupBy } from '../core/common/types.js';
import type { DataSource } from '../core/data-source/base.js';
import { DetailSelection } from '../core/detail/selection.js';
import { groupByMatchers } from '../core/group-by/define.js';
import { t } from '../core/logical/type-presets.js';
import type { DataViewCellLifeCycle } from '../core/property/index.js';
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
@@ -456,4 +458,60 @@ describe('kanban', () => {
expect(next?.hideEmpty).toBe(true);
});
});
describe('detail selection', () => {
it('should avoid recursive selection update when exiting select edit mode', () => {
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof requestAnimationFrame);
try {
let selection: DetailSelection;
let beforeExitCalls = 0;
const cell = {
beforeEnterEditMode: () => true,
beforeExitEditingMode: () => {
beforeExitCalls += 1;
selection.selection = {
propertyId: 'status',
isEditing: false,
};
},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
} satisfies DataViewCellLifeCycle;
const field = {
isFocus$: signal(false),
isEditing$: signal(false),
cell,
focus: () => {},
blur: () => {},
};
const detail = {
querySelector: () => field,
};
selection = new DetailSelection(detail);
selection.selection = {
propertyId: 'status',
isEditing: true,
};
selection.selection = {
propertyId: 'status',
isEditing: false,
};
expect(beforeExitCalls).toBe(1);
expect(field.isEditing$.value).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
});

View File

@@ -1,36 +0,0 @@
import { describe, expect, test } from 'vitest';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from 'vitest';
import { numberFormats } from '../property-presets/number/utils/formats.js';
import {
formatNumber,
NumberFormatSchema,
parseNumber,
} from '../property-presets/number/utils/formatter.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});
describe('number formatter', () => {
test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type);
const schemaFormats = NumberFormatSchema.options;
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
expect(menuFormats).toHaveLength(schemaFormats.length);
});
test('formats grouped decimal numbers with Intl grouping rules', () => {
const value = 11451.4;
const decimals = 1;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
});
test('formats percent values with Intl percent rules', () => {
const value = 0.1234;
const decimals = 2;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'percent',
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
});
test('formats currency values with Intl currency rules', () => {
const value = 11451.4;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
}).format(value);
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
});
test('parses grouped number string pasted from clipboard', () => {
expect(parseNumber('11,451.4')).toBe(11451.4);
});
test('keeps regular decimal parsing', () => {
expect(parseNumber('123.45')).toBe(123.45);
});
test('supports comma as decimal separator in locale-specific input', () => {
expect(parseNumber('11451,4', ',')).toBe(11451.4);
});
});

View File

@@ -22,7 +22,6 @@ import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from './common/css-variable.js';
import type { DataSource } from './data-source/index.js';
import type { DataViewSelection } from './types.js';
import { cacheComputed } from './utils/cache.js';
import { renderUniLit } from './utils/uni-component/index.js';
import type { DataViewUILogicBase } from './view/data-view-base.js';
import type { SingleView } from './view-manager/single-view.js';
@@ -75,12 +74,38 @@ export class DataViewRootUILogic {
return new (logic(view))(this, view);
}
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
this.createDataViewUILogic(viewId)
);
private readonly _viewsCache = new Map<
string,
{ mode: string; logic: DataViewUILogicBase }
>();
private readonly views$ = computed(() => {
const viewDataList = this.dataSource.viewDataList$.value;
const validIds = new Set(viewDataList.map(viewData => viewData.id));
for (const cachedId of this._viewsCache.keys()) {
if (!validIds.has(cachedId)) {
this._viewsCache.delete(cachedId);
}
}
return viewDataList.map(viewData => {
const cached = this._viewsCache.get(viewData.id);
if (cached && cached.mode === viewData.mode) {
return cached.logic;
}
const logic = this.createDataViewUILogic(viewData.id);
this._viewsCache.set(viewData.id, {
mode: viewData.mode,
logic,
});
return logic;
});
});
private readonly viewsMap$ = computed(() => {
return Object.fromEntries(
this.views$.list.value.map(logic => [logic.view.id, logic])
this.views$.value.map(logic => [logic.view.id, logic])
);
});
private readonly _uiRef = signal<DataViewRootUI>();

View File

@@ -1,7 +1,6 @@
import type { KanbanCardSelection } from '../../view-presets';
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
import type { RecordDetail } from './detail.js';
import { RecordField } from './field.js';
type DetailViewSelection = {
@@ -9,16 +8,39 @@ type DetailViewSelection = {
isEditing: boolean;
};
type DetailSelectionHost = {
querySelector: (selector: string) => unknown;
};
const isSameDetailSelection = (
current?: DetailViewSelection,
next?: DetailViewSelection
) => {
if (!current && !next) {
return true;
}
if (!current || !next) {
return false;
}
return (
current.propertyId === next.propertyId &&
current.isEditing === next.isEditing
);
};
export class DetailSelection {
_selection?: DetailViewSelection;
onSelect = (selection?: DetailViewSelection) => {
if (isSameDetailSelection(this._selection, selection)) {
return;
}
const old = this._selection;
this._selection = selection;
if (old) {
this.blur(old);
}
this._selection = selection;
if (selection) {
if (selection && isSameDetailSelection(this._selection, selection)) {
this.focus(selection);
}
};
@@ -49,7 +71,7 @@ export class DetailSelection {
}
}
constructor(private readonly viewEle: RecordDetail) {}
constructor(private readonly viewEle: DetailSelectionHost) {}
blur(selection: DetailViewSelection) {
const container = this.getFocusCellContainer(selection);
@@ -111,8 +133,10 @@ export class DetailSelection {
}
focusFirstCell() {
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
?.column.id;
const firstField = this.viewEle.querySelector(
'affine-data-view-record-field'
) as RecordField | undefined;
const firstId = firstField?.column.id;
if (firstId) {
this.selection = {
propertyId: firstId,
@@ -144,11 +168,12 @@ export class DetailSelection {
getSelectCard(selection: KanbanCardSelection) {
const { groupKey, cardId } = selection.cards[0];
const group = this.viewEle.querySelector(
`affine-data-view-kanban-group[data-key="${groupKey}"]`
) as HTMLElement | undefined;
return this.viewEle
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
return group?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
}
}

View File

@@ -12,6 +12,5 @@ export type PropertyDataUpdater<
> = (data: Data) => Partial<Data>;
export interface DatabaseFlags {
enable_number_formatting: boolean;
enable_table_virtual_scroll: boolean;
}

View File

@@ -24,17 +24,11 @@ export class NumberCell extends BaseCellRenderer<
private accessor _inputEle!: HTMLInputElement;
private _getFormattedString(value: number | undefined = this.value) {
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const decimals = this.property.data$.value.decimal ?? 0;
const formatMode = (this.property.data$.value.format ??
'number') as NumberFormat;
return value != undefined
? enableNewFormatting
? formatNumber(value, formatMode, decimals)
: value.toString()
: '';
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
}
private readonly _keydown = (e: KeyboardEvent) => {
@@ -58,9 +52,7 @@ export class NumberCell extends BaseCellRenderer<
return;
}
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
const value = parseNumber(str);
if (isNaN(value)) {
if (this._inputEle) {
this._inputEle.value = this.value

View File

@@ -3,6 +3,7 @@ import zod from 'zod';
import { t } from '../../core/logical/type-presets.js';
import { propertyType } from '../../core/property/property-config.js';
import { NumberPropertySchema } from './types.js';
import { parseNumber } from './utils/formatter.js';
export const numberPropertyType = propertyType('number');
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
@@ -21,7 +22,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
default: () => null,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
const num = value ? Number(value) : NaN;
const num = value ? parseNumber(value) : NaN;
return { value: isNaN(num) ? null : num };
},
toJson: ({ value }) => value ?? null,

View File

@@ -64,9 +64,6 @@ export class MobileTableColumnHeader extends SignalWatcher(
};
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
title: {
@@ -76,41 +73,36 @@ export class MobileTableColumnHeader extends SignalWatcher(
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
},
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
});
}),
]
: []),
],
},
}),
// Number format end
menu.group({
items: [

View File

@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
]
: []),
],
},
}),
// Number format end
menu.group({
items: [

View File

@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
]
: []),
],
},
}),
// Number format end
menu.group({
items: [

View File

@@ -337,6 +337,7 @@ export const popViewOptions = (
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
@@ -350,16 +351,9 @@ export const popViewOptions = (
items.push(
menu.group({
items: [
menu.action({
name: 'Layout',
postfix: html` <div
style="font-size: 14px;text-transform: capitalize;"
>
${view.type}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
@@ -379,10 +373,10 @@ export const popViewOptions = (
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const data: MenuButtonData = {
const buttonData: MenuButtonData = {
content: () => html`
<div
style="color:var(--affine-text-emphasis-color);width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
@@ -392,7 +386,7 @@ export const popViewOptions = (
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id) {
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
@@ -403,55 +397,35 @@ export const popViewOptions = (
const containerStyle = styleMap({
flex: '1',
});
return html` <affine-menu-button
return html`<affine-menu-button
style="${containerStyle}"
.data="${data}"
.data="${buttonData}"
.menu="${menu}"
></affine-menu-button>`;
};
});
const subHandler = popMenu(target, {
options: {
title: {
onBack: reopen,
text: 'Layout',
},
items: [
menu => {
const result = menu.renderItems(viewTypes);
if (result.length) {
return html` <div style="display: flex">${result}</div>`;
}
return html``;
},
// menu.toggleSwitch({
// name: 'Show block icon',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
// menu.toggleSwitch({
// name: 'Show Vertical lines',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
],
},
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
prefix: LayoutIcon(),
}),
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
@@ -486,7 +460,6 @@ export const popViewOptions = (
],
})
);
let handler: ReturnType<typeof popMenu>;
handler = popMenu(target, {
options: {
title: {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { isValidUrl } from '../../utils/url.js';
import { isValidUrl, splitTextByUrl } from '../../utils/url.js';
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
test('basic case', () => {
@@ -85,3 +85,55 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', (
expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true);
});
});
describe('splitTextByUrl', () => {
test('should split text and keep url part as link segment', () => {
expect(splitTextByUrl('hi - https://google.com')).toEqual([
{ text: 'hi - ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should support prefixed url token without swallowing prefix text', () => {
expect(splitTextByUrl('-https://google.com')).toEqual([
{ text: '-' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should trim tail punctuations from url token', () => {
expect(splitTextByUrl('visit https://google.com, now')).toEqual([
{ text: 'visit ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
{ text: ', now' },
]);
});
test('should convert domain token in plain text', () => {
expect(splitTextByUrl('google.com and text')).toEqual([
{
text: 'google.com',
link: 'https://google.com',
},
{ text: ' and text' },
]);
});
test('should normalize www domain token link while preserving display text', () => {
expect(splitTextByUrl('www.google.com')).toEqual([
{
text: 'www.google.com',
link: 'https://www.google.com',
},
]);
});
});

View File

@@ -2,7 +2,6 @@ import { type Store, StoreExtension } from '@blocksuite/store';
import { type Signal, signal } from '@preact/signals-core';
export interface BlockSuiteFlags {
enable_database_number_formatting: boolean;
enable_database_attachment_note: boolean;
enable_database_full_width: boolean;
enable_block_query: boolean;
@@ -28,7 +27,6 @@ export class FeatureFlagService extends StoreExtension {
static override key = 'feature-flag-server';
private readonly _flags: Signal<BlockSuiteFlags> = signal({
enable_database_number_formatting: false,
enable_database_attachment_note: false,
enable_database_full_width: false,
enable_block_query: false,

View File

@@ -1,3 +1,4 @@
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
import { createIdentifier } from '@blocksuite/global/di';
import { IS_FIREFOX } from '@blocksuite/global/env';
import { LifeCycleWatcher } from '@blocksuite/std';
@@ -20,33 +21,171 @@ const initFontFace = IS_FIREFOX
export class FontLoaderService extends LifeCycleWatcher {
static override readonly key = 'font-loader';
private static readonly DEFERRED_LOAD_DELAY_MS = 5000;
private static readonly DEFERRED_LOAD_BATCH_SIZE = 4;
private static readonly DEFERRED_LOAD_BATCH_INTERVAL_MS = 1000;
private _idleLoadTaskId: number | null = null;
private _lazyLoadTimeoutId: number | null = null;
private _deferredFontsQueue: FontConfig[] = [];
private _deferredFontsCursor = 0;
private readonly _loadedFontKeys = new Set<string>();
readonly fontFaces: FontFace[] = [];
get ready() {
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
}
private readonly _fontKey = ({ font, weight, style, url }: FontConfig) => {
return `${font}:${weight}:${style}:${url}`;
};
private readonly _isCriticalCanvasFont = ({
font,
weight,
style,
}: FontConfig) => {
if (style !== FontStyle.Normal) return false;
if (font === FontFamily.Poppins) {
return (
weight === FontWeight.Regular ||
weight === FontWeight.Medium ||
weight === FontWeight.SemiBold
);
}
if (font === FontFamily.Inter) {
return weight === FontWeight.Regular || weight === FontWeight.SemiBold;
}
if (font === FontFamily.Kalam) {
// Mindmap style four uses bold Kalam text.
// We map to SemiBold because this is the strongest shipped Kalam weight.
return weight === FontWeight.SemiBold;
}
return false;
};
private readonly _scheduleDeferredLoad = (fonts: FontConfig[]) => {
if (fonts.length === 0 || typeof window === 'undefined') {
return;
}
this._deferredFontsQueue = fonts;
this._deferredFontsCursor = 0;
const win = window as Window & {
requestIdleCallback?: (
callback: () => void,
options?: { timeout?: number }
) => number;
cancelIdleCallback?: (handle: number) => void;
};
const scheduleBatch = (delayMs: number) => {
this._lazyLoadTimeoutId = window.setTimeout(() => {
this._lazyLoadTimeoutId = null;
const runBatch = () => {
this._idleLoadTaskId = null;
const start = this._deferredFontsCursor;
const end = Math.min(
start + FontLoaderService.DEFERRED_LOAD_BATCH_SIZE,
this._deferredFontsQueue.length
);
const batch = this._deferredFontsQueue.slice(start, end);
this._deferredFontsCursor = end;
this.load(batch);
if (this._deferredFontsCursor < this._deferredFontsQueue.length) {
scheduleBatch(FontLoaderService.DEFERRED_LOAD_BATCH_INTERVAL_MS);
}
};
if (typeof win.requestIdleCallback === 'function') {
this._idleLoadTaskId = win.requestIdleCallback(runBatch, {
timeout: 2000,
});
return;
}
runBatch();
}, delayMs);
};
scheduleBatch(FontLoaderService.DEFERRED_LOAD_DELAY_MS);
};
private readonly _cancelDeferredLoad = () => {
if (typeof window === 'undefined') {
return;
}
const win = window as Window & {
cancelIdleCallback?: (handle: number) => void;
};
if (
this._idleLoadTaskId !== null &&
typeof win.cancelIdleCallback === 'function'
) {
win.cancelIdleCallback(this._idleLoadTaskId);
this._idleLoadTaskId = null;
}
if (this._lazyLoadTimeoutId !== null) {
window.clearTimeout(this._lazyLoadTimeoutId);
this._lazyLoadTimeoutId = null;
}
this._deferredFontsQueue = [];
this._deferredFontsCursor = 0;
};
load(fonts: FontConfig[]) {
this.fontFaces.push(
...fonts.map(font => {
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
return fontFace;
})
);
for (const font of fonts) {
const key = this._fontKey(font);
if (this._loadedFontKeys.has(key)) {
continue;
}
this._loadedFontKeys.add(key);
const fontFace = initFontFace(font);
document.fonts.add(fontFace);
fontFace.load().catch(console.error);
this.fontFaces.push(fontFace);
}
}
override mounted() {
const config = this.std.getOptional(FontConfigIdentifier);
if (config) {
this.load(config);
if (!config || config.length === 0) {
return;
}
const criticalFonts = config.filter(this._isCriticalCanvasFont);
const eagerFonts =
criticalFonts.length > 0 ? criticalFonts : config.slice(0, 3);
const eagerFontKeySet = new Set(eagerFonts.map(this._fontKey));
const deferredFonts = config.filter(
font => !eagerFontKeySet.has(this._fontKey(font))
);
this.load(eagerFonts);
this._scheduleDeferredLoad(deferredFonts);
}
override unmounted() {
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
this._cancelDeferredLoad();
for (const fontFace of this.fontFaces) {
document.fonts.delete(fontFace);
}
this.fontFaces.splice(0, this.fontFaces.length);
this._loadedFontKeys.clear();
}
}

View File

@@ -95,28 +95,107 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
return result?.allowed ?? false;
}
const URL_SCHEME_IN_TOKEN_REGEXP =
/(?:https?:\/\/|ftp:\/\/|sftp:\/\/|mailto:|tel:|www\.)/i;
const URL_LEADING_DELIMITER_REGEXP = /^[-([{<'"~]+/;
const URL_TRAILING_DELIMITER_REGEXP = /[)\]}>.,;:!?'"]+$/;
export type UrlTextSegment = {
text: string;
link?: string;
};
function appendUrlTextSegment(
segments: UrlTextSegment[],
segment: UrlTextSegment
) {
if (!segment.text) return;
const last = segments[segments.length - 1];
if (last && !last.link && !segment.link) {
last.text += segment.text;
return;
}
segments.push(segment);
}
function splitTokenByUrl(token: string, baseUrl: string): UrlTextSegment[] {
const schemeMatch = token.match(URL_SCHEME_IN_TOKEN_REGEXP);
const schemeIndex = schemeMatch?.index;
if (typeof schemeIndex === 'number' && schemeIndex > 0) {
return [
{ text: token.slice(0, schemeIndex) },
...splitTokenByUrl(token.slice(schemeIndex), baseUrl),
];
}
const leading = token.match(URL_LEADING_DELIMITER_REGEXP)?.[0] ?? '';
const withoutLeading = token.slice(leading.length);
const trailing =
withoutLeading.match(URL_TRAILING_DELIMITER_REGEXP)?.[0] ?? '';
const core = trailing
? withoutLeading.slice(0, withoutLeading.length - trailing.length)
: withoutLeading;
if (core && isValidUrl(core, baseUrl)) {
const segments: UrlTextSegment[] = [];
appendUrlTextSegment(segments, { text: leading });
appendUrlTextSegment(segments, { text: core, link: normalizeUrl(core) });
appendUrlTextSegment(segments, { text: trailing });
return segments;
}
return [{ text: token }];
}
/**
* Split plain text into mixed segments, where only URL segments carry link metadata.
* This is used by paste handlers so text like `example:https://google.com` keeps
* normal text while only URL parts are linkified.
*/
export function splitTextByUrl(text: string, baseUrl = location.origin) {
const chunks = text.match(/\s+|\S+/g);
if (!chunks) {
return [];
}
const segments: UrlTextSegment[] = [];
chunks.forEach(chunk => {
if (/^\s+$/.test(chunk)) {
appendUrlTextSegment(segments, { text: chunk });
return;
}
splitTokenByUrl(chunk, baseUrl).forEach(segment => {
appendUrlTextSegment(segments, segment);
});
});
return segments;
}
// https://en.wikipedia.org/wiki/Top-level_domain
const COMMON_TLDS = new Set([
'com',
'org',
'net',
'edu',
'gov',
'co',
'io',
'me',
'moe',
'mil',
'top',
'dev',
'xyz',
'info',
'cat',
'ru',
'co',
'com',
'de',
'dev',
'edu',
'eu',
'gov',
'info',
'io',
'jp',
'uk',
'me',
'mil',
'moe',
'net',
'org',
'pro',
'ru',
'top',
'uk',
'xyz',
]);
function isCommonTLD(url: URL) {

View File

@@ -14,6 +14,17 @@ import {
} from '../config.js';
import type { AffineDragHandleWidget } from '../drag-handle.js';
type HoveredElemArea = {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
padding: number;
containerWidth: number;
};
/**
* Used to control the drag handle visibility in edgeless mode
*
@@ -21,6 +32,52 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
* 2. Multiple selection is not supported
*/
export class EdgelessWatcher {
private _pendingHoveredElemArea: HoveredElemArea | null = null;
private _lastAppliedHoveredElemArea: HoveredElemArea | null = null;
private _showDragHandleRafId: number | null = null;
private _surfaceElementUpdatedRafId: number | null = null;
private readonly _cloneArea = (area: HoveredElemArea): HoveredElemArea => ({
left: area.left,
top: area.top,
right: area.right,
bottom: area.bottom,
width: area.width,
height: area.height,
padding: area.padding,
containerWidth: area.containerWidth,
});
private readonly _isAreaEqual = (
left: HoveredElemArea | null,
right: HoveredElemArea | null
) => {
if (!left || !right) return false;
return (
left.left === right.left &&
left.top === right.top &&
left.right === right.right &&
left.bottom === right.bottom &&
left.width === right.width &&
left.height === right.height &&
left.padding === right.padding &&
left.containerWidth === right.containerWidth
);
};
private readonly _scheduleShowDragHandleFromSurfaceUpdate = () => {
if (this._surfaceElementUpdatedRafId !== null) return;
this._surfaceElementUpdatedRafId = requestAnimationFrame(() => {
this._surfaceElementUpdatedRafId = null;
if (!this.widget.isGfxDragHandleVisible) return;
this._showDragHandle();
});
};
private readonly _handleEdgelessToolUpdated = (
newTool: ToolOptionWithType
) => {
@@ -43,46 +100,123 @@ export class EdgelessWatcher {
}
if (
this.widget.center[0] !== center[0] &&
this.widget.center[0] !== center[0] ||
this.widget.center[1] !== center[1]
) {
this.widget.center = [...center];
}
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle();
this._updateDragHoverRectTopLevelBlock();
const area = this.hoveredElemArea;
this._showDragHandle(area);
this._updateDragHoverRectTopLevelBlock(area);
} else if (this.widget.activeDragHandle) {
this.widget.hide();
}
};
private readonly _showDragHandle = () => {
if (!this.widget.anchorBlockId) return;
private readonly _flushShowDragHandle = () => {
this._showDragHandleRafId = null;
if (!this.widget.anchorBlockId.peek()) return;
const container = this.widget.dragHandleContainer;
const grabber = this.widget.dragHandleGrabber;
if (!container || !grabber) return;
const area = this.hoveredElemArea;
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
this._pendingHoveredElemArea = null;
if (!area) return;
container.style.transition = 'none';
container.style.paddingTop = `0px`;
container.style.paddingBottom = `0px`;
container.style.left = `${area.left}px`;
container.style.top = `${area.top}px`;
container.style.display = 'flex';
if (
this.widget.isGfxDragHandleVisible &&
this._isAreaEqual(this._lastAppliedHoveredElemArea, area)
) {
return;
}
if (container.style.transition !== 'none') {
container.style.transition = 'none';
}
const nextPaddingTop = '0px';
if (container.style.paddingTop !== nextPaddingTop) {
container.style.paddingTop = nextPaddingTop;
}
const nextPaddingBottom = '0px';
if (container.style.paddingBottom !== nextPaddingBottom) {
container.style.paddingBottom = nextPaddingBottom;
}
const nextLeft = `${area.left}px`;
if (container.style.left !== nextLeft) {
container.style.left = nextLeft;
}
const nextTop = `${area.top}px`;
if (container.style.top !== nextTop) {
container.style.top = nextTop;
}
if (container.style.display !== 'flex') {
container.style.display = 'flex';
}
this.widget.handleAnchorModelDisposables();
this.widget.activeDragHandle = 'gfx';
this._lastAppliedHoveredElemArea = this._cloneArea(area);
};
private readonly _updateDragHoverRectTopLevelBlock = () => {
private readonly _showDragHandle = (area?: HoveredElemArea | null) => {
const nextArea = area ?? this.hoveredElemArea;
this._pendingHoveredElemArea = nextArea;
if (!this._pendingHoveredElemArea) {
return;
}
if (
this.widget.isGfxDragHandleVisible &&
this._showDragHandleRafId === null &&
this._isAreaEqual(
this._lastAppliedHoveredElemArea,
this._pendingHoveredElemArea
)
) {
return;
}
if (this._showDragHandleRafId !== null) {
return;
}
this._showDragHandleRafId = requestAnimationFrame(
this._flushShowDragHandle
);
};
private readonly _updateDragHoverRectTopLevelBlock = (
area?: HoveredElemArea | null
) => {
if (!this.widget.dragHoverRect) return;
this.widget.dragHoverRect = this.hoveredElemAreaRect;
const nextArea = area ?? this.hoveredElemArea;
if (!nextArea) {
this.widget.dragHoverRect = null;
return;
}
const nextRect = new Rect(
nextArea.left,
nextArea.top,
nextArea.right,
nextArea.bottom
);
const prevRect = this.widget.dragHoverRect;
if (
prevRect &&
prevRect.left === nextRect.left &&
prevRect.top === nextRect.top &&
prevRect.width === nextRect.width &&
prevRect.height === nextRect.height
) {
return;
}
this.widget.dragHoverRect = nextRect;
};
get gfx() {
@@ -123,7 +257,7 @@ export class EdgelessWatcher {
return new Rect(area.left, area.top, area.right, area.bottom);
}
get hoveredElemArea() {
get hoveredElemArea(): HoveredElemArea | null {
const edgelessElement = this.widget.anchorEdgelessElement.peek();
if (!edgelessElement) return null;
@@ -174,6 +308,19 @@ export class EdgelessWatcher {
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
);
disposables.add(() => {
if (this._showDragHandleRafId !== null) {
cancelAnimationFrame(this._showDragHandleRafId);
this._showDragHandleRafId = null;
}
if (this._surfaceElementUpdatedRafId !== null) {
cancelAnimationFrame(this._surfaceElementUpdatedRafId);
this._surfaceElementUpdatedRafId = null;
}
this._pendingHoveredElemArea = null;
this._lastAppliedHoveredElemArea = null;
});
disposables.add(
selection.slots.updated.subscribe(() => {
this.updateAnchorElement();
@@ -216,7 +363,7 @@ export class EdgelessWatcher {
this.widget.hide();
}
if (payload.type === 'update') {
this._showDragHandle();
this._scheduleShowDragHandleFromSurfaceUpdate();
}
}
})
@@ -224,9 +371,10 @@ export class EdgelessWatcher {
if (surface) {
disposables.add(
surface.elementUpdated.subscribe(() => {
surface.elementUpdated.subscribe(({ id }) => {
if (this.widget.isGfxDragHandleVisible) {
this._showDragHandle();
if (id !== this.widget.anchorBlockId.peek()) return;
this._scheduleShowDragHandleFromSurfaceUpdate();
}
})
);

View File

@@ -153,6 +153,10 @@ export class PointerEventWatcher {
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
private _lastPointerHitBlockId: string | null = null;
private _lastPointerHitBlockElement: Element | null = null;
/**
* When pointer move on block, should show drag handle
* And update hover block id and path
@@ -169,6 +173,7 @@ export class PointerEventWatcher {
point
);
if (!closestBlock) {
this._lastPointerHitBlockId = null;
this.widget.anchorBlockId.value = null;
return;
}
@@ -237,19 +242,38 @@ export class PointerEventWatcher {
const state = ctx.get('pointerState');
// When pointer is moving, should do nothing
if (state.delta.x !== 0 && state.delta.y !== 0) return;
const { target } = state.raw;
const element = captureEventTarget(target);
// When pointer not on block or on dragging, should do nothing
if (!element) return;
if (!element) {
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
return;
}
// When pointer on drag handle, should do nothing
if (element.closest('.affine-drag-handle-container')) return;
if (!this.widget.rootComponent) return;
const hitBlock = element.closest(`[${BLOCK_ID_ATTR}]`);
const hitBlockId = hitBlock?.getAttribute(BLOCK_ID_ATTR) ?? null;
// Pointer move events are high-frequency. If hovered block identity is
// unchanged and the underlying block element is the same, skip the
// closest-note lookup.
if (
hitBlockId &&
this.widget.isBlockDragHandleVisible &&
hitBlockId === this._lastPointerHitBlockId &&
hitBlock === this._lastPointerHitBlockElement &&
isBlockIdEqual(this.widget.anchorBlockId.peek(), hitBlockId)
) {
return;
}
this._lastPointerHitBlockId = hitBlockId;
this._lastPointerHitBlockElement = hitBlock;
// When pointer out of note block hover area or inside database, should hide drag handle
const point = new Point(state.raw.x, state.raw.y);
@@ -354,6 +378,8 @@ export class PointerEventWatcher {
reset() {
this._lastHoveredBlockId = null;
this._lastShowedBlock = null;
this._lastPointerHitBlockId = null;
this._lastPointerHitBlockElement = null;
}
watch() {

View File

@@ -10,25 +10,15 @@ import type { InlineRange } from '../types.js';
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
export class RenderService<TextAttributes extends BaseTextAttributes> {
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
private _pendingRemoteInlineRangeSync = false;
const yText = this.editor.yText;
private _carriageReturnValidationCounter = 0;
if (yText.toString().includes('\r')) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
this.render();
private _renderVersion = 0;
private readonly _syncRemoteInlineRange = () => {
const inlineRange = this.editor.inlineRange$.peek();
if (!inlineRange || transaction.local) return;
if (!inlineRange) return;
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
@@ -50,7 +40,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
const startIndex = absoluteStart?.index;
const endIndex = absoluteEnd?.index;
if (!startIndex || !endIndex) return;
if (startIndex == null || endIndex == null) return;
const newInlineRange: InlineRange = {
index: startIndex,
@@ -59,7 +49,31 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
if (!this.editor.isValidInlineRange(newInlineRange)) return;
this.editor.setInlineRange(newInlineRange);
this.editor.syncInlineRange();
};
private readonly _onYTextChange = (
_: Y.YTextEvent,
transaction: Y.Transaction
) => {
this.editor.slots.textChange.next();
const yText = this.editor.yText;
if (
(this._carriageReturnValidationCounter++ & 0x3f) === 0 &&
yText.toString().includes('\r')
) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'yText must not contain "\\r" because it will break the range synchronization'
);
}
if (!transaction.local) {
this._pendingRemoteInlineRangeSync = true;
}
this.render();
};
mount = () => {
@@ -70,6 +84,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
editor.disposables.add({
dispose: () => {
yText.unobserve(this._onYTextChange);
this._pendingRemoteInlineRangeSync = false;
},
});
};
@@ -82,6 +97,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
render = () => {
if (!this.editor.rootElement) return;
const renderVersion = ++this._renderVersion;
this._rendering = true;
const rootElement = this.editor.rootElement;
@@ -152,11 +168,21 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
this.editor
.waitForUpdate()
.then(() => {
if (renderVersion !== this._renderVersion) return;
if (this._pendingRemoteInlineRangeSync) {
this._pendingRemoteInlineRangeSync = false;
this._syncRemoteInlineRange();
}
this._rendering = false;
this.editor.slots.renderComplete.next();
this.editor.syncInlineRange();
})
.catch(console.error);
.catch(error => {
if (renderVersion === this._renderVersion) {
this._rendering = false;
}
console.error(error);
});
};
rerenderWholeEditor = () => {

View File

@@ -9,7 +9,12 @@ import {
isVElement,
isVLine,
} from './guard.js';
import { calculateTextLength, getTextNodesFromElement } from './text.js';
import {
calculateTextLength,
getInlineRootTextCache,
getTextNodesFromElement,
invalidateInlineRootTextCache,
} from './text.js';
export function nativePointToTextPoint(
node: unknown,
@@ -67,19 +72,6 @@ export function textPointToDomPoint(
if (!rootElement.contains(text)) return null;
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
let index = 0;
for (const text of texts.slice(0, goalIndex)) {
index += calculateTextLength(text);
}
if (text.wholeText !== ZERO_WIDTH_FOR_EMPTY_LINE) {
index += offset;
}
const textParentElement = text.parentElement;
if (!textParentElement) {
throw new BlockSuiteError(
@@ -97,9 +89,44 @@ export function textPointToDomPoint(
);
}
const textOffset = text.wholeText === ZERO_WIDTH_FOR_EMPTY_LINE ? 0 : offset;
for (let attempt = 0; attempt < 2; attempt++) {
const { textNodes, textNodeIndexMap, prefixLengths, lineIndexMap } =
getInlineRootTextCache(rootElement);
if (textNodes.length === 0) return null;
const goalIndex = textNodeIndexMap.get(text);
const lineIndex = lineIndexMap.get(lineElement);
if (goalIndex !== undefined && lineIndex !== undefined) {
const index = (prefixLengths[goalIndex] ?? 0) + textOffset;
return { text, index: index + lineIndex };
}
if (attempt === 0) {
// MutationObserver marks cache dirty asynchronously; force one sync retry
// when a newly-added node is queried within the same task.
invalidateInlineRootTextCache(rootElement);
}
}
// Fallback to linear scan when cache still misses. This keeps behavior
// stable even if MutationObserver-based invalidation lags behind.
const texts = getTextNodesFromElement(rootElement);
if (texts.length === 0) return null;
const goalIndex = texts.indexOf(text);
if (goalIndex < 0) return null;
let index = textOffset;
for (const beforeText of texts.slice(0, goalIndex)) {
index += calculateTextLength(beforeText);
}
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
lineElement
);
if (lineIndex < 0) return null;
return { text, index: index + lineIndex };
}

View File

@@ -8,6 +8,92 @@ export function calculateTextLength(text: Text): number {
}
}
type InlineRootTextCache = {
dirty: boolean;
observer: MutationObserver | null;
textNodes: Text[];
textNodeIndexMap: WeakMap<Text, number>;
prefixLengths: number[];
lineIndexMap: WeakMap<Element, number>;
};
const inlineRootTextCaches = new WeakMap<HTMLElement, InlineRootTextCache>();
const buildInlineRootTextCache = (
rootElement: HTMLElement,
cache: InlineRootTextCache
) => {
const textSpanElements = Array.from(
rootElement.querySelectorAll('[data-v-text="true"]')
);
const textNodes: Text[] = [];
const textNodeIndexMap = new WeakMap<Text, number>();
const prefixLengths: number[] = [];
let prefixLength = 0;
for (const textSpanElement of textSpanElements) {
const textNode = Array.from(textSpanElement.childNodes).find(
(node): node is Text => node instanceof Text
);
if (!textNode) continue;
prefixLengths.push(prefixLength);
textNodeIndexMap.set(textNode, textNodes.length);
textNodes.push(textNode);
prefixLength += calculateTextLength(textNode);
}
const lineIndexMap = new WeakMap<Element, number>();
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
for (const [index, line] of lineElements.entries()) {
lineIndexMap.set(line, index);
}
cache.textNodes = textNodes;
cache.textNodeIndexMap = textNodeIndexMap;
cache.prefixLengths = prefixLengths;
cache.lineIndexMap = lineIndexMap;
cache.dirty = false;
};
export function invalidateInlineRootTextCache(rootElement: HTMLElement) {
const cache = inlineRootTextCaches.get(rootElement);
if (cache) {
cache.dirty = true;
}
}
export function getInlineRootTextCache(rootElement: HTMLElement) {
let cache = inlineRootTextCaches.get(rootElement);
if (!cache) {
cache = {
dirty: true,
observer: null,
textNodes: [],
textNodeIndexMap: new WeakMap(),
prefixLengths: [],
lineIndexMap: new WeakMap(),
};
inlineRootTextCaches.set(rootElement, cache);
}
if (!cache.observer && typeof MutationObserver !== 'undefined') {
cache.observer = new MutationObserver(() => {
cache!.dirty = true;
});
cache.observer.observe(rootElement, {
subtree: true,
childList: true,
characterData: true,
});
}
if (cache.dirty) {
buildInlineRootTextCache(rootElement, cache);
}
return cache;
}
export function getTextNodesFromElement(element: Element): Text[] {
const textSpanElements = Array.from(
element.querySelectorAll('[data-v-text="true"]')

View File

@@ -47,7 +47,10 @@ describe('frame', () => {
expect(rect!.width).toBeGreaterThan(0);
expect(rect!.height).toBeGreaterThan(0);
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
const [titleX, titleY] = service.viewport.toModelCoordFromClientCoord([
rect!.x,
rect!.y,
]);
expect(titleX).toBeCloseTo(0);
expect(titleY).toBeLessThan(0);
@@ -66,10 +69,11 @@ describe('frame', () => {
if (!nestedTitle) return;
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
nestedTitleRect.x,
nestedTitleRect.y
);
const [nestedTitleX, nestedTitleY] =
service.viewport.toModelCoordFromClientCoord([
nestedTitleRect.x,
nestedTitleRect.y,
]);
expect(nestedTitleX).toBeGreaterThan(20);
expect(nestedTitleY).toBeGreaterThan(20);

View File

@@ -0,0 +1,81 @@
CREATE TABLE IF NOT EXISTS "workspace_admin_stats_daily" (
"workspace_id" VARCHAR NOT NULL,
"date" DATE NOT NULL,
"snapshot_size" BIGINT NOT NULL DEFAULT 0,
"blob_size" BIGINT NOT NULL DEFAULT 0,
"member_count" BIGINT NOT NULL DEFAULT 0,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
CONSTRAINT "workspace_admin_stats_daily_pkey" PRIMARY KEY ("workspace_id", "date"),
CONSTRAINT "workspace_admin_stats_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_daily_date_idx" ON "workspace_admin_stats_daily" ("date");
CREATE TABLE IF NOT EXISTS "sync_active_users_minutely" (
"minute_ts" TIMESTAMPTZ(3) NOT NULL,
"active_users" INTEGER NOT NULL DEFAULT 0,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
CONSTRAINT "sync_active_users_minutely_pkey" PRIMARY KEY ("minute_ts")
);
CREATE TABLE IF NOT EXISTS "workspace_doc_view_daily" (
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"date" DATE NOT NULL,
"total_views" BIGINT NOT NULL DEFAULT 0,
"unique_views" BIGINT NOT NULL DEFAULT 0,
"guest_views" BIGINT NOT NULL DEFAULT 0,
"last_accessed_at" TIMESTAMPTZ(3),
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
CONSTRAINT "workspace_doc_view_daily_pkey" PRIMARY KEY ("workspace_id", "doc_id", "date"),
CONSTRAINT "workspace_doc_view_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "workspace_doc_view_daily_workspace_id_date_idx" ON "workspace_doc_view_daily" ("workspace_id", "date");
CREATE TABLE IF NOT EXISTS "workspace_member_last_access" (
"workspace_id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"last_accessed_at" TIMESTAMPTZ(3) NOT NULL,
"last_doc_id" VARCHAR,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
CONSTRAINT "workspace_member_last_access_pkey" PRIMARY KEY ("workspace_id", "user_id"),
CONSTRAINT "workspace_member_last_access_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "workspace_member_last_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_accessed_at_idx" ON "workspace_member_last_access" ("workspace_id", "last_accessed_at" DESC);
CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_doc_id_idx" ON "workspace_member_last_access" ("workspace_id", "last_doc_id");
CREATE INDEX IF NOT EXISTS "workspace_pages_public_published_at_idx" ON "workspace_pages" ("public", "published_at");
CREATE INDEX IF NOT EXISTS "ai_sessions_messages_created_at_role_idx" ON "ai_sessions_messages" ("created_at", "role");
DROP TRIGGER IF EXISTS user_features_set_feature_id ON "user_features";
DROP TRIGGER IF EXISTS workspace_features_set_feature_id ON "workspace_features";
DROP FUNCTION IF EXISTS set_user_feature_id_from_name();
DROP FUNCTION IF EXISTS set_workspace_feature_id_from_name();
DROP FUNCTION IF EXISTS ensure_feature_exists(TEXT);
ALTER TABLE
"user_features" DROP CONSTRAINT IF EXISTS "user_features_feature_id_fkey";
ALTER TABLE
"workspace_features" DROP CONSTRAINT IF EXISTS "workspace_features_feature_id_fkey";
DROP INDEX IF EXISTS "user_features_feature_id_idx";
DROP INDEX IF EXISTS "workspace_features_feature_id_idx";
ALTER TABLE
"user_features" DROP COLUMN IF EXISTS "feature_id";
ALTER TABLE
"workspace_features" DROP COLUMN IF EXISTS "feature_id";
DROP TABLE IF EXISTS "features";

View File

@@ -25,31 +25,32 @@ model User {
registered Boolean @default(true)
disabled Boolean @default(false)
features UserFeature[]
userStripeCustomer UserStripeCustomer?
workspaces WorkspaceUserRole[]
features UserFeature[]
userStripeCustomer UserStripeCustomer?
workspaces WorkspaceUserRole[]
// Invite others to join the workspace
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
docPermissions WorkspaceDocUserRole[]
connectedAccounts ConnectedAccount[]
calendarAccounts CalendarAccount[]
sessions UserSession[]
aiSessions AiSession[]
appConfigs AppConfig[]
userSnapshots UserSnapshot[]
createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
createdUpdate Update[] @relation("createdUpdate")
createdHistory SnapshotHistory[] @relation("createdHistory")
createdAiJobs AiJobs[] @relation("createdAiJobs")
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
docPermissions WorkspaceDocUserRole[]
connectedAccounts ConnectedAccount[]
calendarAccounts CalendarAccount[]
sessions UserSession[]
aiSessions AiSession[]
appConfigs AppConfig[]
userSnapshots UserSnapshot[]
createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
createdUpdate Update[] @relation("createdUpdate")
createdHistory SnapshotHistory[] @relation("createdHistory")
createdAiJobs AiJobs[] @relation("createdAiJobs")
// receive notifications
notifications Notification[] @relation("user_notifications")
settings UserSettings?
comments Comment[]
replies Reply[]
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
AccessToken AccessToken[]
workspaceCalendars WorkspaceCalendar[]
notifications Notification[] @relation("user_notifications")
settings UserSettings?
comments Comment[]
replies Reply[]
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
AccessToken AccessToken[]
workspaceCalendars WorkspaceCalendar[]
workspaceMemberLastAccesses WorkspaceMemberLastAccess[]
@@index([email])
@@map("users")
@@ -151,6 +152,9 @@ model Workspace {
workspaceCalendars WorkspaceCalendar[]
workspaceAdminStats WorkspaceAdminStats[]
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
workspaceDocViewDaily WorkspaceDocViewDaily[]
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
@@index([lastCheckEmbeddings])
@@index([createdAt])
@@ -180,6 +184,7 @@ model WorkspaceDoc {
@@id([workspaceId, docId])
@@index([workspaceId, public])
@@index([public, publishedAt])
@@map("workspace_pages")
}
@@ -320,6 +325,62 @@ model WorkspaceAdminStatsDirty {
@@map("workspace_admin_stats_dirty")
}
model WorkspaceAdminStatsDaily {
workspaceId String @map("workspace_id") @db.VarChar
date DateTime @db.Date
snapshotSize BigInt @default(0) @map("snapshot_size") @db.BigInt
blobSize BigInt @default(0) @map("blob_size") @db.BigInt
memberCount BigInt @default(0) @map("member_count") @db.BigInt
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, date])
@@index([date])
@@map("workspace_admin_stats_daily")
}
model SyncActiveUsersMinutely {
minuteTs DateTime @id @map("minute_ts") @db.Timestamptz(3)
activeUsers Int @default(0) @map("active_users") @db.Integer
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
@@map("sync_active_users_minutely")
}
model WorkspaceDocViewDaily {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
date DateTime @db.Date
totalViews BigInt @default(0) @map("total_views") @db.BigInt
uniqueViews BigInt @default(0) @map("unique_views") @db.BigInt
guestViews BigInt @default(0) @map("guest_views") @db.BigInt
lastAccessedAt DateTime? @map("last_accessed_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId, date])
@@index([workspaceId, date])
@@map("workspace_doc_view_daily")
}
model WorkspaceMemberLastAccess {
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("user_id") @db.VarChar
lastAccessedAt DateTime @map("last_accessed_at") @db.Timestamptz(3)
lastDocId String? @map("last_doc_id") @db.VarChar
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([workspaceId, userId])
@@index([workspaceId, lastAccessedAt(sort: Desc)])
@@index([workspaceId, lastDocId])
@@map("workspace_member_last_access")
}
// the latest snapshot of each doc that we've seen
// Snapshot + Updates are the latest state of the doc
model Snapshot {
@@ -456,6 +517,7 @@ model AiSessionMessage {
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
@@index([createdAt, role])
@@map("ai_sessions_messages")
}

View File

@@ -1,12 +1,28 @@
import { getCurrentUserQuery } from '@affine/graphql';
import { JobExecutor } from '../../../base/job/queue/executor';
import { DatabaseDocReader, DocReader } from '../../../core/doc';
import { createApp } from '../create-app';
import { e2e } from '../test';
e2e('should init doc service', async t => {
type TestFlavor = 'doc' | 'graphql' | 'sync' | 'renderer' | 'front';
const createFlavorApp = async (flavor: TestFlavor) => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'doc';
await using app = await createApp();
globalThis.env.FLAVOR = flavor;
return await createApp({
tapModule(module) {
module.overrideProvider(JobExecutor).useValue({
onConfigInit: async () => {},
onConfigChanged: async () => {},
onModuleDestroy: async () => {},
});
},
});
};
e2e('should init doc service', async t => {
await using app = await createFlavorApp('doc');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'doc');
@@ -15,9 +31,7 @@ e2e('should init doc service', async t => {
});
e2e('should init graphql service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'graphql';
await using app = await createApp();
await using app = await createFlavorApp('graphql');
const res = await app.GET('/info').expect(200);
@@ -28,28 +42,25 @@ e2e('should init graphql service', async t => {
});
e2e('should init sync service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'sync';
await using app = await createApp();
await using app = await createFlavorApp('sync');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'sync');
});
e2e('should init renderer service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'renderer';
await using app = await createApp();
await using app = await createFlavorApp('renderer');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'renderer');
});
e2e('should init front service', async t => {
// @ts-expect-error override
globalThis.env.FLAVOR = 'front';
await using app = await createApp();
await using app = await createFlavorApp('front');
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'front');
const docReader = app.get(DocReader);
t.true(docReader instanceof DatabaseDocReader);
});

View File

@@ -0,0 +1,610 @@
import { PrismaClient } from '@prisma/client';
import { app, e2e, Mockers } from '../test';
async function gql(query: string, variables?: Record<string, unknown>) {
const res = await app.POST('/graphql').send({ query, variables }).expect(200);
return res.body as {
data?: Record<string, any>;
errors?: Array<{ message: string; extensions: Record<string, any> }>;
};
}
async function ensureAnalyticsTables(db: PrismaClient) {
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_admin_stats_daily (
workspace_id VARCHAR NOT NULL,
date DATE NOT NULL,
snapshot_size BIGINT NOT NULL DEFAULT 0,
blob_size BIGINT NOT NULL DEFAULT 0,
member_count BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, date)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
active_users INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_doc_view_daily (
workspace_id VARCHAR NOT NULL,
doc_id VARCHAR NOT NULL,
date DATE NOT NULL,
total_views BIGINT NOT NULL DEFAULT 0,
unique_views BIGINT NOT NULL DEFAULT 0,
guest_views BIGINT NOT NULL DEFAULT 0,
last_accessed_at TIMESTAMPTZ(3),
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, doc_id, date)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_member_last_access (
workspace_id VARCHAR NOT NULL,
user_id VARCHAR NOT NULL,
last_accessed_at TIMESTAMPTZ(3) NOT NULL,
last_doc_id VARCHAR,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, user_id)
);
`);
}
async function createPublicDoc(input: {
workspaceId: string;
ownerId: string;
title: string;
updatedAt: Date;
publishedAt: Date;
}) {
const snapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: input.workspaceId,
user: { id: input.ownerId },
});
await app.create(Mockers.DocMeta, {
workspaceId: input.workspaceId,
docId: snapshot.id,
title: input.title,
public: true,
publishedAt: input.publishedAt,
});
const db = app.get(PrismaClient);
await db.snapshot.update({
where: {
workspaceId_id: {
workspaceId: input.workspaceId,
id: snapshot.id,
},
},
data: {
updatedAt: input.updatedAt,
updatedBy: input.ownerId,
},
});
return snapshot.id;
}
e2e(
'adminAllSharedLinks should support stable pagination and includeTotal',
async t => {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const newerDocId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'newer-doc',
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
publishedAt: new Date('2026-02-11T10:00:00.000Z'),
});
const olderDocId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'older-doc',
updatedAt: new Date('2026-02-10T10:00:00.000Z'),
publishedAt: new Date('2026-02-10T10:00:00.000Z'),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES
(${workspace.id}, ${newerDocId}, CURRENT_DATE, 10, 8, 2, NOW(), NOW()),
(${workspace.id}, ${olderDocId}, CURRENT_DATE, 5, 4, 1, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
const query = `
query AdminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
totalCount
analyticsWindow {
requestedSize
effectiveSize
}
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
workspaceId
docId
title
shareUrl
views
uniqueViews
guestViews
}
}
}
}
`;
const firstPage = await gql(query, {
pagination: { first: 1, offset: 0 },
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.falsy(firstPage.errors);
const first = firstPage.data!.adminAllSharedLinks;
t.is(first.totalCount, null);
t.true(first.pageInfo.hasNextPage);
t.is(first.edges.length, 1);
t.true([newerDocId, olderDocId].includes(first.edges[0].node.docId));
t.true(
first.edges[0].node.shareUrl.includes(`/workspace/${workspace.id}/`)
);
const secondPage = await gql(query, {
pagination: { first: 1, offset: 0, after: first.pageInfo.endCursor },
filter: {
includeTotal: true,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.falsy(secondPage.errors);
const second = secondPage.data!.adminAllSharedLinks;
t.is(second.totalCount, 2);
t.is(second.edges.length, 1);
t.not(second.edges[0].node.docId, first.edges[0].node.docId);
const conflict = await gql(query, {
pagination: {
first: 1,
offset: 1,
after: first.pageInfo.endCursor,
},
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.truthy(conflict.errors?.length);
t.is(conflict.errors![0].extensions.name, 'BAD_REQUEST');
const malformedDateCursor = await gql(query, {
pagination: {
first: 1,
offset: 0,
after: JSON.stringify({
orderBy: 'UpdatedAtDesc',
sortValue: 'not-a-date',
workspaceId: workspace.id,
docId: newerDocId,
}),
},
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.truthy(malformedDateCursor.errors?.length);
t.is(malformedDateCursor.errors![0].extensions.name, 'BAD_REQUEST');
const malformedViewsCursor = await gql(query, {
pagination: {
first: 1,
offset: 0,
after: JSON.stringify({
orderBy: 'ViewsDesc',
sortValue: 'NaN',
workspaceId: workspace.id,
docId: newerDocId,
}),
},
filter: {
includeTotal: false,
orderBy: 'ViewsDesc',
workspaceId: workspace.id,
},
});
t.truthy(malformedViewsCursor.errors?.length);
t.is(malformedViewsCursor.errors![0].extensions.name, 'BAD_REQUEST');
}
);
e2e(
'adminDashboard should clamp window inputs and return expected buckets',
async t => {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'dashboard-doc',
updatedAt: new Date(),
publishedAt: new Date(),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
const minute = new Date();
minute.setSeconds(0, 0);
await db.$executeRaw`
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
VALUES (${minute}, 7, NOW())
ON CONFLICT (minute_ts)
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
)
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
snapshot_size = EXCLUDED.snapshot_size,
blob_count = EXCLUDED.blob_count,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
)
VALUES (${workspace.id}, CURRENT_DATE, 100, 50, 1, NOW())
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 3, 2, 1, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
const dashboardQuery = `
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncWindow {
bucket
requestedSize
effectiveSize
}
storageWindow {
bucket
requestedSize
effectiveSize
}
topSharedLinksWindow {
bucket
requestedSize
effectiveSize
}
syncActiveUsersTimeline {
minute
activeUsers
}
workspaceStorageHistory {
date
value
}
}
}
`;
const result = await gql(dashboardQuery, {
input: {
storageHistoryDays: -10,
syncHistoryHours: -10,
sharedLinkWindowDays: -10,
},
});
t.falsy(result.errors);
const dashboard = result.data!.adminDashboard;
t.is(dashboard.syncWindow.bucket, 'Minute');
t.is(dashboard.syncWindow.effectiveSize, 1);
t.is(dashboard.storageWindow.bucket, 'Day');
t.is(dashboard.storageWindow.effectiveSize, 1);
t.is(dashboard.topSharedLinksWindow.effectiveSize, 1);
t.is(dashboard.syncActiveUsersTimeline.length, 1);
t.is(dashboard.workspaceStorageHistory.length, 1);
}
);
e2e(
'Doc analytics and lastAccessedMembers should enforce permissions and privacy',
async t => {
const owner = await app.signup();
const member = await app.create(Mockers.User);
const staleMember = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: staleMember.id,
});
const docId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'page-analytics-doc',
updatedAt: new Date(),
publishedAt: new Date(),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 9, 5, 2, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_member_last_access (
workspace_id, user_id, last_accessed_at, last_doc_id, updated_at
)
VALUES
(${workspace.id}, ${owner.id}, NOW(), ${docId}, NOW()),
(${workspace.id}, ${member.id}, NOW() - interval '1 minute', ${docId}, NOW()),
(${workspace.id}, ${staleMember.id}, NOW() - interval '8 day', ${docId}, NOW())
ON CONFLICT (workspace_id, user_id)
DO UPDATE SET
last_accessed_at = EXCLUDED.last_accessed_at,
last_doc_id = EXCLUDED.last_doc_id,
updated_at = EXCLUDED.updated_at
`;
const analyticsQuery = `
query DocAnalytics($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
analytics(input: { windowDays: 999 }) {
window {
effectiveSize
}
series {
date
totalViews
}
summary {
totalViews
uniqueViews
guestViews
}
}
lastAccessedMembers(
pagination: { first: 100, offset: 0 }
includeTotal: true
) {
totalCount
edges {
node {
user {
id
name
avatarUrl
}
lastAccessedAt
lastDocId
}
}
}
}
}
}
`;
await app.login(owner);
const ownerResult = await gql(analyticsQuery, {
workspaceId: workspace.id,
docId,
});
t.falsy(ownerResult.errors);
t.is(ownerResult.data!.workspace.doc.analytics.window.effectiveSize, 7);
t.true(ownerResult.data!.workspace.doc.analytics.series.length > 0);
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.totalCount, 2);
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.edges.length, 2);
t.false(
ownerResult.data!.workspace.doc.lastAccessedMembers.edges.some(
(edge: { node: { user: { id: string } } }) =>
edge.node.user.id === staleMember.id
)
);
const malformedMembersCursor = await gql(
`
query DocMembersCursor($workspaceId: String!, $docId: String!, $after: String) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(
pagination: { first: 10, offset: 0, after: $after }
) {
edges {
node {
user {
id
}
}
}
}
}
}
}
`,
{
workspaceId: workspace.id,
docId,
after: JSON.stringify({
lastAccessedAt: 'not-a-date',
userId: owner.id,
}),
}
);
t.truthy(malformedMembersCursor.errors?.length);
t.is(malformedMembersCursor.errors![0].extensions.name, 'BAD_REQUEST');
const privacyQuery = `
query DocMembersPrivacy($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
edges {
node {
user {
id
email
}
}
}
}
}
}
}
`;
const privacyRes = await app
.POST('/graphql')
.send({
query: privacyQuery,
variables: {
workspaceId: workspace.id,
docId,
},
})
.expect(400);
const privacyResult = privacyRes.body as {
errors?: Array<{ message: string }>;
};
t.truthy(privacyResult.errors?.length);
t.true(
privacyResult.errors![0].message.includes(
'Cannot query field "email" on type "PublicUserType"'
)
);
await app.login(member);
const memberDeniedRes = await app
.POST('/graphql')
.send({
query: `
query DocMembersDenied($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
edges {
node {
user {
id
}
}
}
}
}
}
}
`,
variables: { workspaceId: workspace.id, docId },
})
.expect(200);
const memberDenied = memberDeniedRes.body as {
errors?: Array<{ extensions: Record<string, unknown> }>;
};
t.truthy(memberDenied.errors?.length);
t.is(memberDenied.errors![0].extensions.name, 'SPACE_ACCESS_DENIED');
}
);

View File

@@ -1,3 +1,4 @@
import { PrismaClient } from '@prisma/client';
import test, { type ExecutionContext } from 'ava';
import { io, type Socket as SocketIOClient } from 'socket.io-client';
import { Doc, encodeStateAsUpdate } from 'yjs';
@@ -146,6 +147,44 @@ function createYjsUpdateBase64() {
return Buffer.from(update).toString('base64');
}
async function ensureSyncActiveUsersTable(db: PrismaClient) {
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
active_users INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
)
`);
}
async function latestActiveUsers(db: PrismaClient) {
const rows = await db.$queryRaw<{ activeUsers: number }[]>`
SELECT active_users::integer AS "activeUsers"
FROM sync_active_users_minutely
ORDER BY minute_ts DESC
LIMIT 1
`;
if (!rows[0]) {
return null;
}
return Number(rows[0].activeUsers);
}
async function waitForActiveUsers(db: PrismaClient, expected: number) {
const deadline = Date.now() + WS_TIMEOUT_MS;
while (Date.now() < deadline) {
const current = await latestActiveUsers(db);
if (current === expected) {
return;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Timed out waiting active users=${expected}`);
}
let app: TestingApp;
let url: string;
@@ -461,3 +500,22 @@ test('space:join-awareness should reject clientVersion<0.25.0', async t => {
socket.disconnect();
}
});
test('active users metric should dedupe multiple sockets for one user', async t => {
const db = app.get(PrismaClient);
await ensureSyncActiveUsersTable(db);
const { cookieHeader } = await login(app);
const first = createClient(url, cookieHeader);
const second = createClient(url, cookieHeader);
try {
await Promise.all([waitForConnect(first), waitForConnect(second)]);
await waitForActiveUsers(db, 1);
t.pass();
} finally {
first.disconnect();
second.disconnect();
await Promise.all([waitForDisconnect(first), waitForDisconnect(second)]);
}
});

View File

@@ -217,6 +217,35 @@ test('should be able to get doc', async t => {
t.deepEqual(res.body, Buffer.from([0, 0]));
});
test('should record doc view when reading doc', async t => {
const { app, workspace: doc, models } = t.context;
doc.getDoc.resolves({
spaceId: '',
docId: '',
bin: Buffer.from([0, 0]),
timestamp: Date.now(),
});
const record = Sinon.stub(
models.workspaceAnalytics,
'recordDocView'
).resolves();
await app.login(t.context.u1);
const res = await app.GET('/api/workspaces/private/docs/public');
t.is(res.status, HttpStatus.OK);
t.true(record.calledOnce);
t.like(record.firstCall.args[0], {
workspaceId: 'private',
docId: 'public',
userId: t.context.u1.id,
isGuest: false,
});
record.restore();
});
test('should be able to change page publish mode', async t => {
const { app, workspace: doc, models } = t.context;

View File

@@ -159,8 +159,11 @@ export function buildAppModule(env: Env) {
// basic
.use(...FunctionalityModules)
// enable indexer module on graphql server and doc service
.useIf(() => env.flavors.graphql || env.flavors.doc, IndexerModule)
// enable indexer module on graphql, doc and front service
.useIf(
() => env.flavors.graphql || env.flavors.doc || env.flavors.front,
IndexerModule
)
// auth
.use(UserModule, AuthModule, PermissionModule)
@@ -202,8 +205,8 @@ export function buildAppModule(env: Env) {
AccessTokenModule,
QueueDashboardModule
)
// doc service only
.useIf(() => env.flavors.doc, DocServiceModule)
// doc service and front service
.useIf(() => env.flavors.doc || env.flavors.front, DocServiceModule)
// worker for and self-hosted API only for self-host and local development only
.useIf(() => env.dev || env.selfhosted, WorkerModule, SelfhostModule)
// static frontend routes for front flavor

View File

@@ -82,7 +82,7 @@ test('should decode pagination input', async t => {
await app.gql(query, {
input: {
first: 5,
offset: 1,
offset: 0,
after: Buffer.from('4').toString('base64'),
},
});
@@ -90,12 +90,34 @@ test('should decode pagination input', async t => {
t.true(
paginationStub.calledOnceWithExactly({
first: 5,
offset: 1,
offset: 0,
after: '4',
})
);
});
test('should reject mixed pagination cursor and offset', async t => {
const res = await app.POST('/graphql').send({
query,
variables: {
input: {
first: 5,
offset: 1,
after: Buffer.from('4').toString('base64'),
},
},
});
t.is(res.status, 200);
t.truthy(res.body.errors?.length);
t.is(
res.body.errors[0].message,
'pagination.after and pagination.offset cannot be used together'
);
t.is(res.body.errors[0].extensions.status, 400);
t.is(res.body.errors[0].extensions.name, 'BAD_REQUEST');
});
test('should return encode pageInfo', async t => {
const result = paginate(
ITEMS.slice(10, 20),

View File

@@ -1,6 +1,8 @@
import { PipeTransform, Type } from '@nestjs/common';
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { BadRequest } from '../error';
@InputType()
export class PaginationInput {
/**
@@ -13,11 +15,15 @@ export class PaginationInput {
*/
static decode: PipeTransform<PaginationInput, PaginationInput> = {
transform: value => {
return {
const input = {
...value,
first: Math.min(Math.max(value?.first ?? 10, 1), 100),
offset: Math.max(value?.offset ?? 0, 0),
after: decode(value?.after),
// before: decode(value.before),
};
assertPaginationInput(input);
return input;
},
};
@@ -51,6 +57,18 @@ export class PaginationInput {
// before?: string | null;
}
export function assertPaginationInput(paginationInput?: PaginationInput) {
if (!paginationInput) {
return;
}
if (paginationInput.after && paginationInput.offset > 0) {
throw new BadRequest(
'pagination.after and pagination.offset cannot be used together'
);
}
}
const encode = (input: unknown) => {
let inputStr: string;
if (input instanceof Date) {
@@ -65,7 +83,7 @@ const encode = (input: unknown) => {
const decode = (base64String?: string | null) =>
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
function encodeWithJson(input: unknown) {
export function encodeWithJson(input: unknown) {
return encode(JSON.stringify(input ?? null));
}

View File

@@ -31,8 +31,8 @@ export class JobExecutor implements OnModuleDestroy {
? difference(QUEUES, [Queue.DOC, Queue.INDEXER])
: [];
// NOTE(@forehalo): only enable doc queue in doc service
if (env.flavors.doc) {
// Enable doc/indexer queues in both doc and front service.
if (env.flavors.doc || env.flavors.front) {
queues.push(Queue.DOC);
// NOTE(@fengmk2): Once the index task cannot be processed in time, it needs to be separated from the doc service and deployed independently.
queues.push(Queue.INDEXER);

View File

@@ -37,12 +37,7 @@ function extractTokenFromHeader(authorization: string) {
@Injectable()
export class AuthService implements OnApplicationBootstrap {
readonly cookieOptions: CookieOptions = {
sameSite: 'lax',
httpOnly: true,
path: '/',
secure: this.config.server.https,
};
readonly cookieOptions: CookieOptions;
static readonly sessionCookieName = 'affine_session';
static readonly userCookieName = 'affine_user_id';
static readonly csrfCookieName = 'affine_csrf_token';
@@ -51,7 +46,14 @@ export class AuthService implements OnApplicationBootstrap {
private readonly config: Config,
private readonly models: Models,
private readonly mailer: Mailer
) {}
) {
this.cookieOptions = {
sameSite: 'lax',
httpOnly: true,
path: '/',
secure: this.config.server.https,
};
}
async onApplicationBootstrap() {
if (env.dev) {

View File

@@ -2,18 +2,20 @@ import { randomUUID } from 'node:crypto';
import { User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { Doc as YDoc } from 'yjs';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { ConfigFactory } from '../../../base';
import { Flavor } from '../../../env';
import { Models } from '../../../models';
import { PgWorkspaceDocStorageAdapter } from '../../doc';
import { DocReader, PgWorkspaceDocStorageAdapter } from '../../doc';
const test = ava as TestFn<{
models: Models;
app: TestingApp;
adapter: PgWorkspaceDocStorageAdapter;
docReader: DocReader;
}>;
test.before(async t => {
@@ -23,6 +25,7 @@ test.before(async t => {
t.context.models = app.get(Models);
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
t.context.docReader = app.get(DocReader);
t.context.app = app;
});
@@ -68,3 +71,41 @@ test('should render page success', async t => {
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
t.pass();
});
test('should record page view when rendering shared page', async t => {
const docId = randomUUID();
const { app, adapter, models, docReader } = t.context;
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'analytics');
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
await models.doc.publish(workspace.id, docId);
const docContent = Sinon.stub(docReader, 'getDocContent').resolves({
title: 'analytics-doc',
summary: 'summary',
});
const record = Sinon.stub(
models.workspaceAnalytics,
'recordDocView'
).resolves();
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
t.true(record.calledOnce);
t.like(record.firstCall.args[0], {
workspaceId: workspace.id,
docId,
isGuest: true,
});
docContent.restore();
record.restore();
});

View File

@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -5,7 +6,7 @@ import { Controller, Get, Logger, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import isMobile from 'is-mobile';
import { Config, metrics } from '../../base';
import { Config, getRequestTrackerId, metrics } from '../../base';
import { Models } from '../../models';
import { htmlSanitize } from '../../native';
import { Public } from '../auth';
@@ -60,6 +61,13 @@ export class DocRendererController {
);
}
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
const tracker = getRequestTrackerId(req);
return createHash('sha256')
.update(`${workspaceId}:${docId}:${tracker}`)
.digest('hex');
}
@Public()
@Get('/*path')
async render(@Req() req: Request, @Res() res: Response) {
@@ -83,6 +91,22 @@ export class DocRendererController {
? await this.getWorkspaceContent(workspaceId)
: await this.getPageContent(workspaceId, subPath);
metrics.doc.counter('render').add(1);
if (opts && workspaceId !== subPath) {
void this.models.workspaceAnalytics
.recordDocView({
workspaceId,
docId: subPath,
visitorId: this.buildVisitorId(req, workspaceId, subPath),
isGuest: true,
})
.catch(error => {
this.logger.warn(
`Failed to record shared page view: ${workspaceId}/${subPath}`,
error as Error
);
});
}
} catch (e) {
this.logger.error('failed to render page', e);
}

View File

@@ -447,7 +447,7 @@ export class RpcDocReader extends DatabaseDocReader {
export const DocReaderProvider: FactoryProvider = {
provide: DocReader,
useFactory: (ref: ModuleRef) => {
if (env.flavors.doc) {
if (env.flavors.doc || env.flavors.front) {
return ref.create(DatabaseDocReader);
}
return ref.create(RpcDocReader);

View File

@@ -1,4 +1,10 @@
import { applyDecorators, Logger, UseInterceptors } from '@nestjs/common';
import {
applyDecorators,
Logger,
OnModuleDestroy,
OnModuleInit,
UseInterceptors,
} from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
@@ -8,6 +14,7 @@ import {
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import type { Request } from 'express';
import { ClsInterceptor } from 'nestjs-cls';
import semver from 'semver';
import { type Server, Socket } from 'socket.io';
@@ -71,6 +78,7 @@ const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', {
});
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId';
function normalizeWsClientVersion(clientVersion: string): string | null {
if (env.namespaces.canary) {
@@ -190,7 +198,11 @@ interface UpdateAwarenessMessage {
@WebSocketGateway()
@UseInterceptors(ClsInterceptor)
export class SpaceSyncGateway
implements OnGatewayConnection, OnGatewayDisconnect
implements
OnGatewayConnection,
OnGatewayDisconnect,
OnModuleInit,
OnModuleDestroy
{
protected logger = new Logger(SpaceSyncGateway.name);
@@ -198,6 +210,7 @@ export class SpaceSyncGateway
private readonly server!: Server;
private connectionCount = 0;
private flushTimer?: NodeJS.Timeout;
constructor(
private readonly ac: AccessController,
@@ -208,6 +221,22 @@ export class SpaceSyncGateway
private readonly models: Models
) {}
onModuleInit() {
this.flushTimer = setInterval(() => {
this.flushActiveUsersMinute().catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}, 60_000);
this.flushTimer.unref?.();
}
onModuleDestroy() {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
}
private encodeUpdates(updates: Uint8Array[]) {
return updates.map(update => Buffer.from(update).toString('base64'));
}
@@ -269,18 +298,95 @@ export class SpaceSyncGateway
setImmediate(() => client.disconnect());
}
handleConnection() {
handleConnection(client: Socket) {
this.connectionCount++;
this.logger.debug(`New connection, total: ${this.connectionCount}`);
metrics.socketio.gauge('connections').record(this.connectionCount);
this.attachPresenceUserId(client);
this.flushActiveUsersMinute().catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}
handleDisconnect() {
this.connectionCount--;
handleDisconnect(_client: Socket) {
this.connectionCount = Math.max(0, this.connectionCount - 1);
this.logger.debug(
`Connection disconnected, total: ${this.connectionCount}`
);
metrics.socketio.gauge('connections').record(this.connectionCount);
void this.flushActiveUsersMinute({
aggregateAcrossCluster: false,
}).catch(error => {
this.logger.warn('Failed to flush active users minute', error as Error);
});
}
private attachPresenceUserId(client: Socket) {
const request = client.request as Request;
const userId = request.session?.user.id ?? request.token?.user.id;
if (typeof userId !== 'string' || !userId) {
this.logger.warn(
`Unable to resolve authenticated user id for socket ${client.id}`
);
return;
}
client.data[SOCKET_PRESENCE_USER_ID_KEY] = userId;
}
private resolvePresenceUserId(socket: { data?: unknown }) {
if (!socket.data || typeof socket.data !== 'object') {
return null;
}
const userId = (socket.data as Record<string, unknown>)[
SOCKET_PRESENCE_USER_ID_KEY
];
return typeof userId === 'string' && userId ? userId : null;
}
private async flushActiveUsersMinute(options?: {
aggregateAcrossCluster?: boolean;
}) {
const minute = new Date();
minute.setSeconds(0, 0);
const aggregateAcrossCluster = options?.aggregateAcrossCluster ?? true;
let activeUsers = Math.max(0, this.connectionCount);
if (aggregateAcrossCluster) {
try {
const sockets = await this.server.fetchSockets();
const uniqueUsers = new Set<string>();
let missingUserCount = 0;
for (const socket of sockets) {
const userId = this.resolvePresenceUserId(socket);
if (userId) {
uniqueUsers.add(userId);
} else {
missingUserCount++;
}
}
if (missingUserCount > 0) {
activeUsers = sockets.length;
this.logger.warn(
`Unable to resolve user id for ${missingUserCount} active sockets, fallback to connection count`
);
} else {
activeUsers = uniqueUsers.size;
}
} catch (error) {
this.logger.warn(
'Failed to aggregate active users from sockets, using local value',
error as Error
);
}
}
await this.models.workspaceAnalytics.upsertSyncActiveUsersMinute(
minute,
activeUsers
);
}
@OnEvent('doc.updates.pushed')

View File

@@ -1,5 +1,15 @@
import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import { createHash } from 'node:crypto';
import {
Controller,
Get,
Logger,
Param,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
applyAttachHeaders,
@@ -8,6 +18,7 @@ import {
CommentAttachmentNotFound,
DocHistoryNotFound,
DocNotFound,
getRequestTrackerId,
InvalidHistoryTimestamp,
} from '../../base';
import { DocMode, Models, PublicDocMode } from '../../models';
@@ -30,6 +41,13 @@ export class WorkspacesController {
private readonly models: Models
) {}
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
const tracker = getRequestTrackerId(req);
return createHash('sha256')
.update(`${workspaceId}:${docId}:${tracker}`)
.digest('hex');
}
// get workspace blob
//
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
@@ -99,6 +117,7 @@ export class WorkspacesController {
@CallMetric('controllers', 'workspace_get_doc')
async doc(
@CurrentUser() user: CurrentUser | undefined,
@Req() req: Request,
@Param('id') ws: string,
@Param('guid') guid: string,
@Res() res: Response
@@ -127,6 +146,23 @@ export class WorkspacesController {
});
}
if (!docId.isWorkspace) {
void this.models.workspaceAnalytics
.recordDocView({
workspaceId: docId.workspace,
docId: docId.guid,
userId: user?.id,
visitorId: this.buildVisitorId(req, docId.workspace, docId.guid),
isGuest: !user,
})
.catch(error => {
this.logger.warn(
`Failed to record doc view: ${docId.workspace}/${docId.guid}`,
error as Error
);
});
}
if (!docId.isWorkspace) {
// fetch the publish page mode for publish page
const docMeta = await this.models.doc.getMeta(

View File

@@ -16,6 +16,8 @@ import {
} from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { PaginationInput, URLHelper } from '../../../base';
import { PageInfo } from '../../../base/graphql/pagination';
import {
Feature,
Models,
@@ -25,6 +27,7 @@ import {
} from '../../../models';
import { Admin } from '../../common';
import { WorkspaceUserType } from '../../user';
import { TimeWindow } from './analytics-types';
enum AdminWorkspaceSort {
CreatedAt = 'CreatedAt',
@@ -40,6 +43,16 @@ registerEnumType(AdminWorkspaceSort, {
name: 'AdminWorkspaceSort',
});
enum AdminSharedLinksOrder {
UpdatedAtDesc = 'UpdatedAtDesc',
PublishedAtDesc = 'PublishedAtDesc',
ViewsDesc = 'ViewsDesc',
}
registerEnumType(AdminSharedLinksOrder, {
name: 'AdminSharedLinksOrder',
});
@InputType()
class ListWorkspaceInput {
@Field(() => Int, { defaultValue: 20 })
@@ -106,6 +119,195 @@ class AdminWorkspaceSharedLink {
publishedAt?: Date | null;
}
@InputType()
class AdminDashboardInput {
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
timezone?: string;
@Field(() => Int, { nullable: true, defaultValue: 30 })
storageHistoryDays?: number;
@Field(() => Int, { nullable: true, defaultValue: 48 })
syncHistoryHours?: number;
@Field(() => Int, { nullable: true, defaultValue: 28 })
sharedLinkWindowDays?: number;
}
@ObjectType()
class AdminDashboardMinutePoint {
@Field(() => Date)
minute!: Date;
@Field(() => Int)
activeUsers!: number;
}
@ObjectType()
class AdminDashboardValueDayPoint {
@Field(() => Date)
date!: Date;
@Field(() => SafeIntResolver)
value!: number;
}
@ObjectType()
class AdminSharedLinkTopItem {
@Field(() => String)
workspaceId!: string;
@Field(() => String)
docId!: string;
@Field(() => String, { nullable: true })
title?: string | null;
@Field(() => String)
shareUrl!: string;
@Field(() => Date, { nullable: true })
publishedAt?: Date | null;
@Field(() => SafeIntResolver)
views!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
@Field(() => Date, { nullable: true })
lastAccessedAt?: Date | null;
}
@ObjectType()
class AdminDashboard {
@Field(() => Int)
syncActiveUsers!: number;
@Field(() => [AdminDashboardMinutePoint])
syncActiveUsersTimeline!: AdminDashboardMinutePoint[];
@Field(() => TimeWindow)
syncWindow!: TimeWindow;
@Field(() => SafeIntResolver)
copilotConversations!: number;
@Field(() => SafeIntResolver)
workspaceStorageBytes!: number;
@Field(() => SafeIntResolver)
blobStorageBytes!: number;
@Field(() => [AdminDashboardValueDayPoint])
workspaceStorageHistory!: AdminDashboardValueDayPoint[];
@Field(() => [AdminDashboardValueDayPoint])
blobStorageHistory!: AdminDashboardValueDayPoint[];
@Field(() => TimeWindow)
storageWindow!: TimeWindow;
@Field(() => [AdminSharedLinkTopItem])
topSharedLinks!: AdminSharedLinkTopItem[];
@Field(() => TimeWindow)
topSharedLinksWindow!: TimeWindow;
@Field(() => Date)
generatedAt!: Date;
}
@InputType()
class AdminAllSharedLinksFilterInput {
@Field(() => String, { nullable: true })
keyword?: string;
@Field(() => String, { nullable: true })
workspaceId?: string;
@Field(() => Date, { nullable: true })
updatedAfter?: Date;
@Field(() => AdminSharedLinksOrder, {
nullable: true,
defaultValue: AdminSharedLinksOrder.UpdatedAtDesc,
})
orderBy?: AdminSharedLinksOrder;
@Field(() => Int, { nullable: true, defaultValue: 28 })
analyticsWindowDays?: number;
@Field(() => Boolean, { nullable: true, defaultValue: false })
includeTotal?: boolean;
}
@ObjectType()
class AdminAllSharedLink {
@Field(() => String)
workspaceId!: string;
@Field(() => String)
docId!: string;
@Field(() => String, { nullable: true })
title?: string | null;
@Field(() => Date, { nullable: true })
publishedAt?: Date | null;
@Field(() => Date, { nullable: true })
docUpdatedAt?: Date | null;
@Field(() => String, { nullable: true })
workspaceOwnerId?: string | null;
@Field(() => String, { nullable: true })
lastUpdaterId?: string | null;
@Field(() => String)
shareUrl!: string;
@Field(() => SafeIntResolver, { nullable: true })
views?: number | null;
@Field(() => SafeIntResolver, { nullable: true })
uniqueViews?: number | null;
@Field(() => SafeIntResolver, { nullable: true })
guestViews?: number | null;
@Field(() => Date, { nullable: true })
lastAccessedAt?: Date | null;
}
@ObjectType()
class AdminAllSharedLinkEdge {
@Field(() => String)
cursor!: string;
@Field(() => AdminAllSharedLink)
node!: AdminAllSharedLink;
}
@ObjectType()
class PaginatedAdminAllSharedLink {
@Field(() => [AdminAllSharedLinkEdge])
edges!: AdminAllSharedLinkEdge[];
@Field(() => PageInfo)
pageInfo!: PageInfo;
@Field(() => Int, { nullable: true })
totalCount?: number;
@Field(() => TimeWindow)
analyticsWindow!: TimeWindow;
}
@ObjectType()
export class AdminWorkspace {
@Field()
@@ -187,7 +389,10 @@ class AdminUpdateWorkspaceInput extends PartialType(
@Admin()
@Resolver(() => AdminWorkspace)
export class AdminWorkspaceResolver {
constructor(private readonly models: Models) {}
constructor(
private readonly models: Models,
private readonly url: URLHelper
) {}
private assertCloudOnly() {
if (env.selfhosted) {
@@ -261,6 +466,72 @@ export class AdminWorkspaceResolver {
return row;
}
@Query(() => AdminDashboard, {
description: 'Get aggregated dashboard metrics for admin panel',
})
async adminDashboard(
@Args('input', { nullable: true, type: () => AdminDashboardInput })
input?: AdminDashboardInput
) {
this.assertCloudOnly();
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
timezone: input?.timezone,
storageHistoryDays: input?.storageHistoryDays,
syncHistoryHours: input?.syncHistoryHours,
sharedLinkWindowDays: input?.sharedLinkWindowDays,
});
return {
...dashboard,
topSharedLinks: dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
})),
};
}
@Query(() => PaginatedAdminAllSharedLink, {
description: 'List all shared links across workspaces for admin panel',
})
async adminAllSharedLinks(
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
@Args('filter', {
nullable: true,
type: () => AdminAllSharedLinksFilterInput,
})
filter?: AdminAllSharedLinksFilterInput
) {
this.assertCloudOnly();
const result =
await this.models.workspaceAnalytics.adminPaginateAllSharedLinks({
keyword: filter?.keyword,
workspaceId: filter?.workspaceId,
updatedAfter: filter?.updatedAfter,
orderBy:
filter?.orderBy === AdminSharedLinksOrder.PublishedAtDesc
? 'PublishedAtDesc'
: filter?.orderBy === AdminSharedLinksOrder.ViewsDesc
? 'ViewsDesc'
: 'UpdatedAtDesc',
analyticsWindowDays: filter?.analyticsWindowDays,
includeTotal: filter?.includeTotal,
pagination,
});
return {
...result,
edges: result.edges.map(edge => ({
...edge,
node: {
...edge.node,
shareUrl: this.url.link(
`/workspace/${edge.node.workspaceId}/${edge.node.docId}`
),
},
})),
};
}
@ResolveField(() => [AdminWorkspaceMember], {
description: 'Members of workspace',
})

View File

@@ -0,0 +1,31 @@
import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
export enum TimeBucket {
Minute = 'Minute',
Day = 'Day',
}
registerEnumType(TimeBucket, {
name: 'TimeBucket',
});
@ObjectType()
export class TimeWindow {
@Field(() => Date)
from!: Date;
@Field(() => Date)
to!: Date;
@Field(() => String)
timezone!: string;
@Field(() => TimeBucket)
bucket!: TimeBucket;
@Field(() => Int)
requestedSize!: number;
@Field(() => Int)
effectiveSize!: number;
}

View File

@@ -3,6 +3,7 @@ import {
Args,
Field,
InputType,
Int,
Mutation,
ObjectType,
Parent,
@@ -11,6 +12,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { PrismaClient } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import {
Cache,
@@ -27,6 +29,7 @@ import {
PaginationInput,
registerObjectType,
} from '../../../base';
import { PageInfo } from '../../../base/graphql/pagination';
import { Models, PublicDocMode } from '../../../models';
import { CurrentUser } from '../../auth';
import { Editor } from '../../doc';
@@ -38,6 +41,7 @@ import {
} from '../../permission';
import { PublicUserType, WorkspaceUserType } from '../../user';
import { WorkspaceType } from '../types';
import { TimeBucket, TimeWindow } from './analytics-types';
import {
DotToUnderline,
mapPermissionsToGraphqlPermissions,
@@ -194,6 +198,93 @@ class WorkspaceDocMeta {
updatedBy!: EditorType | null;
}
@InputType()
class DocPageAnalyticsInput {
@Field(() => Int, { nullable: true, defaultValue: 28 })
windowDays?: number;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
timezone?: string;
}
@ObjectType()
class DocPageAnalyticsPoint {
@Field(() => Date)
date!: Date;
@Field(() => SafeIntResolver)
totalViews!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
}
@ObjectType()
class DocPageAnalyticsSummary {
@Field(() => SafeIntResolver)
totalViews!: number;
@Field(() => SafeIntResolver)
uniqueViews!: number;
@Field(() => SafeIntResolver)
guestViews!: number;
@Field(() => Date, { nullable: true })
lastAccessedAt!: Date | null;
}
@ObjectType()
class DocPageAnalytics {
@Field(() => TimeWindow)
window!: TimeWindow;
@Field(() => [DocPageAnalyticsPoint])
series!: DocPageAnalyticsPoint[];
@Field(() => DocPageAnalyticsSummary)
summary!: DocPageAnalyticsSummary;
@Field(() => Date)
generatedAt!: Date;
}
@ObjectType()
class DocMemberLastAccess {
@Field(() => PublicUserType)
user!: PublicUserType;
@Field(() => Date)
lastAccessedAt!: Date;
@Field(() => String, { nullable: true })
lastDocId!: string | null;
}
@ObjectType()
class DocMemberLastAccessEdge {
@Field(() => String)
cursor!: string;
@Field(() => DocMemberLastAccess)
node!: DocMemberLastAccess;
}
@ObjectType()
class PaginatedDocMemberLastAccess {
@Field(() => [DocMemberLastAccessEdge])
edges!: DocMemberLastAccessEdge[];
@Field(() => PageInfo)
pageInfo!: PageInfo;
@Field(() => Int, { nullable: true })
totalCount?: number;
}
@Resolver(() => WorkspaceType)
export class WorkspaceDocResolver {
private readonly logger = new Logger(WorkspaceDocResolver.name);
@@ -464,6 +555,64 @@ export class DocResolver {
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => DocPageAnalytics, {
description: 'Doc page analytics in a time window',
complexity: 5,
})
async analytics(
@CurrentUser() me: CurrentUser,
@Parent() doc: DocType,
@Args('input', { nullable: true, type: () => DocPageAnalyticsInput })
input?: DocPageAnalyticsInput
): Promise<DocPageAnalytics> {
await this.ac.user(me.id).doc(doc).assert('Doc.Read');
const analytics = await this.models.workspaceAnalytics.getDocPageAnalytics({
workspaceId: doc.workspaceId,
docId: doc.docId,
windowDays: input?.windowDays,
timezone: input?.timezone,
});
return {
...analytics,
window: {
...analytics.window,
bucket:
analytics.window.bucket === 'Minute'
? TimeBucket.Minute
: TimeBucket.Day,
},
};
}
@ResolveField(() => PaginatedDocMemberLastAccess, {
description: 'Paginated last accessed members of the current doc',
complexity: 5,
})
async lastAccessedMembers(
@CurrentUser() me: CurrentUser,
@Parent() doc: DocType,
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
@Args('query', { nullable: true }) query?: string,
@Args('includeTotal', { nullable: true, defaultValue: false })
includeTotal?: boolean
): Promise<PaginatedDocMemberLastAccess> {
await this.ac
.user(me.id)
.workspace(doc.workspaceId)
.assert('Workspace.Users.Manage');
return this.models.workspaceAnalytics.paginateDocLastAccessedMembers({
workspaceId: doc.workspaceId,
docId: doc.docId,
pagination,
query,
includeTotal: includeTotal ?? false,
});
}
@ResolveField(() => DocPermissions)
async permissions(
@CurrentUser() user: CurrentUser,

View File

@@ -124,6 +124,21 @@ export class WorkspaceStatsJob {
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
);
}
try {
const snapshotted = await this.withAdvisoryLock(async tx => {
await this.writeDailySnapshot(tx);
return true;
});
if (snapshotted) {
this.logger.debug('Wrote daily workspace admin stats snapshot');
}
} catch (error) {
this.logger.error(
'Failed to write daily workspace admin stats snapshot',
error as Error
);
}
}
private async withAdvisoryLock<T>(
@@ -304,4 +319,31 @@ export class WorkspaceStatsJob {
LIMIT ${limit}
`;
}
private async writeDailySnapshot(tx: Prisma.TransactionClient) {
await tx.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id,
date,
snapshot_size,
blob_size,
member_count,
updated_at
)
SELECT
workspace_id,
CURRENT_DATE,
snapshot_size,
blob_size,
member_count,
NOW()
FROM workspace_admin_stats
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
}
}

View File

@@ -34,6 +34,7 @@ import { UserFeatureModel } from './user-feature';
import { UserSettingsModel } from './user-settings';
import { VerificationTokenModel } from './verification-token';
import { WorkspaceModel } from './workspace';
import { WorkspaceAnalyticsModel } from './workspace-analytics';
import { WorkspaceCalendarModel } from './workspace-calendar';
import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceUserModel } from './workspace-user';
@@ -68,6 +69,7 @@ const MODELS = {
calendarEvent: CalendarEventModel,
calendarEventInstance: CalendarEventInstanceModel,
workspaceCalendar: WorkspaceCalendarModel,
workspaceAnalytics: WorkspaceAnalyticsModel,
};
type ModelsType = {
@@ -144,6 +146,7 @@ export * from './user-feature';
export * from './user-settings';
export * from './verification-token';
export * from './workspace';
export * from './workspace-analytics';
export * from './workspace-calendar';
export * from './workspace-feature';
export * from './workspace-user';

File diff suppressed because it is too large Load Diff

View File

@@ -59,11 +59,13 @@ export const CheckoutParams = z.object({
});
export abstract class SubscriptionManager {
protected readonly scheduleManager = new ScheduleManager(this.stripeProvider);
protected readonly scheduleManager: ScheduleManager;
constructor(
protected readonly stripeProvider: StripeFactory,
protected readonly db: PrismaClient
) {}
) {
this.scheduleManager = new ScheduleManager(this.stripeProvider);
}
get stripe() {
return this.stripeProvider.stripe;

View File

@@ -75,7 +75,7 @@ export { CheckoutParams };
@Injectable()
export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
private readonly scheduleManager = new ScheduleManager(this.stripeProvider);
private readonly scheduleManager: ScheduleManager;
constructor(
private readonly stripeProvider: StripeFactory,
@@ -85,7 +85,9 @@ export class SubscriptionService {
private readonly userManager: UserSubscriptionManager,
private readonly workspaceManager: WorkspaceSubscriptionManager,
private readonly selfhostManager: SelfhostTeamSubscriptionManager
) {}
) {
this.scheduleManager = new ScheduleManager(this.stripeProvider);
}
get stripe() {
return this.stripeProvider.stripe;

View File

@@ -5,12 +5,14 @@ import { fixUrl, OriginRules } from './utils';
@Injectable()
export class WorkerService {
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
allowedOrigins: OriginRules;
constructor(
private readonly config: Config,
private readonly url: URLHelper
) {}
) {
this.allowedOrigins = [...this.url.allowedOrigins];
}
@OnEvent('config.init')
onConfigInit() {

View File

@@ -30,6 +30,85 @@ input AddContextFileInput {
contextId: String!
}
type AdminAllSharedLink {
docId: String!
docUpdatedAt: DateTime
guestViews: SafeInt
lastAccessedAt: DateTime
lastUpdaterId: String
publishedAt: DateTime
shareUrl: String!
title: String
uniqueViews: SafeInt
views: SafeInt
workspaceId: String!
workspaceOwnerId: String
}
type AdminAllSharedLinkEdge {
cursor: String!
node: AdminAllSharedLink!
}
input AdminAllSharedLinksFilterInput {
analyticsWindowDays: Int = 28
includeTotal: Boolean = false
keyword: String
orderBy: AdminSharedLinksOrder = UpdatedAtDesc
updatedAfter: DateTime
workspaceId: String
}
type AdminDashboard {
blobStorageBytes: SafeInt!
blobStorageHistory: [AdminDashboardValueDayPoint!]!
copilotConversations: SafeInt!
generatedAt: DateTime!
storageWindow: TimeWindow!
syncActiveUsers: Int!
syncActiveUsersTimeline: [AdminDashboardMinutePoint!]!
syncWindow: TimeWindow!
topSharedLinks: [AdminSharedLinkTopItem!]!
topSharedLinksWindow: TimeWindow!
workspaceStorageBytes: SafeInt!
workspaceStorageHistory: [AdminDashboardValueDayPoint!]!
}
input AdminDashboardInput {
sharedLinkWindowDays: Int = 28
storageHistoryDays: Int = 30
syncHistoryHours: Int = 48
timezone: String = "UTC"
}
type AdminDashboardMinutePoint {
activeUsers: Int!
minute: DateTime!
}
type AdminDashboardValueDayPoint {
date: DateTime!
value: SafeInt!
}
type AdminSharedLinkTopItem {
docId: String!
guestViews: SafeInt!
lastAccessedAt: DateTime
publishedAt: DateTime
shareUrl: String!
title: String
uniqueViews: SafeInt!
views: SafeInt!
workspaceId: String!
}
enum AdminSharedLinksOrder {
PublishedAtDesc
UpdatedAtDesc
ViewsDesc
}
input AdminUpdateWorkspaceInput {
avatarKey: String
enableAi: Boolean
@@ -720,6 +799,17 @@ type DocHistoryType {
workspaceId: String!
}
type DocMemberLastAccess {
lastAccessedAt: DateTime!
lastDocId: String
user: PublicUserType!
}
type DocMemberLastAccessEdge {
cursor: String!
node: DocMemberLastAccess!
}
"""Doc mode"""
enum DocMode {
edgeless
@@ -731,6 +821,32 @@ type DocNotFoundDataType {
spaceId: String!
}
type DocPageAnalytics {
generatedAt: DateTime!
series: [DocPageAnalyticsPoint!]!
summary: DocPageAnalyticsSummary!
window: TimeWindow!
}
input DocPageAnalyticsInput {
timezone: String = "UTC"
windowDays: Int = 28
}
type DocPageAnalyticsPoint {
date: DateTime!
guestViews: SafeInt!
totalViews: SafeInt!
uniqueViews: SafeInt!
}
type DocPageAnalyticsSummary {
guestViews: SafeInt!
lastAccessedAt: DateTime
totalViews: SafeInt!
uniqueViews: SafeInt!
}
type DocPermissions {
Doc_Comments_Create: Boolean!
Doc_Comments_Delete: Boolean!
@@ -763,6 +879,8 @@ enum DocRole {
}
type DocType {
"""Doc page analytics in a time window"""
analytics(input: DocPageAnalyticsInput): DocPageAnalytics!
createdAt: DateTime
"""Doc create user"""
@@ -774,6 +892,9 @@ type DocType {
grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType!
id: String!
"""Paginated last accessed members of the current doc"""
lastAccessedMembers(includeTotal: Boolean = false, pagination: PaginationInput!, query: String): PaginatedDocMemberLastAccess!
"""Doc last updated user"""
lastUpdatedBy: PublicUserType
lastUpdaterId: String
@@ -1677,6 +1798,13 @@ type PageInfo {
startCursor: String
}
type PaginatedAdminAllSharedLink {
analyticsWindow: TimeWindow!
edges: [AdminAllSharedLinkEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type PaginatedCommentChangeObjectType {
edges: [CommentChangeObjectTypeEdge!]!
pageInfo: PageInfo!
@@ -1701,6 +1829,12 @@ type PaginatedCopilotWorkspaceFileType {
totalCount: Int!
}
type PaginatedDocMemberLastAccess {
edges: [DocMemberLastAccessEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type PaginatedDocType {
edges: [DocTypeEdge!]!
pageInfo: PageInfo!
@@ -1762,6 +1896,12 @@ type PublicUserType {
}
type Query {
"""List all shared links across workspaces for admin panel"""
adminAllSharedLinks(filter: AdminAllSharedLinksFilterInput, pagination: PaginationInput!): PaginatedAdminAllSharedLink!
"""Get aggregated dashboard metrics for admin panel"""
adminDashboard(input: AdminDashboardInput): AdminDashboard!
"""Get workspace detail for admin"""
adminWorkspace(id: String!): AdminWorkspace
@@ -2207,6 +2347,20 @@ enum SubscriptionVariant {
Onetime
}
enum TimeBucket {
Day
Minute
}
type TimeWindow {
bucket: TimeBucket!
effectiveSize: Int!
from: DateTime!
requestedSize: Int!
timezone: String!
to: DateTime!
}
type TranscriptionItemType {
end: String!
speaker: String!

View File

@@ -0,0 +1,39 @@
query adminAllSharedLinks(
$pagination: PaginationInput!
$filter: AdminAllSharedLinksFilterInput
) {
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
totalCount
analyticsWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
workspaceId
docId
title
publishedAt
docUpdatedAt
workspaceOwnerId
lastUpdaterId
shareUrl
views
uniqueViews
guestViews
lastAccessedAt
}
}
}
}

View File

@@ -0,0 +1,56 @@
query adminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncActiveUsers
syncActiveUsersTimeline {
minute
activeUsers
}
syncWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
copilotConversations
workspaceStorageBytes
blobStorageBytes
workspaceStorageHistory {
date
value
}
blobStorageHistory {
date
value
}
storageWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
topSharedLinks {
workspaceId
docId
title
shareUrl
publishedAt
views
uniqueViews
guestViews
lastAccessedAt
}
topSharedLinksWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
generatedAt
}
}

View File

@@ -0,0 +1,37 @@
query getDocLastAccessedMembers(
$workspaceId: String!
$docId: String!
$pagination: PaginationInput!
$query: String
$includeTotal: Boolean
) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(
pagination: $pagination
query: $query
includeTotal: $includeTotal
) {
totalCount
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
user {
id
name
avatarUrl
}
lastAccessedAt
lastDocId
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
query getDocPageAnalytics(
$workspaceId: String!
$docId: String!
$input: DocPageAnalyticsInput
) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
analytics(input: $input) {
window {
from
to
timezone
bucket
requestedSize
effectiveSize
}
series {
date
totalViews
uniqueViews
guestViews
}
summary {
totalViews
uniqueViews
guestViews
lastAccessedAt
}
generatedAt
}
}
}
}

View File

@@ -144,6 +144,108 @@ export const revokeUserAccessTokenMutation = {
}`,
};
export const adminAllSharedLinksQuery = {
id: 'adminAllSharedLinksQuery' as const,
op: 'adminAllSharedLinks',
query: `query adminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
totalCount
analyticsWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
workspaceId
docId
title
publishedAt
docUpdatedAt
workspaceOwnerId
lastUpdaterId
shareUrl
views
uniqueViews
guestViews
lastAccessedAt
}
}
}
}`,
};
export const adminDashboardQuery = {
id: 'adminDashboardQuery' as const,
op: 'adminDashboard',
query: `query adminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncActiveUsers
syncActiveUsersTimeline {
minute
activeUsers
}
syncWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
copilotConversations
workspaceStorageBytes
blobStorageBytes
workspaceStorageHistory {
date
value
}
blobStorageHistory {
date
value
}
storageWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
topSharedLinks {
workspaceId
docId
title
shareUrl
publishedAt
views
uniqueViews
guestViews
lastAccessedAt
}
topSharedLinksWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
generatedAt
}
}`,
};
export const adminServerConfigQuery = {
id: 'adminServerConfigQuery' as const,
op: 'adminServerConfig',
@@ -1877,6 +1979,76 @@ export const getDocDefaultRoleQuery = {
}`,
};
export const getDocLastAccessedMembersQuery = {
id: 'getDocLastAccessedMembersQuery' as const,
op: 'getDocLastAccessedMembers',
query: `query getDocLastAccessedMembers($workspaceId: String!, $docId: String!, $pagination: PaginationInput!, $query: String, $includeTotal: Boolean) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(
pagination: $pagination
query: $query
includeTotal: $includeTotal
) {
totalCount
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
edges {
cursor
node {
user {
id
name
avatarUrl
}
lastAccessedAt
lastDocId
}
}
}
}
}
}`,
};
export const getDocPageAnalyticsQuery = {
id: 'getDocPageAnalyticsQuery' as const,
op: 'getDocPageAnalytics',
query: `query getDocPageAnalytics($workspaceId: String!, $docId: String!, $input: DocPageAnalyticsInput) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
analytics(input: $input) {
window {
from
to
timezone
bucket
requestedSize
effectiveSize
}
series {
date
totalViews
uniqueViews
guestViews
}
summary {
totalViews
uniqueViews
guestViews
lastAccessedAt
}
generatedAt
}
}
}
}`,
};
export const getDocSummaryQuery = {
id: 'getDocSummaryQuery' as const,
op: 'getDocSummary',

View File

@@ -66,6 +66,91 @@ export interface AddContextFileInput {
contextId: Scalars['String']['input'];
}
export interface AdminAllSharedLink {
__typename?: 'AdminAllSharedLink';
docId: Scalars['String']['output'];
docUpdatedAt: Maybe<Scalars['DateTime']['output']>;
guestViews: Maybe<Scalars['SafeInt']['output']>;
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
lastUpdaterId: Maybe<Scalars['String']['output']>;
publishedAt: Maybe<Scalars['DateTime']['output']>;
shareUrl: Scalars['String']['output'];
title: Maybe<Scalars['String']['output']>;
uniqueViews: Maybe<Scalars['SafeInt']['output']>;
views: Maybe<Scalars['SafeInt']['output']>;
workspaceId: Scalars['String']['output'];
workspaceOwnerId: Maybe<Scalars['String']['output']>;
}
export interface AdminAllSharedLinkEdge {
__typename?: 'AdminAllSharedLinkEdge';
cursor: Scalars['String']['output'];
node: AdminAllSharedLink;
}
export interface AdminAllSharedLinksFilterInput {
analyticsWindowDays?: InputMaybe<Scalars['Int']['input']>;
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
keyword?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AdminSharedLinksOrder>;
updatedAfter?: InputMaybe<Scalars['DateTime']['input']>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
}
export interface AdminDashboard {
__typename?: 'AdminDashboard';
blobStorageBytes: Scalars['SafeInt']['output'];
blobStorageHistory: Array<AdminDashboardValueDayPoint>;
copilotConversations: Scalars['SafeInt']['output'];
generatedAt: Scalars['DateTime']['output'];
storageWindow: TimeWindow;
syncActiveUsers: Scalars['Int']['output'];
syncActiveUsersTimeline: Array<AdminDashboardMinutePoint>;
syncWindow: TimeWindow;
topSharedLinks: Array<AdminSharedLinkTopItem>;
topSharedLinksWindow: TimeWindow;
workspaceStorageBytes: Scalars['SafeInt']['output'];
workspaceStorageHistory: Array<AdminDashboardValueDayPoint>;
}
export interface AdminDashboardInput {
sharedLinkWindowDays?: InputMaybe<Scalars['Int']['input']>;
storageHistoryDays?: InputMaybe<Scalars['Int']['input']>;
syncHistoryHours?: InputMaybe<Scalars['Int']['input']>;
timezone?: InputMaybe<Scalars['String']['input']>;
}
export interface AdminDashboardMinutePoint {
__typename?: 'AdminDashboardMinutePoint';
activeUsers: Scalars['Int']['output'];
minute: Scalars['DateTime']['output'];
}
export interface AdminDashboardValueDayPoint {
__typename?: 'AdminDashboardValueDayPoint';
date: Scalars['DateTime']['output'];
value: Scalars['SafeInt']['output'];
}
export interface AdminSharedLinkTopItem {
__typename?: 'AdminSharedLinkTopItem';
docId: Scalars['String']['output'];
guestViews: Scalars['SafeInt']['output'];
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
publishedAt: Maybe<Scalars['DateTime']['output']>;
shareUrl: Scalars['String']['output'];
title: Maybe<Scalars['String']['output']>;
uniqueViews: Scalars['SafeInt']['output'];
views: Scalars['SafeInt']['output'];
workspaceId: Scalars['String']['output'];
}
export enum AdminSharedLinksOrder {
PublishedAtDesc = 'PublishedAtDesc',
UpdatedAtDesc = 'UpdatedAtDesc',
ViewsDesc = 'ViewsDesc',
}
export interface AdminUpdateWorkspaceInput {
avatarKey?: InputMaybe<Scalars['String']['input']>;
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
@@ -851,6 +936,19 @@ export interface DocHistoryType {
workspaceId: Scalars['String']['output'];
}
export interface DocMemberLastAccess {
__typename?: 'DocMemberLastAccess';
lastAccessedAt: Scalars['DateTime']['output'];
lastDocId: Maybe<Scalars['String']['output']>;
user: PublicUserType;
}
export interface DocMemberLastAccessEdge {
__typename?: 'DocMemberLastAccessEdge';
cursor: Scalars['String']['output'];
node: DocMemberLastAccess;
}
/** Doc mode */
export enum DocMode {
edgeless = 'edgeless',
@@ -863,6 +961,35 @@ export interface DocNotFoundDataType {
spaceId: Scalars['String']['output'];
}
export interface DocPageAnalytics {
__typename?: 'DocPageAnalytics';
generatedAt: Scalars['DateTime']['output'];
series: Array<DocPageAnalyticsPoint>;
summary: DocPageAnalyticsSummary;
window: TimeWindow;
}
export interface DocPageAnalyticsInput {
timezone?: InputMaybe<Scalars['String']['input']>;
windowDays?: InputMaybe<Scalars['Int']['input']>;
}
export interface DocPageAnalyticsPoint {
__typename?: 'DocPageAnalyticsPoint';
date: Scalars['DateTime']['output'];
guestViews: Scalars['SafeInt']['output'];
totalViews: Scalars['SafeInt']['output'];
uniqueViews: Scalars['SafeInt']['output'];
}
export interface DocPageAnalyticsSummary {
__typename?: 'DocPageAnalyticsSummary';
guestViews: Scalars['SafeInt']['output'];
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
totalViews: Scalars['SafeInt']['output'];
uniqueViews: Scalars['SafeInt']['output'];
}
export interface DocPermissions {
__typename?: 'DocPermissions';
Doc_Comments_Create: Scalars['Boolean']['output'];
@@ -897,6 +1024,8 @@ export enum DocRole {
export interface DocType {
__typename?: 'DocType';
/** Doc page analytics in a time window */
analytics: DocPageAnalytics;
createdAt: Maybe<Scalars['DateTime']['output']>;
/** Doc create user */
createdBy: Maybe<PublicUserType>;
@@ -905,6 +1034,8 @@ export interface DocType {
/** paginated doc granted users list */
grantedUsersList: PaginatedGrantedDocUserType;
id: Scalars['String']['output'];
/** Paginated last accessed members of the current doc */
lastAccessedMembers: PaginatedDocMemberLastAccess;
/** Doc last updated user */
lastUpdatedBy: Maybe<PublicUserType>;
lastUpdaterId: Maybe<Scalars['String']['output']>;
@@ -919,10 +1050,20 @@ export interface DocType {
workspaceId: Scalars['String']['output'];
}
export interface DocTypeAnalyticsArgs {
input?: InputMaybe<DocPageAnalyticsInput>;
}
export interface DocTypeGrantedUsersListArgs {
pagination: PaginationInput;
}
export interface DocTypeLastAccessedMembersArgs {
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
pagination: PaginationInput;
query?: InputMaybe<Scalars['String']['input']>;
}
export interface DocTypeEdge {
__typename?: 'DocTypeEdge';
cursor: Scalars['String']['output'];
@@ -2282,6 +2423,14 @@ export interface PageInfo {
startCursor: Maybe<Scalars['String']['output']>;
}
export interface PaginatedAdminAllSharedLink {
__typename?: 'PaginatedAdminAllSharedLink';
analyticsWindow: TimeWindow;
edges: Array<AdminAllSharedLinkEdge>;
pageInfo: PageInfo;
totalCount: Maybe<Scalars['Int']['output']>;
}
export interface PaginatedCommentChangeObjectType {
__typename?: 'PaginatedCommentChangeObjectType';
edges: Array<CommentChangeObjectTypeEdge>;
@@ -2310,6 +2459,13 @@ export interface PaginatedCopilotWorkspaceFileType {
totalCount: Scalars['Int']['output'];
}
export interface PaginatedDocMemberLastAccess {
__typename?: 'PaginatedDocMemberLastAccess';
edges: Array<DocMemberLastAccessEdge>;
pageInfo: PageInfo;
totalCount: Maybe<Scalars['Int']['output']>;
}
export interface PaginatedDocType {
__typename?: 'PaginatedDocType';
edges: Array<DocTypeEdge>;
@@ -2376,6 +2532,10 @@ export interface PublicUserType {
export interface Query {
__typename?: 'Query';
/** List all shared links across workspaces for admin panel */
adminAllSharedLinks: PaginatedAdminAllSharedLink;
/** Get aggregated dashboard metrics for admin panel */
adminDashboard: AdminDashboard;
/** Get workspace detail for admin */
adminWorkspace: Maybe<AdminWorkspace>;
/** List workspaces for admin */
@@ -2428,6 +2588,15 @@ export interface Query {
workspaces: Array<WorkspaceType>;
}
export interface QueryAdminAllSharedLinksArgs {
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
pagination: PaginationInput;
}
export interface QueryAdminDashboardArgs {
input?: InputMaybe<AdminDashboardInput>;
}
export interface QueryAdminWorkspaceArgs {
id: Scalars['String']['input'];
}
@@ -2871,6 +3040,21 @@ export enum SubscriptionVariant {
Onetime = 'Onetime',
}
export enum TimeBucket {
Day = 'Day',
Minute = 'Minute',
}
export interface TimeWindow {
__typename?: 'TimeWindow';
bucket: TimeBucket;
effectiveSize: Scalars['Int']['output'];
from: Scalars['DateTime']['output'];
requestedSize: Scalars['Int']['output'];
timezone: Scalars['String']['output'];
to: Scalars['DateTime']['output'];
}
export interface TranscriptionItemType {
__typename?: 'TranscriptionItemType';
end: Scalars['String']['output'];
@@ -3409,6 +3593,124 @@ export type RevokeUserAccessTokenMutation = {
revokeUserAccessToken: boolean;
};
export type AdminAllSharedLinksQueryVariables = Exact<{
pagination: PaginationInput;
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
}>;
export type AdminAllSharedLinksQuery = {
__typename?: 'Query';
adminAllSharedLinks: {
__typename?: 'PaginatedAdminAllSharedLink';
totalCount: number | null;
analyticsWindow: {
__typename?: 'TimeWindow';
from: string;
to: string;
timezone: string;
bucket: TimeBucket;
requestedSize: number;
effectiveSize: number;
};
pageInfo: {
__typename?: 'PageInfo';
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
edges: Array<{
__typename?: 'AdminAllSharedLinkEdge';
cursor: string;
node: {
__typename?: 'AdminAllSharedLink';
workspaceId: string;
docId: string;
title: string | null;
publishedAt: string | null;
docUpdatedAt: string | null;
workspaceOwnerId: string | null;
lastUpdaterId: string | null;
shareUrl: string;
views: number | null;
uniqueViews: number | null;
guestViews: number | null;
lastAccessedAt: string | null;
};
}>;
};
};
export type AdminDashboardQueryVariables = Exact<{
input?: InputMaybe<AdminDashboardInput>;
}>;
export type AdminDashboardQuery = {
__typename?: 'Query';
adminDashboard: {
__typename?: 'AdminDashboard';
syncActiveUsers: number;
copilotConversations: number;
workspaceStorageBytes: number;
blobStorageBytes: number;
generatedAt: string;
syncActiveUsersTimeline: Array<{
__typename?: 'AdminDashboardMinutePoint';
minute: string;
activeUsers: number;
}>;
syncWindow: {
__typename?: 'TimeWindow';
from: string;
to: string;
timezone: string;
bucket: TimeBucket;
requestedSize: number;
effectiveSize: number;
};
workspaceStorageHistory: Array<{
__typename?: 'AdminDashboardValueDayPoint';
date: string;
value: number;
}>;
blobStorageHistory: Array<{
__typename?: 'AdminDashboardValueDayPoint';
date: string;
value: number;
}>;
storageWindow: {
__typename?: 'TimeWindow';
from: string;
to: string;
timezone: string;
bucket: TimeBucket;
requestedSize: number;
effectiveSize: number;
};
topSharedLinks: Array<{
__typename?: 'AdminSharedLinkTopItem';
workspaceId: string;
docId: string;
title: string | null;
shareUrl: string;
publishedAt: string | null;
views: number;
uniqueViews: number;
guestViews: number;
lastAccessedAt: string | null;
}>;
topSharedLinksWindow: {
__typename?: 'TimeWindow';
from: string;
to: string;
timezone: string;
bucket: TimeBucket;
requestedSize: number;
effectiveSize: number;
};
};
};
export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>;
export type AdminServerConfigQuery = {
@@ -5916,6 +6218,93 @@ export type GetDocDefaultRoleQuery = {
};
};
export type GetDocLastAccessedMembersQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input'];
pagination: PaginationInput;
query?: InputMaybe<Scalars['String']['input']>;
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
}>;
export type GetDocLastAccessedMembersQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
doc: {
__typename?: 'DocType';
lastAccessedMembers: {
__typename?: 'PaginatedDocMemberLastAccess';
totalCount: number | null;
pageInfo: {
__typename?: 'PageInfo';
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
edges: Array<{
__typename?: 'DocMemberLastAccessEdge';
cursor: string;
node: {
__typename?: 'DocMemberLastAccess';
lastAccessedAt: string;
lastDocId: string | null;
user: {
__typename?: 'PublicUserType';
id: string;
name: string;
avatarUrl: string | null;
};
};
}>;
};
};
};
};
export type GetDocPageAnalyticsQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input'];
input?: InputMaybe<DocPageAnalyticsInput>;
}>;
export type GetDocPageAnalyticsQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
doc: {
__typename?: 'DocType';
analytics: {
__typename?: 'DocPageAnalytics';
generatedAt: string;
window: {
__typename?: 'TimeWindow';
from: string;
to: string;
timezone: string;
bucket: TimeBucket;
requestedSize: number;
effectiveSize: number;
};
series: Array<{
__typename?: 'DocPageAnalyticsPoint';
date: string;
totalViews: number;
uniqueViews: number;
guestViews: number;
}>;
summary: {
__typename?: 'DocPageAnalyticsSummary';
totalViews: number;
uniqueViews: number;
guestViews: number;
lastAccessedAt: string | null;
};
};
};
};
};
export type GetDocSummaryQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input'];
@@ -7199,6 +7588,16 @@ export type Queries =
variables: ListUserAccessTokensQueryVariables;
response: ListUserAccessTokensQuery;
}
| {
name: 'adminAllSharedLinksQuery';
variables: AdminAllSharedLinksQueryVariables;
response: AdminAllSharedLinksQuery;
}
| {
name: 'adminDashboardQuery';
variables: AdminDashboardQueryVariables;
response: AdminDashboardQuery;
}
| {
name: 'adminServerConfigQuery';
variables: AdminServerConfigQueryVariables;
@@ -7419,6 +7818,16 @@ export type Queries =
variables: GetDocDefaultRoleQueryVariables;
response: GetDocDefaultRoleQuery;
}
| {
name: 'getDocLastAccessedMembersQuery';
variables: GetDocLastAccessedMembersQueryVariables;
response: GetDocLastAccessedMembersQuery;
}
| {
name: 'getDocPageAnalyticsQuery';
variables: GetDocPageAnalyticsQueryVariables;
response: GetDocPageAnalyticsQuery;
}
| {
name: 'getDocSummaryQuery';
variables: GetDocSummaryQueryVariables;

View File

@@ -53,6 +53,7 @@
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.12.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"swr": "^2.3.7",
"vaul": "^1.1.2",

View File

@@ -23,6 +23,9 @@ export const Setup = lazy(
export const Accounts = lazy(
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
);
export const Dashboard = lazy(
() => import(/* webpackChunkName: "dashboard" */ './modules/dashboard')
);
export const Workspaces = lazy(
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
);
@@ -75,7 +78,15 @@ function RootRoutes() {
}
if (/^\/admin\/?$/.test(location.pathname)) {
return <Navigate to="/admin/accounts" />;
return (
<Navigate
to={
environment.isSelfHosted
? ROUTES.admin.accounts
: ROUTES.admin.dashboard
}
/>
);
}
return <Outlet />;
@@ -96,6 +107,16 @@ export const App = () => {
<Route path={ROUTES.admin.auth} element={<Auth />} />
<Route path={ROUTES.admin.setup} element={<Setup />} />
<Route element={<AuthenticatedRoutes />}>
<Route
path={ROUTES.admin.dashboard}
element={
environment.isSelfHosted ? (
<Navigate to={ROUTES.admin.accounts} replace />
) : (
<Dashboard />
)
}
/>
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
<Route
path={ROUTES.admin.workspaces}

View File

@@ -0,0 +1,173 @@
import { cn } from '@affine/admin/utils';
import * as React from 'react';
import type { TooltipProps } from 'recharts';
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
color?: string;
theme?: Partial<Record<keyof typeof THEMES, string>>;
}
>;
type ChartContextValue = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextValue | null>(null);
function useChart() {
const value = React.useContext(ChartContext);
if (!value) {
throw new Error('useChart must be used within <ChartContainer />');
}
return value;
}
function ChartStyle({
chartId,
config,
}: {
chartId: string;
config: ChartConfig;
}) {
const colorEntries = Object.entries(config).filter(
([, item]) => item.color || item.theme
);
if (!colorEntries.length) {
return null;
}
const css = Object.entries(THEMES)
.map(([themeKey, prefix]) => {
const declarations = colorEntries
.map(([key, item]) => {
const color =
item.theme?.[themeKey as keyof typeof THEMES] ?? item.color;
return color ? ` --color-${key}: ${color};` : '';
})
.filter(Boolean)
.join('\n');
if (!declarations) {
return '';
}
return `${prefix} [data-chart="${chartId}"] {\n${declarations}\n}`;
})
.filter(Boolean)
.join('\n');
if (!css) {
return null;
}
return <style dangerouslySetInnerHTML={{ __html: css }} />;
}
type ChartContainerProps = React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
};
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
ref={ref}
data-chart={chartId}
className={cn(
'flex min-h-0 w-full items-center justify-center text-xs',
className
)}
{...props}
>
<ChartStyle chartId={chartId} config={config} />
<ResponsiveContainer>{children}</ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
);
ChartContainer.displayName = 'ChartContainer';
const ChartTooltip = RechartsTooltip;
type TooltipContentProps = {
active?: boolean;
payload?: TooltipProps<number, string>['payload'];
label?: string | number;
labelFormatter?: (
label: string | number,
payload: TooltipProps<number, string>['payload']
) => React.ReactNode;
valueFormatter?: (value: number, key: string) => React.ReactNode;
};
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
const { config } = useChart();
if (!active || !payload?.length) {
return null;
}
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
return (
<div
ref={ref}
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
>
{title ? (
<div className="mb-2 font-medium text-foreground/90">{title}</div>
) : null}
<div className="space-y-1">
{payload.map((item, index) => {
const dataKey = String(item.dataKey ?? item.name ?? index);
const itemConfig = config[dataKey];
const labelText = itemConfig?.label ?? item.name ?? dataKey;
const numericValue =
typeof item.value === 'number'
? item.value
: Number(item.value ?? 0);
const valueText = valueFormatter
? valueFormatter(numericValue, dataKey)
: numericValue;
const color = item.color ?? `var(--color-${dataKey})`;
return (
<div
key={`${dataKey}-${index}`}
className="flex items-center gap-2"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<span className="text-muted-foreground">{labelText}</span>
<span className="ml-auto font-medium tabular-nums">
{valueText}
</span>
</div>
);
})}
</div>
</div>
);
});
ChartTooltipContent.displayName = 'ChartTooltipContent';
export { ChartContainer, ChartTooltip, ChartTooltipContent };

View File

@@ -0,0 +1,645 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@affine/admin/components/ui/card';
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@affine/admin/components/ui/chart';
import { Label } from '@affine/admin/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@affine/admin/components/ui/select';
import { Separator } from '@affine/admin/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import { useQuery } from '@affine/admin/use-query';
import { adminDashboardQuery } from '@affine/graphql';
import { ROUTES } from '@affine/routes';
import {
DatabaseIcon,
MessageSquareTextIcon,
RefreshCwIcon,
UsersIcon,
} from 'lucide-react';
import { type ReactNode, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import { Header } from '../header';
import { formatBytes } from '../workspaces/utils';
const intFormatter = new Intl.NumberFormat('en-US');
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const utcDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
});
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] as const;
const SHARED_DAY_OPTIONS = [7, 14, 28, 60, 90] as const;
type DualNumberPoint = {
label: string;
primary: number;
secondary: number;
};
type TrendPoint = {
x: number;
label: string;
primary: number;
secondary?: number;
};
function formatDateTime(value: string) {
return utcDateTimeFormatter.format(new Date(value));
}
function formatDate(value: string) {
return utcDateFormatter.format(new Date(value));
}
function downsample<T>(items: T[], maxPoints: number) {
if (items.length <= maxPoints) {
return items;
}
const step = Math.ceil(items.length / maxPoints);
return items.filter(
(_, index) => index % step === 0 || index === items.length - 1
);
}
function toIndexedTrendPoints<T extends Omit<TrendPoint, 'x'>>(points: T[]) {
return points.map((point, index) => ({
...point,
x: index,
}));
}
function TrendChart({
ariaLabel,
points,
primaryLabel,
primaryFormatter,
secondaryLabel,
secondaryFormatter,
}: {
ariaLabel: string;
points: TrendPoint[];
primaryLabel: string;
primaryFormatter: (value: number) => string;
secondaryLabel?: string;
secondaryFormatter?: (value: number) => string;
}) {
if (points.length === 0) {
return <div className="text-sm text-muted-foreground">No data</div>;
}
const chartPoints =
points.length === 1
? [points[0], { ...points[0], x: points[0].x + 1 }]
: points;
const hasSecondary =
Boolean(secondaryLabel) &&
chartPoints.some(point => typeof point.secondary === 'number');
const config: ChartConfig = {
primary: {
label: primaryLabel,
color: 'hsl(var(--primary))',
},
...(hasSecondary
? {
secondary: {
label: secondaryLabel,
color: 'hsl(var(--foreground) / 0.6)',
},
}
: {}),
};
return (
<div className="space-y-3">
<ChartContainer
config={config}
className="h-44 w-full"
aria-label={ariaLabel}
role="img"
>
<LineChart
data={chartPoints}
margin={{ top: 8, right: 0, bottom: 0, left: 0 }}
>
<CartesianGrid
vertical={false}
stroke="hsl(var(--border) / 0.6)"
strokeDasharray="3 4"
/>
<XAxis
dataKey="x"
type="number"
hide
allowDecimals={false}
domain={['dataMin', 'dataMax']}
/>
<YAxis
hide
domain={[
0,
(max: number) => {
if (max <= 0) {
return 1;
}
return Math.ceil(max * 1.1);
},
]}
/>
<ChartTooltip
cursor={{
stroke: 'hsl(var(--border))',
strokeDasharray: '4 4',
strokeWidth: 1,
}}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const item = payload?.[0];
return item?.payload?.label ?? '';
}}
valueFormatter={(value, key) => {
if (key === 'secondary') {
return secondaryFormatter
? secondaryFormatter(value)
: intFormatter.format(value);
}
return primaryFormatter(value);
}}
/>
}
/>
<Area
dataKey="primary"
type="monotone"
fill="var(--color-primary)"
fillOpacity={0.16}
stroke="none"
isAnimationActive={false}
/>
<Line
dataKey="primary"
type="monotone"
stroke="var(--color-primary)"
strokeWidth={3}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
{hasSecondary ? (
<Line
dataKey="secondary"
type="monotone"
stroke="var(--color-secondary)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
strokeDasharray="6 4"
connectNulls
isAnimationActive={false}
/>
) : null}
</LineChart>
</ChartContainer>
<div className="flex justify-between text-[11px] text-muted-foreground tabular-nums">
<span>{points[0]?.label}</span>
<span>{points[points.length - 1]?.label}</span>
</div>
</div>
);
}
function PrimaryMetricCard({
value,
description,
}: {
value: string;
description: string;
}) {
return (
<Card className="lg:col-span-5 border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2 text-foreground/75">
<UsersIcon className="h-4 w-4" aria-hidden="true" />
Current Sync Active Users
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<div className="text-4xl font-bold tracking-tight tabular-nums">
{value}
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function SecondaryMetricCard({
title,
value,
description,
icon,
}: {
title: string;
value: string;
description: string;
icon: ReactNode;
}) {
return (
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<span aria-hidden="true">{icon}</span>
{title}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{value}
</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</CardContent>
</Card>
);
}
function WindowSelect({
id,
label,
value,
options,
unit,
onChange,
}: {
id: string;
label: string;
value: number;
options: readonly number[];
unit: string;
onChange: (value: number) => void;
}) {
return (
<div className="flex flex-col gap-2 min-w-40">
<Label
htmlFor={id}
className="text-xs uppercase tracking-wide text-muted-foreground"
>
{label}
</Label>
<Select
value={String(value)}
onValueChange={next => onChange(Number(next))}
>
<SelectTrigger id={id}>
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{options.map(option => (
<SelectItem key={option} value={String(option)}>
{option} {unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function DashboardPage() {
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
const variables = useMemo(
() => ({
input: {
storageHistoryDays,
syncHistoryHours,
sharedLinkWindowDays,
timezone: 'UTC',
},
}),
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
);
const { data, isValidating, mutate } = useQuery(
{
query: adminDashboardQuery,
variables,
},
{
keepPreviousData: true,
revalidateOnFocus: false,
revalidateIfStale: true,
revalidateOnReconnect: true,
}
);
const dashboard = data.adminDashboard;
const syncPoints = useMemo(
() =>
toIndexedTrendPoints(
downsample(
dashboard.syncActiveUsersTimeline.map(point => ({
label: formatDateTime(point.minute),
primary: point.activeUsers,
})),
96
)
),
[dashboard.syncActiveUsersTimeline]
);
const storagePoints = useMemo(() => {
const merged: DualNumberPoint[] = dashboard.workspaceStorageHistory.map(
(point, index) => ({
label: formatDate(point.date),
primary: point.value,
secondary: dashboard.blobStorageHistory[index]?.value ?? 0,
})
);
return toIndexedTrendPoints(downsample(merged, 60));
}, [dashboard.blobStorageHistory, dashboard.workspaceStorageHistory]);
const totalStorageBytes =
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
return (
<div className="h-screen flex-1 flex-col flex overflow-hidden">
<Header
title="Dashboard"
endFix={
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="text-xs text-muted-foreground tabular-nums">
Updated at {formatDateTime(dashboard.generatedAt)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
mutate().catch(() => {});
}}
disabled={isValidating}
>
<RefreshCwIcon
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
</div>
}
/>
<div className="flex-1 overflow-auto p-6 space-y-6">
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 via-card to-card shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base">Window Controls</CardTitle>
<CardDescription>
Tune dashboard windows. Data is sampled in UTC and refreshes
automatically.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 grid-cols-1 md:grid-cols-3 items-end">
<WindowSelect
id="storage-history-window"
label="Storage History"
value={storageHistoryDays}
options={STORAGE_DAY_OPTIONS}
unit="days"
onChange={setStorageHistoryDays}
/>
<WindowSelect
id="sync-history-window"
label="Sync History"
value={syncHistoryHours}
options={SYNC_HOUR_OPTIONS}
unit="hours"
onChange={setSyncHistoryHours}
/>
<WindowSelect
id="shared-link-window"
label="Shared Link Window"
value={sharedLinkWindowDays}
options={SHARED_DAY_OPTIONS}
unit="days"
onChange={setSharedLinkWindowDays}
/>
</CardContent>
</Card>
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
<PrimaryMetricCard
value={intFormatter.format(dashboard.syncActiveUsers)}
description={`${dashboard.syncWindow.effectiveSize}h active window`}
/>
<SecondaryMetricCard
title="Copilot Conversations"
value={intFormatter.format(dashboard.copilotConversations)}
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
icon={
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
}
/>
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
Managed Storage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{formatBytes(totalStorageBytes)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Workspace {formatBytes(dashboard.workspaceStorageBytes)} Blob{' '}
{formatBytes(dashboard.blobStorageBytes)}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
<Card className="xl:col-span-1 border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Sync Active Users Trend
</CardTitle>
<CardDescription>
{dashboard.syncWindow.effectiveSize}h at minute bucket
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<TrendChart
ariaLabel="Sync active users trend"
points={syncPoints}
primaryLabel="Sync Active Users"
primaryFormatter={value => intFormatter.format(value)}
/>
</CardContent>
</Card>
<Card className="xl:col-span-2 border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Storage Trend (Workspace + Blob)
</CardTitle>
<CardDescription>
{dashboard.storageWindow.effectiveSize}d at day bucket
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<TrendChart
ariaLabel="Workspace and blob storage trend"
points={storagePoints}
primaryLabel="Workspace Storage"
primaryFormatter={value => formatBytes(value)}
secondaryLabel="Blob Storage"
secondaryFormatter={value => formatBytes(value)}
/>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
Workspace: {formatBytes(dashboard.workspaceStorageBytes)}
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-foreground/50" />
Blob: {formatBytes(dashboard.blobStorageBytes)}
</div>
</div>
</CardContent>
</Card>
</div>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Top Shared Links</CardTitle>
<CardDescription>
Top {dashboard.topSharedLinks.length} links in the last{' '}
{dashboard.topSharedLinksWindow.effectiveSize} days
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dashboard.topSharedLinks.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
<div className="text-sm font-medium">
No shared links in this window
</div>
<div className="text-xs text-muted-foreground mt-2">
Publish pages and collect traffic, then this table will rank
links by views.
</div>
<Button asChild variant="outline" size="sm" className="mt-4">
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Document</TableHead>
<TableHead>Workspace</TableHead>
<TableHead className="text-right">Views</TableHead>
<TableHead className="text-right">Unique</TableHead>
<TableHead className="text-right">Guest</TableHead>
<TableHead>Last Accessed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboard.topSharedLinks.map(link => (
<TableRow
key={`${link.workspaceId}-${link.docId}`}
className="hover:bg-muted/40"
>
<TableCell className="max-w-80 min-w-0">
<a
href={link.shareUrl}
target="_blank"
rel="noreferrer"
className="font-medium underline-offset-4 hover:underline truncate block"
>
{link.title || link.docId}
</a>
</TableCell>
<TableCell className="font-mono text-xs tabular-nums">
{link.workspaceId}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.views)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.uniqueViews)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.guestViews)}
</TableCell>
<TableCell className="tabular-nums">
{link.lastAccessedAt
? formatDateTime(link.lastAccessedAt)
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Separator />
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{formatDate(dashboard.topSharedLinksWindow.from)}</span>
<span>{formatDate(dashboard.topSharedLinksWindow.to)}</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export { DashboardPage as Component };

View File

@@ -1,8 +1,13 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { ROUTES } from '@affine/routes';
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { LayoutDashboardIcon, ListChecksIcon } from 'lucide-react';
import {
BarChart3Icon,
LayoutDashboardIcon,
ListChecksIcon,
} from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { ServerVersion } from './server-version';
@@ -85,22 +90,30 @@ export function Nav({ isCollapsed = false }: NavProps) {
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
)}
>
{environment.isSelfHosted ? null : (
<NavItem
to={ROUTES.admin.dashboard}
icon={<BarChart3Icon size={18} />}
label="Dashboard"
isCollapsed={isCollapsed}
/>
)}
<NavItem
to="/admin/accounts"
to={ROUTES.admin.accounts}
icon={<AccountIcon fontSize={20} />}
label="Accounts"
isCollapsed={isCollapsed}
/>
{environment.isSelfHosted ? null : (
<NavItem
to="/admin/workspaces"
to={ROUTES.admin.workspaces}
icon={<LayoutDashboardIcon size={18} />}
label="Workspaces"
isCollapsed={isCollapsed}
/>
)}
<NavItem
to="/admin/queue"
to={ROUTES.admin.queue}
icon={<ListChecksIcon size={18} />}
label="Queue"
isCollapsed={isCollapsed}
@@ -113,7 +126,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
/> */}
<SettingsItem isCollapsed={isCollapsed} />
<NavItem
to="/admin/about"
to={ROUTES.admin.about}
icon={<SelfhostIcon fontSize={20} />}
label="About"
isCollapsed={isCollapsed}

View File

@@ -54,7 +54,7 @@
"@toeverything/infra": "workspace:*",
"@types/set-cookie-parser": "^2.4.10",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"@vitejs/plugin-react-swc": "^4.0.0",
"app-builder-lib": "^26.1.0",
"builder-util-runtime": "^9.5.0",
"cross-env": "^10.1.0",

View File

@@ -15,6 +15,7 @@ import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
import type { WorkspaceMeta } from '../type';
import {
getDeletedWorkspacesBasePath,
getSpaceBasePath,
getSpaceDBPath,
getWorkspaceBasePathV1,
getWorkspaceMeta,
@@ -96,6 +97,33 @@ export async function storeWorkspaceMeta(
}
}
export async function listLocalWorkspaceIds(): Promise<string[]> {
const localWorkspaceBasePath = path.join(
await getSpaceBasePath('workspace'),
'local'
);
if (!(await fs.pathExists(localWorkspaceBasePath))) {
return [];
}
const entries = await fs.readdir(localWorkspaceBasePath);
const ids = await Promise.all(
entries.map(async entry => {
const workspacePath = path.join(localWorkspaceBasePath, entry);
const stat = await fs.stat(workspacePath).catch(() => null);
if (!stat?.isDirectory()) {
return null;
}
if (!(await fs.pathExists(path.join(workspacePath, 'storage.db')))) {
return null;
}
return entry;
})
);
return ids.filter((id): id is string => typeof id === 'string');
}
type WorkspaceDocMeta = {
id: string;
name: string;

View File

@@ -3,6 +3,7 @@ import {
deleteBackupWorkspace,
deleteWorkspace,
getDeletedWorkspaces,
listLocalWorkspaceIds,
trashWorkspace,
} from './handlers';
@@ -18,4 +19,5 @@ export const workspaceHandlers = {
return getDeletedWorkspaces();
},
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
};

View File

@@ -33,6 +33,43 @@ afterAll(() => {
});
describe('workspace db management', () => {
test('list local workspace ids', async () => {
const { listLocalWorkspaceIds } =
await import('@affine/electron/helper/workspace/handlers');
const validWorkspaceId = v4();
const noDbWorkspaceId = v4();
const fileEntry = 'README.txt';
const validWorkspacePath = path.join(
appDataPath,
'workspaces',
'local',
validWorkspaceId
);
const noDbWorkspacePath = path.join(
appDataPath,
'workspaces',
'local',
noDbWorkspaceId
);
const nonDirectoryPath = path.join(
appDataPath,
'workspaces',
'local',
fileEntry
);
await fs.ensureDir(validWorkspacePath);
await fs.ensureFile(path.join(validWorkspacePath, 'storage.db'));
await fs.ensureDir(noDbWorkspacePath);
await fs.outputFile(nonDirectoryPath, 'not-a-workspace');
const ids = await listLocalWorkspaceIds();
expect(ids).toContain(validWorkspaceId);
expect(ids).not.toContain(noDbWorkspaceId);
expect(ids).not.toContain(fileEntry);
});
test('trash workspace', async () => {
const { trashWorkspace } =
await import('@affine/electron/helper/workspace/handlers');

View File

@@ -18,6 +18,15 @@
"version" : "0.1.5"
}
},
{
"identity" : "highlightr",
"kind" : "remoteSourceControl",
"location" : "https://github.com/raspu/Highlightr",
"state" : {
"revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
"version" : "2.3.0"
}
},
{
"identity" : "listviewkit",
"kind" : "remoteSourceControl",
@@ -27,13 +36,22 @@
"version" : "1.1.8"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "c7e83f2f580ce34a102ca9ba9d2bb24e507dccd9",
"version" : "0.5.6"
}
},
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/LRUCache",
"state" : {
"revision" : "542f0449556327415409ededc9c43a4bd0a397dc",
"version" : "1.0.7"
"revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e",
"version" : "1.2.1"
}
},
{
@@ -41,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MarkdownView",
"state" : {
"revision" : "20fa808889944921e8da3a1c8317e8a557db373e",
"version" : "3.4.7"
"revision" : "8b8c1eecd251051c5ec2bdd5f31a2243efd9be6c",
"version" : "3.6.2"
}
},
{
@@ -59,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios-spm.git",
"state" : {
"revision" : "6676da5c4c6a61e53b3139216a775d1224bf056e",
"version" : "5.56.1"
"revision" : "8f5df97653eb361a2097119479332afccf0aa816",
"version" : "5.58.0"
}
},
{
@@ -72,15 +90,6 @@
"version" : "5.7.1"
}
},
{
"identity" : "splash",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Splash",
"state" : {
"revision" : "de9cde249fdb7a173a6e6b950ab18b11f6c2a557",
"version" : "0.18.0"
}
},
{
"identity" : "springinterpolation",
"kind" : "remoteSourceControl",
@@ -95,8 +104,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
"version" : "0.6.0"
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
@@ -120,10 +129,10 @@
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/SwiftMath",
"location" : "https://github.com/mgriebling/SwiftMath",
"state" : {
"revision" : "cfd646dcac0c5553e21ebf1ee05f9078277518bc",
"version" : "1.7.2"
"revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7",
"version" : "1.7.3"
}
}
],

View File

@@ -9,9 +9,36 @@ import Intelligents
import UIKit
extension AFFiNEViewController: IntelligentsButtonDelegate {
private static let aiConsentKey = "com.affine.intelligents.userConsented"
private var hasUserConsented: Bool {
UserDefaults.standard.bool(forKey: Self.aiConsentKey)
}
func onIntelligentsButtonTapped(_: IntelligentsButton) {
// if it shows up then we are ready to go
if hasUserConsented {
presentIntelligentsController()
return
}
showAIConsentAlert()
}
private func presentIntelligentsController() {
let controller = IntelligentsController()
present(controller, animated: true)
}
private func showAIConsentAlert() {
let alert = UIAlertController(
title: "AI Feature Data Usage",
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to a third-party AI service for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with the AI service.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Agree & Continue", style: .default) { [weak self] _ in
UserDefaults.standard.set(true, forKey: Self.aiConsentKey)
self?.presentIntelligentsController()
})
present(alert, animated: true)
}
}

View File

@@ -0,0 +1,170 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public struct CurrentUserProfile: AffineGraphQL.SelectionSet, Fragment {
public static var fragmentDefinition: StaticString {
#"fragment CurrentUserProfile on UserType { __typename id name email avatarUrl emailVerified features settings { __typename receiveInvitationEmail receiveMentionEmail receiveCommentEmail } quota { __typename name blobLimit storageQuota historyPeriod memberLimit humanReadable { __typename name blobLimit storageQuota historyPeriod memberLimit } } quotaUsage { __typename storageQuota } copilot { __typename quota { __typename limit used } } }"#
}
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
.field("emailVerified", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("settings", Settings.self),
.field("quota", Quota.self),
.field("quotaUsage", QuotaUsage.self),
.field("copilot", Copilot.self),
] }
public var id: AffineGraphQL.ID { __data["id"] }
/// User name
public var name: String { __data["name"] }
/// User email
public var email: String { __data["email"] }
/// User avatar url
public var avatarUrl: String? { __data["avatarUrl"] }
/// User email verified
public var emailVerified: Bool { __data["emailVerified"] }
/// Enabled features of a user
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
/// Get user settings
public var settings: Settings { __data["settings"] }
public var quota: Quota { __data["quota"] }
public var quotaUsage: QuotaUsage { __data["quotaUsage"] }
public var copilot: Copilot { __data["copilot"] }
/// Settings
///
/// Parent Type: `UserSettingsType`
public struct Settings: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserSettingsType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("receiveInvitationEmail", Bool.self),
.field("receiveMentionEmail", Bool.self),
.field("receiveCommentEmail", Bool.self),
] }
/// Receive invitation email
public var receiveInvitationEmail: Bool { __data["receiveInvitationEmail"] }
/// Receive mention email
public var receiveMentionEmail: Bool { __data["receiveMentionEmail"] }
/// Receive comment email
public var receiveCommentEmail: Bool { __data["receiveCommentEmail"] }
}
/// Quota
///
/// Parent Type: `UserQuotaType`
public struct Quota: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("name", String.self),
.field("blobLimit", AffineGraphQL.SafeInt.self),
.field("storageQuota", AffineGraphQL.SafeInt.self),
.field("historyPeriod", AffineGraphQL.SafeInt.self),
.field("memberLimit", Int.self),
.field("humanReadable", HumanReadable.self),
] }
public var name: String { __data["name"] }
public var blobLimit: AffineGraphQL.SafeInt { __data["blobLimit"] }
public var storageQuota: AffineGraphQL.SafeInt { __data["storageQuota"] }
public var historyPeriod: AffineGraphQL.SafeInt { __data["historyPeriod"] }
public var memberLimit: Int { __data["memberLimit"] }
public var humanReadable: HumanReadable { __data["humanReadable"] }
/// Quota.HumanReadable
///
/// Parent Type: `UserQuotaHumanReadableType`
public struct HumanReadable: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaHumanReadableType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("name", String.self),
.field("blobLimit", String.self),
.field("storageQuota", String.self),
.field("historyPeriod", String.self),
.field("memberLimit", String.self),
] }
public var name: String { __data["name"] }
public var blobLimit: String { __data["blobLimit"] }
public var storageQuota: String { __data["storageQuota"] }
public var historyPeriod: String { __data["historyPeriod"] }
public var memberLimit: String { __data["memberLimit"] }
}
}
/// QuotaUsage
///
/// Parent Type: `UserQuotaUsageType`
public struct QuotaUsage: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserQuotaUsageType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("storageQuota", AffineGraphQL.SafeInt.self),
] }
@available(*, deprecated, message: "use `UserQuotaType[\'usedStorageQuota\']` instead")
public var storageQuota: AffineGraphQL.SafeInt { __data["storageQuota"] }
}
/// Copilot
///
/// Parent Type: `Copilot`
public struct Copilot: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("quota", Quota.self),
] }
/// Get the quota of the user in the workspace
public var quota: Quota { __data["quota"] }
/// Copilot.Quota
///
/// Parent Type: `CopilotQuota`
public struct Quota: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotQuota }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("limit", AffineGraphQL.SafeInt?.self),
.field("used", AffineGraphQL.SafeInt.self),
] }
public var limit: AffineGraphQL.SafeInt? { __data["limit"] }
public var used: AffineGraphQL.SafeInt { __data["used"] }
}
}
}

View File

@@ -0,0 +1,103 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminUpdateWorkspaceMutation: GraphQLMutation {
public static let operationName: String = "adminUpdateWorkspace"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) { adminUpdateWorkspace(input: $input) { __typename id public createdAt name avatarKey enableAi enableSharing enableUrlPreview enableDocEmbedding features owner { __typename id name email avatarUrl } memberCount publicPageCount snapshotCount snapshotSize blobCount blobSize } }"#
))
public var input: AdminUpdateWorkspaceInput
public init(input: AdminUpdateWorkspaceInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminUpdateWorkspace", AdminUpdateWorkspace?.self, arguments: ["input": .variable("input")]),
] }
/// Update workspace flags and features for admin
public var adminUpdateWorkspace: AdminUpdateWorkspace? { __data["adminUpdateWorkspace"] }
/// AdminUpdateWorkspace
///
/// Parent Type: `AdminWorkspace`
public struct AdminUpdateWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspace }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("public", Bool.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("name", String?.self),
.field("avatarKey", String?.self),
.field("enableAi", Bool.self),
.field("enableSharing", Bool.self),
.field("enableUrlPreview", Bool.self),
.field("enableDocEmbedding", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("owner", Owner?.self),
.field("memberCount", Int.self),
.field("publicPageCount", Int.self),
.field("snapshotCount", Int.self),
.field("snapshotSize", AffineGraphQL.SafeInt.self),
.field("blobCount", Int.self),
.field("blobSize", AffineGraphQL.SafeInt.self),
] }
public var id: String { __data["id"] }
public var `public`: Bool { __data["public"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var name: String? { __data["name"] }
public var avatarKey: String? { __data["avatarKey"] }
public var enableAi: Bool { __data["enableAi"] }
public var enableSharing: Bool { __data["enableSharing"] }
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
public var enableDocEmbedding: Bool { __data["enableDocEmbedding"] }
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
public var owner: Owner? { __data["owner"] }
public var memberCount: Int { __data["memberCount"] }
public var publicPageCount: Int { __data["publicPageCount"] }
public var snapshotCount: Int { __data["snapshotCount"] }
public var snapshotSize: AffineGraphQL.SafeInt { __data["snapshotSize"] }
public var blobCount: Int { __data["blobCount"] }
public var blobSize: AffineGraphQL.SafeInt { __data["blobSize"] }
/// AdminUpdateWorkspace.Owner
///
/// Parent Type: `WorkspaceUserType`
public struct Owner: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceUserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
}
}
}
}

View File

@@ -3,11 +3,11 @@
@_exported import ApolloAPI
public class ApplyDocUpdatesQuery: GraphQLQuery {
public class ApplyDocUpdatesMutation: GraphQLMutation {
public static let operationName: String = "applyDocUpdates"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) { applyDocUpdates( workspaceId: $workspaceId docId: $docId op: $op updates: $updates ) }"#
#"mutation applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) { applyDocUpdates( workspaceId: $workspaceId docId: $docId op: $op updates: $updates ) }"#
))
public var workspaceId: String
@@ -38,7 +38,7 @@ public class ApplyDocUpdatesQuery: GraphQLQuery {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("applyDocUpdates", String.self, arguments: [
"workspaceId": .variable("workspaceId"),

View File

@@ -1,73 +0,0 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetBlobUploadPartUrlMutation: GraphQLMutation {
public static let operationName: String = "getBlobUploadPartUrl"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) { getBlobUploadPartUrl( workspaceId: $workspaceId key: $key uploadId: $uploadId partNumber: $partNumber ) { __typename uploadUrl headers expiresAt } }"#
))
public var workspaceId: String
public var key: String
public var uploadId: String
public var partNumber: Int
public init(
workspaceId: String,
key: String,
uploadId: String,
partNumber: Int
) {
self.workspaceId = workspaceId
self.key = key
self.uploadId = uploadId
self.partNumber = partNumber
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"key": key,
"uploadId": uploadId,
"partNumber": partNumber
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("getBlobUploadPartUrl", GetBlobUploadPartUrl.self, arguments: [
"workspaceId": .variable("workspaceId"),
"key": .variable("key"),
"uploadId": .variable("uploadId"),
"partNumber": .variable("partNumber")
]),
] }
public var getBlobUploadPartUrl: GetBlobUploadPartUrl { __data["getBlobUploadPartUrl"] }
/// GetBlobUploadPartUrl
///
/// Parent Type: `BlobUploadPart`
public struct GetBlobUploadPartUrl: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.BlobUploadPart }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("uploadUrl", String.self),
.field("headers", AffineGraphQL.JSONObject?.self),
.field("expiresAt", AffineGraphQL.DateTime?.self),
] }
public var uploadUrl: String { __data["uploadUrl"] }
public var headers: AffineGraphQL.JSONObject? { __data["headers"] }
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
}
}
}

View File

@@ -0,0 +1,68 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class LinkCalDavAccountMutation: GraphQLMutation {
public static let operationName: String = "linkCalDavAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation linkCalDavAccount($input: LinkCalDAVAccountInput!) { linkCalDAVAccount(input: $input) { __typename id provider providerAccountId displayName email status lastError refreshIntervalMinutes calendarsCount createdAt updatedAt } }"#
))
public var input: LinkCalDAVAccountInput
public init(input: LinkCalDAVAccountInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("linkCalDAVAccount", LinkCalDAVAccount.self, arguments: ["input": .variable("input")]),
] }
public var linkCalDAVAccount: LinkCalDAVAccount { __data["linkCalDAVAccount"] }
/// LinkCalDAVAccount
///
/// Parent Type: `CalendarAccountObjectType`
public struct LinkCalDAVAccount: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarAccountObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("providerAccountId", String.self),
.field("displayName", String?.self),
.field("email", String?.self),
.field("status", String.self),
.field("lastError", String?.self),
.field("refreshIntervalMinutes", Int.self),
.field("calendarsCount", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
] }
public var id: String { __data["id"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var providerAccountId: String { __data["providerAccountId"] }
public var displayName: String? { __data["displayName"] }
public var email: String? { __data["email"] }
public var status: String { __data["status"] }
public var lastError: String? { __data["lastError"] }
public var refreshIntervalMinutes: Int { __data["refreshIntervalMinutes"] }
public var calendarsCount: Int { __data["calendarsCount"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
}
}
}

View File

@@ -0,0 +1,32 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class LinkCalendarAccountMutation: GraphQLMutation {
public static let operationName: String = "linkCalendarAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation linkCalendarAccount($input: LinkCalendarAccountInput!) { linkCalendarAccount(input: $input) }"#
))
public var input: LinkCalendarAccountInput
public init(input: LinkCalendarAccountInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("linkCalendarAccount", String.self, arguments: ["input": .variable("input")]),
] }
public var linkCalendarAccount: String { __data["linkCalendarAccount"] }
}
}

View File

@@ -0,0 +1,60 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class SetEnableSharingMutation: GraphQLMutation {
public static let operationName: String = "setEnableSharing"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation setEnableSharing($id: ID!, $enableSharing: Boolean!) { updateWorkspace(input: { id: $id, enableSharing: $enableSharing }) { __typename id } }"#
))
public var id: ID
public var enableSharing: Bool
public init(
id: ID,
enableSharing: Bool
) {
self.id = id
self.enableSharing = enableSharing
}
public var __variables: Variables? { [
"id": id,
"enableSharing": enableSharing
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("updateWorkspace", UpdateWorkspace.self, arguments: ["input": [
"id": .variable("id"),
"enableSharing": .variable("enableSharing")
]]),
] }
/// Update workspace
public var updateWorkspace: UpdateWorkspace { __data["updateWorkspace"] }
/// UpdateWorkspace
///
/// Parent Type: `WorkspaceType`
public struct UpdateWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID.self),
] }
public var id: AffineGraphQL.ID { __data["id"] }
}
}
}

View File

@@ -0,0 +1,32 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class UnlinkCalendarAccountMutation: GraphQLMutation {
public static let operationName: String = "unlinkCalendarAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation unlinkCalendarAccount($accountId: String!) { unlinkCalendarAccount(accountId: $accountId) }"#
))
public var accountId: String
public init(accountId: String) {
self.accountId = accountId
}
public var __variables: Variables? { ["accountId": accountId] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("unlinkCalendarAccount", Bool.self, arguments: ["accountId": .variable("accountId")]),
] }
public var unlinkCalendarAccount: Bool { __data["unlinkCalendarAccount"] }
}
}

View File

@@ -0,0 +1,79 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class UpdateCalendarAccountMutation: GraphQLMutation {
public static let operationName: String = "updateCalendarAccount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation updateCalendarAccount($accountId: String!, $refreshIntervalMinutes: Int!) { updateCalendarAccount( accountId: $accountId refreshIntervalMinutes: $refreshIntervalMinutes ) { __typename id provider providerAccountId displayName email status lastError refreshIntervalMinutes calendarsCount createdAt updatedAt } }"#
))
public var accountId: String
public var refreshIntervalMinutes: Int
public init(
accountId: String,
refreshIntervalMinutes: Int
) {
self.accountId = accountId
self.refreshIntervalMinutes = refreshIntervalMinutes
}
public var __variables: Variables? { [
"accountId": accountId,
"refreshIntervalMinutes": refreshIntervalMinutes
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("updateCalendarAccount", UpdateCalendarAccount?.self, arguments: [
"accountId": .variable("accountId"),
"refreshIntervalMinutes": .variable("refreshIntervalMinutes")
]),
] }
public var updateCalendarAccount: UpdateCalendarAccount? { __data["updateCalendarAccount"] }
/// UpdateCalendarAccount
///
/// Parent Type: `CalendarAccountObjectType`
public struct UpdateCalendarAccount: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarAccountObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("providerAccountId", String.self),
.field("displayName", String?.self),
.field("email", String?.self),
.field("status", String.self),
.field("lastError", String?.self),
.field("refreshIntervalMinutes", Int.self),
.field("calendarsCount", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
] }
public var id: String { __data["id"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var providerAccountId: String { __data["providerAccountId"] }
public var displayName: String? { __data["displayName"] }
public var email: String? { __data["email"] }
public var status: String { __data["status"] }
public var lastError: String? { __data["lastError"] }
public var refreshIntervalMinutes: Int { __data["refreshIntervalMinutes"] }
public var calendarsCount: Int { __data["calendarsCount"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
}
}
}

View File

@@ -0,0 +1,84 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class UpdateWorkspaceCalendarsMutation: GraphQLMutation {
public static let operationName: String = "updateWorkspaceCalendars"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"mutation updateWorkspaceCalendars($input: UpdateWorkspaceCalendarsInput!) { updateWorkspaceCalendars(input: $input) { __typename id workspaceId createdByUserId displayNameOverride colorOverride enabled items { __typename id subscriptionId sortOrder colorOverride enabled } } }"#
))
public var input: UpdateWorkspaceCalendarsInput
public init(input: UpdateWorkspaceCalendarsInput) {
self.input = input
}
public var __variables: Variables? { ["input": input] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
public static var __selections: [ApolloAPI.Selection] { [
.field("updateWorkspaceCalendars", UpdateWorkspaceCalendars.self, arguments: ["input": .variable("input")]),
] }
public var updateWorkspaceCalendars: UpdateWorkspaceCalendars { __data["updateWorkspaceCalendars"] }
/// UpdateWorkspaceCalendars
///
/// Parent Type: `WorkspaceCalendarObjectType`
public struct UpdateWorkspaceCalendars: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceCalendarObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("workspaceId", String.self),
.field("createdByUserId", String.self),
.field("displayNameOverride", String?.self),
.field("colorOverride", String?.self),
.field("enabled", Bool.self),
.field("items", [Item].self),
] }
public var id: String { __data["id"] }
public var workspaceId: String { __data["workspaceId"] }
public var createdByUserId: String { __data["createdByUserId"] }
public var displayNameOverride: String? { __data["displayNameOverride"] }
public var colorOverride: String? { __data["colorOverride"] }
public var enabled: Bool { __data["enabled"] }
public var items: [Item] { __data["items"] }
/// UpdateWorkspaceCalendars.Item
///
/// Parent Type: `WorkspaceCalendarItemObjectType`
public struct Item: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceCalendarItemObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("subscriptionId", String.self),
.field("sortOrder", Int?.self),
.field("colorOverride", String?.self),
.field("enabled", Bool.self),
] }
public var id: String { __data["id"] }
public var subscriptionId: String { __data["subscriptionId"] }
public var sortOrder: Int? { __data["sortOrder"] }
public var colorOverride: String? { __data["colorOverride"] }
public var enabled: Bool { __data["enabled"] }
}
}
}
}

View File

@@ -7,7 +7,7 @@ public class AdminServerConfigQuery: GraphQLQuery {
public static let operationName: String = "adminServerConfig"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminServerConfig { serverConfig { __typename version baseUrl name features type initialized credentialsRequirement { __typename ...CredentialsRequirements } availableUpgrade { __typename changelog version publishedAt url } availableUserFeatures } }"#,
#"query adminServerConfig { serverConfig { __typename version baseUrl name features type initialized credentialsRequirement { __typename ...CredentialsRequirements } availableUpgrade { __typename changelog version publishedAt url } availableUserFeatures availableWorkspaceFeatures } }"#,
fragments: [CredentialsRequirements.self, PasswordLimits.self]
))
@@ -44,6 +44,7 @@ public class AdminServerConfigQuery: GraphQLQuery {
.field("credentialsRequirement", CredentialsRequirement.self),
.field("availableUpgrade", AvailableUpgrade?.self),
.field("availableUserFeatures", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("availableWorkspaceFeatures", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
] }
/// server version
@@ -64,6 +65,8 @@ public class AdminServerConfigQuery: GraphQLQuery {
public var availableUpgrade: AvailableUpgrade? { __data["availableUpgrade"] }
/// Features for user that can be configured
public var availableUserFeatures: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["availableUserFeatures"] }
/// Workspace features available for admin configuration
public var availableWorkspaceFeatures: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["availableWorkspaceFeatures"] }
/// ServerConfig.CredentialsRequirement
///

View File

@@ -0,0 +1,174 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminWorkspaceQuery: GraphQLQuery {
public static let operationName: String = "adminWorkspace"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminWorkspace($id: String!, $memberSkip: Int, $memberTake: Int, $memberQuery: String) { adminWorkspace(id: $id) { __typename id public createdAt name avatarKey enableAi enableSharing enableUrlPreview enableDocEmbedding features owner { __typename id name email avatarUrl } memberCount publicPageCount snapshotCount snapshotSize blobCount blobSize sharedLinks { __typename docId title publishedAt } members(skip: $memberSkip, take: $memberTake, query: $memberQuery) { __typename id name email avatarUrl role status } } }"#
))
public var id: String
public var memberSkip: GraphQLNullable<Int>
public var memberTake: GraphQLNullable<Int>
public var memberQuery: GraphQLNullable<String>
public init(
id: String,
memberSkip: GraphQLNullable<Int>,
memberTake: GraphQLNullable<Int>,
memberQuery: GraphQLNullable<String>
) {
self.id = id
self.memberSkip = memberSkip
self.memberTake = memberTake
self.memberQuery = memberQuery
}
public var __variables: Variables? { [
"id": id,
"memberSkip": memberSkip,
"memberTake": memberTake,
"memberQuery": memberQuery
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminWorkspace", AdminWorkspace?.self, arguments: ["id": .variable("id")]),
] }
/// Get workspace detail for admin
public var adminWorkspace: AdminWorkspace? { __data["adminWorkspace"] }
/// AdminWorkspace
///
/// Parent Type: `AdminWorkspace`
public struct AdminWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspace }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("public", Bool.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("name", String?.self),
.field("avatarKey", String?.self),
.field("enableAi", Bool.self),
.field("enableSharing", Bool.self),
.field("enableUrlPreview", Bool.self),
.field("enableDocEmbedding", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("owner", Owner?.self),
.field("memberCount", Int.self),
.field("publicPageCount", Int.self),
.field("snapshotCount", Int.self),
.field("snapshotSize", AffineGraphQL.SafeInt.self),
.field("blobCount", Int.self),
.field("blobSize", AffineGraphQL.SafeInt.self),
.field("sharedLinks", [SharedLink].self),
.field("members", [Member].self, arguments: [
"skip": .variable("memberSkip"),
"take": .variable("memberTake"),
"query": .variable("memberQuery")
]),
] }
public var id: String { __data["id"] }
public var `public`: Bool { __data["public"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var name: String? { __data["name"] }
public var avatarKey: String? { __data["avatarKey"] }
public var enableAi: Bool { __data["enableAi"] }
public var enableSharing: Bool { __data["enableSharing"] }
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
public var enableDocEmbedding: Bool { __data["enableDocEmbedding"] }
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
public var owner: Owner? { __data["owner"] }
public var memberCount: Int { __data["memberCount"] }
public var publicPageCount: Int { __data["publicPageCount"] }
public var snapshotCount: Int { __data["snapshotCount"] }
public var snapshotSize: AffineGraphQL.SafeInt { __data["snapshotSize"] }
public var blobCount: Int { __data["blobCount"] }
public var blobSize: AffineGraphQL.SafeInt { __data["blobSize"] }
public var sharedLinks: [SharedLink] { __data["sharedLinks"] }
/// Members of workspace
public var members: [Member] { __data["members"] }
/// AdminWorkspace.Owner
///
/// Parent Type: `WorkspaceUserType`
public struct Owner: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceUserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
}
/// AdminWorkspace.SharedLink
///
/// Parent Type: `AdminWorkspaceSharedLink`
public struct SharedLink: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspaceSharedLink }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("docId", String.self),
.field("title", String?.self),
.field("publishedAt", AffineGraphQL.DateTime?.self),
] }
public var docId: String { __data["docId"] }
public var title: String? { __data["title"] }
public var publishedAt: AffineGraphQL.DateTime? { __data["publishedAt"] }
}
/// AdminWorkspace.Member
///
/// Parent Type: `AdminWorkspaceMember`
public struct Member: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspaceMember }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
.field("role", GraphQLEnum<AffineGraphQL.Permission>.self),
.field("status", GraphQLEnum<AffineGraphQL.WorkspaceMemberStatus>.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
public var role: GraphQLEnum<AffineGraphQL.Permission> { __data["role"] }
public var status: GraphQLEnum<AffineGraphQL.WorkspaceMemberStatus> { __data["status"] }
}
}
}
}

View File

@@ -0,0 +1,33 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminWorkspacesCountQuery: GraphQLQuery {
public static let operationName: String = "adminWorkspacesCount"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminWorkspacesCount($filter: ListWorkspaceInput!) { adminWorkspacesCount(filter: $filter) }"#
))
public var filter: ListWorkspaceInput
public init(filter: ListWorkspaceInput) {
self.filter = filter
}
public var __variables: Variables? { ["filter": filter] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminWorkspacesCount", Int.self, arguments: ["filter": .variable("filter")]),
] }
/// Workspaces count for admin
public var adminWorkspacesCount: Int { __data["adminWorkspacesCount"] }
}
}

View File

@@ -0,0 +1,103 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class AdminWorkspacesQuery: GraphQLQuery {
public static let operationName: String = "adminWorkspaces"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query adminWorkspaces($filter: ListWorkspaceInput!) { adminWorkspaces(filter: $filter) { __typename id public createdAt name avatarKey enableAi enableSharing enableUrlPreview enableDocEmbedding features owner { __typename id name email avatarUrl } memberCount publicPageCount snapshotCount snapshotSize blobCount blobSize } }"#
))
public var filter: ListWorkspaceInput
public init(filter: ListWorkspaceInput) {
self.filter = filter
}
public var __variables: Variables? { ["filter": filter] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("adminWorkspaces", [AdminWorkspace].self, arguments: ["filter": .variable("filter")]),
] }
/// List workspaces for admin
public var adminWorkspaces: [AdminWorkspace] { __data["adminWorkspaces"] }
/// AdminWorkspace
///
/// Parent Type: `AdminWorkspace`
public struct AdminWorkspace: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.AdminWorkspace }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("public", Bool.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("name", String?.self),
.field("avatarKey", String?.self),
.field("enableAi", Bool.self),
.field("enableSharing", Bool.self),
.field("enableUrlPreview", Bool.self),
.field("enableDocEmbedding", Bool.self),
.field("features", [GraphQLEnum<AffineGraphQL.FeatureType>].self),
.field("owner", Owner?.self),
.field("memberCount", Int.self),
.field("publicPageCount", Int.self),
.field("snapshotCount", Int.self),
.field("snapshotSize", AffineGraphQL.SafeInt.self),
.field("blobCount", Int.self),
.field("blobSize", AffineGraphQL.SafeInt.self),
] }
public var id: String { __data["id"] }
public var `public`: Bool { __data["public"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var name: String? { __data["name"] }
public var avatarKey: String? { __data["avatarKey"] }
public var enableAi: Bool { __data["enableAi"] }
public var enableSharing: Bool { __data["enableSharing"] }
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
public var enableDocEmbedding: Bool { __data["enableDocEmbedding"] }
public var features: [GraphQLEnum<AffineGraphQL.FeatureType>] { __data["features"] }
public var owner: Owner? { __data["owner"] }
public var memberCount: Int { __data["memberCount"] }
public var publicPageCount: Int { __data["publicPageCount"] }
public var snapshotCount: Int { __data["snapshotCount"] }
public var snapshotSize: AffineGraphQL.SafeInt { __data["snapshotSize"] }
public var blobCount: Int { __data["blobCount"] }
public var blobSize: AffineGraphQL.SafeInt { __data["blobSize"] }
/// AdminWorkspace.Owner
///
/// Parent Type: `WorkspaceUserType`
public struct Owner: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceUserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("name", String.self),
.field("email", String.self),
.field("avatarUrl", String?.self),
] }
public var id: String { __data["id"] }
public var name: String { __data["name"] }
public var email: String { __data["email"] }
public var avatarUrl: String? { __data["avatarUrl"] }
}
}
}
}

View File

@@ -0,0 +1,113 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class CalendarAccountsQuery: GraphQLQuery {
public static let operationName: String = "calendarAccounts"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query calendarAccounts { currentUser { __typename calendarAccounts { __typename id provider providerAccountId displayName email status lastError refreshIntervalMinutes calendarsCount createdAt updatedAt calendars { __typename id accountId provider externalCalendarId displayName timezone color enabled lastSyncAt } } } }"#
))
public init() {}
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("calendarAccounts", [CalendarAccount].self),
] }
public var calendarAccounts: [CalendarAccount] { __data["calendarAccounts"] }
/// CurrentUser.CalendarAccount
///
/// Parent Type: `CalendarAccountObjectType`
public struct CalendarAccount: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarAccountObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("providerAccountId", String.self),
.field("displayName", String?.self),
.field("email", String?.self),
.field("status", String.self),
.field("lastError", String?.self),
.field("refreshIntervalMinutes", Int.self),
.field("calendarsCount", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
.field("calendars", [Calendar].self),
] }
public var id: String { __data["id"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var providerAccountId: String { __data["providerAccountId"] }
public var displayName: String? { __data["displayName"] }
public var email: String? { __data["email"] }
public var status: String { __data["status"] }
public var lastError: String? { __data["lastError"] }
public var refreshIntervalMinutes: Int { __data["refreshIntervalMinutes"] }
public var calendarsCount: Int { __data["calendarsCount"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
public var calendars: [Calendar] { __data["calendars"] }
/// CurrentUser.CalendarAccount.Calendar
///
/// Parent Type: `CalendarSubscriptionObjectType`
public struct Calendar: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarSubscriptionObjectType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", String.self),
.field("accountId", String.self),
.field("provider", GraphQLEnum<AffineGraphQL.CalendarProviderType>.self),
.field("externalCalendarId", String.self),
.field("displayName", String?.self),
.field("timezone", String?.self),
.field("color", String?.self),
.field("enabled", Bool.self),
.field("lastSyncAt", AffineGraphQL.DateTime?.self),
] }
public var id: String { __data["id"] }
public var accountId: String { __data["accountId"] }
public var provider: GraphQLEnum<AffineGraphQL.CalendarProviderType> { __data["provider"] }
public var externalCalendarId: String { __data["externalCalendarId"] }
public var displayName: String? { __data["displayName"] }
public var timezone: String? { __data["timezone"] }
public var color: String? { __data["color"] }
public var enabled: Bool { __data["enabled"] }
public var lastSyncAt: AffineGraphQL.DateTime? { __data["lastSyncAt"] }
}
}
}
}
}

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