Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b4b8a7f24 | ||
|
|
a7397b74a0 | ||
|
|
6e4cc3a174 | ||
|
|
e9ce211c16 | ||
|
|
59bbc43fc4 | ||
|
|
1534a02953 | ||
|
|
55f06cb7a9 | ||
|
|
e21ad18453 | ||
|
|
1b69f7841c | ||
|
|
b2d6876347 | ||
|
|
d5c08a3c80 | ||
|
|
f96218820d | ||
|
|
bec7227cff | ||
|
|
4ece2ff959 | ||
|
|
59b36f67c8 | ||
|
|
38f49d617f | ||
|
|
5be41553be | ||
|
|
2a74fbcfe6 | ||
|
|
81f0100e78 | ||
|
|
cb2a7c9f98 | ||
|
|
2da3744700 | ||
|
|
0daabd2f3c | ||
|
|
be4d60ca0d | ||
|
|
e852d25ad8 | ||
|
|
9c68983ff3 | ||
|
|
df51cfda09 | ||
|
|
0a40e67e4f | ||
|
|
2bf83df2b3 | ||
|
|
03a068eb0b | ||
|
|
4348355868 | ||
|
|
15de98d61c | ||
|
|
80255f98de | ||
|
|
9595011f4e | ||
|
|
357988c248 | ||
|
|
655b0735bb | ||
|
|
28e3025c83 | ||
|
|
460b9968e6 | ||
|
|
4e43ee1904 | ||
|
|
4ae9aac50e | ||
|
|
b9b7ce60ce | ||
|
|
5396f07ad2 | ||
|
|
a5193477bb | ||
|
|
b59bda9210 | ||
|
|
3aabe7182d | ||
|
|
2b602f10cb | ||
|
|
de3a1dfd15 | ||
|
|
deae07b83e | ||
|
|
a5a8e8f125 | ||
|
|
e5fd455e93 | ||
|
|
d4c5180761 | ||
|
|
7b133e60fc | ||
|
|
215910cd24 | ||
|
|
e211fdce13 | ||
|
|
b1afc455e9 | ||
|
|
df7e70e9f6 | ||
|
|
6e8b0aa467 | ||
|
|
8bd1df539f | ||
|
|
1c0992ed58 | ||
|
|
f7174727d4 | ||
|
|
91368a04b5 | ||
|
|
967a647342 | ||
|
|
9ab1ed8ccb | ||
|
|
30e24294cb | ||
|
|
7d55369dd8 | ||
|
|
37864a07ce | ||
|
|
6cb81d7b7c | ||
|
|
14d62db688 | ||
|
|
70e6a5c545 | ||
|
|
724f1ce362 | ||
|
|
a379e805bc | ||
|
|
6033e128f0 | ||
|
|
f83bf109fc | ||
|
|
eab1519c3e | ||
|
|
7dd4a52c61 | ||
|
|
07f22a9913 | ||
|
|
c398c0cb19 | ||
|
|
c07158935d | ||
|
|
2eeac030a5 | ||
|
|
95c4da5e6f | ||
|
|
40f7e8ed5a | ||
|
|
349705768a | ||
|
|
701b55dc45 | ||
|
|
b4fe7fa03d | ||
|
|
2f9f2c1ba7 | ||
|
|
15dfd194a1 | ||
|
|
8c0d451630 | ||
|
|
516cf1c9e5 | ||
|
|
2cbcf2968c | ||
|
|
163a80a8ee | ||
|
|
127d0319ad | ||
|
|
e51b0f90de | ||
|
|
b67a576b19 | ||
|
|
39f2789e13 | ||
|
|
c9b1591dba | ||
|
|
0ffc4cb4eb | ||
|
|
3b76ee9078 | ||
|
|
e27dddef61 | ||
|
|
3f05ac8f62 | ||
|
|
a5cb427a8a | ||
|
|
dc910e46b2 | ||
|
|
303e2b30ac | ||
|
|
492b9fddb5 | ||
|
|
4cef37d9a7 | ||
|
|
990c149c75 | ||
|
|
b64514c7e4 | ||
|
|
971c0e1ea9 | ||
|
|
2557d90c0d | ||
|
|
431e03e3c1 | ||
|
|
1c90d935be | ||
|
|
1309a94439 | ||
|
|
02199e3a73 | ||
|
|
9119e4a926 | ||
|
|
14ba35c728 | ||
|
|
e88423c83e | ||
|
|
95194930f9 | ||
|
|
360f3df99e | ||
|
|
3edcc08543 | ||
|
|
1c8913eb79 | ||
|
|
4151c48180 |
No files matched your search
@@ -1,72 +0,0 @@
|
||||
name: Deploy Web (Test)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- build/test
|
||||
env:
|
||||
NODE_VERSION: "18.16.0"
|
||||
PNPM_VERSION: "8.5.0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }}
|
||||
REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }}
|
||||
REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }}
|
||||
SSL_CERTIFICATE: ${{ secrets.WEB_TEST_SSL_CERTIFICATE }}
|
||||
SSL_CERTIFICATE_KEY: ${{ secrets.WEB_TEST_SSL_CERTIFICATE_KEY }}
|
||||
ENV_FILE: test.env
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- name: Node_modules cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: frontend/appflowy_web_app/node_modules
|
||||
key: node-modules-${{ runner.os }}
|
||||
- name: install frontend dependencies
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
pnpm install
|
||||
- name: copy env file
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
cp ${{ env.ENV_FILE }} .env
|
||||
- name: test and lint
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
pnpm run lint
|
||||
- name: build
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
pnpm run build
|
||||
- name: generate SSL certificate
|
||||
run: |
|
||||
echo "${{ env.SSL_CERTIFICATE }}" > nginx-signed.crt
|
||||
echo "${{ env.SSL_CERTIFICATE_KEY }}" > nginx-signed.key
|
||||
- name: Deploy to EC2
|
||||
uses: easingthemes/ssh-deploy@main
|
||||
with:
|
||||
SSH_PRIVATE_KEY: ${{ env.SSH_PRIVATE_KEY }}
|
||||
ARGS: "-rlgoDzvc -i"
|
||||
SOURCE: "frontend/appflowy_web_app/dist frontend/appflowy_web_app/server.cjs frontend/appflowy_web_app/start.sh frontend/appflowy_web_app/Dockerfile frontend/appflowy_web_app/nginx.conf frontend/appflowy_web_app/.env nginx-signed.crt nginx-signed.key"
|
||||
REMOTE_HOST: ${{ env.REMOTE_HOST }}
|
||||
REMOTE_USER: ${{ env.REMOTE_USER }}
|
||||
EXCLUDE: "frontend/appflowy_web_app/dist/, frontend/appflowy_web_app/node_modules/"
|
||||
SCRIPT_AFTER: |
|
||||
docker build -t appflowy-web-app .
|
||||
docker rm -f appflowy-web-app || true
|
||||
docker run -d -p 80:80 -p 443:443 --name appflowy-web-app appflowy-web-app
|
||||
@@ -0,0 +1,102 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Environment'
|
||||
required: true
|
||||
default: 'test'
|
||||
port:
|
||||
description: 'Docker Port'
|
||||
required: true
|
||||
default: '30012'
|
||||
api_base_url:
|
||||
description: 'API Base URL'
|
||||
required: true
|
||||
default: 'https://test.appflowy.cloud'
|
||||
api_gotrue_url:
|
||||
description: 'GoTrue API URL'
|
||||
required: true
|
||||
default: 'https://test.appflowy.cloud/gotrue'
|
||||
ws_url:
|
||||
description: 'Websocket URL'
|
||||
required: true
|
||||
default: 'wss://test.appflowy.cloud/ws/v1'
|
||||
env:
|
||||
NODE_VERSION: "18.16.0"
|
||||
PNPM_VERSION: "8.5.0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- name: Node_modules cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: frontend/appflowy_web_app/node_modules
|
||||
key: node-modules-${{ runner.os }}
|
||||
- name: install frontend dependencies
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
pnpm install
|
||||
- name: generate env file
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
echo "AF_BASE_URL=${{ github.event.inputs.api_base_url }}" >> .env
|
||||
echo "AF_GOTRUE_URL=${{ github.event.inputs.api_gotrue_url }}" >> .env
|
||||
echo "AF_WS_URL=${{ github.event.inputs.ws_url }}" >> .env
|
||||
- name: test and lint
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
pnpm run lint
|
||||
- name: build
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
pnpm run build
|
||||
- name: Archive build output
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
tar -czf build-output.tar.gz dist deploy .env
|
||||
|
||||
- name: Deploy to EC2 (Test)
|
||||
if: github.event.inputs.environment == 'test'
|
||||
uses: easingthemes/ssh-deploy@main
|
||||
with:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }}
|
||||
ARGS: "-rlgoDzvc -i"
|
||||
SOURCE: frontend/appflowy_web_app/build-output.tar.gz frontend/appflowy_web_app/deploy/deploy.sh
|
||||
TARGET: /home/${{ secrets.WEB_TEST_REMOTE_USER }}/appflowy-web-app
|
||||
REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }}
|
||||
REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }}
|
||||
SCRIPT_AFTER: |
|
||||
cd appflowy-web-app
|
||||
chmod +x deploy.sh
|
||||
sh deploy.sh ${{ github.event.inputs.port }}
|
||||
- name: Deploy to EC2 (Prod)
|
||||
if: github.event.inputs.environment == 'prod'
|
||||
uses: easingthemes/ssh-deploy@main
|
||||
with:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.WEB_PROD_SSH_PRIVATE_KEY }}
|
||||
ARGS: "-rlgoDzvc -i"
|
||||
TARGET: /home/${{ secrets.WEB_PROD_REMOTE_USER }}/appflowy-web-app
|
||||
SOURCE: frontend/appflowy_web_app/build-output.tar.gz frontend/appflowy_web_app/deploy/deploy.sh
|
||||
REMOTE_HOST: ${{ secrets.WEB_PROD_REMOTE_HOST }}
|
||||
REMOTE_USER: ${{ secrets.WEB_PROD_REMOTE_USER }}
|
||||
SCRIPT_AFTER: |
|
||||
cd appflowy-web-app
|
||||
chmod +x deploy.sh
|
||||
sh deploy.sh ${{ github.event.inputs.port }}
|
||||
@@ -116,11 +116,11 @@
|
||||
{
|
||||
"name": "AF-desktop: Debug Rust",
|
||||
"type": "lldb",
|
||||
"request": "attach",
|
||||
"pid": "${command:pickMyProcess}"
|
||||
// "request": "attach",
|
||||
// "pid": "${command:pickMyProcess}"
|
||||
// To launch the application directly, use the following configuration:
|
||||
// "request": "launch",
|
||||
// "program": "[YOUR_APPLICATION_PATH]",
|
||||
"request": "launch",
|
||||
"program": "/Users/lucas.xu/Desktop/development/AppFlowy/frontend/appflowy_flutter/build/macos/Build/Products/Debug/AppFlowy.app",
|
||||
},
|
||||
{
|
||||
// https://tauri.app/v1/guides/debugging/vs-code
|
||||
|
||||
@@ -1,58 +1,177 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/application/export/document_exporter.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_listener.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'document_share_bloc.freezed.dart';
|
||||
|
||||
// todo: replace with beta
|
||||
const _url = 'https://beta.appflowy.com';
|
||||
|
||||
class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
|
||||
DocumentShareBloc({
|
||||
required this.view,
|
||||
}) : super(const DocumentShareState.initial()) {
|
||||
}) : super(DocumentShareState.initial()) {
|
||||
on<DocumentShareEvent>((event, emit) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
viewListener = ViewListener(viewId: view.id)
|
||||
..start(
|
||||
onViewUpdated: (value) {
|
||||
add(DocumentShareEvent.updateViewName(value.name));
|
||||
},
|
||||
);
|
||||
|
||||
final publishInfo = await ViewBackendService.getPublishInfo(view);
|
||||
final enablePublish =
|
||||
await UserBackendService.getCurrentUserProfile().fold(
|
||||
(v) => v.authenticator == AuthenticatorPB.AppFlowyCloud,
|
||||
(p) => false,
|
||||
);
|
||||
publishInfo.fold((s) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isPublished: true,
|
||||
url: '$_url/${s.namespace}/${s.publishName}',
|
||||
viewName: view.name,
|
||||
enablePublish: enablePublish,
|
||||
),
|
||||
);
|
||||
}, (f) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isPublished: false,
|
||||
url: '',
|
||||
viewName: view.name,
|
||||
enablePublish: enablePublish,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
share: (type, path) async {
|
||||
if (DocumentShareType.unimplemented.contains(type)) {
|
||||
Log.error('DocumentShareType $type is not implemented');
|
||||
return;
|
||||
}
|
||||
|
||||
emit(const DocumentShareState.loading());
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
final exporter = DocumentExporter(view);
|
||||
final FlowyResult<ExportDataPB, FlowyError> result =
|
||||
await exporter.export(type.exportType).then((value) {
|
||||
return value.fold(
|
||||
(s) {
|
||||
if (path != null) {
|
||||
switch (type) {
|
||||
case DocumentShareType.markdown:
|
||||
return FlowyResult.success(_saveMarkdownToPath(s, path));
|
||||
case DocumentShareType.html:
|
||||
return FlowyResult.success(_saveHTMLToPath(s, path));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FlowyResult.failure(FlowyError());
|
||||
},
|
||||
(f) => FlowyResult.failure(f),
|
||||
final result = await _export(type, path);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
exportResult: result,
|
||||
),
|
||||
);
|
||||
},
|
||||
publish: (nameSpace, publishName) async {
|
||||
// set space name
|
||||
try {
|
||||
final result =
|
||||
await ViewBackendService.getPublishNameSpace().getOrThrow();
|
||||
|
||||
await ViewBackendService.publish(
|
||||
view,
|
||||
name: publishName,
|
||||
).getOrThrow();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isPublished: true,
|
||||
publishResult: FlowySuccess(null),
|
||||
unpublishResult: null,
|
||||
url: '$_url/${result.namespace}/$publishName',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Log.error('publish error: $e');
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isPublished: false,
|
||||
publishResult: FlowyResult.failure(
|
||||
FlowyError(msg: 'publish error: $e'),
|
||||
),
|
||||
unpublishResult: null,
|
||||
url: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
unPublish: () async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
publishResult: null,
|
||||
unpublishResult: null,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await ViewBackendService.unpublish(view);
|
||||
final isPublished = !result.isSuccess;
|
||||
result.onFailure((f) {
|
||||
Log.error('unpublish error: $f');
|
||||
});
|
||||
|
||||
emit(DocumentShareState.finish(result));
|
||||
emit(
|
||||
state.copyWith(
|
||||
isPublished: isPublished,
|
||||
publishResult: null,
|
||||
unpublishResult: result,
|
||||
url: result.fold((_) => '', (_) => state.url),
|
||||
),
|
||||
);
|
||||
},
|
||||
updateViewName: (viewName) async {
|
||||
emit(state.copyWith(viewName: viewName));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
final ViewPB view;
|
||||
late final ViewListener viewListener;
|
||||
|
||||
late final exporter = DocumentExporter(view);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await viewListener.stop();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<FlowyResult<ExportDataPB, FlowyError>> _export(
|
||||
DocumentShareType type,
|
||||
String? path,
|
||||
) async {
|
||||
final result = await exporter.export(type.exportType);
|
||||
return result.fold(
|
||||
(s) {
|
||||
if (path != null) {
|
||||
switch (type) {
|
||||
case DocumentShareType.markdown:
|
||||
return FlowySuccess(_saveMarkdownToPath(s, path));
|
||||
case DocumentShareType.html:
|
||||
return FlowySuccess(_saveHTMLToPath(s, path));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FlowyResult.failure(FlowyError());
|
||||
},
|
||||
(f) => FlowyResult.failure(f),
|
||||
);
|
||||
}
|
||||
|
||||
ExportDataPB _saveMarkdownToPath(String markdown, String path) {
|
||||
File(path).writeAsStringSync(markdown);
|
||||
@@ -93,15 +212,38 @@ enum DocumentShareType {
|
||||
|
||||
@freezed
|
||||
class DocumentShareEvent with _$DocumentShareEvent {
|
||||
const factory DocumentShareEvent.share(DocumentShareType type, String? path) =
|
||||
Share;
|
||||
const factory DocumentShareEvent.initial() = _Initial;
|
||||
const factory DocumentShareEvent.share(
|
||||
DocumentShareType type,
|
||||
String? path,
|
||||
) = _Share;
|
||||
const factory DocumentShareEvent.publish(
|
||||
String nameSpace,
|
||||
String pageId,
|
||||
) = _Publish;
|
||||
const factory DocumentShareEvent.unPublish() = _UnPublish;
|
||||
const factory DocumentShareEvent.updateViewName(String name) =
|
||||
_UpdateViewName;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class DocumentShareState with _$DocumentShareState {
|
||||
const factory DocumentShareState.initial() = _Initial;
|
||||
const factory DocumentShareState.loading() = _Loading;
|
||||
const factory DocumentShareState.finish(
|
||||
FlowyResult<ExportDataPB, FlowyError> successOrFail,
|
||||
) = _Finish;
|
||||
const factory DocumentShareState({
|
||||
required bool isPublished,
|
||||
required bool isLoading,
|
||||
required String url,
|
||||
required String viewName,
|
||||
required bool enablePublish,
|
||||
FlowyResult<ExportDataPB, FlowyError>? exportResult,
|
||||
FlowyResult<void, FlowyError>? publishResult,
|
||||
FlowyResult<void, FlowyError>? unpublishResult,
|
||||
}) = _DocumentShareState;
|
||||
|
||||
factory DocumentShareState.initial() => const DocumentShareState(
|
||||
isLoading: false,
|
||||
isPublished: false,
|
||||
enablePublish: true,
|
||||
url: '',
|
||||
viewName: '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/export/document_exporter.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ExportTab extends StatelessWidget {
|
||||
const ExportTab({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const VSpace(10),
|
||||
_ExportButton(
|
||||
title: LocaleKeys.shareAction_html.tr(),
|
||||
svg: FlowySvgs.export_html_s,
|
||||
onTap: () => _exportHTML(context),
|
||||
),
|
||||
const VSpace(10),
|
||||
_ExportButton(
|
||||
title: LocaleKeys.shareAction_markdown.tr(),
|
||||
svg: FlowySvgs.export_markdown_s,
|
||||
onTap: () => _exportMarkdown(context),
|
||||
),
|
||||
const VSpace(10),
|
||||
_ExportButton(
|
||||
title: LocaleKeys.shareAction_clipboard.tr(),
|
||||
svg: FlowySvgs.duplicate_s,
|
||||
onTap: () => _exportToClipboard(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportHTML(BuildContext context) async {
|
||||
final viewName = context.read<DocumentShareBloc>().state.viewName;
|
||||
final exportPath = await getIt<FilePickerService>().saveFile(
|
||||
dialogTitle: '',
|
||||
fileName: '${viewName.toFileName()}.html',
|
||||
);
|
||||
if (context.mounted && exportPath != null) {
|
||||
context.read<DocumentShareBloc>().add(
|
||||
DocumentShareEvent.share(
|
||||
DocumentShareType.html,
|
||||
exportPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportMarkdown(BuildContext context) async {
|
||||
final viewName = context.read<DocumentShareBloc>().state.viewName;
|
||||
final exportPath = await getIt<FilePickerService>().saveFile(
|
||||
dialogTitle: '',
|
||||
fileName: '${viewName.toFileName()}.md',
|
||||
);
|
||||
if (context.mounted && exportPath != null) {
|
||||
context.read<DocumentShareBloc>().add(
|
||||
DocumentShareEvent.share(
|
||||
DocumentShareType.markdown,
|
||||
exportPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportToClipboard(BuildContext context) async {
|
||||
final documentExporter =
|
||||
DocumentExporter(context.read<DocumentShareBloc>().view);
|
||||
final result = await documentExporter.export(DocumentExportType.markdown);
|
||||
result.fold(
|
||||
(markdown) {
|
||||
getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(plainText: markdown),
|
||||
);
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
);
|
||||
},
|
||||
(error) => showToastNotification(context, message: error.msg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExportButton extends StatelessWidget {
|
||||
const _ExportButton({
|
||||
required this.title,
|
||||
required this.svg,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final FlowySvgData svg;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).isLightMode
|
||||
? const Color(0x1E14171B)
|
||||
: Colors.white.withOpacity(0.1);
|
||||
final radius = BorderRadius.circular(10.0);
|
||||
return FlowyButton(
|
||||
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
||||
iconPadding: 12,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: color,
|
||||
),
|
||||
borderRadius: radius,
|
||||
),
|
||||
radius: radius,
|
||||
text: FlowyText(title),
|
||||
leftIcon: FlowySvg(svg),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ShareMenuColors {
|
||||
static Color borderColor(BuildContext context) {
|
||||
final borderColor = Theme.of(context).isLightMode
|
||||
? const Color(0x1E14171B)
|
||||
: Colors.white.withOpacity(0.1);
|
||||
return borderColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
String replaceInvalidChars(String input) {
|
||||
final RegExp invalidCharsRegex = RegExp('[^a-zA-Z0-9-]');
|
||||
return input.replaceAll(invalidCharsRegex, '-');
|
||||
}
|
||||
|
||||
Future<String> generateNameSpace() async {
|
||||
return '';
|
||||
}
|
||||
|
||||
// The backend limits the publish name to a maximum of 120 characters.
|
||||
// If the combined length of the ID and the name exceeds 120 characters,
|
||||
// we will truncate the name to ensure the final result is within the limit.
|
||||
// The name should only contain alphanumeric characters and hyphens.
|
||||
Future<String> generatePublishName(String id, String name) async {
|
||||
if (name.length >= 120 - id.length) {
|
||||
name = name.substring(0, 120 - id.length);
|
||||
}
|
||||
return replaceInvalidChars('$name-$id');
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/share/pubish_color_extension.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/share/publish_name_generator.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class PublishTab extends StatelessWidget {
|
||||
const PublishTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<DocumentShareBloc, DocumentShareState>(
|
||||
listener: (context, state) {
|
||||
_showToast(context, state);
|
||||
},
|
||||
builder: (context, state) {
|
||||
return state.isPublished
|
||||
? _PublishedWidget(
|
||||
url: state.url,
|
||||
onVisitSite: () {},
|
||||
onUnPublish: () {
|
||||
context
|
||||
.read<DocumentShareBloc>()
|
||||
.add(const DocumentShareEvent.unPublish());
|
||||
},
|
||||
)
|
||||
: _UnPublishWidget(
|
||||
onPublish: () async {
|
||||
final id = context.read<DocumentShareBloc>().view.id;
|
||||
final publishName = await generatePublishName(
|
||||
id,
|
||||
state.viewName,
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.read<DocumentShareBloc>().add(
|
||||
DocumentShareEvent.publish('', publishName),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showToast(BuildContext context, DocumentShareState state) {
|
||||
if (state.publishResult != null) {
|
||||
state.publishResult!.fold(
|
||||
(value) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_publishSuccessfully.tr(),
|
||||
),
|
||||
(error) => showToastNotification(
|
||||
context,
|
||||
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
|
||||
),
|
||||
);
|
||||
} else if (state.unpublishResult != null) {
|
||||
state.unpublishResult!.fold(
|
||||
(value) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
|
||||
),
|
||||
(error) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_unpublishFailed.tr(),
|
||||
description: error.msg,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PublishedWidget extends StatefulWidget {
|
||||
const _PublishedWidget({
|
||||
required this.url,
|
||||
required this.onVisitSite,
|
||||
required this.onUnPublish,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final VoidCallback onVisitSite;
|
||||
final VoidCallback onUnPublish;
|
||||
|
||||
@override
|
||||
State<_PublishedWidget> createState() => _PublishedWidgetState();
|
||||
}
|
||||
|
||||
class _PublishedWidgetState extends State<_PublishedWidget> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.text = widget.url;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(16),
|
||||
const _PublishTabHeader(),
|
||||
const VSpace(16),
|
||||
_PublishUrl(
|
||||
controller: controller,
|
||||
onCopy: (url) {
|
||||
getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(plainText: url),
|
||||
);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copy.tr(),
|
||||
);
|
||||
},
|
||||
onSubmitted: (url) {},
|
||||
),
|
||||
const VSpace(16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildButton(
|
||||
context,
|
||||
name: LocaleKeys.shareAction_unPublish.tr(),
|
||||
borderColor: ShareMenuColors.borderColor(context),
|
||||
onTap: widget.onUnPublish,
|
||||
),
|
||||
const Spacer(),
|
||||
_buildButton(
|
||||
context,
|
||||
name: LocaleKeys.shareAction_visitSite.tr(),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
textColor:
|
||||
Theme.of(context).isLightMode ? Colors.white : Colors.black,
|
||||
onTap: () {
|
||||
safeLaunchUrl(controller.text);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButton(
|
||||
BuildContext context, {
|
||||
required String name,
|
||||
Color? backgroundColor,
|
||||
Color borderColor = Colors.transparent,
|
||||
Color? textColor,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
width: 184,
|
||||
child: FlowyButton(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
radius: BorderRadius.circular(10),
|
||||
text: FlowyText.regular(
|
||||
name,
|
||||
textAlign: TextAlign.center,
|
||||
color: textColor,
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnPublishWidget extends StatelessWidget {
|
||||
const _UnPublishWidget({
|
||||
required this.onPublish,
|
||||
});
|
||||
|
||||
final VoidCallback onPublish;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(16),
|
||||
const _PublishTabHeader(),
|
||||
const VSpace(16),
|
||||
RoundedTextButton(
|
||||
height: 36,
|
||||
title: LocaleKeys.shareAction_publish.tr(),
|
||||
padding: const EdgeInsets.symmetric(vertical: 9.0),
|
||||
fontSize: 14.0,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
onPressed: onPublish,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublishTabHeader extends StatelessWidget {
|
||||
const _PublishTabHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const FlowySvg(FlowySvgs.share_publish_s),
|
||||
const HSpace(6),
|
||||
FlowyText(LocaleKeys.shareAction_publishToTheWeb.tr()),
|
||||
],
|
||||
),
|
||||
const VSpace(4),
|
||||
FlowyText.regular(
|
||||
LocaleKeys.shareAction_publishToTheWebHint.tr(),
|
||||
fontSize: 12,
|
||||
maxLines: 3,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublishUrl extends StatelessWidget {
|
||||
const _PublishUrl({
|
||||
required this.controller,
|
||||
required this.onCopy,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final void Function(String url) onCopy;
|
||||
final void Function(String url) onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: FlowyTextField(
|
||||
readOnly: true,
|
||||
autoFocus: false,
|
||||
controller: controller,
|
||||
enableBorderColor: ShareMenuColors.borderColor(context),
|
||||
suffixIcon: _buildCopyLinkIcon(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCopyLinkIcon(BuildContext context) {
|
||||
return FlowyHover(
|
||||
child: GestureDetector(
|
||||
onTap: () => onCopy(controller.text),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(left: BorderSide(color: Color(0x141F2329))),
|
||||
),
|
||||
child: const FlowySvg(
|
||||
FlowySvgs.m_toolbar_link_m,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/share/share_menu.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/string_extension.dart';
|
||||
import 'package:appflowy/workspace/application/export/document_exporter.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -28,23 +30,53 @@ class DocumentShareButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => getIt<DocumentShareBloc>(param1: view),
|
||||
create: (context) => getIt<DocumentShareBloc>(param1: view)
|
||||
..add(const DocumentShareEvent.initial()),
|
||||
child: BlocListener<DocumentShareBloc, DocumentShareState>(
|
||||
listener: (context, state) {
|
||||
state.mapOrNull(
|
||||
finish: (state) {
|
||||
state.successOrFail.fold(
|
||||
(data) => _handleExportData(context, data),
|
||||
_handleExportError,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (state.isLoading == false && state.exportResult != null) {
|
||||
state.exportResult!.fold(
|
||||
(data) => _handleExportData(context, data),
|
||||
_handleExportError,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<DocumentShareBloc, DocumentShareState>(
|
||||
builder: (context, state) => SizedBox(
|
||||
height: 32.0,
|
||||
child: IntrinsicWidth(child: ShareActionList(view: view)),
|
||||
),
|
||||
builder: (context, state) {
|
||||
final tabs = [
|
||||
if (state.enablePublish) ShareMenuTab.publish,
|
||||
ShareMenuTab.exportAs,
|
||||
];
|
||||
final shareBloc = context.read<DocumentShareBloc>();
|
||||
return SizedBox(
|
||||
height: 32.0,
|
||||
child: IntrinsicWidth(
|
||||
child: AppFlowyPopover(
|
||||
direction: PopoverDirection.bottomWithRightAligned,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 422,
|
||||
),
|
||||
offset: const Offset(0, 8),
|
||||
popupBuilder: (context) => BlocProvider.value(
|
||||
value: shareBloc,
|
||||
child: ShareMenu(
|
||||
tabs: tabs,
|
||||
),
|
||||
),
|
||||
child: RoundedTextButton(
|
||||
title: LocaleKeys.shareAction_buttonText.tr(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14.0),
|
||||
fontSize: 14.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(10.0),
|
||||
),
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/share/export_tab.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'publish_tab.dart';
|
||||
|
||||
enum ShareMenuTab {
|
||||
share,
|
||||
publish,
|
||||
exportAs;
|
||||
|
||||
String get i18n {
|
||||
switch (this) {
|
||||
case ShareMenuTab.share:
|
||||
return LocaleKeys.shareAction_shareTab.tr();
|
||||
case ShareMenuTab.publish:
|
||||
return LocaleKeys.shareAction_publishTab.tr();
|
||||
case ShareMenuTab.exportAs:
|
||||
return LocaleKeys.shareAction_exportAsTab.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ShareMenu extends StatefulWidget {
|
||||
const ShareMenu({
|
||||
super.key,
|
||||
required this.tabs,
|
||||
});
|
||||
|
||||
final List<ShareMenuTab> tabs;
|
||||
|
||||
@override
|
||||
State<ShareMenu> createState() => _ShareMenuState();
|
||||
}
|
||||
|
||||
class _ShareMenuState extends State<ShareMenu>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late ShareMenuTab selectedTab = widget.tabs.first;
|
||||
late final tabController = TabController(
|
||||
length: widget.tabs.length,
|
||||
vsync: this,
|
||||
initialIndex: widget.tabs.indexOf(selectedTab),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.tabs.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const VSpace(10),
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
height: 30,
|
||||
child: _buildTabBar(context),
|
||||
),
|
||||
Divider(
|
||||
color: Theme.of(context).dividerColor,
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14.0),
|
||||
child: _buildTab(context),
|
||||
),
|
||||
const VSpace(20),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildTabBar(BuildContext context) {
|
||||
final children = [
|
||||
for (final tab in widget.tabs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _Segment(
|
||||
title: tab.i18n.tr(),
|
||||
isSelected: selectedTab == tab,
|
||||
),
|
||||
),
|
||||
];
|
||||
return TabBar(
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
indicator: RoundUnderlineTabIndicator(
|
||||
width: 48.0,
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
insets: const EdgeInsets.only(bottom: -2),
|
||||
),
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
tabs: children,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
selectedTab = widget.tabs[index];
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTab(BuildContext context) {
|
||||
switch (selectedTab) {
|
||||
case ShareMenuTab.publish:
|
||||
return const PublishTab();
|
||||
case ShareMenuTab.exportAs:
|
||||
return const ExportTab();
|
||||
default:
|
||||
return const Center(
|
||||
child: FlowyText('🏡 under construction'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment extends StatelessWidget {
|
||||
const _Segment({
|
||||
required this.title,
|
||||
required this.isSelected,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = isSelected ? null : Theme.of(context).hintColor;
|
||||
return FlowyText(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
color: textColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
@@ -139,10 +140,18 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
},
|
||||
delete: (e) async {
|
||||
final result = await ViewBackendService.delete(viewId: view.id);
|
||||
|
||||
result.onSuccess((_) {
|
||||
add(const ViewEvent.unpublish(sync: false));
|
||||
});
|
||||
|
||||
emit(
|
||||
result.fold(
|
||||
(l) =>
|
||||
state.copyWith(successOrFailure: FlowyResult.success(null)),
|
||||
(l) {
|
||||
return state.copyWith(
|
||||
successOrFailure: FlowyResult.success(null),
|
||||
);
|
||||
},
|
||||
(error) => state.copyWith(
|
||||
successOrFailure: FlowyResult.failure(error),
|
||||
),
|
||||
@@ -180,8 +189,11 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
);
|
||||
emit(
|
||||
result.fold(
|
||||
(l) =>
|
||||
state.copyWith(successOrFailure: FlowyResult.success(null)),
|
||||
(l) {
|
||||
return state.copyWith(
|
||||
successOrFailure: FlowyResult.success(null),
|
||||
);
|
||||
},
|
||||
(error) => state.copyWith(
|
||||
successOrFailure: FlowyResult.failure(error),
|
||||
),
|
||||
@@ -236,6 +248,13 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
}
|
||||
add(const ViewEvent.setIsExpanded(false));
|
||||
},
|
||||
unpublish: (value) async {
|
||||
if (value.sync) {
|
||||
await _unpublishPage(view);
|
||||
} else {
|
||||
unawaited(_unpublishPage(view));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -383,6 +402,51 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// unpublish the page and all its child pages
|
||||
Future<void> _unpublishPage(ViewPB view) async {
|
||||
final allChildViews = await _getAllChildViews(view);
|
||||
final views = [view, ...allChildViews];
|
||||
|
||||
// unpublish
|
||||
await Future.wait(
|
||||
views.map((view) => ViewBackendService.getPublishInfo(view)),
|
||||
).then((results) {
|
||||
final viewsToUnpublish = <ViewPB>[];
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
final result = results[i];
|
||||
if (result.isSuccess) {
|
||||
viewsToUnpublish.add(views[i]);
|
||||
}
|
||||
}
|
||||
return viewsToUnpublish;
|
||||
}).then((views) async {
|
||||
await Future.wait(
|
||||
views.map((view) => ViewBackendService.unpublish(view)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<ViewPB>> _getAllChildViews(ViewPB view) async {
|
||||
final views = <ViewPB>[];
|
||||
|
||||
final childViews =
|
||||
await ViewBackendService.getChildViews(viewId: view.id).fold(
|
||||
(s) => s,
|
||||
(f) => [],
|
||||
);
|
||||
|
||||
for (final child in childViews) {
|
||||
// filter the view itself
|
||||
if (child.id == view.id) {
|
||||
continue;
|
||||
}
|
||||
views.add(child);
|
||||
views.addAll(await _getAllChildViews(child));
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
bool _isSameViewIgnoreChildren(ViewPB from, ViewPB to) {
|
||||
return _hash(from) == _hash(to);
|
||||
}
|
||||
@@ -430,6 +494,8 @@ class ViewEvent with _$ViewEvent {
|
||||
) = UpdateViewVisibility;
|
||||
const factory ViewEvent.updateIcon(String? icon) = UpdateIcon;
|
||||
const factory ViewEvent.collapseAllPages() = CollapseAllPages;
|
||||
// this event will unpublish the page and all its child pages if they are published
|
||||
const factory ViewEvent.unpublish({required bool sync}) = Unpublish;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -291,4 +291,63 @@ class ViewBackendService {
|
||||
);
|
||||
return FolderEventUpdateViewVisibilityStatus(payload).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<PublishInfoResponsePB, FlowyError>> getPublishInfo(
|
||||
ViewPB view,
|
||||
) async {
|
||||
final payload = ViewIdPB()..value = view.id;
|
||||
return FolderEventGetPublishInfo(payload).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<void, FlowyError>> publish(
|
||||
ViewPB view, {
|
||||
String? name,
|
||||
}) async {
|
||||
final payload = PublishViewParamsPB()..viewId = view.id;
|
||||
|
||||
if (name != null) {
|
||||
payload.publishName = name;
|
||||
}
|
||||
return FolderEventPublishView(payload).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<void, FlowyError>> unpublish(
|
||||
ViewPB view,
|
||||
) async {
|
||||
final payload = UnpublishViewsPayloadPB(viewIds: [view.id]);
|
||||
return FolderEventUnpublishViews(payload).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<void, FlowyError>> setPublishNameSpace(
|
||||
String name,
|
||||
) async {
|
||||
final payload = SetPublishNamespacePayloadPB()..newNamespace = name;
|
||||
return FolderEventSetPublishNamespace(payload).send();
|
||||
}
|
||||
|
||||
static Future<FlowyResult<PublishNamespacePB, FlowyError>>
|
||||
getPublishNameSpace() async {
|
||||
return FolderEventGetPublishNamespace().send();
|
||||
}
|
||||
|
||||
static Future<List<ViewPB>> getAllChildViews(ViewPB view) async {
|
||||
final views = <ViewPB>[];
|
||||
|
||||
final childViews =
|
||||
await ViewBackendService.getChildViews(viewId: view.id).fold(
|
||||
(s) => s,
|
||||
(f) => [],
|
||||
);
|
||||
|
||||
for (final child in childViews) {
|
||||
// filter the view itself
|
||||
if (child.id == view.id) {
|
||||
continue;
|
||||
}
|
||||
views.add(child);
|
||||
views.addAll(await getAllChildViews(child));
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
typedef MovePageMenuOnSelected = void Function(ViewPB space, ViewPB view);
|
||||
|
||||
class MovePageMenu extends StatefulWidget {
|
||||
const MovePageMenu({
|
||||
super.key,
|
||||
@@ -23,7 +25,7 @@ class MovePageMenu extends StatefulWidget {
|
||||
final ViewPB sourceView;
|
||||
final UserProfilePB userProfile;
|
||||
final String workspaceId;
|
||||
final void Function(ViewPB view) onSelected;
|
||||
final MovePageMenuOnSelected onSelected;
|
||||
|
||||
@override
|
||||
State<MovePageMenu> createState() => _MovePageMenuState();
|
||||
@@ -88,7 +90,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
|
||||
);
|
||||
}
|
||||
return Expanded(
|
||||
child: _buildGroupedViews(state.queryResults!),
|
||||
child: _buildGroupedViews(space, state.queryResults!),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -99,7 +101,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupedViews(List<ViewPB> views) {
|
||||
Widget _buildGroupedViews(ViewPB space, List<ViewPB> views) {
|
||||
final groupedViews = views
|
||||
.where(
|
||||
(view) =>
|
||||
@@ -108,7 +110,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
|
||||
.toList();
|
||||
return _MovePageGroupedViews(
|
||||
views: groupedViews,
|
||||
onSelected: widget.onSelected,
|
||||
onSelected: (view) => widget.onSelected(space, view),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +126,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
|
||||
child: CurrentSpace(
|
||||
onTapBlankArea: () {
|
||||
// move the page to current space
|
||||
widget.onSelected(space);
|
||||
widget.onSelected(space, space);
|
||||
},
|
||||
space: space,
|
||||
),
|
||||
@@ -145,7 +147,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
|
||||
disableSelectedStatus: true,
|
||||
// hide the ... and + buttons
|
||||
rightIconsBuilder: (context, view) => [],
|
||||
onSelected: (_, view) => widget.onSelected(view),
|
||||
onSelected: (_, view) => widget.onSelected(space, view),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SpacePermissionSwitch extends StatefulWidget {
|
||||
@@ -222,55 +223,79 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteSpacePopup extends StatelessWidget {
|
||||
const DeleteSpacePopup({super.key});
|
||||
class ConfirmDeletionPopup extends StatefulWidget {
|
||||
const ConfirmDeletionPopup({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final VoidCallback onConfirm;
|
||||
|
||||
@override
|
||||
State<ConfirmDeletionPopup> createState() => _ConfirmDeletionPopupState();
|
||||
}
|
||||
|
||||
class _ConfirmDeletionPopupState extends State<ConfirmDeletionPopup> {
|
||||
final focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final space = context.read<SpaceBloc>().state.currentSpace;
|
||||
final name = space != null ? space.name : '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 20.0,
|
||||
horizontal: 20.0,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FlowyText(
|
||||
LocaleKeys.space_deleteConfirmation.tr() + name,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
const Spacer(),
|
||||
FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text: const FlowySvg(FlowySvgs.upgrade_close_s),
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VSpace(8.0),
|
||||
FlowyText.regular(
|
||||
LocaleKeys.space_deleteConfirmationDescription.tr(),
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
maxLines: 3,
|
||||
lineHeight: 1.4,
|
||||
),
|
||||
const VSpace(20.0),
|
||||
SpaceCancelOrConfirmButton(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () {
|
||||
context.read<SpaceBloc>().add(const SpaceEvent.delete(null));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
confirmButtonName: LocaleKeys.space_delete.tr(),
|
||||
confirmButtonColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
],
|
||||
return KeyboardListener(
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: (event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 20.0,
|
||||
horizontal: 20.0,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FlowyText(
|
||||
widget.title,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
const Spacer(),
|
||||
FlowyButton(
|
||||
useIntrinsicWidth: true,
|
||||
text: const FlowySvg(FlowySvgs.upgrade_close_s),
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VSpace(8.0),
|
||||
FlowyText.regular(
|
||||
widget.description,
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
maxLines: 3,
|
||||
lineHeight: 1.4,
|
||||
),
|
||||
const VSpace(20.0),
|
||||
SpaceCancelOrConfirmButton(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () {
|
||||
widget.onConfirm();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
confirmButtonName: LocaleKeys.space_delete.tr(),
|
||||
confirmButtonColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,18 +227,14 @@ class _SidebarSpaceHeaderState extends State<SidebarSpaceHeader> {
|
||||
|
||||
void _showDeleteSpaceDialog(BuildContext context) {
|
||||
final spaceBloc = context.read<SpaceBloc>();
|
||||
showDialog(
|
||||
final space = spaceBloc.state.currentSpace;
|
||||
final name = space != null ? space.name : '';
|
||||
showConfirmDeletionDialog(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: BlocProvider.value(
|
||||
value: spaceBloc,
|
||||
child: const SizedBox(width: 440, child: DeleteSpacePopup()),
|
||||
),
|
||||
);
|
||||
name: name,
|
||||
description: LocaleKeys.space_deleteConfirmationDescription.tr(),
|
||||
onConfirm: () {
|
||||
context.read<SpaceBloc>().add(const SpaceEvent.delete(null));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,14 +105,16 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell {
|
||||
case WorkspaceMoreAction.divider:
|
||||
break;
|
||||
case WorkspaceMoreAction.delete:
|
||||
await NavigatorAlertDialog(
|
||||
title: LocaleKeys.workspace_deleteWorkspaceHintText.tr(),
|
||||
confirm: () {
|
||||
await showConfirmDeletionDialog(
|
||||
context: context,
|
||||
name: workspace.name,
|
||||
description: LocaleKeys.workspace_deleteWorkspaceHintText.tr(),
|
||||
onConfirm: () {
|
||||
workspaceBloc.add(
|
||||
UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId),
|
||||
);
|
||||
},
|
||||
).show(context);
|
||||
);
|
||||
case WorkspaceMoreAction.rename:
|
||||
await NavigatorTextFieldDialog(
|
||||
title: LocaleKeys.workspace_renameWorkspace.tr(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
|
||||
@@ -5,6 +7,7 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
@@ -17,9 +20,10 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.d
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
|
||||
import 'package:appflowy_popover/appflowy_popover.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -334,6 +338,7 @@ class _InnerViewItemState extends State<InnerViewItem> {
|
||||
onMove: widget.isPlaceholder
|
||||
? (from, to) => _moveViewCrossSection(
|
||||
context,
|
||||
null,
|
||||
widget.view,
|
||||
widget.parentView,
|
||||
widget.spaceType,
|
||||
@@ -690,7 +695,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
spaceType: widget.spaceType,
|
||||
onEditing: (value) =>
|
||||
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
|
||||
onAction: (action, data) {
|
||||
onAction: (action, data) async {
|
||||
switch (action) {
|
||||
case ViewMoreActionType.favorite:
|
||||
case ViewMoreActionType.unFavorite:
|
||||
@@ -699,18 +704,38 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
.add(FavoriteEvent.toggle(widget.view));
|
||||
break;
|
||||
case ViewMoreActionType.rename:
|
||||
NavigatorTextFieldDialog(
|
||||
title: LocaleKeys.disclosureAction_rename.tr(),
|
||||
autoSelectAllText: true,
|
||||
value: widget.view.name,
|
||||
maxLength: 256,
|
||||
onConfirm: (newValue, _) {
|
||||
context.read<ViewBloc>().add(ViewEvent.rename(newValue));
|
||||
},
|
||||
).show(context);
|
||||
unawaited(
|
||||
NavigatorTextFieldDialog(
|
||||
title: LocaleKeys.disclosureAction_rename.tr(),
|
||||
autoSelectAllText: true,
|
||||
value: widget.view.name,
|
||||
maxLength: 256,
|
||||
onConfirm: (newValue, _) {
|
||||
context.read<ViewBloc>().add(ViewEvent.rename(newValue));
|
||||
},
|
||||
).show(context),
|
||||
);
|
||||
break;
|
||||
case ViewMoreActionType.delete:
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
// get if current page contains published child views
|
||||
final childViews =
|
||||
await ViewBackendService.getAllChildViews(widget.view);
|
||||
final views = [widget.view, ...childViews];
|
||||
final containPublishedPage = await Future.wait(
|
||||
views.map((e) => ViewBackendService.getPublishInfo(e)),
|
||||
).then((value) => value.where((e) => e.isSuccess));
|
||||
if (containPublishedPage.isNotEmpty && context.mounted) {
|
||||
await showConfirmDeletionDialog(
|
||||
context: context,
|
||||
name: widget.view.name,
|
||||
description: LocaleKeys.publish_containsPublishedPage.tr(),
|
||||
onConfirm: () {
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
},
|
||||
);
|
||||
} else if (context.mounted) {
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
}
|
||||
break;
|
||||
case ViewMoreActionType.duplicate:
|
||||
context.read<ViewBloc>().add(const ViewEvent.duplicate());
|
||||
@@ -726,22 +751,22 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
return;
|
||||
}
|
||||
final result = data;
|
||||
ViewBackendService.updateViewIcon(
|
||||
await ViewBackendService.updateViewIcon(
|
||||
viewId: widget.view.id,
|
||||
viewIcon: result.emoji,
|
||||
iconType: result.type.toProto(),
|
||||
);
|
||||
break;
|
||||
case ViewMoreActionType.moveTo:
|
||||
final target = data;
|
||||
if (target is! ViewPB) {
|
||||
final value = data;
|
||||
if (value is! (ViewPB, ViewPB)) {
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'Move view ${widget.view.id}, ${widget.view.name} to ${target.id}, ${target.name}',
|
||||
);
|
||||
final space = value.$1;
|
||||
final target = value.$2;
|
||||
_moveViewCrossSection(
|
||||
context,
|
||||
space,
|
||||
widget.view,
|
||||
widget.parentView,
|
||||
widget.spaceType,
|
||||
@@ -802,6 +827,7 @@ bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) {
|
||||
|
||||
void _moveViewCrossSection(
|
||||
BuildContext context,
|
||||
ViewPB? toSpace,
|
||||
ViewPB view,
|
||||
ViewPB? parentView,
|
||||
FolderSpaceType spaceType,
|
||||
@@ -822,6 +848,17 @@ void _moveViewCrossSection(
|
||||
final toSection = spaceType == FolderSpaceType.public
|
||||
? ViewSectionPB.Public
|
||||
: ViewSectionPB.Private;
|
||||
|
||||
final currentSpace = context.read<SpaceBloc>().state.currentSpace;
|
||||
if (currentSpace != null &&
|
||||
toSpace != null &&
|
||||
currentSpace.id != toSpace.id) {
|
||||
Log.info(
|
||||
'Move view(${from.name}) to another space(${toSpace.name}), unpublish the view',
|
||||
);
|
||||
context.read<ViewBloc>().add(const ViewEvent.unpublish(sync: false));
|
||||
}
|
||||
|
||||
context.read<ViewBloc>().add(
|
||||
ViewEvent.move(
|
||||
from,
|
||||
|
||||
@@ -192,8 +192,8 @@ class ViewMoreActionTypeWrapper extends CustomActionCell {
|
||||
sourceView: sourceView,
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
onSelected: (view) {
|
||||
onTap(controller, view);
|
||||
onSelected: (space, view) {
|
||||
onTap(controller, (space, view));
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/tasks/app_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
@@ -10,6 +9,8 @@ import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
|
||||
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:toastification/toastification.dart';
|
||||
|
||||
export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
|
||||
|
||||
@@ -283,3 +284,58 @@ class OkCancelButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showToastNotification(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
String? description,
|
||||
}) {
|
||||
toastification.show(
|
||||
context: context,
|
||||
type: ToastificationType.success,
|
||||
style: ToastificationStyle.flat,
|
||||
title: FlowyText(message),
|
||||
description: description != null
|
||||
? FlowyText.regular(
|
||||
description,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
maxLines: 3,
|
||||
)
|
||||
: null,
|
||||
alignment: Alignment.bottomCenter,
|
||||
autoCloseDuration: const Duration(milliseconds: 3000),
|
||||
showProgressBar: false,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showConfirmDeletionDialog({
|
||||
required BuildContext context,
|
||||
required String name,
|
||||
required String description,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
final title = LocaleKeys.space_deleteConfirmation.tr() + name;
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 440,
|
||||
child: ConfirmDeletionPopup(
|
||||
title: title,
|
||||
description: description,
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:flowy_infra/size.dart';
|
||||
|
||||
class FlowyTextField extends StatefulWidget {
|
||||
final String? hintText;
|
||||
final String? text;
|
||||
@@ -37,6 +36,8 @@ class FlowyTextField extends StatefulWidget {
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool obscureText;
|
||||
final bool isDense;
|
||||
final bool readOnly;
|
||||
final Color? enableBorderColor;
|
||||
|
||||
const FlowyTextField({
|
||||
super.key,
|
||||
@@ -71,6 +72,8 @@ class FlowyTextField extends StatefulWidget {
|
||||
this.inputFormatters,
|
||||
this.obscureText = false,
|
||||
this.isDense = true,
|
||||
this.readOnly = false,
|
||||
this.enableBorderColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -144,6 +147,7 @@ class FlowyTextFieldState extends State<FlowyTextField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
readOnly: widget.readOnly,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
onChanged: (text) {
|
||||
@@ -178,7 +182,8 @@ class FlowyTextFieldState extends State<FlowyTextField> {
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: Corners.s8Border,
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
color: widget.enableBorderColor ??
|
||||
Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
isDense: false,
|
||||
|
||||
@@ -13,6 +13,7 @@ class RoundedTextButton extends StatelessWidget {
|
||||
final Color? hoverColor;
|
||||
final Color? textColor;
|
||||
final double? fontSize;
|
||||
final FontWeight? fontWeight;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const RoundedTextButton({
|
||||
@@ -27,6 +28,7 @@ class RoundedTextButton extends StatelessWidget {
|
||||
this.hoverColor,
|
||||
this.textColor,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
});
|
||||
|
||||
@@ -42,6 +44,7 @@ class RoundedTextButton extends StatelessWidget {
|
||||
child: SizedBox.expand(
|
||||
child: FlowyTextButton(
|
||||
title ?? '',
|
||||
fontWeight: fontWeight,
|
||||
onPressed: onPressed,
|
||||
fontSize: fontSize,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -394,6 +394,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
custom_sliding_segmented_control:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: custom_sliding_segmented_control
|
||||
sha256: "53c3e931c3ae1f696085d1ec70ac8e934da836595a9b7d9b88fdd0fcbf2a5574"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.3"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -949,6 +957,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
iconsax_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: iconsax_flutter
|
||||
sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
image_gallery_saver:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1410,6 +1426,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
pausable_timer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pausable_timer
|
||||
sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0+3"
|
||||
percent_indicator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2049,6 +2073,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
toastification:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: toastification
|
||||
sha256: "5e751acc2fb5b8d008138dac255d62290fde4e5a24824f29809ac098c3dfe395"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -149,6 +149,8 @@ dependencies:
|
||||
bitsdojo_window: ^0.1.6
|
||||
|
||||
flutter_highlight: ^0.7.0
|
||||
custom_sliding_segmented_control: ^1.8.3
|
||||
toastification: ^2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^3.0.1
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
FROM oven/bun:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nginx
|
||||
|
||||
RUN bun install cheerio pino axios pino-pretty
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN addgroup --system nginx && \
|
||||
adduser --system --no-create-home --disabled-login --ingroup nginx nginx
|
||||
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
COPY dist/ /usr/share/nginx/html/
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
COPY nginx-signed.crt /etc/ssl/certs/nginx-signed.crt
|
||||
COPY nginx-signed.key /etc/ssl/private/nginx-signed.key
|
||||
|
||||
RUN chown -R nginx:nginx /etc/ssl/certs/nginx-signed.crt /etc/ssl/private/nginx-signed.key
|
||||
|
||||
COPY start.sh /app/start.sh
|
||||
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
CMD ["/app/start.sh"]
|
||||
@@ -43,6 +43,16 @@ Before you begin, ensure you have the following installed:
|
||||
pnpm install
|
||||
```
|
||||
|
||||
- Modify the environment file in `frontend/appflowy_web_app/.env` to add or modify environment variables
|
||||
|
||||
```bash
|
||||
# Example
|
||||
AF_BASE_URL=https://test.appflowy.cloud
|
||||
AF_GOTRUE_URL=https://test.appflowy.cloud/gotrue
|
||||
AF_WS_URL=wss://test.appflowy.cloud/ws/v1
|
||||
|
||||
```
|
||||
|
||||
#### 🖥️ Desktop Application (Tauri) (Optional)
|
||||
|
||||
> **Note**: if you want to run the web app in the browser, skip this step
|
||||
@@ -148,10 +158,6 @@ Don't modify the theme file in `frontend/appflowy_web_app/src/styles/variables`
|
||||
pnpm run css:variables
|
||||
```
|
||||
|
||||
#### How to add or modify the environment variables
|
||||
|
||||
- Modify the environment file in `frontend/appflowy_web_app/.env` to add or modify environment variables
|
||||
|
||||
#### How to create symlink for the @appflowyinc/client-api-wasm in local development
|
||||
|
||||
- Run the following command to create a symlink for the @appflowyinc/client-api-wasm
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
AF_WS_URL=wss://beta.appflowy.cloud/ws/v1
|
||||
AF_BASE_URL=https://beta.appflowy.cloud
|
||||
AF_GOTRUE_URL=https://beta.appflowy.cloud/gotrue
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "9eebea03-3ed5-4298-86b2-a7f77856d48b",
|
||||
"name": "workspace",
|
||||
"icon": "",
|
||||
"owner": {
|
||||
"id": 0,
|
||||
"name": "system"
|
||||
},
|
||||
"type": 0,
|
||||
"workspaceDatabaseId": "375874be-7a4f-4b7c-8b89-1dc9a39838f4"
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
[
|
||||
{
|
||||
"database_id": "037a985f-f369-4c4a-8011-620012850a68",
|
||||
"created_at": "1713429700",
|
||||
"views": [
|
||||
"48c52cf7-bf98-43fa-96ad-b31aade9b071"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "daea6aee-9365-4703-a8e2-a2fa6a07b214",
|
||||
"created_at": "1714449533",
|
||||
"views": [
|
||||
"b6347acb-3174-4f0e-98e9-dcce07e5dbf7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
|
||||
"created_at": "0",
|
||||
"views": [
|
||||
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
|
||||
"e410747b-5f2f-45a0-b2f7-890ad3001355",
|
||||
"2143e95d-5dcb-4e0f-bb2c-50944e6e019f",
|
||||
"a5566e49-f156-4168-9b2d-17926c5da329",
|
||||
"135615fa-66f7-4451-9b54-d7e99445fca4",
|
||||
"b4e77203-5c8b-48df-bbc5-2e1143eb0e61",
|
||||
"a6af311f-cbc8-42c2-b801-7115619c3776"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
|
||||
"created_at": "0",
|
||||
"views": [
|
||||
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
|
||||
"e97877f5-c365-4025-9e6a-e590c4b19dbb",
|
||||
"f0c59921-04ee-4971-995c-79b7fd8c00e2",
|
||||
"7eb697cd-6a55-40bb-96ac-0d4a3bc924b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "ee63da2b-aa2a-4d0b-aab0-59008635363a",
|
||||
"created_at": "0",
|
||||
"views": [
|
||||
"2c1ee95a-1b09-4a1f-8d5e-501bc4861a9d",
|
||||
"91ea7c08-f6b3-4b81-aa1e-d3664686186f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "e788f014-d0d3-4dfe-81ef-aa1ebb4d6366",
|
||||
"created_at": "0",
|
||||
"views": [
|
||||
"1b0e322d-4909-4c63-914a-d034fc363097",
|
||||
"350f425b-b671-4e2d-8182-5998a6e62924"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d",
|
||||
"created_at": "0",
|
||||
"views": [
|
||||
"0ce13415-6cce-4497-94c6-475ad96c249e",
|
||||
"e4c89421-12b2-4d02-863d-20949eec9271"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "ce267d12-3b61-4ebb-bb03-d65272f5f817",
|
||||
"created_at": "0",
|
||||
"views": [
|
||||
"ee3ae8ce-959a-4df3-8734-40b535ff88e3",
|
||||
"66a6f3bc-c78f-4f74-a09e-08d4717bf1fd",
|
||||
"2bf50c03-f41f-4363-b5b1-101216a6c5cc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"database_id": "87bc006e-c1eb-47fd-9ac6-e39b17956369",
|
||||
"created_at": "0",
|
||||
"views": [
|
||||
"7f233be4-1b4d-46b2-bcfc-f341b8d75267",
|
||||
"a734a068-e73d-4b4b-853c-4daffea389c0"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"data":{"state_vector":[6,248,208,217,159,7,16,226,250,246,177,3,17,211,142,141,147,5,8,196,148,203,38,26,230,150,209,139,14,193,6,167,238,246,72,86],"doc_state":[6,177,5,230,150,209,139,14,0,0,2,0,1,0,5,39,0,196,148,203,38,4,6,122,45,72,76,78,68,2,4,0,230,150,209,139,14,8,4,49,49,49,49,39,0,196,148,203,38,1,6,54,70,57,105,75,107,1,40,0,230,150,209,139,14,13,2,105,100,1,119,6,54,70,57,105,75,107,40,0,230,150,209,139,14,13,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,13,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,13,8,99,104,105,108,100,114,101,110,1,119,6,97,118,105,69,115,45,33,0,230,150,209,139,14,13,4,100,97,116,97,1,40,0,230,150,209,139,14,13,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,13,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,97,118,105,69,115,45,0,72,196,148,203,38,24,1,119,6,54,70,57,105,75,107,39,0,196,148,203,38,4,6,69,115,57,50,84,74,2,33,0,196,148,203,38,1,6,98,71,84,69,74,116,1,0,7,33,0,196,148,203,38,3,6,87,56,66,102,66,110,1,129,196,148,203,38,24,1,168,230,150,209,139,14,18,1,119,39,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,49,49,49,34,125,93,44,34,108,101,118,101,108,34,58,50,125,4,0,230,150,209,139,14,23,1,35,0,1,132,230,150,209,139,14,35,1,35,0,1,132,230,150,209,139,14,37,1,35,0,1,132,230,150,209,139,14,39,1,35,0,1,39,0,196,148,203,38,4,6,56,76,76,113,48,56,2,39,0,196,148,203,38,1,6,56,70,105,117,112,98,1,40,0,230,150,209,139,14,44,2,105,100,1,119,6,56,70,105,117,112,98,40,0,230,150,209,139,14,44,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,44,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,44,8,99,104,105,108,100,114,101,110,1,119,6,49,66,67,68,107,53,33,0,230,150,209,139,14,44,4,100,97,116,97,1,40,0,230,150,209,139,14,44,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,44,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,49,66,67,68,107,53,0,200,196,148,203,38,24,230,150,209,139,14,33,1,119,6,56,70,105,117,112,98,4,0,230,150,209,139,14,43,1,50,161,230,150,209,139,14,49,1,132,230,150,209,139,14,54,1,50,161,230,150,209,139,14,55,1,132,230,150,209,139,14,56,1,50,161,230,150,209,139,14,57,1,132,230,150,209,139,14,58,1,50,161,230,150,209,139,14,59,1,39,0,196,148,203,38,4,6,103,76,57,104,70,45,2,39,0,196,148,203,38,1,6,72,99,71,82,109,56,1,40,0,230,150,209,139,14,63,2,105,100,1,119,6,72,99,71,82,109,56,40,0,230,150,209,139,14,63,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,230,150,209,139,14,63,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,63,8,99,104,105,108,100,114,101,110,1,119,6,50,114,117,49,72,109,33,0,230,150,209,139,14,63,4,100,97,116,97,1,40,0,230,150,209,139,14,63,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,63,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,50,114,117,49,72,109,0,136,230,150,209,139,14,33,1,119,6,72,99,71,82,109,56,168,230,150,209,139,14,61,1,119,39,123,34,108,101,118,101,108,34,58,52,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,50,50,50,50,34,125,93,125,1,0,230,150,209,139,14,62,1,161,230,150,209,139,14,68,1,129,230,150,209,139,14,74,1,161,230,150,209,139,14,75,1,129,230,150,209,139,14,76,2,161,230,150,209,139,14,77,1,129,230,150,209,139,14,79,1,161,230,150,209,139,14,80,1,129,230,150,209,139,14,81,1,161,230,150,209,139,14,82,1,68,230,150,209,139,14,74,6,228,189,160,229,165,189,168,230,150,209,139,14,84,1,119,31,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,228,189,160,229,165,189,34,125,93,125,39,0,196,148,203,38,4,6,113,111,68,56,109,111,2,33,0,196,148,203,38,1,6,113,118,66,98,82,83,1,0,7,33,0,196,148,203,38,3,6,118,76,97,51,57,90,1,129,230,150,209,139,14,72,1,1,0,230,150,209,139,14,88,1,0,1,129,230,150,209,139,14,99,1,0,1,129,230,150,209,139,14,101,1,0,4,132,230,150,209,139,14,103,1,91,0,1,132,230,150,209,139,14,108,1,93,0,1,39,0,196,148,203,38,4,6,69,114,99,104,55,72,2,39,0,196,148,203,38,1,6,51,111,95,70,117,67,1,40,0,230,150,209,139,14,113,2,105,100,1,119,6,51,111,95,70,117,67,40,0,230,150,209,139,14,113,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,230,150,209,139,14,113,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,113,8,99,104,105,108,100,114,101,110,1,119,6,68,56,109,75,57,97,33,0,230,150,209,139,14,113,4,100,97,116,97,1,40,0,230,150,209,139,14,113,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,113,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,68,56,109,75,57,97,0,200,230,150,209,139,14,72,230,150,209,139,14,98,1,119,6,51,111,95,70,117,67,4,0,230,150,209,139,14,112,1,49,161,230,150,209,139,14,118,1,132,230,150,209,139,14,123,1,50,161,230,150,209,139,14,Line truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"data":{"state_vector":[160,1,128,252,161,128,4,33,130,180,254,251,6,18,135,232,133,203,9,15,135,240,136,178,10,45,137,226,192,199,6,20,138,202,240,189,2,134,1,139,240,196,145,14,138,2,139,152,215,249,10,34,141,216,158,150,1,3,142,130,192,134,11,3,143,184,153,180,6,6,140,228,230,243,1,2,145,190,137,224,10,2,145,144,146,185,5,12,140,152,206,145,6,165,1,135,166,246,235,6,3,147,206,229,235,1,3,137,164,190,210,1,39,152,158,185,230,10,7,153,130,203,161,6,97,152,252,186,192,1,2,154,244,246,165,8,198,1,156,148,170,169,10,16,157,240,144,231,2,3,158,156,181,152,10,49,158,182,250,251,9,15,160,192,253,131,5,3,161,178,132,150,11,101,159,156,204,250,6,27,163,236,177,169,4,12,164,188,201,172,1,3,158,184,218,165,3,38,162,238,198,212,7,3,170,140,240,234,9,31,171,204,155,217,8,3,171,142,166,254,1,6,173,252,148,184,13,64,176,154,159,227,1,20,178,162,190,217,10,3,179,252,154,44,3,180,230,210,212,13,7,182,172,247,194,5,3,183,226,184,158,8,8,187,220,199,239,8,70,195,242,227,194,8,3,196,154,250,183,6,62,197,254,154,201,10,3,200,142,208,241,4,157,1,202,160,246,212,1,6,203,184,221,173,11,37,206,220,129,131,4,21,207,228,238,162,8,50,209,250,203,254,15,3,210,228,153,221,12,3,211,202,217,232,12,7,211,166,203,229,4,195,1,214,168,149,214,3,15,219,220,239,171,8,10,225,248,138,176,2,49,226,212,179,248,2,15,229,154,128,197,12,24,234,182,182,157,9,8,235,178,165,206,5,72,234,156,130,211,12,12,241,130,161,205,7,12,244,226,228,149,2,30,245,220,194,52,39,247,200,243,247,14,100,248,136,168,181,1,3,248,210,237,129,13,2,250,198,166,187,7,2,248,196,187,185,10,31,252,218,241,167,14,3,255,140,248,220,6,3,128,211,179,216,2,10,133,159,138,205,12,131,1,135,167,156,250,14,16,135,193,208,135,7,16,141,245,194,142,11,7,141,205,220,149,4,3,143,131,148,152,6,15,141,171,170,217,4,2,145,159,164,217,14,3,149,129,169,191,12,16,149,249,242,175,4,31,149,189,189,215,8,3,149,161,132,184,14,221,2,154,243,157,196,14,12,154,193,208,134,10,9,155,165,205,152,11,3,154,235,215,240,4,13,155,159,180,195,15,6,157,207,243,216,6,2,161,239,241,154,13,106,162,159,252,196,11,3,164,155,139,169,7,35,165,139,157,171,15,103,164,203,250,235,13,7,167,131,133,162,9,11,166,201,221,141,13,72,169,197,188,221,3,3,170,255,211,105,38,166,203,155,46,6,173,187,245,170,14,46,174,151,139,93,179,2,175,225,172,150,8,11,175,147,217,214,1,33,177,161,136,243,11,10,178,203,205,182,4,1,175,205,156,228,6,10,180,205,189,133,13,20,181,175,219,209,12,10,182,143,233,195,4,111,177,219,160,167,7,4,184,201,188,172,10,3,184,231,170,67,6,186,197,166,179,15,7,187,173,214,176,15,3,188,171,136,250,8,21,188,237,223,145,6,26,190,139,191,155,1,2,191,157,147,233,9,32,193,249,142,142,4,17,198,189,216,175,6,23,200,205,214,172,10,30,201,129,238,197,4,84,201,191,159,147,14,17,200,203,236,184,2,35,200,159,185,206,9,8,205,149,231,236,11,68,213,161,242,209,13,35,214,139,213,136,8,66,213,255,156,145,1,2,219,227,140,137,6,34,221,147,167,147,15,177,2,222,205,223,235,7,31,223,209,193,147,11,81,227,209,197,253,2,14,229,153,197,202,7,241,6,231,139,244,188,8,13,232,207,157,148,2,2,233,165,139,246,14,43,233,247,183,159,1,4,235,225,184,133,10,3,234,153,236,158,4,73,234,187,164,181,1,24,231,189,134,196,8,77,239,199,189,146,3,24,240,149,229,225,6,15,241,155,213,233,1,52,240,253,240,229,1,79,243,239,182,181,13,30,236,229,225,232,8,116,246,185,174,192,6,90,248,153,216,10,3,251,189,220,155,14,3,252,163,130,200,6,30,253,205,145,137,11,10,252,171,209,175,15,4,255,255,147,249,10,3],"doc_state":[160,1,3,209,250,203,254,15,0,161,226,212,179,248,2,9,1,161,226,212,179,248,2,10,1,136,174,151,139,93,249,1,1,118,2,2,105,100,119,36,50,54,100,53,99,56,99,49,45,49,99,54,54,45,52,53,57,99,45,98,99,54,99,45,102,52,100,97,49,97,54,54,51,51,52,56,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,83,246,78,2,155,159,180,195,15,0,161,198,189,216,175,6,22,1,161,253,205,145,137,11,9,5,7,186,197,166,179,15,0,161,161,178,132,150,11,98,1,161,161,178,132,150,11,99,1,129,161,178,132,150,11,100,1,161,161,178,132,150,11,97,1,161,161,178,132,150,11,94,1,161,161,178,132,150,11,95,1,129,186,197,166,179,15,2,1,3,187,173,214,176,15,0,168,244,226,228,149,2,27,1,122,4,56,115,160,190,64,16,0,168,244,226,228,149,2,28,1,122,0,0,0,0,102,32,153,171,136,248,153,216,10,2,1,118,2,2,105,100,119,36,48,53,51,51,50,98,97,52,45,97,54,57,48,45,52,50,57,51,45,57,56,54,54,45,56,52,100,97,99,55,102,101,50,97,101,97,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,32,153,171,1,252,171,209,175,15,0,161,128,211,179,216,2,9,4,103,165,139,157,171,15,0,8,0,201,129,238,197,4,52,1,118,1,2,105,100,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,168,201,129,238,197,4,51,1,122,0,0,0,0,102,74,244,14,161,170,140,240,234,9,22,1,39,0,203,184,221,173,11,1,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,1,40,0,165,139,157,171,15,3,2,105,100,1,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,40,0,Line truncated
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI4Mjk2MjAsImlhdCI6MTcxMjgyNjAyMCwic3ViIjoiY2JmZjA2MGEtMTk2ZC00MTVhLWFhODAtNzU5YzAxODg2NDY2IiwiZW1haWwiOiJsdUBhcHBmbG93eS5pbyIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZ29vZ2xlIiwicHJvdmlkZXJzIjpbImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTEhabVZBczRTb0ZlVFFuWG5CU2JiNTBBVXF0YktHNWx5MGllVHZCSklYZ1o3UmdRPXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImFwcGZsb3d5LmlvIn0sImVtYWlsIjoibHVAYXBwZmxvd3kuaW8iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTHUgSGUiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTHUgSGUiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NMSFptVkFzNFNvRmVUUW5YbkJTYmI1MEFVcXRiS0c1bHkwaWVUdkJKSVhnWjdSZ1E9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSIsInN1YiI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3MTI4MjYwMjB9XSwic2Vzc2lvbl9pZCI6ImJmMzE5OTRlLTQwMTgtNDhjMS05Yzc0LWVmYzkyMGNjOWQ0NSJ9.QeTrRhsnBjBL1GUS3TIWOgU1SPM6RcaWwxZdMVfcFBU",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"expires_at": 4869016461,
|
||||
"refresh_token": "71vp1jJnSAVluZKaXkhG1A",
|
||||
"user": {
|
||||
"id": "cbff060a-196d-415a-aa80-759c01886466",
|
||||
"aud": "",
|
||||
"role": "",
|
||||
"email": "lu@appflowy.io",
|
||||
"email_confirmed_at": "2024-03-13T10:49:53.165361Z",
|
||||
"phone": "",
|
||||
"confirmed_at": "2024-03-13T10:49:53.165361Z",
|
||||
"last_sign_in_at": "2024-04-11T09:00:20.547468985Z",
|
||||
"app_metadata": {
|
||||
"provider": "google",
|
||||
"providers": [
|
||||
"google"
|
||||
]
|
||||
},
|
||||
"user_metadata": {
|
||||
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"custom_claims": {
|
||||
"hd": "appflowy.io"
|
||||
},
|
||||
"email": "lu@appflowy.io",
|
||||
"email_verified": true,
|
||||
"full_name": "Lu He",
|
||||
"iss": "https://accounts.google.com",
|
||||
"name": "Lu He",
|
||||
"phone_verified": false,
|
||||
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"provider_id": "101169250829554028381",
|
||||
"sub": "101169250829554028381"
|
||||
},
|
||||
"identities": [
|
||||
{
|
||||
"identity_id": "e4cf8b69-7f80-42e9-aed2-e25132ad0178",
|
||||
"id": "101169250829554028381",
|
||||
"user_id": "cbff060a-196d-415a-aa80-759c01886466",
|
||||
"identity_data": {
|
||||
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"custom_claims": {
|
||||
"hd": "appflowy.io"
|
||||
},
|
||||
"email": "lu@appflowy.io",
|
||||
"email_verified": true,
|
||||
"full_name": "Lu He",
|
||||
"iss": "https://accounts.google.com",
|
||||
"name": "Lu He",
|
||||
"phone_verified": false,
|
||||
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"provider_id": "101169250829554028381",
|
||||
"sub": "101169250829554028381"
|
||||
},
|
||||
"provider": "google",
|
||||
"last_sign_in_at": "2024-03-13T07:22:43.110504Z",
|
||||
"created_at": "2024-03-13T07:22:43.110543Z",
|
||||
"updated_at": "2024-04-04T06:15:14.03093Z"
|
||||
}
|
||||
],
|
||||
"created_at": "2024-03-13T07:22:43.102586Z",
|
||||
"updated_at": "2024-04-11T09:00:20.551485Z"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"uid": 304120109071339520,
|
||||
"uuid": "cbff060a-196d-415a-aa80-759c01886466",
|
||||
"email": "lu@appflowy.io",
|
||||
"password": "",
|
||||
"name": "Kilu",
|
||||
"metadata": {
|
||||
"icon_url": "🇽🇰"
|
||||
},
|
||||
"encryption_sign": null,
|
||||
"latest_workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968",
|
||||
"updated_at": 1710421586
|
||||
},
|
||||
"code": 0,
|
||||
"message": "Operation completed successfully."
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"data":{"user_profile":{"uid":304120109071339520,"uuid":"cbff060a-196d-415a-aa80-759c01886466","email":"lu@appflowy.io","password":"","name":"Kilu","metadata":{"icon_url":"🇽🇰"},"encryption_sign":null,"latest_workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","updated_at":1715847453},"visiting_workspace":{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"},"workspaces":[{"workspace_id":"81570fa8-8be9-4b2d-9f1c-1ef4f34079a8","database_storage_id":"6c1f1a2c-e8d5-4bc2-917f-495bce862abb","owner_uid":311828434584080384,"owner_name":"Zack Zi Xiang Fu","workspace_type":0,"workspace_name":"My Workspace","created_at":"2024-04-03T13:53:18.295918Z","icon":""},{"workspace_id":"fcb503f9-9287-4de4-8de0-ea191e680968","database_storage_id":"ae1b82a5-2b93-45c7-901a-f9357c544534","owner_uid":276169796100296704,"owner_name":"Annie Anqi Wang","workspace_type":0,"workspace_name":"AppFlowy Test","created_at":"2023-12-27T04:18:36.372013Z","icon":""},{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"}]},"code":0,"message":"Operation completed successfully."}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"is_new": false
|
||||
}
|
||||
}
|
||||
@@ -25,96 +25,9 @@
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { JSDatabaseService } from '@/application/services/js-services/database.service';
|
||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
Cypress.Commands.add('mockAPI', () => {
|
||||
cy.fixture('sign_in_success').then((json) => {
|
||||
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
|
||||
fixture: 'verify_token',
|
||||
}).as('verifyToken');
|
||||
cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess');
|
||||
cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken');
|
||||
});
|
||||
cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile');
|
||||
cy.intercept('GET', '/api/user/workspace', { fixture: 'user_workspace' }).as('getUserWorkspace');
|
||||
// Mock the API
|
||||
});
|
||||
|
||||
// Example use:
|
||||
// beforeEach(() => {
|
||||
// cy.mockAPI();
|
||||
// });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockCurrentWorkspace', () => {
|
||||
cy.fixture('current_workspace').then((workspace) => {
|
||||
cy.stub(JSDatabaseService.prototype, 'currentWorkspace').resolves(workspace);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockGetWorkspaceDatabases', () => {
|
||||
cy.fixture('database/databases').then((databases) => {
|
||||
cy.stub(JSDatabaseService.prototype, 'getWorkspaceDatabases').resolves(databases);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockDatabase', () => {
|
||||
cy.mockCurrentWorkspace();
|
||||
cy.mockGetWorkspaceDatabases();
|
||||
|
||||
const ids = [
|
||||
'4c658817-20db-4f56-b7f9-0637a22dfeb6',
|
||||
'ce267d12-3b61-4ebb-bb03-d65272f5f817',
|
||||
'ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d',
|
||||
];
|
||||
|
||||
const mockOpenDatabase = cy.stub(JSDatabaseService.prototype, 'openDatabase');
|
||||
|
||||
ids.forEach((id) => {
|
||||
cy.fixture(`database/${id}`).then((database) => {
|
||||
cy.fixture(`database/rows/${id}`).then((rows) => {
|
||||
const doc = new Y.Doc();
|
||||
const rootRowsDoc = new Y.Doc();
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const databaseState = new Uint8Array(database.data.doc_state);
|
||||
|
||||
applyYDoc(doc, databaseState);
|
||||
|
||||
Object.keys(rows).forEach((key) => {
|
||||
const data = rows[key];
|
||||
const rowDoc = new Y.Doc();
|
||||
|
||||
applyYDoc(rowDoc, new Uint8Array(data));
|
||||
rowsFolder.set(key, rowDoc);
|
||||
});
|
||||
mockOpenDatabase.withArgs(id).resolves({
|
||||
databaseDoc: doc,
|
||||
rows: rowsFolder,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockDocument', (id: string) => {
|
||||
cy.fixture(`document/${id}`).then((subDocument) => {
|
||||
const doc = new Y.Doc();
|
||||
const state = new Uint8Array(subDocument.data.doc_state);
|
||||
|
||||
applyYDoc(doc, state);
|
||||
|
||||
cy.stub(JSDocumentService.prototype, 'openDocument').withArgs(id).resolves(doc);
|
||||
});
|
||||
});
|
||||
export {};
|
||||
@@ -0,0 +1,32 @@
|
||||
FROM oven/bun:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nginx supervisor
|
||||
|
||||
RUN bun install cheerio pino pino-pretty
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY supervisord.conf /app/supervisord.conf
|
||||
|
||||
RUN addgroup --system nginx && \
|
||||
adduser --system --no-create-home --disabled-login --ingroup nginx nginx
|
||||
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
COPY dist /usr/share/nginx/html/
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
COPY start.sh /app/start.sh
|
||||
|
||||
RUN chmod +x /app/start.sh
|
||||
RUN chmod +x /app/supervisord.conf
|
||||
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["supervisord", "-c", "/app/supervisord.conf"]
|
||||
@@ -0,0 +1,28 @@
|
||||
if [ -z "$1" ]; then
|
||||
echo "No port number provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PORT=$1
|
||||
|
||||
echo "Starting deployment on port $PORT"
|
||||
|
||||
rm -rf deploy
|
||||
|
||||
tar -xzf build-output.tar.gz
|
||||
|
||||
rm -rf build-output.tar.gz
|
||||
|
||||
mv dist deploy/dist
|
||||
|
||||
mv .env deploy/.env
|
||||
|
||||
cd deploy
|
||||
|
||||
docker system prune -f
|
||||
|
||||
docker build -t appflowy-web-app-"$PORT" .
|
||||
|
||||
docker rm -f appflowy-web-app-"$PORT" || true
|
||||
|
||||
docker run -d --env-file .env -p "$PORT":80 --restart always --name appflowy-web-app-"$PORT" appflowy-web-app-"$PORT"
|
||||
@@ -40,29 +40,6 @@ http {
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
#server_name appflowy.com *.appflowy.com;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# Additional server block for HTTPS
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name localhost;
|
||||
#server_name appflowy.com *.appflowy.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/nginx-signed.crt;
|
||||
ssl_certificate_key /etc/ssl/private/nginx-signed.key;
|
||||
|
||||
ssl_session_cache shared:SSL:1m;
|
||||
ssl_session_timeout 5m;
|
||||
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
@@ -89,6 +66,18 @@ http {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /og-image.png {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /covers/ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
root /usr/share/nginx/html;
|
||||
@@ -98,5 +87,6 @@ http {
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import pino from 'pino';
|
||||
import { type CheerioAPI, load } from 'cheerio';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
import { fetch } from 'bun';
|
||||
|
||||
const distDir = path.join(__dirname, 'dist');
|
||||
const indexPath = path.join(distDir, 'index.html');
|
||||
const baseURL = process.env.AF_BASE_URL as string;
|
||||
const defaultSite = 'https://appflowy.io';
|
||||
|
||||
const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => {
|
||||
if ($(selector).length === 0) {
|
||||
$('head').append(`<meta ${attribute}="${selector.match(/\[(.*?)\]/)?.[1]}" content="${content}">`);
|
||||
} else {
|
||||
$(selector).attr('content', content);
|
||||
}
|
||||
};
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
destination: `${__dirname}/pino-logger.log`,
|
||||
},
|
||||
},
|
||||
level: 'info',
|
||||
});
|
||||
|
||||
const logRequestTimer = (req: Request) => {
|
||||
const start = Date.now();
|
||||
const pathname = new URL(req.url).pathname;
|
||||
|
||||
logger.info(`Incoming request: ${pathname}`);
|
||||
return () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.info(`Request for ${pathname} took ${duration}ms`);
|
||||
};
|
||||
};
|
||||
|
||||
const fetchMetaData = async (url: string) => {
|
||||
logger.info(`Fetching meta data from ${url}`);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching meta data ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createServer = async (req: Request) => {
|
||||
const timer = logRequestTimer(req);
|
||||
const reqUrl = new URL(req.url);
|
||||
const hostname = req.headers.get('host');
|
||||
|
||||
logger.info(`Request URL: ${hostname}${reqUrl.pathname}`);
|
||||
|
||||
const [namespace, publishName] = reqUrl.pathname.slice(1).split('/');
|
||||
|
||||
logger.info(`Namespace: ${namespace}, Publish Name: ${publishName}`);
|
||||
|
||||
if (req.method === 'GET') {
|
||||
if (namespace === '' || !publishName) {
|
||||
timer();
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: defaultSite,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let metaData;
|
||||
|
||||
try {
|
||||
metaData = await fetchMetaData(`${baseURL}/api/workspace/published/${namespace}/${publishName}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching meta data: ${error}`);
|
||||
}
|
||||
|
||||
const htmlData = fs.readFileSync(indexPath, 'utf8');
|
||||
const $ = load(htmlData);
|
||||
|
||||
const description = 'Write, share, and publish docs quickly on AppFlowy.\nGet started for free.';
|
||||
let title = 'AppFlowy';
|
||||
const url = `https://${hostname}${reqUrl.pathname}`;
|
||||
let image = '/og-image.png';
|
||||
|
||||
try {
|
||||
if (metaData && metaData.view) {
|
||||
title = `${metaData.view.name} | AppFlowy`;
|
||||
|
||||
try {
|
||||
const cover = metaData.view.extra ? JSON.parse(metaData.view.extra)?.cover : null;
|
||||
|
||||
if (cover) {
|
||||
if (['unsplash', 'custom'].includes(cover.type)) {
|
||||
image = cover.value;
|
||||
} else if (cover.type === 'built_in') {
|
||||
image = `/covers/m_cover_image_${cover.value}.png`;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error injecting meta data: ${error}`);
|
||||
}
|
||||
|
||||
$('title').text(title);
|
||||
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:site_name"]', 'property', 'AppFlowy');
|
||||
setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'website');
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image');
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title);
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description);
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy');
|
||||
|
||||
timer();
|
||||
return new Response($.html(), {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
} else {
|
||||
timer();
|
||||
logger.error({ message: 'Method not allowed', method: req.method });
|
||||
return new Response('Method not allowed', { status: 405 });
|
||||
}
|
||||
};
|
||||
|
||||
declare const Bun: {
|
||||
serve: (options: { port: number; fetch: typeof createServer; error: (err: Error) => Response }) => void;
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
Bun.serve({
|
||||
port: 3000,
|
||||
fetch: createServer,
|
||||
error: (err) => {
|
||||
logger.error(`Internal Server Error: ${err}`);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
},
|
||||
});
|
||||
logger.info('Server is running on port 3000');
|
||||
logger.info(`Base URL: ${baseURL}`);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
export {};
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
# Start the frontend server
|
||||
bun run server.cjs &
|
||||
|
||||
# Start the nginx server
|
||||
service nginx start
|
||||
|
||||
# Start the frontend server
|
||||
bun run server.ts
|
||||
|
||||
tail -f /dev/null
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
|
||||
[program:bun]
|
||||
command=sh /app/start.sh
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stderr_logfile=/var/log/bun.err.log
|
||||
stdout_logfile=/var/log/bun.out.log
|
||||
@@ -26,6 +26,8 @@
|
||||
/>
|
||||
<meta name="twitter:site" content="@appflowy" />
|
||||
<meta name="twitter:creator" content="@appflowy" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
|
||||
|
||||
</head>
|
||||
<body id="body">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"coverage": "pnpm run test:unit && pnpm run test:components"
|
||||
},
|
||||
"dependencies": {
|
||||
"@appflowyinc/client-api-wasm": "0.0.3",
|
||||
"@appflowyinc/client-api-wasm": "0.1.1",
|
||||
"@atlaskit/primitives": "^5.5.3",
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -43,6 +43,8 @@
|
||||
"colorthief": "^2.4.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"decimal.js": "^10.4.3",
|
||||
"dexie": "^4.0.7",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.2.1",
|
||||
"events": "^3.3.0",
|
||||
@@ -56,6 +58,7 @@
|
||||
"katex": "^0.16.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^4.0.0",
|
||||
"notistack": "^3.0.1",
|
||||
"numeral": "^2.0.6",
|
||||
"prismjs": "^1.29.0",
|
||||
"protoc-gen-ts": "0.8.7",
|
||||
@@ -136,6 +139,7 @@
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-jest": "^29.6.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"cross-env": "^7.0.3",
|
||||
"cypress": "^13.7.2",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -145,6 +149,8 @@
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"jest-environment-jsdom": "^29.6.2",
|
||||
"nyc": "^15.1.0",
|
||||
"pino": "^9.2.0",
|
||||
"pino-pretty": "^11.2.1",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-tailwindcss": "^0.2.2",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@appflowyinc/client-api-wasm':
|
||||
specifier: 0.0.3
|
||||
version: 0.0.3
|
||||
specifier: 0.1.1
|
||||
version: 0.1.1
|
||||
'@atlaskit/primitives':
|
||||
specifier: ^5.5.3
|
||||
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
|
||||
@@ -58,6 +62,12 @@ dependencies:
|
||||
decimal.js:
|
||||
specifier: ^10.4.3
|
||||
version: 10.4.3
|
||||
dexie:
|
||||
specifier: ^4.0.7
|
||||
version: 4.0.7
|
||||
dexie-react-hooks:
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0)
|
||||
emoji-mart:
|
||||
specifier: ^5.5.2
|
||||
version: 5.6.0
|
||||
@@ -97,6 +107,9 @@ dependencies:
|
||||
nanoid:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.2
|
||||
notistack:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
|
||||
numeral:
|
||||
specifier: ^2.0.6
|
||||
version: 2.0.6
|
||||
@@ -333,6 +346,9 @@ devDependencies:
|
||||
chalk:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
cheerio:
|
||||
specifier: 1.0.0-rc.12
|
||||
version: 1.0.0-rc.12
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
@@ -360,6 +376,12 @@ devDependencies:
|
||||
nyc:
|
||||
specifier: ^15.1.0
|
||||
version: 15.1.0
|
||||
pino:
|
||||
specifier: ^9.2.0
|
||||
version: 9.2.0
|
||||
pino-pretty:
|
||||
specifier: ^11.2.1
|
||||
version: 11.2.1
|
||||
postcss:
|
||||
specifier: ^8.4.21
|
||||
version: 8.4.21
|
||||
@@ -429,8 +451,8 @@ packages:
|
||||
'@jridgewell/gen-mapping': 0.3.5
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
|
||||
/@appflowyinc/client-api-wasm@0.0.3:
|
||||
resolution: {integrity: sha512-ARjLhiDZ8MiZ9egWDbAX9VAdXXS30av+InCPLrS/iqCMYrhuuU9rxS9jQeNEB7jucFrj158gBRusimFN7P/lyw==}
|
||||
/@appflowyinc/client-api-wasm@0.1.1:
|
||||
resolution: {integrity: sha512-7+/TCmzMi9KrxX3HFLJv9R6ON2AO5xQavV547ii7RZM8+5bZJakuf6+pnyCzOquQX07q3ZYwJCa3MIgDvficaA==}
|
||||
dev: false
|
||||
|
||||
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
|
||||
@@ -4580,6 +4602,13 @@ packages:
|
||||
deprecated: Use your platform's native atob() and btoa() methods instead
|
||||
dev: true
|
||||
|
||||
/abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
dev: true
|
||||
|
||||
/acorn-globals@7.0.1:
|
||||
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
|
||||
dependencies:
|
||||
@@ -4886,6 +4915,11 @@ packages:
|
||||
engines: {node: '>= 4.0.0'}
|
||||
dev: true
|
||||
|
||||
/atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dev: true
|
||||
|
||||
/autoprefixer@10.4.13(postcss@8.4.21):
|
||||
resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -5180,6 +5214,13 @@ packages:
|
||||
ieee754: 1.2.1
|
||||
dev: true
|
||||
|
||||
/buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
dev: true
|
||||
|
||||
/cachedir@2.4.0:
|
||||
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5289,6 +5330,30 @@ packages:
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dev: true
|
||||
|
||||
/cheerio-select@2.1.0:
|
||||
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
css-select: 5.1.0
|
||||
css-what: 6.1.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.1.0
|
||||
dev: true
|
||||
|
||||
/cheerio@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
|
||||
engines: {node: '>= 6'}
|
||||
dependencies:
|
||||
cheerio-select: 2.1.0
|
||||
dom-serializer: 2.0.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.1.0
|
||||
htmlparser2: 8.0.2
|
||||
parse5: 7.1.2
|
||||
parse5-htmlparser2-tree-adapter: 7.0.0
|
||||
dev: true
|
||||
|
||||
/chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
@@ -5739,6 +5804,10 @@ packages:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.1
|
||||
|
||||
/dateformat@4.6.3:
|
||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||
dev: true
|
||||
|
||||
/dayjs@1.11.10:
|
||||
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
|
||||
dev: true
|
||||
@@ -5860,6 +5929,22 @@ packages:
|
||||
minimist: 1.2.8
|
||||
dev: true
|
||||
|
||||
/dexie-react-hooks@1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16'
|
||||
dexie: ^3.2 || ^4.0.1-alpha
|
||||
react: '>=16'
|
||||
dependencies:
|
||||
'@types/react': 18.2.66
|
||||
dexie: 4.0.7
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/dexie@4.0.7:
|
||||
resolution: {integrity: sha512-M+Lo6rk4pekIfrc2T0o2tvVJwL6EAAM/B78DNfb8aaxFVoI1f8/rz5KTxuAnApkwqTSuxx7T5t0RKH7qprapGg==}
|
||||
dev: false
|
||||
|
||||
/didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
dev: true
|
||||
@@ -6372,6 +6457,11 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/eventemitter2@6.4.7:
|
||||
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
|
||||
dev: true
|
||||
@@ -6455,6 +6545,10 @@ packages:
|
||||
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
|
||||
engines: {'0': node >=0.6.0}
|
||||
|
||||
/fast-copy@3.0.2:
|
||||
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
|
||||
dev: true
|
||||
|
||||
/fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -6492,6 +6586,15 @@ packages:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
dev: true
|
||||
|
||||
/fast-redact@3.5.0:
|
||||
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
dev: true
|
||||
|
||||
/fastq@1.17.1:
|
||||
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
|
||||
dependencies:
|
||||
@@ -6961,6 +7064,10 @@ packages:
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
dev: true
|
||||
|
||||
/hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
dependencies:
|
||||
@@ -6983,6 +7090,15 @@ packages:
|
||||
void-elements: 3.1.0
|
||||
dev: false
|
||||
|
||||
/htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.1.0
|
||||
entities: 4.5.0
|
||||
dev: true
|
||||
|
||||
/http-proxy-agent@5.0.0:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -7881,6 +7997,11 @@ packages:
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
/joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/jpeg-js@0.4.4:
|
||||
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
|
||||
dev: false
|
||||
@@ -8440,6 +8561,21 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/notistack@3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==}
|
||||
engines: {node: '>=12.0.0', npm: '>=6.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
clsx: 1.2.1
|
||||
goober: 2.1.14(csstype@3.1.3)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- csstype
|
||||
dev: false
|
||||
|
||||
/npm-run-path@4.0.1:
|
||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8576,6 +8712,11 @@ packages:
|
||||
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
|
||||
dev: false
|
||||
|
||||
/on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
dev: true
|
||||
|
||||
/once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
dependencies:
|
||||
@@ -8721,6 +8862,13 @@ packages:
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
/parse5-htmlparser2-tree-adapter@7.0.0:
|
||||
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
parse5: 7.1.2
|
||||
dev: true
|
||||
|
||||
/parse5@7.1.2:
|
||||
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
|
||||
dependencies:
|
||||
@@ -8810,6 +8958,54 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/pino-abstract-transport@1.2.0:
|
||||
resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==}
|
||||
dependencies:
|
||||
readable-stream: 4.5.2
|
||||
split2: 4.2.0
|
||||
dev: true
|
||||
|
||||
/pino-pretty@11.2.1:
|
||||
resolution: {integrity: sha512-O05NuD9tkRasFRWVaF/uHLOvoRDFD7tb5VMertr78rbsYFjYp48Vg3477EshVAF5eZaEw+OpDl/tu+B0R5o+7g==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
colorette: 2.0.20
|
||||
dateformat: 4.6.3
|
||||
fast-copy: 3.0.2
|
||||
fast-safe-stringify: 2.1.1
|
||||
help-me: 5.0.0
|
||||
joycon: 3.1.1
|
||||
minimist: 1.2.8
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 1.2.0
|
||||
pump: 3.0.0
|
||||
readable-stream: 4.5.2
|
||||
secure-json-parse: 2.7.0
|
||||
sonic-boom: 4.0.1
|
||||
strip-json-comments: 3.1.1
|
||||
dev: true
|
||||
|
||||
/pino-std-serializers@7.0.0:
|
||||
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||
dev: true
|
||||
|
||||
/pino@9.2.0:
|
||||
resolution: {integrity: sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
fast-redact: 3.5.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 1.2.0
|
||||
pino-std-serializers: 7.0.0
|
||||
process-warning: 3.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.4.3
|
||||
sonic-boom: 4.0.1
|
||||
thread-stream: 3.1.0
|
||||
dev: true
|
||||
|
||||
/pirates@4.0.6:
|
||||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -9027,6 +9223,10 @@ packages:
|
||||
fromentries: 1.3.2
|
||||
dev: true
|
||||
|
||||
/process-warning@3.0.0:
|
||||
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
|
||||
dev: true
|
||||
|
||||
/process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
@@ -9115,6 +9315,10 @@ packages:
|
||||
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
|
||||
dev: true
|
||||
|
||||
/quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
dev: true
|
||||
|
||||
/quick-lru@5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -9635,12 +9839,28 @@ packages:
|
||||
util-deprecate: 1.0.2
|
||||
dev: true
|
||||
|
||||
/readable-stream@4.5.2:
|
||||
resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
dev: true
|
||||
|
||||
/readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
/real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
dev: true
|
||||
|
||||
/redux-thunk@3.1.0(redux@5.0.1):
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
@@ -9924,6 +10144,11 @@ packages:
|
||||
is-regex: 1.1.4
|
||||
dev: true
|
||||
|
||||
/safe-stable-stringify@2.4.3:
|
||||
resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
@@ -9973,6 +10198,10 @@ packages:
|
||||
compute-scroll-into-view: 3.1.0
|
||||
dev: false
|
||||
|
||||
/secure-json-parse@2.7.0:
|
||||
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
|
||||
dev: true
|
||||
|
||||
/semver@5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
@@ -10151,6 +10380,12 @@ packages:
|
||||
tslib: 2.6.2
|
||||
dev: true
|
||||
|
||||
/sonic-boom@4.0.1:
|
||||
resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==}
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
dev: true
|
||||
|
||||
/source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -10194,6 +10429,11 @@ packages:
|
||||
which: 2.0.2
|
||||
dev: true
|
||||
|
||||
/split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
dev: true
|
||||
|
||||
/sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
@@ -10306,6 +10546,12 @@ packages:
|
||||
safe-buffer: 5.1.2
|
||||
dev: true
|
||||
|
||||
/string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
dev: true
|
||||
|
||||
/strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -10499,6 +10745,12 @@ packages:
|
||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||
dev: true
|
||||
|
||||
/thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
dev: true
|
||||
|
||||
/throttleit@1.0.1:
|
||||
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
||||
dev: true
|
||||
@@ -11414,7 +11666,3 @@ packages:
|
||||
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
|
||||
engines: {node: '>=12.20'}
|
||||
dev: true
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 731 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 765 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 189 KiB |
@@ -0,0 +1,35 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jestCoverageFile = path.join(__dirname, '../coverage/jest/coverage-final.json');
|
||||
const cypressCoverageFile = path.join(__dirname, '../coverage/cypress/coverage-final.json');
|
||||
const nycOutputDir = path.join(__dirname, '../coverage/.nyc_output');
|
||||
|
||||
// Ensure .nyc_output directory exists
|
||||
if (fs.existsSync(nycOutputDir)) {
|
||||
fs.rmSync(nycOutputDir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(nycOutputDir, { recursive: true });
|
||||
|
||||
if (fs.existsSync(path.join(__dirname, '../coverage/merged'))) {
|
||||
fs.rmSync(path.join(__dirname, '../coverage/merged'), { recursive: true });
|
||||
}
|
||||
// Copy Jest coverage file
|
||||
fs.copyFileSync(jestCoverageFile, path.join(nycOutputDir, 'jest-coverage.json'));
|
||||
// Copy Cypress E2E coverage file
|
||||
fs.copyFileSync(cypressCoverageFile, path.join(nycOutputDir, 'cypress-coverage.json'));
|
||||
|
||||
// Merge coverage files
|
||||
execSync('nyc merge ./coverage/.nyc_output ./coverage/merged/coverage-final.json', { stdio: 'inherit' });
|
||||
|
||||
// Move the merged result to the .nyc_output directory
|
||||
fs.rmSync(nycOutputDir, { recursive: true });
|
||||
fs.mkdirSync(nycOutputDir, { recursive: true });
|
||||
fs.copyFileSync(path.join(__dirname, '../coverage/merged/coverage-final.json'), path.join(nycOutputDir, 'out.json'));
|
||||
|
||||
// Generate final merged report
|
||||
execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output', { stdio: 'inherit' });
|
||||
console.log(`Merged coverage report written to coverage/merged`);
|
||||
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const pino = require('pino');
|
||||
const cheerio = require('cheerio');
|
||||
const axios = require('axios');
|
||||
|
||||
const distDir = path.join(__dirname, 'dist');
|
||||
const indexPath = path.join(distDir, 'index.html');
|
||||
|
||||
const setOrUpdateMetaTag = ($, selector, attribute, content) => {
|
||||
if ($(selector).length === 0) {
|
||||
$('head').append(`<meta ${attribute}="${selector.match(/\[(.*?)\]/)[1]}" content="${content}">`);
|
||||
} else {
|
||||
$(selector).attr('content', content);
|
||||
}
|
||||
};
|
||||
// Create a new logger instance
|
||||
const logger = pino({
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
level: 'info',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
destination: `${__dirname}/pino-logger.log`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const logRequestTimer = (req) => {
|
||||
const start = Date.now();
|
||||
const pathname = new URL(req.url).pathname;
|
||||
logger.info(`Incoming request: ${pathname}`);
|
||||
return () => {
|
||||
const duration = Date.now() - start;
|
||||
logger.info(`Request for ${pathname} took ${duration}ms`);
|
||||
};
|
||||
};
|
||||
|
||||
const fetchMetaData = async (url) => {
|
||||
try {
|
||||
const response = await axios.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching meta data', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createServer = async (req) => {
|
||||
const timer = logRequestTimer(req);
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const pageId = req.url.split('/').pop();
|
||||
let htmlData = fs.readFileSync(indexPath, 'utf8');
|
||||
const $ = cheerio.load(htmlData);
|
||||
if (!pageId) {
|
||||
timer();
|
||||
return new Response($.html(), {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
}
|
||||
|
||||
const description = 'Write, share, comment, react, and publish docs quickly and securely on AppFlowy.';
|
||||
let title = 'AppFlowy';
|
||||
const url = 'https://appflowy.com';
|
||||
let image = 'https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png';
|
||||
// Inject meta data into the HTML to support SEO and social sharing
|
||||
// if (metaData) {
|
||||
// title = metaData.title;
|
||||
// image = metaData.image;
|
||||
// }
|
||||
|
||||
$('title').text(title);
|
||||
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url);
|
||||
setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'article');
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image');
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title);
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description);
|
||||
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
|
||||
|
||||
timer();
|
||||
return new Response($.html(), {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
} else {
|
||||
timer();
|
||||
logger.error({ message: 'Method not allowed', method: req.method });
|
||||
return new Response('Method not allowed', { status: 405 });
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
try {
|
||||
Bun.serve({
|
||||
port: 3000,
|
||||
fetch: createServer,
|
||||
error: (err) => {
|
||||
logger.error(`Internal Server Error: ${err}`);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
},
|
||||
});
|
||||
logger.info(`Server is running on port 3000`);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
@@ -32,6 +32,7 @@ export enum BlockType {
|
||||
OutlineBlock = 'outline',
|
||||
TableBlock = 'table',
|
||||
TableCell = 'table/cell',
|
||||
LinkPreview = 'link_preview',
|
||||
}
|
||||
|
||||
export enum InlineBlockType {
|
||||
@@ -46,7 +47,7 @@ export enum AlignType {
|
||||
}
|
||||
|
||||
export interface BlockData {
|
||||
bg_color?: string;
|
||||
bgColor?: string;
|
||||
font_color?: string;
|
||||
align?: AlignType;
|
||||
}
|
||||
@@ -79,6 +80,10 @@ export interface MathEquationBlockData extends BlockData {
|
||||
formula?: string;
|
||||
}
|
||||
|
||||
export interface LinkPreviewBlockData extends BlockData {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export enum ImageType {
|
||||
Local = 0,
|
||||
Internal = 1,
|
||||
@@ -636,3 +641,27 @@ export enum LineHeightLayout {
|
||||
normal = 'normal',
|
||||
large = 'large',
|
||||
}
|
||||
|
||||
export interface ViewMetaIcon {
|
||||
ty: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PublishViewInfo {
|
||||
view_id: string;
|
||||
name: string;
|
||||
icon: ViewMetaIcon | null;
|
||||
extra: string | null;
|
||||
layout: number;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
last_edited_time: string;
|
||||
last_edited_by: string;
|
||||
child_views: PublishViewInfo[] | null;
|
||||
}
|
||||
|
||||
export interface PublishViewMetaData {
|
||||
view: PublishViewInfo;
|
||||
child_views: PublishViewInfo[];
|
||||
ancestor_views: PublishViewInfo[];
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
useSortsSelector,
|
||||
} from '../selector';
|
||||
import { useDatabaseViewId } from '../context';
|
||||
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
|
||||
import { withTestingDatabase } from '@/application/database-yjs/__tests__/withTestingData';
|
||||
import { expect } from '@jest/globals';
|
||||
@@ -31,11 +30,9 @@ const wrapperCreator =
|
||||
(viewId: string, doc: YDoc, rowDocMap: Y.Map<YDoc>) =>
|
||||
({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<IdProvider objectId={viewId}>
|
||||
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
|
||||
{children}
|
||||
</DatabaseContextProvider>
|
||||
</IdProvider>
|
||||
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
|
||||
{children}
|
||||
</DatabaseContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { createContext, useContext } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@@ -9,6 +10,10 @@ export interface DatabaseContextState {
|
||||
rowDocMap: Y.Map<YDoc>;
|
||||
isDatabaseRowPage?: boolean;
|
||||
navigateToRow?: (rowId: string) => void;
|
||||
loadView?: (viewId: string) => Promise<YDoc>;
|
||||
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
|
||||
navigateToView?: (viewId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const DatabaseContext = createContext<DatabaseContextState | null>(null);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
FieldId,
|
||||
SortId,
|
||||
YDatabaseField,
|
||||
YDoc,
|
||||
YjsDatabaseKey,
|
||||
YjsEditorKey,
|
||||
YjsFolderKey,
|
||||
} from '@/application/collab.type';
|
||||
import { FieldId, SortId, YDatabaseField, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { getCell, metaIdFromRowId, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
|
||||
import {
|
||||
useDatabase,
|
||||
@@ -19,7 +11,6 @@ import {
|
||||
import { filterBy, parseFilter } from '@/application/database-yjs/filter';
|
||||
import { groupByField } from '@/application/database-yjs/group';
|
||||
import { sortBy } from '@/application/database-yjs/sort';
|
||||
import { useViewsIdSelector } from '@/application/folder-yjs';
|
||||
import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse';
|
||||
import { DateTimeCell } from '@/application/database-yjs/cell.type';
|
||||
import * as dayjs from 'dayjs';
|
||||
@@ -42,9 +33,8 @@ export interface Row {
|
||||
|
||||
const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
|
||||
|
||||
export function useDatabaseViewsSelector(iidIndex: string) {
|
||||
export function useDatabaseViewsSelector(_iidIndex: string) {
|
||||
const database = useDatabase();
|
||||
const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector();
|
||||
|
||||
const views = database?.get(YjsDatabaseKey.views);
|
||||
const [viewIds, setViewIds] = useState<string[]>([]);
|
||||
@@ -65,22 +55,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
|
||||
return Number(viewB.created_at) - Number(viewA.created_at);
|
||||
});
|
||||
|
||||
const viewsId = [];
|
||||
|
||||
for (const viewItem of viewsSorted) {
|
||||
const [key] = viewItem;
|
||||
const view = folderViews?.get(key);
|
||||
|
||||
if (
|
||||
visibleViewsId.includes(key) &&
|
||||
view &&
|
||||
(view.get(YjsFolderKey.bid) === iidIndex || view.get(YjsFolderKey.id) === iidIndex)
|
||||
) {
|
||||
viewsId.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
setViewIds(viewsId);
|
||||
setViewIds(viewsSorted.map(([key]) => key));
|
||||
};
|
||||
|
||||
observerEvent();
|
||||
@@ -89,7 +64,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
|
||||
return () => {
|
||||
views.unobserve(observerEvent);
|
||||
};
|
||||
}, [visibleViewsId, views, folderViews, iidIndex]);
|
||||
}, [views]);
|
||||
|
||||
return {
|
||||
childViews,
|
||||
|
||||
@@ -2,6 +2,17 @@ import { YDoc } from '@/application/collab.type';
|
||||
import { databasePrefix } from '@/application/constants';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import * as Y from 'yjs';
|
||||
import BaseDexie from 'dexie';
|
||||
import { viewMetasSchema, ViewMetasTable } from '@/application/db/tables/view_metas';
|
||||
|
||||
type DexieTables = ViewMetasTable;
|
||||
|
||||
export type Dexie<T = DexieTables> = BaseDexie & T;
|
||||
|
||||
export const db = new BaseDexie(`${databasePrefix}_cache`) as Dexie;
|
||||
const schema = Object.assign({}, viewMetasSchema);
|
||||
|
||||
db.version(1).stores(schema);
|
||||
|
||||
const openedSet = new Set<string>();
|
||||
|
||||
@@ -32,10 +43,16 @@ export async function openCollabDB(docName: string): Promise<YDoc> {
|
||||
return doc as YDoc;
|
||||
}
|
||||
|
||||
export function getCollabDBName(id: string, type: string, uuid?: string) {
|
||||
if (!uuid) {
|
||||
return `${type}_${id}`;
|
||||
export async function closeCollabDB(docName: string) {
|
||||
const name = `${databasePrefix}_${docName}`;
|
||||
|
||||
if (openedSet.has(name)) {
|
||||
openedSet.delete(name);
|
||||
}
|
||||
|
||||
return `${uuid}_${type}_${id}`;
|
||||
const doc = new Y.Doc();
|
||||
|
||||
const provider = new IndexeddbPersistence(name, doc);
|
||||
|
||||
await provider.destroy();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Table } from 'dexie';
|
||||
import { PublishViewInfo } from '@/application/collab.type';
|
||||
|
||||
export type ViewMeta = {
|
||||
publish_name: string;
|
||||
|
||||
child_views: PublishViewInfo[];
|
||||
ancestor_views: PublishViewInfo[];
|
||||
} & PublishViewInfo;
|
||||
|
||||
export type ViewMetasTable = {
|
||||
view_metas: Table<ViewMeta>;
|
||||
};
|
||||
|
||||
export const viewMetasSchema = {
|
||||
view_metas: 'publish_name',
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ViewLayout, YFolder, YjsFolderKey } from '@/application/collab.type';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export interface Crumb {
|
||||
viewId: string;
|
||||
rowId?: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const FolderContext = createContext<{
|
||||
folder: YFolder | null;
|
||||
onNavigateToView?: (viewId: string) => void;
|
||||
crumbs?: Crumb[];
|
||||
setCrumbs?: React.Dispatch<React.SetStateAction<Crumb[]>>;
|
||||
} | null>(null);
|
||||
|
||||
export const useFolderContext = () => {
|
||||
return useContext(FolderContext)?.folder;
|
||||
};
|
||||
|
||||
export const useViewLayout = () => {
|
||||
const folder = useFolderContext();
|
||||
const { objectId } = useParams();
|
||||
const views = folder?.get(YjsFolderKey.views);
|
||||
const view = objectId ? views?.get(objectId) : null;
|
||||
|
||||
return Number(view?.get(YjsFolderKey.layout)) as ViewLayout;
|
||||
};
|
||||
|
||||
export const useNavigateToView = () => {
|
||||
return useContext(FolderContext)?.onNavigateToView;
|
||||
};
|
||||
|
||||
export const useCrumbs = () => {
|
||||
return useContext(FolderContext)?.crumbs;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export enum CoverType {
|
||||
NormalColor = 'color',
|
||||
GradientColor = 'gradient',
|
||||
BuildInImage = 'built_in',
|
||||
CustomImage = 'custom',
|
||||
LocalImage = 'local',
|
||||
UpsplashImage = 'unsplash',
|
||||
None = 'none',
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './selector';
|
||||
export * from './context';
|
||||
@@ -1,70 +0,0 @@
|
||||
import { YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { useFolderContext } from '@/application/folder-yjs/context';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useViewsIdSelector() {
|
||||
const folder = useFolderContext();
|
||||
const [viewsId, setViewsId] = useState<string[]>([]);
|
||||
const views = folder?.get(YjsFolderKey.views);
|
||||
const trash = folder?.get(YjsFolderKey.section)?.get(YjsFolderKey.trash);
|
||||
const meta = folder?.get(YjsFolderKey.meta);
|
||||
|
||||
useEffect(() => {
|
||||
if (!views) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trashUid = trash ? Array.from(trash.keys())[0] : null;
|
||||
const userTrash = trashUid ? trash?.get(trashUid) : null;
|
||||
|
||||
const collectIds = () => {
|
||||
const trashIds = userTrash?.toJSON()?.map((item) => item.id) || [];
|
||||
|
||||
return Array.from(views.keys()).filter((id) => {
|
||||
return !trashIds.includes(id) && id !== meta?.get(YjsFolderKey.current_workspace);
|
||||
});
|
||||
};
|
||||
|
||||
setViewsId(collectIds());
|
||||
const observerEvent = () => setViewsId(collectIds());
|
||||
|
||||
views.observe(observerEvent);
|
||||
userTrash?.observe(observerEvent);
|
||||
|
||||
return () => {
|
||||
views.unobserve(observerEvent);
|
||||
userTrash?.unobserve(observerEvent);
|
||||
};
|
||||
}, [views, trash, meta]);
|
||||
|
||||
return {
|
||||
viewsId,
|
||||
views,
|
||||
};
|
||||
}
|
||||
|
||||
export function useViewSelector(viewId: string) {
|
||||
const folder = useFolderContext();
|
||||
const [clock, setClock] = useState<number>(0);
|
||||
const [view, setView] = useState<YView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!folder) return;
|
||||
|
||||
const view = folder.get(YjsFolderKey.views)?.get(viewId);
|
||||
|
||||
setView(view || null);
|
||||
const observerEvent = () => setClock((prev) => prev + 1);
|
||||
|
||||
view?.observe(observerEvent);
|
||||
|
||||
return () => {
|
||||
view?.unobserve(observerEvent);
|
||||
};
|
||||
}, [folder, viewId]);
|
||||
|
||||
return {
|
||||
clock,
|
||||
view,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { db } from '@/application/db';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { createContext, useCallback, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export interface PublishContextType {
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
viewMeta?: ViewMeta;
|
||||
toView: (viewId: string) => Promise<void>;
|
||||
loadViewMeta: (viewId: string) => Promise<ViewMeta>;
|
||||
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
|
||||
|
||||
loadView: (viewId: string) => Promise<YDoc>;
|
||||
}
|
||||
|
||||
export const PublishContext = createContext<PublishContextType | null>(null);
|
||||
|
||||
export const PublishProvider = ({
|
||||
children,
|
||||
namespace,
|
||||
publishName,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
}) => {
|
||||
const viewMeta = useLiveQuery(async () => {
|
||||
const name = `${namespace}_${publishName}`;
|
||||
|
||||
return db.view_metas.get(name);
|
||||
}, [namespace, publishName]);
|
||||
const service = useContext(AFConfigContext)?.service;
|
||||
const navigate = useNavigate();
|
||||
const toView = useCallback(
|
||||
async (viewId: string) => {
|
||||
try {
|
||||
const res = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('Not found');
|
||||
}
|
||||
|
||||
const { namespace, publishName } = res;
|
||||
|
||||
navigate(`/${namespace}/${publishName}`);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[navigate, service]
|
||||
);
|
||||
|
||||
const loadViewMeta = useCallback(
|
||||
async (viewId: string) => {
|
||||
try {
|
||||
const info = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!info) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
const { namespace, publishName } = info;
|
||||
|
||||
const res = await service?.getPublishViewMeta(namespace, publishName);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('View meta has not been published yet');
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[service]
|
||||
);
|
||||
|
||||
const getViewRowsMap = useCallback(
|
||||
async (viewId: string, rowIds: string[]) => {
|
||||
try {
|
||||
const info = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!info) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
const { namespace, publishName } = info;
|
||||
const res = await service?.getPublishDatabaseViewRows(namespace, publishName, rowIds);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[service]
|
||||
);
|
||||
|
||||
const loadView = useCallback(
|
||||
async (viewId: string) => {
|
||||
try {
|
||||
const res = await service?.getPublishInfo(viewId);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
const { namespace, publishName } = res;
|
||||
|
||||
const data = service?.getPublishView(namespace, publishName);
|
||||
|
||||
if (!data) {
|
||||
throw new Error('View has not been published yet');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[service]
|
||||
);
|
||||
|
||||
return (
|
||||
<PublishContext.Provider
|
||||
value={{
|
||||
loadView,
|
||||
viewMeta,
|
||||
getViewRowsMap,
|
||||
loadViewMeta,
|
||||
toView,
|
||||
namespace,
|
||||
publishName,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PublishContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePublishContext() {
|
||||
return useContext(PublishContext);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './context';
|
||||
@@ -1,310 +0,0 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import * as Y from 'yjs';
|
||||
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
|
||||
import { expect } from '@jest/globals';
|
||||
import { getCollab, batchCollab, collabTypeToDBType } from '../cache';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import { getCollabDBName, openCollabDB } from '../cache/db';
|
||||
import { StrategyType } from '../cache/types';
|
||||
|
||||
jest.mock('@/application/ydoc/apply', () => ({
|
||||
applyYDoc: jest.fn(),
|
||||
}));
|
||||
jest.mock('../cache/db', () => ({
|
||||
openCollabDB: jest.fn(),
|
||||
getCollabDBName: jest.fn(),
|
||||
}));
|
||||
|
||||
const emptyDoc = new Y.Doc();
|
||||
const normalDoc = withTestingYDoc('1');
|
||||
const mockFetcher = jest.fn();
|
||||
const mockBatchFetcher = jest.fn();
|
||||
|
||||
describe('Cache functions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCollab', () => {
|
||||
describe('with CACHE_ONLY strategy', () => {
|
||||
it('should throw error when no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await expect(
|
||||
getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_ONLY
|
||||
)
|
||||
).rejects.toThrow('No cache found');
|
||||
});
|
||||
it('should fetch collab with CACHE_ONLY strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_ONLY
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).not.toHaveBeenCalled();
|
||||
expect(applyYDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_FIRST strategy', () => {
|
||||
it('should fetch collab with CACHE_FIRST strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).not.toHaveBeenCalled();
|
||||
expect(applyYDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch collab with CACHE_FIRST strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(result).toBe(emptyDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_AND_NETWORK strategy', () => {
|
||||
it('should fetch collab with CACHE_AND_NETWORK strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch collab with CACHE_AND_NETWORK strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(result).toBe(emptyDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with default strategy', () => {
|
||||
it('should fetch collab with default strategy', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
|
||||
|
||||
const result = await getCollab(
|
||||
mockFetcher,
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
StrategyType.NETWORK_ONLY
|
||||
);
|
||||
|
||||
expect(result).toBe(normalDoc);
|
||||
expect(mockFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchCollab', () => {
|
||||
describe('with CACHE_ONLY strategy', () => {
|
||||
it('should batch fetch collabs with CACHE_ONLY strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await expect(
|
||||
batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_ONLY
|
||||
)
|
||||
).rejects.toThrow('No cache found');
|
||||
});
|
||||
|
||||
it('should batch fetch collabs with CACHE_ONLY strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_ONLY
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_FIRST strategy', () => {
|
||||
it('should batch fetch collabs with CACHE_FIRST strategy and existing cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should batch fetch collabs with CACHE_FIRST strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_FIRST
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with CACHE_AND_NETWORK strategy', () => {
|
||||
it('should batch fetch collabs with CACHE_AND_NETWORK strategy', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should batch fetch collabs with CACHE_AND_NETWORK strategy and no cache', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
|
||||
|
||||
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
|
||||
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
|
||||
|
||||
await batchCollab(
|
||||
mockBatchFetcher,
|
||||
[
|
||||
{
|
||||
collabId: 'id1',
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
],
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
expect(mockBatchFetcher).toHaveBeenCalled();
|
||||
expect(applyYDoc).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabTypeToDBType', () => {
|
||||
it('should return correct DB type', () => {
|
||||
expect(collabTypeToDBType(CollabType.Document)).toBe('document');
|
||||
expect(collabTypeToDBType(CollabType.Folder)).toBe('folder');
|
||||
expect(collabTypeToDBType(CollabType.Database)).toBe('database');
|
||||
expect(collabTypeToDBType(CollabType.WorkspaceDatabase)).toBe('databases');
|
||||
expect(collabTypeToDBType(CollabType.DatabaseRow)).toBe('database_row');
|
||||
expect(collabTypeToDBType(CollabType.UserAwareness)).toBe('user_awareness');
|
||||
expect(collabTypeToDBType(CollabType.Empty)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from '@jest/globals';
|
||||
import { fetchCollab, batchFetchCollab } from '../fetch';
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch';
|
||||
import { APIService } from '@/application/services/js-services/wasm';
|
||||
|
||||
jest.mock('@/application/services/js-services/wasm', () => {
|
||||
return {
|
||||
APIService: {
|
||||
getCollab: jest.fn(),
|
||||
batchGetCollab: jest.fn(),
|
||||
getPublishView: jest.fn(),
|
||||
getPublishViewMeta: jest.fn(),
|
||||
getPublishInfoWithViewId: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -17,41 +17,100 @@ describe('Collab fetch functions with deduplication', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchCollab', () => {
|
||||
it('should fetch collab without duplicating requests', async () => {
|
||||
const workspaceId = 'workspace1';
|
||||
const id = 'id1';
|
||||
const type = CollabType.Document;
|
||||
describe('fetchPublishView', () => {
|
||||
it('should fetch publish view without duplicating requests', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getCollab as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchCollab(workspaceId, id, type);
|
||||
const result2 = fetchCollab(workspaceId, id, type);
|
||||
const result1 = fetchPublishView(namespace, publishName);
|
||||
const result2 = fetchPublishView(namespace, publishName);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getCollab).toHaveBeenCalledTimes(1);
|
||||
expect(APIService.getPublishView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fetch publish view with different params', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchPublishView(namespace, publishName);
|
||||
const result2 = fetchPublishView(namespace, 'publish2');
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
await expect(result2).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishView).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchFetchCollab', () => {
|
||||
it('should batch fetch collabs without duplicating requests', async () => {
|
||||
const workspaceId = 'workspace1';
|
||||
const params = [
|
||||
{ collabId: 'id1', collabType: CollabType.Document },
|
||||
{ collabId: 'id2', collabType: CollabType.Folder },
|
||||
];
|
||||
describe('fetchViewInfo', () => {
|
||||
it('should fetch view info without duplicating requests', async () => {
|
||||
const viewId = 'view1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.batchGetCollab as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = batchFetchCollab(workspaceId, params);
|
||||
const result2 = batchFetchCollab(workspaceId, params);
|
||||
const result1 = fetchViewInfo(viewId);
|
||||
const result2 = fetchViewInfo(viewId);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
expect(APIService.batchGetCollab).toHaveBeenCalledTimes(1);
|
||||
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fetch view info with different params', async () => {
|
||||
const viewId = 'view1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchViewInfo(viewId);
|
||||
const result2 = fetchViewInfo('view2');
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
await expect(result2).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPublishViewMeta', () => {
|
||||
it('should fetch publish view meta without duplicating requests', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchPublishViewMeta(namespace, publishName);
|
||||
const result2 = fetchPublishViewMeta(namespace, publishName);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fetch publish view meta with different params', async () => {
|
||||
const namespace = 'namespace1';
|
||||
const publishName = 'publish1';
|
||||
const mockResponse = { data: 'mockData' };
|
||||
|
||||
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = fetchPublishViewMeta(namespace, publishName);
|
||||
const result2 = fetchPublishViewMeta(namespace, 'publish2');
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
await expect(result1).resolves.toEqual(mockResponse);
|
||||
await expect(result2).resolves.toEqual(mockResponse);
|
||||
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
|
||||
import { AFClientService } from '../index';
|
||||
import { fetchViewInfo } from '@/application/services/js-services/fetch';
|
||||
import { expect, jest } from '@jest/globals';
|
||||
import { getBatchCollabs, getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache';
|
||||
|
||||
jest.mock('@/application/services/js-services/wasm/client_api', () => {
|
||||
return {
|
||||
initAPIService: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('nanoid', () => {
|
||||
return {
|
||||
nanoid: jest.fn().mockReturnValue('12345678'),
|
||||
};
|
||||
});
|
||||
jest.mock('@/application/services/js-services/fetch', () => {
|
||||
return {
|
||||
fetchPublishView: jest.fn(),
|
||||
fetchPublishViewMeta: jest.fn(),
|
||||
fetchViewInfo: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/application/services/js-services/cache', () => {
|
||||
return {
|
||||
getPublishView: jest.fn(),
|
||||
getPublishViewMeta: jest.fn(),
|
||||
getBatchCollabs: jest.fn(),
|
||||
};
|
||||
});
|
||||
describe('AFClientService', () => {
|
||||
let service: AFClientService;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AFClientService({
|
||||
cloudConfig: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
gotrueURL: 'http://localhost:3000',
|
||||
wsURL: 'ws://localhost:3000',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should get view meta', async () => {
|
||||
const namespace = 'namespace';
|
||||
const publishName = 'publishName';
|
||||
const mockResponse = {
|
||||
view_id: 'view_id',
|
||||
publish_name: publishName,
|
||||
metadata: {
|
||||
view: {
|
||||
name: 'viewName',
|
||||
view_id: 'view_id',
|
||||
},
|
||||
child_views: [],
|
||||
ancestor_views: [],
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
(getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishViewMeta(namespace, publishName);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should get view', async () => {
|
||||
const namespace = 'namespace';
|
||||
const publishName = 'publishName';
|
||||
const mockResponse = {
|
||||
data: [1, 2, 3],
|
||||
meta: {
|
||||
metadata: {
|
||||
view: {
|
||||
name: 'viewName',
|
||||
view_id: 'view_id',
|
||||
},
|
||||
child_views: [],
|
||||
ancestor_views: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
(getPublishView as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishView(namespace, publishName);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should get view info', async () => {
|
||||
const viewId = 'viewId';
|
||||
const mockResponse = {
|
||||
namespace: 'namespace',
|
||||
publish_name: 'publishName',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
(fetchViewInfo as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishInfo(viewId);
|
||||
|
||||
expect(result).toEqual({
|
||||
namespace: 'namespace',
|
||||
publishName: 'publishName',
|
||||
});
|
||||
});
|
||||
|
||||
it('getPublishDatabaseViewRows', async () => {
|
||||
const namespace = 'namespace';
|
||||
const publishName = 'publishName';
|
||||
const rowIds = ['1', '2', '3'];
|
||||
const mockResponse = [withTestingYDoc('1'), withTestingYDoc('2'), withTestingYDoc('3')];
|
||||
|
||||
// @ts-ignore
|
||||
(getBatchCollabs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await service.getPublishDatabaseViewRows(namespace, publishName, rowIds);
|
||||
|
||||
expect(result).toEqual({
|
||||
rows: expect.any(Object),
|
||||
destroy: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { AuthService } from '@/application/services/services.type';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams } from '@/application/user.type';
|
||||
import { APIService } from 'src/application/services/js-services/wasm';
|
||||
import { signInSuccess } from '@/application/services/js-services/session/auth';
|
||||
import { invalidToken } from 'src/application/services/js-services/session';
|
||||
import { afterSignInDecorator } from '@/application/services/js-services/decorator';
|
||||
|
||||
export class JSAuthService implements AuthService {
|
||||
constructor() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
getOAuthURL = async (_provider: ProviderType): Promise<string> => {
|
||||
return Promise.reject('Not implemented');
|
||||
};
|
||||
|
||||
@afterSignInDecorator(signInSuccess)
|
||||
async signInWithOAuth(_: { uri: string }): Promise<void> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||
return Promise.reject('Not implemented');
|
||||
};
|
||||
|
||||
@afterSignInDecorator(signInSuccess)
|
||||
async signinWithEmailPassword(email: string, password: string): Promise<void> {
|
||||
try {
|
||||
return APIService.signIn(email, password);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
signOut = async (): Promise<void> => {
|
||||
invalidToken();
|
||||
return APIService.logout();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
|
||||
import { expect } from '@jest/globals';
|
||||
import {
|
||||
collabTypeToDBType,
|
||||
getPublishView,
|
||||
getPublishViewMeta,
|
||||
getBatchCollabs,
|
||||
} from '@/application/services/js-services/cache';
|
||||
import { openCollabDB, db } from '@/application/db';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
|
||||
jest.mock('@/application/ydoc/apply', () => ({
|
||||
applyYDoc: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/application/db', () => ({
|
||||
openCollabDB: jest.fn(),
|
||||
db: {
|
||||
view_metas: {
|
||||
get: jest.fn(),
|
||||
put: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const normalDoc = withTestingYDoc('1');
|
||||
const mockFetcher = jest.fn();
|
||||
|
||||
async function runTestWithStrategy(strategy: StrategyType) {
|
||||
return getPublishView(
|
||||
mockFetcher,
|
||||
{
|
||||
namespace: 'appflowy',
|
||||
publishName: 'test',
|
||||
},
|
||||
strategy
|
||||
);
|
||||
}
|
||||
|
||||
async function runGetPublishViewMetaWithStrategy(strategy: StrategyType) {
|
||||
return getPublishViewMeta(
|
||||
mockFetcher,
|
||||
{
|
||||
namespace: 'appflowy',
|
||||
publishName: 'test',
|
||||
},
|
||||
strategy
|
||||
);
|
||||
}
|
||||
|
||||
describe('Cache functions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetcher.mockClear();
|
||||
(openCollabDB as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('getPublishView', () => {
|
||||
it('should call fetcher when no cache found', async () => {
|
||||
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
|
||||
await runTestWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
|
||||
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(mockFetcher).toBeCalledTimes(2);
|
||||
await expect(runTestWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
|
||||
});
|
||||
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
|
||||
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
|
||||
await runTestWithStrategy(StrategyType.CACHE_ONLY);
|
||||
expect(openCollabDB).toBeCalledTimes(1);
|
||||
|
||||
await runTestWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(openCollabDB).toBeCalledTimes(2);
|
||||
expect(mockFetcher).toBeCalledTimes(0);
|
||||
|
||||
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(openCollabDB).toBeCalledTimes(3);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublishViewMeta', () => {
|
||||
it('should call fetcher when no cache found', async () => {
|
||||
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(mockFetcher).toBeCalledTimes(2);
|
||||
|
||||
await expect(runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
|
||||
});
|
||||
|
||||
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
|
||||
|
||||
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
|
||||
const meta = await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY);
|
||||
expect(openCollabDB).toBeCalledTimes(0);
|
||||
expect(meta).toBeDefined();
|
||||
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
|
||||
expect(openCollabDB).toBeCalledTimes(0);
|
||||
expect(mockFetcher).toBeCalledTimes(0);
|
||||
|
||||
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
|
||||
expect(openCollabDB).toBeCalledTimes(0);
|
||||
expect(mockFetcher).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBatchCollabs', () => {
|
||||
it('should return empty array when no cache found', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(undefined);
|
||||
const collabs = await getBatchCollabs(['1', '2', '3']);
|
||||
expect(collabs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return collabs when cache found', async () => {
|
||||
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
|
||||
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
|
||||
const collabs = await getBatchCollabs(['1', '2', '3']);
|
||||
expect(collabs).toEqual([normalDoc, normalDoc, normalDoc]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabTypeToDBType', () => {
|
||||
it('should return correct DB type', () => {
|
||||
expect(collabTypeToDBType(CollabType.Document)).toBe('document');
|
||||
expect(collabTypeToDBType(CollabType.Folder)).toBe('folder');
|
||||
expect(collabTypeToDBType(CollabType.Database)).toBe('database');
|
||||
expect(collabTypeToDBType(CollabType.WorkspaceDatabase)).toBe('databases');
|
||||
expect(collabTypeToDBType(CollabType.DatabaseRow)).toBe('database_row');
|
||||
expect(collabTypeToDBType(CollabType.UserAwareness)).toBe('user_awareness');
|
||||
expect(collabTypeToDBType(CollabType.Empty)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { CollabType, YDoc, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
|
||||
import {
|
||||
CollabType,
|
||||
PublishViewInfo,
|
||||
PublishViewMetaData,
|
||||
YDoc,
|
||||
YjsEditorKey,
|
||||
YSharedRoot,
|
||||
} from '@/application/collab.type';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
import { getCollabDBName, openCollabDB } from './db';
|
||||
import { Fetcher, StrategyType } from './types';
|
||||
import { closeCollabDB, db, openCollabDB } from '@/application/db';
|
||||
import { Fetcher, StrategyType } from '@/application/services/js-services/cache/types';
|
||||
|
||||
export function collabTypeToDBType(type: CollabType) {
|
||||
switch (type) {
|
||||
@@ -32,30 +39,40 @@ const collabSharedRootKeyMap = {
|
||||
[CollabType.Empty]: YjsEditorKey.empty,
|
||||
};
|
||||
|
||||
export function hasCache(doc: YDoc, type: CollabType) {
|
||||
export function hasCollabCache(doc: YDoc) {
|
||||
const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
|
||||
return data.has(collabSharedRootKeyMap[type] as string);
|
||||
return Object.values(collabSharedRootKeyMap).some((key) => {
|
||||
return data.has(key);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCollab(
|
||||
fetcher: Fetcher<{
|
||||
state: Uint8Array;
|
||||
}>,
|
||||
export async function hasViewMetaCache(name: string) {
|
||||
const data = await db.view_metas.get(name);
|
||||
|
||||
return !!data;
|
||||
}
|
||||
|
||||
export async function getPublishViewMeta<
|
||||
T extends {
|
||||
view: PublishViewInfo;
|
||||
child_views: PublishViewInfo[];
|
||||
ancestor_views: PublishViewInfo[];
|
||||
}
|
||||
>(
|
||||
fetcher: Fetcher<T>,
|
||||
{
|
||||
collabId,
|
||||
collabType,
|
||||
uuid,
|
||||
namespace,
|
||||
publishName,
|
||||
}: {
|
||||
uuid?: string;
|
||||
collabId: string;
|
||||
collabType: CollabType;
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
},
|
||||
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
|
||||
) {
|
||||
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
|
||||
const collab = await openCollabDB(name);
|
||||
const exist = hasCache(collab, collabType);
|
||||
const name = `${namespace}_${publishName}`;
|
||||
const exist = await hasViewMetaCache(name);
|
||||
const meta = await db.view_metas.get(name);
|
||||
|
||||
switch (strategy) {
|
||||
case StrategyType.CACHE_ONLY: {
|
||||
@@ -63,103 +80,159 @@ export async function getCollab(
|
||||
throw new Error('No cache found');
|
||||
}
|
||||
|
||||
return collab;
|
||||
return meta;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_FIRST: {
|
||||
if (!exist) {
|
||||
await revalidateCollab(fetcher, collab);
|
||||
return revalidatePublishViewMeta(name, fetcher);
|
||||
}
|
||||
|
||||
return collab;
|
||||
return meta;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_AND_NETWORK: {
|
||||
if (!exist) {
|
||||
await revalidateCollab(fetcher, collab);
|
||||
return revalidatePublishViewMeta(name, fetcher);
|
||||
} else {
|
||||
void revalidateCollab(fetcher, collab);
|
||||
void revalidatePublishViewMeta(name, fetcher);
|
||||
}
|
||||
|
||||
return collab;
|
||||
return meta;
|
||||
}
|
||||
|
||||
default: {
|
||||
await revalidateCollab(fetcher, collab);
|
||||
|
||||
return collab;
|
||||
return revalidatePublishViewMeta(name, fetcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function revalidateCollab(
|
||||
fetcher: Fetcher<{
|
||||
state: Uint8Array;
|
||||
}>,
|
||||
collab: YDoc
|
||||
export async function getPublishView<
|
||||
T extends {
|
||||
data: number[];
|
||||
meta: {
|
||||
view: PublishViewInfo;
|
||||
child_views: PublishViewInfo[];
|
||||
ancestor_views: PublishViewInfo[];
|
||||
};
|
||||
}
|
||||
>(
|
||||
fetcher: Fetcher<T>,
|
||||
{
|
||||
namespace,
|
||||
publishName,
|
||||
}: {
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
},
|
||||
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
|
||||
) {
|
||||
const { state } = await fetcher();
|
||||
const name = `${namespace}_${publishName}`;
|
||||
const doc = await openCollabDB(name);
|
||||
const exist = (await hasViewMetaCache(name)) && hasCollabCache(doc);
|
||||
|
||||
switch (strategy) {
|
||||
case StrategyType.CACHE_ONLY: {
|
||||
if (!exist) {
|
||||
throw new Error('No cache found');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_FIRST: {
|
||||
if (!exist) {
|
||||
await revalidatePublishView(name, fetcher, doc);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case StrategyType.CACHE_AND_NETWORK: {
|
||||
if (!exist) {
|
||||
await revalidatePublishView(name, fetcher, doc);
|
||||
} else {
|
||||
void revalidatePublishView(name, fetcher, doc);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
await revalidatePublishView(name, fetcher, doc);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function revalidatePublishViewMeta<
|
||||
T extends {
|
||||
view: PublishViewInfo;
|
||||
child_views: PublishViewInfo[];
|
||||
ancestor_views: PublishViewInfo[];
|
||||
}
|
||||
>(name: string, fetcher: Fetcher<T>) {
|
||||
const { view, child_views, ancestor_views } = await fetcher();
|
||||
|
||||
await db.view_metas.put(
|
||||
{
|
||||
publish_name: name,
|
||||
...view,
|
||||
child_views: child_views,
|
||||
ancestor_views: ancestor_views,
|
||||
},
|
||||
name
|
||||
);
|
||||
|
||||
return db.view_metas.get(name);
|
||||
}
|
||||
|
||||
export async function revalidatePublishView<
|
||||
T extends {
|
||||
data: number[];
|
||||
rows?: Record<string, number[]>;
|
||||
meta: PublishViewMetaData;
|
||||
}
|
||||
>(name: string, fetcher: Fetcher<T>, collab: YDoc) {
|
||||
const { data, meta, rows } = await fetcher();
|
||||
|
||||
await db.view_metas.put(
|
||||
{
|
||||
publish_name: name,
|
||||
...meta.view,
|
||||
child_views: meta.child_views,
|
||||
ancestor_views: meta.ancestor_views,
|
||||
},
|
||||
name
|
||||
);
|
||||
|
||||
if (rows) {
|
||||
for (const [key, value] of Object.entries(rows)) {
|
||||
const row = await openCollabDB(`${name}_${key}`);
|
||||
|
||||
applyYDoc(row, new Uint8Array(value));
|
||||
}
|
||||
}
|
||||
|
||||
const state = new Uint8Array(data);
|
||||
|
||||
applyYDoc(collab, state);
|
||||
}
|
||||
|
||||
export async function batchCollab(
|
||||
batchFetcher: Fetcher<Record<string, number[]>>,
|
||||
collabs: {
|
||||
collabId: string;
|
||||
collabType: CollabType;
|
||||
uuid?: string;
|
||||
}[],
|
||||
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK,
|
||||
itemCallback?: (id: string, doc: YDoc) => void
|
||||
) {
|
||||
const collabMap = new Map<string, YDoc>();
|
||||
export async function getBatchCollabs(names: string[]) {
|
||||
const collabs = await Promise.all(names.map((name) => openCollabDB(name)));
|
||||
|
||||
for (const { collabId, collabType, uuid } of collabs) {
|
||||
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
|
||||
const collab = await openCollabDB(name);
|
||||
const exist = hasCache(collab, collabType);
|
||||
return collabs;
|
||||
}
|
||||
|
||||
collabMap.set(collabId, collab);
|
||||
if (exist) {
|
||||
itemCallback?.(collabId, collab);
|
||||
}
|
||||
}
|
||||
export async function deleteViewMeta(name: string) {
|
||||
await db.view_metas.delete(name);
|
||||
}
|
||||
|
||||
const notCacheIds = collabs.filter(({ collabId, collabType }) => {
|
||||
const id = collabMap.get(collabId);
|
||||
|
||||
if (!id) return false;
|
||||
|
||||
return !hasCache(id, collabType);
|
||||
});
|
||||
|
||||
if (strategy === StrategyType.CACHE_ONLY) {
|
||||
if (notCacheIds.length > 0) {
|
||||
throw new Error('No cache found');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (strategy === StrategyType.CACHE_FIRST && notCacheIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const states = await batchFetcher();
|
||||
|
||||
for (const [collabId, data] of Object.entries(states)) {
|
||||
const info = collabs.find((item) => item.collabId === collabId);
|
||||
const collab = collabMap.get(collabId);
|
||||
|
||||
if (!info || !collab) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = new Uint8Array(data);
|
||||
|
||||
applyYDoc(collab, state);
|
||||
|
||||
itemCallback?.(collabId, collab);
|
||||
}
|
||||
export async function deleteView(name: string) {
|
||||
console.log('deleteView', name);
|
||||
await deleteViewMeta(name);
|
||||
await closeCollabDB(name);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { batchCollab, getCollab } from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { batchFetchCollab, fetchCollab } from '@/application/services/js-services/fetch';
|
||||
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
|
||||
import { DatabaseService } from '@/application/services/services.type';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class JSDatabaseService implements DatabaseService {
|
||||
private loadedDatabaseId: Set<string> = new Set();
|
||||
|
||||
private loadedWorkspaceId: Set<string> = new Set();
|
||||
|
||||
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
|
||||
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
currentWorkspace() {
|
||||
return getCurrentWorkspace();
|
||||
}
|
||||
|
||||
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
|
||||
const workspace = await this.currentWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace database not found');
|
||||
}
|
||||
|
||||
const isLoaded = this.loadedWorkspaceId.has(workspace.id);
|
||||
|
||||
const workspaceDatabase = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspace.id, workspace.workspaceDatabaseId, CollabType.WorkspaceDatabase);
|
||||
},
|
||||
{
|
||||
collabId: workspace.workspaceDatabaseId,
|
||||
collabType: CollabType.WorkspaceDatabase,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) {
|
||||
this.loadedWorkspaceId.add(workspace.id);
|
||||
}
|
||||
|
||||
return workspaceDatabase.getMap(YjsEditorKey.data_section).get(YjsEditorKey.workspace_database).toJSON() as {
|
||||
views: string[];
|
||||
database_id: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async openDatabase(databaseId: string): Promise<{
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
}> {
|
||||
const workspace = await this.currentWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace database not found');
|
||||
}
|
||||
|
||||
const workspaceId = workspace.id;
|
||||
const isLoaded = this.loadedDatabaseId.has(databaseId);
|
||||
|
||||
const rootRowsDoc =
|
||||
this.cacheDatabaseRowDocMap.get(databaseId) ??
|
||||
new Y.Doc({
|
||||
guid: databaseId,
|
||||
});
|
||||
|
||||
if (!this.cacheDatabaseRowDocMap.has(databaseId)) {
|
||||
this.cacheDatabaseRowDocMap.set(databaseId, rootRowsDoc);
|
||||
}
|
||||
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
|
||||
const databaseDoc = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspaceId, databaseId, CollabType.Database);
|
||||
},
|
||||
{
|
||||
collabId: databaseId,
|
||||
collabType: CollabType.Database,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) this.loadedDatabaseId.add(databaseId);
|
||||
|
||||
const database = databaseDoc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase;
|
||||
const viewId = database.get(YjsDatabaseKey.metas)?.get(YjsDatabaseKey.iid)?.toString();
|
||||
const rowOrders = database.get(YjsDatabaseKey.views)?.get(viewId)?.get(YjsDatabaseKey.row_orders);
|
||||
const rowOrdersIds = rowOrders.toJSON() as {
|
||||
id: string;
|
||||
}[];
|
||||
|
||||
if (!rowOrdersIds) {
|
||||
throw new Error('Database rows not found');
|
||||
}
|
||||
|
||||
const rowsParams = rowOrdersIds.map((item) => ({
|
||||
collabId: item.id,
|
||||
collabType: CollabType.DatabaseRow,
|
||||
}));
|
||||
|
||||
void batchCollab(
|
||||
() => {
|
||||
return batchFetchCollab(workspaceId, rowsParams);
|
||||
},
|
||||
rowsParams,
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK,
|
||||
(id: string, doc: YDoc) => {
|
||||
if (!rowsFolder.has(id)) {
|
||||
rowsFolder.set(id, doc);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update rows if there are new rows added after the database has been loaded
|
||||
rowOrders?.observe((event) => {
|
||||
if (event.changes.added.size > 0) {
|
||||
const rowIds = rowOrders.toJSON() as {
|
||||
id: string;
|
||||
}[];
|
||||
|
||||
const params = rowIds.map((item) => ({
|
||||
collabId: item.id,
|
||||
collabType: CollabType.DatabaseRow,
|
||||
}));
|
||||
|
||||
void batchCollab(
|
||||
() => {
|
||||
return batchFetchCollab(workspaceId, params);
|
||||
},
|
||||
params,
|
||||
StrategyType.CACHE_AND_NETWORK,
|
||||
(id: string, doc: YDoc) => {
|
||||
if (!rowsFolder.has(id)) {
|
||||
rowsFolder.set(id, doc);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
databaseDoc,
|
||||
rows: rowsFolder,
|
||||
};
|
||||
}
|
||||
|
||||
async closeDatabase(databaseId: string) {
|
||||
this.cacheDatabaseRowDocMap.delete(databaseId);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @description:
|
||||
* * This is a decorator that can be used to read data from storage and fetch data from the server.
|
||||
* * If the data is already in storage, it will return the data from storage and fetch the data from the server in the background.
|
||||
*
|
||||
* @param getStorage A function that returns the data from storage. eg. `() => Promise<T | undefined>`
|
||||
*
|
||||
* @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise<void>`
|
||||
*
|
||||
* @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise<T | undefined>`
|
||||
*
|
||||
* @returns: A function that returns the data from storage and fetches the data from the server in the background.
|
||||
*/
|
||||
export function asyncDataDecorator<P, T>(
|
||||
getStorage: () => Promise<T | undefined>,
|
||||
setStorage: (data: T) => Promise<void>,
|
||||
fetchFunction: (params: P) => Promise<T | undefined>
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
async function fetchData(params: P) {
|
||||
const data = await fetchFunction(params);
|
||||
|
||||
if (!data) return;
|
||||
await setStorage(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (params: P) {
|
||||
const data = await getStorage();
|
||||
|
||||
await originalMethod.apply(this, [params]);
|
||||
if (data) {
|
||||
void fetchData(params);
|
||||
return data;
|
||||
} else {
|
||||
return fetchData(params);
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
export function afterSignInDecorator(successCallback: () => Promise<void>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
await originalMethod.apply(this, args);
|
||||
await successCallback();
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
|
||||
import { getCollab } from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { fetchCollab } from '@/application/services/js-services/fetch';
|
||||
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
|
||||
import { DocumentService } from '@/application/services/services.type';
|
||||
|
||||
export class JSDocumentService implements DocumentService {
|
||||
private loaded: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async openDocument(docId: string): Promise<YDoc> {
|
||||
const workspace = await getCurrentWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace database not found');
|
||||
}
|
||||
|
||||
const isLoaded = this.loaded.has(docId);
|
||||
|
||||
const doc = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspace.id, docId, CollabType.Document);
|
||||
},
|
||||
{
|
||||
collabId: docId,
|
||||
collabType: CollabType.Document,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) this.loaded.add(docId);
|
||||
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
|
||||
if (origin === CollabOrigin.LocalSync) {
|
||||
// Send the update to the server
|
||||
console.log('update', update);
|
||||
}
|
||||
};
|
||||
|
||||
doc.on('update', handleUpdate);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { APIService } from '@/application/services/js-services/wasm';
|
||||
|
||||
const pendingRequests = new Map();
|
||||
@@ -31,36 +30,20 @@ function fetchWithDeduplication<Req, Res>(url: string, params: Req, fetchFunctio
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch collab
|
||||
* @param workspaceId
|
||||
* @param id
|
||||
* @param type [CollabType]
|
||||
*/
|
||||
export function fetchCollab(workspaceId: string, id: string, type: CollabType) {
|
||||
const fetchFunction = () => APIService.getCollab(workspaceId, id, type);
|
||||
export function fetchPublishView(namespace: string, publishName: string) {
|
||||
const fetchFunction = () => APIService.getPublishView(namespace, publishName);
|
||||
|
||||
return fetchWithDeduplication(`fetchCollab_${workspaceId}`, { id, type }, fetchFunction);
|
||||
return fetchWithDeduplication(`fetchPublishView_${namespace}`, { publishName }, fetchFunction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch collab
|
||||
* Usage:
|
||||
* // load database rows
|
||||
* const rows = await batchFetchCollab(workspaceId, databaseRows.map((row) => ({ collabId: row.id, collabType: CollabType.DatabaseRow })));
|
||||
*
|
||||
* @param workspaceId
|
||||
* @param params [{ collabId: string; collabType: CollabType }]
|
||||
*/
|
||||
export function batchFetchCollab(workspaceId: string, params: { collabId: string; collabType: CollabType }[]) {
|
||||
const fetchFunction = () =>
|
||||
APIService.batchGetCollab(
|
||||
workspaceId,
|
||||
params.map(({ collabId, collabType }) => ({
|
||||
object_id: collabId,
|
||||
collab_type: collabType,
|
||||
}))
|
||||
);
|
||||
export function fetchViewInfo(viewId: string) {
|
||||
const fetchFunction = () => APIService.getPublishInfoWithViewId(viewId);
|
||||
|
||||
return fetchWithDeduplication(`batchFetchCollab_${workspaceId}`, params, fetchFunction);
|
||||
return fetchWithDeduplication(`fetchViewInfo`, { viewId }, fetchFunction);
|
||||
}
|
||||
|
||||
export function fetchPublishViewMeta(namespace: string, publishName: string) {
|
||||
const fetchFunction = () => APIService.getPublishViewMeta(namespace, publishName);
|
||||
|
||||
return fetchWithDeduplication(`fetchPublishViewMeta_${namespace}`, { publishName }, fetchFunction);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
|
||||
import { getCollab } from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { fetchCollab } from '@/application/services/js-services/fetch';
|
||||
import { FolderService } from '@/application/services/services.type';
|
||||
|
||||
export class JSFolderService implements FolderService {
|
||||
private loaded: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async openWorkspace(workspaceId: string): Promise<YDoc> {
|
||||
const isLoaded = this.loaded.has(workspaceId);
|
||||
const doc = await getCollab(
|
||||
() => {
|
||||
return fetchCollab(workspaceId, workspaceId, CollabType.Folder);
|
||||
},
|
||||
{
|
||||
collabId: workspaceId,
|
||||
collabType: CollabType.Folder,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) this.loaded.add(workspaceId);
|
||||
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
|
||||
if (origin === CollabOrigin.LocalSync) {
|
||||
// Send the update to the server
|
||||
console.log('update', update);
|
||||
}
|
||||
};
|
||||
|
||||
doc.on('update', handleUpdate);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,34 @@
|
||||
import { JSDatabaseService } from '@/application/services/js-services/database.service';
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import {
|
||||
AFService,
|
||||
AFServiceConfig,
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
DocumentService,
|
||||
FolderService,
|
||||
UserService,
|
||||
} from '@/application/services/services.type';
|
||||
import { JSUserService } from '@/application/services/js-services/user.service';
|
||||
import { JSAuthService } from '@/application/services/js-services/auth.service';
|
||||
import { JSFolderService } from '@/application/services/js-services/folder.service';
|
||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||
deleteView,
|
||||
getBatchCollabs,
|
||||
getPublishView,
|
||||
getPublishViewMeta,
|
||||
hasViewMetaCache,
|
||||
} from '@/application/services/js-services/cache';
|
||||
import { StrategyType } from '@/application/services/js-services/cache/types';
|
||||
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '@/application/services/js-services/fetch';
|
||||
import { AFService, AFServiceConfig } from '@/application/services/services.type';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class AFClientService implements AFService {
|
||||
authService: AuthService;
|
||||
|
||||
userService: UserService;
|
||||
|
||||
documentService: DocumentService;
|
||||
|
||||
folderService: FolderService;
|
||||
|
||||
databaseService: DatabaseService;
|
||||
|
||||
private deviceId: string = nanoid(8);
|
||||
|
||||
private clientId: string = 'web';
|
||||
|
||||
getDeviceID = (): string => {
|
||||
return this.deviceId;
|
||||
};
|
||||
private publishViewLoaded: Set<string> = new Set();
|
||||
|
||||
getClientID = (): string => {
|
||||
return this.clientId;
|
||||
};
|
||||
private publishViewInfo: Map<
|
||||
string,
|
||||
{
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
|
||||
|
||||
constructor(config: AFServiceConfig) {
|
||||
initAPIService({
|
||||
@@ -44,11 +36,118 @@ export class AFClientService implements AFService {
|
||||
deviceId: this.deviceId,
|
||||
clientId: this.clientId,
|
||||
});
|
||||
}
|
||||
|
||||
this.authService = new JSAuthService();
|
||||
this.userService = new JSUserService();
|
||||
this.documentService = new JSDocumentService();
|
||||
this.folderService = new JSFolderService();
|
||||
this.databaseService = new JSDatabaseService();
|
||||
async getPublishViewMeta(namespace: string, publishName: string) {
|
||||
const viewMeta = await getPublishViewMeta(
|
||||
() => {
|
||||
return fetchPublishViewMeta(namespace, publishName);
|
||||
},
|
||||
{
|
||||
namespace,
|
||||
publishName,
|
||||
},
|
||||
StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!viewMeta) {
|
||||
return Promise.reject(new Error('View has not been published yet'));
|
||||
}
|
||||
|
||||
return viewMeta;
|
||||
}
|
||||
|
||||
async getPublishView(namespace: string, publishName: string) {
|
||||
const name = `${namespace}_${publishName}`;
|
||||
|
||||
const isLoaded = this.publishViewLoaded.has(name);
|
||||
|
||||
const doc = await getPublishView(
|
||||
async () => {
|
||||
try {
|
||||
return await fetchPublishView(namespace, publishName);
|
||||
} catch (e) {
|
||||
void (async () => {
|
||||
if (await hasViewMetaCache(name)) {
|
||||
this.publishViewLoaded.delete(name);
|
||||
void deleteView(name);
|
||||
window.location.reload();
|
||||
}
|
||||
})();
|
||||
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
{
|
||||
namespace,
|
||||
publishName,
|
||||
},
|
||||
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
|
||||
);
|
||||
|
||||
if (!isLoaded) {
|
||||
this.publishViewLoaded.add(name);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async getPublishDatabaseViewRows(namespace: string, publishName: string, rowIds: string[]) {
|
||||
const name = `${namespace}_${publishName}`;
|
||||
|
||||
if (!this.publishViewLoaded.has(name)) {
|
||||
await this.getPublishView(namespace, publishName);
|
||||
}
|
||||
|
||||
const rootRowsDoc =
|
||||
this.cacheDatabaseRowDocMap.get(name) ??
|
||||
new Y.Doc({
|
||||
guid: name,
|
||||
});
|
||||
|
||||
if (!this.cacheDatabaseRowDocMap.has(name)) {
|
||||
this.cacheDatabaseRowDocMap.set(name, rootRowsDoc);
|
||||
}
|
||||
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
const docs = await getBatchCollabs(rowIds);
|
||||
|
||||
docs.forEach((doc, index) => {
|
||||
rowsFolder.set(rowIds[index], doc);
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rowsFolder,
|
||||
destroy: () => {
|
||||
this.cacheDatabaseRowDocMap.delete(name);
|
||||
rootRowsDoc.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getPublishInfo(viewId: string) {
|
||||
if (this.publishViewInfo.has(viewId)) {
|
||||
return this.publishViewInfo.get(viewId) as {
|
||||
namespace: string;
|
||||
publishName: string;
|
||||
};
|
||||
}
|
||||
|
||||
const info = await fetchViewInfo(viewId);
|
||||
|
||||
const namespace = info.namespace;
|
||||
|
||||
if (!namespace) {
|
||||
return Promise.reject(new Error('View not found'));
|
||||
}
|
||||
|
||||
const data = {
|
||||
namespace,
|
||||
publishName: info.publish_name,
|
||||
};
|
||||
|
||||
this.publishViewInfo.set(viewId, data);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function signInSuccess() {
|
||||
// Do nothing
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './token';
|
||||
export * from './user';
|
||||
export * from './auth';
|
||||
@@ -1,37 +0,0 @@
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
|
||||
const tokenKey = 'token';
|
||||
|
||||
export function readTokenStr() {
|
||||
return sessionStorage.getItem(tokenKey);
|
||||
}
|
||||
|
||||
export function getAuthInfo() {
|
||||
const token = readTokenStr() || '';
|
||||
|
||||
try {
|
||||
const info = JSON.parse(token);
|
||||
|
||||
return {
|
||||
uuid: info.user.id,
|
||||
access_token: info.access_token,
|
||||
email: info.user.email,
|
||||
};
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeToken(token: string) {
|
||||
if (!token) {
|
||||
invalidToken();
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(tokenKey, token);
|
||||
}
|
||||
|
||||
export function invalidToken() {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
notify.error('Invalid token, please login again');
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { UserProfile, UserWorkspace, Workspace } from '@/application/user.type';
|
||||
|
||||
const userKey = 'user';
|
||||
const workspaceKey = 'workspace';
|
||||
|
||||
export async function getSignInUser(): Promise<UserProfile | undefined> {
|
||||
const userStr = localStorage.getItem(userKey);
|
||||
|
||||
try {
|
||||
return userStr ? JSON.parse(userStr) : undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSignInUser(profile: UserProfile) {
|
||||
const userStr = JSON.stringify(profile);
|
||||
|
||||
localStorage.setItem(userKey, userStr);
|
||||
}
|
||||
|
||||
export async function getUserWorkspace(): Promise<UserWorkspace | undefined> {
|
||||
const str = localStorage.getItem(workspaceKey);
|
||||
|
||||
try {
|
||||
return str ? JSON.parse(str) : undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserWorkspace(workspace: UserWorkspace) {
|
||||
const str = JSON.stringify(workspace);
|
||||
|
||||
localStorage.setItem(workspaceKey, str);
|
||||
}
|
||||
|
||||
export async function getCurrentWorkspace(): Promise<Workspace | undefined> {
|
||||
const userProfile = await getSignInUser();
|
||||
const userWorkspace = await getUserWorkspace();
|
||||
|
||||
return userWorkspace?.workspaces.find((workspace) => workspace.id === userProfile?.workspaceId);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { UserService } from '@/application/services/services.type';
|
||||
import { UserProfile, UserWorkspace } from '@/application/user.type';
|
||||
import { APIService } from 'src/application/services/js-services/wasm';
|
||||
import {
|
||||
getAuthInfo,
|
||||
getSignInUser,
|
||||
getUserWorkspace,
|
||||
invalidToken,
|
||||
setSignInUser,
|
||||
setUserWorkspace,
|
||||
} from 'src/application/services/js-services/session';
|
||||
import { asyncDataDecorator } from '@/application/services/js-services/decorator';
|
||||
|
||||
async function getUser() {
|
||||
try {
|
||||
const user = await APIService.getUser();
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
invalidToken();
|
||||
}
|
||||
}
|
||||
|
||||
export class JSUserService implements UserService {
|
||||
@asyncDataDecorator<void, UserProfile>(getSignInUser, setSignInUser, getUser)
|
||||
async getUserProfile(): Promise<UserProfile> {
|
||||
if (!getAuthInfo()) {
|
||||
return Promise.reject('Not authenticated');
|
||||
}
|
||||
|
||||
await this.getUserWorkspace();
|
||||
|
||||
return null!;
|
||||
}
|
||||
|
||||
async checkUser(): Promise<boolean> {
|
||||
return (await getSignInUser()) !== undefined;
|
||||
}
|
||||
|
||||
@asyncDataDecorator<void, UserWorkspace>(getUserWorkspace, setUserWorkspace, APIService.getUserWorkspace)
|
||||
async getUserWorkspace(): Promise<UserWorkspace> {
|
||||
return null!;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { ClientAPI } from '@appflowyinc/client-api-wasm';
|
||||
import { UserProfile, UserWorkspace } from '@/application/user.type';
|
||||
import { AFCloudConfig } from '@/application/services/services.type';
|
||||
import { invalidToken, readTokenStr, writeToken } from 'src/application/services/js-services/session';
|
||||
import { PublishViewMetaData } from '@/application/collab.type';
|
||||
|
||||
let client: ClientAPI;
|
||||
|
||||
@@ -12,8 +10,18 @@ export function initAPIService(
|
||||
clientId: string;
|
||||
}
|
||||
) {
|
||||
window.refresh_token = writeToken;
|
||||
window.invalid_token = invalidToken;
|
||||
if (client) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.refresh_token = () => {
|
||||
//
|
||||
};
|
||||
|
||||
window.invalid_token = () => {
|
||||
// invalidToken();
|
||||
};
|
||||
|
||||
client = ClientAPI.new({
|
||||
base_url: config.baseURL,
|
||||
ws_addr: config.wsURL,
|
||||
@@ -26,96 +34,25 @@ export function initAPIService(
|
||||
},
|
||||
});
|
||||
|
||||
const token = readTokenStr();
|
||||
|
||||
if (token) {
|
||||
client.restore_token(token);
|
||||
}
|
||||
|
||||
client.subscribe();
|
||||
}
|
||||
|
||||
export function signIn(email: string, password: string) {
|
||||
return client.login(email, password);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return client.logout();
|
||||
}
|
||||
|
||||
export async function getUser(): Promise<UserProfile> {
|
||||
try {
|
||||
const user = await client.get_user();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user found');
|
||||
}
|
||||
|
||||
return {
|
||||
uid: parseInt(user.uid),
|
||||
uuid: user.uuid || undefined,
|
||||
email: user.email || undefined,
|
||||
name: user.name || undefined,
|
||||
workspaceId: user.latest_workspace_id,
|
||||
iconUrl: user.icon_url || undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCollab(workspaceId: string, object_id: string, collabType: CollabType) {
|
||||
const res = await client.get_collab({
|
||||
workspace_id: workspaceId,
|
||||
object_id: object_id,
|
||||
collab_type: Number(collabType) as 0 | 1 | 2 | 3 | 4 | 5,
|
||||
});
|
||||
|
||||
const state = new Uint8Array(res.doc_state);
|
||||
export async function getPublishView(publishNamespace: string, publishName: string) {
|
||||
const data = await client.get_publish_view(publishNamespace, publishName);
|
||||
|
||||
return {
|
||||
state,
|
||||
data: data.data,
|
||||
meta: JSON.parse(data.meta.data) as PublishViewMetaData,
|
||||
};
|
||||
}
|
||||
|
||||
export async function batchGetCollab(
|
||||
workspaceId: string,
|
||||
params: {
|
||||
object_id: string;
|
||||
collab_type: CollabType;
|
||||
}[]
|
||||
) {
|
||||
const res = (await client.batch_get_collab(
|
||||
workspaceId,
|
||||
params.map((param) => ({
|
||||
object_id: param.object_id,
|
||||
collab_type: Number(param.collab_type) as 0 | 1 | 2 | 3 | 4 | 5,
|
||||
}))
|
||||
)) as unknown as Map<string, { doc_state: number[] }>;
|
||||
|
||||
const result: Record<string, number[]> = {};
|
||||
|
||||
res.forEach((value, key) => {
|
||||
result[key] = value.doc_state;
|
||||
});
|
||||
return result;
|
||||
export async function getPublishInfoWithViewId(viewId: string) {
|
||||
return client.get_publish_info(viewId);
|
||||
}
|
||||
|
||||
export async function getUserWorkspace(): Promise<UserWorkspace> {
|
||||
const res = await client.get_user_workspace();
|
||||
export async function getPublishViewMeta(publishNamespace: string, publishName: string) {
|
||||
const data = await client.get_publish_view_meta(publishNamespace, publishName);
|
||||
const metadata = JSON.parse(data.data) as PublishViewMetaData;
|
||||
|
||||
return {
|
||||
visitingWorkspaceId: res.visiting_workspace_id,
|
||||
workspaces: res.workspaces.map((workspace) => ({
|
||||
id: workspace.workspace_id,
|
||||
name: workspace.workspace_name,
|
||||
icon: workspace.icon,
|
||||
owner: {
|
||||
id: Number(workspace.owner_uid),
|
||||
name: workspace.owner_name,
|
||||
},
|
||||
type: workspace.workspace_type,
|
||||
workspaceDatabaseId: workspace.database_storage_id,
|
||||
})),
|
||||
};
|
||||
return metadata;
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||
import { ViewMeta } from '@/application/db/tables/view_metas';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export interface AFService {
|
||||
getDeviceID: () => string;
|
||||
getClientID: () => string;
|
||||
authService: AuthService;
|
||||
userService: UserService;
|
||||
documentService: DocumentService;
|
||||
folderService: FolderService;
|
||||
databaseService: DatabaseService;
|
||||
}
|
||||
export type AFService = PublishService;
|
||||
|
||||
export interface AFServiceConfig {
|
||||
cloudConfig: AFCloudConfig;
|
||||
@@ -22,35 +14,16 @@ export interface AFCloudConfig {
|
||||
wsURL: string;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
getOAuthURL: (provider: ProviderType) => Promise<string>;
|
||||
signInWithOAuth: (params: { uri: string }) => Promise<void>;
|
||||
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<void>;
|
||||
signinWithEmailPassword: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DocumentService {
|
||||
openDocument: (docId: string) => Promise<YDoc>;
|
||||
}
|
||||
|
||||
export interface DatabaseService {
|
||||
getWorkspaceDatabases: () => Promise<{ views: string[]; database_id: string }[]>;
|
||||
openDatabase: (
|
||||
databaseId: string,
|
||||
rowIds?: string[]
|
||||
export interface PublishService {
|
||||
getPublishViewMeta: (namespace: string, publishName: string) => Promise<ViewMeta>;
|
||||
getPublishView: (namespace: string, publishName: string) => Promise<YDoc>;
|
||||
getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>;
|
||||
getPublishDatabaseViewRows: (
|
||||
namespace: string,
|
||||
publishName: string,
|
||||
rowIds: string[]
|
||||
) => Promise<{
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
destroy: () => void;
|
||||
}>;
|
||||
closeDatabase: (databaseId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UserService {
|
||||
getUserProfile: () => Promise<UserProfile | null>;
|
||||
checkUser: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface FolderService {
|
||||
openWorkspace: (workspaceId: string) => Promise<YDoc>;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { AFCloudConfig, AuthService } from '@/application/services/services.type';
|
||||
import {
|
||||
AuthenticatorPB,
|
||||
OauthProviderPB,
|
||||
OauthSignInPB,
|
||||
SignInPayloadPB,
|
||||
SignUpPayloadPB,
|
||||
UserEventGetOauthURLWithProvider,
|
||||
UserEventOauthSignIn,
|
||||
UserEventSignInWithEmailPassword,
|
||||
UserEventSignOut,
|
||||
UserEventSignUp,
|
||||
UserProfilePB,
|
||||
} from './backend/events/flowy-user';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||
|
||||
export class TauriAuthService implements AuthService {
|
||||
|
||||
constructor (private cloudConfig: AFCloudConfig, private clientConfig: {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
|
||||
}) {}
|
||||
|
||||
getDeviceID = (): string => {
|
||||
return this.clientConfig.deviceId;
|
||||
};
|
||||
getOAuthURL = async (provider: ProviderType): Promise<string> => {
|
||||
const providerDataRes = await UserEventGetOauthURLWithProvider(
|
||||
OauthProviderPB.fromObject({
|
||||
provider: provider as number,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!providerDataRes.ok) {
|
||||
throw new Error(providerDataRes.val.msg);
|
||||
}
|
||||
|
||||
const providerData = providerDataRes.val;
|
||||
|
||||
return providerData.oauth_url;
|
||||
};
|
||||
|
||||
signInWithOAuth = async ({ uri }: { uri: string }): Promise<void> => {
|
||||
const payload = OauthSignInPB.fromObject({
|
||||
authenticator: AuthenticatorPB.AppFlowyCloud,
|
||||
map: {
|
||||
sign_in_url: uri,
|
||||
device_id: this.getDeviceID(),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await UserEventOauthSignIn(payload);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
signinWithEmailPassword = async (email: string, password: string): Promise<void> => {
|
||||
const payload = SignInPayloadPB.fromObject({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
const res = await UserEventSignInWithEmailPassword(payload);
|
||||
|
||||
if (!res.ok) {
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||
const payload = SignUpPayloadPB.fromObject({
|
||||
name: params.name,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
device_id: this.getDeviceID(),
|
||||
});
|
||||
|
||||
const res = await UserEventSignUp(payload);
|
||||
|
||||
if (!res.ok) {
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
signOut = async () => {
|
||||
const res = await UserEventSignOut();
|
||||
|
||||
if (!res.ok) {
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserProfileFrom (userPB: UserProfilePB): UserProfile {
|
||||
const user = userPB.toObject();
|
||||
|
||||
return {
|
||||
uid: user.id as number,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
iconUrl: user.icon_url,
|
||||
workspaceId: user.workspace_id,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { DatabaseService } from '@/application/services/services.type';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class TauriDatabaseService implements DatabaseService {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
async closeDatabase(_databaseId: string) {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
async openDatabase(_viewId: string): Promise<{
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
}> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { DocumentService } from '@/application/services/services.type';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export class TauriDocumentService implements DocumentService {
|
||||
async openDocument(_id: string): Promise<Y.Doc> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { FolderService } from '@/application/services/services.type';
|
||||
|
||||
export class TauriFolderService implements FolderService {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async openWorkspace(_workspaceId: string): Promise<YDoc> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,24 @@
|
||||
import {
|
||||
AFService,
|
||||
AFServiceConfig,
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
DocumentService,
|
||||
FolderService,
|
||||
UserService,
|
||||
} from '@/application/services/services.type';
|
||||
import { TauriAuthService } from '@/application/services/tauri-services/auth.service';
|
||||
import { TauriDatabaseService } from '@/application/services/tauri-services/database.service';
|
||||
import { TauriFolderService } from '@/application/services/tauri-services/folder.service';
|
||||
import { TauriUserService } from '@/application/services/tauri-services/user.service';
|
||||
import { TauriDocumentService } from '@/application/services/tauri-services/document.service';
|
||||
import { AFService } from '@/application/services/services.type';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export class AFClientService implements AFService {
|
||||
authService: AuthService;
|
||||
|
||||
userService: UserService;
|
||||
|
||||
documentService: DocumentService;
|
||||
|
||||
folderService: FolderService;
|
||||
|
||||
databaseService: DatabaseService;
|
||||
|
||||
private deviceId: string = nanoid(8);
|
||||
|
||||
private clientId: string = 'web';
|
||||
private clientId: string = 'tauri';
|
||||
|
||||
getDeviceID = (): string => {
|
||||
return this.deviceId;
|
||||
};
|
||||
async getPublishView(_namespace: string, _publishName: string) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
getClientID = (): string => {
|
||||
return this.clientId;
|
||||
};
|
||||
async getPublishInfo(_viewId: string) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
constructor(config: AFServiceConfig) {
|
||||
this.authService = new TauriAuthService(config.cloudConfig, {
|
||||
deviceId: this.deviceId,
|
||||
clientId: this.clientId,
|
||||
});
|
||||
this.userService = new TauriUserService();
|
||||
this.documentService = new TauriDocumentService();
|
||||
this.folderService = new TauriFolderService();
|
||||
this.databaseService = new TauriDatabaseService();
|
||||
async getPublishViewMeta(_namespace: string, _publishName: string) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
async getPublishDatabaseViewRows(_namespace: string, _publishName: string, _rowIds: string[]) {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { UserService } from '@/application/services/services.type';
|
||||
import { UserProfile } from '@/application/user.type';
|
||||
import { UserEventGetUserProfile } from './backend/events/flowy-user';
|
||||
import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service';
|
||||
|
||||
export class TauriUserService implements UserService {
|
||||
async getUserProfile(): Promise<UserProfile | null> {
|
||||
const res = await UserEventGetUserProfile();
|
||||
|
||||
if (res.ok) {
|
||||
return parseUserProfileFrom(res.val);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkUser(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe('convert yjs data to slate content', () => {
|
||||
it('should return undefined if root block is not exist', () => {
|
||||
const doc = new Y.Doc();
|
||||
|
||||
expect(() => yDocToSlateContent(doc)).toThrowError();
|
||||
expect(yDocToSlateContent(doc)).toBeUndefined();
|
||||
|
||||
const doc2 = withTestingYDoc('1');
|
||||
const { blocks, childrenMap, textMap, pageId } = getTestingDocData(doc2);
|
||||
|
||||
@@ -75,7 +75,6 @@ export function withYjs<T extends Editor>(
|
||||
|
||||
e.children = content.children;
|
||||
|
||||
console.log('initializeDocumentContent', doc.getMap(YjsEditorKey.data_section).toJSON(), e.children);
|
||||
Editor.normalize(editor, { force: true });
|
||||
};
|
||||
|
||||
|
||||
@@ -97,9 +97,11 @@ export function yDataToSlateContent({
|
||||
export function yDocToSlateContent(doc: YDoc): Element | undefined {
|
||||
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
|
||||
if (!sharedRoot || sharedRoot.size === 0) return;
|
||||
const document = sharedRoot.get(YjsEditorKey.document);
|
||||
const pageId = document.get(YjsEditorKey.page_id) as string;
|
||||
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
|
||||
|
||||
const meta = document.get(YjsEditorKey.meta) as YMeta;
|
||||
const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap;
|
||||
const textMap = meta.get(YjsEditorKey.text_map) as YTextMap;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
export enum Authenticator {
|
||||
Local = 0,
|
||||
Supabase = 1,
|
||||
AppFlowyCloud = 2,
|
||||
}
|
||||
|
||||
export enum EncryptionType {
|
||||
NoEncryption = 0,
|
||||
Symmetric = 1,
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
uid: number;
|
||||
uuid?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
iconUrl?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface UserWorkspace {
|
||||
visitingWorkspaceId: string;
|
||||
workspaces: Workspace[];
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
type: number;
|
||||
workspaceDatabaseId: string;
|
||||
}
|
||||
|
||||
export interface SignUpWithEmailPasswordParams {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export enum ProviderType {
|
||||
Apple = 0,
|
||||
Azure = 1,
|
||||
Bitbucket = 2,
|
||||
Discord = 3,
|
||||
Facebook = 4,
|
||||
Figma = 5,
|
||||
Github = 6,
|
||||
Gitlab = 7,
|
||||
Google = 8,
|
||||
Keycloak = 9,
|
||||
Kakao = 10,
|
||||
Linkedin = 11,
|
||||
Notion = 12,
|
||||
Spotify = 13,
|
||||
Slack = 14,
|
||||
Workos = 15,
|
||||
Twitch = 16,
|
||||
Twitter = 17,
|
||||
Email = 18,
|
||||
Phone = 19,
|
||||
Zoom = 20,
|
||||
}
|
||||
|
||||
export interface UserSetting {
|
||||
workspaceId: string;
|
||||
latestView?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
hasLatestView: boolean;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<svg width="57" height="14" viewBox="0 0 57 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 1171274570">
|
||||
<path id="Vector"
|
||||
d="M8.35547 4.01126H10.1226V4.67123C10.5568 4.21099 11.3679 3.84192 12.2589 3.84192C14.1641 3.84192 15.3938 5.31819 15.3938 7.36065C15.3938 9.46563 13.9488 11.0947 11.7361 11.0947C11.1213 11.0947 10.5204 10.9871 10.1226 10.7569V13.9995H8.35547V4.01126ZM10.1226 6.25258V9.04967C10.5994 9.35708 10.9989 9.44913 11.5824 9.44913C12.8121 9.44913 13.5189 8.55729 13.5189 7.40494C13.5189 6.2986 12.8737 5.49968 11.7057 5.49968C11.1065 5.50142 10.5534 5.77844 10.1226 6.25258Z"
|
||||
fill="currentColor"/>
|
||||
<path id="Vector_2"
|
||||
d="M3.98189 3.88086C1.76836 3.88086 0.324219 5.50996 0.324219 7.61495C0.324219 9.65915 1.55386 11.1345 3.45912 11.1345C4.35009 11.1345 5.16464 10.7655 5.59536 10.3035V10.9652H7.36862V5.16348C6.05561 4.16917 4.60019 3.88086 3.98189 3.88086ZM5.59536 8.72215C5.16117 9.19716 4.61147 9.47418 4.01228 9.47418C2.84516 9.47418 2.19908 8.67526 2.19908 7.56979C2.19908 6.41657 2.90595 5.52473 4.13473 5.52473C4.71916 5.52473 5.11861 5.61765 5.59536 5.92506V8.72215Z"
|
||||
fill="currentColor"/>
|
||||
<path id="Vector_3"
|
||||
d="M16.1484 4.01126H17.9156V4.67123C18.3498 4.21099 19.16 3.84192 20.051 3.84192C21.9571 3.84192 23.1859 5.31819 23.1859 7.36065C23.1859 9.46563 21.7418 11.0947 19.5291 11.0947C18.9143 11.0947 18.3133 10.9871 17.9156 10.7569V13.9995H16.1484V4.01126ZM17.9156 6.25258V9.04967C18.3915 9.35708 18.791 9.44913 19.3754 9.44913C20.6042 9.44913 21.311 8.55729 21.311 7.40494C21.311 6.2986 20.6658 5.49968 19.4978 5.49968C18.8986 5.50142 18.3455 5.77844 17.9156 6.25258Z"
|
||||
fill="currentColor"/>
|
||||
<path id="Vector_4"
|
||||
d="M28.3102 2.02887C27.9904 1.80209 27.6113 1.6738 27.2195 1.6598C26.4206 1.6598 26.0037 2.0749 26.0037 3.02752V4.01141H27.2481V5.67091H26.0055V10.9264H24.2383V2.88945C24.2383 0.968563 25.1605 0.000305176 26.6967 0.000305176C27.4036 0.000305176 28.0497 0.173984 28.5099 0.461422H30.0774V8.29867C30.0774 9.08022 30.2771 9.41976 30.6766 9.41976C30.9692 9.41976 31.1976 9.29732 31.4147 9.14361L31.7829 10.4957C31.3834 10.8187 30.7842 11.0793 29.9593 11.0793C28.8989 11.0793 28.3145 10.434 28.3145 9.0203L28.3102 2.02887Z"
|
||||
fill="currentColor"/>
|
||||
<path id="Vector_5"
|
||||
d="M35.5466 3.84192C37.8357 3.84192 39.3111 5.39374 39.3111 7.46833C39.3111 9.54292 37.8348 11.0947 35.5466 11.0947C33.2584 11.0947 31.7812 9.54292 31.7812 7.46833C31.7812 5.39374 33.2566 3.84192 35.5466 3.84192ZM35.5466 9.45087C36.6373 9.45087 37.4362 8.66932 37.4362 7.46833C37.4362 6.30034 36.6225 5.50142 35.5466 5.50142C34.5045 5.50142 33.6717 6.26995 33.6717 7.46833C33.6717 8.62069 34.4707 9.45087 35.5466 9.45087Z"
|
||||
fill="currentColor"/>
|
||||
<path id="Vector_6"
|
||||
d="M48.0544 10.9565H46.4713L44.995 7.11477C44.8874 6.85425 44.8413 6.56161 44.7641 6.30022C44.7077 6.58869 44.6255 6.87148 44.5183 7.14517L43.0594 10.9565H41.5545L39.3105 4.0094H41.201L42.3074 7.77388C42.3943 8.07652 42.4611 8.38461 42.5071 8.69611C42.5772 8.38422 42.6642 8.07635 42.7676 7.77388L44.0581 4.0094H45.6212L46.9672 7.75999C47.0701 8.0738 47.157 8.39262 47.2277 8.71522C47.2893 8.39305 47.3666 8.05437 47.443 7.71657L48.4573 4.01374H50.2401L48.0544 10.9565Z"
|
||||
fill="currentColor"/>
|
||||
<path id="Vector_7"
|
||||
d="M53.9557 10.9265C52.9414 13.3997 52.2962 13.9997 51.2819 13.9997C50.6514 13.9997 50.1903 13.7531 49.7909 13.4613L50.3596 12.1249C50.5437 12.2325 50.8051 12.3706 51.0822 12.3706C51.5276 12.3706 51.8498 12.0172 52.1425 11.2947L52.2962 10.9265L49.0996 4.01147H51.222L52.8354 7.79159C52.9735 8.12939 53.0656 8.45157 53.1732 8.79024C53.2298 8.4465 53.3119 8.10746 53.419 7.77596L54.6643 4.01147H56.6755L53.9557 10.9265Z"
|
||||
fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icons/ Arrow / right" opacity="0.5">
|
||||
<path id="Vector 15" d="M4.5 9.375L7.875 6L4.5 2.625" stroke="currentColor" stroke-width="0.9"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.2023 19.4658V4.53479C10.2023 3.11729 9.61505 2.55029 8.15599 2.55029H4.4487C2.98964 2.55029 2.40234 3.11729 2.40234 4.53479V19.4658C2.40234 20.8833 2.98964 21.4503 4.4487 21.4503H8.15599C9.61505 21.4503 10.2023 20.8833 10.2023 19.4658Z"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.6022 13.1778V4.59779C21.6022 3.13529 21.015 2.55029 19.5559 2.55029H15.8486C14.3895 2.55029 13.8022 3.13529 13.8022 4.59779V13.1778C13.8022 14.6403 14.3895 15.2253 15.8486 15.2253H19.5559C21.015 15.2253 21.6022 14.6403 21.6022 13.1778Z"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 811 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="2.75" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 160 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="2.75" stroke="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 162 B |