feat: add auto-date titles for new documents (#14716)

## Summary

Adds an Editor setting to automatically title blank new documents with
the current date.
fixes https://github.com/toeverything/AFFiNE/issues/14709
https://www.loom.com/share/953b4eafcfb247839e977dca6f457229

## What Changed

- Added `Auto-title new docs with current date` under Editor settings
- Added `New doc date format`, shown only when auto-title is enabled
- Supported formats:
  - `DD-MM-YYYY`
  - `MM-DD-YYYY`
  - `YYYY-MM-DD`
  - `Journal style (localized)`
- Kept titles unique by appending duplicate-style suffixes:
  - `2026-03-24`
  - `2026-03-24(2)`
  - `2026-03-24(3)`

## Behavior

- Only applies to blank new docs
- Does not override explicitly provided titles
- Uses the existing journal-style localized formatter for the localized
option

## Implementation Notes

- Extended editor setting schema with:
  - `autoTitleNewDocWithCurrentDate`
  - `newDocDateTitleFormat`
- Added a helper for generating unique date-based titles
- Wired title generation into doc creation middleware
- Synced created titles into doc metadata so uniqueness works
consistently

## Tests

- Added unit coverage for:
  - date title formatting
  - duplicate suffix generation
  - doc creation middleware behavior
  - settings UI behavior


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

* **New Features**
* General settings: toggle to auto-insert current date into new document
titles; selectable formats: DD-MM-YYYY, MM-DD-YYYY, YYYY-MM-DD, and
localized "journal". Date-format chooser appears only when enabled.

* **Behavior**
* Blank new-docs are auto-populated per chosen format; user-provided
titles are preserved. Auto-generated titles avoid collisions by
appending incrementing suffixes.

* **Localization**
* Added translations for the setting, description, format chooser, and
all format labels.

* **Tests**
* Added UI and unit tests covering formatting, uniqueness, middleware
behavior, and interaction.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
chauhan_s
2026-04-05 14:40:01 +05:30
committed by GitHub
parent fc5329a1be
commit 558400b7db
13 changed files with 686 additions and 23 deletions
@@ -4,7 +4,7 @@ export { DocRecordList } from './entities/record-list';
export { DocCreated } from './events';
export { DocScope } from './scopes/doc';
export { DocService } from './services/doc';
export { DocsService } from './services/docs';
export { DocsQueryService, DocsService } from './services/docs';
import type { Framework } from '@toeverything/infra';
@@ -17,7 +17,7 @@ import { DocRecordList } from './entities/record-list';
import { DocCreateMiddleware } from './providers/doc-create-middleware';
import { DocScope } from './scopes/doc';
import { DocService } from './services/doc';
import { DocsService } from './services/docs';
import { DocsQueryService, DocsService } from './services/docs';
import { DocPropertiesStore } from './stores/doc-properties';
import { DocsStore } from './stores/docs';
@@ -26,10 +26,11 @@ export { DocCreateMiddleware } from './providers/doc-create-middleware';
export function configureDocModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(DocsQueryService, [DocsStore, DocPropertiesStore])
.service(DocsService, [
DocsStore,
DocPropertiesStore,
[DocCreateMiddleware],
DocsQueryService,
])
.store(DocPropertiesStore, [WorkspaceService, WorkspaceDBService])
.store(DocsStore, [WorkspaceService, DocPropertiesStore])
@@ -22,15 +22,7 @@ import { getDuplicatedDocTitle } from './duplicate-title';
const logger = new DebugLogger('DocsService');
export class DocsService extends Service {
list = this.framework.createEntity(DocRecordList);
pool = new ObjectPool<string, Doc>({
onDelete(obj) {
obj.scope.dispose();
},
});
export class DocsQueryService extends Service {
/**
* Get all property values of a property, used for search
*
@@ -88,11 +80,60 @@ export class DocsService extends Service {
constructor(
private readonly store: DocsStore,
private readonly docPropertiesStore: DocPropertiesStore,
private readonly docCreateMiddlewares: DocCreateMiddleware[]
private readonly docPropertiesStore: DocPropertiesStore
) {
super();
}
}
export class DocsService extends Service {
list = this.framework.createEntity(DocRecordList);
pool = new ObjectPool<string, Doc>({
onDelete(obj) {
obj.scope.dispose();
},
});
constructor(
private readonly store: DocsStore,
private readonly docCreateMiddlewares: DocCreateMiddleware[],
private readonly docsQueryService: DocsQueryService
) {
super();
}
propertyValues$(propertyKey: string) {
return this.docsQueryService.propertyValues$(propertyKey);
}
allDocsCreatedDate$() {
return this.docsQueryService.allDocsCreatedDate$();
}
allDocsUpdatedDate$() {
return this.docsQueryService.allDocsUpdatedDate$();
}
allDocsTagIds$() {
return this.docsQueryService.allDocsTagIds$();
}
allDocIds$() {
return this.docsQueryService.allDocIds$();
}
allNonTrashDocIds$() {
return this.docsQueryService.allNonTrashDocIds$();
}
allTrashDocIds$() {
return this.docsQueryService.allTrashDocIds$();
}
allDocTitle$() {
return this.docsQueryService.allDocTitle$();
}
loaded(docId: string) {
const exists = this.pool.get(docId);
@@ -165,6 +206,9 @@ export class DocsService extends Service {
if (options.isTemplate) {
docRecord.setProperty('isTemplate', true);
}
if (options.title?.trim()) {
docRecord.setMeta({ title: options.title });
}
for (const middleware of this.docCreateMiddlewares) {
middleware.afterCreate?.(docRecord, options);
}
@@ -0,0 +1,71 @@
import { getOrCreateI18n } from '@affine/i18n';
import { describe, expect, test } from 'vitest';
import {
buildNewDocDateTitle,
getUniqueNewDocDateTitle,
} from '../utils/date-title';
describe('date-title', () => {
test('formats dates using DD-MM-YYYY', () => {
expect(buildNewDocDateTitle('2026-03-23', 'DD-MM-YYYY')).toBe('23-03-2026');
});
test('formats dates using MM-DD-YYYY', () => {
expect(buildNewDocDateTitle('2026-03-23', 'MM-DD-YYYY')).toBe('03-23-2026');
});
test('formats dates using YYYY-MM-DD', () => {
expect(buildNewDocDateTitle('2026-03-23', 'YYYY-MM-DD')).toBe('2026-03-23');
});
test('formats dates using journal style', () => {
getOrCreateI18n();
expect(buildNewDocDateTitle('2026-03-23', 'journal')).toBe('Mar 23, 2026');
});
test('returns the base title when there is no collision', () => {
expect(
getUniqueNewDocDateTitle({
existingTitles: ['Some title'],
format: 'DD-MM-YYYY',
date: '2026-03-23',
})
).toBe('23-03-2026');
});
test('suffixes duplicate titles starting at (2)', () => {
expect(
getUniqueNewDocDateTitle({
existingTitles: ['23-03-2026'],
format: 'DD-MM-YYYY',
date: '2026-03-23',
})
).toBe('23-03-2026(2)');
});
test('increments to the next available duplicate suffix', () => {
expect(
getUniqueNewDocDateTitle({
existingTitles: [
'23-03-2026',
'23-03-2026(2)',
'23-03-2026(3)',
'Another doc',
],
format: 'DD-MM-YYYY',
date: '2026-03-23',
})
).toBe('23-03-2026(4)');
});
test('does not suffix when only duplicate-style titles exist', () => {
expect(
getUniqueNewDocDateTitle({
existingTitles: ['23-03-2026(2)'],
format: 'DD-MM-YYYY',
date: '2026-03-23',
})
).toBe('23-03-2026');
});
});
@@ -0,0 +1,177 @@
import { getOrCreateI18n } from '@affine/i18n';
import { Framework, Service } from '@toeverything/infra';
import { of } from 'rxjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { EditorSettingDocCreateMiddleware } from '../impls/doc-create-middleware';
const createDocsQueryService = (titles: string[]) => {
return {
['allDocTitle$']: () =>
of(
titles.map((title, index) => ({
id: `doc-${index}`,
title,
}))
),
};
};
const createEditorSettingService = (overrides?: Record<string, unknown>) => {
return {
editorSetting: {
['settings$']: {
value: {
newDocDefaultMode: 'page',
autoTitleNewDocWithCurrentDate: false,
newDocDateTitleFormat: 'DD-MM-YYYY',
...overrides,
},
},
get: vi.fn((key: string) => {
if (key === 'affine:note') {
return undefined;
}
if (key === 'edgelessDefaultTheme') {
return 'specified';
}
return undefined;
}),
},
};
};
const appThemeService = {
appTheme: {
['theme$']: {
value: 'light',
},
},
};
const createMiddleware = ({
settings,
titles,
}: {
settings?: Record<string, unknown>;
titles?: string[];
}) => {
class MockEditorSettingService extends Service {
editorSetting = createEditorSettingService(settings).editorSetting;
}
class MockAppThemeService extends Service {
appTheme = appThemeService.appTheme;
}
class MockDocsQueryService extends Service {
['allDocTitle$'] =
createDocsQueryService(titles ?? [])['allDocTitle$'];
}
const framework = new Framework();
framework
.service(MockEditorSettingService)
.service(MockAppThemeService)
.service(MockDocsQueryService)
.service(EditorSettingDocCreateMiddleware, [
MockEditorSettingService as never,
MockAppThemeService as never,
MockDocsQueryService as never,
]);
return framework.provider().get(EditorSettingDocCreateMiddleware);
};
describe('EditorSettingDocCreateMiddleware', () => {
afterEach(() => {
vi.useRealTimers();
});
test('adds an auto date title for blank docs when enabled', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-23T09:00:00.000Z'));
const middleware = createMiddleware({
settings: {
autoTitleNewDocWithCurrentDate: true,
newDocDateTitleFormat: 'DD-MM-YYYY',
},
});
expect(middleware.beforeCreate({})).toMatchObject({
title: '23-03-2026',
primaryMode: 'page',
});
});
test('keeps blank docs untitled when the feature is disabled', () => {
const middleware = createMiddleware({});
expect(middleware.beforeCreate({})).toMatchObject({
primaryMode: 'page',
});
expect(middleware.beforeCreate({}).title).toBeUndefined();
});
test('does not override explicitly provided titles', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-23T09:00:00.000Z'));
const middleware = createMiddleware({
settings: {
autoTitleNewDocWithCurrentDate: true,
},
titles: ['23-03-2026'],
});
expect(
middleware.beforeCreate({
title: 'Typed by user',
}).title
).toBe('Typed by user');
});
test('uses the next duplicate suffix when the date title already exists', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-23T09:00:00.000Z'));
const middleware = createMiddleware({
settings: {
autoTitleNewDocWithCurrentDate: true,
},
titles: ['23-03-2026', '23-03-2026(2)'],
});
expect(middleware.beforeCreate({}).title).toBe('23-03-2026(3)');
});
test('uses the selected format for the generated title', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-23T09:00:00.000Z'));
const middleware = createMiddleware({
settings: {
autoTitleNewDocWithCurrentDate: true,
newDocDateTitleFormat: 'YYYY-MM-DD',
},
});
expect(middleware.beforeCreate({}).title).toBe('2026-03-23');
});
test('supports month-name formats for generated titles', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-23T09:00:00.000Z'));
getOrCreateI18n();
const middleware = createMiddleware({
settings: {
autoTitleNewDocWithCurrentDate: true,
newDocDateTitleFormat: 'journal',
},
});
expect(middleware.beforeCreate({}).title).toBe('Mar 23, 2026');
});
});
@@ -1,10 +1,15 @@
import { Service } from '@toeverything/infra';
import { LiveData, Service } from '@toeverything/infra';
import type { DocCreateMiddleware, DocRecord } from '../../doc';
import type {
DocCreateMiddleware,
DocRecord,
DocsQueryService,
} from '../../doc';
import type { DocCreateOptions } from '../../doc/types';
import type { AppThemeService } from '../../theme';
import type { EdgelessDefaultTheme } from '../schema';
import type { EditorSettingService } from '../services/editor-setting';
import { getUniqueNewDocDateTitle } from '../utils/date-title';
const getValueByDefaultTheme = (
defaultTheme: EdgelessDefaultTheme,
@@ -28,23 +33,42 @@ export class EditorSettingDocCreateMiddleware
extends Service
implements DocCreateMiddleware
{
private readonly allDocTitles$: LiveData<{ id: string; title: string }[]>;
constructor(
private readonly editorSettingService: EditorSettingService,
private readonly appThemeService: AppThemeService
private readonly appThemeService: AppThemeService,
private readonly docsQueryService: DocsQueryService
) {
super();
this.allDocTitles$ = LiveData.from(this.docsQueryService.allDocTitle$(), []);
}
private getCurrentDocTitles() {
return this.allDocTitles$.value.map(doc => doc.title).filter(Boolean);
}
beforeCreate(docCreateOptions: DocCreateOptions): DocCreateOptions {
// clone the docCreateOptions to avoid mutating the original object
docCreateOptions = {
...docCreateOptions,
};
const preferMode =
this.editorSettingService.editorSetting.settings$.value.newDocDefaultMode;
const settings = this.editorSettingService.editorSetting.settings$.value;
const preferMode = settings.newDocDefaultMode;
const mode = preferMode === 'ask' ? 'page' : preferMode;
docCreateOptions.primaryMode ??= mode;
if (
!docCreateOptions.title?.trim() &&
settings.autoTitleNewDocWithCurrentDate
) {
docCreateOptions.title = getUniqueNewDocDateTitle({
existingTitles: this.getCurrentDocTitles(),
format: settings.newDocDateTitleFormat,
});
}
docCreateOptions.docProps = {
...docCreateOptions.docProps,
note: this.editorSettingService.editorSetting.get('affine:note'),
@@ -2,7 +2,7 @@ import { type Framework } from '@toeverything/infra';
import { ServersService } from '../cloud';
import { DesktopApiService } from '../desktop-api';
import { DocCreateMiddleware } from '../doc';
import { DocCreateMiddleware, DocsQueryService } from '../doc';
import { I18n } from '../i18n';
import { GlobalState, GlobalStateService } from '../storage';
import { AppThemeService } from '../theme';
@@ -14,8 +14,12 @@ import { EditorSettingProvider } from './provider/editor-setting-provider';
import { EditorSettingService } from './services/editor-setting';
import { SpellCheckSettingService } from './services/spell-check-setting';
import { TraySettingService } from './services/tray-settings';
export type { FontFamily } from './schema';
export { EditorSettingSchema, fontStyleOptions } from './schema';
export type { FontFamily, NewDocDateTitleFormat } from './schema';
export {
EditorSettingSchema,
fontStyleOptions,
newDocDateTitleFormatOptions,
} from './schema';
export { EditorSettingService } from './services/editor-setting';
export function configureEditorSettingModule(framework: Framework) {
@@ -30,6 +34,7 @@ export function configureEditorSettingModule(framework: Framework) {
.impl(DocCreateMiddleware, EditorSettingDocCreateMiddleware, [
EditorSettingService,
AppThemeService,
DocsQueryService,
]);
}
@@ -5,6 +5,14 @@ export const BSEditorSettingSchema = GeneralSettingSchema;
export type FontFamily = 'Sans' | 'Serif' | 'Mono' | 'Custom';
export type EdgelessDefaultTheme = 'auto' | 'dark' | 'light' | 'specified';
export const newDocDateTitleFormatOptions = [
'DD-MM-YYYY',
'MM-DD-YYYY',
'YYYY-MM-DD',
'journal',
] as const;
export type NewDocDateTitleFormat =
(typeof newDocDateTitleFormatOptions)[number];
export const fontStyleOptions = [
{ key: 'Sans', value: 'var(--affine-font-sans-family)' },
@@ -21,6 +29,10 @@ const AffineEditorSettingSchema = z.object({
customFontFamily: z.string().default(''),
fontSize: z.number().min(12).max(24).default(16),
newDocDefaultMode: z.enum(['edgeless', 'page', 'ask']).default('page'),
autoTitleNewDocWithCurrentDate: z.boolean().default(false),
newDocDateTitleFormat: z
.enum(newDocDateTitleFormatOptions)
.default('DD-MM-YYYY'),
fullWidthLayout: z.boolean().default(false),
displayDocInfo: z.boolean().default(true),
displayBiDirectionalLink: z.boolean().default(true),
@@ -0,0 +1,41 @@
import { i18nTime } from '@affine/i18n';
import dayjs from 'dayjs';
import type { NewDocDateTitleFormat } from '../schema';
export const buildNewDocDateTitle = (
date: dayjs.ConfigType,
format: NewDocDateTitleFormat
) => {
if (format === 'journal') {
return i18nTime(date, {
absolute: { accuracy: 'day' },
});
}
return dayjs(date).format(format);
};
export const getUniqueNewDocDateTitle = ({
existingTitles,
format,
date = new Date(),
}: {
existingTitles: Iterable<string>;
format: NewDocDateTitleFormat;
date?: dayjs.ConfigType;
}) => {
const normalizedTitles = new Set(existingTitles);
const baseTitle = buildNewDocDateTitle(date, format);
if (!normalizedTitles.has(baseTitle)) {
return baseTitle;
}
let duplicateIndex = 2;
while (normalizedTitles.has(`${baseTitle}(${duplicateIndex})`)) {
duplicateIndex += 1;
}
return `${baseTitle}(${duplicateIndex})`;
};