mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
refactor(core): refactor collection to use new filter system (#12228)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a new Collection entity and store with reactive management and real-time updates. - Added reactive favorite and shared filters with expanded filtering options. - **Refactor** - Overhauled collection and filtering logic for better performance and maintainability. - Replaced legacy filtering UI and logic with a streamlined, service-driven rules system. - Updated collection components to use reactive data streams and simplified props. - Simplified collection creation by delegating ID generation and instantiation to the service layer. - Removed deprecated hooks and replaced state-based filtering with observable-driven filtering. - **Bug Fixes** - Improved accuracy and consistency of tag and favorite filtering in collections. - **Chores** - Removed deprecated and unused filter-related files, types, components, and styles to reduce complexity. - Cleaned up imports and removed unused code across multiple components. - **Documentation** - Corrected inline documentation for improved clarity. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -14,7 +14,7 @@ export class CreatedByFilterProvider extends Service implements FilterProvider {
|
||||
filter$(params: FilterParams): Observable<Set<string>> {
|
||||
const method = params.method as WorkspacePropertyFilter<'createdBy'>;
|
||||
if (method === 'include') {
|
||||
const userIds = params.value?.split(',') ?? [];
|
||||
const userIds = params.value?.split(',').filter(Boolean) ?? [];
|
||||
|
||||
return this.docsService.propertyValues$('createdBy').pipe(
|
||||
map(o => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DocsService } from '@affine/core/modules/doc';
|
||||
import type { FavoriteService } from '@affine/core/modules/favorite';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { combineLatest, map, type Observable } from 'rxjs';
|
||||
|
||||
import type { FilterProvider } from '../../provider';
|
||||
import type { FilterParams } from '../../types';
|
||||
|
||||
export class FavoriteFilterProvider extends Service implements FilterProvider {
|
||||
constructor(
|
||||
private readonly favoriteService: FavoriteService,
|
||||
private readonly docsService: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
filter$(params: FilterParams): Observable<Set<string>> {
|
||||
const method = params.method;
|
||||
if (method === 'is') {
|
||||
return combineLatest([
|
||||
this.favoriteService.favoriteList.list$,
|
||||
this.docsService.allDocIds$(),
|
||||
]).pipe(
|
||||
map(([favoriteList, allDocIds]) => {
|
||||
const favoriteDocIds = new Set<string>();
|
||||
for (const { id, type } of favoriteList) {
|
||||
if (type === 'doc') {
|
||||
favoriteDocIds.add(id);
|
||||
}
|
||||
}
|
||||
if (params.value === 'true') {
|
||||
return favoriteDocIds;
|
||||
} else if (params.value === 'false') {
|
||||
const notFavoriteDocIds = new Set<string>();
|
||||
for (const id of allDocIds) {
|
||||
if (!favoriteDocIds.has(id)) {
|
||||
notFavoriteDocIds.add(id);
|
||||
}
|
||||
}
|
||||
return notFavoriteDocIds;
|
||||
} else {
|
||||
throw new Error(`Unsupported value: ${params.value}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`Unsupported method: ${params.method}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DocsService } from '@affine/core/modules/doc';
|
||||
import type { ShareDocsListService } from '@affine/core/modules/share-doc';
|
||||
import { onStart, Service } from '@toeverything/infra';
|
||||
import { combineLatest, map, type Observable, of } from 'rxjs';
|
||||
|
||||
import type { FilterProvider } from '../../provider';
|
||||
import type { FilterParams } from '../../types';
|
||||
|
||||
export class SharedFilterProvider extends Service implements FilterProvider {
|
||||
constructor(
|
||||
private readonly shareDocsListService: ShareDocsListService,
|
||||
private readonly docsService: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
filter$(params: FilterParams): Observable<Set<string>> {
|
||||
const method = params.method;
|
||||
if (method === 'is') {
|
||||
return combineLatest([
|
||||
this.shareDocsListService.shareDocs?.list$ ??
|
||||
(of([]) as Observable<{ id: string }[]>),
|
||||
this.docsService.allDocIds$(),
|
||||
]).pipe(
|
||||
onStart(() => {
|
||||
this.shareDocsListService.shareDocs?.revalidate();
|
||||
}),
|
||||
map(([shareDocsList, allDocIds]) => {
|
||||
const shareDocIds = new Set(shareDocsList.map(item => item.id));
|
||||
if (params.value === 'true') {
|
||||
return shareDocIds;
|
||||
} else if (params.value === 'false') {
|
||||
const notShareDocIds = new Set<string>();
|
||||
for (const id of allDocIds) {
|
||||
if (!shareDocIds.has(id)) {
|
||||
notShareDocIds.add(id);
|
||||
}
|
||||
}
|
||||
return notShareDocIds;
|
||||
} else {
|
||||
throw new Error(`Unsupported value: ${params.value}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`Unsupported method: ${params.method}`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DocsService } from '@affine/core/modules/doc';
|
||||
import type { TagService } from '@affine/core/modules/tag';
|
||||
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
|
||||
import { Service } from '@toeverything/infra';
|
||||
import { combineLatest, map, type Observable, of, switchMap } from 'rxjs';
|
||||
|
||||
@@ -15,23 +16,66 @@ export class TagsFilterProvider extends Service implements FilterProvider {
|
||||
}
|
||||
|
||||
filter$(params: FilterParams): Observable<Set<string>> {
|
||||
if (params.method === 'include') {
|
||||
const tagIds = params.value?.split(',') ?? [];
|
||||
|
||||
const tags = tagIds.map(id => this.tagService.tagList.tagByTagId$(id));
|
||||
|
||||
const method = params.method as WorkspacePropertyFilter<'tags'>;
|
||||
const tagIds = params.value?.split(',').filter(Boolean) ?? [];
|
||||
const tags = tagIds.map(id => this.tagService.tagList.tagByTagId$(id));
|
||||
if (method === 'include-all' || method === 'not-include-all') {
|
||||
if (tags.length === 0) {
|
||||
return of(new Set<string>());
|
||||
}
|
||||
|
||||
return combineLatest(tags).pipe(
|
||||
const includeDocIds$ = combineLatest(tags).pipe(
|
||||
switchMap(tags =>
|
||||
combineLatest(
|
||||
tags
|
||||
.filter(tag => tag !== undefined)
|
||||
.map(tag => tag.pageIds$.map(ids => new Set(ids)))
|
||||
).pipe(
|
||||
map(pageIds =>
|
||||
pageIds.reduce((acc, curr) => acc.intersection(curr))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
if (method === 'include-all') {
|
||||
return includeDocIds$;
|
||||
} else {
|
||||
return combineLatest([
|
||||
this.docsService.allDocIds$(),
|
||||
includeDocIds$,
|
||||
]).pipe(
|
||||
map(
|
||||
([docIds, includeDocIds]) =>
|
||||
new Set(docIds.filter(id => !includeDocIds.has(id)))
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (method === 'include-any-of' || method === 'not-include-any-of') {
|
||||
if (tags.length === 0) {
|
||||
return of(new Set<string>());
|
||||
}
|
||||
|
||||
const includeAnyOfDocIds$ = combineLatest(tags).pipe(
|
||||
switchMap(tags =>
|
||||
combineLatest(
|
||||
tags.filter(tag => tag !== undefined).map(tag => tag.pageIds$)
|
||||
).pipe(map(pageIds => new Set(pageIds.flat())))
|
||||
)
|
||||
);
|
||||
} else if (params.method === 'is-not-empty') {
|
||||
if (method === 'include-any-of') {
|
||||
return includeAnyOfDocIds$;
|
||||
} else {
|
||||
return combineLatest([
|
||||
this.docsService.allDocIds$(),
|
||||
includeAnyOfDocIds$,
|
||||
]).pipe(
|
||||
map(
|
||||
([docIds, includeAnyOfDocIds]) =>
|
||||
new Set(docIds.filter(id => !includeAnyOfDocIds.has(id)))
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (method === 'is-not-empty') {
|
||||
return combineLatest([
|
||||
this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))),
|
||||
this.docsService.allDocsTagIds$(),
|
||||
@@ -49,7 +93,7 @@ export class TagsFilterProvider extends Service implements FilterProvider {
|
||||
)
|
||||
)
|
||||
);
|
||||
} else if (params.method === 'is-empty') {
|
||||
} else if (method === 'is-empty') {
|
||||
return this.tagService.tagList.tags$
|
||||
.map(tags => new Set(tags.map(t => t.id)))
|
||||
.pipe(
|
||||
@@ -70,6 +114,6 @@ export class TagsFilterProvider extends Service implements FilterProvider {
|
||||
)
|
||||
);
|
||||
}
|
||||
throw new Error(`Unsupported method: ${params.method}`);
|
||||
throw new Error(`Unsupported method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export class UpdatedByFilterProvider extends Service implements FilterProvider {
|
||||
filter$(params: FilterParams): Observable<Set<string>> {
|
||||
const method = params.method as WorkspacePropertyFilter<'updatedBy'>;
|
||||
if (method === 'include') {
|
||||
const userIds = params.value?.split(',') ?? [];
|
||||
const userIds = params.value?.split(',').filter(Boolean) ?? [];
|
||||
|
||||
return this.docsService.propertyValues$('updatedBy').pipe(
|
||||
map(o => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { DocsService } from '../doc';
|
||||
import { FavoriteService } from '../favorite';
|
||||
import { ShareDocsListService } from '../share-doc';
|
||||
import { TagService } from '../tag';
|
||||
import { WorkspaceScope } from '../workspace';
|
||||
import { WorkspacePropertyService } from '../workspace-property';
|
||||
@@ -10,8 +12,10 @@ import { CreatedByFilterProvider } from './impls/filters/created-by';
|
||||
import { DatePropertyFilterProvider } from './impls/filters/date';
|
||||
import { DocPrimaryModeFilterProvider } from './impls/filters/doc-primary-mode';
|
||||
import { EmptyJournalFilterProvider } from './impls/filters/empty-journal';
|
||||
import { FavoriteFilterProvider } from './impls/filters/favorite';
|
||||
import { JournalFilterProvider } from './impls/filters/journal';
|
||||
import { PropertyFilterProvider } from './impls/filters/property';
|
||||
import { SharedFilterProvider } from './impls/filters/shared';
|
||||
import { SystemFilterProvider } from './impls/filters/system';
|
||||
import { TagsFilterProvider } from './impls/filters/tags';
|
||||
import { TextPropertyFilterProvider } from './impls/filters/text';
|
||||
@@ -118,6 +122,14 @@ export function configureCollectionRulesModule(framework: Framework) {
|
||||
.impl(FilterProvider('system:empty-journal'), EmptyJournalFilterProvider, [
|
||||
DocsService,
|
||||
])
|
||||
.impl(FilterProvider('system:favorite'), FavoriteFilterProvider, [
|
||||
FavoriteService,
|
||||
DocsService,
|
||||
])
|
||||
.impl(FilterProvider('system:shared'), SharedFilterProvider, [
|
||||
ShareDocsListService,
|
||||
DocsService,
|
||||
])
|
||||
// --------------- Group By ---------------
|
||||
.impl(GroupByProvider('system'), SystemGroupByProvider)
|
||||
.impl(GroupByProvider('property'), PropertyGroupByProvider, [
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
@@ -21,7 +22,8 @@ export class CollectionRulesService extends Service {
|
||||
watch(
|
||||
filters: FilterParams[],
|
||||
groupBy?: GroupByParams,
|
||||
orderBy?: OrderByParams
|
||||
orderBy?: OrderByParams,
|
||||
extraAllowList?: string[]
|
||||
): Observable<{
|
||||
groups: {
|
||||
key: string;
|
||||
@@ -36,7 +38,10 @@ export class CollectionRulesService extends Service {
|
||||
filterErrors: any[]; // errors from the filter providers
|
||||
}> =
|
||||
filters.length === 0
|
||||
? of({ filtered: new Set<string>(), filterErrors: [] })
|
||||
? of({
|
||||
filtered: new Set<string>(extraAllowList ?? []),
|
||||
filterErrors: [],
|
||||
})
|
||||
: combineLatest(
|
||||
filters.map(filter => {
|
||||
const provider = filterProviders.get(filter.type);
|
||||
@@ -57,7 +62,7 @@ export class CollectionRulesService extends Service {
|
||||
})
|
||||
).pipe(
|
||||
map(results => {
|
||||
const finalSet = results.reduce((acc, result) => {
|
||||
const aggregated = results.reduce((acc, result) => {
|
||||
if ('error' in acc) {
|
||||
return acc;
|
||||
}
|
||||
@@ -67,8 +72,15 @@ export class CollectionRulesService extends Service {
|
||||
return acc.intersection(result);
|
||||
});
|
||||
|
||||
const filtered =
|
||||
'error' in aggregated ? new Set<string>() : aggregated;
|
||||
|
||||
const finalSet = filtered.union(
|
||||
new Set<string>(extraAllowList ?? [])
|
||||
);
|
||||
|
||||
return {
|
||||
filtered: 'error' in finalSet ? new Set<string>() : finalSet,
|
||||
filtered: finalSet,
|
||||
filterErrors: results.map(i => ('error' in i ? i.error : null)),
|
||||
};
|
||||
})
|
||||
@@ -204,4 +216,15 @@ export class CollectionRulesService extends Service {
|
||||
|
||||
return final$;
|
||||
}
|
||||
|
||||
compute(
|
||||
filters: FilterParams[],
|
||||
groupBy?: GroupByParams,
|
||||
orderBy?: OrderByParams,
|
||||
extraAllowList?: string[]
|
||||
) {
|
||||
return firstValueFrom(
|
||||
this.watch(filters, groupBy, orderBy, extraAllowList)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { map, switchMap } from 'rxjs';
|
||||
|
||||
import type { CollectionRulesService } from '../../collection-rules';
|
||||
import type { CollectionInfo, CollectionStore } from '../stores/collection';
|
||||
|
||||
export class Collection extends Entity<{ id: string }> {
|
||||
constructor(
|
||||
private readonly store: CollectionStore,
|
||||
private readonly rulesService: CollectionRulesService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
id = this.props.id;
|
||||
|
||||
info$ = LiveData.from<CollectionInfo>(
|
||||
this.store.watchCollectionInfo(this.id).pipe(
|
||||
map(
|
||||
info =>
|
||||
({
|
||||
// default fields in case collection info is not found
|
||||
name: '',
|
||||
id: this.id,
|
||||
rules: {
|
||||
filters: [],
|
||||
},
|
||||
allowList: [],
|
||||
...info,
|
||||
}) as CollectionInfo
|
||||
)
|
||||
),
|
||||
{} as CollectionInfo
|
||||
);
|
||||
|
||||
name$ = this.info$.map(info => info.name);
|
||||
allowList$ = this.info$.map(info => info.allowList);
|
||||
rules$ = this.info$.map(info => info.rules);
|
||||
|
||||
/**
|
||||
* Returns a list of document IDs that match the collection rules and allow list.
|
||||
*
|
||||
* For performance optimization,
|
||||
* Developers must explicitly call `watch()` to retrieve the result and properly manage the subscription lifecycle.
|
||||
*/
|
||||
watch() {
|
||||
return this.info$.pipe(
|
||||
switchMap(info => {
|
||||
return this.rulesService
|
||||
.watch(
|
||||
info.rules.filters.length > 0
|
||||
? [
|
||||
...info.rules.filters,
|
||||
// if we have more than one filter, we need to add a system filter to exclude trash
|
||||
{
|
||||
type: 'system',
|
||||
key: 'trash',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
: [], // If no filters are provided, an empty filter list will match no documents
|
||||
undefined,
|
||||
undefined,
|
||||
info.allowList
|
||||
)
|
||||
.pipe(map(result => result.groups.map(group => group.items).flat()));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateInfo(info: Partial<CollectionInfo>) {
|
||||
this.store.updateCollectionInfo(this.id, info);
|
||||
}
|
||||
|
||||
addDoc(...docIds: string[]) {
|
||||
this.store.updateCollectionInfo(this.id, {
|
||||
allowList: uniq([...this.info$.value.allowList, ...docIds]),
|
||||
});
|
||||
}
|
||||
|
||||
removeDoc(...docIds: string[]) {
|
||||
this.store.updateCollectionInfo(this.id, {
|
||||
allowList: this.info$.value.allowList.filter(id => !docIds.includes(id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
export { Collection } from './entities/collection';
|
||||
export type { CollectionMeta } from './services/collection';
|
||||
export { CollectionService } from './services/collection';
|
||||
export type { CollectionInfo } from './stores/collection';
|
||||
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { CollectionRulesService } from '../collection-rules';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { Collection } from './entities/collection';
|
||||
import { CollectionService } from './services/collection';
|
||||
import { CollectionStore } from './stores/collection';
|
||||
|
||||
export function configureCollectionModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(CollectionService, [WorkspaceService]);
|
||||
.service(CollectionService, [CollectionStore])
|
||||
.store(CollectionStore, [WorkspaceService])
|
||||
.entity(Collection, [CollectionStore, CollectionRulesService]);
|
||||
}
|
||||
|
||||
@@ -1,193 +1,78 @@
|
||||
import type {
|
||||
Collection,
|
||||
DeleteCollectionInfo,
|
||||
DeletedCollection,
|
||||
} from '@affine/env/filter';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Array as YArray } from 'yjs';
|
||||
import { LiveData, ObjectPool, Service } from '@toeverything/infra';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import { Collection } from '../entities/collection';
|
||||
import type { CollectionInfo, CollectionStore } from '../stores/collection';
|
||||
|
||||
const SETTING_KEY = 'setting';
|
||||
|
||||
const COLLECTIONS_KEY = 'collections';
|
||||
const COLLECTIONS_TRASH_KEY = 'collections_trash';
|
||||
export interface CollectionMeta extends Pick<CollectionInfo, 'id' | 'name'> {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export class CollectionService extends Service {
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
constructor(private readonly store: CollectionStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get doc() {
|
||||
return this.workspaceService.workspace.docCollection.doc;
|
||||
}
|
||||
pool = new ObjectPool<string, Collection>({
|
||||
onDelete(obj) {
|
||||
obj.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
private get setting() {
|
||||
return this.workspaceService.workspace.docCollection.doc.getMap(
|
||||
SETTING_KEY
|
||||
);
|
||||
}
|
||||
|
||||
private get collectionsYArray(): YArray<Collection> | undefined {
|
||||
return this.setting.get(COLLECTIONS_KEY) as YArray<Collection>;
|
||||
}
|
||||
|
||||
private get collectionsTrashYArray(): YArray<DeletedCollection> | undefined {
|
||||
return this.setting.get(COLLECTIONS_TRASH_KEY) as YArray<DeletedCollection>;
|
||||
}
|
||||
// collection metas used in collection list, only include `id` and `name`, without `rules` and `allowList`
|
||||
readonly collectionMetas$ = LiveData.from(
|
||||
this.store.watchCollectionMetas(),
|
||||
[]
|
||||
);
|
||||
|
||||
readonly collections$ = LiveData.from(
|
||||
new Observable<Collection[]>(subscriber => {
|
||||
subscriber.next(this.collectionsYArray?.toArray() ?? []);
|
||||
const fn = () => {
|
||||
subscriber.next(this.collectionsYArray?.toArray() ?? []);
|
||||
};
|
||||
this.setting.observeDeep(fn);
|
||||
return () => {
|
||||
this.setting.unobserveDeep(fn);
|
||||
};
|
||||
}),
|
||||
[]
|
||||
this.store.watchCollectionIds().pipe(
|
||||
map(
|
||||
ids =>
|
||||
new Map<string, Collection>(
|
||||
ids.map(id => {
|
||||
const exists = this.pool.get(id);
|
||||
if (exists) {
|
||||
return [id, exists.obj];
|
||||
}
|
||||
const record = this.framework.createEntity(Collection, { id });
|
||||
this.pool.put(id, record);
|
||||
return [id, record] as const;
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
new Map<string, Collection>()
|
||||
);
|
||||
|
||||
collection$(id: string) {
|
||||
return this.collections$.map(collections => {
|
||||
return collections.find(v => v.id === id);
|
||||
return this.collections$.selector(collections => {
|
||||
return collections.get(id);
|
||||
});
|
||||
}
|
||||
|
||||
readonly collectionsTrash$ = LiveData.from(
|
||||
new Observable<DeletedCollection[]>(subscriber => {
|
||||
subscriber.next(this.collectionsTrashYArray?.toArray() ?? []);
|
||||
const fn = () => {
|
||||
subscriber.next(this.collectionsTrashYArray?.toArray() ?? []);
|
||||
};
|
||||
this.setting.observeDeep(fn);
|
||||
return () => {
|
||||
this.setting.unobserveDeep(fn);
|
||||
};
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
addCollection(...collections: Collection[]) {
|
||||
if (!this.setting.has(COLLECTIONS_KEY)) {
|
||||
this.setting.set(COLLECTIONS_KEY, new YArray());
|
||||
}
|
||||
this.doc.transact(() => {
|
||||
this.collectionsYArray?.insert(0, collections);
|
||||
});
|
||||
createCollection(collectionInfo: Partial<Omit<CollectionInfo, 'id'>>) {
|
||||
return this.store.createCollection(collectionInfo);
|
||||
}
|
||||
|
||||
updateCollection(id: string, updater: (value: Collection) => Collection) {
|
||||
if (this.collectionsYArray) {
|
||||
updateFirstOfYArray(
|
||||
this.collectionsYArray,
|
||||
v => v.id === id,
|
||||
v => {
|
||||
return updater(v);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
addPageToCollection(collectionId: string, pageId: string) {
|
||||
this.updateCollection(collectionId, old => {
|
||||
return {
|
||||
...old,
|
||||
allowList: [pageId, ...(old.allowList ?? [])],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deletePageFromCollection(collectionId: string, pageId: string) {
|
||||
this.updateCollection(collectionId, old => {
|
||||
return {
|
||||
...old,
|
||||
allowList: old.allowList?.filter(id => id !== pageId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) {
|
||||
const collectionsYArray = this.collectionsYArray;
|
||||
if (!collectionsYArray) {
|
||||
return;
|
||||
}
|
||||
const set = new Set(ids);
|
||||
this.workspaceService.workspace.docCollection.doc.transact(() => {
|
||||
const indexList: number[] = [];
|
||||
const list: Collection[] = [];
|
||||
collectionsYArray.forEach((collection, i) => {
|
||||
if (set.has(collection.id)) {
|
||||
set.delete(collection.id);
|
||||
indexList.unshift(i);
|
||||
list.push(JSON.parse(JSON.stringify(collection)));
|
||||
}
|
||||
});
|
||||
indexList.forEach(i => {
|
||||
collectionsYArray.delete(i);
|
||||
});
|
||||
if (!this.collectionsTrashYArray) {
|
||||
this.setting.set(COLLECTIONS_TRASH_KEY, new YArray());
|
||||
}
|
||||
const collectionsTrashYArray = this.collectionsTrashYArray;
|
||||
if (!collectionsTrashYArray) {
|
||||
return;
|
||||
}
|
||||
collectionsTrashYArray.insert(
|
||||
0,
|
||||
list.map(collection => ({
|
||||
userId: info?.userId,
|
||||
userName: info ? info.userName : 'Local User',
|
||||
collection,
|
||||
}))
|
||||
);
|
||||
if (collectionsTrashYArray.length > 10) {
|
||||
collectionsTrashYArray.delete(10, collectionsTrashYArray.length - 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private deletePagesFromCollection(
|
||||
collection: Collection,
|
||||
idSet: Set<string>
|
||||
updateCollection(
|
||||
id: string,
|
||||
collectionInfo: Partial<Omit<CollectionInfo, 'id'>>
|
||||
) {
|
||||
const newAllowList = collection.allowList.filter(id => !idSet.has(id));
|
||||
if (newAllowList.length !== collection.allowList.length) {
|
||||
this.updateCollection(collection.id, old => {
|
||||
return {
|
||||
...old,
|
||||
allowList: newAllowList,
|
||||
};
|
||||
});
|
||||
}
|
||||
return this.store.updateCollectionInfo(id, collectionInfo);
|
||||
}
|
||||
|
||||
deletePagesFromCollections(ids: string[]) {
|
||||
const idSet = new Set(ids);
|
||||
this.doc.transact(() => {
|
||||
this.collections$.value.forEach(collection => {
|
||||
this.deletePagesFromCollection(collection, idSet);
|
||||
});
|
||||
});
|
||||
addDocToCollection(collectionId: string, docId: string) {
|
||||
const collection = this.collection$(collectionId).value;
|
||||
collection?.addDoc(docId);
|
||||
}
|
||||
|
||||
removeDocFromCollection(collectionId: string, docId: string) {
|
||||
const collection = this.collection$(collectionId).value;
|
||||
collection?.removeDoc(docId);
|
||||
}
|
||||
|
||||
deleteCollection(id: string) {
|
||||
this.store.deleteCollection(id);
|
||||
}
|
||||
}
|
||||
|
||||
const updateFirstOfYArray = <T>(
|
||||
array: YArray<T>,
|
||||
p: (value: T) => boolean,
|
||||
update: (value: T) => T
|
||||
) => {
|
||||
array.doc?.transact(() => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const ele = array.get(i);
|
||||
if (p(ele)) {
|
||||
array.delete(i);
|
||||
array.insert(i, [update(ele)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import type { Collection as LegacyCollectionInfo } from '@affine/env/filter';
|
||||
import {
|
||||
Store,
|
||||
yjsGetPath,
|
||||
yjsObserve,
|
||||
yjsObserveDeep,
|
||||
} from '@toeverything/infra';
|
||||
import dayjs from 'dayjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { map, type Observable, switchMap } from 'rxjs';
|
||||
import { Array as YArray } from 'yjs';
|
||||
|
||||
import type { FilterParams } from '../../collection-rules';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
|
||||
export interface CollectionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
rules: {
|
||||
filters: FilterParams[];
|
||||
};
|
||||
allowList: string[];
|
||||
}
|
||||
|
||||
export class CollectionStore extends Store {
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get rootYDoc() {
|
||||
return this.workspaceService.workspace.rootYDoc;
|
||||
}
|
||||
|
||||
private get workspaceSettingYMap() {
|
||||
return this.rootYDoc.getMap('setting');
|
||||
}
|
||||
|
||||
watchCollectionMetas() {
|
||||
return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe(
|
||||
switchMap(yjsObserveDeep),
|
||||
map(yjs => {
|
||||
if (yjs instanceof YArray) {
|
||||
return yjs.map(v => {
|
||||
return {
|
||||
id: v.id as string,
|
||||
name: v.name as string,
|
||||
// for old code compatibility
|
||||
title: v.name as string,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
watchCollectionIds() {
|
||||
return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe(
|
||||
switchMap(yjsObserve),
|
||||
map(yjs => {
|
||||
if (yjs instanceof YArray) {
|
||||
return yjs.map(v => {
|
||||
return v.id as string;
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
watchCollectionInfo(id: string): Observable<CollectionInfo | null> {
|
||||
return yjsGetPath(this.workspaceSettingYMap, 'collections').pipe(
|
||||
switchMap(yjsObserve),
|
||||
map(meta => {
|
||||
if (meta instanceof YArray) {
|
||||
// meta is YArray, `for-of` is faster then `for`
|
||||
for (const doc of meta) {
|
||||
if (doc && doc.id === id) {
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
switchMap(yjsObserveDeep),
|
||||
map(yjs => {
|
||||
if (yjs) {
|
||||
return this.migrateCollectionInfo(yjs as LegacyCollectionInfo);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createCollection(info: Partial<Omit<CollectionInfo, 'id'>>) {
|
||||
const id = nanoid();
|
||||
let yArray = this.rootYDoc.getMap('setting').get('collections') as
|
||||
| YArray<any>
|
||||
| undefined;
|
||||
|
||||
if (!(yArray instanceof YArray)) {
|
||||
// if collections list is not a YArray, create a new one
|
||||
yArray = new YArray<any>();
|
||||
this.rootYDoc.getMap('setting').set('collections', yArray);
|
||||
}
|
||||
|
||||
yArray.push([
|
||||
{
|
||||
id: id,
|
||||
name: info.name ?? '',
|
||||
rules: info.rules ?? { filters: [] },
|
||||
allowList: info.allowList ?? [],
|
||||
},
|
||||
]);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
deleteCollection(id: string) {
|
||||
const yArray = this.rootYDoc.getMap('setting').get('collections') as
|
||||
| YArray<any>
|
||||
| undefined;
|
||||
|
||||
if (!(yArray instanceof YArray)) {
|
||||
throw new Error('Collections is not a YArray');
|
||||
}
|
||||
|
||||
for (let i = 0; i < yArray.length; i++) {
|
||||
const collection = yArray.get(i);
|
||||
if (collection.id === id) {
|
||||
yArray.delete(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCollectionInfo(id: string, info: Partial<Omit<CollectionInfo, 'id'>>) {
|
||||
const yArray = this.rootYDoc.getMap('setting').get('collections') as
|
||||
| YArray<any>
|
||||
| undefined;
|
||||
|
||||
if (!(yArray instanceof YArray)) {
|
||||
throw new Error('Collections is not a YArray');
|
||||
}
|
||||
|
||||
let collectionIndex = 0;
|
||||
for (const collection of yArray) {
|
||||
if (collection.id === id) {
|
||||
this.rootYDoc.transact(() => {
|
||||
yArray.delete(collectionIndex, 1);
|
||||
yArray.insert(collectionIndex, [
|
||||
{
|
||||
id: collection.id,
|
||||
name: info.name ?? collection.name,
|
||||
rules: info.rules ?? collection.rules,
|
||||
allowList: info.allowList ?? collection.allowList,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
collectionIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
migrateCollectionInfo(
|
||||
legacyCollectionInfo: LegacyCollectionInfo
|
||||
): CollectionInfo {
|
||||
if ('rules' in legacyCollectionInfo) {
|
||||
return legacyCollectionInfo as CollectionInfo;
|
||||
}
|
||||
return {
|
||||
id: legacyCollectionInfo.id,
|
||||
name: legacyCollectionInfo.name,
|
||||
rules: {
|
||||
filters: this.migrateFilterList(legacyCollectionInfo.filterList),
|
||||
},
|
||||
allowList: legacyCollectionInfo.allowList,
|
||||
};
|
||||
}
|
||||
|
||||
migrateFilterList(
|
||||
filterList: LegacyCollectionInfo['filterList']
|
||||
): FilterParams[] {
|
||||
return filterList.map(filter => {
|
||||
const leftValue = filter.left.name;
|
||||
const method = filter.funcName;
|
||||
const args = filter.args.map(arg => arg.value);
|
||||
const arg0 = args[0];
|
||||
if (leftValue === 'Created' || leftValue === 'Updated') {
|
||||
const key = leftValue === 'Created' ? 'createdAt' : 'updatedAt';
|
||||
if (method === 'after' && typeof arg0 === 'number') {
|
||||
return {
|
||||
type: 'system',
|
||||
key,
|
||||
method: 'after',
|
||||
value: dayjs(arg0).format('YYYY-MM-DD'),
|
||||
};
|
||||
} else if (method === 'before' && typeof arg0 === 'number') {
|
||||
return {
|
||||
type: 'system',
|
||||
key,
|
||||
method: 'before',
|
||||
value: dayjs(arg0).format('YYYY-MM-DD'),
|
||||
};
|
||||
} else if (method === 'last' && typeof arg0 === 'number') {
|
||||
return {
|
||||
type: 'system',
|
||||
key,
|
||||
method: 'last',
|
||||
value: dayjs().subtract(arg0, 'day').format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
} else if (leftValue === 'Is Favourited') {
|
||||
if (method === 'is') {
|
||||
const value = arg0 ? 'true' : 'false';
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'favorite',
|
||||
method: 'is',
|
||||
value,
|
||||
};
|
||||
}
|
||||
} else if (leftValue === 'Tags') {
|
||||
if (method === 'is not empty') {
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'tags',
|
||||
method: 'is-not-empty',
|
||||
};
|
||||
} else if (method === 'is empty') {
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'tags',
|
||||
method: 'is-empty',
|
||||
};
|
||||
} else if (method === 'contains all' && Array.isArray(arg0)) {
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'tags',
|
||||
method: 'include-all',
|
||||
value: arg0.join(','),
|
||||
};
|
||||
} else if (method === 'contains one of' && Array.isArray(arg0)) {
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'tags',
|
||||
method: 'include-any-of',
|
||||
value: arg0.join(','),
|
||||
};
|
||||
} else if (method === 'does not contains all' && Array.isArray(arg0)) {
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'tags',
|
||||
method: 'not-include-all',
|
||||
value: arg0.join(','),
|
||||
};
|
||||
} else if (
|
||||
method === 'does not contains one of' &&
|
||||
Array.isArray(arg0)
|
||||
) {
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'tags',
|
||||
method: 'not-include-any-of',
|
||||
value: arg0.join(','),
|
||||
};
|
||||
}
|
||||
} else if (leftValue === 'Is Public' && method === 'is') {
|
||||
return {
|
||||
type: 'system',
|
||||
key: 'shared',
|
||||
method: 'is',
|
||||
value: arg0 ? 'true' : 'false',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
key: 'unknown',
|
||||
method: 'unknown',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ export class DocsService extends Service {
|
||||
return this.store.watchAllDocTagIds();
|
||||
}
|
||||
|
||||
allDocIds$() {
|
||||
return this.store.watchDocIds();
|
||||
}
|
||||
|
||||
allNonTrashDocIds$() {
|
||||
return this.store.watchNonTrashDocIds();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class CollectionsQuickSearchSession
|
||||
LiveData.computed(get => {
|
||||
const query = get(this.query$);
|
||||
|
||||
const collections = get(this.collectionService.collections$);
|
||||
const collections = get(this.collectionService.collectionMetas$);
|
||||
|
||||
const fuse = new Fuse(collections, {
|
||||
keys: ['name'],
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CollectionMeta,
|
||||
TagMeta,
|
||||
} from '@affine/core/components/page-list';
|
||||
import type { TagMeta } from '@affine/core/components/page-list';
|
||||
import { I18n } from '@affine/i18n';
|
||||
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
@@ -18,7 +15,7 @@ import { html } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import type { CollectionService } from '../../collection';
|
||||
import type { CollectionMeta, CollectionService } from '../../collection';
|
||||
import type { DocDisplayMetaService } from '../../doc-display-meta';
|
||||
import type { DocsSearchService } from '../../docs-search';
|
||||
import { type RecentDocsService } from '../../quicksearch';
|
||||
@@ -298,7 +295,7 @@ export class SearchMenuService extends Service {
|
||||
action: SearchCollectionMenuAction,
|
||||
_abortSignal: AbortSignal
|
||||
): LinkedMenuGroup {
|
||||
const collections = this.collectionService.collections$.value;
|
||||
const collections = this.collectionService.collectionMetas$.value;
|
||||
if (query.trim().length === 0) {
|
||||
return {
|
||||
name: I18n.t('com.affine.editor.at-menu.collections', {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import { tap } from 'rxjs';
|
||||
import { map, tap } from 'rxjs';
|
||||
|
||||
import type { GlobalCache } from '../../storage';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
@@ -22,7 +22,12 @@ type ShareDocListType = GetWorkspacePublicPagesQuery['workspace']['publicDocs'];
|
||||
export const logger = new DebugLogger('affine:share-doc-list');
|
||||
|
||||
export class ShareDocsList extends Entity {
|
||||
list$ = LiveData.from(this.cache.watch<ShareDocListType>('share-docs'), []);
|
||||
list$ = LiveData.from(
|
||||
this.cache
|
||||
.watch<ShareDocListType>('share-docs')
|
||||
.pipe(map(list => list ?? [])),
|
||||
[]
|
||||
);
|
||||
isLoading$ = new LiveData<boolean>(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@ type DateFilters =
|
||||
|
||||
export type WorkspacePropertyTypes = {
|
||||
tags: {
|
||||
filter: 'include' | 'is-not-empty' | 'is-empty';
|
||||
filter:
|
||||
| 'include-all'
|
||||
| 'include-any-of'
|
||||
| 'not-include-all'
|
||||
| 'not-include-any-of'
|
||||
| 'is-not-empty'
|
||||
| 'is-empty';
|
||||
};
|
||||
text: {
|
||||
filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty';
|
||||
|
||||
Reference in New Issue
Block a user