diff --git a/blocksuite/affine/model/src/utils/types.ts b/blocksuite/affine/model/src/utils/types.ts index 4f72dd9a78..77574a9c0c 100644 --- a/blocksuite/affine/model/src/utils/types.ts +++ b/blocksuite/affine/model/src/utils/types.ts @@ -30,3 +30,10 @@ export type Connectable = Exclude< GfxModel, ConnectorElementModel | BrushElementModel | GroupElementModel >; + +export type BlockMeta = { + 'meta:createdAt'?: number; + 'meta:createdBy'?: string; + 'meta:updatedAt'?: number; + 'meta:updatedBy'?: string; +}; diff --git a/blocksuite/affine/shared/src/services/block-meta-service.ts b/blocksuite/affine/shared/src/services/block-meta-service.ts new file mode 100644 index 0000000000..f645282f35 --- /dev/null +++ b/blocksuite/affine/shared/src/services/block-meta-service.ts @@ -0,0 +1,112 @@ +import type { BlockMeta } from '@blocksuite/affine-model'; +import { type BlockModel, type Store, StoreExtension } from '@blocksuite/store'; + +import { FeatureFlagService } from './feature-flag-service'; +import { UserProvider } from './user-service'; + +/** + * The service is used to add following info to the block. + * - createdAt: The time when the block is created. + * - createdBy: The user who created the block. + * - updatedAt: The time when the block is updated. + * - updatedBy: The user who updated the block. + */ +export class BlockMetaService extends StoreExtension { + static override key = 'affine-block-meta-service'; + + get isBlockMetaEnabled() { + return ( + this.store.get(FeatureFlagService).getFlag('enable_block_meta') === true + ); + } + + constructor(store: Store) { + super(store); + + if (!this.isBlockMetaEnabled) return; + + this.store.slots.blockUpdated.on(({ type, id }) => { + if (!this.isBlockMetaEnabled) return; + + const model = this.store.getBlock(id)?.model; + if (!model) return; + + if (type === 'add') { + return this._onBlockCreated(model); + } + + if (type === 'update') { + return this._onBlockUpdated(model); + } + }); + } + + private readonly _onBlockCreated = (model: BlockModel): void => { + if (!isBlockMetaSupported(model)) { + return; + } + + const currentUser = this._getCurrentUser(); + if (!currentUser) return; + const now = getNow(); + + this.store.withoutTransact(() => { + const isFlatModel = model.schema.model.isFlatData; + if (!isFlatModel) { + model['meta:createdAt'] = now; + model['meta:createdBy'] = currentUser.id; + return; + } + + model.props['meta:createdAt'] = now; + model.props['meta:createdBy'] = currentUser.id; + }); + }; + + private readonly _onBlockUpdated = (model: BlockModel): void => { + if (!isBlockMetaSupported(model)) { + return; + } + + const currentUser = this._getCurrentUser(); + if (!currentUser) return; + const now = getNow(); + + this.store.withoutTransact(() => { + const isFlatModel = model.schema.model.isFlatData; + if (!isFlatModel) { + model['meta:updatedAt'] = now; + model['meta:updatedBy'] = currentUser.id; + if (!model['meta:createdAt']) { + model['meta:createdAt'] = now; + model['meta:createdBy'] = currentUser.id; + } + return; + } + + model.props['meta:updatedAt'] = now; + model.props['meta:updatedBy'] = currentUser.id; + if (!model.props['meta:createdAt']) { + model.props['meta:createdAt'] = now; + model.props['meta:createdBy'] = currentUser.id; + } + }); + }; + + private readonly _getCurrentUser = () => { + return this.store.getOptional(UserProvider)?.getCurrentUser(); + }; +} + +function isBlockMetaSupported(model: BlockModel) { + return [ + 'meta:createdAt', + 'meta:createdBy', + 'meta:updatedAt', + 'meta:updatedBy', + ].every(key => model.keys.includes(key)); +} + +function getNow() { + return Date.now(); +} diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 12e4440949..5dbb11c667 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -1,3 +1,4 @@ +export * from './block-meta-service'; export * from './doc-display-meta-service'; export * from './doc-mode-service'; export * from './drag-handle-config'; diff --git a/blocksuite/blocks/src/extensions/store.ts b/blocksuite/blocks/src/extensions/store.ts index e59780dd6c..afe95e2a8c 100644 --- a/blocksuite/blocks/src/extensions/store.ts +++ b/blocksuite/blocks/src/extensions/store.ts @@ -32,6 +32,7 @@ import { ImageSelectionExtension, } from '@blocksuite/affine-shared/selection'; import { + BlockMetaService, FeatureFlagService, FileSizeLimitService, LinkPreviewerService, @@ -87,15 +88,15 @@ export const StoreExtensions: ExtensionType[] = [ DatabaseSelectionExtension, TableSelectionExtension, - FeatureFlagService, - LinkPreviewerService, - FileSizeLimitService, - ImageStoreSpec, - HtmlAdapterExtension, MarkdownAdapterExtension, NotionHtmlAdapterExtension, PlainTextAdapterExtension, - AdapterFactoryExtensions, + + FeatureFlagService, + LinkPreviewerService, + FileSizeLimitService, + ImageStoreSpec, + BlockMetaService, ].flat(); diff --git a/blocksuite/framework/store/src/extension/store-extension.ts b/blocksuite/framework/store/src/extension/store-extension.ts index 613c1c0340..7b0b04139a 100644 --- a/blocksuite/framework/store/src/extension/store-extension.ts +++ b/blocksuite/framework/store/src/extension/store-extension.ts @@ -11,6 +11,8 @@ export const StoreExtensionIdentifier = export const storeExtensionSymbol = Symbol('StoreExtension'); export class StoreExtension extends Extension { + static readonly key: string; + constructor(readonly store: Store) { super(); } @@ -30,8 +32,6 @@ export class StoreExtension extends Extension { provider.get(this) ); } - - static readonly key: string; } export function isStoreExtensionConstructor( diff --git a/blocksuite/framework/store/src/model/store/store.ts b/blocksuite/framework/store/src/model/store/store.ts index 414a6b7157..ade4a7ac5c 100644 --- a/blocksuite/framework/store/src/model/store/store.ts +++ b/blocksuite/framework/store/src/model/store/store.ts @@ -6,6 +6,7 @@ import { computed, signal } from '@preact/signals-core'; import type { ExtensionType } from '../../extension/extension.js'; import { BlockSchemaIdentifier, + StoreExtensionIdentifier, StoreSelectionExtension, } from '../../extension/index.js'; import { Schema } from '../../schema/index.js'; @@ -313,6 +314,17 @@ export class Store { } constructor({ doc, readonly, query, provider, extensions }: StoreOptions) { + this._doc = doc; + this.slots = { + ready: new Slot(), + rootAdded: new Slot(), + rootDeleted: new Slot(), + blockUpdated: new Slot(), + historyUpdated: this._doc.slots.historyUpdated, + yBlockUpdated: this._doc.slots.yBlockUpdated, + }; + this._schema = new Schema(); + const container = new Container(); container.addImpl(StoreIdentifier, () => this); @@ -327,18 +339,6 @@ export class Store { }); this._provider = container.provider(undefined, provider); - this._doc = doc; - - this.slots = { - ready: new Slot(), - rootAdded: new Slot(), - rootDeleted: new Slot(), - blockUpdated: new Slot(), - historyUpdated: this._doc.slots.historyUpdated, - yBlockUpdated: this._doc.slots.yBlockUpdated, - }; - - this._schema = new Schema(); this._provider.getAll(BlockSchemaIdentifier).forEach(schema => { this._schema.register([schema]); }); @@ -698,6 +698,7 @@ export class Store { load(initFn?: () => void) { this._doc.load(initFn); + this._provider.getAll(StoreExtensionIdentifier); this.slots.ready.emit(); return this; }