mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 13:57:02 +08:00
Compare commits
8 Commits
v0.26.2-be
...
v0.26.3-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72df9cb457 | ||
|
|
98e5747fdc | ||
|
|
4460604dd3 | ||
|
|
b4be9118ad | ||
|
|
b46bf91575 | ||
|
|
3ad482351b | ||
|
|
03b1d15a8f | ||
|
|
52c7b04a01 |
33
.github/actions/deploy/deploy.mjs
vendored
33
.github/actions/deploy/deploy.mjs
vendored
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
5
.github/helm/affine/charts/doc/values.yaml
vendored
5
.github/helm/affine/charts/doc/values.yaml
vendored
@@ -30,9 +30,12 @@ podSecurityContext:
|
||||
fsGroup: 2000
|
||||
|
||||
resources:
|
||||
requests:
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: 4Gi
|
||||
requests:
|
||||
cpu: '1'
|
||||
memory: 2Gi
|
||||
|
||||
probe:
|
||||
initialDelaySeconds: 20
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
6
.github/helm/affine/charts/front/values.yaml
vendored
6
.github/helm/affine/charts/front/values.yaml
vendored
@@ -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: []
|
||||
|
||||
@@ -27,8 +27,11 @@ podSecurityContext:
|
||||
fsGroup: 2000
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: 4Gi
|
||||
requests:
|
||||
cpu: '2'
|
||||
cpu: '1'
|
||||
memory: 2Gi
|
||||
|
||||
probe:
|
||||
|
||||
10
.github/helm/affine/values.yaml
vendored
10
.github/helm/affine/values.yaml
vendored
@@ -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"}'
|
||||
|
||||
@@ -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>`);
|
||||
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
);
|
||||
@@ -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)]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal file
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal 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 };
|
||||
645
packages/frontend/admin/src/modules/dashboard/index.tsx
Normal file
645
packages/frontend/admin/src/modules/dashboard/index.tsx
Normal 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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class CalendarEventsQuery: GraphQLQuery {
|
||||
public static let operationName: String = "calendarEvents"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query calendarEvents($workspaceId: String!, $from: DateTime!, $to: DateTime!) { workspace(id: $workspaceId) { __typename calendars { __typename id events(from: $from, to: $to) { __typename id subscriptionId externalEventId recurrenceId status title description location startAtUtc endAtUtc originalTimezone allDay } } } }"#
|
||||
))
|
||||
|
||||
public var workspaceId: String
|
||||
public var from: DateTime
|
||||
public var to: DateTime
|
||||
|
||||
public init(
|
||||
workspaceId: String,
|
||||
from: DateTime,
|
||||
to: DateTime
|
||||
) {
|
||||
self.workspaceId = workspaceId
|
||||
self.from = from
|
||||
self.to = to
|
||||
}
|
||||
|
||||
public var __variables: Variables? { [
|
||||
"workspaceId": workspaceId,
|
||||
"from": from,
|
||||
"to": to
|
||||
] }
|
||||
|
||||
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("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]),
|
||||
] }
|
||||
|
||||
/// Get workspace by id
|
||||
public var workspace: Workspace { __data["workspace"] }
|
||||
|
||||
/// Workspace
|
||||
///
|
||||
/// Parent Type: `WorkspaceType`
|
||||
public struct Workspace: 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("calendars", [Calendar].self),
|
||||
] }
|
||||
|
||||
public var calendars: [Calendar] { __data["calendars"] }
|
||||
|
||||
/// Workspace.Calendar
|
||||
///
|
||||
/// Parent Type: `WorkspaceCalendarObjectType`
|
||||
public struct Calendar: 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("events", [Event].self, arguments: [
|
||||
"from": .variable("from"),
|
||||
"to": .variable("to")
|
||||
]),
|
||||
] }
|
||||
|
||||
public var id: String { __data["id"] }
|
||||
public var events: [Event] { __data["events"] }
|
||||
|
||||
/// Workspace.Calendar.Event
|
||||
///
|
||||
/// Parent Type: `CalendarEventObjectType`
|
||||
public struct Event: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarEventObjectType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", String.self),
|
||||
.field("subscriptionId", String.self),
|
||||
.field("externalEventId", String.self),
|
||||
.field("recurrenceId", String?.self),
|
||||
.field("status", String?.self),
|
||||
.field("title", String?.self),
|
||||
.field("description", String?.self),
|
||||
.field("location", String?.self),
|
||||
.field("startAtUtc", AffineGraphQL.DateTime.self),
|
||||
.field("endAtUtc", AffineGraphQL.DateTime.self),
|
||||
.field("originalTimezone", String?.self),
|
||||
.field("allDay", Bool.self),
|
||||
] }
|
||||
|
||||
public var id: String { __data["id"] }
|
||||
public var subscriptionId: String { __data["subscriptionId"] }
|
||||
public var externalEventId: String { __data["externalEventId"] }
|
||||
public var recurrenceId: String? { __data["recurrenceId"] }
|
||||
public var status: String? { __data["status"] }
|
||||
public var title: String? { __data["title"] }
|
||||
public var description: String? { __data["description"] }
|
||||
public var location: String? { __data["location"] }
|
||||
public var startAtUtc: AffineGraphQL.DateTime { __data["startAtUtc"] }
|
||||
public var endAtUtc: AffineGraphQL.DateTime { __data["endAtUtc"] }
|
||||
public var originalTimezone: String? { __data["originalTimezone"] }
|
||||
public var allDay: Bool { __data["allDay"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class CalendarProvidersQuery: GraphQLQuery {
|
||||
public static let operationName: String = "calendarProviders"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query calendarProviders { serverConfig { __typename calendarCalDAVProviders { __typename id label requiresAppPassword docsUrl } calendarProviders } }"#
|
||||
))
|
||||
|
||||
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("serverConfig", ServerConfig.self),
|
||||
] }
|
||||
|
||||
/// server config
|
||||
public var serverConfig: ServerConfig { __data["serverConfig"] }
|
||||
|
||||
/// ServerConfig
|
||||
///
|
||||
/// Parent Type: `ServerConfigType`
|
||||
public struct ServerConfig: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ServerConfigType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("calendarCalDAVProviders", [CalendarCalDAVProvider].self),
|
||||
.field("calendarProviders", [GraphQLEnum<AffineGraphQL.CalendarProviderType>].self),
|
||||
] }
|
||||
|
||||
public var calendarCalDAVProviders: [CalendarCalDAVProvider] { __data["calendarCalDAVProviders"] }
|
||||
public var calendarProviders: [GraphQLEnum<AffineGraphQL.CalendarProviderType>] { __data["calendarProviders"] }
|
||||
|
||||
/// ServerConfig.CalendarCalDAVProvider
|
||||
///
|
||||
/// Parent Type: `CalendarCalDAVProviderPresetObjectType`
|
||||
public struct CalendarCalDAVProvider: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CalendarCalDAVProviderPresetObjectType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", String.self),
|
||||
.field("label", String.self),
|
||||
.field("requiresAppPassword", Bool?.self),
|
||||
.field("docsUrl", String?.self),
|
||||
] }
|
||||
|
||||
public var id: String { __data["id"] }
|
||||
public var label: String { __data["label"] }
|
||||
public var requiresAppPassword: Bool? { __data["requiresAppPassword"] }
|
||||
public var docsUrl: String? { __data["docsUrl"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class GetBlobUploadPartUrlQuery: GraphQLQuery {
|
||||
public static let operationName: String = "getBlobUploadPartUrl"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) { workspace(id: $workspaceId) { __typename blobUploadPartUrl(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.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]),
|
||||
] }
|
||||
|
||||
/// Get workspace by id
|
||||
public var workspace: Workspace { __data["workspace"] }
|
||||
|
||||
/// Workspace
|
||||
///
|
||||
/// Parent Type: `WorkspaceType`
|
||||
public struct Workspace: 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("blobUploadPartUrl", BlobUploadPartUrl.self, arguments: [
|
||||
"key": .variable("key"),
|
||||
"uploadId": .variable("uploadId"),
|
||||
"partNumber": .variable("partNumber")
|
||||
]),
|
||||
] }
|
||||
|
||||
/// Get blob upload part url
|
||||
public var blobUploadPartUrl: BlobUploadPartUrl { __data["blobUploadPartUrl"] }
|
||||
|
||||
/// Workspace.BlobUploadPartUrl
|
||||
///
|
||||
/// Parent Type: `BlobUploadPart`
|
||||
public struct BlobUploadPartUrl: 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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class GetCurrentUserProfileQuery: GraphQLQuery {
|
||||
public static let operationName: String = "getCurrentUserProfile"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query getCurrentUserProfile { currentUser { __typename ...CurrentUserProfile } }"#,
|
||||
fragments: [CurrentUserProfile.self]
|
||||
))
|
||||
|
||||
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),
|
||||
.fragment(CurrentUserProfile.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"] }
|
||||
|
||||
public struct Fragments: FragmentContainer {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public var currentUserProfile: CurrentUserProfile { _toFragment() }
|
||||
}
|
||||
|
||||
public typealias Settings = CurrentUserProfile.Settings
|
||||
|
||||
public typealias Quota = CurrentUserProfile.Quota
|
||||
|
||||
public typealias QuotaUsage = CurrentUserProfile.QuotaUsage
|
||||
|
||||
public typealias Copilot = CurrentUserProfile.Copilot
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ public class GetWorkspaceConfigQuery: GraphQLQuery {
|
||||
public static let operationName: String = "getWorkspaceConfig"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query getWorkspaceConfig($id: String!) { workspace(id: $id) { __typename enableAi enableUrlPreview enableDocEmbedding inviteLink { __typename link expireTime } } }"#
|
||||
#"query getWorkspaceConfig($id: String!) { workspace(id: $id) { __typename enableAi enableSharing enableUrlPreview enableDocEmbedding inviteLink { __typename link expireTime } } }"#
|
||||
))
|
||||
|
||||
public var id: String
|
||||
@@ -41,6 +41,7 @@ public class GetWorkspaceConfigQuery: GraphQLQuery {
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("enableAi", Bool.self),
|
||||
.field("enableSharing", Bool.self),
|
||||
.field("enableUrlPreview", Bool.self),
|
||||
.field("enableDocEmbedding", Bool.self),
|
||||
.field("inviteLink", InviteLink?.self),
|
||||
@@ -48,6 +49,8 @@ public class GetWorkspaceConfigQuery: GraphQLQuery {
|
||||
|
||||
/// Enable AI
|
||||
public var enableAi: Bool { __data["enableAi"] }
|
||||
/// Enable workspace sharing
|
||||
public var enableSharing: Bool { __data["enableSharing"] }
|
||||
/// Enable url previous when sharing
|
||||
public var enableUrlPreview: Bool { __data["enableUrlPreview"] }
|
||||
/// Enable doc embedding
|
||||
|
||||
@@ -7,7 +7,7 @@ public class GetWorkspaceInfoQuery: GraphQLQuery {
|
||||
public static let operationName: String = "getWorkspaceInfo"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query getWorkspaceInfo($workspaceId: String!) { workspace(id: $workspaceId) { __typename role team } }"#
|
||||
#"query getWorkspaceInfo($workspaceId: String!) { workspace(id: $workspaceId) { __typename permissions { __typename Workspace_Administrators_Manage Workspace_Blobs_List Workspace_Blobs_Read Workspace_Blobs_Write Workspace_Copilot Workspace_CreateDoc Workspace_Delete Workspace_Organize_Read Workspace_Payment_Manage Workspace_Properties_Create Workspace_Properties_Delete Workspace_Properties_Read Workspace_Properties_Update Workspace_Read Workspace_Settings_Read Workspace_Settings_Update Workspace_Sync Workspace_TransferOwner Workspace_Users_Manage Workspace_Users_Read } role team } }"#
|
||||
))
|
||||
|
||||
public var workspaceId: String
|
||||
@@ -40,14 +40,71 @@ public class GetWorkspaceInfoQuery: GraphQLQuery {
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspaceType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("permissions", Permissions.self),
|
||||
.field("role", GraphQLEnum<AffineGraphQL.Permission>.self),
|
||||
.field("team", Bool.self),
|
||||
] }
|
||||
|
||||
/// map of action permissions
|
||||
public var permissions: Permissions { __data["permissions"] }
|
||||
/// Role of current signed in user in workspace
|
||||
public var role: GraphQLEnum<AffineGraphQL.Permission> { __data["role"] }
|
||||
/// if workspace is team workspace
|
||||
public var team: Bool { __data["team"] }
|
||||
|
||||
/// Workspace.Permissions
|
||||
///
|
||||
/// Parent Type: `WorkspacePermissions`
|
||||
public struct Permissions: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.WorkspacePermissions }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("Workspace_Administrators_Manage", Bool.self),
|
||||
.field("Workspace_Blobs_List", Bool.self),
|
||||
.field("Workspace_Blobs_Read", Bool.self),
|
||||
.field("Workspace_Blobs_Write", Bool.self),
|
||||
.field("Workspace_Copilot", Bool.self),
|
||||
.field("Workspace_CreateDoc", Bool.self),
|
||||
.field("Workspace_Delete", Bool.self),
|
||||
.field("Workspace_Organize_Read", Bool.self),
|
||||
.field("Workspace_Payment_Manage", Bool.self),
|
||||
.field("Workspace_Properties_Create", Bool.self),
|
||||
.field("Workspace_Properties_Delete", Bool.self),
|
||||
.field("Workspace_Properties_Read", Bool.self),
|
||||
.field("Workspace_Properties_Update", Bool.self),
|
||||
.field("Workspace_Read", Bool.self),
|
||||
.field("Workspace_Settings_Read", Bool.self),
|
||||
.field("Workspace_Settings_Update", Bool.self),
|
||||
.field("Workspace_Sync", Bool.self),
|
||||
.field("Workspace_TransferOwner", Bool.self),
|
||||
.field("Workspace_Users_Manage", Bool.self),
|
||||
.field("Workspace_Users_Read", Bool.self),
|
||||
] }
|
||||
|
||||
public var workspace_Administrators_Manage: Bool { __data["Workspace_Administrators_Manage"] }
|
||||
public var workspace_Blobs_List: Bool { __data["Workspace_Blobs_List"] }
|
||||
public var workspace_Blobs_Read: Bool { __data["Workspace_Blobs_Read"] }
|
||||
public var workspace_Blobs_Write: Bool { __data["Workspace_Blobs_Write"] }
|
||||
public var workspace_Copilot: Bool { __data["Workspace_Copilot"] }
|
||||
public var workspace_CreateDoc: Bool { __data["Workspace_CreateDoc"] }
|
||||
public var workspace_Delete: Bool { __data["Workspace_Delete"] }
|
||||
public var workspace_Organize_Read: Bool { __data["Workspace_Organize_Read"] }
|
||||
public var workspace_Payment_Manage: Bool { __data["Workspace_Payment_Manage"] }
|
||||
public var workspace_Properties_Create: Bool { __data["Workspace_Properties_Create"] }
|
||||
public var workspace_Properties_Delete: Bool { __data["Workspace_Properties_Delete"] }
|
||||
public var workspace_Properties_Read: Bool { __data["Workspace_Properties_Read"] }
|
||||
public var workspace_Properties_Update: Bool { __data["Workspace_Properties_Update"] }
|
||||
public var workspace_Read: Bool { __data["Workspace_Read"] }
|
||||
public var workspace_Settings_Read: Bool { __data["Workspace_Settings_Read"] }
|
||||
public var workspace_Settings_Update: Bool { __data["Workspace_Settings_Update"] }
|
||||
public var workspace_Sync: Bool { __data["Workspace_Sync"] }
|
||||
public var workspace_TransferOwner: Bool { __data["Workspace_TransferOwner"] }
|
||||
public var workspace_Users_Manage: Bool { __data["Workspace_Users_Manage"] }
|
||||
public var workspace_Users_Read: Bool { __data["Workspace_Users_Read"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
|
||||
public static let operationName: String = "listUserAccessTokens"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query listUserAccessTokens { revealedAccessTokens { __typename id name createdAt expiresAt token } }"#
|
||||
#"query listUserAccessTokens { currentUser { __typename revealedAccessTokens { __typename id name createdAt expiresAt token } } }"#
|
||||
))
|
||||
|
||||
public init() {}
|
||||
@@ -18,33 +18,50 @@ public class ListUserAccessTokensQuery: GraphQLQuery {
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("revealedAccessTokens", [RevealedAccessToken].self),
|
||||
.field("currentUser", CurrentUser?.self),
|
||||
] }
|
||||
|
||||
public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] }
|
||||
/// Get current user
|
||||
public var currentUser: CurrentUser? { __data["currentUser"] }
|
||||
|
||||
/// RevealedAccessToken
|
||||
/// CurrentUser
|
||||
///
|
||||
/// Parent Type: `RevealedAccessToken`
|
||||
public struct RevealedAccessToken: AffineGraphQL.SelectionSet {
|
||||
/// 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.RevealedAccessToken }
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", String.self),
|
||||
.field("name", String.self),
|
||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||
.field("expiresAt", AffineGraphQL.DateTime?.self),
|
||||
.field("token", String.self),
|
||||
.field("revealedAccessTokens", [RevealedAccessToken].self),
|
||||
] }
|
||||
|
||||
public var id: String { __data["id"] }
|
||||
public var name: String { __data["name"] }
|
||||
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
||||
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
|
||||
public var token: String { __data["token"] }
|
||||
public var revealedAccessTokens: [RevealedAccessToken] { __data["revealedAccessTokens"] }
|
||||
|
||||
/// CurrentUser.RevealedAccessToken
|
||||
///
|
||||
/// Parent Type: `RevealedAccessToken`
|
||||
public struct RevealedAccessToken: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.RevealedAccessToken }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("id", String.self),
|
||||
.field("name", String.self),
|
||||
.field("createdAt", AffineGraphQL.DateTime.self),
|
||||
.field("expiresAt", AffineGraphQL.DateTime?.self),
|
||||
.field("token", String.self),
|
||||
] }
|
||||
|
||||
public var id: String { __data["id"] }
|
||||
public var name: String { __data["name"] }
|
||||
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
|
||||
public var expiresAt: AffineGraphQL.DateTime? { __data["expiresAt"] }
|
||||
public var token: String { __data["token"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public class ListUsersQuery: GraphQLQuery {
|
||||
public static let operationName: String = "listUsers"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query listUsers($filter: ListUserInput!) { users(filter: $filter) { __typename id name email disabled features hasPassword emailVerified avatarUrl } usersCount }"#
|
||||
#"query listUsers($filter: ListUserInput!) { users(filter: $filter) { __typename id name email disabled features hasPassword emailVerified avatarUrl } usersCount(filter: $filter) }"#
|
||||
))
|
||||
|
||||
public var filter: ListUserInput
|
||||
@@ -25,7 +25,7 @@ public class ListUsersQuery: GraphQLQuery {
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("users", [User].self, arguments: ["filter": .variable("filter")]),
|
||||
.field("usersCount", Int.self),
|
||||
.field("usersCount", Int.self, arguments: ["filter": .variable("filter")]),
|
||||
] }
|
||||
|
||||
/// List registered users
|
||||
|
||||
@@ -7,7 +7,7 @@ public class NotificationCountQuery: GraphQLQuery {
|
||||
public static let operationName: String = "notificationCount"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query notificationCount { currentUser { __typename notificationCount } }"#
|
||||
#"query notificationCount { currentUser { __typename notifications(pagination: { first: 1 }) { __typename totalCount } } }"#
|
||||
))
|
||||
|
||||
public init() {}
|
||||
@@ -34,11 +34,27 @@ public class NotificationCountQuery: GraphQLQuery {
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("notificationCount", Int.self),
|
||||
.field("notifications", Notifications.self, arguments: ["pagination": ["first": 1]]),
|
||||
] }
|
||||
|
||||
/// Get user notification count
|
||||
public var notificationCount: Int { __data["notificationCount"] }
|
||||
/// Get current user notifications
|
||||
public var notifications: Notifications { __data["notifications"] }
|
||||
|
||||
/// CurrentUser.Notifications
|
||||
///
|
||||
/// Parent Type: `PaginatedNotificationObjectType`
|
||||
public struct Notifications: AffineGraphQL.SelectionSet {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.PaginatedNotificationObjectType }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("__typename", String.self),
|
||||
.field("totalCount", Int.self),
|
||||
] }
|
||||
|
||||
public var totalCount: Int { __data["totalCount"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public class ServerConfigQuery: GraphQLQuery {
|
||||
public static let operationName: String = "serverConfig"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query serverConfig { serverConfig { __typename version baseUrl name features type initialized credentialsRequirement { __typename ...CredentialsRequirements } } }"#,
|
||||
#"query serverConfig { serverConfig { __typename version baseUrl name features type initialized calendarProviders credentialsRequirement { __typename ...CredentialsRequirements } } }"#,
|
||||
fragments: [CredentialsRequirements.self, PasswordLimits.self]
|
||||
))
|
||||
|
||||
@@ -41,6 +41,7 @@ public class ServerConfigQuery: GraphQLQuery {
|
||||
.field("features", [GraphQLEnum<AffineGraphQL.ServerFeature>].self),
|
||||
.field("type", GraphQLEnum<AffineGraphQL.ServerDeploymentType>.self),
|
||||
.field("initialized", Bool.self),
|
||||
.field("calendarProviders", [GraphQLEnum<AffineGraphQL.CalendarProviderType>].self),
|
||||
.field("credentialsRequirement", CredentialsRequirement.self),
|
||||
] }
|
||||
|
||||
@@ -56,6 +57,7 @@ public class ServerConfigQuery: GraphQLQuery {
|
||||
public var type: GraphQLEnum<AffineGraphQL.ServerDeploymentType> { __data["type"] }
|
||||
/// whether server has been initialized
|
||||
public var initialized: Bool { __data["initialized"] }
|
||||
public var calendarProviders: [GraphQLEnum<AffineGraphQL.CalendarProviderType>] { __data["calendarProviders"] }
|
||||
/// credentials requirement
|
||||
public var credentialsRequirement: CredentialsRequirement { __data["credentialsRequirement"] }
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class ValidateConfigMutation: GraphQLMutation {
|
||||
public class ValidateConfigQuery: GraphQLQuery {
|
||||
public static let operationName: String = "validateConfig"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"mutation validateConfig($updates: [UpdateAppConfigInput!]!) { validateAppConfig(updates: $updates) { __typename module key value valid error } }"#
|
||||
#"query validateConfig($updates: [UpdateAppConfigInput!]!) { validateAppConfig(updates: $updates) { __typename module key value valid error } }"#
|
||||
))
|
||||
|
||||
public var updates: [UpdateAppConfigInput]
|
||||
@@ -22,7 +22,7 @@ public class ValidateConfigMutation: GraphQLMutation {
|
||||
public let __data: DataDict
|
||||
public init(_dataDict: DataDict) { __data = _dataDict }
|
||||
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Mutation }
|
||||
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
|
||||
public static var __selections: [ApolloAPI.Selection] { [
|
||||
.field("validateAppConfig", [ValidateAppConfig].self, arguments: ["updates": .variable("updates")]),
|
||||
] }
|
||||
@@ -0,0 +1,101 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
@_exported import ApolloAPI
|
||||
|
||||
public class WorkspaceCalendarsQuery: GraphQLQuery {
|
||||
public static let operationName: String = "workspaceCalendars"
|
||||
public static let operationDocument: ApolloAPI.OperationDocument = .init(
|
||||
definition: .init(
|
||||
#"query workspaceCalendars($workspaceId: String!) { workspace(id: $workspaceId) { __typename calendars { __typename id workspaceId createdByUserId displayNameOverride colorOverride enabled items { __typename id subscriptionId sortOrder colorOverride enabled } } } }"#
|
||||
))
|
||||
|
||||
public var workspaceId: String
|
||||
|
||||
public init(workspaceId: String) {
|
||||
self.workspaceId = workspaceId
|
||||
}
|
||||
|
||||
public var __variables: Variables? { ["workspaceId": workspaceId] }
|
||||
|
||||
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("workspace", Workspace.self, arguments: ["id": .variable("workspaceId")]),
|
||||
] }
|
||||
|
||||
/// Get workspace by id
|
||||
public var workspace: Workspace { __data["workspace"] }
|
||||
|
||||
/// Workspace
|
||||
///
|
||||
/// Parent Type: `WorkspaceType`
|
||||
public struct Workspace: 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("calendars", [Calendar].self),
|
||||
] }
|
||||
|
||||
public var calendars: [Calendar] { __data["calendars"] }
|
||||
|
||||
/// Workspace.Calendar
|
||||
///
|
||||
/// Parent Type: `WorkspaceCalendarObjectType`
|
||||
public struct Calendar: 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"] }
|
||||
|
||||
/// Workspace.Calendar.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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public typealias JSON = JSONObject
|
||||
/// The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
|
||||
public typealias JSON = String
|
||||
|
||||
@@ -7,35 +7,5 @@
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public typealias JSONObject = CustomJSON
|
||||
|
||||
public enum CustomJSON: CustomScalarType, Hashable {
|
||||
case dictionary([String: AnyHashable])
|
||||
case array([AnyHashable])
|
||||
|
||||
public init(_jsonValue value: JSONValue) throws {
|
||||
if let dict = value as? [String: AnyHashable] {
|
||||
self = .dictionary(dict)
|
||||
} else if let array = value as? [AnyHashable] {
|
||||
self = .array(array)
|
||||
} else {
|
||||
throw JSONDecodingError.couldNotConvert(value: value, to: CustomJSON.self)
|
||||
}
|
||||
}
|
||||
|
||||
public var _jsonValue: JSONValue {
|
||||
switch self {
|
||||
case let .dictionary(json as AnyHashable),
|
||||
let .array(json as AnyHashable):
|
||||
json
|
||||
}
|
||||
}
|
||||
|
||||
public static func == (lhs: CustomJSON, rhs: CustomJSON) -> Bool {
|
||||
lhs._jsonValue == rhs._jsonValue
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(_jsonValue)
|
||||
}
|
||||
}
|
||||
/// The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
|
||||
public typealias JSONObject = String
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public enum AdminWorkspaceSort: String, EnumType {
|
||||
case blobCount = "BlobCount"
|
||||
case blobSize = "BlobSize"
|
||||
case createdAt = "CreatedAt"
|
||||
case memberCount = "MemberCount"
|
||||
case publicPageCount = "PublicPageCount"
|
||||
case snapshotCount = "SnapshotCount"
|
||||
case snapshotSize = "SnapshotSize"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public enum CalendarProviderType: String, EnumType {
|
||||
case calDAV = "CalDAV"
|
||||
case google = "Google"
|
||||
}
|
||||
@@ -11,20 +11,13 @@ public struct AddContextFileInput: InputObject {
|
||||
}
|
||||
|
||||
public init(
|
||||
blobId: GraphQLNullable<String> = nil,
|
||||
contextId: String
|
||||
) {
|
||||
__data = InputDict([
|
||||
"blobId": blobId,
|
||||
"contextId": contextId
|
||||
])
|
||||
}
|
||||
|
||||
public var blobId: GraphQLNullable<String> {
|
||||
get { __data["blobId"] }
|
||||
set { __data["blobId"] = newValue }
|
||||
}
|
||||
|
||||
public var contextId: String {
|
||||
get { __data["contextId"] }
|
||||
set { __data["contextId"] = newValue }
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public struct AdminUpdateWorkspaceInput: InputObject {
|
||||
public private(set) var __data: InputDict
|
||||
|
||||
public init(_ data: InputDict) {
|
||||
__data = data
|
||||
}
|
||||
|
||||
public init(
|
||||
avatarKey: GraphQLNullable<String> = nil,
|
||||
enableAi: GraphQLNullable<Bool> = nil,
|
||||
enableDocEmbedding: GraphQLNullable<Bool> = nil,
|
||||
enableSharing: GraphQLNullable<Bool> = nil,
|
||||
enableUrlPreview: GraphQLNullable<Bool> = nil,
|
||||
features: GraphQLNullable<[GraphQLEnum<FeatureType>]> = nil,
|
||||
id: String,
|
||||
name: GraphQLNullable<String> = nil,
|
||||
`public`: GraphQLNullable<Bool> = nil
|
||||
) {
|
||||
__data = InputDict([
|
||||
"avatarKey": avatarKey,
|
||||
"enableAi": enableAi,
|
||||
"enableDocEmbedding": enableDocEmbedding,
|
||||
"enableSharing": enableSharing,
|
||||
"enableUrlPreview": enableUrlPreview,
|
||||
"features": features,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"public": `public`
|
||||
])
|
||||
}
|
||||
|
||||
public var avatarKey: GraphQLNullable<String> {
|
||||
get { __data["avatarKey"] }
|
||||
set { __data["avatarKey"] = newValue }
|
||||
}
|
||||
|
||||
public var enableAi: GraphQLNullable<Bool> {
|
||||
get { __data["enableAi"] }
|
||||
set { __data["enableAi"] = newValue }
|
||||
}
|
||||
|
||||
public var enableDocEmbedding: GraphQLNullable<Bool> {
|
||||
get { __data["enableDocEmbedding"] }
|
||||
set { __data["enableDocEmbedding"] = newValue }
|
||||
}
|
||||
|
||||
public var enableSharing: GraphQLNullable<Bool> {
|
||||
get { __data["enableSharing"] }
|
||||
set { __data["enableSharing"] = newValue }
|
||||
}
|
||||
|
||||
public var enableUrlPreview: GraphQLNullable<Bool> {
|
||||
get { __data["enableUrlPreview"] }
|
||||
set { __data["enableUrlPreview"] = newValue }
|
||||
}
|
||||
|
||||
public var features: GraphQLNullable<[GraphQLEnum<FeatureType>]> {
|
||||
get { __data["features"] }
|
||||
set { __data["features"] = newValue }
|
||||
}
|
||||
|
||||
public var id: String {
|
||||
get { __data["id"] }
|
||||
set { __data["id"] = newValue }
|
||||
}
|
||||
|
||||
public var name: GraphQLNullable<String> {
|
||||
get { __data["name"] }
|
||||
set { __data["name"] = newValue }
|
||||
}
|
||||
|
||||
public var `public`: GraphQLNullable<Bool> {
|
||||
get { __data["public"] }
|
||||
set { __data["public"] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public struct LinkCalDAVAccountInput: InputObject {
|
||||
public private(set) var __data: InputDict
|
||||
|
||||
public init(_ data: InputDict) {
|
||||
__data = data
|
||||
}
|
||||
|
||||
public init(
|
||||
displayName: GraphQLNullable<String> = nil,
|
||||
password: String,
|
||||
providerPresetId: String,
|
||||
username: String
|
||||
) {
|
||||
__data = InputDict([
|
||||
"displayName": displayName,
|
||||
"password": password,
|
||||
"providerPresetId": providerPresetId,
|
||||
"username": username
|
||||
])
|
||||
}
|
||||
|
||||
public var displayName: GraphQLNullable<String> {
|
||||
get { __data["displayName"] }
|
||||
set { __data["displayName"] = newValue }
|
||||
}
|
||||
|
||||
public var password: String {
|
||||
get { __data["password"] }
|
||||
set { __data["password"] = newValue }
|
||||
}
|
||||
|
||||
public var providerPresetId: String {
|
||||
get { __data["providerPresetId"] }
|
||||
set { __data["providerPresetId"] = newValue }
|
||||
}
|
||||
|
||||
public var username: String {
|
||||
get { __data["username"] }
|
||||
set { __data["username"] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public struct LinkCalendarAccountInput: InputObject {
|
||||
public private(set) var __data: InputDict
|
||||
|
||||
public init(_ data: InputDict) {
|
||||
__data = data
|
||||
}
|
||||
|
||||
public init(
|
||||
provider: GraphQLEnum<CalendarProviderType>,
|
||||
redirectUri: GraphQLNullable<String> = nil
|
||||
) {
|
||||
__data = InputDict([
|
||||
"provider": provider,
|
||||
"redirectUri": redirectUri
|
||||
])
|
||||
}
|
||||
|
||||
public var provider: GraphQLEnum<CalendarProviderType> {
|
||||
get { __data["provider"] }
|
||||
set { __data["provider"] = newValue }
|
||||
}
|
||||
|
||||
public var redirectUri: GraphQLNullable<String> {
|
||||
get { __data["redirectUri"] }
|
||||
set { __data["redirectUri"] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -11,20 +11,34 @@ public struct ListUserInput: InputObject {
|
||||
}
|
||||
|
||||
public init(
|
||||
features: GraphQLNullable<[GraphQLEnum<FeatureType>]> = nil,
|
||||
first: GraphQLNullable<Int> = nil,
|
||||
keyword: GraphQLNullable<String> = nil,
|
||||
skip: GraphQLNullable<Int> = nil
|
||||
) {
|
||||
__data = InputDict([
|
||||
"features": features,
|
||||
"first": first,
|
||||
"keyword": keyword,
|
||||
"skip": skip
|
||||
])
|
||||
}
|
||||
|
||||
public var features: GraphQLNullable<[GraphQLEnum<FeatureType>]> {
|
||||
get { __data["features"] }
|
||||
set { __data["features"] = newValue }
|
||||
}
|
||||
|
||||
public var first: GraphQLNullable<Int> {
|
||||
get { __data["first"] }
|
||||
set { __data["first"] = newValue }
|
||||
}
|
||||
|
||||
public var keyword: GraphQLNullable<String> {
|
||||
get { __data["keyword"] }
|
||||
set { __data["keyword"] = newValue }
|
||||
}
|
||||
|
||||
public var skip: GraphQLNullable<Int> {
|
||||
get { __data["skip"] }
|
||||
set { __data["skip"] = newValue }
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public struct ListWorkspaceInput: InputObject {
|
||||
public private(set) var __data: InputDict
|
||||
|
||||
public init(_ data: InputDict) {
|
||||
__data = data
|
||||
}
|
||||
|
||||
public init(
|
||||
enableAi: GraphQLNullable<Bool> = nil,
|
||||
enableDocEmbedding: GraphQLNullable<Bool> = nil,
|
||||
enableSharing: GraphQLNullable<Bool> = nil,
|
||||
enableUrlPreview: GraphQLNullable<Bool> = nil,
|
||||
features: GraphQLNullable<[GraphQLEnum<FeatureType>]> = nil,
|
||||
first: Int? = nil,
|
||||
keyword: GraphQLNullable<String> = nil,
|
||||
orderBy: GraphQLNullable<GraphQLEnum<AdminWorkspaceSort>> = nil,
|
||||
`public`: GraphQLNullable<Bool> = nil,
|
||||
skip: Int? = nil
|
||||
) {
|
||||
__data = InputDict([
|
||||
"enableAi": enableAi,
|
||||
"enableDocEmbedding": enableDocEmbedding,
|
||||
"enableSharing": enableSharing,
|
||||
"enableUrlPreview": enableUrlPreview,
|
||||
"features": features,
|
||||
"first": first,
|
||||
"keyword": keyword,
|
||||
"orderBy": orderBy,
|
||||
"public": `public`,
|
||||
"skip": skip
|
||||
])
|
||||
}
|
||||
|
||||
public var enableAi: GraphQLNullable<Bool> {
|
||||
get { __data["enableAi"] }
|
||||
set { __data["enableAi"] = newValue }
|
||||
}
|
||||
|
||||
public var enableDocEmbedding: GraphQLNullable<Bool> {
|
||||
get { __data["enableDocEmbedding"] }
|
||||
set { __data["enableDocEmbedding"] = newValue }
|
||||
}
|
||||
|
||||
public var enableSharing: GraphQLNullable<Bool> {
|
||||
get { __data["enableSharing"] }
|
||||
set { __data["enableSharing"] = newValue }
|
||||
}
|
||||
|
||||
public var enableUrlPreview: GraphQLNullable<Bool> {
|
||||
get { __data["enableUrlPreview"] }
|
||||
set { __data["enableUrlPreview"] = newValue }
|
||||
}
|
||||
|
||||
public var features: GraphQLNullable<[GraphQLEnum<FeatureType>]> {
|
||||
get { __data["features"] }
|
||||
set { __data["features"] = newValue }
|
||||
}
|
||||
|
||||
public var first: Int? {
|
||||
get { __data["first"] }
|
||||
set { __data["first"] = newValue }
|
||||
}
|
||||
|
||||
public var keyword: GraphQLNullable<String> {
|
||||
get { __data["keyword"] }
|
||||
set { __data["keyword"] = newValue }
|
||||
}
|
||||
|
||||
public var orderBy: GraphQLNullable<GraphQLEnum<AdminWorkspaceSort>> {
|
||||
get { __data["orderBy"] }
|
||||
set { __data["orderBy"] = newValue }
|
||||
}
|
||||
|
||||
public var `public`: GraphQLNullable<Bool> {
|
||||
get { __data["public"] }
|
||||
set { __data["public"] = newValue }
|
||||
}
|
||||
|
||||
public var skip: Int? {
|
||||
get { __data["skip"] }
|
||||
set { __data["skip"] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import ApolloAPI
|
||||
|
||||
public struct UpdateWorkspaceCalendarsInput: InputObject {
|
||||
public private(set) var __data: InputDict
|
||||
|
||||
public init(_ data: InputDict) {
|
||||
__data = data
|
||||
}
|
||||
|
||||
public init(
|
||||
items: [WorkspaceCalendarItemInput],
|
||||
workspaceId: String
|
||||
) {
|
||||
__data = InputDict([
|
||||
"items": items,
|
||||
"workspaceId": workspaceId
|
||||
])
|
||||
}
|
||||
|
||||
public var items: [WorkspaceCalendarItemInput] {
|
||||
get { __data["items"] }
|
||||
set { __data["items"] = newValue }
|
||||
}
|
||||
|
||||
public var workspaceId: String {
|
||||
get { __data["workspaceId"] }
|
||||
set { __data["workspaceId"] = newValue }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user