feat(core): page info adapter for journal (#5561)

Page info adapter + schema.
Adapted for journal features.

![image](https://github.com/toeverything/AFFiNE/assets/584378/2731ed2b-a125-4d62-b658-f2aff49d0e17)
This commit is contained in:
Peng Xiao
2024-01-22 08:25:27 +00:00
parent 8b92cc0cae
commit 735e1cb117
18 changed files with 412 additions and 68 deletions

View File

@@ -1 +1,2 @@
export * from './atoms';
export * from './properties';

View File

@@ -0,0 +1,156 @@
// the adapter is to bridge the workspace rootdoc & native js bindings
import { createYProxy, type Workspace, type Y } from '@blocksuite/store';
import { defaultsDeep } from 'lodash-es';
import {
PagePropertyType,
PageSystemPropertyId,
type TagOption,
type WorkspaceAffineProperties,
type WorkspaceFavoriteItem,
} from './schema';
const AFFINE_PROPERTIES_ID = 'affine:workspace-properties';
/**
* WorkspacePropertiesAdapter is a wrapper for workspace properties.
* Users should not directly access the workspace properties via yjs, but use this adapter instead.
*
* Question for enhancement in the future:
* May abstract the adapter for each property type, e.g. PagePropertiesAdapter, SchemaAdapter, etc.
* So that the adapter could be more focused and easier to maintain (like assigning default values)
* However the properties for an abstraction may not be limited to a single yjs map.
*/
export class WorkspacePropertiesAdapter {
// provides a easy-to-use interface for workspace properties
private readonly proxy: WorkspaceAffineProperties;
public readonly properties: Y.Map<any>;
constructor(private readonly workspace: Workspace) {
// check if properties exists, if not, create one
const rootDoc = workspace.doc;
this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID);
this.proxy = createYProxy(this.properties);
// fixme: deal with migration issue?
this.ensureRootProperties();
}
private ensureRootProperties() {
// todo: deal with schema change issue
// fixme: may not to be called every time
defaultsDeep(this.proxy, {
schema: {
pageProperties: {
custom: {},
system: {
journal: {
id: PageSystemPropertyId.Journal,
name: 'Journal',
source: 'system',
type: PagePropertyType.Date,
},
tags: {
id: PageSystemPropertyId.Tags,
name: 'Tags',
source: 'system',
type: PagePropertyType.Tags,
options: this.workspace.meta.properties.tags?.options ?? [], // better use a one time migration
},
},
},
},
favorites: {},
pageProperties: {},
});
}
private ensurePageProperties(pageId: string) {
// fixme: may not to be called every time
defaultsDeep(this.proxy.pageProperties, {
[pageId]: {
custom: {},
system: {
[PageSystemPropertyId.Journal]: {
id: PageSystemPropertyId.Journal,
value: false,
},
[PageSystemPropertyId.Tags]: {
id: PageSystemPropertyId.Tags,
value: [],
},
},
},
});
}
get schema() {
return this.proxy.schema;
}
get favorites() {
return this.proxy.favorites;
}
get pageProperties() {
return this.proxy.pageProperties;
}
// ====== utilities ======
getPageProperties(pageId: string) {
return this.pageProperties[pageId];
}
isFavorite(id: string, type: WorkspaceFavoriteItem['type']) {
return this.favorites[id]?.type === type;
}
getJournalPageDateString(id: string) {
this.ensurePageProperties(id);
return this.pageProperties[id].system[PageSystemPropertyId.Journal].value;
}
setJournalPageDateString(id: string, date: string) {
this.ensurePageProperties(id);
const pageProperties = this.pageProperties[id];
pageProperties.system[PageSystemPropertyId.Journal].value = date;
}
get tagOptions() {
return this.schema.pageProperties.system[PageSystemPropertyId.Tags].options;
}
// page tags could be reactive
getPageTags(pageId: string) {
this.ensurePageProperties(pageId);
const tags =
this.pageProperties[pageId].system[PageSystemPropertyId.Tags].value;
const optionsMap = Object.fromEntries(this.tagOptions.map(o => [o.id, o]));
return tags.map(tag => optionsMap[tag]).filter((t): t is TagOption => !!t);
}
addPageTag(pageId: string, tag: TagOption | string) {
this.ensurePageProperties(pageId);
const tags = this.getPageTags(pageId);
const tagId = typeof tag === 'string' ? tag : tag.id;
if (tags.some(t => t.id === tagId)) {
return;
}
const pageProperties = this.pageProperties[pageId];
pageProperties.system[PageSystemPropertyId.Tags].value.push(tagId);
}
removePageTag(pageId: string, tag: TagOption | string) {
this.ensurePageProperties(pageId);
const tags = this.getPageTags(pageId);
const tagId = typeof tag === 'string' ? tag : tag.id;
const index = tags.findIndex(t => t.id === tagId);
if (index === -1) {
return;
}
const pageProperties = this.pageProperties[pageId];
pageProperties.system[PageSystemPropertyId.Tags].value.splice(index, 1);
}
}

View File

@@ -0,0 +1,16 @@
import type { Workspace } from '@affine/workspace/workspace';
import { atomWithObservable } from 'jotai/utils';
import { filter, map, of } from 'rxjs';
import { currentWorkspaceAtom } from '../atoms';
import { WorkspacePropertiesAdapter } from './adapter';
export const currentWorkspacePropertiesAdapterAtom =
atomWithObservable<WorkspacePropertiesAdapter>(get => {
return of(get(currentWorkspaceAtom)).pipe(
filter((workspace): workspace is Workspace => !!workspace),
map(workspace => {
return new WorkspacePropertiesAdapter(workspace.blockSuiteWorkspace);
})
);
});

View File

@@ -0,0 +1,2 @@
export * from './adapter';
export * from './atom';

View File

@@ -0,0 +1,101 @@
import { z } from 'zod';
// ===== workspace-wide page property schema =====
export const TagOptionSchema = z.object({
id: z.string(),
name: z.string(),
color: z.string(),
});
export type TagOption = z.infer<typeof TagOptionSchema>;
export enum PageSystemPropertyId {
Tags = 'tags',
Journal = 'journal',
}
export enum PagePropertyType {
String = 'string',
Number = 'number',
Boolean = 'boolean',
Date = 'date',
Tags = 'tags',
}
export const PagePropertyMetaBaseSchema = z.object({
id: z.string(),
name: z.string(),
source: z.string(),
type: z.string(),
});
export const PageSystemPropertyMetaBaseSchema =
PagePropertyMetaBaseSchema.extend({
source: z.literal('system'),
});
export const PageCustomPropertyMetaSchema = PagePropertyMetaBaseSchema.extend({
source: z.literal('custom'),
type: z.nativeEnum(PagePropertyType),
});
// ====== page info schema ======
export const PageInfoItemSchema = z.object({
id: z.string(), // property id. Maps to PagePropertyMetaSchema.id
hidden: z.boolean().optional(),
value: z.any(), // corresponds to PagePropertyMetaSchema.type
});
export const PageInfoJournalItemSchema = PageInfoItemSchema.extend({
id: z.literal(PageSystemPropertyId.Journal),
value: z.union([z.string(), z.literal(false)]),
});
export const PageInfoTagsItemSchema = PageInfoItemSchema.extend({
id: z.literal(PageSystemPropertyId.Tags),
value: z.array(z.string()),
});
// ====== workspace properties schema ======
export const WorkspaceFavoriteItemSchema = z.object({
id: z.string(),
order: z.number(),
type: z.enum(['page', 'collection']),
});
export type WorkspaceFavoriteItem = z.infer<typeof WorkspaceFavoriteItemSchema>;
const WorkspaceAffinePropertiesSchemaSchema = z.object({
pageProperties: z.object({
custom: z.record(PageCustomPropertyMetaSchema),
system: z.object({
[PageSystemPropertyId.Journal]: PageSystemPropertyMetaBaseSchema.extend({
id: z.literal(PageSystemPropertyId.Journal),
type: z.literal(PagePropertyType.Date),
}),
[PageSystemPropertyId.Tags]: PagePropertyMetaBaseSchema.extend({
id: z.literal(PageSystemPropertyId.Tags),
type: z.literal(PagePropertyType.Tags),
options: z.array(TagOptionSchema),
}),
}),
}),
});
const WorkspacePagePropertiesSchema = z.object({
custom: z.record(PageInfoItemSchema.extend({ order: z.number() })),
system: z.object({
[PageSystemPropertyId.Journal]: PageInfoJournalItemSchema,
[PageSystemPropertyId.Tags]: PageInfoTagsItemSchema,
}),
});
export const WorkspaceAffinePropertiesSchema = z.object({
schema: WorkspaceAffinePropertiesSchemaSchema,
favorites: z.record(WorkspaceFavoriteItemSchema),
pageProperties: z.record(WorkspacePagePropertiesSchema),
});
export type WorkspaceAffineProperties = z.infer<
typeof WorkspaceAffinePropertiesSchema
>;