feat(core): add collection rules module (#11683)

whats changed:

### orm

add a new `select$` method, can subscribe on only one field to improve batch subscribe performance

### yjs-observable

add a new `yjsObservePath` method, which can subscribe to changes from specific path in yjs. Improves batch subscribe performance

```ts
yjsGetPath(
      this.workspaceService.workspace.rootYDoc.getMap('meta'),
      'pages'
    ).pipe(
      switchMap(pages => yjsObservePath(pages, '*.tags')),
      map(pages => {
          // only when tags changed
      })
)
```

### standard property naming

All `DocProperty` components renamed to `WorkspaceProperty` which is consistent with the product definition.

### `WorkspacePropertyService`

Split the workspace property management logic from the `doc` module and create a new `WorkspacePropertyService`. The new service manages the creation and modification of properties, and the `docService` is only responsible for storing the property value data.

### new `<Filters />` component

in `core/component/filter`

### new `<ExplorerDisplayMenuButton />` component

in `core/component/explorer/display-menu`

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/c47ab43c-ac53-4ab6-922e-03127d07bef3.png)

### new `/workspace/xxx/all-new` route

New route for test components and functions

### new collection role service

Implemented some filter group order rules

see `collection-rules/index.ts`

### standard property type definition

define type in `modules\workspace-property\types.ts`

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/4324453a-4fab-4d1e-83bb-53693e68e87a.png)

define components (name,icon,....) in `components\workspace-property-types\index.ts`

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/93a23947-aaff-480d-a158-dd4075baae17.png)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced comprehensive filtering, grouping, and ordering capabilities for workspace documents with reactive updates.
  - Added a new "All Pages" workspace view supporting dynamic filters and display preferences.
  - Developed UI components for filter creation, condition editing, and display menu controls.
  - Launched enhanced tag management with inline editors, selection, creation, and deletion workflows.
  - Added workspace property types with dedicated filter UIs including checkbox, date, tags, and text.
  - Introduced workspace property management replacing document property handling.
  - Added modular providers for filters, group-by, and order-by operations supporting various property types and system attributes.

- **Improvements**
  - Standardized tag and property naming conventions across the application (using `name` instead of `value` or `title`).
  - Migrated document property handling to workspace property-centric logic.
  - Enhanced internationalization with additional filter and display menu labels.
  - Improved styling for filter conditions, display menus, and workspace pages.
  - Optimized reactive data subscriptions and state management for performance.
  - Refined schema typings and type safety for workspace properties.
  - Updated imports and component references to workspace property equivalents throughout frontend.

- **Bug Fixes**
  - Resolved tag property inconsistencies affecting display and filtering.
  - Fixed filter and tag selection behaviors for accurate and reliable UI interactions.

- **Chores**
  - Added and refined test cases for ORM, observables, and filtering logic.
  - Cleaned up legacy document property code and improved type safety.
  - Modularized and restructured components for better maintainability.
  - Introduced new CSS styles for workspace pages and display menus.
  - Added framework module configurations for collection rules and workspace property features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-08 08:38:56 +00:00
