mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
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:
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const editorSettingSet = vi.fn();
|
||||
|
||||
const editorSettingService = {
|
||||
editorSetting: {
|
||||
['settings$']: {
|
||||
value: {
|
||||
autoTitleNewDocWithCurrentDate: true,
|
||||
newDocDateTitleFormat: 'DD-MM-YYYY',
|
||||
},
|
||||
},
|
||||
set: editorSettingSet,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@affine/i18n', () => {
|
||||
const translations: Record<string, string> = {
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.title':
|
||||
'Auto-title new docs with current date',
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.description':
|
||||
"Automatically title blank new docs with today's date.",
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.title':
|
||||
'New doc date format',
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.description':
|
||||
'Choose the date format used for automatic new doc titles.',
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.dd-mm-yyyy':
|
||||
'DD-MM-YYYY',
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.mm-dd-yyyy':
|
||||
'MM-DD-YYYY',
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.yyyy-mm-dd':
|
||||
'YYYY-MM-DD',
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.journal':
|
||||
'Journal style (localized)',
|
||||
};
|
||||
|
||||
const useI18n = () =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, key: string) => {
|
||||
if (key === 't') {
|
||||
return (translationKey: string) =>
|
||||
translations[translationKey] ?? translationKey;
|
||||
}
|
||||
return () => translations[key] ?? key;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
Trans: ({ children }: PropsWithChildren) => children,
|
||||
useI18n,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@toeverything/infra', async importOriginal => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useLiveData: (value: { value: unknown } | unknown) => {
|
||||
if (value && typeof value === 'object' && 'value' in value) {
|
||||
return value.value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
useService: vi.fn(),
|
||||
useServices: () => ({
|
||||
editorSettingService,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { NewDocDateTitleSettings } from './general';
|
||||
|
||||
describe('NewDocDateTitleSettings', () => {
|
||||
beforeEach(() => {
|
||||
editorSettingSet.mockReset();
|
||||
editorSettingService.editorSetting['settings$'].value = {
|
||||
autoTitleNewDocWithCurrentDate: true,
|
||||
newDocDateTitleFormat: 'DD-MM-YYYY',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('persists the auto title toggle through EditorSettingService', () => {
|
||||
render(<NewDocDateTitleSettings />);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(editorSettingSet).toHaveBeenCalledWith(
|
||||
'autoTitleNewDocWithCurrentDate',
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('persists the selected date format through EditorSettingService', () => {
|
||||
render(<NewDocDateTitleSettings />);
|
||||
|
||||
fireEvent.pointerDown(
|
||||
screen.getByTestId('new-doc-date-title-format-trigger')
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'YYYY-MM-DD' }));
|
||||
|
||||
expect(editorSettingSet).toHaveBeenCalledWith(
|
||||
'newDocDateTitleFormat',
|
||||
'YYYY-MM-DD'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders all supported date format options', () => {
|
||||
render(<NewDocDateTitleSettings />);
|
||||
|
||||
const trigger = screen.getByTestId('new-doc-date-title-format-trigger');
|
||||
|
||||
expect(trigger.textContent).toContain('DD-MM-YYYY');
|
||||
|
||||
fireEvent.pointerDown(trigger);
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'DD-MM-YYYY' })).toBeTruthy();
|
||||
expect(screen.getByRole('menuitem', { name: 'MM-DD-YYYY' })).toBeTruthy();
|
||||
expect(screen.getByRole('menuitem', { name: 'YYYY-MM-DD' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'Journal style (localized)' })
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('hides the date format row when auto title is disabled', () => {
|
||||
editorSettingService.editorSetting['settings$'].value = {
|
||||
autoTitleNewDocWithCurrentDate: false,
|
||||
newDocDateTitleFormat: 'DD-MM-YYYY',
|
||||
};
|
||||
|
||||
render(<NewDocDateTitleSettings />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('new-doc-date-title-format-trigger')
|
||||
).toBeNull();
|
||||
expect(screen.queryByText('New doc date format')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
EditorSettingService,
|
||||
type FontFamily,
|
||||
fontStyleOptions,
|
||||
type NewDocDateTitleFormat,
|
||||
newDocDateTitleFormatOptions,
|
||||
} from '@affine/core/modules/editor-setting';
|
||||
import { SpellCheckSettingService } from '@affine/core/modules/editor-setting/services/spell-check-setting';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
@@ -428,6 +430,99 @@ const NewDocDefaultModeSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const getNewDocDateTitleFormatItems = (
|
||||
t: ReturnType<typeof useI18n>
|
||||
): Array<{
|
||||
value: NewDocDateTitleFormat;
|
||||
label: string;
|
||||
}> => {
|
||||
return newDocDateTitleFormatOptions.map(value => ({
|
||||
value,
|
||||
label: t.t(
|
||||
`com.affine.settings.editorSettings.general.auto-date-title.format.${value.toLowerCase()}`
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
export const NewDocDateTitleSettings = () => {
|
||||
const t = useI18n();
|
||||
const { editorSettingService } = useServices({ EditorSettingService });
|
||||
const settings = useLiveData(editorSettingService.editorSetting.settings$);
|
||||
|
||||
const formatItems = useMemo(() => getNewDocDateTitleFormatItems(t), [t]);
|
||||
|
||||
const onToggleAutoDateTitle = useCallback(
|
||||
(checked: boolean) => {
|
||||
editorSettingService.editorSetting.set(
|
||||
'autoTitleNewDocWithCurrentDate',
|
||||
checked
|
||||
);
|
||||
},
|
||||
[editorSettingService.editorSetting]
|
||||
);
|
||||
|
||||
const onDateTitleFormatChange = useCallback(
|
||||
(value: NewDocDateTitleFormat) => {
|
||||
editorSettingService.editorSetting.set('newDocDateTitleFormat', value);
|
||||
},
|
||||
[editorSettingService.editorSetting]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.title'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.description'
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.autoTitleNewDocWithCurrentDate}
|
||||
onChange={onToggleAutoDateTitle}
|
||||
/>
|
||||
</SettingRow>
|
||||
{settings.autoTitleNewDocWithCurrentDate ? (
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.title'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.settings.editorSettings.general.auto-date-title.format.description'
|
||||
]()}
|
||||
>
|
||||
<Menu
|
||||
contentOptions={menuContentOptions}
|
||||
items={formatItems.map(item => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.value}
|
||||
selected={item.value === settings.newDocDateTitleFormat}
|
||||
onSelect={() => onDateTitleFormatChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTrigger}
|
||||
data-testid="new-doc-date-title-format-trigger"
|
||||
>
|
||||
{
|
||||
formatItems.find(
|
||||
item => item.value === settings.newDocDateTitleFormat
|
||||
)?.label
|
||||
}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AISettings = () => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -573,6 +668,7 @@ export const General = () => {
|
||||
<CustomFontFamilySettings />
|
||||
<FontSizeSettings />
|
||||
<NewDocDefaultModeSettings />
|
||||
<NewDocDateTitleSettings />
|
||||
{BUILD_CONFIG.isElectron && <SpellCheckSettings />}
|
||||
{environment.isLinux && <MiddleClickPasteSettings />}
|
||||
{/* // TODO(@akumatus): implement these settings
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+177
@@ -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})`;
|
||||
};
|
||||
@@ -15,7 +15,7 @@
|
||||
"ja": 96,
|
||||
"ko": 97,
|
||||
"nb-NO": 47,
|
||||
"pl": 98,
|
||||
"pl": 97,
|
||||
"pt-BR": 96,
|
||||
"ru": 98,
|
||||
"sv-SE": 96,
|
||||
|
||||
@@ -5490,6 +5490,38 @@ export function useAFFiNEI18N(): {
|
||||
* `New doc default mode`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.default-new-doc.title"](): string;
|
||||
/**
|
||||
* `Auto-title new docs with current date`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.title"](): string;
|
||||
/**
|
||||
* `Automatically title blank new docs with today's date.`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.description"](): string;
|
||||
/**
|
||||
* `New doc date format`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.format.title"](): string;
|
||||
/**
|
||||
* `Choose the date format used for automatic new doc titles.`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.format.description"](): string;
|
||||
/**
|
||||
* `DD-MM-YYYY`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.format.dd-mm-yyyy"](): string;
|
||||
/**
|
||||
* `MM-DD-YYYY`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.format.mm-dd-yyyy"](): string;
|
||||
/**
|
||||
* `YYYY-MM-DD`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.format.yyyy-mm-dd"](): string;
|
||||
/**
|
||||
* `Journal style (localized)`
|
||||
*/
|
||||
["com.affine.settings.editorSettings.general.auto-date-title.format.journal"](): string;
|
||||
/**
|
||||
* `Customize your text experience.`
|
||||
*/
|
||||
|
||||
@@ -1366,6 +1366,14 @@
|
||||
"com.affine.settings.editorSettings.general.default-code-block.wrap.title": "Wrap code in code blocks",
|
||||
"com.affine.settings.editorSettings.general.default-new-doc.description": "Default mode for new doc.",
|
||||
"com.affine.settings.editorSettings.general.default-new-doc.title": "New doc default mode",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.title": "Auto-title new docs with current date",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.description": "Automatically title blank new docs with today's date.",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.format.title": "New doc date format",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.format.description": "Choose the date format used for automatic new doc titles.",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.format.dd-mm-yyyy": "DD-MM-YYYY",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.format.mm-dd-yyyy": "MM-DD-YYYY",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.format.yyyy-mm-dd": "YYYY-MM-DD",
|
||||
"com.affine.settings.editorSettings.general.auto-date-title.format.journal": "Journal style (localized)",
|
||||
"com.affine.settings.editorSettings.general.font-family.custom.description": "Customize your text experience.",
|
||||
"com.affine.settings.editorSettings.general.font-family.custom.title": "Custom font family",
|
||||
"com.affine.settings.editorSettings.general.font-family.description": "Choose your editor's font family.",
|
||||
|
||||
Reference in New Issue
Block a user