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:
EYHN
2025-08-01 16:31:31 +08:00
committed by GitHub
parent 1661ab1790
commit 1ceed6c145
34 changed files with 717 additions and 286 deletions

View File

@@ -0,0 +1,7 @@
query getDocSummary($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
summary
}
}
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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(),
});
}
}

View File

@@ -143,6 +143,8 @@ interface GroupedWorkerOps {
sync: {
enableBatterySaveMode: [void, void];
disableBatterySaveMode: [void, void];
pauseSync: [void, void];
resumeSync: [void, void];
};
}

View File

@@ -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, {

View File

@@ -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 = {

View File

@@ -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']()} ·{' '}

View File

@@ -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;

View File

@@ -312,8 +312,6 @@ export const WorkspaceCard = forwardRef<
onClickOpenSettings?.(workspaceMetadata);
}, [onClickOpenSettings, workspaceMetadata]);
console.log(information);
return (
<div
className={clsx(

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 (

View File

@@ -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(

View File

@@ -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;
})
)
)
);
}

View File

@@ -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]);
}

View 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,
]);
}

View File

@@ -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();
}
}

View File

@@ -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}`
);
}
}

View File

@@ -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)
);
}
/**

View File

@@ -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();
}
}

View File

@@ -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(),

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -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,