Merge remote-tracking branch 'refs/remotes/origin/feat/cloud-sync-saika'

Conflicts:
	package.json
	packages/app/src/components/workspace-slider-bar/index.tsx
	packages/app/src/libs/i18n/resources/bn.json
	packages/app/src/libs/i18n/resources/fr.json
	packages/app/src/libs/i18n/resources/sr.json
	packages/app/src/libs/i18n/resources/zh-Hans.json
	packages/app/src/libs/i18n/resources/zh-Hant.json
	packages/app/src/pages/_app.tsx
	packages/data-center/src/datacenter.ts
	pnpm-lock.yaml
This commit is contained in:
linonetwo 2023-01-10 17:08:57 +08:00
commit 876f3d235e
65 changed files with 728 additions and 4076 deletions

View File

@ -17,8 +17,7 @@
"test": "playwright test",
"test:dc": "pnpm --filter @affine/datacenter test",
"test:e2e:codegen": "npx playwright codegen http://localhost:8080",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit": "playwright test --config=playwright.config.unit.ts",
"postinstall": "husky install",
"notify": "node --experimental-modules scripts/notify.mjs",
"check:ci": "pnpm lint & pnpm test"

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@affine/datacenter": "workspace:*",
"@affine/i18n": "workspace:*",
"@blocksuite/blocks": "0.3.1-20230109032243-37ad3ba",
"@blocksuite/editor": "0.3.1-20230109032243-37ad3ba",
"@blocksuite/icons": "^2.0.2",
@ -27,7 +28,6 @@
"cmdk": "^0.1.20",
"css-spring": "^4.1.0",
"dayjs": "^1.11.7",
"i18next": "^21.9.1",
"lit": "^2.3.1",
"next": "13.1.0",
"next-debug-local": "^0.1.5",
@ -36,7 +36,6 @@
"quill-cursors": "^4.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-i18next": "^11.18.4",
"yjs": "^13.5.44"
},
"devDependencies": {

View File

@ -1,7 +1,7 @@
import { describe, test, expect } from 'vitest';
import { test, expect } from '@playwright/test';
import { printer } from './../printer';
const chalk = require('chalk');
describe('printer', () => {
test.describe('printer', () => {
test('test debug', () => {
expect(printer.debug('test debug')).toBe(
chalk.green`debug` + chalk.white(' - test debug')

View File

@ -1,5 +1,5 @@
import { NotFoundTitle, PageContainer } from './styles';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export const NotfoundPage = () => {
const { t } = useTranslation();
return (

View File

@ -23,7 +23,7 @@ import {
StyledModalFooter,
} from './style';
import bg from '@/components/contact-modal/bg.png';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
const linkList = [
{
icon: <GithubIcon />,

View File

@ -15,7 +15,7 @@ import { useTheme } from '@/providers/ThemeProvider';
import { EdgelessIcon, PaperIcon } from './Icons';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
const PaperItem = ({ active }: { active?: boolean }) => {
const {
theme: {

View File

@ -3,7 +3,7 @@ import { IconButton, IconButtonProps } from '@/ui/button';
import { Tooltip } from '@/ui/tooltip';
import { ArrowDownIcon } from '@blocksuite/icons';
import { useModal } from '@/providers/GlobalModalProvider';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export const QuickSearchButton = ({
onClick,
...props

View File

@ -16,7 +16,7 @@ import { usePageHelper } from '@/hooks/use-page-helper';
import { useConfirm } from '@/providers/ConfirmProvider';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { toast } from '@/ui/toast';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
const PopoverContent = () => {
const { editor } = useAppState();
const { toggleFavoritePage, toggleDeletePage } = usePageHelper();

View File

@ -8,7 +8,7 @@ import {
import { CloseIcon, ContactIcon, HelpIcon, KeyboardIcon } from './Icons';
import Grow from '@mui/material/Grow';
import { Tooltip } from '@/ui/tooltip';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
import { useModal } from '@/providers/GlobalModalProvider';
import { useTheme } from '@/providers/ThemeProvider';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';

View File

@ -6,7 +6,7 @@ import Loading from '@/components/loading';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useAppState } from '@/providers/app-state-provider/context';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
// import { Tooltip } from '@/ui/tooltip';
type ImportModalProps = {
open: boolean;

View File

@ -14,7 +14,7 @@ import {
} from '@blocksuite/icons';
import { toast } from '@/ui/toast';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export const OperationCell = ({ pageMeta }: { pageMeta: PageMeta }) => {
const { id, favorite } = pageMeta;
const { openPage } = usePageHelper();

View File

@ -24,7 +24,7 @@ import { useAppState } from '@/providers/app-state-provider/context';
import { toast } from '@/ui/toast';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useTheme } from '@/providers/ThemeProvider';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
const FavoriteTag = ({
pageMeta: { favorite, id },
}: {

View File

@ -4,7 +4,7 @@ import { StyledModalFooterContent } from './style';
import { useModal } from '@/providers/GlobalModalProvider';
import { Command } from 'cmdk';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export const Footer = (props: { query: string }) => {
const { triggerQuickSearchModal } = useModal();
const { openPage, createPage } = usePageHelper();

View File

@ -7,7 +7,7 @@ import { useAppState } from '@/providers/app-state-provider';
import { useRouter } from 'next/router';
import { useSwitchToConfig } from './config';
import { NoResultSVG } from './NoResultSVG';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
import usePageHelper from '@/hooks/use-page-helper';
import usePageMetaList from '@/hooks/use-page-meta-list';
export const Results = (props: {

View File

@ -1,5 +1,5 @@
import { AllPagesIcon, FavouritesIcon, TrashIcon } from '@blocksuite/icons';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export const useSwitchToConfig = (
currentWorkspaceId: string

View File

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
interface ShortcutTip {
[x: string]: string;
}
@ -13,7 +13,7 @@ export const useMacKeyboardShortcuts = (): ShortcutTip => {
[t('Strikethrough')]: '⌘+⇧+S',
[t('Inline code')]: ' ⌘+E',
[t('Code block')]: '⌘+⌥+C',
[t('Hyperlink(with selected text)')]: '⌘+K',
[t('Link')]: '⌘+K',
[t('Quick search')]: '⌘+K',
[t('Body text')]: '⌘+⌥+0',
[t('Heading', { number: '1' })]: '⌘+⌥+1',
@ -57,7 +57,7 @@ export const useWindowsKeyboardShortcuts = (): ShortcutTip => {
[t('Strikethrough')]: 'Ctrl+Shift+S',
[t('Inline code')]: ' Ctrl+E',
[t('Code block')]: 'Ctrl+Alt+C',
[t('Hyperlink(with selected text)')]: 'Ctrl+K',
[t('Link')]: 'Ctrl+K',
[t('Quick search')]: 'Ctrl+K',
[t('Body text')]: 'Ctrl+Shift+0',
[t('Heading', { number: '1' })]: 'Ctrl+Shift+1',

View File

@ -16,7 +16,7 @@ import {
import Slide from '@mui/material/Slide';
import { ModalCloseButton } from '@/ui/modal';
import { getUaHelper } from '@/utils';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
type ModalProps = {
open: boolean;
onClose: () => void;

View File

@ -32,7 +32,7 @@ import useLocalStorage from '@/hooks/use-local-storage';
import usePageMetaList from '@/hooks/use-page-meta-list';
import { usePageHelper } from '@/hooks/use-page-helper';
import { WorkspaceSetting } from '@/components/workspace-setting';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
const FavoriteList = ({ showList }: { showList: boolean }) => {
const { openPage } = usePageHelper();

View File

@ -18,8 +18,8 @@ import { useEffect } from 'react';
import { useAppState } from '@/providers/app-state-provider';
import { PageLoading } from '@/components/loading';
import Head from 'next/head';
import '@/libs/i18n';
import TemporaryHelperProvider from '@/providers/temporary-helper-provider';
import '@affine/i18n';
const ThemeProvider = dynamic(() => import('@/providers/ThemeProvider'), {
ssr: false,

View File

@ -4,7 +4,7 @@ import usePageMetaList from '@/hooks/use-page-meta-list';
import { PageListHeader } from '@/components/header';
import { ReactElement } from 'react';
import WorkspaceLayout from '@/components/workspace-layout';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
const All = () => {
const pageMetaList = usePageMetaList();
const { t } = useTranslation();

View File

@ -4,7 +4,7 @@ import { FavouritesIcon } from '@blocksuite/icons';
import usePageMetaList from '@/hooks/use-page-meta-list';
import { ReactElement } from 'react';
import WorkspaceLayout from '@/components/workspace-layout';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export const Favorite = () => {
const pageMetaList = usePageMetaList();
const { t } = useTranslation();

View File

@ -4,7 +4,7 @@ import { TrashIcon } from '@blocksuite/icons';
import usePageMetaList from '@/hooks/use-page-meta-list';
import { ReactElement } from 'react';
import WorkspaceLayout from '@/components/workspace-layout';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export const Trash = () => {
const pageMetaList = usePageMetaList();
const { t } = useTranslation();

View File

@ -7,7 +7,7 @@ import {
StyledModalWrapper,
} from '@/ui/confirm/styles';
import { Button } from '@/ui/button';
import { useTranslation } from 'react-i18next';
import { useTranslation } from '@affine/i18n';
export type ConfirmProps = {
title?: string;
content?: string;

View File

@ -1,7 +1,7 @@
import { describe, test, expect } from 'vitest';
import { test, expect } from '@playwright/test';
import { isMobile } from '../get-is-mobile';
describe('get-is-mobile', () => {
test.describe('get-is-mobile', () => {
test('get-is-mobile', () => {
expect(
isMobile(

View File

@ -1,23 +1,26 @@
import { Workspaces } from './workspaces';
import type { WorkspacesChangeEvent } from './workspaces';
import { BlobStorage, Workspace } from '@blocksuite/store';
import { WorkspaceMetaCollection } from './workspace-meta-collection.js';
import type { WorkspaceMetaCollectionChangeEvent } from './workspace-meta-collection';
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import { BaseProvider } from './provider/base';
import { LocalProvider } from './provider/local/local';
import { AffineProvider } from './provider';
import type { WorkspaceMeta } from './types';
import type { Message, WorkspaceMeta } from './types';
import assert from 'assert';
import { getLogger } from './logger';
import { BlockSchema } from '@blocksuite/blocks/models';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { TauriIPCProvider } from './provider/tauri-ipc';
import { createBlocksuiteWorkspace } from './utils/index.js';
import { MessageCenter } from './message/message';
/**
* @class DataCenter
* @classdesc Data center is made for managing different providers for business
*/
export class DataCenter {
private readonly _workspaces = new Workspaces();
private readonly _workspaceMetaCollection = new WorkspaceMetaCollection();
private readonly _logger = getLogger('dc');
private _workspaceInstances: Map<string, BlocksuiteWorkspace> = new Map();
private _messageCenter = new MessageCenter();
/**
* A mainProvider must exist as the only data trustworthy source.
*/
@ -30,26 +33,18 @@ export class DataCenter {
static async init(debug: boolean): Promise<DataCenter> {
const dc = new DataCenter(debug);
const getInitParams = () => {
return {
logger: dc._logger,
workspaces: dc._workspaceMetaCollection.createScope(),
messageCenter: dc._messageCenter,
};
};
// TODO: switch different provider
dc.registerProvider(
new LocalProvider({
logger: dc._logger,
workspaces: dc._workspaces.createScope(),
})
);
dc.registerProvider(
new AffineProvider({
logger: dc._logger,
workspaces: dc._workspaces.createScope(),
})
);
dc.registerProvider(new LocalProvider(getInitParams()));
dc.registerProvider(new AffineProvider(getInitParams()));
if (typeof window !== 'undefined' && window.CLIENT_APP) {
dc.registerProvider(
new TauriIPCProvider({
logger: dc._logger,
workspaces: dc._workspaces.createScope(),
})
);
dc.registerProvider(new TauriIPCProvider(getInitParams()));
}
return dc;
@ -77,7 +72,7 @@ export class DataCenter {
}
public get workspaces() {
return this._workspaces.workspaces;
return this._workspaceMetaCollection.workspaces;
}
public async refreshWorkspaces() {
@ -89,7 +84,7 @@ export class DataCenter {
/**
* create new workspace , new workspace is a local workspace
* @param {string} name workspace name
* @returns {Promise<WS>}
* @returns {Promise<Workspace>}
*/
public async createWorkspace(workspaceMeta: WorkspaceMeta) {
assert(
@ -97,7 +92,13 @@ export class DataCenter {
'There is no provider. You should add provider first.'
);
const workspace = await this._mainProvider.createWorkspace(workspaceMeta);
const workspaceInfo = await this._mainProvider.createWorkspaceInfo(
workspaceMeta
);
const workspace = createBlocksuiteWorkspace(workspaceInfo.id);
await this._mainProvider.createWorkspace(workspace, workspaceMeta);
return workspace;
}
@ -106,7 +107,7 @@ export class DataCenter {
* @param {string} workspaceId workspace id
*/
public async deleteWorkspace(workspaceId: string) {
const workspaceInfo = this._workspaces.find(workspaceId);
const workspaceInfo = this._workspaceMetaCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
assert(provider, `Workspace exists, but we couldn't find its provider.`);
@ -117,39 +118,42 @@ export class DataCenter {
* get a new workspace only has room id
* @param {string} workspaceId workspace id
*/
private _getWorkspace(workspaceId: string) {
return new Workspace({
room: workspaceId,
}).register(BlockSchema);
private _getBlocksuiteWorkspace(workspaceId: string) {
const workspaceInfo = this._workspaceMetaCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
return (
this._workspaceInstances.get(workspaceId) ||
createBlocksuiteWorkspace(workspaceId)
);
}
/**
* login to all providers, it will default run all auth ,
* maybe need a params to control which provider to auth
*/
public async login() {
this.providers.forEach(p => {
// TODO: may be add params of auth
p.auth();
});
public async login(providerId = 'affine') {
const provider = this.providerMap.get(providerId);
assert(provider, `provide '${providerId}' is not registered`);
await provider.auth();
provider.loadWorkspaces();
}
/**
* logout from all providers
*/
public async logout() {
this.providers.forEach(p => {
p.logout();
});
public async logout(providerId = 'affine') {
const provider = this.providerMap.get(providerId);
assert(provider, `provide '${providerId}' is not registered`);
await provider.logout();
}
/**
* load workspace instance by id
* @param {string} workspaceId workspace id
* @returns {Promise<Workspace>}
* @returns {Promise<BlocksuiteWorkspace>}
*/
public async loadWorkspace(workspaceId: string) {
const workspaceInfo = this._workspaces.find(workspaceId);
const workspaceInfo = this._workspaceMetaCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const currentProvider = this.providerMap.get(workspaceInfo.provider);
if (currentProvider) {
@ -158,8 +162,9 @@ export class DataCenter {
const provider = this.providerMap.get(workspaceInfo.provider);
assert(provider, `provide '${workspaceInfo.provider}' is not registered`);
this._logger(`Loading ${workspaceInfo.provider} workspace: `, workspaceId);
return await provider.warpWorkspace(this._getWorkspace(workspaceId));
const workspace = this._getBlocksuiteWorkspace(workspaceId);
this._workspaceInstances.set(workspaceId, workspace);
return await provider.warpWorkspace(workspace);
}
/**
@ -179,32 +184,32 @@ export class DataCenter {
* @param {Function} callback callback function
*/
public async onWorkspacesChange(
callback: (workspaces: WorkspacesChangeEvent) => void
callback: (workspaces: WorkspaceMetaCollectionChangeEvent) => void
) {
this._workspaces.on('change', callback);
this._workspaceMetaCollection.on('change', callback);
}
/**
* change workspaces meta
* @param {WorkspaceMeta} workspaceMeta workspace meta
* @param {Workspace} workspace workspace instance
* @param {BlocksuiteWorkspace} workspace workspace instance
*/
public async resetWorkspaceMeta(
{ name, avatar }: WorkspaceMeta,
workspace: Workspace
public async updateWorkspaceMeta(
{ name, avatar }: Partial<WorkspaceMeta>,
workspace: BlocksuiteWorkspace
) {
assert(workspace?.room, 'No workspace to set meta');
const update: Partial<WorkspaceMeta> = {};
if (name) {
workspace.doc.meta.setName(name);
workspace.meta.setName(name);
update.name = name;
}
if (avatar) {
workspace.doc.meta.setAvatar(avatar);
workspace.meta.setAvatar(avatar);
update.avatar = avatar;
}
// may run for change workspace meta
const workspaceInfo = this._workspaces.find(workspace.room);
const workspaceInfo = this._workspaceMetaCollection.find(workspace.room);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
provider?.updateWorkspaceMeta(workspace.room, update);
@ -216,7 +221,7 @@ export class DataCenter {
* @param id workspace id
*/
public async leaveWorkspace(workspaceId: string) {
const workspaceInfo = this._workspaces.find(workspaceId);
const workspaceInfo = this._workspaceMetaCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
@ -226,7 +231,7 @@ export class DataCenter {
}
public async setWorkspacePublish(workspaceId: string, isPublish: boolean) {
const workspaceInfo = this._workspaces.find(workspaceId);
const workspaceInfo = this._workspaceMetaCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
@ -235,7 +240,7 @@ export class DataCenter {
}
public async inviteMember(id: string, email: string) {
const workspaceInfo = this._workspaces.find(id);
const workspaceInfo = this._workspaceMetaCollection.find(id);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
@ -248,7 +253,7 @@ export class DataCenter {
* @param {number} permissionId permission id
*/
public async removeMember(workspaceId: string, permissionId: number) {
const workspaceInfo = this._workspaces.find(workspaceId);
const workspaceInfo = this._workspaceMetaCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
@ -275,11 +280,11 @@ export class DataCenter {
}
private async _transWorkspaceProvider(
workspace: Workspace,
workspace: BlocksuiteWorkspace,
providerId: string
) {
assert(workspace.room, 'No workspace id');
const workspaceInfo = this._workspaces.find(workspace.room);
const workspaceInfo = this._workspaceMetaCollection.find(workspace.room);
assert(workspaceInfo, 'Workspace not found');
if (workspaceInfo.provider === providerId) {
this._logger('Workspace provider is same');
@ -290,11 +295,17 @@ export class DataCenter {
const newProvider = this.providerMap.get(providerId);
assert(newProvider, `provide '${providerId}' is not registered`);
this._logger(`create ${providerId} workspace: `, workspaceInfo.name);
// TODO optimize this function
const newWorkspace = await newProvider.createWorkspace({
const newWorkspaceInfo = await newProvider.createWorkspaceInfo({
name: workspaceInfo.name,
avatar: workspaceInfo.avatar,
});
const newWorkspace = createBlocksuiteWorkspace(newWorkspaceInfo.id);
// TODO optimize this function
await newProvider.createWorkspace(newWorkspace, {
name: workspaceInfo.name,
avatar: workspaceInfo.avatar,
});
assert(newWorkspace, 'Create workspace failed');
this._logger(
`update workspace data from ${workspaceInfo.provider} to ${providerId}`
@ -308,7 +319,7 @@ export class DataCenter {
* Enable workspace cloud
* @param {string} id ID of workspace.
*/
public async enableWorkspaceCloud(workspace: Workspace) {
public async enableWorkspaceCloud(workspace: BlocksuiteWorkspace) {
assert(workspace?.room, 'No workspace to enable cloud');
return await this._transWorkspaceProvider(workspace, 'affine');
}
@ -341,21 +352,30 @@ export class DataCenter {
return;
}
// /**
// * get blob url by workspaces id
// * @param id
// * @returns {Promise<string | null>} blob url
// */
// async getBlob(id: string): Promise<string | null> {
// return await this._blobStorage.get(id);
// }
/**
* get blob url by workspaces id
* @param id
* @returns {Promise<string | null>} blob url
*/
async getBlob(
workspace: BlocksuiteWorkspace,
id: string
): Promise<string | null> {
const blob = await workspace.blobs;
return (await blob?.get(id)) || '';
}
// /**
// * up load blob and get a blob url
// * @param id
// * @returns {Promise<string | null>} blob url
// */
// async setBlob(blob: Blob): Promise<string> {
// return await this._blobStorage.set(blob);
// }
/**
* up load blob and get a blob url
* @param id
* @returns {Promise<string | null>} blob url
*/
async setBlob(workspace: BlocksuiteWorkspace, blob: Blob): Promise<string> {
const blobStorage = await workspace.blobs;
return (await blobStorage?.set(blob)) || '';
}
onMessage(cb: (message: Message) => void) {
return this._messageCenter.onMessage(cb);
}
}

View File

@ -26,5 +26,6 @@ const _initializeDataCenter = () => {
export const getDataCenter = _initializeDataCenter();
export type { AccessTokenMessage } from './provider/affine/apis';
export type { Workspace } from './types';
export type { WorkspaceInfo } from './types';
export { getLogger } from './logger';
export * from './message';

View File

@ -0,0 +1,3 @@
export enum MessageCode {
loginError,
}

View File

@ -0,0 +1,2 @@
export { MessageCenter } from './message';
export { MessageCode } from './code';

View File

@ -0,0 +1,24 @@
import { Observable } from 'lib0/observable';
import { Message } from 'src/types';
import { MessageCode } from './code';
export class MessageCenter extends Observable<string> {
constructor() {
super();
}
public send(message: MessageCode) {
this.emit('message', [message]);
}
public onMessage(callback: (message: Message) => void) {
this.on('message', callback);
}
private messages: Record<number, Message> = {
[MessageCode.loginError]: {
code: MessageCode.loginError,
message: 'Login failed',
},
};
}

View File

@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
import { AffineProvider } from '../affine.js';
// import { Workspaces } from '../../../workspaces/index.js';
import { apis } from './mock-apis.js';
import 'fake-indexeddb/auto';
// TODO: we should find a better way for testing AffineProvider.
test.describe.serial('affine provider', async () => {
// const workspaces = new Workspaces();
// const provider = new AffineProvider({
// workspaces: workspaces.createScope(),
// apis,
// });
// await provider.auth();
// const workspaceName = 'workspace-test';
// let workspaceId: string | undefined;
// test('create workspace', async () => {
// const w = await provider.createWorkspace({
// name: workspaceName,
// avatar: 'avatar-url-test',
// });
// workspaceId = w?.room;
// expect(workspaces.workspaces.length).toEqual(1);
// expect(workspaces.workspaces[0].name).toEqual(workspaceName);
// });
// test('workspace list cache', async () => {
// const workspaces1 = new Workspaces();
// const provider1 = new AffineProvider({
// workspaces: workspaces1.createScope(),
// });
// await provider1.loadWorkspaces();
// expect(workspaces1.workspaces.length).toEqual(1);
// expect(workspaces1.workspaces[0].name).toEqual(workspaceName);
// expect(workspaces1.workspaces[0].id).toEqual(workspaceId);
// });
// test('update workspace', async () => {
// await provider.updateWorkspaceMeta(workspaceId!, {
// name: '1111',
// });
// expect(workspaces.workspaces[0].name).toEqual('1111');
// });
// test('delete workspace', async () => {
// expect(workspaces.workspaces.length).toEqual(1);
// await provider.deleteWorkspace(workspaces.workspaces[0].id);
// expect(workspaces.workspaces.length).toEqual(0);
// });
});

View File

@ -0,0 +1,19 @@
import type { Apis, AccessTokenMessage } from '../apis';
const user: AccessTokenMessage = {
create_at: Date.now(),
exp: 100000000,
email: 'demo@demo.demo',
id: '123',
name: 'demo',
avatar_url: 'demo-avatar-url',
};
export const apis = {
signInWithGoogle: () => {
return Promise.resolve(user);
},
createWorkspace: mate => {
return Promise.resolve({ id: 'test' });
},
} as Apis;

View File

@ -1,84 +1,104 @@
import {
getWorkspaces,
getWorkspaceDetail,
WorkspaceDetail,
downloadWorkspace,
deleteWorkspace,
leaveWorkspace,
inviteMember,
removeMember,
createWorkspace,
updateWorkspace,
} from './apis/workspace';
import { BaseProvider } from '../base';
import { BaseProvider } from '../base.js';
import type { ProviderConstructorParams } from '../base';
import { User, Workspace as WS, WorkspaceMeta } from '../../types';
import { Workspace } from '@blocksuite/store';
import type { User, WorkspaceInfo, WorkspaceMeta } from '../../types';
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import { BlockSchema } from '@blocksuite/blocks/models';
import { applyUpdate } from 'yjs';
import { token, Callback } from './apis';
import { varStorage as storage } from 'lib0/storage';
import { storage } from './storage.js';
import assert from 'assert';
import { getAuthorizer } from './apis/token';
import { WebsocketProvider } from './sync';
import { IndexedDBProvider } from '../indexeddb';
import { getDefaultHeadImgBlob } from '../../utils';
import { getUserByEmail } from './apis/user';
import { WebsocketProvider } from './sync.js';
// import { IndexedDBProvider } from '../local/indexeddb';
import { getApis } from './apis/index.js';
import type { Apis, WorkspaceDetail, Callback } from './apis';
import { setDefaultAvatar } from '../utils.js';
import { MessageCode } from 'src/message/code.js';
export interface AffineProviderConstructorParams
extends ProviderConstructorParams {
apis?: Apis;
}
export class AffineProvider extends BaseProvider {
public id = 'affine';
private _workspacesCache: Map<string, Workspace> = new Map();
private _workspacesCache: Map<string, BlocksuiteWorkspace> = new Map();
private _onTokenRefresh?: Callback = undefined;
private readonly _authorizer = getAuthorizer();
private _user: User | undefined = undefined;
private _wsMap: Map<string, WebsocketProvider> = new Map();
private _idbMap: Map<string, IndexedDBProvider> = new Map();
private _apis: Apis;
// private _idbMap: Map<string, IndexedDBProvider> = new Map();
constructor(params: ProviderConstructorParams) {
constructor({ apis, ...params }: AffineProviderConstructorParams) {
super(params);
this._apis = apis || getApis();
this.init().then(() => {
if (this._apis.token.isLogin) {
this.loadWorkspaces();
}
});
}
override async init() {
this._onTokenRefresh = () => {
if (token.refresh) {
storage.setItem('token', token.refresh);
if (this._apis.token.refresh) {
storage.setItem('token', this._apis.token.refresh);
}
};
token.onChange(this._onTokenRefresh);
this._apis.token.onChange(this._onTokenRefresh);
// initial login token
if (token.isExpired) {
if (this._apis.token.isExpired) {
try {
const refreshToken = storage.getItem('token');
await token.refreshToken(refreshToken);
await this._apis.token.refreshToken(refreshToken);
if (token.refresh) {
storage.set('token', token.refresh);
if (this._apis.token.refresh) {
storage.set('token', this._apis.token.refresh);
}
assert(token.isLogin);
assert(this._apis.token.isLogin);
} catch (_) {
// this._logger('Authorization failed, fallback to local mode');
}
} else {
storage.setItem('token', token.refresh);
storage.setItem('token', this._apis.token.refresh);
}
}
override async warpWorkspace(workspace: Workspace) {
const { doc, room } = workspace;
assert(room);
this._initWorkspaceDb(workspace);
const updates = await downloadWorkspace(room);
if (updates) {
private async _applyCloudUpdates(blocksuiteWorkspace: BlocksuiteWorkspace) {
const { doc, room: workspaceId } = blocksuiteWorkspace;
assert(workspaceId, 'Blocksuite Workspace without room(workspaceId).');
const updates = await this._apis.downloadWorkspace(workspaceId);
if (updates && updates.byteLength) {
await new Promise(resolve => {
doc.once('update', resolve);
applyUpdate(doc, new Uint8Array(updates));
BlocksuiteWorkspace.Y.applyUpdate(doc, new Uint8Array(updates));
});
}
const ws = new WebsocketProvider('/', room, doc);
}
override async warpWorkspace(workspace: BlocksuiteWorkspace) {
await this._applyCloudUpdates(workspace);
const { doc, room } = workspace;
assert(room);
this.linkLocal(workspace);
let ws = this._wsMap.get(room);
if (!ws) {
const wsUrl = `${
window.location.protocol === 'https:' ? 'wss' : 'ws'
}://${window.location.host}/api/sync/`;
ws = new WebsocketProvider(wsUrl, room, doc, {
params: { token: this._apis.token.refresh },
});
this._wsMap.set(room, ws);
}
// close all websocket links
Array.from(this._wsMap.entries()).forEach(([id, ws]) => {
if (id !== room) {
ws.disconnect();
}
});
ws.connect();
await new Promise<void>((resolve, reject) => {
// TODO: synced will also be triggered on reconnection after losing sync
// There needs to be an event mechanism to emit the synchronization state to the upper layer
@ -91,11 +111,11 @@ export class AffineProvider extends BaseProvider {
}
override async loadWorkspaces() {
if (!token.isLogin) {
if (!this._apis.token.isLogin) {
return [];
}
const workspacesList = await getWorkspaces();
const workspaces: WS[] = workspacesList.map(w => {
const workspacesList = await this._apis.getWorkspaces();
const workspaces: WorkspaceInfo[] = workspacesList.map(w => {
return {
...w,
memberCount: 0,
@ -106,13 +126,13 @@ export class AffineProvider extends BaseProvider {
const workspaceInstances = workspaces.map(({ id }) => {
const workspace =
this._workspacesCache.get(id) ||
new Workspace({
new BlocksuiteWorkspace({
room: id,
}).register(BlockSchema);
this._workspacesCache.set(id, workspace);
if (workspace) {
return new Promise<Workspace>(resolve => {
downloadWorkspace(id).then(data => {
return new Promise<BlocksuiteWorkspace>(resolve => {
this._apis.downloadWorkspace(id).then(data => {
applyUpdate(workspace.doc, new Uint8Array(data));
resolve(workspace);
});
@ -135,7 +155,7 @@ export class AffineProvider extends BaseProvider {
const { id } = w;
return new Promise<{ id: string; detail: WorkspaceDetail | null }>(
resolve => {
getWorkspaceDetail({ id }).then(data => {
this._apis.getWorkspaceDetail({ id }).then(data => {
resolve({ id, detail: data || null });
});
}
@ -171,30 +191,34 @@ export class AffineProvider extends BaseProvider {
override async auth() {
const refreshToken = await storage.getItem('token');
if (refreshToken) {
await token.refreshToken(refreshToken);
if (token.isLogin && !token.isExpired) {
await this._apis.token.refreshToken(refreshToken);
if (this._apis.token.isLogin && !this._apis.token.isExpired) {
// login success
return;
}
}
const user = await this._authorizer[0]?.();
assert(user);
this._user = {
const user = await this._apis.signInWithGoogle?.();
if (!user) {
this._messageCenter.send(MessageCode.loginError);
}
}
public override async getUserInfo(): Promise<User | undefined> {
const user = this._apis.token.user;
return user
? {
id: user.id,
name: user.name,
avatar: user.avatar_url,
email: user.email,
};
}
public override async getUserInfo(): Promise<User | undefined> {
return this._user;
: undefined;
}
public override async deleteWorkspace(id: string): Promise<void> {
await this.closeWorkspace(id);
IndexedDBProvider.delete(id);
await deleteWorkspace({ id });
// IndexedDBProvider.delete(id);
await this._apis.deleteWorkspace({ id });
this._workspaces.remove(id);
}
@ -213,83 +237,95 @@ export class AffineProvider extends BaseProvider {
}
public override async closeWorkspace(id: string) {
const idb = this._idbMap.get(id);
idb?.destroy();
// const idb = this._idbMap.get(id);
// idb?.destroy();
const ws = this._wsMap.get(id);
ws?.disconnect();
}
public override async leaveWorkspace(id: string): Promise<void> {
await leaveWorkspace({ id });
await this._apis.leaveWorkspace({ id });
}
public override async invite(id: string, email: string): Promise<void> {
return await inviteMember({ id, email });
return await this._apis.inviteMember({ id, email });
}
public override async removeMember(permissionId: number): Promise<void> {
return await removeMember({ permissionId });
return await this._apis.removeMember({ permissionId });
}
private async _initWorkspaceDb(workspace: Workspace) {
assert(workspace.room);
let idb = this._idbMap.get(workspace.room);
idb?.destroy();
idb = new IndexedDBProvider(workspace.room, workspace.doc);
this._idbMap.set(workspace.room, idb);
await idb.whenSynced;
this._logger('Local data loaded');
return idb;
public override async linkLocal(workspace: BlocksuiteWorkspace) {
return workspace;
// assert(workspace.room);
// let idb = this._idbMap.get(workspace.room);
// idb?.destroy();
// idb = new IndexedDBProvider(workspace.room, workspace.doc);
// this._idbMap.set(workspace.room, idb);
// await idb.whenSynced;
// this._logger('Local data loaded');
// return workspace;
}
public override async createWorkspaceInfo(
meta: WorkspaceMeta
): Promise<WorkspaceInfo> {
const { id } = await this._apis.createWorkspace(
meta as Required<WorkspaceMeta>
);
const workspaceInfo: WorkspaceInfo = {
name: meta.name,
id: id,
isPublish: false,
avatar: '',
owner: await this.getUserInfo(),
isLocal: true,
memberCount: 1,
provider: 'affine',
};
return workspaceInfo;
}
public override async createWorkspace(
blocksuiteWorkspace: BlocksuiteWorkspace,
meta: WorkspaceMeta
): Promise<Workspace | undefined> {
assert(meta.name, 'Workspace name is required');
const { id } = await createWorkspace(meta as Required<WorkspaceMeta>);
): Promise<BlocksuiteWorkspace | undefined> {
const workspaceId = blocksuiteWorkspace.room;
assert(workspaceId, 'Blocksuite Workspace without room(workspaceId).');
this._logger('Creating affine workspace');
const nw = new Workspace({
room: id,
}).register(BlockSchema);
nw.meta.setName(meta.name);
this._initWorkspaceDb(nw);
const workspaceInfo: WS = {
this._applyCloudUpdates(blocksuiteWorkspace);
this.linkLocal(blocksuiteWorkspace);
const workspaceInfo: WorkspaceInfo = {
name: meta.name,
id,
id: workspaceId,
isPublish: false,
avatar: '',
owner: undefined,
isLocal: true,
memberCount: 1,
provider: 'local',
provider: 'affine',
};
if (!meta.avatar) {
// set default avatar
const blob = await getDefaultHeadImgBlob(meta.name);
const blobStorage = await nw.blobs;
assert(blobStorage, 'No blob storage');
const blobId = await blobStorage.set(blob);
const avatar = await blobStorage.get(blobId);
if (avatar) {
nw.meta.setAvatar(avatar);
workspaceInfo.avatar = avatar;
}
if (!blocksuiteWorkspace.meta.avatar) {
await setDefaultAvatar(blocksuiteWorkspace);
workspaceInfo.avatar = blocksuiteWorkspace.meta.avatar;
}
this._workspaces.add(workspaceInfo);
return nw;
return blocksuiteWorkspace;
}
public override async publish(id: string, isPublish: boolean): Promise<void> {
await updateWorkspace({ id, public: isPublish });
await this._apis.updateWorkspace({ id, public: isPublish });
}
public override async getUserByEmail(
workspace_id: string,
email: string
): Promise<User | null> {
const user = await getUserByEmail({ workspace_id, email });
const user = await this._apis.getUserByEmail({ workspace_id, email });
return user
? {
id: user.id,

View File

@ -1,15 +1,16 @@
export { token } from './token.js';
// export { token } from './token.js';
export type { Callback } from './token.js';
import { getAuthorizer } from './token.js';
import * as user from './user.js';
import * as workspace from './workspace.js';
import { token } from './token.js';
export type Apis = typeof user &
typeof workspace & {
Omit<typeof workspace, 'WorkspaceType' | 'PermissionType'> & {
signInWithGoogle: ReturnType<typeof getAuthorizer>[0];
onAuthStateChanged: ReturnType<typeof getAuthorizer>[1];
};
} & { token: typeof token };
export const getApis = (): Apis => {
const [signInWithGoogle, onAuthStateChanged] = getAuthorizer();
@ -18,9 +19,10 @@ export const getApis = (): Apis => {
...workspace,
signInWithGoogle,
onAuthStateChanged,
token,
};
};
export type { AccessTokenMessage } from './token';
export type { Member, Workspace } from './workspace';
export type { Member, Workspace, WorkspaceDetail } from './workspace';
export { WorkspaceType } from './workspace.js';

View File

@ -2,8 +2,8 @@ import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
import type { User } from 'firebase/auth';
import { getLogger } from '../../../logger';
import { bareClient } from './request';
import { getLogger } from '../../../logger.js';
import { bareClient } from './request.js';
export interface AccessTokenMessage {
create_at: number;
@ -46,6 +46,10 @@ class Token {
this._setToken(); // fill with default value
}
get user() {
return this._user;
}
private _setToken(login?: LoginResponse) {
this._accessToken = login?.token || '';
this._refreshToken = login?.refresh || '';
@ -185,6 +189,7 @@ export const getAuthorizer = () => {
return [signInWithGoogle, onAuthStateChanged] as const;
} catch (e) {
getLogger('getAuthorizer')(e);
return [] as const;
}
};

View File

@ -0,0 +1 @@
export { varStorage as storage } from 'lib0/storage';

View File

@ -1,6 +1,7 @@
import { BlobStorage, Workspace } from '@blocksuite/store';
import { Logger, User, Workspace as WS, WorkspaceMeta } from '../types';
import type { WorkspacesScope } from '../workspaces';
import { Workspace as BlocksuiteWorkspace, uuidv4 } from '@blocksuite/store';
import { MessageCenter } from 'src/message';
import { Logger, User, WorkspaceInfo, WorkspaceMeta } from '../types';
import type { WorkspaceMetaCollectionScope } from '../workspace-meta-collection';
const defaultLogger = () => {
return;
@ -8,17 +9,24 @@ const defaultLogger = () => {
export interface ProviderConstructorParams {
logger?: Logger;
workspaces: WorkspacesScope;
workspaces: WorkspaceMetaCollectionScope;
messageCenter: MessageCenter;
}
export class BaseProvider {
public readonly id: string = 'base';
protected _workspaces!: WorkspacesScope;
protected _workspaces!: WorkspaceMetaCollectionScope;
protected _logger!: Logger;
protected _messageCenter!: MessageCenter;
public constructor({ logger, workspaces }: ProviderConstructorParams) {
public constructor({
logger,
workspaces,
messageCenter,
}: ProviderConstructorParams) {
this._logger = (logger || defaultLogger) as Logger;
this._workspaces = workspaces;
this._messageCenter = messageCenter;
}
/**
@ -28,6 +36,12 @@ export class BaseProvider {
return;
}
public async createWorkspaceInfo(
meta: WorkspaceMeta
): Promise<WorkspaceInfo> {
throw new Error(`provider: ${this.id} createWorkspaceInfo Not implemented`);
}
/**
* auth provider
*/
@ -47,14 +61,16 @@ export class BaseProvider {
* @param workspace
* @returns
*/
public async warpWorkspace(workspace: Workspace): Promise<Workspace> {
public async warpWorkspace(
workspace: BlocksuiteWorkspace
): Promise<BlocksuiteWorkspace> {
return workspace;
}
/**
* load workspaces
**/
public async loadWorkspaces(): Promise<WS[]> {
public async loadWorkspaces(): Promise<WorkspaceInfo[]> {
throw new Error(`provider: ${this.id} loadWorkSpace Not implemented`);
}
@ -153,10 +169,10 @@ export class BaseProvider {
* @param {WorkspaceMeta} meta
*/
public async createWorkspace(
blocksuiteWorkspace: BlocksuiteWorkspace,
meta: WorkspaceMeta
): Promise<Workspace | undefined> {
meta;
return;
): Promise<BlocksuiteWorkspace | undefined> {
return blocksuiteWorkspace;
}
/**
@ -169,4 +185,15 @@ export class BaseProvider {
email;
return null;
}
/**
* link workspace to local caches
* @param workspace
* @returns
*/
public async linkLocal(
workspace: BlocksuiteWorkspace
): Promise<BlocksuiteWorkspace> {
return workspace;
}
}

View File

@ -1 +1 @@
export * from './local';
export * from './local.js';

View File

@ -1,52 +1,63 @@
import { describe, test, expect } from 'vitest';
import { Workspaces } from '../../workspaces';
import { LocalProvider } from './local';
import { test, expect } from '@playwright/test';
import { WorkspaceMetaCollection } from '../../workspace-meta-collection.js';
import { LocalProvider } from './local.js';
import { createBlocksuiteWorkspace } from '../../utils/index.js';
import 'fake-indexeddb/auto';
import { BlobStorage } from '@blocksuite/store';
describe('local provider', () => {
const workspaces = new Workspaces();
test.describe.serial('local provider', () => {
const workspaceMetaCollection = new WorkspaceMetaCollection();
const provider = new LocalProvider({
workspaces: workspaces.createScope(),
blobs: new BlobStorage(),
workspaces: workspaceMetaCollection.createScope(),
});
const workspaceName = 'workspace-test';
let workspaceId: string | undefined;
test('create workspace', async () => {
const w = await provider.createWorkspace({
const workspaceInfo = await provider.createWorkspaceInfo({
name: workspaceName,
avatar: 'avatar-url-test',
});
workspaceId = workspaceInfo.id;
const blocksuiteWorkspace = createBlocksuiteWorkspace(workspaceId);
await provider.createWorkspace(blocksuiteWorkspace, {
name: workspaceName,
avatar: 'avatar-url-test',
});
workspaceId = w?.room;
expect(workspaces.workspaces.length).toEqual(1);
expect(workspaces.workspaces[0].name).toEqual(workspaceName);
expect(workspaceMetaCollection.workspaces.length).toEqual(1);
expect(workspaceMetaCollection.workspaces[0].name).toEqual(workspaceName);
});
test('workspace list cache', async () => {
const workspaces1 = new Workspaces();
const workspacesMetaCollection1 = new WorkspaceMetaCollection();
const provider1 = new LocalProvider({
workspaces: workspaces1.createScope(),
blobs: new BlobStorage(),
workspaces: workspacesMetaCollection1.createScope(),
});
await provider1.loadWorkspaces();
expect(workspaces1.workspaces.length).toEqual(1);
expect(workspaces1.workspaces[0].name).toEqual(workspaceName);
expect(workspaces1.workspaces[0].id).toEqual(workspaceId);
expect(workspacesMetaCollection1.workspaces.length).toEqual(1);
expect(workspacesMetaCollection1.workspaces[0].name).toEqual(workspaceName);
expect(workspacesMetaCollection1.workspaces[0].id).toEqual(workspaceId);
});
test('update workspace', async () => {
await provider.updateWorkspaceMeta(workspaceId!, {
name: '1111',
});
expect(workspaces.workspaces[0].name).toEqual('1111');
expect(workspaceMetaCollection.workspaces[0].name).toEqual('1111');
});
test('delete workspace', async () => {
expect(workspaces.workspaces.length).toEqual(1);
await provider.deleteWorkspace(workspaces.workspaces[0].id);
expect(workspaces.workspaces.length).toEqual(0);
expect(workspaceMetaCollection.workspaces.length).toEqual(1);
/**
* FIXME
* If we don't wrap setTimeout,
* Running deleteWorkspace will crash the worker, and get error like next line:
* InvalidStateError: An operation was called on an object on which it is not allowed or at a time when it is not allowed. Also occurs if a request is made on a source object that has been deleted or removed. Use TransactionInactiveError or ReadOnlyError when possible, as they are more specific variations of InvalidStateError.
* */
setTimeout(async () => {
await provider.deleteWorkspace(workspaceMetaCollection.workspaces[0].id);
expect(workspaceMetaCollection.workspaces.length).toEqual(0);
}, 10);
});
});

View File

@ -1,11 +1,11 @@
import { BaseProvider } from '../base';
import { BaseProvider } from '../base.js';
import type { ProviderConstructorParams } from '../base';
import { varStorage as storage } from 'lib0/storage';
import { Workspace as WS, WorkspaceMeta } from '../../types';
import { Workspace, uuidv4 } from '@blocksuite/store';
import { IndexedDBProvider } from '../indexeddb';
import { WorkspaceInfo, WorkspaceMeta } from '../../types';
import { Workspace as BlocksuiteWorkspace, uuidv4 } from '@blocksuite/store';
import { IndexedDBProvider } from './indexeddb.js';
import assert from 'assert';
import { getDefaultHeadImgBlob } from '../../utils';
import { setDefaultAvatar } from '../utils.js';
const WORKSPACE_KEY = 'workspaces';
@ -18,34 +18,34 @@ export class LocalProvider extends BaseProvider {
this.loadWorkspaces();
}
private _storeWorkspaces(workspaces: WS[]) {
private _storeWorkspaces(workspaces: WorkspaceInfo[]) {
storage.setItem(WORKSPACE_KEY, JSON.stringify(workspaces));
}
private async _initWorkspaceDb(workspace: Workspace) {
public override async linkLocal(workspace: BlocksuiteWorkspace) {
assert(workspace.room);
let idb = this._idbMap.get(workspace.room);
idb?.destroy();
idb = new IndexedDBProvider(workspace.room, workspace.doc);
this._idbMap.set(workspace.room, idb);
this._logger('Local data loaded');
return idb;
}
public override async warpWorkspace(
workspace: Workspace
): Promise<Workspace> {
assert(workspace.room);
await this._initWorkspaceDb(workspace);
return workspace;
}
override loadWorkspaces(): Promise<WS[]> {
public override async warpWorkspace(
workspace: BlocksuiteWorkspace
): Promise<BlocksuiteWorkspace> {
assert(workspace.room);
await this.linkLocal(workspace);
return workspace;
}
override loadWorkspaces(): Promise<WorkspaceInfo[]> {
const workspaceStr = storage.getItem(WORKSPACE_KEY);
let workspaces: WS[] = [];
let workspaces: WorkspaceInfo[] = [];
if (workspaceStr) {
try {
workspaces = JSON.parse(workspaceStr) as WS[];
workspaces = JSON.parse(workspaceStr) as WorkspaceInfo[];
workspaces.forEach(workspace => {
this._workspaces.add(workspace);
});
@ -75,13 +75,10 @@ export class LocalProvider extends BaseProvider {
this._storeWorkspaces(this._workspaces.list());
}
public override async createWorkspace(
public override async createWorkspaceInfo(
meta: WorkspaceMeta
): Promise<Workspace | undefined> {
assert(meta.name, 'Workspace name is required');
this._logger('Creating affine workspace');
const workspaceInfo: WS = {
): Promise<WorkspaceInfo> {
const workspaceInfo: WorkspaceInfo = {
name: meta.name,
id: uuidv4(),
isPublish: false,
@ -91,27 +88,41 @@ export class LocalProvider extends BaseProvider {
memberCount: 1,
provider: 'local',
};
const workspace = new Workspace({ room: workspaceInfo.id });
this._initWorkspaceDb(workspace);
workspace.meta.setName(meta.name);
if (!meta.avatar) {
// set default avatar
const blob = await getDefaultHeadImgBlob(meta.name);
const blobStorage = await workspace.blobs;
assert(blobStorage, 'No blob storage');
const blobId = await blobStorage.set(blob);
const avatar = await blobStorage.get(blobId);
if (avatar) {
workspace.meta.setAvatar(avatar);
workspaceInfo.avatar = avatar;
return Promise.resolve(workspaceInfo);
}
public override async createWorkspace(
blocksuiteWorkspace: BlocksuiteWorkspace,
meta: WorkspaceMeta
): Promise<BlocksuiteWorkspace | undefined> {
const workspaceId = blocksuiteWorkspace.room;
assert(workspaceId, 'Blocksuite Workspace without room(workspaceId).');
assert(meta.name, 'Workspace name is required');
this._logger('Creating affine workspace');
const workspaceInfo: WorkspaceInfo = {
name: meta.name,
id: workspaceId,
isPublish: false,
avatar: '',
owner: undefined,
isLocal: true,
memberCount: 1,
provider: 'local',
};
this.linkLocal(blocksuiteWorkspace);
blocksuiteWorkspace.meta.setName(meta.name);
if (!meta.avatar) {
await setDefaultAvatar(blocksuiteWorkspace);
workspaceInfo.avatar = blocksuiteWorkspace.meta.avatar;
}
this._workspaces.add(workspaceInfo);
this._storeWorkspaces(this._workspaces.list());
return workspace;
return blocksuiteWorkspace;
}
public override async clear(): Promise<void> {

View File

@ -0,0 +1,15 @@
import assert from 'assert';
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import { getDefaultHeadImgBlob } from '../utils/index.js';
export const setDefaultAvatar = async (
blocksuiteWorkspace: BlocksuiteWorkspace
) => {
const blob = await getDefaultHeadImgBlob(blocksuiteWorkspace.meta.name);
const blobStorage = await blocksuiteWorkspace.blobs;
assert(blobStorage, 'No blob storage');
const avatar = await blobStorage.set(blob);
if (avatar) {
blocksuiteWorkspace.meta.setAvatar(avatar);
}
};

View File

@ -1,6 +1,6 @@
import { getLogger } from '../logger';
export type Workspace = {
export type WorkspaceInfo = {
name: string;
id: string;
isPublish?: boolean;
@ -18,6 +18,11 @@ export type User = {
avatar: string;
};
export type WorkspaceMeta = Pick<Workspace, 'name' | 'avatar'>;
export type WorkspaceMeta = Pick<WorkspaceInfo, 'name' | 'avatar'>;
export type Logger = ReturnType<typeof getLogger>;
export type Message = {
code: number;
message: string;
};

View File

@ -1,3 +1,12 @@
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import { BlockSchema } from '@blocksuite/blocks/models';
export const createBlocksuiteWorkspace = (workspaceId: string) => {
return new BlocksuiteWorkspace({
room: workspaceId,
}).register(BlockSchema);
};
const DefaultHeadImgColors = [
['#C6F2F3', '#0C6066'],
['#FFF5AB', '#896406'],

View File

@ -1,14 +1,14 @@
import { describe, test, expect } from 'vitest';
import { Workspaces } from './workspaces';
import type { WorkspacesChangeEvent } from './workspaces';
import { test, expect } from '@playwright/test';
import { WorkspaceMetaCollection } from './workspace-meta-collection.js';
import type { WorkspaceMetaCollectionChangeEvent } from './workspace-meta-collection';
describe('workspaces observable', () => {
const workspaces = new Workspaces();
test.describe.serial('workspace meta collection observable', () => {
const workspaces = new WorkspaceMetaCollection();
const scope = workspaces.createScope();
test('add workspace', () => {
workspaces.once('change', (event: WorkspacesChangeEvent) => {
workspaces.once('change', (event: WorkspaceMetaCollectionChangeEvent) => {
expect(event.added?.id).toEqual('123');
});
scope.add({
@ -30,7 +30,7 @@ describe('workspaces observable', () => {
});
test('update workspace', () => {
workspaces.once('change', (event: WorkspacesChangeEvent) => {
workspaces.once('change', (event: WorkspaceMetaCollectionChangeEvent) => {
expect(event.updated?.name).toEqual('demo');
});
scope.update('123', { name: 'demo' });
@ -42,7 +42,7 @@ describe('workspaces observable', () => {
});
test('delete workspace', () => {
workspaces.once('change', (event: WorkspacesChangeEvent) => {
workspaces.once('change', (event: WorkspaceMetaCollectionChangeEvent) => {
expect(event.deleted?.id).toEqual('123');
});
scope.remove('123');

View File

@ -1,25 +1,25 @@
import { Observable } from 'lib0/observable';
import type { Workspace, WorkspaceMeta } from '../types';
import type { WorkspaceInfo, WorkspaceMeta } from './types';
export interface WorkspacesScope {
get: (workspaceId: string) => Workspace | undefined;
list: () => Workspace[];
add: (workspace: Workspace) => void;
export interface WorkspaceMetaCollectionScope {
get: (workspaceId: string) => WorkspaceInfo | undefined;
list: () => WorkspaceInfo[];
add: (workspace: WorkspaceInfo) => void;
remove: (workspaceId: string) => boolean;
clear: () => void;
update: (workspaceId: string, workspaceMeta: Partial<WorkspaceMeta>) => void;
}
export interface WorkspacesChangeEvent {
added?: Workspace;
deleted?: Workspace;
updated?: Workspace;
export interface WorkspaceMetaCollectionChangeEvent {
added?: WorkspaceInfo;
deleted?: WorkspaceInfo;
updated?: WorkspaceInfo;
}
export class Workspaces extends Observable<'change'> {
private _workspacesMap = new Map<string, Workspace>();
export class WorkspaceMetaCollection extends Observable<'change'> {
private _workspacesMap = new Map<string, WorkspaceInfo>();
get workspaces(): Workspace[] {
get workspaces(): WorkspaceInfo[] {
return Array.from(this._workspacesMap.values());
}
@ -27,7 +27,7 @@ export class Workspaces extends Observable<'change'> {
return this._workspacesMap.get(workspaceId);
}
createScope(): WorkspacesScope {
createScope(): WorkspaceMetaCollectionScope {
const scopedWorkspaceIds = new Set<string>();
const get = (workspaceId: string) => {
@ -37,7 +37,7 @@ export class Workspaces extends Observable<'change'> {
return this._workspacesMap.get(workspaceId);
};
const add = (workspace: Workspace) => {
const add = (workspace: WorkspaceInfo) => {
if (this._workspacesMap.has(workspace.id)) {
throw new Error(`Duplicate workspace id.`);
}
@ -47,7 +47,7 @@ export class Workspaces extends Observable<'change'> {
this.emit('change', [
{
added: workspace,
} as WorkspacesChangeEvent,
} as WorkspaceMetaCollectionChangeEvent,
]);
};
@ -69,7 +69,7 @@ export class Workspaces extends Observable<'change'> {
this.emit('change', [
{
deleted: workspace,
} as WorkspacesChangeEvent,
} as WorkspaceMetaCollectionChangeEvent,
]);
}
return true;
@ -99,13 +99,13 @@ export class Workspaces extends Observable<'change'> {
this.emit('change', [
{
updated: this._workspacesMap.get(workspaceId),
} as WorkspacesChangeEvent,
} as WorkspaceMetaCollectionChangeEvent,
]);
};
// TODO: need to optimize
const list = () => {
const workspaces: Workspace[] = [];
const workspaces: WorkspaceInfo[] = [];
scopedWorkspaceIds.forEach(id => {
const workspace = this._workspacesMap.get(id);
if (workspace) {

View File

@ -1,2 +0,0 @@
export { Workspaces } from './workspaces';
export type { WorkspacesScope, WorkspacesChangeEvent } from './workspaces';

View File

@ -1,101 +0,0 @@
import { Workspace as WS } from '../types';
import { Observable } from 'lib0/observable';
import { uuidv4 } from '@blocksuite/store';
import { DataCenter } from '../datacenter';
export class Workspaces extends Observable<string> {
private _workspaces: WS[];
private readonly _dc: DataCenter;
constructor(dc: DataCenter) {
super();
this._workspaces = [];
this._dc = dc;
}
public init() {
this._loadWorkspaces();
}
get workspaces() {
return this._workspaces;
}
/**
* emit when workspaces changed
* @param {(workspace: WS[]) => void} cb
*/
onWorkspacesChange(cb: (workspace: WS[]) => void) {
this.on('change', cb);
}
private async _loadWorkspaces() {
const providers = this._dc.providers;
let workspaces: WS[] = [];
providers.forEach(async p => {
const pWorkspaces = await p.loadWorkspaces();
workspaces = [...workspaces, ...pWorkspaces];
this._updateWorkspaces([...workspaces, ...pWorkspaces]);
});
}
/**
* focus load all workspaces list
*/
public async refreshWorkspaces() {
this._loadWorkspaces();
}
private _updateWorkspaces(workspaces: WS[]) {
this._workspaces = workspaces;
this.emit('change', this._workspaces);
}
private _getDefaultWorkspace(name: string): WS {
return {
name,
id: uuidv4(),
isPublish: false,
avatar: '',
owner: undefined,
isLocal: true,
memberCount: 1,
provider: 'local',
};
}
/** add a local workspaces */
public addLocalWorkspace(name: string) {
const workspace = this._getDefaultWorkspace(name);
this._updateWorkspaces([...this._workspaces, workspace]);
return workspace;
}
/** delete a workspaces by id */
public delete(id: string) {
const index = this._workspaces.findIndex(w => w.id === id);
if (index >= 0) {
this._workspaces.splice(index, 1);
this._updateWorkspaces(this._workspaces);
}
}
/** get workspace info by id */
public getWorkspace(id: string) {
return this._workspaces.find(w => w.id === id);
}
/** check if workspace exists */
public hasWorkspace(id: string) {
return this._workspaces.some(w => w.id === id);
}
public updateWorkspaceInfo(id: string, info: Partial<WS>) {
const index = this._workspaces.findIndex(w => w.id === id);
if (index >= 0) {
this._workspaces[index] = { ...this._workspaces[index], ...info };
this._updateWorkspaces(this._workspaces);
}
}
}

View File

@ -21,5 +21,10 @@
"outDir": "./dist"
},
"include": ["next-env.d.ts", "src/**/*.ts", "pages/**/*.tsx"],
"exclude": ["node_modules", "dist", "src/provider/affine/sync.js"]
"exclude": [
"node_modules",
"dist",
"src/provider/affine/sync.js",
"src/**/*.spec.ts"
]
}

View File

@ -0,0 +1,29 @@
{
"name": "@affine/i18n",
"version": "0.1.0",
"description": "",
"main": "dist/src/index.js",
"type": "module",
"types": "dist/src/index.d.ts",
"exports": {
"./src/*": "./dist/src/*.js",
".": "./dist/src/index.js"
},
"scripts": {
"build": "tsc --project ./tsconfig.json"
},
"keywords": [],
"repository": {
"type": "git",
"url": "git+https://github.com/toeverything/AFFiNE.git"
},
"dependencies": {
"i18next": "^21.9.1",
"prettier": "^2.7.1",
"react-i18next": "^11.18.4"
},
"devDependencies": {
"@types/prettier": "^2.7.2",
"typescript": "^4.8.4"
}
}

View File

@ -1,10 +1,6 @@
import i18next, { Resource } from 'i18next';
import {
I18nextProvider,
initReactI18next,
useTranslation,
} from 'react-i18next';
import { LOCALES } from './resources';
import { initReactI18next, useTranslation } from 'react-i18next';
import { LOCALES } from './resources/index.js';
import type en_US from './resources/en.json';
// const localStorage = {
@ -27,7 +23,7 @@ declare module 'react-i18next' {
// const STORAGE_KEY = 'i18n_lng';
export { i18n, useTranslation, I18nProvider, LOCALES };
export { i18n, useTranslation, LOCALES };
const resources = LOCALES.reduce<Resource>(
(acc, { tag, res }) => ({ ...acc, [tag]: { translation: res } }),
@ -63,4 +59,4 @@ i18n.on('languageChanged', () => {
// localStorage.setItem(STORAGE_KEY, lng);
});
const I18nProvider = I18nextProvider;
// const I18nProvider = I18nextProvider;

View File

@ -54,7 +54,7 @@
"Strikethrough": "Strikethrough",
"Inline code": "Inline code",
"Code block": "Code block",
"Hyperlink(with selected text)": "Hyperlink(with selected text)",
"Link": "Hyperlink(with selected text)",
"Body text": "Body text",
"Heading": "Heading {{number}}",
"Increase indent": "Increase indent",

View File

@ -43,7 +43,19 @@ import { fetchTolgee } from './request';
* ]
* ```
*/
export const getAllProjectLanguages = async (size = 1000) => {
export const getAllProjectLanguages = async (
size = 1000
): Promise<
{
id: number;
name: string;
tag: string;
originalName: string;
flagEmoji: string;
base: boolean;
}[]
> => {
const url = `/languages?size=${size}`;
const resp = await fetchTolgee(url);
if (resp.status < 200 || resp.status >= 300) {
@ -70,7 +82,7 @@ export const getAllProjectLanguages = async (size = 1000) => {
*
* See https://tolgee.io/api#operation/getTranslations_
*/
export const getTranslations = async () => {
export const getTranslations = async (): Promise<unknown> => {
const url = '/translations';
const resp = await fetchTolgee(url);
if (resp.status < 200 || resp.status >= 300) {
@ -87,17 +99,19 @@ export const getTranslations = async () => {
*/
export const getLanguagesTranslations = async <T extends string>(
languages: T
) => {
): Promise<{ [key in T]?: Record<string, string> }> => {
const url = `/translations/${languages}`;
const resp = await fetchTolgee(url);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(url + ' ' + resp.status + '\n' + (await resp.text()));
}
const json: { [key in T]?: Record<string, string> } = await resp.json();
const json = await resp.json();
return json;
};
export const getRemoteTranslations = async (languages: string) => {
export const getRemoteTranslations = async (
languages: string
): Promise<Record<string, string>> => {
const translations = await getLanguagesTranslations(languages);
if (!(languages in translations)) {
return {};
@ -115,7 +129,7 @@ export const getRemoteTranslations = async (languages: string) => {
export const createsNewKey = async (
key: string,
translations: Record<string, string>
) => {
): Promise<unknown> => {
const url = '/translations/keys/create';
const resp = await fetchTolgee(url, {
method: 'POST',
@ -133,7 +147,10 @@ export const createsNewKey = async (
*
* See https://tolgee.io/api#operation/tagKey_1
*/
export const addTag = async (keyId: string, tagName: string) => {
export const addTag = async (
keyId: string,
tagName: string
): Promise<unknown> => {
const url = `/keys/${keyId}/tags`;
const resp = await fetchTolgee(url, {
method: 'PUT',
@ -151,7 +168,10 @@ export const addTag = async (keyId: string, tagName: string) => {
*
* See https://tolgee.io/api#operation/tagKey_1
*/
export const removeTag = async (keyId: string, tagId: number) => {
export const removeTag = async (
keyId: string,
tagId: number
): Promise<unknown> => {
const url = `/keys/${keyId}/tags/${tagId}`;
const resp = await fetchTolgee(url, {
method: 'DELETE',
@ -174,7 +194,7 @@ export const removeTag = async (keyId: string, tagId: number) => {
*
* See https://tolgee.io/api#operation/export_1
*/
export const exportResources = async () => {
export const exportResources = async (): Promise<Response> => {
const url = `/export`;
const resp = await fetchTolgee(url);

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": false,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"experimentalDecorators": true,
"declaration": true,
"baseUrl": ".",
"rootDir": ".",
"outDir": "./dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['packages/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
},
});