parent f9e003d220
commit 8399d99e79
174 changed files with 5644 additions and 980 deletions
@@ -15,6 +15,7 @@ export { FetchService } from './services/fetch';
export { GraphQLService } from './services/graphql';
export { InvitationService } from './services/invitation';
export { InvoicesService } from './services/invoices';
export type { PublicUserInfo } from './services/public-user';
export { PublicUserService } from './services/public-user';
export { SelfhostGenerateLicenseService } from './services/selfhost-generate-license';
export { SelfhostLicenseService } from './services/selfhost-license';
@@ -20,6 +20,7 @@ type ExistedUserInfo = {
id: string;
name?: string | null;
avatar?: string | null;
avatarUrl?: string | null;
removed?: false;
};
@@ -90,6 +91,7 @@ export class PublicUserService extends Service {
id,
name: user.name,
avatar: user.avatarUrl,
avatarUrl: user.avatarUrl,
};
}).pipe(
smartRetry(),
@@ -0,0 +1,47 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class CheckboxPropertyFilterProvider
extends Service
implements FilterProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'checkbox'>;
if (method === 'is') {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if ((value === 'true' ? 'true' : 'false') === params.value) {
match.add(id);
}
}
return match;
})
);
}
if (method === 'is-not') {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if ((value === 'true' ? 'true' : 'false') !== params.value) {
match.add(id);
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${params.method}`);
}
}
@@ -0,0 +1,19 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
import { basicDateFilter } from './date';
export class CreatedAtFilterProvider extends Service implements FilterProvider {
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
return this.docsService.allDocsCreatedDate$().pipe(
map(docs => new Map(docs.map(doc => [doc.id, doc.createDate]))),
basicDateFilter(params)
);
}
}
@@ -0,0 +1,33 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class CreatedByFilterProvider extends Service implements FilterProvider {
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'createdBy'>;
if (method === 'include') {
const userIds = params.value?.split(',') ?? [];
return this.docsService.propertyValues$('createdBy').pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value && userIds.includes(value)) {
match.add(id);
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${params.method}`);
}
}
@@ -0,0 +1,160 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import dayjs, { type Dayjs, isDayjs } from 'dayjs';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class DatePropertyFilterProvider
extends Service
implements FilterProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
return this.docsService
.propertyValues$('custom:' + params.key)
.pipe(basicDateFilter(params));
}
}
export function basicDateFilter(
params: FilterParams
): (
upstream$: Observable<Map<string, string | number | undefined>>
) => Observable<Set<string>> {
return upstream$ => {
// value can be like "2025-01-01,2025-01-02"
// or "2025-01-01"
const filterValues = (params.value
?.split(',')
.map(t => parseDate(t))
.filter(Boolean) ?? []) as [number, number, number][];
const now = dayjs();
const method = params.method as WorkspacePropertyFilter<'date'>;
const relativeRanges: Record<string, Dayjs> = {
'last-3-days': now.subtract(3, 'day'),
'last-7-days': now.subtract(7, 'day'),
'last-15-days': now.subtract(15, 'day'),
'last-30-days': now.subtract(30, 'day'),
'this-week': now.startOf('week'),
'this-month': now.startOf('month'),
// @ts-expect-error 'quarter' is not in type, but it's supported by dayjs
'this-quarter': now.startOf('quarter'),
'this-year': now.startOf('year'),
};
return upstream$.pipe(
map(o => {
if (method === 'is-empty' || method === 'is-not-empty') {
const match = new Set<string>();
for (const [id, value] of o) {
if (method === 'is-empty' ? !value : !!value) {
match.add(id);
}
}
return match;
}
if (method === 'between' && filterValues.length >= 2) {
return handleDateRangeFilter(
o,
parsed =>
isAfter(parsed, filterValues[0]) &&
isBefore(parsed, filterValues[1])
);
}
if (method === 'after' && filterValues.length >= 1) {
return handleDateRangeFilter(o, parsed =>
isAfter(parsed, filterValues[0])
);
}
if (method === 'before' && filterValues.length >= 1) {
return handleDateRangeFilter(o, parsed =>
isBefore(parsed, filterValues[0])
);
}
if (method in relativeRanges) {
return handleDateRangeFilter(o, parsed =>
isAfter(parsed, relativeRanges[method])
);
}
throw new Error(`Unsupported method: ${method}`);
})
);
};
}
function parseDate(value: string | number): [number, number, number] | null {
if (typeof value === 'string') {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const [_, year, month, day] = match;
return [parseInt(year), parseInt(month), parseInt(day)];
} else if (typeof value === 'number') {
const date = new Date(value);
return [date.getFullYear(), date.getMonth() + 1, date.getDate()];
}
return null;
}
function handleDateRangeFilter(
propertyValues: Map<string, string | number | undefined>,
predicate: (parsed: [number, number, number]) => boolean
): Set<string> {
const match = new Set<string>();
for (const [id, value] of propertyValues) {
if (!value) {
continue;
}
const parsed = parseDate(value);
if (parsed && predicate(parsed)) {
match.add(id);
}
}
return match;
}
function isAfter(
targetDate: readonly [number, number, number] | Dayjs,
referenceDate: readonly [number, number, number] | Dayjs
): boolean {
const [targetYear, targetMonth, targetDay] = isDayjs(targetDate)
? [targetDate.year(), targetDate.month(), targetDate.date()]
: targetDate;
const [refYear, refMonth, refDay] = isDayjs(referenceDate)
? [referenceDate.year(), referenceDate.month(), referenceDate.date()]
: referenceDate;
return (
targetYear >= refYear ||
(targetYear === refYear && targetMonth >= refMonth) ||
(targetYear === refYear && targetMonth === refMonth && targetDay >= refDay)
);
}
function isBefore(
targetDate: [number, number, number],
referenceDate: [number, number, number]
): boolean {
const [targetYear, targetMonth, targetDay] = targetDate;
const [refYear, refMonth, refDay] = referenceDate;
return (
targetYear <= refYear ||
(targetYear === refYear && targetMonth <= refMonth) ||
(targetYear === refYear && targetMonth === refMonth && targetDay <= refDay)
);
}
@@ -0,0 +1,46 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class DocPrimaryModeFilterProvider
extends Service
implements FilterProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'docPrimaryMode'>;
if (method === 'is') {
return this.docsService.propertyValues$('primaryMode').pipe(
map(values => {
const match = new Set<string>();
for (const [id, value] of values) {
if ((value ?? 'page') === params.value) {
match.add(id);
}
}
return match;
})
);
} else if (method === 'is-not') {
return this.docsService.propertyValues$('primaryMode').pipe(
map(values => {
const match = new Set<string>();
for (const [id, value] of values) {
if ((value ?? 'page') !== params.value) {
match.add(id);
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${params.method}`);
}
}
@@ -0,0 +1,43 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class JournalFilterProvider extends Service implements FilterProvider {
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'journal'>;
if (method === 'is') {
return this.docsService.propertyValues$('journal').pipe(
map(values => {
const match = new Set<string>();
for (const [id, value] of values) {
if (!!value === (params.value === 'true' ? true : false)) {
match.add(id);
}
}
return match;
})
);
} else if (method === 'is-not') {
return this.docsService.propertyValues$('journal').pipe(
map(values => {
const match = new Set<string>();
for (const [id, value] of values) {
if (!value === (params.value === 'true' ? true : false)) {
match.add(id);
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${params.method}`);
}
}
@@ -0,0 +1,34 @@
import type { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { type Observable, switchMap } from 'rxjs';
import { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class PropertyFilterProvider extends Service implements FilterProvider {
constructor(
private readonly workspacePropertyService: WorkspacePropertyService
) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const property$ = this.workspacePropertyService.propertyInfo$(params.key);
return property$.pipe(
switchMap(property => {
if (!property) {
throw new Error('Unknown property');
}
const type = property.type;
const provider = this.framework.getOptional(
FilterProvider('property:' + type)
);
if (!provider) {
throw new Error('Unsupported property type');
}
return provider.filter$(params);
})
);
}
}
@@ -0,0 +1,17 @@
import { Service } from '@toeverything/infra';
import { type Observable } from 'rxjs';
import { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class SystemFilterProvider extends Service implements FilterProvider {
filter$(params: FilterParams): Observable<Set<string>> {
const provider = this.framework.getOptional(
FilterProvider('system:' + params.key)
);
if (!provider) {
throw new Error('Unsupported system filter: ' + params.key);
}
return provider.filter$(params);
}
}
@@ -0,0 +1,75 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { TagService } from '@affine/core/modules/tag';
import { Service } from '@toeverything/infra';
import { combineLatest, map, type Observable, of, switchMap } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class TagsFilterProvider extends Service implements FilterProvider {
constructor(
private readonly tagService: TagService,
private readonly docsService: DocsService
) {
super();
}
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));
if (tags.length === 0) {
return of(new Set<string>());
}
return 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') {
return combineLatest([
this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))),
this.docsService.allDocsTagIds$(),
]).pipe(
map(
([tags, docs]) =>
new Set(
docs
.filter(
// filter deleted tags
// oxlint-disable-next-line prefer-array-some
doc => doc.tags.filter(tag => tags.has(tag)).length > 0
)
.map(doc => doc.id)
)
)
);
} else if (params.method === 'is-empty') {
return this.tagService.tagList.tags$
.map(tags => new Set(tags.map(t => t.id)))
.pipe(
switchMap(tags =>
this.docsService.allDocsTagIds$().pipe(
map(docs => {
return new Set(
docs
.filter(
// filter deleted tags
// oxlint-disable-next-lint prefer-array-some
doc => doc.tags.filter(tag => tags.has(tag)).length === 0
)
.map(doc => doc.id)
);
})
)
)
);
}
throw new Error(`Unsupported method: ${params.method}`);
}
}
@@ -0,0 +1,70 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class TextPropertyFilterProvider
extends Service
implements FilterProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'text'>;
if (method === 'is') {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value === params.value) {
match.add(id);
}
}
return match;
})
);
} else if (method === 'is-not') {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value !== params.value) {
match.add(id);
}
}
return match;
})
);
} else if (method === 'is-not-empty') {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value) {
match.add(id);
}
}
return match;
})
);
} else if (method === 'is-empty') {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (!value) {
match.add(id);
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${method}`);
}
}
@@ -0,0 +1,19 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
import { basicDateFilter } from './date';
export class UpdatedAtFilterProvider extends Service implements FilterProvider {
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
return this.docsService.allDocsUpdatedDate$().pipe(
map(docs => new Map(docs.map(doc => [doc.id, doc.updatedDate]))),
basicDateFilter(params)
);
}
}
@@ -0,0 +1,32 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class UpdatedByFilterProvider extends Service implements FilterProvider {
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'updatedBy'>;
if (method === 'include') {
const userIds = params.value?.split(',') ?? [];
return this.docsService.propertyValues$('updatedBy').pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value && userIds.includes(value)) {
match.add(id);
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${params.method}`);
}
}
@@ -0,0 +1,34 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class CheckboxPropertyGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(values => {
const result = new Map<string, Set<string>>();
for (const [id, value] of values) {
// Treat undefined or any non-true value as false for checkbox grouping
const v = value === 'true' ? 'true' : 'false';
const set = result.get(v) ?? new Set<string>();
set.add(id);
result.set(v, set);
}
return result;
})
);
}
}
@@ -0,0 +1,39 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class CreatedAtGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
_params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.allDocsCreatedDate$().pipe(
map(docs => {
const result = new Map<string, Set<string>>();
docs.forEach(doc => {
if (!doc.createDate) {
return;
}
const date = new Date(doc.createDate);
const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
if (!result.has(formattedDate)) {
result.set(formattedDate, new Set([doc.id]));
} else {
result.get(formattedDate)?.add(doc.id);
}
});
return result;
})
);
}
}
@@ -0,0 +1,34 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class CreatedByGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
_params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('createdBy').pipe(
map(o => {
const result = new Map<string, Set<string>>();
for (const [id, value] of o) {
if (value === undefined) {
continue;
}
const set = result.get(value) ?? new Set<string>();
set.add(id);
result.set(value, set);
}
return result;
})
);
}
}
@@ -0,0 +1,35 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class DatePropertyGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const result = new Map<string, Set<string>>();
for (const [id, value] of o) {
if (value === undefined) {
continue;
}
const set = result.get(value) ?? new Set<string>();
set.add(id);
result.set(value, set);
}
return result;
})
);
}
}
@@ -0,0 +1,35 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class DocPrimaryModeGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
_params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('primaryMode').pipe(
map(values => {
const result = new Map<string, Set<string>>();
for (const [id, value] of values) {
const mode = value ?? 'page';
if (!result.has(mode)) {
result.set(mode, new Set([id]));
} else {
result.get(mode)?.add(id);
}
}
return result;
})
);
}
}
@@ -0,0 +1,32 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class JournalGroupByProvider extends Service implements GroupByProvider {
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
_params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('journal').pipe(
map(values => {
const result = new Map<string, Set<string>>();
for (const [id, value] of values) {
const isJournal = value ? 'true' : 'false';
if (!result.has(isJournal)) {
result.set(isJournal, new Set([id]));
} else {
result.get(isJournal)?.add(id);
}
}
return result;
})
);
}
}
@@ -0,0 +1,41 @@
import type { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import { switchMap } from 'rxjs';
import { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class PropertyGroupByProvider
extends Service
implements GroupByProvider
{
constructor(
private readonly workspacePropertyService: WorkspacePropertyService
) {
super();
}
groupBy$(
items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>> {
const property$ = this.workspacePropertyService.propertyInfo$(params.key);
return property$.pipe(
switchMap(property => {
if (!property) {
throw new Error('Unknown property');
}
const type = property.type;
const provider = this.framework.getOptional(
GroupByProvider('property:' + type)
);
if (!provider) {
throw new Error('Unsupported property type');
}
return provider.groupBy$(items$, params);
})
);
}
}
@@ -0,0 +1,20 @@
import { Service } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class SystemGroupByProvider extends Service implements GroupByProvider {
groupBy$(
items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>> {
const provider = this.framework.getOptional(
GroupByProvider('system:' + params.key)
);
if (!provider) {
throw new Error('Unsupported system group by: ' + params.key);
}
return provider.groupBy$(items$, params);
}
}
@@ -0,0 +1,43 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { TagService } from '@affine/core/modules/tag';
import { Service } from '@toeverything/infra';
import { combineLatest, map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class TagsGroupByProvider extends Service implements GroupByProvider {
constructor(
private readonly docsService: DocsService,
private readonly tagService: TagService
) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
_params: GroupByParams
): Observable<Map<string, Set<string>>> {
return combineLatest([
this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))),
this.docsService.allDocsTagIds$(),
]).pipe(
map(([existsTags, docs]) => {
const map = new Map<string, Set<string>>();
for (const { id, tags } of docs) {
for (const tag of tags) {
if (!existsTags.has(tag)) {
continue;
}
const set = map.get(tag) ?? new Set();
set.add(id);
map.set(tag, set);
}
}
return map;
})
);
}
}
@@ -0,0 +1,35 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class TextPropertyGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const result = new Map<string, Set<string>>();
for (const [id, value] of o) {
if (value === undefined) {
continue;
}
const set = result.get(value) ?? new Set<string>();
set.add(id);
result.set(value, set);
}
return result;
})
);
}
}
@@ -0,0 +1,40 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class UpdatedAtGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
// not used in this implementation
_items$: Observable<Set<string>>,
_params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.allDocsUpdatedDate$().pipe(
map(docs => {
const result = new Map<string, Set<string>>();
docs.forEach(doc => {
if (!doc.updatedDate) {
return;
}
const date = new Date(doc.updatedDate);
const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
if (!result.has(formattedDate)) {
result.set(formattedDate, new Set([doc.id]));
} else {
result.get(formattedDate)?.add(doc.id);
}
});
return result;
})
);
}
}
@@ -0,0 +1,34 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class UpdatedByGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
_params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('updatedBy').pipe(
map(o => {
const result = new Map<string, Set<string>>();
for (const [id, value] of o) {
if (value === undefined) {
continue;
}
const set = result.get(value) ?? new Set<string>();
set.add(id);
result.set(value, set);
}
return result;
})
);
}
}
@@ -0,0 +1,38 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { combineLatest, map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class CheckboxPropertyOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return combineLatest([
this.docsService.list.docs$, // We need the complete doc list as docs without property values should default to false
this.docsService.propertyValues$('custom:' + params.key),
]).pipe(
map(([docs, values]) => {
const result: [string, boolean][] = [];
for (const doc of docs) {
const value = values.get(doc.id) === 'true' ? true : false;
result.push([doc.id, value]);
}
return result
.sort(
(a, b) => (a[1] === b[1] ? 0 : a[1] ? 1 : -1) * (isDesc ? -1 : 1)
)
.map(i => i[0]);
})
);
}
}
@@ -0,0 +1,33 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class CreatedAtOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
return this.docsService.allDocsCreatedDate$().pipe(
map(docs => {
if (params.desc) {
return docs
.sort((a, b) => (b.createDate ?? 0) - (a.createDate ?? 0))
.map(doc => doc.id);
} else {
return docs
.sort((a, b) => (a.createDate ?? 0) - (b.createDate ?? 0))
.map(doc => doc.id);
}
})
);
}
}
@@ -0,0 +1,32 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class CreatedByOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return this.docsService.propertyValues$('createdBy').pipe(
map(o => {
return Array.from(o)
.filter((i): i is [string, string] => !!i[1]) // filter empty value
.sort(
(a, b) =>
(a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1)
)
.map(i => i[0]);
})
);
}
}
@@ -0,0 +1,32 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class DatePropertyOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
return Array.from(o)
.filter((i): i is [string, string] => !!i[1]) // filter empty value
.sort(
(a, b) =>
(a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1)
)
.map(i => i[0]);
})
);
}
}
@@ -0,0 +1,39 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class DocPrimaryModeOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
return this.docsService.propertyValues$('primaryMode').pipe(
map(values => {
const docs = Array.from(values).map(([id, value]) => ({
id,
mode: value ?? 'page',
}));
if (params.desc) {
return docs
.sort((a, b) => b.mode.localeCompare(a.mode))
.map(doc => doc.id);
} else {
return docs
.sort((a, b) => a.mode.localeCompare(b.mode))
.map(doc => doc.id);
}
})
);
}
}
@@ -0,0 +1,35 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class JournalOrderByProvider extends Service implements OrderByProvider {
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return this.docsService.propertyValues$('journal').pipe(
map(values => {
return Array.from(values)
.map(([id, value]) => ({
id,
isJournal: !!value,
}))
.sort((a, b) => {
if (a.isJournal === b.isJournal) {
return 0;
}
return (a.isJournal ? 1 : -1) * (isDesc ? -1 : 1);
})
.map(doc => doc.id);
})
);
}
}
@@ -0,0 +1,40 @@
import type { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { type Observable, switchMap } from 'rxjs';
import { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class PropertyOrderByProvider
extends Service
implements OrderByProvider
{
constructor(
private readonly workspacePropertyService: WorkspacePropertyService
) {
super();
}
orderBy$(
items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const property$ = this.workspacePropertyService.propertyInfo$(params.key);
return property$.pipe(
switchMap(property => {
if (!property) {
throw new Error('Unknown property');
}
const type = property.type;
const provider = this.framework.getOptional(
OrderByProvider('property:' + type)
);
if (!provider) {
throw new Error('Unsupported property type');
}
return provider.orderBy$(items$, params);
})
);
}
}
@@ -0,0 +1,20 @@
import { Service } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class SystemOrderByProvider extends Service implements OrderByProvider {
orderBy$(
items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const provider = this.framework.getOptional(
OrderByProvider('system:' + params.key)
);
if (!provider) {
throw new Error('Unsupported system order by: ' + params.key);
}
return provider.orderBy$(items$, params);
}
}
@@ -0,0 +1,43 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { TagService } from '@affine/core/modules/tag';
import { Service } from '@toeverything/infra';
import { combineLatest, map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class TagsOrderByProvider extends Service implements OrderByProvider {
constructor(
private readonly docsService: DocsService,
private readonly tagService: TagService
) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return combineLatest([
this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))),
this.docsService.allDocsTagIds$(),
]).pipe(
map(([existsTags, docs]) =>
docs
.map(doc => {
const filteredTags = doc.tags
.filter(tag => existsTags.has(tag)) // filter out tags that don't exist
.sort() // sort tags by ids
.join(','); // convert to string
return [doc.id, filteredTags];
})
.sort(
(a, b) =>
(a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1)
)
.map(i => i[0])
)
);
}
}
@@ -0,0 +1,32 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class TextPropertyOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
return Array.from(o)
.filter((i): i is [string, string] => !!i[1]) // filter empty value
.sort(
(a, b) =>
(a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1)
)
.map(i => i[0]);
})
);
}
}
@@ -0,0 +1,35 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class UpdatedAtOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
return this.docsService.allDocsUpdatedDate$().pipe(
map(docs => {
if (params.desc) {
return docs
.filter(doc => doc.updatedDate)
.sort((a, b) => (b.updatedDate ?? 0) - (a.updatedDate ?? 0))
.map(doc => doc.id);
} else {
return docs
.filter(doc => doc.updatedDate)
.sort((a, b) => (a.updatedDate ?? 0) - (b.updatedDate ?? 0))
.map(doc => doc.id);
}
})
);
}
}
@@ -0,0 +1,32 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class UpdatedByOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return this.docsService.propertyValues$('updatedBy').pipe(
map(o => {
return Array.from(o)
.filter((i): i is [string, string] => !!i[1]) // filter empty value
.sort(
(a, b) =>
(a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1)
)
.map(i => i[0]);
})
);
}
}
@@ -0,0 +1,243 @@
import type { Framework } from '@toeverything/infra';
import { DocsService } from '../doc';
import { TagService } from '../tag';
import { WorkspaceScope } from '../workspace';
import { WorkspacePropertyService } from '../workspace-property';
import { CheckboxPropertyFilterProvider } from './impls/filters/checkbox';
import { CreatedAtFilterProvider } from './impls/filters/created-at';
import { CreatedByFilterProvider } from './impls/filters/created-by';
import { DatePropertyFilterProvider } from './impls/filters/date';
import { DocPrimaryModeFilterProvider } from './impls/filters/doc-primary-mode';
import { JournalFilterProvider } from './impls/filters/journal';
import { PropertyFilterProvider } from './impls/filters/property';
import { SystemFilterProvider } from './impls/filters/system';
import { TagsFilterProvider } from './impls/filters/tags';
import { TextPropertyFilterProvider } from './impls/filters/text';
import { UpdatedAtFilterProvider } from './impls/filters/updated-at';
import { UpdatedByFilterProvider } from './impls/filters/updated-by';
import { CheckboxPropertyGroupByProvider } from './impls/group-by/checkbox';
import { CreatedAtGroupByProvider } from './impls/group-by/created-at';
import { CreatedByGroupByProvider } from './impls/group-by/created-by';
import { DatePropertyGroupByProvider } from './impls/group-by/date';
import { DocPrimaryModeGroupByProvider } from './impls/group-by/doc-primary-mode';
import { JournalGroupByProvider } from './impls/group-by/journal';
import { PropertyGroupByProvider } from './impls/group-by/property';
import { SystemGroupByProvider } from './impls/group-by/system';
import { TagsGroupByProvider } from './impls/group-by/tags';
import { TextPropertyGroupByProvider } from './impls/group-by/text';
import { UpdatedAtGroupByProvider } from './impls/group-by/updated-at';
import { UpdatedByGroupByProvider } from './impls/group-by/updated-by';
import { CheckboxPropertyOrderByProvider } from './impls/order-by/checkbox';
import { CreatedAtOrderByProvider } from './impls/order-by/created-at';
import { CreatedByOrderByProvider } from './impls/order-by/created-by';
import { DatePropertyOrderByProvider } from './impls/order-by/date';
import { DocPrimaryModeOrderByProvider } from './impls/order-by/doc-primary-mode';
import { JournalOrderByProvider } from './impls/order-by/journal';
import { PropertyOrderByProvider } from './impls/order-by/property';
import { SystemOrderByProvider } from './impls/order-by/system';
import { TagsOrderByProvider } from './impls/order-by/tags';
import { TextPropertyOrderByProvider } from './impls/order-by/text';
import { UpdatedAtOrderByProvider } from './impls/order-by/updated-at';
import { UpdatedByOrderByProvider } from './impls/order-by/updated-by';
import { FilterProvider, GroupByProvider, OrderByProvider } from './provider';
import { CollectionRulesService } from './services/collection-rules';
export { CollectionRulesService } from './services/collection-rules';
export type { FilterParams } from './types';
export function configureCollectionRulesModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(CollectionRulesService)
// --------------- Filter ---------------
.impl(FilterProvider('system'), SystemFilterProvider)
.impl(FilterProvider('property'), PropertyFilterProvider, [
WorkspacePropertyService,
])
.impl(FilterProvider('property:checkbox'), CheckboxPropertyFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:text'), TextPropertyFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:tags'), TagsFilterProvider, [
TagService,
DocsService,
])
.impl(FilterProvider('system:tags'), TagsFilterProvider, [
TagService,
DocsService,
])
.impl(
FilterProvider('property:docPrimaryMode'),
DocPrimaryModeFilterProvider,
[DocsService]
)
.impl(
FilterProvider('system:docPrimaryMode'),
DocPrimaryModeFilterProvider,
[DocsService]
)
.impl(FilterProvider('property:date'), DatePropertyFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:createdAt'), CreatedAtFilterProvider, [
DocsService,
])
.impl(FilterProvider('system:createdAt'), CreatedAtFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:updatedAt'), UpdatedAtFilterProvider, [
DocsService,
])
.impl(FilterProvider('system:updatedAt'), UpdatedAtFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:journal'), JournalFilterProvider, [
DocsService,
])
.impl(FilterProvider('system:journal'), JournalFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:createdBy'), CreatedByFilterProvider, [
DocsService,
])
.impl(FilterProvider('system:createdBy'), CreatedByFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:updatedBy'), UpdatedByFilterProvider, [
DocsService,
])
.impl(FilterProvider('system:updatedBy'), UpdatedByFilterProvider, [
DocsService,
])
// --------------- Group By ---------------
.impl(GroupByProvider('system'), SystemGroupByProvider)
.impl(GroupByProvider('property'), PropertyGroupByProvider, [
WorkspacePropertyService,
])
.impl(GroupByProvider('property:date'), DatePropertyGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('property:tags'), TagsGroupByProvider, [
DocsService,
TagService,
])
.impl(GroupByProvider('system:tags'), TagsGroupByProvider, [
DocsService,
TagService,
])
.impl(
GroupByProvider('property:checkbox'),
CheckboxPropertyGroupByProvider,
[DocsService]
)
.impl(GroupByProvider('property:text'), TextPropertyGroupByProvider, [
DocsService,
])
.impl(
GroupByProvider('property:docPrimaryMode'),
DocPrimaryModeGroupByProvider,
[DocsService]
)
.impl(
GroupByProvider('system:docPrimaryMode'),
DocPrimaryModeGroupByProvider,
[DocsService]
)
.impl(GroupByProvider('property:createdAt'), CreatedAtGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('system:createdAt'), CreatedAtGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('property:updatedAt'), UpdatedAtGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('system:updatedAt'), UpdatedAtGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('property:journal'), JournalGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('system:journal'), JournalGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('property:createdBy'), CreatedByGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('system:createdBy'), CreatedByGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('property:updatedBy'), UpdatedByGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('system:updatedBy'), UpdatedByGroupByProvider, [
DocsService,
])
// --------------- Order By ---------------
.impl(OrderByProvider('system'), SystemOrderByProvider)
.impl(OrderByProvider('property'), PropertyOrderByProvider, [
WorkspacePropertyService,
])
.impl(
OrderByProvider('property:docPrimaryMode'),
DocPrimaryModeOrderByProvider,
[DocsService]
)
.impl(
OrderByProvider('system:docPrimaryMode'),
DocPrimaryModeOrderByProvider,
[DocsService]
)
.impl(OrderByProvider('property:updatedAt'), UpdatedAtOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('system:updatedAt'), UpdatedAtOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:createdAt'), CreatedAtOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('system:createdAt'), CreatedAtOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:text'), TextPropertyOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:date'), DatePropertyOrderByProvider, [
DocsService,
])
.impl(
OrderByProvider('property:checkbox'),
CheckboxPropertyOrderByProvider,
[DocsService]
)
.impl(OrderByProvider('property:tags'), TagsOrderByProvider, [
DocsService,
TagService,
])
.impl(OrderByProvider('system:tags'), TagsOrderByProvider, [
DocsService,
TagService,
])
.impl(OrderByProvider('property:journal'), JournalOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('system:journal'), JournalOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:createdBy'), CreatedByOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('system:createdBy'), CreatedByOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:updatedBy'), UpdatedByOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('system:updatedBy'), UpdatedByOrderByProvider, [
DocsService,
]);
}
@@ -0,0 +1,31 @@
import { createIdentifier } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import type { FilterParams, GroupByParams, OrderByParams } from '../types';
export interface FilterProvider {
filter$(params: FilterParams): Observable<Set<string>>;
}
export const FilterProvider =
createIdentifier<FilterProvider>('FilterProvider');
export interface GroupByProvider {
groupBy$(
items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>>;
}
export const GroupByProvider =
createIdentifier<GroupByProvider>('GroupByProvider');
export interface OrderByProvider {
orderBy$(
items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]>;
}
export const OrderByProvider =
createIdentifier<OrderByProvider>('OrderByProvider');
@@ -0,0 +1,207 @@
import { Service } from '@toeverything/infra';
import {
catchError,
combineLatest,
distinctUntilChanged,
map,
type Observable,
of,
share,
throttleTime,
} from 'rxjs';
import { FilterProvider, GroupByProvider, OrderByProvider } from '../provider';
import type { FilterParams, GroupByParams, OrderByParams } from '../types';
export class CollectionRulesService extends Service {
constructor() {
super();
}
watch(
filters: FilterParams[],
groupBy?: GroupByParams,
orderBy?: OrderByParams
): Observable<{
groups: {
key: string;
items: string[];
}[];
filterErrors: any[];
}> {
// STEP 1: FILTER
const filterProviders = this.framework.getAll(FilterProvider);
const filtered$: Observable<{
filtered: Set<string>;
filterErrors: any[]; // errors from the filter providers
}> =
filters.length === 0
? of({ filtered: new Set<string>(), filterErrors: [] })
: combineLatest(
filters.map(filter => {
const provider = filterProviders.get(filter.type);
if (!provider) {
return of({
error: new Error(`Unsupported filter type: ${filter.type}`),
});
}
return provider.filter$(filter).pipe(
distinctUntilChanged((prev, curr) => {
return prev.isSubsetOf(curr) && curr.isSubsetOf(prev);
}),
catchError(error => {
console.log(error);
return of({ error });
})
);
})
).pipe(
map(results => {
const finalSet = results.reduce((acc, result) => {
if ('error' in acc) {
return acc;
}
if ('error' in result) {
return acc;
}
return acc.intersection(result);
});
return {
filtered: 'error' in finalSet ? new Set<string>() : finalSet,
filterErrors: results.map(i => ('error' in i ? i.error : null)),
};
})
);
// STEP 2: ORDER BY
const orderByProvider = orderBy
? this.framework.getOptional(OrderByProvider(orderBy.type))
: null;
const ordered$: Observable<{
ordered: string[];
filtered: Set<string>;
filterErrors: any[];
}> = filtered$.pipe(last$ => {
if (orderBy && orderByProvider) {
const shared$ = last$.pipe(share());
const items$ = shared$.pipe(
map(i => i.filtered),
// avoid re-ordering the same items
distinctUntilChanged((prev, curr) => {
return prev.isSubsetOf(curr) && curr.isSubsetOf(prev);
})
);
return combineLatest([
orderByProvider.orderBy$(items$, orderBy),
shared$,
]).pipe(
map(([ordered, last]) => {
return {
ordered: Array.from(ordered),
...last,
};
})
);
}
return last$.pipe(
map(last => ({
ordered: Array.from(last.filtered),
...last,
}))
);
});
// STEP 3: GROUP BY
const groupByProvider = groupBy
? this.framework.getOptional(GroupByProvider(groupBy.type))
: null;
const grouped$: Observable<{
grouped: Map<string, Set<string>>;
ordered: string[];
filtered: Set<string>;
filterErrors: any[];
}> = ordered$.pipe(last$ => {
if (groupBy && groupByProvider) {
const shared$ = last$.pipe(share());
const items$ = shared$.pipe(
map(i => i.filtered),
// avoid re-grouping the same items
distinctUntilChanged((prev, curr) => {
return prev.isSubsetOf(curr) && curr.isSubsetOf(prev);
})
);
return combineLatest([
groupByProvider.groupBy$(items$, groupBy),
shared$,
]).pipe(
map(([grouped, last]) => {
return {
grouped: grouped,
...last,
};
})
);
}
return last$.pipe(
map(last => ({
grouped: new Map<string, Set<string>>([['', last.filtered]]),
...last,
}))
);
});
// STEP 4: Merge the results
const final$: Observable<{
groups: {
key: string;
items: string[];
}[];
filterErrors: any[];
}> = grouped$.pipe(
throttleTime(500), // throttle the results to avoid too many re-renders
map(({ grouped, ordered, filtered, filterErrors }) => {
const result: { key: string; items: string[] }[] = [];
function addToResult(key: string, item: string) {
const existing = result.find(i => i.key === key);
if (existing) {
existing.items.push(item);
} else {
result.push({ key: key, items: [item] });
}
}
// this step ensures that all filtered items are present in ordered
const finalOrdered = new Set(ordered.concat(Array.from(filtered)));
for (const item of finalOrdered) {
const included = filtered.has(item);
if (!included) {
continue;
}
const groups: string[] = [];
for (const [group, items] of grouped) {
if (items.has(item)) {
groups.push(group);
}
}
if (groups.length === 0) {
// ungrouped items
addToResult('', item);
} else {
for (const group of groups) {
addToResult(group, item);
}
}
}
return { groups: result, filterErrors };
})
);
return final$;
}
}
@@ -0,0 +1,17 @@
export interface FilterParams {
type: string;
key: string;
method: string;
value?: string;
}
export interface GroupByParams {
type: string;
key: string;
}
export interface OrderByParams {
type: string;
key: string;
desc?: boolean;
}
@@ -41,6 +41,9 @@ export class WorkspaceDBTable<
find = this.table.find.bind(this.table) as typeof this.table.find;
// eslint-disable-next-line rxjs/finnish
find$ = this.table.find$.bind(this.table) as typeof this.table.find$;
select = this.table.select.bind(this.table) as typeof this.table.select;
// eslint-disable-next-line rxjs/finnish
select$ = this.table.select$.bind(this.table) as typeof this.table.select$;
keys = this.table.keys.bind(this.table) as typeof this.table.keys;
delete = this.table.delete.bind(this.table) as typeof this.table.delete;
}
@@ -1,11 +1,14 @@
import {
type DBSchemaBuilder,
f,
type FieldSchemaBuilder,
type ORMEntity,
t,
} from '@toeverything/infra';
import { nanoid } from 'nanoid';
import type { WorkspacePropertyType } from '../../workspace-property';
const integrationType = f.enum('readwise', 'zotero');
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
@@ -31,7 +34,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
docCustomPropertyInfo: {
id: f.string().primaryKey().optional().default(nanoid),
name: f.string().optional(),
type: f.string(),
type: f.string() as FieldSchemaBuilder<WorkspacePropertyType, false, false>,
show: f.enum('always-show', 'always-hide', 'hide-when-empty').optional(),
index: f.string().optional(),
icon: f.string().optional(),
@@ -1,4 +1,4 @@
import { CheckboxValue } from '@affine/core/components/doc-properties/types/checkbox';
import { CheckboxValue } from '@affine/core/components/workspace-property-types/checkbox';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
@@ -1,4 +1,4 @@
import { DateValue } from '@affine/core/components/doc-properties/types/date';
import { DateValue } from '@affine/core/components/workspace-property-types/date';
import type { LiveData } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import dayjs from 'dayjs';
@@ -1,4 +1,4 @@
import { NumberValue } from '@affine/core/components/doc-properties/types/number';
import { NumberValue } from '@affine/core/components/workspace-property-types/number';
import { useLiveData } from '@toeverything/infra';
import type { DatabaseCellRendererProps } from '../../../types';
@@ -115,7 +115,7 @@ const adapter = {
...options,
{
id: newTag.id,
value: newTag.value,
value: newTag.name,
color: newTag.color,
},
]);
@@ -156,9 +156,10 @@ const BlocksuiteDatabaseSelector = ({
let tagOptions = useLiveData(adapter.getTagOptions$(selectCell));
// adapt bs database old tag color to new tag color
tagOptions = useMemo(() => {
let adaptedTagOptions = useMemo(() => {
return tagOptions.map(tag => ({
...tag,
id: tag.id,
name: tag.value,
color: databaseTagColorToV2(tag.color),
}));
}, [tagOptions]);
@@ -168,7 +169,7 @@ const BlocksuiteDatabaseSelector = ({
// bs database uses --affine-tag-xxx colors
const newTag = {
id: nanoid(),
value: name,
name: name,
color: color,
};
adapter.createTag(selectCell, dataSource, newTag);
@@ -228,7 +229,7 @@ const BlocksuiteDatabaseSelector = ({
<TagsInlineEditor
tagMode="db-label"
className={styles.tagInlineEditor}
tags={tagOptions}
tags={adaptedTagOptions}
selectedTags={selectedIds}
onCreateTag={onCreateTag}
onDeleteTag={onDeleteTag}
@@ -11,7 +11,6 @@ import type { Framework } from '@toeverything/infra';
import { WorkspaceDBService } from '../db/services/db';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { Doc } from './entities/doc';
import { DocPropertyList } from './entities/property-list';
import { DocRecord } from './entities/record';
import { DocRecordList } from './entities/record-list';
import { DocCreateMiddleware } from './providers/doc-create-middleware';
@@ -35,7 +34,6 @@ export function configureDocModule(framework: Framework) {
.store(DocsStore, [WorkspaceService, DocPropertiesStore])
.entity(DocRecord, [DocsStore, DocPropertiesStore])
.entity(DocRecordList, [DocsStore])
.entity(DocPropertyList, [DocPropertiesStore])
.scope(DocScope)
.entity(Doc, [DocScope, DocsStore, WorkspaceService])
.service(DocService);
@@ -4,15 +4,12 @@ import { replaceIdMiddleware } from '@blocksuite/affine/shared/adapters';
import type { AffineTextAttributes } from '@blocksuite/affine/shared/types';
import type { DeltaInsert } from '@blocksuite/affine/store';
import { Slice, Text, Transformer } from '@blocksuite/affine/store';
import { LiveData, ObjectPool, Service } from '@toeverything/infra';
import { omitBy } from 'lodash-es';
import { ObjectPool, Service } from '@toeverything/infra';
import { combineLatest, map } from 'rxjs';
import { initDocFromProps } from '../../../blocksuite/initialization';
import type { DocProperties } from '../../db';
import { getAFFiNEWorkspaceSchema } from '../../workspace';
import type { Doc } from '../entities/doc';
import { DocPropertyList } from '../entities/property-list';
import { DocRecordList } from '../entities/record-list';
import { DocCreated, DocInitialized } from '../events';
import type { DocCreateMiddleware } from '../providers/doc-create-middleware';
@@ -33,26 +30,44 @@ export class DocsService extends Service {
},
});
propertyList = this.framework.createEntity(DocPropertyList);
/**
* Get all property values of a property, used for search
*
* Results may include docs in trash or deleted docs
* Legacy property data such as old `journal` will not be included in the values
*/
propertyValues$(propertyKey: string) {
return combineLatest([
this.store.watchDocIds(),
this.docPropertiesStore.watchPropertyAllValues(propertyKey),
]).pipe(
map(([docIds, propertyValues]) => {
const result = new Map<string, string | undefined>();
for (const docId of docIds) {
result.set(docId, propertyValues.get(docId));
}
return result;
})
);
}
/**
* used for search doc by properties, for convenience of search, all non-exist doc or trash doc have been filtered
* used for search
*/
allDocProperties$: LiveData<Record<string, DocProperties>> = LiveData.from(
combineLatest([
this.docPropertiesStore.watchAllDocProperties(),
this.store.watchNonTrashDocIds(),
]).pipe(
map(([properties, docIds]) => {
const allIds = new Set(docIds);
return omitBy(
properties as Record<string, DocProperties>,
(_, id) => !allIds.has(id)
);
})
),
{}
);
allDocsCreatedDate$() {
return this.store.watchAllDocCreateDate();
}
/**
* used for search
*/
allDocsUpdatedDate$() {
return this.store.watchAllDocUpdatedDate();
}
allDocsTagIds$() {
return this.store.watchAllDocTagIds();
}
constructor(
private readonly store: DocsStore,
@@ -1,33 +1,22 @@
import { Store, yjsObserveByPath, yjsObserveDeep } from '@toeverything/infra';
import { differenceBy, isNil, omitBy } from 'lodash-es';
import {
LiveData,
Store,
yjsGetPath,
yjsObserveDeep,
} from '@toeverything/infra';
import { isNil, omitBy } from 'lodash-es';
import { combineLatest, map, switchMap } from 'rxjs';
import { AbstractType as YAbstractType } from 'yjs';
import type { WorkspaceDBService } from '../../db';
import type {
DocCustomPropertyInfo,
DocProperties,
} from '../../db/schema/schema';
import type { DocProperties } from '../../db/schema/schema';
import type { WorkspaceService } from '../../workspace';
import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants';
interface LegacyDocProperties {
custom?: Record<string, { value: unknown } | undefined>;
system?: Record<string, { value: unknown } | undefined>;
}
type LegacyDocPropertyInfo = {
id?: string;
name?: string;
type?: string;
icon?: string;
};
type LegacyDocPropertyInfoList = Record<
string,
LegacyDocPropertyInfo | undefined
>;
export class DocPropertiesStore extends Store {
constructor(
private readonly workspaceService: WorkspaceService,
@@ -43,92 +32,6 @@ export class DocPropertiesStore extends Store {
});
}
getDocPropertyInfoList() {
const db = this.dbService.db.docCustomPropertyInfo.find();
const legacy = this.upgradeLegacyDocPropertyInfoList(
this.getLegacyDocPropertyInfoList()
);
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
}
createDocPropertyInfo(
config: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
) {
return this.dbService.db.docCustomPropertyInfo.create(config);
}
removeDocPropertyInfo(id: string) {
this.updateDocPropertyInfo(id, {
additionalData: {}, // also remove additional data to reduce size
isDeleted: true,
});
}
updateDocPropertyInfo(id: string, config: Partial<DocCustomPropertyInfo>) {
const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id);
const isBuiltIn =
needMigration && BUILT_IN_CUSTOM_PROPERTY_TYPE.some(i => i.id === id);
if (isBuiltIn) {
this.createPropertyFromBuiltIn(id, config);
} else if (needMigration) {
// if this property is not in db, we need to migration it from legacy to db, only type and name is needed
this.migrateLegacyDocPropertyInfo(id, config);
} else {
this.dbService.db.docCustomPropertyInfo.update(id, config);
}
}
migrateLegacyDocPropertyInfo(
id: string,
override: Partial<DocCustomPropertyInfo>
) {
const legacy = this.getLegacyDocPropertyInfo(id);
this.dbService.db.docCustomPropertyInfo.create({
id,
type:
legacy?.type ??
'unknown' /* should never reach here, just for safety, we need handle unknown property type */,
name: legacy?.name,
...override,
});
}
createPropertyFromBuiltIn(
id: string,
override: Partial<DocCustomPropertyInfo>
) {
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE.find(i => i.id === id);
if (!builtIn) {
return;
}
this.createDocPropertyInfo({ ...builtIn, ...override });
}
watchDocPropertyInfoList() {
return combineLatest([
this.watchLegacyDocPropertyInfoList().pipe(
map(this.upgradeLegacyDocPropertyInfoList)
),
this.dbService.db.docCustomPropertyInfo.find$(),
]).pipe(
map(([legacy, db]) => {
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
})
);
}
getDocProperties(id: string) {
return {
...this.upgradeLegacyDocProperties(this.getLegacyDocProperties(id)),
@@ -137,28 +40,6 @@ export class DocPropertiesStore extends Store {
};
}
watchAllDocProperties() {
const allDocProperties$ = this.dbService.db.docProperties.find$();
const allLegacyDocProperties$ = this.watchAllLegacyDocProperties();
return combineLatest([allDocProperties$, allLegacyDocProperties$]).pipe(
map(([db, legacy]) => {
const map = new Map(db.map(i => [i.id, i]));
const allIds = new Set([...map.keys(), ...Object.keys(legacy ?? {})]);
const result = {} as Record<string, Record<string, any>>;
for (const id of allIds) {
result[id] = {
...this.upgradeLegacyDocProperties(legacy?.[id]),
...omitBy(map.get(id), isNil),
};
}
return result;
})
);
}
watchDocProperties(id: string) {
return combineLatest([
this.watchLegacyDocProperties(id).pipe(
@@ -176,6 +57,20 @@ export class DocPropertiesStore extends Store {
);
}
/**
* find doc ids by property key and value
*
* this apis will not include legacy properties
*/
watchPropertyAllValues(propertyKey: string) {
return LiveData.from<Map<string, string | undefined>>(
this.dbService.db.docProperties
.select$(propertyKey)
.pipe(map(o => new Map(o.map(i => [i.id, i[propertyKey]])))),
new Map()
);
}
private upgradeLegacyDocProperties(properties?: LegacyDocProperties) {
if (!properties) {
return {};
@@ -194,29 +89,6 @@ export class DocPropertiesStore extends Store {
return newProperties;
}
private upgradeLegacyDocPropertyInfoList(
infoList?: LegacyDocPropertyInfoList
) {
if (!infoList) {
return [];
}
const newInfoList: DocCustomPropertyInfo[] = [];
for (const [id, info] of Object.entries(infoList ?? {})) {
if (info?.type) {
newInfoList.push({
id,
name: info.name,
type: info.type,
icon: info.icon,
});
}
}
return newInfoList;
}
private getLegacyDocProperties(id: string) {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
@@ -225,25 +97,8 @@ export class DocPropertiesStore extends Store {
?.toJSON() as LegacyDocProperties | undefined;
}
private watchAllLegacyDocProperties() {
return yjsObserveByPath(
this.workspaceService.workspace.rootYDoc.getMap<any>(
'affine:workspace-properties'
),
`pageProperties`
).pipe(
switchMap(yjsObserveDeep),
map(
p =>
(p instanceof YAbstractType ? p.toJSON() : p) as
| { [docId: string]: LegacyDocProperties }
| undefined
)
);
}
private watchLegacyDocProperties(id: string) {
return yjsObserveByPath(
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap<any>(
'affine:workspace-properties'
),
@@ -258,40 +113,4 @@ export class DocPropertiesStore extends Store {
)
);
}
private getLegacyDocPropertyInfoList() {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
.get('schema')
?.get('pageProperties')
?.get('custom')
?.toJSON() as LegacyDocPropertyInfoList | undefined;
}
private watchLegacyDocPropertyInfoList() {
return yjsObserveByPath(
this.workspaceService.workspace.rootYDoc.getMap<any>(
'affine:workspace-properties'
),
'schema.pageProperties.custom'
).pipe(
switchMap(yjsObserveDeep),
map(
p =>
(p instanceof YAbstractType ? p.toJSON() : p) as
| LegacyDocPropertyInfoList
| undefined
)
);
}
private getLegacyDocPropertyInfo(id: string) {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
.get('schema')
?.get('pageProperties')
?.get('custom')
?.get(id)
?.toJSON() as LegacyDocPropertyInfo | undefined;
}
}
@@ -2,9 +2,10 @@ import type { DocMode } from '@blocksuite/affine/model';
import type { DocMeta } from '@blocksuite/affine/store';
import {
Store,
yjsGetPath,
yjsObserve,
yjsObserveByPath,
yjsObserveDeep,
yjsObservePath,
} from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { distinctUntilChanged, map, switchMap } from 'rxjs';
@@ -63,7 +64,7 @@ export class DocsStore extends Store {
}
watchDocIds() {
return yjsObserveByPath(
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
@@ -78,8 +79,65 @@ export class DocsStore extends Store {
);
}
watchAllDocUpdatedDate() {
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(pages => yjsObservePath(pages, '*.updatedDate')),
map(pages => {
if (pages instanceof YArray) {
return pages.map(v => ({
id: v.get('id') as string,
updatedDate: v.get('updatedDate') as number | undefined,
}));
} else {
return [];
}
})
);
}
watchAllDocTagIds() {
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(pages => yjsObservePath(pages, '*.tags')),
map(pages => {
if (pages instanceof YArray) {
return pages.map(v => ({
id: v.get('id') as string,
tags: (v.get('tags')?.toJSON() ?? []) as string[],
}));
} else {
return [];
}
})
);
}
watchAllDocCreateDate() {
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(pages => yjsObservePath(pages, '*.createDate')),
map(pages => {
if (pages instanceof YArray) {
return pages.map(v => ({
id: v.get('id') as string,
createDate: (v.get('createDate') ?? 0) as number,
}));
} else {
return [];
}
})
);
}
watchNonTrashDocIds() {
return yjsObserveByPath(
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
@@ -97,7 +155,7 @@ export class DocsStore extends Store {
}
watchTrashDocIds() {
return yjsObserveByPath(
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
@@ -116,7 +174,7 @@ export class DocsStore extends Store {
watchDocMeta(id: string) {
let docMetaIndexCache = -1;
return yjsObserveByPath(
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
@@ -1,5 +1,5 @@
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
import type { DefaultOpenProperty } from '@affine/core/components/doc-properties';
import type { DefaultOpenProperty } from '@affine/core/components/properties';
import { PresentTool } from '@blocksuite/affine/blocks/frame';
import { DefaultTool } from '@blocksuite/affine/blocks/surface';
import type { DocTitle } from '@blocksuite/affine/fragments/doc-title';
@@ -11,6 +11,7 @@ import { configAtMenuConfigModule } from './at-menu-config';
import { configureBlobManagementModule } from './blob-management';
import { configureCloudModule } from './cloud';
import { configureCollectionModule } from './collection';
import { configureCollectionRulesModule } from './collection-rules';
import { configureWorkspaceDBModule } from './db';
import { configureDialogModule } from './dialogs';
import { configureDndModule } from './dnd';
@@ -56,6 +57,7 @@ import { configureThemeEditorModule } from './theme-editor';
import { configureUrlModule } from './url';
import { configureUserspaceModule } from './userspace';
import { configureWorkspaceModule } from './workspace';
import { configureWorkspacePropertyModule } from './workspace-property';
export function configureCommonModules(framework: Framework) {
configureI18nModule(framework);
@@ -110,4 +112,6 @@ export function configureCommonModules(framework: Framework) {
configureImportClipperModule(framework);
configureNotificationModule(framework);
configureIntegrationModule(framework);
configureWorkspacePropertyModule(framework);
configureCollectionRulesModule(framework);
}
@@ -1,4 +1,4 @@
import type { DefaultOpenProperty } from '@affine/core/components/doc-properties';
import type { DefaultOpenProperty } from '@affine/core/components/properties';
import type { DocMode } from '@blocksuite/affine/model';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
@@ -66,6 +66,7 @@ export class MemberSearchService extends Service {
}
search(searchText?: string) {
console.log('search', searchText);
this.reset();
this.searchText$.setValue(searchText ?? '');
this.loadMore();
@@ -262,12 +262,12 @@ export class SearchMenuService extends Service {
query,
}),
items: result.map(item => {
const title = this.highlightFuseTitle(
const name = this.highlightFuseTitle(
item.matches,
item.item.title,
'title'
item.item.name,
'name'
);
return this.toTagMenuItem({ ...item.item, title }, action);
return this.toTagMenuItem({ ...item.item, name }, action);
}),
};
}
@@ -285,7 +285,7 @@ export class SearchMenuService extends Service {
`;
return {
key: tag.id,
name: html`${unsafeHTML(tag.title)}`,
name: html`${unsafeHTML(tag.name)}`,
icon: tagIcon,
action: async () => {
await action(tag);
@@ -58,7 +58,7 @@ export class TagList extends Entity {
return get(this.tags$).map(tag => {
return {
id: tag.id,
title: get(tag.value$),
name: get(tag.value$),
color: get(tag.color$),
createDate: get(tag.createDate$),
updatedDate: get(tag.updateDate$),
@@ -1,10 +1,9 @@
import type { Tag, Tag as TagSchema } from '@affine/env/filter';
import type { DocsPropertiesMeta } from '@blocksuite/affine/store';
import {
LiveData,
Store,
yjsObserveByPath,
yjsObserveDeep,
yjsGetPath,
yjsObservePath,
} from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { map, Observable, switchMap } from 'rxjs';
@@ -12,6 +11,15 @@ import { Array as YArray } from 'yjs';
import type { WorkspaceService } from '../../workspace';
export type Tag = {
value: string;
id: string;
color: string;
createDate?: number | Date | undefined;
updateDate?: number | Date | undefined;
parentId?: string | undefined;
};
export class TagStore extends Store {
get properties() {
return this.workspaceService.workspace.docCollection.meta.properties;
@@ -105,13 +113,13 @@ export class TagStore extends Store {
watchTagInfo(id: string) {
return this.tagOptions$.map(
tags => tags.find(tag => tag.id === id) as TagSchema | undefined
tags => tags.find(tag => tag.id === id) as Tag | undefined
);
}
updateTagInfo(id: string, tagInfo: Partial<TagSchema>) {
updateTagInfo(id: string, tagInfo: Partial<Tag>) {
const tag = this.tagOptions$.value.find(tag => tag.id === id) as
| TagSchema
| Tag
| undefined;
if (!tag) {
return;
@@ -127,11 +135,13 @@ export class TagStore extends Store {
}
watchTagPageIds(id: string) {
return yjsObserveByPath(
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(yjsObserveDeep),
switchMap(pages => {
return yjsObservePath(pages, '*.tags');
}),
map(meta => {
if (meta instanceof YArray) {
return meta
@@ -0,0 +1,17 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceDBService } from '../db';
import { WorkspaceService } from '../workspace';
import { WorkspaceScope } from '../workspace/scopes/workspace';
import { WorkspacePropertyService } from './services/workspace-property';
import { WorkspacePropertyStore } from './stores/workspace-property';
export { WorkspacePropertyService } from './services/workspace-property';
export type * from './types';
export function configureWorkspacePropertyModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(WorkspacePropertyService, [WorkspacePropertyStore])
.store(WorkspacePropertyStore, [WorkspaceService, WorkspaceDBService]);
}
@@ -1,19 +1,21 @@
import {
Entity,
generateFractionalIndexingKeyBetween,
LiveData,
Service,
} from '@toeverything/infra';
import type { DocCustomPropertyInfo } from '../../db/schema/schema';
import type { DocPropertiesStore } from '../stores/doc-properties';
import type { WorkspacePropertyStore } from '../stores/workspace-property';
export class DocPropertyList extends Entity {
constructor(private readonly docPropertiesStore: DocPropertiesStore) {
export class WorkspacePropertyService extends Service {
constructor(
private readonly workspacePropertiesStore: WorkspacePropertyStore
) {
super();
}
properties$ = LiveData.from(
this.docPropertiesStore.watchDocPropertyInfoList(),
this.workspacePropertiesStore.watchWorkspaceProperties(),
[]
);
@@ -27,17 +29,17 @@ export class DocPropertyList extends Entity {
}
updatePropertyInfo(id: string, properties: Partial<DocCustomPropertyInfo>) {
this.docPropertiesStore.updateDocPropertyInfo(id, properties);
this.workspacePropertiesStore.updateWorkspaceProperty(id, properties);
}
createProperty(
properties: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
) {
return this.docPropertiesStore.createDocPropertyInfo(properties);
return this.workspacePropertiesStore.createWorkspaceProperty(properties);
}
removeProperty(id: string) {
this.docPropertiesStore.removeDocPropertyInfo(id);
this.workspacePropertiesStore.removeWorkspaceProperty(id);
}
indexAt(at: 'before' | 'after', targetId?: string) {
@@ -0,0 +1,175 @@
import { Store, yjsGetPath, yjsObserveDeep } from '@toeverything/infra';
import { differenceBy } from 'lodash-es';
import { combineLatest, map, switchMap } from 'rxjs';
import { AbstractType as YAbstractType } from 'yjs';
import type { WorkspaceDBService } from '../../db';
import type { DocCustomPropertyInfo } from '../../db/schema/schema';
import type { WorkspaceService } from '../../workspace';
import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants';
import type { WorkspacePropertyType } from '../types';
type LegacyWorkspacePropertyInfo = {
id?: string;
name?: string;
type?: string;
icon?: string;
};
type LegacyWorkspacePropertyInfoList = Record<
string,
LegacyWorkspacePropertyInfo | undefined
>;
export class WorkspacePropertyStore extends Store {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly dbService: WorkspaceDBService
) {
super();
}
getWorkspaceProperties() {
const db = this.dbService.db.docCustomPropertyInfo.find();
const legacy = this.upgradeLegacyWorkspacePropertyInfoList(
this.getLegacyWorkspacePropertyInfoList()
);
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
}
createWorkspaceProperty(
config: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
) {
return this.dbService.db.docCustomPropertyInfo.create(config);
}
removeWorkspaceProperty(id: string) {
this.updateWorkspaceProperty(id, {
additionalData: {}, // also remove additional data to reduce size
isDeleted: true,
});
}
updateWorkspaceProperty(id: string, config: Partial<DocCustomPropertyInfo>) {
const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id);
const isBuiltIn =
needMigration && BUILT_IN_CUSTOM_PROPERTY_TYPE.some(i => i.id === id);
if (isBuiltIn) {
this.createWorkspacePropertyFromBuiltIn(id, config);
} else if (needMigration) {
// if this property is not in db, we need to migration it from legacy to db, only type and name is needed
this.migrateLegacyWorkspaceProperty(id, config);
} else {
this.dbService.db.docCustomPropertyInfo.update(id, config);
}
}
migrateLegacyWorkspaceProperty(
id: string,
override: Partial<DocCustomPropertyInfo>
) {
const legacy = this.getLegacyWorkspacePropertyInfo(id);
this.dbService.db.docCustomPropertyInfo.create({
id,
type: (legacy?.type ??
'unknown') /* should never reach here, just for safety, we need handle unknown property type */ as WorkspacePropertyType,
name: legacy?.name,
...override,
});
}
createWorkspacePropertyFromBuiltIn(
id: string,
override: Partial<DocCustomPropertyInfo>
) {
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE.find(i => i.id === id);
if (!builtIn) {
return;
}
this.createWorkspaceProperty({ ...builtIn, ...override });
}
watchWorkspaceProperties() {
return combineLatest([
this.watchLegacyWorkspacePropertyInfoList().pipe(
map(this.upgradeLegacyWorkspacePropertyInfoList)
),
this.dbService.db.docCustomPropertyInfo.find$(),
]).pipe(
map(([legacy, db]) => {
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
})
);
}
private upgradeLegacyWorkspacePropertyInfoList(
infoList?: LegacyWorkspacePropertyInfoList
) {
if (!infoList) {
return [];
}
const newInfoList: DocCustomPropertyInfo[] = [];
for (const [id, info] of Object.entries(infoList ?? {})) {
if (info?.type) {
newInfoList.push({
id,
name: info.name,
type: info.type as WorkspacePropertyType,
icon: info.icon,
});
}
}
return newInfoList;
}
private getLegacyWorkspacePropertyInfoList() {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
.get('schema')
?.get('pageProperties')
?.get('custom')
?.toJSON() as LegacyWorkspacePropertyInfoList | undefined;
}
private watchLegacyWorkspacePropertyInfoList() {
return yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap<any>(
'affine:workspace-properties'
),
'schema.pageProperties.custom'
).pipe(
switchMap(yjsObserveDeep),
map(
p =>
(p instanceof YAbstractType ? p.toJSON() : p) as
| LegacyWorkspacePropertyInfoList
| undefined
)
);
}
private getLegacyWorkspacePropertyInfo(id: string) {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
.get('schema')
?.get('pageProperties')
?.get('custom')
?.get(id)
?.toJSON() as LegacyWorkspacePropertyInfo | undefined;
}
}
@@ -0,0 +1,44 @@
type DateFilters =
| 'after'
| 'before'
| 'between'
| 'last-3-days'
| 'last-7-days'
| 'last-15-days'
| 'last-30-days'
| 'this-month'
| 'this-week'
| 'this-quarter'
| 'this-year';
export type WorkspacePropertyTypes = {
tags: {
filter: 'include' | 'is-not-empty' | 'is-empty';
};
text: {
filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty';
};
number: {
filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty';
};
checkbox: {
filter: 'is' | 'is-not';
};
date: {
filter: DateFilters | 'is-not-empty' | 'is-empty';
};
createdBy: { filter: 'include' };
updatedBy: { filter: 'include' };
updatedAt: { filter: DateFilters };
createdAt: { filter: DateFilters };
docPrimaryMode: { filter: 'is' | 'is-not' };
journal: { filter: 'is' | 'is-not' };
edgelessTheme: { filter: never };
pageWidth: { filter: never };
template: { filter: never };
unknown: { filter: never };
};
export type WorkspacePropertyType = keyof WorkspacePropertyTypes;
export type WorkspacePropertyFilter<T extends WorkspacePropertyType> =
WorkspacePropertyTypes[T]['filter'];
@@ -1,5 +1,5 @@
import type { Workspace as WorkspaceInterface } from '@blocksuite/affine/store';
import { Entity, LiveData, yjsObserveByPath } from '@toeverything/infra';
import { Entity, LiveData, yjsGetPath } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import { Doc as YDoc, transact } from 'yjs';
@@ -80,14 +80,14 @@ export class Workspace extends Entity {
}
name$ = LiveData.from<string | undefined>(
yjsObserveByPath(this.rootYDoc.getMap('meta'), 'name') as Observable<
yjsGetPath(this.rootYDoc.getMap('meta'), 'name') as Observable<
string | undefined
>,
undefined
);
avatar$ = LiveData.from(
yjsObserveByPath(this.rootYDoc.getMap('meta'), 'avatar') as Observable<
yjsGetPath(this.rootYDoc.getMap('meta'), 'avatar') as Observable<
string | undefined
>,
undefined