mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 09:33:45 +00:00
Compare commits
8 Commits
v0.20.1-be
...
v0.20.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ec7de7e32 | ||
|
|
e5e5c0a8ba | ||
|
|
c644a46b8d | ||
|
|
7e892b3a7e | ||
|
|
848145150d | ||
|
|
dee6be11fb | ||
|
|
abda70d2c8 | ||
|
|
40104f2f87 |
@@ -15,8 +15,12 @@ export class BlobFrontend {
|
||||
return this.sync.uploadBlob(blob);
|
||||
}
|
||||
|
||||
fullSync() {
|
||||
return this.sync.fullSync();
|
||||
fullDownload() {
|
||||
return this.sync.fullDownload();
|
||||
}
|
||||
|
||||
fullUpload() {
|
||||
return this.sync.fullUpload();
|
||||
}
|
||||
|
||||
addPriority(_id: string, _priority: number) {
|
||||
|
||||
@@ -399,31 +399,23 @@ export class DocFrontend {
|
||||
this.statusUpdatedSubject$.next(job.docId);
|
||||
}
|
||||
|
||||
/**
|
||||
* skip listen doc update when apply update
|
||||
*/
|
||||
private skipDocUpdate = false;
|
||||
|
||||
applyUpdate(docId: string, update: Uint8Array) {
|
||||
const doc = this.status.docs.get(docId);
|
||||
if (doc && !isEmptyUpdate(update)) {
|
||||
try {
|
||||
this.skipDocUpdate = true;
|
||||
applyUpdate(doc, update, NBSTORE_ORIGIN);
|
||||
} catch (err) {
|
||||
console.error('failed to apply update yjs doc', err);
|
||||
} finally {
|
||||
this.skipDocUpdate = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly handleDocUpdate = (
|
||||
update: Uint8Array,
|
||||
_origin: any,
|
||||
origin: any,
|
||||
doc: YDoc
|
||||
) => {
|
||||
if (this.skipDocUpdate) {
|
||||
if (origin === NBSTORE_ORIGIN) {
|
||||
return;
|
||||
}
|
||||
if (!this.status.docs.has(doc.guid)) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { PeerStorageOptions } from '../types';
|
||||
|
||||
export interface BlobSyncState {
|
||||
isStorageOverCapacity: boolean;
|
||||
total: number;
|
||||
synced: number;
|
||||
}
|
||||
|
||||
export interface BlobSync {
|
||||
@@ -18,7 +20,8 @@ export interface BlobSync {
|
||||
signal?: AbortSignal
|
||||
): Promise<BlobRecord | null>;
|
||||
uploadBlob(blob: BlobRecord, signal?: AbortSignal): Promise<void>;
|
||||
fullSync(signal?: AbortSignal): Promise<void>;
|
||||
fullDownload(signal?: AbortSignal): Promise<void>;
|
||||
fullUpload(signal?: AbortSignal): Promise<void>;
|
||||
setMaxBlobSize(size: number): void;
|
||||
onReachedMaxBlobSize(cb: (byteSize: number) => void): () => void;
|
||||
}
|
||||
@@ -26,6 +29,8 @@ export interface BlobSync {
|
||||
export class BlobSyncImpl implements BlobSync {
|
||||
readonly state$ = new BehaviorSubject<BlobSyncState>({
|
||||
isStorageOverCapacity: false,
|
||||
total: Object.values(this.storages.remotes).length ? 1 : 0,
|
||||
synced: 0,
|
||||
});
|
||||
private abort: AbortController | null = null;
|
||||
private maxBlobSize: number = 1024 * 1024 * 100; // 100MB
|
||||
@@ -34,19 +39,24 @@ export class BlobSyncImpl implements BlobSync {
|
||||
constructor(readonly storages: PeerStorageOptions<BlobStorage>) {}
|
||||
|
||||
async downloadBlob(blobId: string, signal?: AbortSignal) {
|
||||
const localBlob = await this.storages.local.get(blobId, signal);
|
||||
if (localBlob) {
|
||||
return localBlob;
|
||||
}
|
||||
|
||||
for (const storage of Object.values(this.storages.remotes)) {
|
||||
const data = await storage.get(blobId, signal);
|
||||
if (data) {
|
||||
await this.storages.local.set(data, signal);
|
||||
return data;
|
||||
try {
|
||||
const localBlob = await this.storages.local.get(blobId, signal);
|
||||
if (localBlob) {
|
||||
return localBlob;
|
||||
}
|
||||
|
||||
for (const storage of Object.values(this.storages.remotes)) {
|
||||
const data = await storage.get(blobId, signal);
|
||||
if (data) {
|
||||
await this.storages.local.set(data, signal);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('error when download blob', e);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async uploadBlob(blob: BlobRecord, signal?: AbortSignal) {
|
||||
@@ -62,7 +72,11 @@ export class BlobSyncImpl implements BlobSync {
|
||||
return await remote.set(blob, signal);
|
||||
} catch (err) {
|
||||
if (err instanceof OverCapacityError) {
|
||||
this.state$.next({ isStorageOverCapacity: true });
|
||||
this.state$.next({
|
||||
isStorageOverCapacity: true,
|
||||
total: this.state$.value.total,
|
||||
synced: this.state$.value.synced,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -70,71 +84,95 @@ export class BlobSyncImpl implements BlobSync {
|
||||
);
|
||||
}
|
||||
|
||||
async fullSync(signal?: AbortSignal) {
|
||||
async fullDownload(signal?: AbortSignal) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
await this.storages.local.connection.waitForConnected(signal);
|
||||
const localList = (await this.storages.local.list(signal)).map(b => b.key);
|
||||
this.state$.next({
|
||||
...this.state$.value,
|
||||
synced: localList.length,
|
||||
});
|
||||
|
||||
for (const [remotePeer, remote] of Object.entries(this.storages.remotes)) {
|
||||
let localList: string[] = [];
|
||||
let remoteList: string[] = [];
|
||||
await Promise.allSettled(
|
||||
Object.entries(this.storages.remotes).map(
|
||||
async ([remotePeer, remote]) => {
|
||||
await remote.connection.waitForConnected(signal);
|
||||
|
||||
await remote.connection.waitForConnected(signal);
|
||||
const remoteList = (await remote.list(signal)).map(b => b.key);
|
||||
|
||||
try {
|
||||
localList = (await this.storages.local.list(signal)).map(b => b.key);
|
||||
throwIfAborted(signal);
|
||||
remoteList = (await remote.list(signal)).map(b => b.key);
|
||||
throwIfAborted(signal);
|
||||
} catch (err) {
|
||||
if (err === MANUALLY_STOP) {
|
||||
throw err;
|
||||
}
|
||||
console.error(`error when sync`, err);
|
||||
continue;
|
||||
}
|
||||
this.state$.next({
|
||||
...this.state$.value,
|
||||
total: Math.max(this.state$.value.total, remoteList.length),
|
||||
});
|
||||
|
||||
const needUpload = difference(localList, remoteList);
|
||||
for (const key of needUpload) {
|
||||
try {
|
||||
const data = await this.storages.local.get(key, signal);
|
||||
throwIfAborted(signal);
|
||||
if (data) {
|
||||
await remote.set(data, signal);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const needDownload = difference(remoteList, localList);
|
||||
for (const key of needDownload) {
|
||||
try {
|
||||
const data = await remote.get(key, signal);
|
||||
throwIfAborted(signal);
|
||||
if (data) {
|
||||
await this.storages.local.set(data, signal);
|
||||
this.state$.next({
|
||||
...this.state$.value,
|
||||
synced: this.state$.value.synced + 1,
|
||||
});
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err === MANUALLY_STOP) {
|
||||
throw err;
|
||||
}
|
||||
console.error(
|
||||
`error when sync ${key} from [${remotePeer}] to [local]`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err === MANUALLY_STOP) {
|
||||
throw err;
|
||||
}
|
||||
console.error(
|
||||
`error when sync ${key} from [local] to [${remotePeer}]`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const needDownload = difference(remoteList, localList);
|
||||
async fullUpload(signal?: AbortSignal) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
await this.storages.local.connection.waitForConnected(signal);
|
||||
const localList = (await this.storages.local.list(signal)).map(b => b.key);
|
||||
|
||||
await Promise.allSettled(
|
||||
Object.entries(this.storages.remotes).map(
|
||||
async ([remotePeer, remote]) => {
|
||||
await remote.connection.waitForConnected(signal);
|
||||
|
||||
const remoteList = (await remote.list(signal)).map(b => b.key);
|
||||
|
||||
for (const key of needDownload) {
|
||||
try {
|
||||
const data = await remote.get(key, signal);
|
||||
throwIfAborted(signal);
|
||||
if (data) {
|
||||
await this.storages.local.set(data, signal);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const needUpload = difference(localList, remoteList);
|
||||
for (const key of needUpload) {
|
||||
try {
|
||||
const data = await this.storages.local.get(key, signal);
|
||||
throwIfAborted(signal);
|
||||
if (data) {
|
||||
await remote.set(data, signal);
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err === MANUALLY_STOP) {
|
||||
throw err;
|
||||
}
|
||||
console.error(
|
||||
`error when sync ${key} from [local] to [${remotePeer}]`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err === MANUALLY_STOP) {
|
||||
throw err;
|
||||
}
|
||||
console.error(
|
||||
`error when sync ${key} from [${remotePeer}] to [local]`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -144,16 +182,12 @@ export class BlobSyncImpl implements BlobSync {
|
||||
|
||||
const abort = new AbortController();
|
||||
this.abort = abort;
|
||||
|
||||
// TODO(@eyhn): fix this, large blob may cause iOS to crash?
|
||||
if (!BUILD_CONFIG.isIOS) {
|
||||
this.fullSync(abort.signal).catch(error => {
|
||||
if (error === MANUALLY_STOP) {
|
||||
return;
|
||||
}
|
||||
console.error('sync blob error', error);
|
||||
});
|
||||
}
|
||||
this.fullUpload(abort.signal).catch(error => {
|
||||
if (error === MANUALLY_STOP) {
|
||||
return;
|
||||
}
|
||||
console.error('sync blob error', error);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
|
||||
@@ -257,26 +257,23 @@ class WorkerBlobSync implements BlobSync {
|
||||
uploadBlob(blob: BlobRecord, _signal?: AbortSignal): Promise<void> {
|
||||
return this.client.call('blobSync.uploadBlob', blob);
|
||||
}
|
||||
fullSync(signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const abortListener = () => {
|
||||
reject(signal?.reason);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
fullDownload(signal?: AbortSignal): Promise<void> {
|
||||
const download = this.client.call('blobSync.fullDownload');
|
||||
|
||||
signal?.addEventListener('abort', abortListener);
|
||||
|
||||
const subscription = this.client.ob$('blobSync.fullSync').subscribe({
|
||||
next() {
|
||||
signal?.removeEventListener('abort', abortListener);
|
||||
resolve();
|
||||
},
|
||||
error(err) {
|
||||
signal?.removeEventListener('abort', abortListener);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
signal?.addEventListener('abort', () => {
|
||||
download.cancel();
|
||||
});
|
||||
|
||||
return download;
|
||||
}
|
||||
fullUpload(signal?: AbortSignal): Promise<void> {
|
||||
const upload = this.client.call('blobSync.fullUpload');
|
||||
|
||||
signal?.addEventListener('abort', () => {
|
||||
upload.cancel();
|
||||
});
|
||||
|
||||
return upload;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -234,20 +234,10 @@ class StoreConsumer {
|
||||
'docSync.resetSync': () => this.docSync.resetSync(),
|
||||
'blobSync.downloadBlob': key => this.blobSync.downloadBlob(key),
|
||||
'blobSync.uploadBlob': blob => this.blobSync.uploadBlob(blob),
|
||||
'blobSync.fullSync': () =>
|
||||
new Observable(subscriber => {
|
||||
const abortController = new AbortController();
|
||||
this.blobSync
|
||||
.fullSync(abortController.signal)
|
||||
.then(() => {
|
||||
subscriber.next(true);
|
||||
subscriber.complete();
|
||||
})
|
||||
.catch(error => {
|
||||
subscriber.error(error);
|
||||
});
|
||||
return () => abortController.abort(MANUALLY_STOP);
|
||||
}),
|
||||
'blobSync.fullDownload': (_, { signal }) =>
|
||||
this.blobSync.fullDownload(signal),
|
||||
'blobSync.fullUpload': (_, { signal }) =>
|
||||
this.blobSync.fullUpload(signal),
|
||||
'blobSync.state': () => this.blobSync.state$,
|
||||
'blobSync.setMaxBlobSize': size => this.blobSync.setMaxBlobSize(size),
|
||||
'blobSync.onReachedMaxBlobSize': () =>
|
||||
|
||||
@@ -87,7 +87,8 @@ interface GroupedWorkerOps {
|
||||
blobSync: {
|
||||
downloadBlob: [string, BlobRecord | null];
|
||||
uploadBlob: [BlobRecord, void];
|
||||
fullSync: [void, boolean];
|
||||
fullDownload: [void, void];
|
||||
fullUpload: [void, void];
|
||||
setMaxBlobSize: [number, void];
|
||||
onReachedMaxBlobSize: [void, number];
|
||||
state: [void, BlobSyncState];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { registerAIEffects } from '@affine/core/blocksuite/ai/effects';
|
||||
import { effects as editorEffects } from '@affine/core/blocksuite/editors';
|
||||
import { editorEffects } from '@affine/core/blocksuite/editors';
|
||||
import { effects as bsEffects } from '@blocksuite/affine/effects';
|
||||
|
||||
import { registerTemplates } from './register-templates';
|
||||
|
||||
@@ -231,7 +231,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
||||
if (typeof externalTitleRef === 'function') {
|
||||
externalTitleRef(el);
|
||||
} else {
|
||||
(externalTitleRef as any).current = el;
|
||||
externalTitleRef.current = el;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export const LitEdgelessEditor = createReactComponentFromLit({
|
||||
elementClass: EdgelessEditor,
|
||||
});
|
||||
|
||||
export function effects() {
|
||||
export function editorEffects() {
|
||||
customElements.define('page-editor', PageEditor);
|
||||
customElements.define('edgeless-editor', EdgelessEditor);
|
||||
}
|
||||
|
||||
@@ -145,16 +145,16 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
||||
// if currentRecurring !== recurring => 'Change to {recurring} Billing'
|
||||
// else => 'Upgrade'
|
||||
|
||||
// not signed in
|
||||
if (!loggedIn) {
|
||||
return <SignUpAction>{signUpText}</SignUpAction>;
|
||||
}
|
||||
|
||||
// team
|
||||
if (detail.plan === SubscriptionPlan.Team) {
|
||||
return <UpgradeToTeam recurring={recurring} />;
|
||||
}
|
||||
|
||||
// not signed in
|
||||
if (!loggedIn) {
|
||||
return <SignUpAction>{signUpText}</SignUpAction>;
|
||||
}
|
||||
|
||||
// lifetime
|
||||
if (isBeliever) {
|
||||
return (
|
||||
|
||||
@@ -4,33 +4,60 @@ import { Button } from '@affine/component/ui/button';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useSystemOnline } from '@affine/core/components/hooks/use-system-online';
|
||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import type { Workspace } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { universalId } from '@affine/nbstore';
|
||||
import track from '@affine/track';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useState } from 'react';
|
||||
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface ExportPanelProps {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
export const DesktopExportPanel = ({ workspace }: ExportPanelProps) => {
|
||||
const workspacePermissionService = useService(
|
||||
WorkspacePermissionService
|
||||
).permission;
|
||||
const isTeam = useLiveData(workspacePermissionService.isTeam$);
|
||||
const isOwner = useLiveData(workspacePermissionService.isOwner$);
|
||||
const isAdmin = useLiveData(workspacePermissionService.isAdmin$);
|
||||
|
||||
const t = useI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isOnline = useSystemOnline();
|
||||
const desktopApi = useService(DesktopApiService);
|
||||
const isLocalWorkspace = workspace.flavour === 'local';
|
||||
|
||||
const docSyncState = useLiveData(
|
||||
useMemo(() => {
|
||||
return workspace
|
||||
? LiveData.from(workspace.engine.doc.state$, null).throttleTime(500)
|
||||
: null;
|
||||
}, [workspace])
|
||||
);
|
||||
|
||||
const blobSyncState = useLiveData(
|
||||
useMemo(() => {
|
||||
return workspace
|
||||
? LiveData.from(workspace.engine.blob.state$, null).throttleTime(500)
|
||||
: null;
|
||||
}, [workspace])
|
||||
);
|
||||
|
||||
const docSynced = !docSyncState?.syncing;
|
||||
const blobSynced =
|
||||
!blobSyncState || blobSyncState.synced === blobSyncState.total;
|
||||
const [fullSynced, setFullSynced] = useState(false);
|
||||
|
||||
const shouldWaitForFullSync =
|
||||
isLocalWorkspace || !isOnline || (fullSynced && docSynced && blobSynced);
|
||||
const fullSyncing = fullSynced && (!docSynced || !blobSynced);
|
||||
|
||||
const fullSync = useAsyncCallback(async () => {
|
||||
// NOTE: doc full sync is always started by default
|
||||
// await workspace.engine.doc.waitForSynced();
|
||||
workspace.engine.blob.fullDownload().catch(() => {
|
||||
/* noop */
|
||||
});
|
||||
setFullSynced(true);
|
||||
}, [workspace.engine.blob]);
|
||||
|
||||
const onExport = useAsyncCallback(async () => {
|
||||
if (saving || !workspace) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -38,10 +65,6 @@ export const DesktopExportPanel = ({ workspace }: ExportPanelProps) => {
|
||||
track.$.settingsPanel.workspace.export({
|
||||
type: 'workspace',
|
||||
});
|
||||
if (isOnline) {
|
||||
await workspace.engine.doc.waitForSynced();
|
||||
await workspace.engine.blob.fullSync();
|
||||
}
|
||||
|
||||
const result = await desktopApi.handler?.dialog.saveDBFileAs(
|
||||
universalId({
|
||||
@@ -61,20 +84,37 @@ export const DesktopExportPanel = ({ workspace }: ExportPanelProps) => {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [desktopApi, isOnline, saving, t, workspace]);
|
||||
}, [desktopApi, saving, t, workspace]);
|
||||
|
||||
if (isTeam && !isOwner && !isAdmin) {
|
||||
return null;
|
||||
if (!shouldWaitForFullSync) {
|
||||
return (
|
||||
<SettingRow name={t['Export']()} desc={t['Full Sync Description']()}>
|
||||
<Button
|
||||
data-testid="export-affine-full-sync"
|
||||
onClick={fullSync}
|
||||
loading={fullSyncing}
|
||||
>
|
||||
{t['Full Sync']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
const button =
|
||||
isLocalWorkspace || isOnline ? t['Export']() : t['Export(Offline)']();
|
||||
const desc =
|
||||
isLocalWorkspace || isOnline
|
||||
? t['Export Description']()
|
||||
: t['Export Description(Offline)']();
|
||||
|
||||
return (
|
||||
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
||||
<SettingRow name={t['Export']()} desc={desc}>
|
||||
<Button
|
||||
data-testid="export-affine-backup"
|
||||
onClick={onExport}
|
||||
disabled={saving}
|
||||
>
|
||||
{t['Export']()}
|
||||
{button}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,9 @@ export const WorkspaceSettingStorage = ({
|
||||
WorkspacePermissionService
|
||||
).permission;
|
||||
const isTeam = useLiveData(workspacePermissionService.isTeam$);
|
||||
const isOwner = useLiveData(workspacePermissionService.isOwner$);
|
||||
|
||||
const canExport = !isTeam || isOwner;
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
@@ -46,7 +49,7 @@ export const WorkspaceSettingStorage = ({
|
||||
</SettingWrapper>
|
||||
) : null}
|
||||
|
||||
{BUILD_CONFIG.isElectron && (
|
||||
{BUILD_CONFIG.isElectron && canExport && (
|
||||
<SettingWrapper>
|
||||
<DesktopExportPanel workspace={workspace} />
|
||||
</SettingWrapper>
|
||||
|
||||
@@ -121,45 +121,26 @@ export class UnusedBlobs extends Entity {
|
||||
}
|
||||
|
||||
private async getUsedBlobs(): Promise<string[]> {
|
||||
const batchSize = 100;
|
||||
let offset = 0;
|
||||
const unusedBlobKeys: string[] = [];
|
||||
|
||||
while (true) {
|
||||
const result = await this.docsSearchService.indexer.blockIndex.aggregate(
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
type: 'exists',
|
||||
field: 'blob',
|
||||
},
|
||||
],
|
||||
},
|
||||
'blob',
|
||||
{
|
||||
pagination: {
|
||||
limit: batchSize,
|
||||
skip: offset,
|
||||
const result = await this.docsSearchService.indexer.blockIndex.aggregate(
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
type: 'exists',
|
||||
field: 'blob',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.buckets.length) {
|
||||
break;
|
||||
],
|
||||
},
|
||||
'blob',
|
||||
{
|
||||
pagination: {
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
unusedBlobKeys.push(...result.buckets.map(bucket => bucket.key));
|
||||
offset += batchSize;
|
||||
|
||||
// If we got less results than the batch size, we've reached the end
|
||||
if (result.buckets.length < batchSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return unusedBlobKeys;
|
||||
return result.buckets.map(bucket => bucket.key);
|
||||
}
|
||||
|
||||
async hydrateBlob(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
toast,
|
||||
@@ -22,10 +23,11 @@ import {
|
||||
InformationIcon,
|
||||
LinkedPageIcon,
|
||||
OpenInNewIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { NodeOperation } from '../../tree/types';
|
||||
|
||||
@@ -52,6 +54,7 @@ export const useExplorerDocNodeOperations = (
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const [addLinkedPageLoading, setAddLinkedPageLoading] = useState(false);
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
@@ -117,17 +120,22 @@ export const useExplorerDocNodeOperations = (
|
||||
}, [docId, workbenchService.workbench]);
|
||||
|
||||
const handleAddLinkedPage = useAsyncCallback(async () => {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
setAddLinkedPageLoading(true);
|
||||
try {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
}
|
||||
const newDoc = createPage();
|
||||
// TODO: handle timeout & error
|
||||
await docsService.addLinkedDoc(docId, newDoc.id);
|
||||
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
|
||||
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
|
||||
options.openNodeCollapsed();
|
||||
} finally {
|
||||
setAddLinkedPageLoading(false);
|
||||
}
|
||||
const newDoc = createPage();
|
||||
// TODO: handle timeout & error
|
||||
await docsService.addLinkedDoc(docId, newDoc.id);
|
||||
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
|
||||
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
|
||||
options.openNodeCollapsed();
|
||||
}, [createPage, guardService, docId, docsService, options, t]);
|
||||
|
||||
const handleToggleFavoriteDoc = useCallback(() => {
|
||||
@@ -139,6 +147,20 @@ export const useExplorerDocNodeOperations = (
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
icon={<PlusIcon />}
|
||||
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||
onClick={handleAddLinkedPage}
|
||||
loading={addLinkedPageLoading}
|
||||
disabled={addLinkedPageLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 50,
|
||||
view: (
|
||||
@@ -233,6 +255,7 @@ export const useExplorerDocNodeOperations = (
|
||||
},
|
||||
],
|
||||
[
|
||||
addLinkedPageLoading,
|
||||
docId,
|
||||
favorite,
|
||||
handleAddLinkedPage,
|
||||
|
||||
@@ -107,9 +107,10 @@ export const InviteMemberEditor = ({
|
||||
selectedMemberIds,
|
||||
inviteDocRoleType
|
||||
);
|
||||
onClickCancel();
|
||||
|
||||
notify.success({
|
||||
title: 'Invite successful',
|
||||
title: t['Invitation sent'](),
|
||||
});
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
@@ -117,7 +118,13 @@ export const InviteMemberEditor = ({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, inviteDocRoleType, selectedMembers, t]);
|
||||
}, [
|
||||
docGrantedUsersService,
|
||||
inviteDocRoleType,
|
||||
onClickCancel,
|
||||
selectedMembers,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleCompositionStart: CompositionEventHandler<HTMLInputElement> =
|
||||
useCallback(() => {
|
||||
|
||||
@@ -195,10 +195,26 @@ export function useAFFiNEI18N(): {
|
||||
* `Export`
|
||||
*/
|
||||
Export(): string;
|
||||
/**
|
||||
* `Export (Offline)`
|
||||
*/
|
||||
["Export(Offline)"](): string;
|
||||
/**
|
||||
* `Full Sync`
|
||||
*/
|
||||
["Full Sync"](): string;
|
||||
/**
|
||||
* `You can export the entire Workspace data for backup, and the exported data can be re-imported.`
|
||||
*/
|
||||
["Export Description"](): string;
|
||||
/**
|
||||
* `You can export the entire Workspace data for backup, and the exported data can be re-imported, but you are offline now which will cause the exported data not up to date.`
|
||||
*/
|
||||
["Export Description(Offline)"](): string;
|
||||
/**
|
||||
* `You can export the entire Workspace data for backup, and the exported data can be re-imported, but you must sync all cloud data first to keep your exported data up to date.`
|
||||
*/
|
||||
["Full Sync Description"](): string;
|
||||
/**
|
||||
* `Export failed`
|
||||
*/
|
||||
@@ -2675,6 +2691,10 @@ export function useAFFiNEI18N(): {
|
||||
* `Workspace name`
|
||||
*/
|
||||
["com.affine.nameWorkspace.subtitle.workspace-name"](): string;
|
||||
/**
|
||||
* `Workspace type`
|
||||
*/
|
||||
["com.affine.nameWorkspace.subtitle.workspace-type"](): string;
|
||||
/**
|
||||
* `Name your workspace`
|
||||
*/
|
||||
@@ -3513,11 +3533,11 @@ export function useAFFiNEI18N(): {
|
||||
*/
|
||||
["com.affine.payment.cloud.free.benefit.g2-5"](): string;
|
||||
/**
|
||||
* `Open-source under MIT license.`
|
||||
* `Local Editor under MIT license.`
|
||||
*/
|
||||
["com.affine.payment.cloud.free.description"](): string;
|
||||
/**
|
||||
* `FOSS + Basic`
|
||||
* `Local FOSS + Cloud Basic`
|
||||
*/
|
||||
["com.affine.payment.cloud.free.name"](): string;
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,11 @@
|
||||
"Enable AFFiNE Cloud Description": "If enabled, the data in this workspace will be backed up and synchronised via AFFiNE Cloud.",
|
||||
"Enable cloud hint": "The following functions rely on AFFiNE Cloud. All data is stored on the current device. You can enable AFFiNE Cloud for this workspace to keep data in sync with the cloud.",
|
||||
"Export": "Export",
|
||||
"Export(Offline)": "Export (Offline)",
|
||||
"Full Sync": "Full Sync",
|
||||
"Export Description": "You can export the entire Workspace data for backup, and the exported data can be re-imported.",
|
||||
"Export Description(Offline)": "You can export the entire Workspace data for backup, and the exported data can be re-imported. But you are offline now which will cause the exported data not up to date.",
|
||||
"Full Sync Description": "You can export the entire Workspace data for backup, and the exported data can be re-imported. But you must sync all cloud data first to keep your exported data up to date.",
|
||||
"Export failed": "Export failed",
|
||||
"Export success": "Export success",
|
||||
"Export to HTML": "Export to HTML",
|
||||
|
||||
Reference in New Issue
Block a user