mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 09:52:49 +08:00
feat(core): support better battery save mode (#13383)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a Document Summary module, enabling live and cached document summaries with cloud revalidation. * Added a feature flag for enabling battery save mode. * Added explicit pause and resume controls for sync operations, accessible via UI events and programmatically. * **Improvements** * Enhanced sync and indexing logic to support pausing, resuming, and battery save mode, with improved job prioritization. * Updated navigation and preview components to use the new document summary service and improved priority handling. * Improved logging and state reporting for sync and indexing processes. * Refined backlink handling with reactive loading states and cloud revalidation. * Replaced backlink and link management to use a new dedicated document links service. * Enhanced workspace engine to conditionally enable battery save mode based on feature flags and workspace flavor. * **Bug Fixes** * Removed unnecessary debug console logs from various components for cleaner output. * **Refactor** * Replaced battery save mode methods with explicit pause/resume methods throughout the app and services. * Modularized and streamlined document summary and sync-related code for better maintainability. * Restructured backlink components to improve visibility handling and data fetching. * Simplified and improved document backlink data fetching with retry and loading state management. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
packages/common/graphql/src/graphql/get-doc-summary.gql
Normal file
7
packages/common/graphql/src/graphql/get-doc-summary.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
query getDocSummary($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
summary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1454,6 +1454,18 @@ export const getDocDefaultRoleQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getDocSummaryQuery = {
|
||||
id: 'getDocSummaryQuery' as const,
|
||||
op: 'getDocSummary',
|
||||
query: `query getDocSummary($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
summary
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getInviteInfoQuery = {
|
||||
id: 'getInviteInfoQuery' as const,
|
||||
op: 'getInviteInfo',
|
||||
|
||||
@@ -5077,6 +5077,19 @@ export type GetDocDefaultRoleQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GetDocSummaryQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetDocSummaryQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
doc: { __typename?: 'DocType'; summary: string | null };
|
||||
};
|
||||
};
|
||||
|
||||
export type GetInviteInfoQueryVariables = Exact<{
|
||||
inviteId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -6432,6 +6445,11 @@ export type Queries =
|
||||
variables: GetDocDefaultRoleQueryVariables;
|
||||
response: GetDocDefaultRoleQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getDocSummaryQuery';
|
||||
variables: GetDocSummaryQueryVariables;
|
||||
response: GetDocSummaryQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getInviteInfoQuery';
|
||||
variables: GetInviteInfoQueryVariables;
|
||||
|
||||
@@ -249,7 +249,7 @@ export class DocFrontend {
|
||||
while (true) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const docId = await this.status.jobDocQueue.asyncPop(signal);
|
||||
const docId = await this.status.jobDocQueue.asyncPop(undefined, signal);
|
||||
const jobs = this.status.jobMap.get(docId);
|
||||
this.status.jobMap.delete(docId);
|
||||
|
||||
|
||||
@@ -712,7 +712,7 @@ export class DocSyncPeer {
|
||||
while (true) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const docId = await this.status.jobDocQueue.asyncPop(signal);
|
||||
const docId = await this.status.jobDocQueue.asyncPop(undefined, signal);
|
||||
|
||||
while (true) {
|
||||
// batch process jobs for the same doc
|
||||
|
||||
@@ -36,6 +36,8 @@ import { crawlingDocData } from './crawler';
|
||||
export type IndexerPreferOptions = 'local' | 'remote';
|
||||
|
||||
export interface IndexerSyncState {
|
||||
paused: boolean;
|
||||
batterySaveMode: boolean;
|
||||
/**
|
||||
* Number of documents currently in the indexing queue
|
||||
*/
|
||||
@@ -167,6 +169,14 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
this.status.disableBatterySaveMode();
|
||||
}
|
||||
|
||||
pauseSync() {
|
||||
this.status.pauseSync();
|
||||
}
|
||||
|
||||
resumeSync() {
|
||||
this.status.resumeSync();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.abort) {
|
||||
this.abort.abort(MANUALLY_STOP);
|
||||
@@ -324,6 +334,7 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
const docId = await this.status.acceptJob(signal);
|
||||
|
||||
if (docId === this.rootDocId) {
|
||||
console.log('[indexer] start indexing root doc', docId);
|
||||
// #region crawl root doc
|
||||
for (const [docId, { title }] of this.status.docsInRootDoc) {
|
||||
const existingDoc = this.status.docsInIndexer.get(docId);
|
||||
@@ -401,6 +412,7 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
// doc is deleted, just skip
|
||||
continue;
|
||||
}
|
||||
console.log('[indexer] start indexing doc', docId);
|
||||
const docYDoc = new YDoc({ guid: docId });
|
||||
applyUpdate(docYDoc, docBin.bin);
|
||||
|
||||
@@ -454,6 +466,8 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
// #endregion
|
||||
}
|
||||
|
||||
console.log('[indexer] complete job', docId);
|
||||
|
||||
this.status.completeJob();
|
||||
}
|
||||
} finally {
|
||||
@@ -619,10 +633,11 @@ class IndexerSyncStatus {
|
||||
currentJob: string | null = null;
|
||||
errorMessage: string | null = null;
|
||||
statusUpdatedSubject$ = new Subject<string | true>();
|
||||
batterySaveMode: {
|
||||
paused: {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
} | null = null;
|
||||
batterySaveMode: boolean = false;
|
||||
|
||||
state$ = new Observable<IndexerSyncState>(subscribe => {
|
||||
const next = () => {
|
||||
@@ -632,6 +647,8 @@ class IndexerSyncStatus {
|
||||
total: 0,
|
||||
errorMessage: this.errorMessage,
|
||||
completed: true,
|
||||
batterySaveMode: this.batterySaveMode,
|
||||
paused: this.paused !== null,
|
||||
});
|
||||
} else {
|
||||
subscribe.next({
|
||||
@@ -639,6 +656,8 @@ class IndexerSyncStatus {
|
||||
total: this.docsInRootDoc.size + 1,
|
||||
errorMessage: this.errorMessage,
|
||||
completed: this.rootDocReady && this.jobs.length() === 0,
|
||||
batterySaveMode: this.batterySaveMode,
|
||||
paused: this.paused !== null,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -697,10 +716,14 @@ class IndexerSyncStatus {
|
||||
}
|
||||
|
||||
async acceptJob(abort?: AbortSignal) {
|
||||
if (this.batterySaveMode) {
|
||||
await this.batterySaveMode.promise;
|
||||
if (this.paused) {
|
||||
await this.paused.promise;
|
||||
}
|
||||
const job = await this.jobs.asyncPop(abort);
|
||||
const job = await this.jobs.asyncPop(
|
||||
// if battery save mode is enabled, only accept jobs with priority > 1; otherwise accept all jobs
|
||||
this.batterySaveMode ? 1 : undefined,
|
||||
abort
|
||||
);
|
||||
this.currentJob = job;
|
||||
this.statusUpdatedSubject$.next(job);
|
||||
return job;
|
||||
@@ -728,12 +751,33 @@ class IndexerSyncStatus {
|
||||
if (this.batterySaveMode) {
|
||||
return;
|
||||
}
|
||||
this.batterySaveMode = Promise.withResolvers();
|
||||
this.batterySaveMode = true;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
disableBatterySaveMode() {
|
||||
this.batterySaveMode?.resolve();
|
||||
this.batterySaveMode = null;
|
||||
if (!this.batterySaveMode) {
|
||||
return;
|
||||
}
|
||||
this.batterySaveMode = false;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
pauseSync() {
|
||||
if (this.paused) {
|
||||
return;
|
||||
}
|
||||
this.paused = Promise.withResolvers();
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
resumeSync() {
|
||||
if (!this.paused) {
|
||||
return;
|
||||
}
|
||||
this.paused.resolve();
|
||||
this.paused = null;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -745,6 +789,8 @@ class IndexerSyncStatus {
|
||||
this.rootDoc = new YDoc();
|
||||
this.rootDocReady = false;
|
||||
this.currentJob = null;
|
||||
this.batterySaveMode = false;
|
||||
this.paused = null;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ export class AsyncPriorityQueue extends PriorityQueue {
|
||||
private _resolveUpdate: (() => void) | null = null;
|
||||
private _waitForUpdate: Promise<void> | null = null;
|
||||
|
||||
async asyncPop(abort?: AbortSignal): Promise<string> {
|
||||
const update = this.pop();
|
||||
async asyncPop(
|
||||
minimumPriority?: number,
|
||||
abort?: AbortSignal
|
||||
): Promise<string> {
|
||||
const update = this.pop(minimumPriority);
|
||||
if (update) {
|
||||
return update;
|
||||
} else {
|
||||
@@ -27,7 +30,7 @@ export class AsyncPriorityQueue extends PriorityQueue {
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.asyncPop(abort);
|
||||
return this.asyncPop(minimumPriority, abort);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,20 @@ export class PriorityQueue {
|
||||
this.priorityMap.set(id, priority);
|
||||
}
|
||||
|
||||
pop() {
|
||||
pop(minimumPriority?: number) {
|
||||
const node = this.tree.max();
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
minimumPriority !== undefined &&
|
||||
node.getValue().priority < minimumPriority
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.tree.removeNode(node);
|
||||
|
||||
const { id } = node.getValue();
|
||||
|
||||
@@ -87,17 +87,18 @@ export class StoreManagerClient {
|
||||
});
|
||||
}
|
||||
|
||||
enableBatterySaveMode() {
|
||||
pause() {
|
||||
this.connections.forEach(connection => {
|
||||
connection.store.enableBatterySaveMode().catch(err => {
|
||||
console.error('error enabling battery save mode', err);
|
||||
connection.store.pauseSync().catch(err => {
|
||||
console.error('error pausing', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
disableBatterySaveMode() {
|
||||
|
||||
resume() {
|
||||
this.connections.forEach(connection => {
|
||||
connection.store.disableBatterySaveMode().catch(err => {
|
||||
console.error('error disabling battery save mode', err);
|
||||
connection.store.resumeSync().catch(err => {
|
||||
console.error('error resuming', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -135,6 +136,13 @@ export class StoreClient {
|
||||
disableBatterySaveMode(): Promise<void> {
|
||||
return this.client.call('sync.disableBatterySaveMode');
|
||||
}
|
||||
|
||||
pauseSync() {
|
||||
return this.client.call('sync.pauseSync');
|
||||
}
|
||||
resumeSync() {
|
||||
return this.client.call('sync.resumeSync');
|
||||
}
|
||||
}
|
||||
|
||||
class WorkerDocStorage implements DocStorage {
|
||||
|
||||
@@ -135,34 +135,44 @@ class StoreConsumer {
|
||||
}
|
||||
|
||||
private readonly ENABLE_BATTERY_SAVE_MODE_DELAY = 1000;
|
||||
private enableBatterySaveModeTimeout: NodeJS.Timeout | null = null;
|
||||
private enabledBatterySaveMode = false;
|
||||
private syncPauseTimeout: NodeJS.Timeout | null = null;
|
||||
private syncPaused = false;
|
||||
|
||||
enableBatterySaveMode() {
|
||||
if (this.enableBatterySaveModeTimeout || this.enabledBatterySaveMode) {
|
||||
private pauseSync() {
|
||||
if (this.syncPauseTimeout || this.syncPaused) {
|
||||
return;
|
||||
}
|
||||
this.enableBatterySaveModeTimeout = setTimeout(() => {
|
||||
if (!this.enabledBatterySaveMode) {
|
||||
this.indexerSync.enableBatterySaveMode();
|
||||
this.enabledBatterySaveMode = true;
|
||||
console.log('[BatterySaveMode] enabled');
|
||||
this.syncPauseTimeout = setTimeout(() => {
|
||||
if (!this.syncPaused) {
|
||||
this.indexerSync.pauseSync();
|
||||
this.syncPaused = true;
|
||||
console.log('[IndexerSync] paused');
|
||||
}
|
||||
}, this.ENABLE_BATTERY_SAVE_MODE_DELAY);
|
||||
}
|
||||
|
||||
disableBatterySaveMode() {
|
||||
if (this.enableBatterySaveModeTimeout) {
|
||||
clearTimeout(this.enableBatterySaveModeTimeout);
|
||||
this.enableBatterySaveModeTimeout = null;
|
||||
private resumeSync() {
|
||||
if (this.syncPauseTimeout) {
|
||||
clearTimeout(this.syncPauseTimeout);
|
||||
this.syncPauseTimeout = null;
|
||||
}
|
||||
if (this.enabledBatterySaveMode) {
|
||||
this.indexerSync.disableBatterySaveMode();
|
||||
this.enabledBatterySaveMode = false;
|
||||
console.log('[BatterySaveMode] disabled');
|
||||
if (this.syncPaused) {
|
||||
this.indexerSync.resumeSync();
|
||||
this.syncPaused = false;
|
||||
console.log('[IndexerSync] resumed');
|
||||
}
|
||||
}
|
||||
|
||||
private enableBatterySaveMode() {
|
||||
console.log('[IndexerSync] enable battery save mode');
|
||||
this.indexerSync.enableBatterySaveMode();
|
||||
}
|
||||
|
||||
private disableBatterySaveMode() {
|
||||
console.log('[IndexerSync] disable battery save mode');
|
||||
this.indexerSync.disableBatterySaveMode();
|
||||
}
|
||||
|
||||
private registerHandlers(consumer: OpConsumer<WorkerOps>) {
|
||||
const collectJobs = new Map<
|
||||
string,
|
||||
@@ -316,6 +326,8 @@ class StoreConsumer {
|
||||
this.indexerSync.aggregate$(table, query, field, options),
|
||||
'sync.enableBatterySaveMode': () => this.enableBatterySaveMode(),
|
||||
'sync.disableBatterySaveMode': () => this.disableBatterySaveMode(),
|
||||
'sync.pauseSync': () => this.pauseSync(),
|
||||
'sync.resumeSync': () => this.resumeSync(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ interface GroupedWorkerOps {
|
||||
sync: {
|
||||
enableBatterySaveMode: [void, void];
|
||||
disableBatterySaveMode: [void, void];
|
||||
pauseSync: [void, void];
|
||||
resumeSync: [void, void];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,13 +47,13 @@ export function setupStoreManager(framework: Framework) {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
window.addEventListener('focus', () => {
|
||||
storeManagerClient.disableBatterySaveMode();
|
||||
storeManagerClient.resume();
|
||||
});
|
||||
window.addEventListener('click', () => {
|
||||
storeManagerClient.disableBatterySaveMode();
|
||||
storeManagerClient.resume();
|
||||
});
|
||||
window.addEventListener('blur', () => {
|
||||
storeManagerClient.enableBatterySaveMode();
|
||||
storeManagerClient.pause();
|
||||
});
|
||||
|
||||
framework.impl(NbstoreProvider, {
|
||||
|
||||
@@ -42,13 +42,13 @@ window.addEventListener('beforeunload', () => {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
window.addEventListener('focus', () => {
|
||||
storeManagerClient.disableBatterySaveMode();
|
||||
storeManagerClient.resume();
|
||||
});
|
||||
window.addEventListener('click', () => {
|
||||
storeManagerClient.disableBatterySaveMode();
|
||||
storeManagerClient.resume();
|
||||
});
|
||||
window.addEventListener('blur', () => {
|
||||
storeManagerClient.enableBatterySaveMode();
|
||||
storeManagerClient.pause();
|
||||
});
|
||||
|
||||
const future = {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Button, Divider, useLitPortalFactory } from '@affine/component';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
observeIntersection,
|
||||
Skeleton,
|
||||
useLitPortalFactory,
|
||||
} from '@affine/component';
|
||||
import { getViewManager } from '@affine/core/blocksuite/manager/view';
|
||||
import {
|
||||
patchReferenceRenderer,
|
||||
@@ -30,11 +36,14 @@ import {
|
||||
useLiveData,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { debounce } from 'lodash-es';
|
||||
import {
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -177,14 +186,22 @@ const usePreviewExtensions = () => {
|
||||
return [extensions, portals] as const;
|
||||
};
|
||||
|
||||
const useBacklinkGroups: () => BacklinkGroups[] = () => {
|
||||
export const BacklinkGroups = () => {
|
||||
const [extensions, portals] = usePreviewExtensions();
|
||||
const { workspaceService, docService } = useServices({
|
||||
WorkspaceService,
|
||||
DocService,
|
||||
});
|
||||
|
||||
const { docLinksService } = useServices({
|
||||
DocLinksService,
|
||||
});
|
||||
|
||||
const backlinkGroups = useLiveData(
|
||||
LiveData.computed(get => {
|
||||
const links = get(docLinksService.backlinks.backlinks$);
|
||||
docLinksService.backlinks.backlinks$.map(links => {
|
||||
if (links === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// group by docId
|
||||
const groupedLinks = links.reduce(
|
||||
@@ -203,17 +220,10 @@ const useBacklinkGroups: () => BacklinkGroups[] = () => {
|
||||
})
|
||||
);
|
||||
|
||||
return backlinkGroups;
|
||||
};
|
||||
useEffect(() => {
|
||||
docLinksService.backlinks.revalidateFromCloud();
|
||||
}, [docLinksService]);
|
||||
|
||||
export const BacklinkGroups = () => {
|
||||
const [extensions, portals] = usePreviewExtensions();
|
||||
const { workspaceService, docService } = useServices({
|
||||
WorkspaceService,
|
||||
DocService,
|
||||
});
|
||||
|
||||
const backlinkGroups = useBacklinkGroups();
|
||||
const textRendererOptions = useMemo(() => {
|
||||
const docLinkBaseURLMiddleware: TransformerMiddleware = ({
|
||||
adapterConfigs,
|
||||
@@ -233,36 +243,87 @@ export const BacklinkGroups = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{backlinkGroups.map(linkGroup => (
|
||||
<CollapsibleSection
|
||||
key={linkGroup.docId}
|
||||
title={
|
||||
<AffinePageReference
|
||||
pageId={linkGroup.docId}
|
||||
onClick={() => {
|
||||
track.doc.biDirectionalLinksPanel.backlinkTitle.navigate();
|
||||
}}
|
||||
{backlinkGroups === undefined ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
backlinkGroups.map(linkGroup => (
|
||||
<CollapsibleSection
|
||||
key={linkGroup.docId}
|
||||
title={
|
||||
<AffinePageReference
|
||||
pageId={linkGroup.docId}
|
||||
onClick={() => {
|
||||
track.doc.biDirectionalLinksPanel.backlinkTitle.navigate();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
length={linkGroup.links.length}
|
||||
docId={docService.doc.id}
|
||||
linkDocId={linkGroup.docId}
|
||||
>
|
||||
<LinkPreview
|
||||
textRendererOptions={textRendererOptions}
|
||||
linkGroup={linkGroup}
|
||||
/>
|
||||
}
|
||||
length={linkGroup.links.length}
|
||||
docId={docService.doc.id}
|
||||
linkDocId={linkGroup.docId}
|
||||
>
|
||||
<LinkPreview
|
||||
textRendererOptions={textRendererOptions}
|
||||
linkGroup={linkGroup}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</CollapsibleSection>
|
||||
))
|
||||
)}
|
||||
{portals.map(p => (
|
||||
<Fragment key={p.id}>{p.portal}</Fragment>
|
||||
))}
|
||||
<>
|
||||
{portals.map(p => (
|
||||
<Fragment key={p.id}>{p.portal}</Fragment>
|
||||
))}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BacklinkLinks = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const [visibility, setVisibility] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
return observeIntersection(
|
||||
container,
|
||||
debounce(
|
||||
entry => {
|
||||
setVisibility(entry.isIntersecting);
|
||||
},
|
||||
500,
|
||||
{
|
||||
trailing: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { docLinksService } = useServices({
|
||||
DocLinksService,
|
||||
});
|
||||
|
||||
const backlinks = useLiveData(docLinksService.backlinks.backlinks$);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibility) {
|
||||
docLinksService.backlinks.revalidateFromCloud();
|
||||
}
|
||||
}, [docLinksService, visibility]);
|
||||
|
||||
const backlinkCount = backlinks?.length;
|
||||
|
||||
return (
|
||||
<div className={styles.linksContainer} ref={containerRef}>
|
||||
<div className={styles.linksTitles}>
|
||||
{t['com.affine.page-properties.backlinks']()}{' '}
|
||||
{backlinkCount !== undefined ? `· ${backlinkCount}` : ''}
|
||||
</div>
|
||||
<BacklinkGroups />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkPreview = ({
|
||||
linkGroup,
|
||||
textRendererOptions,
|
||||
@@ -361,18 +422,13 @@ export const BiDirectionalLinkPanel = () => {
|
||||
show ? docLinksService.links.links$ : new LiveData([] as Link[])
|
||||
);
|
||||
|
||||
const backlinkGroups = useBacklinkGroups();
|
||||
|
||||
const backlinkCount = useMemo(() => {
|
||||
return backlinkGroups.reduce((acc, link) => acc + link.links.length, 0);
|
||||
}, [backlinkGroups]);
|
||||
|
||||
const handleClickShow = useCallback(() => {
|
||||
setShow(!show);
|
||||
track.doc.biDirectionalLinksPanel.$.toggle({
|
||||
type: show ? 'collapse' : 'expand',
|
||||
});
|
||||
}, [show, setShow]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{!show && <Divider size="thinner" />}
|
||||
@@ -390,12 +446,7 @@ export const BiDirectionalLinkPanel = () => {
|
||||
<>
|
||||
<Divider size="thinner" />
|
||||
|
||||
<div className={styles.linksContainer}>
|
||||
<div className={styles.linksTitles}>
|
||||
{t['com.affine.page-properties.backlinks']()} · {backlinkCount}
|
||||
</div>
|
||||
<BacklinkGroups />
|
||||
</div>
|
||||
<BacklinkLinks />
|
||||
<div className={styles.linksContainer}>
|
||||
<div className={styles.linksTitles}>
|
||||
{t['com.affine.page-properties.outgoing-links']()} ·{' '}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { DocSummaryService } from '@affine/core/modules/doc-summary';
|
||||
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { type ReactNode, useEffect, useMemo } from 'react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
|
||||
interface PagePreviewProps {
|
||||
pageId: string;
|
||||
@@ -14,8 +13,7 @@ const PagePreviewInner = ({
|
||||
emptyFallback,
|
||||
fallback,
|
||||
}: PagePreviewProps) => {
|
||||
const docSummary = useService(DocsSearchService);
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const docSummary = useService(DocSummaryService);
|
||||
const summary = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docSummary.watchDocSummary(pageId), null),
|
||||
@@ -23,16 +21,6 @@ const PagePreviewInner = ({
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const undo = docSummary.indexer.addPriority(pageId, 100);
|
||||
return undo;
|
||||
}, [docSummary, pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
const undo = workspaceService.workspace.engine.doc.addPriority(pageId, 10);
|
||||
return undo;
|
||||
}, [workspaceService, pageId]);
|
||||
|
||||
const res =
|
||||
summary === null ? fallback : summary === '' ? emptyFallback : summary;
|
||||
return res;
|
||||
|
||||
@@ -312,8 +312,6 @@ export const WorkspaceCard = forwardRef<
|
||||
onClickOpenSettings?.(workspaceMetadata);
|
||||
}, [onClickOpenSettings, workspaceMetadata]);
|
||||
|
||||
console.log(information);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
@@ -56,12 +57,14 @@ export const NavigationPanelDocNode = ({
|
||||
const t = useI18n();
|
||||
const {
|
||||
docsSearchService,
|
||||
workspaceService,
|
||||
docsService,
|
||||
globalContextService,
|
||||
docDisplayMetaService,
|
||||
featureFlagService,
|
||||
guardService,
|
||||
} = useServices({
|
||||
WorkspaceService,
|
||||
DocsSearchService,
|
||||
DocsService,
|
||||
GlobalContextService,
|
||||
@@ -120,9 +123,17 @@ export const NavigationPanelDocNode = ({
|
||||
|
||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
||||
useLayoutEffect(() => {
|
||||
if (collapsed) {
|
||||
return;
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
const undoSync = workspaceService.workspace.engine.doc.addPriority(
|
||||
docId,
|
||||
10
|
||||
);
|
||||
const undoIndexer = docsSearchService.indexer.addPriority(docId, 10);
|
||||
docsSearchService.indexer
|
||||
.waitForDocCompletedWithPriority(docId, 100, abortController.signal)
|
||||
.waitForDocCompleted(docId, abortController.signal)
|
||||
.then(() => {
|
||||
setReferencesLoading(false);
|
||||
})
|
||||
@@ -132,9 +143,11 @@ export const NavigationPanelDocNode = ({
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
undoSync();
|
||||
undoIndexer();
|
||||
abortController.abort(MANUALLY_STOP);
|
||||
};
|
||||
}, [docId, docsSearchService]);
|
||||
}, [docId, docsSearchService, workspaceService, collapsed]);
|
||||
|
||||
const dndData = useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -14,14 +14,14 @@ import type {
|
||||
DatabaseRow,
|
||||
DatabaseValueCell,
|
||||
} from '@affine/core/modules/doc-info/types';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { DocLinksService } from '@affine/core/modules/doc-link';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { LiveData, useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import * as styles from './info-modal.css';
|
||||
import { LinksRow } from './links-row';
|
||||
@@ -34,11 +34,11 @@ export const InfoTable = ({
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { docsSearchService, workspacePropertyService, guardService } =
|
||||
const { workspacePropertyService, guardService, docLinksService } =
|
||||
useServices({
|
||||
DocsSearchService,
|
||||
WorkspacePropertyService,
|
||||
GuardService,
|
||||
DocLinksService,
|
||||
});
|
||||
const canEditPropertyInfo = useLiveData(
|
||||
guardService.can$('Workspace_Properties_Update')
|
||||
@@ -46,30 +46,9 @@ export const InfoTable = ({
|
||||
const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId));
|
||||
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||
const properties = useLiveData(workspacePropertyService.sortedProperties$);
|
||||
const links = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||
[docId, docsSearchService]
|
||||
)
|
||||
);
|
||||
const links = useLiveData(docLinksService.links.links$);
|
||||
|
||||
const backlinks = useLiveData(
|
||||
useMemo(() => {
|
||||
return LiveData.from(docsSearchService.watchRefsTo(docId), []).map(
|
||||
links => {
|
||||
const visitedDoc = new Set<string>();
|
||||
// for each doc, we only show the first block
|
||||
return links.filter(link => {
|
||||
if (visitedDoc.has(link.docId)) {
|
||||
return false;
|
||||
}
|
||||
visitedDoc.add(link.docId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
);
|
||||
}, [docId, docsSearchService])
|
||||
);
|
||||
const backlinks = useLiveData(docLinksService.backlinks.backlinks$);
|
||||
|
||||
const onBacklinkPropertyChange = useCallback(
|
||||
(_row: DatabaseRow, cell: DatabaseValueCell, _value: unknown) => {
|
||||
@@ -111,6 +90,10 @@ export const InfoTable = ({
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
docLinksService.backlinks.revalidateFromCloud();
|
||||
}, [docLinksService.backlinks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PropertyCollapsibleSection
|
||||
|
||||
@@ -16,12 +16,12 @@ import { LinksRow } from '@affine/core/desktop/dialogs/doc-info/links-row';
|
||||
import { TimeRow } from '@affine/core/desktop/dialogs/doc-info/time-row';
|
||||
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
|
||||
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { DocLinksService } from '@affine/core/modules/doc-link';
|
||||
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { LiveData, useLiveData, useServices } from '@toeverything/infra';
|
||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import * as styles from './doc-info.css';
|
||||
|
||||
@@ -31,37 +31,16 @@ export const DocInfoSheet = ({
|
||||
docId: string;
|
||||
defaultOpenProperty?: DefaultOpenProperty;
|
||||
}) => {
|
||||
const { docsSearchService, workspacePropertyService } = useServices({
|
||||
DocsSearchService,
|
||||
const { workspacePropertyService, docLinksService } = useServices({
|
||||
WorkspacePropertyService,
|
||||
DocLinksService,
|
||||
});
|
||||
const t = useI18n();
|
||||
|
||||
const canEditPropertyInfo = useGuard('Workspace_Properties_Update');
|
||||
const canEditProperty = useGuard('Doc_Update', docId);
|
||||
const links = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||
[docId, docsSearchService]
|
||||
)
|
||||
);
|
||||
const backlinks = useLiveData(
|
||||
useMemo(() => {
|
||||
return LiveData.from(docsSearchService.watchRefsTo(docId), []).map(
|
||||
links => {
|
||||
const visitedDoc = new Set<string>();
|
||||
// for each doc, we only show the first block
|
||||
return links.filter(link => {
|
||||
if (visitedDoc.has(link.docId)) {
|
||||
return false;
|
||||
}
|
||||
visitedDoc.add(link.docId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
);
|
||||
}, [docId, docsSearchService])
|
||||
);
|
||||
const links = useLiveData(docLinksService.links.links$);
|
||||
const backlinks = useLiveData(docLinksService.backlinks.backlinks$);
|
||||
|
||||
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||
|
||||
@@ -69,6 +48,10 @@ export const DocInfoSheet = ({
|
||||
setNewPropertyId(property.id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
docLinksService.backlinks.revalidateFromCloud();
|
||||
}, [docLinksService.backlinks]);
|
||||
|
||||
const properties = useLiveData(workspacePropertyService.sortedProperties$);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
LiveData,
|
||||
@@ -44,8 +45,10 @@ export const NavigationPanelDocNode = ({
|
||||
globalContextService,
|
||||
docDisplayMetaService,
|
||||
featureFlagService,
|
||||
workspaceService,
|
||||
} = useServices({
|
||||
DocsSearchService,
|
||||
WorkspaceService,
|
||||
DocsService,
|
||||
GlobalContextService,
|
||||
DocDisplayMetaService,
|
||||
@@ -95,9 +98,17 @@ export const NavigationPanelDocNode = ({
|
||||
|
||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
||||
useLayoutEffect(() => {
|
||||
if (collapsed) {
|
||||
return;
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
const undoSync = workspaceService.workspace.engine.doc.addPriority(
|
||||
docId,
|
||||
10
|
||||
);
|
||||
const undoIndexer = docsSearchService.indexer.addPriority(docId, 10);
|
||||
docsSearchService.indexer
|
||||
.waitForDocCompletedWithPriority(docId, 100, abortController.signal)
|
||||
.waitForDocCompleted(docId, abortController.signal)
|
||||
.then(() => {
|
||||
setReferencesLoading(false);
|
||||
})
|
||||
@@ -107,9 +118,11 @@ export const NavigationPanelDocNode = ({
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
undoSync();
|
||||
undoIndexer();
|
||||
abortController.abort(MANUALLY_STOP);
|
||||
};
|
||||
}, [docId, docsSearchService]);
|
||||
}, [docId, docsSearchService, workspaceService, collapsed]);
|
||||
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
const option = useMemo(
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
exhaustMapWithTrailing,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import { tap } from 'rxjs';
|
||||
|
||||
import type { DocService } from '../../doc';
|
||||
import type { DocService, DocsService } from '../../doc';
|
||||
import type { DocsSearchService } from '../../docs-search';
|
||||
import type { FeatureFlagService } from '../../feature-flag';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
export interface Backlink {
|
||||
docId: string;
|
||||
@@ -17,13 +30,128 @@ export interface Backlink {
|
||||
export class DocBacklinks extends Entity {
|
||||
constructor(
|
||||
private readonly docsSearchService: DocsSearchService,
|
||||
private readonly docService: DocService
|
||||
private readonly docService: DocService,
|
||||
private readonly docsService: DocsService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly workspaceService: WorkspaceService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
backlinks$ = LiveData.from<Backlink[]>(
|
||||
this.docsSearchService.watchRefsTo(this.docService.doc.id),
|
||||
[]
|
||||
backlinks$ = new LiveData<Backlink[] | undefined>(undefined);
|
||||
|
||||
isLoading$ = new LiveData<boolean>(false);
|
||||
error$ = new LiveData<any>(undefined);
|
||||
|
||||
revalidateFromCloud = effect(
|
||||
exhaustMapWithTrailing(() =>
|
||||
fromPromise(async () => {
|
||||
const searchFromCloud =
|
||||
this.featureFlagService.flags.enable_battery_save_mode &&
|
||||
this.workspaceService.workspace.flavour !== 'local';
|
||||
const { buckets } = await this.docsSearchService.indexer.aggregate(
|
||||
'block',
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
type: 'match',
|
||||
field: 'refDocId',
|
||||
match: this.docService.doc.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
'docId',
|
||||
{
|
||||
hits: {
|
||||
fields: [
|
||||
'docId',
|
||||
'blockId',
|
||||
'parentBlockId',
|
||||
'parentFlavour',
|
||||
'additional',
|
||||
'markdownPreview',
|
||||
],
|
||||
pagination: {
|
||||
limit: 5, // the max number of backlinks to show for each doc
|
||||
},
|
||||
},
|
||||
pagination: {
|
||||
limit: 100,
|
||||
},
|
||||
prefer: searchFromCloud ? 'remote' : 'local',
|
||||
}
|
||||
);
|
||||
return buckets.flatMap(bucket => {
|
||||
const title =
|
||||
this.docsService.list.doc$(bucket.key).value?.title$.value ?? '';
|
||||
|
||||
if (bucket.key === this.docService.doc.id) {
|
||||
// Ignore if it is a link to the current document.
|
||||
return [];
|
||||
}
|
||||
|
||||
return bucket.hits.nodes.map(node => {
|
||||
const blockId = node.fields.blockId ?? '';
|
||||
const markdownPreview = node.fields.markdownPreview ?? '';
|
||||
const additional =
|
||||
typeof node.fields.additional === 'string'
|
||||
? node.fields.additional
|
||||
: node.fields.additional[0];
|
||||
|
||||
const additionalData: {
|
||||
displayMode?: string;
|
||||
noteBlockId?: string;
|
||||
} = JSON.parse(additional || '{}');
|
||||
|
||||
const displayMode = additionalData.displayMode ?? '';
|
||||
const noteBlockId = additionalData.noteBlockId ?? '';
|
||||
const parentBlockId =
|
||||
typeof node.fields.parentBlockId === 'string'
|
||||
? node.fields.parentBlockId
|
||||
: node.fields.parentBlockId[0];
|
||||
const parentFlavour =
|
||||
typeof node.fields.parentFlavour === 'string'
|
||||
? node.fields.parentFlavour
|
||||
: node.fields.parentFlavour[0];
|
||||
|
||||
return {
|
||||
docId: bucket.key,
|
||||
blockId: typeof blockId === 'string' ? blockId : blockId[0],
|
||||
title: title,
|
||||
markdownPreview:
|
||||
typeof markdownPreview === 'string'
|
||||
? markdownPreview
|
||||
: markdownPreview[0],
|
||||
displayMode:
|
||||
typeof displayMode === 'string' ? displayMode : displayMode[0],
|
||||
noteBlockId:
|
||||
typeof noteBlockId === 'string' ? noteBlockId : noteBlockId[0],
|
||||
parentBlockId:
|
||||
typeof parentBlockId === 'string'
|
||||
? parentBlockId
|
||||
: parentBlockId[0],
|
||||
parentFlavour:
|
||||
typeof parentFlavour === 'string'
|
||||
? parentFlavour
|
||||
: parentFlavour[0],
|
||||
};
|
||||
});
|
||||
});
|
||||
}).pipe(
|
||||
smartRetry(),
|
||||
tap(backlinks => {
|
||||
this.backlinks$.value = backlinks;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => {
|
||||
this.isLoading$.value = true;
|
||||
}),
|
||||
onComplete(() => {
|
||||
this.isLoading$.value = false;
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { DocScope } from '../doc/scopes/doc';
|
||||
import { DocService } from '../doc/services/doc';
|
||||
import { DocsService } from '../doc/services/docs';
|
||||
import { DocsSearchService } from '../docs-search';
|
||||
import { WorkspaceScope } from '../workspace';
|
||||
import { FeatureFlagService } from '../feature-flag';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { DocBacklinks } from './entities/doc-backlinks';
|
||||
import { DocLinks } from './entities/doc-links';
|
||||
import { DocLinksService } from './services/doc-links';
|
||||
@@ -17,6 +19,12 @@ export function configureDocLinksModule(framework: Framework) {
|
||||
.scope(WorkspaceScope)
|
||||
.scope(DocScope)
|
||||
.service(DocLinksService)
|
||||
.entity(DocBacklinks, [DocsSearchService, DocService])
|
||||
.entity(DocBacklinks, [
|
||||
DocsSearchService,
|
||||
DocService,
|
||||
DocsService,
|
||||
FeatureFlagService,
|
||||
WorkspaceService,
|
||||
])
|
||||
.entity(DocLinks, [DocsSearchService, DocService]);
|
||||
}
|
||||
|
||||
25
packages/frontend/core/src/modules/doc-summary/index.ts
Normal file
25
packages/frontend/core/src/modules/doc-summary/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceServerService } from '../cloud';
|
||||
import { FeatureFlagService } from '../feature-flag';
|
||||
import { CacheStorage } from '../storage';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { DocSummaryService } from './services/doc-summary';
|
||||
import { DocSummaryStore } from './stores/doc-summary';
|
||||
|
||||
export { DocSummaryService };
|
||||
|
||||
export function configureDocSummaryModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(DocSummaryService, [
|
||||
WorkspaceService,
|
||||
DocSummaryStore,
|
||||
FeatureFlagService,
|
||||
])
|
||||
.store(DocSummaryStore, [
|
||||
WorkspaceService,
|
||||
WorkspaceServerService,
|
||||
CacheStorage,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { effect, fromPromise, Service, smartRetry } from '@toeverything/infra';
|
||||
import { catchError, EMPTY, Observable, type Subscription, tap } from 'rxjs';
|
||||
|
||||
import type { FeatureFlagService } from '../../feature-flag';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { DocSummaryStore } from '../stores/doc-summary';
|
||||
|
||||
export class DocSummaryService extends Service {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly store: DocSummaryStore,
|
||||
private readonly featureFlagService: FeatureFlagService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly docSummaryCache = new Map<
|
||||
string,
|
||||
Observable<string | undefined>
|
||||
>();
|
||||
|
||||
watchDocSummary(docId: string) {
|
||||
const cached$ = this.docSummaryCache.get(docId);
|
||||
if (!cached$) {
|
||||
const ob$ = new Observable<string | undefined>(subscribe => {
|
||||
if (
|
||||
this.workspaceService.workspace.flavour === 'local' ||
|
||||
this.featureFlagService.flags.enable_battery_save_mode.value === false
|
||||
) {
|
||||
// use local indexer
|
||||
const sub = this.store
|
||||
.watchDocSummaryFromIndexer(docId)
|
||||
.subscribe(subscribe);
|
||||
return () => sub.unsubscribe();
|
||||
}
|
||||
// use cache, and revalidate from cloud
|
||||
const sub = this.store.watchDocSummaryCache(docId).subscribe(subscribe);
|
||||
this.revalidateDocSummaryFromCloud(docId);
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
this.docSummaryCache.set(docId, ob$);
|
||||
return ob$;
|
||||
}
|
||||
return cached$;
|
||||
}
|
||||
|
||||
private readonly revalidateDocSummaryFromCloud = effect(
|
||||
(source$: Observable<string>) => {
|
||||
// make a lifo queue
|
||||
const queue: string[] = [];
|
||||
|
||||
let currentTask: Subscription | undefined;
|
||||
|
||||
const processTask = () => {
|
||||
if (currentTask) {
|
||||
return;
|
||||
}
|
||||
const docId = queue.pop();
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
currentTask = fromPromise(this.store.getDocSummaryFromCloud(docId))
|
||||
.pipe(
|
||||
smartRetry(),
|
||||
tap(summary => {
|
||||
if (summary) {
|
||||
this.store.setDocSummaryCache(docId, summary).catch(error => {
|
||||
console.error(error);
|
||||
// ignore error
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(error => {
|
||||
console.error(error);
|
||||
// ignore error
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
complete() {
|
||||
currentTask = undefined;
|
||||
processTask();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return new Observable(subscriber => {
|
||||
const sub = source$.subscribe({
|
||||
next: value => {
|
||||
queue.push(value);
|
||||
processTask();
|
||||
},
|
||||
complete: () => {
|
||||
subscriber.complete();
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
currentTask?.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
override dispose() {
|
||||
this.revalidateDocSummaryFromCloud.unsubscribe();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getDocSummaryQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
import type { WorkspaceServerService } from '../../cloud';
|
||||
import type { CacheStorage } from '../../storage';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
export class DocSummaryStore extends Store {
|
||||
get indexer() {
|
||||
return this.workspaceService.workspace.engine.indexer;
|
||||
}
|
||||
|
||||
private readonly gql = this.workspaceServerService.server?.gql;
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceServerService: WorkspaceServerService,
|
||||
private readonly cacheStorage: CacheStorage
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getDocSummaryFromCloud(docId: string) {
|
||||
return this.gql?.({
|
||||
query: getDocSummaryQuery,
|
||||
variables: {
|
||||
workspaceId: this.workspaceService.workspace.id,
|
||||
docId,
|
||||
},
|
||||
}).then(res => res.workspace.doc.summary ?? '');
|
||||
}
|
||||
|
||||
watchDocSummaryFromIndexer(docId: string) {
|
||||
return new Observable<string>(subscribe => {
|
||||
const undoIndexer = this.indexer.addPriority(docId, 10);
|
||||
const undoSync = this.workspaceService.workspace.engine.doc.addPriority(
|
||||
docId,
|
||||
10
|
||||
);
|
||||
const sub = this.indexer
|
||||
.search$(
|
||||
'doc',
|
||||
{
|
||||
type: 'match',
|
||||
field: 'docId',
|
||||
match: docId,
|
||||
},
|
||||
{
|
||||
fields: ['summary'],
|
||||
pagination: {
|
||||
limit: 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map(({ nodes }) => {
|
||||
const node = nodes.at(0);
|
||||
return (
|
||||
(typeof node?.fields.summary === 'string'
|
||||
? node?.fields.summary
|
||||
: node?.fields.summary[0]) ?? ''
|
||||
);
|
||||
})
|
||||
)
|
||||
.subscribe(subscribe);
|
||||
return () => {
|
||||
undoIndexer();
|
||||
undoSync();
|
||||
sub.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async setDocSummaryCache(docId: string, summary: string) {
|
||||
return this.cacheStorage.set(
|
||||
`doc-summary:${this.workspaceService.workspace.id}:${docId}`,
|
||||
summary
|
||||
);
|
||||
}
|
||||
|
||||
watchDocSummaryCache(docId: string) {
|
||||
return this.cacheStorage.watch<string>(
|
||||
`doc-summary:${this.workspaceService.workspace.id}:${docId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ export class Doc extends Entity {
|
||||
this.yDoc.off('afterTransaction', handleTransactionThrottled);
|
||||
handleTransactionThrottled.cancel();
|
||||
});
|
||||
|
||||
this.disposables.push(
|
||||
this.workspaceService.workspace.engine.doc.addPriority(this.id, 100)
|
||||
);
|
||||
|
||||
this.disposables.push(
|
||||
this.workspaceService.workspace.engine.indexer.addPriority(this.id, 100)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,4 +4,9 @@ import { Doc } from '../entities/doc';
|
||||
|
||||
export class DocService extends Service {
|
||||
public readonly doc = this.framework.createEntity(Doc);
|
||||
|
||||
override dispose() {
|
||||
this.doc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,110 +228,6 @@ export class DocsSearchService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
watchRefsTo(docId: string) {
|
||||
return this.indexer
|
||||
.aggregate$(
|
||||
'block',
|
||||
{
|
||||
type: 'boolean',
|
||||
occur: 'must',
|
||||
queries: [
|
||||
{
|
||||
type: 'match',
|
||||
field: 'refDocId',
|
||||
match: docId,
|
||||
},
|
||||
],
|
||||
},
|
||||
'docId',
|
||||
{
|
||||
hits: {
|
||||
fields: [
|
||||
'docId',
|
||||
'blockId',
|
||||
'parentBlockId',
|
||||
'parentFlavour',
|
||||
'additional',
|
||||
'markdownPreview',
|
||||
],
|
||||
pagination: {
|
||||
limit: 5, // the max number of backlinks to show for each doc
|
||||
},
|
||||
},
|
||||
pagination: {
|
||||
limit: 100,
|
||||
},
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
switchMap(({ buckets }) => {
|
||||
return fromPromise(async () => {
|
||||
return buckets.flatMap(bucket => {
|
||||
const title =
|
||||
this.docsService.list.doc$(bucket.key).value?.title$.value ??
|
||||
'';
|
||||
|
||||
if (bucket.key === docId) {
|
||||
// Ignore if it is a link to the current document.
|
||||
return [];
|
||||
}
|
||||
|
||||
return bucket.hits.nodes.map(node => {
|
||||
const blockId = node.fields.blockId ?? '';
|
||||
const markdownPreview = node.fields.markdownPreview ?? '';
|
||||
const additional =
|
||||
typeof node.fields.additional === 'string'
|
||||
? node.fields.additional
|
||||
: node.fields.additional[0];
|
||||
|
||||
const additionalData: {
|
||||
displayMode?: string;
|
||||
noteBlockId?: string;
|
||||
} = JSON.parse(additional || '{}');
|
||||
|
||||
const displayMode = additionalData.displayMode ?? '';
|
||||
const noteBlockId = additionalData.noteBlockId ?? '';
|
||||
const parentBlockId =
|
||||
typeof node.fields.parentBlockId === 'string'
|
||||
? node.fields.parentBlockId
|
||||
: node.fields.parentBlockId[0];
|
||||
const parentFlavour =
|
||||
typeof node.fields.parentFlavour === 'string'
|
||||
? node.fields.parentFlavour
|
||||
: node.fields.parentFlavour[0];
|
||||
|
||||
return {
|
||||
docId: bucket.key,
|
||||
blockId: typeof blockId === 'string' ? blockId : blockId[0],
|
||||
title: title,
|
||||
markdownPreview:
|
||||
typeof markdownPreview === 'string'
|
||||
? markdownPreview
|
||||
: markdownPreview[0],
|
||||
displayMode:
|
||||
typeof displayMode === 'string'
|
||||
? displayMode
|
||||
: displayMode[0],
|
||||
noteBlockId:
|
||||
typeof noteBlockId === 'string'
|
||||
? noteBlockId
|
||||
: noteBlockId[0],
|
||||
parentBlockId:
|
||||
typeof parentBlockId === 'string'
|
||||
? parentBlockId
|
||||
: parentBlockId[0],
|
||||
parentFlavour:
|
||||
typeof parentFlavour === 'string'
|
||||
? parentFlavour
|
||||
: parentFlavour[0],
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
watchDatabasesTo(docId: string) {
|
||||
const DatabaseAdditionalSchema = z.object({
|
||||
databaseName: z.string().optional(),
|
||||
|
||||
@@ -281,6 +281,13 @@ export const AFFINE_FLAGS = {
|
||||
configurable: true,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_battery_save_mode: {
|
||||
category: 'affine',
|
||||
displayName: 'Enable Battery Save Mode (Require Restart)',
|
||||
description: 'Enable battery save mode',
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
} satisfies { [key in string]: FlagInfo };
|
||||
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
|
||||
@@ -23,6 +23,7 @@ import { configureDocModule } from './doc';
|
||||
import { configureDocDisplayMetaModule } from './doc-display-meta';
|
||||
import { configureDocInfoModule } from './doc-info';
|
||||
import { configureDocLinksModule } from './doc-link';
|
||||
import { configureDocSummaryModule } from './doc-summary';
|
||||
import { configureDocsSearchModule } from './docs-search';
|
||||
import { configureEditorModule } from './editor';
|
||||
import { configureEditorSettingModule } from './editor-setting';
|
||||
@@ -124,4 +125,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureCollectionRulesModule(framework);
|
||||
configureIndexerEmbeddingModule(framework);
|
||||
configureCommentModule(framework);
|
||||
configureDocSummaryModule(framework);
|
||||
}
|
||||
|
||||
@@ -337,8 +337,6 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
|
||||
const isEmpty = isEmptyUpdate(localData) && isEmptyUpdate(cloudData);
|
||||
|
||||
console.log('isEmpty', isEmpty, localData, cloudData);
|
||||
|
||||
docStorage.connection.disconnect();
|
||||
|
||||
const info = await this.getWorkspaceInfo(id, signal);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '@affine/nbstore/worker/client';
|
||||
import { Entity } from '@toeverything/infra';
|
||||
|
||||
import type { FeatureFlagService } from '../../feature-flag';
|
||||
import type { NbstoreService } from '../../storage';
|
||||
import { WorkspaceEngineBeforeStart } from '../events';
|
||||
import type { WorkspaceService } from '../services/workspace';
|
||||
@@ -17,7 +18,8 @@ export class WorkspaceEngine extends Entity<{
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly nbstoreService: NbstoreService
|
||||
private readonly nbstoreService: NbstoreService,
|
||||
private readonly featureFlagService: FeatureFlagService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -61,6 +63,14 @@ export class WorkspaceEngine extends Entity<{
|
||||
`workspace:${this.workspaceService.workspace.flavour}:${this.workspaceService.workspace.id}`,
|
||||
this.props.engineWorkerInitOptions
|
||||
);
|
||||
if (
|
||||
this.featureFlagService.flags.enable_battery_save_mode.value &&
|
||||
this.workspaceService.workspace.flavour !== 'local'
|
||||
) {
|
||||
store.enableBatterySaveMode().catch(err => {
|
||||
console.error('error enabling battery save mode', err);
|
||||
});
|
||||
}
|
||||
this.client = store;
|
||||
this.disposables.push(dispose);
|
||||
this.eventBus.emit(WorkspaceEngineBeforeStart, this);
|
||||
@@ -68,6 +78,7 @@ export class WorkspaceEngine extends Entity<{
|
||||
const rootDoc = this.workspaceService.workspace.docCollection.doc;
|
||||
// priority load root doc
|
||||
this.doc.addPriority(rootDoc.guid, 100);
|
||||
this.indexer.addPriority(rootDoc.guid, 100);
|
||||
this.doc.start();
|
||||
this.disposables.push(() => this.doc.stop());
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
|
||||
}
|
||||
|
||||
private setProfile(info: WorkspaceProfileInfo) {
|
||||
console.log('setProfile', info, isEqual(this.profile$.value, info));
|
||||
if (isEqual(this.profile$.value, info)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,11 @@ export function configureWorkspaceModule(framework: Framework) {
|
||||
.service(WorkspaceService)
|
||||
.entity(Workspace, [WorkspaceScope, FeatureFlagService])
|
||||
.service(WorkspaceEngineService, [WorkspaceScope])
|
||||
.entity(WorkspaceEngine, [WorkspaceService, NbstoreService])
|
||||
.entity(WorkspaceEngine, [
|
||||
WorkspaceService,
|
||||
NbstoreService,
|
||||
FeatureFlagService,
|
||||
])
|
||||
.impl(WorkspaceLocalState, WorkspaceLocalStateImpl, [
|
||||
WorkspaceService,
|
||||
GlobalState,
|
||||
|
||||
Reference in New Issue
Block a user