feat(core): at menu journal entries (#8877) (#8935)

fix AF-1790
fix AF-1774
added `DateSelectorDialog` for selecting a date through blocksuite;
added `AtMenuConfigService` for constructing the at menu config
fix AF-1776
fix PD-1942

resubmitted to replace #8877
This commit is contained in:
pengx17
2024-11-27 03:08:11 +00:00
parent e3a8f1e9bd
commit 83c587f8ad
18 changed files with 965 additions and 272 deletions

View File

@@ -1,196 +1,9 @@
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { JournalService } from '@affine/core/modules/journal';
import { I18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { EditorHost } from '@blocksuite/affine/block-std';
import type {
AffineInlineEditor,
LinkedWidgetConfig,
} from '@blocksuite/affine/blocks';
import { LinkedWidgetUtils } from '@blocksuite/affine/blocks';
import type { DocMeta } from '@blocksuite/affine/store';
import { type FrameworkProvider, WorkspaceService } from '@toeverything/infra';
import { AtMenuConfigService } from '@affine/core/modules/at-menu-config/services';
import type { LinkedWidgetConfig } from '@blocksuite/affine/blocks';
import { type FrameworkProvider } from '@toeverything/infra';
function createNewDocMenuGroup(
framework: FrameworkProvider,
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
) {
const originalNewDocMenuGroup = LinkedWidgetUtils.createNewDocMenuGroup(
query,
abort,
editorHost,
inlineEditor
);
const items = Array.isArray(originalNewDocMenuGroup.items)
? originalNewDocMenuGroup.items
: originalNewDocMenuGroup.items.value;
// Patch the import item, to use the custom import dialog.
const importItemIndex = items.findIndex(item => item.key === 'import');
if (importItemIndex === -1) {
return originalNewDocMenuGroup;
}
const originalImportItem = items[importItemIndex];
const customImportItem = {
...originalImportItem,
action: () => {
abort();
track.doc.editor.atMenu.import();
framework
.get(WorkspaceDialogService)
.open('import', undefined, payload => {
if (!payload) {
return;
}
// If the imported file is a workspace file, insert the entry page node.
const { docIds, entryId, isWorkspaceFile } = payload;
if (isWorkspaceFile && entryId) {
LinkedWidgetUtils.insertLinkedNode({
inlineEditor,
docId: entryId,
});
return;
}
// Otherwise, insert all the doc nodes.
for (const docId of docIds) {
LinkedWidgetUtils.insertLinkedNode({
inlineEditor,
docId,
});
}
});
},
};
// only replace the original import item
items.splice(importItemIndex, 1, customImportItem);
return originalNewDocMenuGroup;
}
// TODO: fix the type
export function createLinkedWidgetConfig(
framework: FrameworkProvider
): Partial<LinkedWidgetConfig> {
return {
getMenus: (
query: string,
abort: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
) => {
const currentWorkspace = framework.get(WorkspaceService).workspace;
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
const journalService = framework.get(JournalService);
const isJournal = (d: DocMeta) =>
!!journalService.journalDate$(d.id).value;
const docDisplayMetaService = framework.get(DocDisplayMetaService);
const docMetas = rawMetas
.filter(meta => {
if (isJournal(meta) && !meta.updatedDate) {
return false;
}
return !meta.trash;
})
.map(meta => {
const title = docDisplayMetaService.title$(meta.id, {
reference: true,
}).value;
return {
...meta,
title: I18n.t(title),
};
})
.filter(({ title }) => isFuzzyMatch(title, query));
// TODO need i18n if BlockSuite supported
const MAX_DOCS = 6;
return Promise.resolve([
{
name: 'Link to Doc',
items: docMetas.map(doc => ({
key: doc.id,
name: doc.title,
icon: docDisplayMetaService
.icon$(doc.id, {
type: 'lit',
reference: true,
})
.value(),
action: () => {
abort();
LinkedWidgetUtils.insertLinkedNode({
inlineEditor,
docId: doc.id,
});
track.doc.editor.atMenu.linkDoc();
},
})),
maxDisplay: MAX_DOCS,
overflowText: `${docMetas.length - MAX_DOCS} more docs`,
},
createNewDocMenuGroup(
framework,
query,
abort,
editorHost,
inlineEditor
),
]);
},
mobile: {
useScreenHeight: BUILD_CONFIG.isIOS,
scrollContainer: window,
scrollTopOffset: () => {
const header = document.querySelector('header');
if (!header) return 0;
const { y, height } = header.getBoundingClientRect();
return y + height;
},
},
};
}
/**
* Checks if the name is a fuzzy match of the query.
*
* @example
* ```ts
* const name = 'John Smith';
* const query = 'js';
* const isMatch = isFuzzyMatch(name, query);
* // isMatch: true
* ```
*/
function isFuzzyMatch(name: string, query: string) {
const pureName = name
.trim()
.toLowerCase()
.split('')
.filter(char => char !== ' ')
.join('');
const regex = new RegExp(
query
.split('')
.filter(char => char !== ' ')
.map(item => `${escapeRegExp(item)}.*`)
.join(''),
'i'
);
return regex.test(pureName);
}
function escapeRegExp(input: string) {
// escape regex characters in the input string to prevent regex format errors
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return framework.get(AtMenuConfigService).getConfig();
}

View File

@@ -1,23 +1,17 @@
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { JournalService } from '@affine/core/modules/journal';
import {
JOURNAL_DATE_FORMAT,
JournalService,
type MaybeDate,
} from '@affine/core/modules/journal';
import { i18nTime } from '@affine/i18n';
import { track } from '@affine/track';
import { Text } from '@blocksuite/affine/store';
import {
type DocProps,
DocsService,
initDocFromProps,
useService,
useServices,
} from '@toeverything/infra';
import { DocsService, useService, useServices } from '@toeverything/infra';
import dayjs from 'dayjs';
import { useCallback, useMemo } from 'react';
import { WorkbenchService } from '../../modules/workbench';
type MaybeDate = Date | string | number;
export const JOURNAL_DATE_FORMAT = 'YYYY-MM-DD';
function isJournalString(j?: string | false) {
return j ? !!j?.match(/^\d{4}-\d{2}-\d{2}$/) : false;
}
@@ -33,73 +27,27 @@ function toDayjs(j?: string | false) {
* @deprecated use `JournalService` directly
*/
export const useJournalHelper = () => {
const { docsService, editorSettingService, journalService } = useServices({
const { journalService } = useServices({
DocsService,
EditorSettingService,
JournalService,
});
/**
* @internal
*/
const _createJournal = useCallback(
(maybeDate: MaybeDate) => {
const day = dayjs(maybeDate);
const title = day.format(JOURNAL_DATE_FORMAT);
const docRecord = docsService.createDoc();
const { doc, release } = docsService.open(docRecord.id);
docsService.list.setPrimaryMode(docRecord.id, 'page');
// set created date to match the journal date
docRecord.setMeta({
createDate: dayjs()
.set('year', day.year())
.set('month', day.month())
.set('date', day.date())
.toDate()
.getTime(),
});
const docProps: DocProps = {
page: { title: new Text(title) },
note: editorSettingService.editorSetting.get('affine:note'),
};
initDocFromProps(doc.blockSuiteDoc, docProps);
release();
journalService.setJournalDate(docRecord.id, title);
return docRecord;
},
[docsService, editorSettingService.editorSetting, journalService]
);
/**
* query all journals by date
*/
const getJournalsByDate = useCallback(
(maybeDate: MaybeDate) => {
return journalService.getJournalsByDate(
dayjs(maybeDate).format(JOURNAL_DATE_FORMAT)
);
},
[journalService]
);
/**
* get journal by date, create one if not exist
*/
const getJournalByDate = useCallback(
(maybeDate: MaybeDate) => {
const pages = getJournalsByDate(maybeDate);
if (pages.length) return pages[0];
return _createJournal(maybeDate);
return journalService.ensureJournalByDate(maybeDate);
},
[_createJournal, getJournalsByDate]
[journalService]
);
return useMemo(
() => ({
getJournalsByDate,
getJournalByDate,
}),
[getJournalsByDate, getJournalByDate]
[getJournalByDate]
);
};

View File

@@ -15,6 +15,7 @@ import { ImportDialog } from './import';
import { ImportTemplateDialog } from './import-template';
import { ImportWorkspaceDialog } from './import-workspace';
import { CollectionSelectorDialog } from './selectors/collection';
import { DateSelectorDialog } from './selectors/date';
import { DocSelectorDialog } from './selectors/doc';
import { TagSelectorDialog } from './selectors/tag';
import { SettingDialog } from './setting';
@@ -36,6 +37,7 @@ const WORKSPACE_DIALOGS = {
'tag-selector': TagSelectorDialog,
'doc-selector': DocSelectorDialog,
'collection-selector': CollectionSelectorDialog,
'date-selector': DateSelectorDialog,
import: ImportDialog,
} satisfies {
[key in keyof WORKSPACE_DIALOG_SCHEMA]?: React.FC<

View File

@@ -0,0 +1,76 @@
import { DatePicker, Menu } from '@affine/component';
import type { DialogComponentProps } from '@affine/core/modules/dialogs';
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
import { useI18n } from '@affine/i18n';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useState } from 'react';
/**
* A global date selector popover, mainly used in blocksuite editor
*/
export const DateSelectorDialog = ({
close,
position,
onSelect,
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['date-selector']>) => {
const [selectedDate, setSelectedDate] = useState<string>();
const t = useI18n();
const onClose = useCallback(
(open: boolean) => {
if (!open) {
close();
}
},
[close]
);
const handleSelect = useCallback(
(date?: string) => {
setSelectedDate(date);
onSelect?.(date);
},
[onSelect]
);
return (
<Menu
rootOptions={{
modal: true,
open: true,
onOpenChange: onClose,
}}
contentOptions={{
side: 'bottom',
sideOffset: 8,
align: 'start',
style: {
padding: 20,
borderRadius: 8,
background: cssVarV2('layer/background/primary'),
},
}}
items={
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
value={selectedDate}
onChange={handleSelect}
/>
}
>
{/* hack the menu positioning using the following fixed anchor */}
<div
style={{
position: 'fixed',
left: position[0],
top: position[1],
width: position[2],
height: position[3],
}}
/>
</Menu>
);
};

View File

@@ -0,0 +1,24 @@
import {
type Framework,
WorkspaceScope,
WorkspaceService,
} from '@toeverything/infra';
import { WorkspaceDialogService } from '../dialogs';
import { DocDisplayMetaService } from '../doc-display-meta';
import { JournalService } from '../journal';
import { RecentDocsService } from '../quicksearch';
import { AtMenuConfigService } from './services';
export function configAtMenuConfigModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(AtMenuConfigService, [
WorkspaceService,
JournalService,
DocDisplayMetaService,
WorkspaceDialogService,
RecentDocsService,
WorkspaceDialogService,
]);
}

View File

@@ -0,0 +1,166 @@
/**
* @vitest-environment happy-dom
*/
import { JOURNAL_DATE_FORMAT } from '@affine/core/modules/journal';
import { I18n } from '@affine/i18n';
import dayjs from 'dayjs';
import { describe, expect, test } from 'vitest';
import { suggestJournalDate } from '../../services/index';
describe('suggestJournalDate', () => {
test('today', () => {
expect(suggestJournalDate('t')).toEqual({
dateString: dayjs().format(JOURNAL_DATE_FORMAT),
alias: I18n.t('com.affine.today'),
});
});
test('yesterday', () => {
expect(suggestJournalDate('y')).toEqual({
dateString: dayjs().subtract(1, 'day').format(JOURNAL_DATE_FORMAT),
alias: I18n.t('com.affine.yesterday'),
});
});
test('tomorrow', () => {
expect(suggestJournalDate('tm')).toEqual({
dateString: dayjs().add(1, 'day').format(JOURNAL_DATE_FORMAT),
alias: I18n.t('com.affine.tomorrow'),
});
});
test('last week - monday', () => {
expect(suggestJournalDate('lm')).toEqual({
dateString: dayjs()
.subtract(1, 'week')
.startOf('week')
.add(1, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Last Monday',
});
});
test('last week - tuesday', () => {
expect(suggestJournalDate('ltt')).toEqual({
dateString: dayjs()
.subtract(1, 'week')
.startOf('week')
.add(2, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Last Tuesday',
});
});
test('last week - wednesday', () => {
expect(suggestJournalDate('lw')).toEqual({
dateString: dayjs()
.subtract(1, 'week')
.startOf('week')
.add(3, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Last Wednesday',
});
});
test('last week - thursday', () => {
expect(suggestJournalDate('lth')).toEqual({
dateString: dayjs()
.subtract(1, 'week')
.startOf('week')
.add(4, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Last Thursday',
});
});
test('last week - friday', () => {
expect(suggestJournalDate('lf')).toEqual({
dateString: dayjs()
.subtract(1, 'week')
.startOf('week')
.add(5, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Last Friday',
});
});
test('next week - monday', () => {
expect(suggestJournalDate('nm')).toEqual({
dateString: dayjs()
.add(1, 'week')
.startOf('week')
.add(1, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Next Monday',
});
});
test('next week - tuesday', () => {
expect(suggestJournalDate('nxtus')).toEqual({
dateString: dayjs()
.add(1, 'week')
.startOf('week')
.add(2, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Next Tuesday',
});
});
test('next week - wednesday', () => {
expect(suggestJournalDate('nw')).toEqual({
dateString: dayjs()
.add(1, 'week')
.startOf('week')
.add(3, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Next Wednesday',
});
});
test('next week - thursday', () => {
expect(suggestJournalDate('nth')).toEqual({
dateString: dayjs()
.add(1, 'week')
.startOf('week')
.add(4, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Next Thursday',
});
});
test('next week - friday', () => {
expect(suggestJournalDate('nf')).toEqual({
dateString: dayjs()
.add(1, 'week')
.startOf('week')
.add(5, 'day')
.format(JOURNAL_DATE_FORMAT),
alias: 'Next Friday',
});
});
test('dec', () => {
const year = dayjs().year();
const date = dayjs().date();
expect(suggestJournalDate(`dec`)).toEqual({
dateString: dayjs(`${year}-12-${date}`).format(JOURNAL_DATE_FORMAT),
});
});
test('dec 1', () => {
const year = dayjs().year();
expect(suggestJournalDate(`dec 10`)).toEqual({
dateString: dayjs(`${year}-12-10`).format(JOURNAL_DATE_FORMAT),
});
});
test('dec 33', () => {
const year = dayjs().year();
const date = dayjs().date();
expect(suggestJournalDate(`dec 33`)).toEqual({
dateString: dayjs(`${year}-12-${date}`).format(JOURNAL_DATE_FORMAT),
});
});
});

View File

@@ -0,0 +1,488 @@
import { I18n, i18nTime } from '@affine/i18n';
import track from '@affine/track';
import {
type AffineInlineEditor,
type LinkedMenuGroup,
type LinkedMenuItem,
type LinkedWidgetConfig,
LinkedWidgetUtils,
} from '@blocksuite/affine/blocks';
import type { EditorHost } from '@blocksuite/block-std';
import { DateTimeIcon } from '@blocksuite/icons/lit';
import type { DocMeta } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import type { WorkspaceService } from '@toeverything/infra';
import { Service } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import dayjs from 'dayjs';
import { html } from 'lit';
import type { WorkspaceDialogService } from '../../dialogs';
import type { DocDisplayMetaService } from '../../doc-display-meta';
import { JOURNAL_DATE_FORMAT, type JournalService } from '../../journal';
import type { RecentDocsService } from '../../quicksearch';
const MAX_DOCS = 3;
const LOAD_CHUNK = 100;
export class AtMenuConfigService extends Service {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly journalService: JournalService,
private readonly docDisplayMetaService: DocDisplayMetaService,
private readonly dialogService: WorkspaceDialogService,
private readonly recentDocsService: RecentDocsService,
private readonly workspaceDialogService: WorkspaceDialogService
) {
super();
}
// todo(@peng17): maybe refactor the config using entity, so that each config
// can be reactive to the query, instead of recreating the whole config?
getConfig(): Partial<LinkedWidgetConfig> {
return {
getMenus: this.getMenusFn(),
mobile: this.getMobileConfig(),
};
}
private insertDoc(inlineEditor: AffineInlineEditor, id: string) {
LinkedWidgetUtils.insertLinkedNode({
inlineEditor,
docId: id,
});
}
private linkToDocGroup(
query: string,
close: () => void,
inlineEditor: AffineInlineEditor,
abortSignal: AbortSignal
): LinkedMenuGroup {
const currentWorkspace = this.workspaceService.workspace;
const rawMetas = currentWorkspace.docCollection.meta.docMetas;
const isJournal = (d: DocMeta) =>
!!this.journalService.journalDate$(d.id).value;
const docItems = signal<LinkedMenuItem[]>([]);
// recent docs should be at the top
const recentDocs = this.recentDocsService.getRecentDocs();
const sortedRawMetas =
query.trim().length === 0
? rawMetas.toSorted((a, b) => {
const indexA = recentDocs.findIndex(doc => doc.id === a.id);
const indexB = recentDocs.findIndex(doc => doc.id === b.id);
if (indexA > -1 && indexB < 0) {
return -1;
} else if (indexA < 0 && indexB > -1) {
return 1;
} else if (indexA > -1 && indexB > -1) {
return indexA - indexB;
}
return Number.MAX_SAFE_INTEGER;
})
: rawMetas;
const docDisplayMetaService = this.docDisplayMetaService;
const toDocItem = (meta: DocMeta): LinkedMenuItem | null => {
if (isJournal(meta) && !meta.updatedDate) {
return null;
}
if (meta.trash) {
return null;
}
let title = docDisplayMetaService.title$(meta.id, {
reference: true,
}).value;
if (typeof title === 'object' && 'i18nKey' in title) {
title = I18n.t(title);
}
if (!fuzzyMatch(title, query)) {
return null;
}
return {
name: title,
key: meta.id,
icon: docDisplayMetaService
.icon$(meta.id, {
type: 'lit',
reference: true,
})
.value(),
action: () => {
close();
track.doc.editor.atMenu.linkDoc();
this.insertDoc(inlineEditor, meta.id);
},
};
};
(async () => {
for (const [index, meta] of sortedRawMetas.entries()) {
if (abortSignal.aborted) {
return;
}
const item = toDocItem(meta);
if (item) {
docItems.value = [...docItems.value, item];
}
if (index % LOAD_CHUNK === 0) {
// use scheduler.yield?
await new Promise(resolve => setTimeout(resolve, 0));
}
}
})().catch(console.error);
return {
name: I18n.t('com.affine.editor.at-menu.link-to-doc'),
items: docItems,
maxDisplay: MAX_DOCS,
get overflowText() {
const overflowCount = docItems.value.length - MAX_DOCS;
return I18n.t('com.affine.editor.at-menu.more-docs-hint', {
count: overflowCount > 100 ? '100+' : overflowCount,
});
},
};
}
private newDocMenuGroup(
query: string,
close: () => void,
editorHost: EditorHost,
inlineEditor: AffineInlineEditor
): LinkedMenuGroup {
const originalNewDocMenuGroup = LinkedWidgetUtils.createNewDocMenuGroup(
query,
close,
editorHost,
inlineEditor
);
// Patch the import item, to use the custom import dialog.
const items = Array.isArray(originalNewDocMenuGroup.items)
? originalNewDocMenuGroup.items
: originalNewDocMenuGroup.items.value;
const newDocItem = items.find(item => item.key === 'create');
const importItem = items.find(item => item.key === 'import');
// should have both new doc and import item
if (!newDocItem || !importItem) {
return originalNewDocMenuGroup;
}
const customNewDocItem: LinkedMenuItem = {
...newDocItem,
name: I18n.t('com.affine.editor.at-menu.create-doc', {
name: query || I18n.t('Untitled'),
}),
};
const customImportItem: LinkedMenuItem = {
...importItem,
name: I18n.t('com.affine.editor.at-menu.import'),
action: () => {
close();
track.doc.editor.atMenu.import();
this.dialogService.open('import', undefined, payload => {
if (!payload) {
return;
}
// If the imported file is a workspace file, insert the entry page node.
const { docIds, entryId, isWorkspaceFile } = payload;
if (isWorkspaceFile && entryId) {
LinkedWidgetUtils.insertLinkedNode({
inlineEditor,
docId: entryId,
});
return;
}
// Otherwise, insert all the doc nodes.
for (const docId of docIds) {
this.insertDoc(inlineEditor, docId);
}
});
},
};
return {
...originalNewDocMenuGroup,
name: I18n.t('com.affine.editor.at-menu.new-doc'),
items: [customNewDocItem, customImportItem],
};
}
private journalGroup(
query: string,
close: () => void,
inlineEditor: AffineInlineEditor
): LinkedMenuGroup {
const suggestedDate = suggestJournalDate(query);
const items: LinkedMenuItem[] = [
{
icon: DateTimeIcon(),
key: 'date-picker',
name: I18n.t('com.affine.editor.at-menu.date-picker'),
action: () => {
close();
const getRect = () => {
let rect = inlineEditor.getNativeRange()?.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) {
rect = inlineEditor.rootElement.getBoundingClientRect();
}
return rect;
};
const { x, y, width, height } = getRect();
const id = this.workspaceDialogService.open('date-selector', {
position: [x, y, width, height || 20],
onSelect: date => {
if (date) {
onSelectDate(date);
this.workspaceDialogService.close(id);
}
},
});
},
},
];
const onSelectDate = (date: string) => {
close();
const doc = this.journalService.ensureJournalByDate(date);
this.insertDoc(inlineEditor, doc.id);
};
if (suggestedDate) {
const { dateString, alias } = suggestedDate;
const dateDisplay = i18nTime(dateString, {
absolute: { accuracy: 'day' },
});
const icon = this.docDisplayMetaService.getJournalIcon(dateString, {
type: 'lit',
});
items.unshift({
icon: icon(),
key: dateString,
name: alias
? html`${alias},
<span style="color: ${cssVarV2('text/secondary')}"
>${dateDisplay}</span
>`
: dateDisplay,
action: () => {
onSelectDate(dateString);
},
});
}
return {
name: I18n.t('com.affine.editor.at-menu.journal'),
items,
};
}
private getMenusFn(): LinkedWidgetConfig['getMenus'] {
return (query, close, editorHost, inlineEditor, abortSignal) => {
return [
this.journalGroup(query, close, inlineEditor),
this.linkToDocGroup(query, close, inlineEditor, abortSignal),
this.newDocMenuGroup(query, close, editorHost, inlineEditor),
];
};
}
private getMobileConfig(): Partial<LinkedWidgetConfig['mobile']> {
return {
useScreenHeight: BUILD_CONFIG.isIOS,
scrollContainer: window,
scrollTopOffset: () => {
const header = document.querySelector('header');
if (!header) return 0;
const { y, height } = header.getBoundingClientRect();
return y + height;
},
};
}
}
/**
* Checks if the name is a fuzzy match of the query.
*
* @example
* ```ts
* const name = 'John Smith';
* const query = 'js';
* const isMatch = fuzzyMatch(name, query);
* // isMatch: true
* ```
*
* if initialMatch = true, the first char must match as well
*/
function fuzzyMatch(name: string, query: string, matchInitial?: boolean) {
const pureName = name
.trim()
.toLowerCase()
.split('')
.filter(char => char !== ' ')
.join('');
const regex = new RegExp(
query
.split('')
.filter(char => char !== ' ')
.map(item => `${escapeRegExp(item)}.*`)
.join(''),
'i'
);
if (matchInitial && query.length > 0 && !pureName.startsWith(query[0])) {
return false;
}
return regex.test(pureName);
}
function escapeRegExp(input: string) {
// escape regex characters in the input string to prevent regex format errors
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// todo: infer locale from user's locale?
const monthNames = Array.from({ length: 12 }, (_, index) =>
new Intl.DateTimeFormat('en-US', { month: 'long' }).format(
new Date(2024, index)
)
);
// todo: infer locale from user's locale?
const weekDayNames = Array.from({ length: 7 }, (_, index) =>
new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(
new Date(2024, 0, index)
)
);
export function suggestJournalDate(query: string): {
dateString: string;
alias?: string;
} | null {
// given a query string, suggest a journal date
// if the query is empty or, starts with "t" AND matches today
// -> suggest today's date
// if the query starts with "y" AND matches "yesterday"
// -> suggest yesterday's date
// if the query starts with "l" AND matches last
// -> suggest last week's date
// if the query starts with "n" AND matches "next"
// -> suggest next week's date
// if the query starts with the first letter of a month and matches the month name
// -> if the trailing part matches a number
// -> suggest the date of the month
// -> otherwise, suggest the current day of the month
// otherwise, return null
query = query.trim().toLowerCase().split(' ').join('');
if (query === '' || fuzzyMatch('today', query, true)) {
return {
dateString: dayjs().format(JOURNAL_DATE_FORMAT),
alias: I18n.t('com.affine.today'),
};
}
if (fuzzyMatch('tomorrow', query, true)) {
return {
dateString: dayjs().add(1, 'day').format(JOURNAL_DATE_FORMAT),
alias: I18n.t('com.affine.tomorrow'),
};
}
if (fuzzyMatch('yesterday', query, true)) {
return {
dateString: dayjs().subtract(1, 'day').format(JOURNAL_DATE_FORMAT),
alias: I18n.t('com.affine.yesterday'),
};
}
// next week dates, start from monday
const nextWeekDates = Array.from({ length: 7 }, (_, index) =>
dayjs()
.add(1, 'week')
.startOf('week')
.add(index, 'day')
.format(JOURNAL_DATE_FORMAT)
).map(date => ({
dateString: date,
alias: I18n.t('com.affine.next-week', {
weekday: weekDayNames[dayjs(date).day()],
}),
}));
const lastWeekDates = Array.from({ length: 7 }, (_, index) =>
dayjs()
.subtract(1, 'week')
.startOf('week')
.add(index, 'day')
.format(JOURNAL_DATE_FORMAT)
).map(date => ({
dateString: date,
alias: I18n.t('com.affine.last-week', {
weekday: weekDayNames[dayjs(date).day()],
}),
}));
for (const date of [...nextWeekDates, ...lastWeekDates]) {
const matched = fuzzyMatch(date.alias, query, true);
if (matched) {
return date;
}
}
// if query is a string that starts with alphabet letters and/or numbers
const regex = new RegExp(`^([a-z]+)(\\d*)$`, 'i');
const matched = query.match(regex);
if (matched) {
const [_, letters, numbers] = matched;
for (const month of monthNames) {
const monthMatched = fuzzyMatch(month, letters, true);
if (monthMatched) {
let day = numbers ? parseInt(numbers) : dayjs().date();
const invalidDay = day < 1 || day > 31;
if (invalidDay) {
// fallback to today's day
day = dayjs().date();
}
const year = dayjs().year();
return {
dateString: dayjs(`${year}-${month}-${day}`).format(
JOURNAL_DATE_FORMAT
),
};
}
}
}
return null;
}

View File

@@ -51,6 +51,10 @@ export type WORKSPACE_DIALOG_SCHEMA = {
init: string[];
onBeforeConfirm?: (ids: string[], cb: () => void) => void;
}) => string[];
'date-selector': (props: {
position: [number, number, number, number]; // [x, y, width, height]
onSelect?: (date?: string) => void;
}) => string;
import: () => {
docIds: string[];
entryId?: string;

View File

@@ -90,10 +90,38 @@ export class DocDisplayMetaService extends Service {
super();
}
getJournalIcon(
journalDate: string | Dayjs,
options?: DocDisplayIconOptions<'rc'>
): typeof TodayIcon;
getJournalIcon(
journalDate: string | Dayjs,
options?: DocDisplayIconOptions<'lit'>
): typeof LitYesterdayIcon;
getJournalIcon<T extends IconType = 'rc'>(
journalDate: string | Dayjs,
options?: DocDisplayIconOptions<T>
): T extends 'rc' ? typeof TodayIcon : typeof LitTodayIcon;
getJournalIcon<T extends IconType = 'rc'>(
journalDate: string | Dayjs,
options?: DocDisplayIconOptions<T>
) {
const iconSet = icons[options?.type ?? 'rc'];
const day = dayjs(journalDate);
return day.isBefore(dayjs(), 'day')
? iconSet.YesterdayIcon
: day.isAfter(dayjs(), 'day')
? iconSet.TomorrowIcon
: iconSet.TodayIcon;
}
icon$<T extends IconType = 'rc'>(
docId: string,
options?: DocDisplayIconOptions<T>
): LiveData<T extends 'lit' ? typeof LitTodayIcon : typeof TodayIcon> {
) {
const iconSet = icons[options?.type ?? 'rc'];
return LiveData.computed(get => {
@@ -113,13 +141,7 @@ export class DocDisplayMetaService extends Service {
this.journalService.journalDate$(docId).value
);
if (journalDate) {
if (!options?.compareDate) return iconSet.TodayIcon;
const compareDate = dayjs(options?.compareDate);
return journalDate.isBefore(compareDate, 'day')
? iconSet.YesterdayIcon
: journalDate.isAfter(compareDate, 'day')
? iconSet.TomorrowIcon
: iconSet.TodayIcon;
return this.getJournalIcon(journalDate, options);
}
// reference icon

View File

@@ -2,6 +2,7 @@ import { configureQuotaModule } from '@affine/core/modules/quota';
import { configureInfraModules, type Framework } from '@toeverything/infra';
import { configureAppSidebarModule } from './app-sidebar';
import { configAtMenuConfigModule } from './at-menu-config';
import { configureCloudModule } from './cloud';
import { configureCollectionModule } from './collection';
import { configureDialogModule } from './dialogs';
@@ -67,4 +68,5 @@ export function configureCommonModules(framework: Framework) {
configureDialogModule(framework);
configureDocInfoModule(framework);
configureOpenInApp(framework);
configAtMenuConfigModule(framework);
}

View File

@@ -6,17 +6,22 @@ import {
WorkspaceScope,
} from '@toeverything/infra';
import { EditorSettingService } from '../editor-setting';
import { JournalService } from './services/journal';
import { JournalDocService } from './services/journal-doc';
import { JournalStore } from './store/journal';
export { JournalService } from './services/journal';
export {
JOURNAL_DATE_FORMAT,
JournalService,
type MaybeDate,
} from './services/journal';
export { JournalDocService } from './services/journal-doc';
export function configureJournalModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(JournalService, [JournalStore])
.service(JournalService, [JournalStore, DocsService, EditorSettingService])
.store(JournalStore, [DocsService])
.scope(DocScope)
.service(JournalDocService, [DocService, JournalService]);

View File

@@ -1,10 +1,21 @@
import { LiveData, Service } from '@toeverything/infra';
import { Text } from '@blocksuite/affine/store';
import type { DocProps, DocsService } from '@toeverything/infra';
import { initDocFromProps, LiveData, Service } from '@toeverything/infra';
import dayjs from 'dayjs';
import type { EditorSettingService } from '../../editor-setting';
import type { JournalStore } from '../store/journal';
export type MaybeDate = Date | string | number;
export const JOURNAL_DATE_FORMAT = 'YYYY-MM-DD';
export class JournalService extends Service {
constructor(private readonly store: JournalStore) {
constructor(
private readonly store: JournalStore,
private readonly docsService: DocsService,
private readonly editorSettingService: EditorSettingService
) {
super();
}
@@ -29,10 +40,40 @@ export class JournalService extends Service {
this.store.removeDocJournalDate(docId);
}
getJournalsByDate(date: string) {
return this.store.getDocsByJournalDate(date);
}
journalsByDate$(date: string) {
return this.store.docsByJournalDate$(date);
}
private createJournal(maybeDate: MaybeDate) {
const day = dayjs(maybeDate);
const title = day.format(JOURNAL_DATE_FORMAT);
const docRecord = this.docsService.createDoc();
const { doc, release } = this.docsService.open(docRecord.id);
this.docsService.list.setPrimaryMode(docRecord.id, 'page');
// set created date to match the journal date
docRecord.setMeta({
createDate: dayjs()
.set('year', day.year())
.set('month', day.month())
.set('date', day.date())
.toDate()
.getTime(),
});
const docProps: DocProps = {
page: { title: new Text(title) },
note: this.editorSettingService.editorSetting.get('affine:note'),
};
initDocFromProps(doc.blockSuiteDoc, docProps);
release();
this.setJournalDate(docRecord.id, title);
return docRecord;
}
ensureJournalByDate(maybeDate: MaybeDate) {
const day = dayjs(maybeDate);
const title = day.format(JOURNAL_DATE_FORMAT);
const docs = this.journalsByDate$(title).value;
if (docs.length) return docs[0];
return this.createJournal(maybeDate);
}
}

View File

@@ -7,7 +7,7 @@
"en": 100,
"es-AR": 14,
"es-CL": 15,
"es": 14,
"es": 13,
"fr": 67,
"hi": 2,
"it-IT": 1,

View File

@@ -1372,6 +1372,8 @@
"com.affine.toastMessage.successfullyDeleted": "Successfully deleted",
"com.affine.today": "Today",
"com.affine.tomorrow": "Tomorrow",
"com.affine.last-week": "Last {{weekday}}",
"com.affine.next-week": "Next {{weekday}}",
"com.affine.top-tip.mobile": "Limited to view-only on mobile.",
"com.affine.trashOperation.delete": "Delete",
"com.affine.trashOperation.delete.description": "Once deleted, you can't undo this action. Do you confirm?",
@@ -1470,5 +1472,12 @@
"com.affine.m.selector.journal-menu.conflicts": "Duplicate Entries in Today's Journal",
"com.affine.attachment.preview.error.title": "Unable to preview this file",
"com.affine.attachment.preview.error.subtitle": "file type not supported.",
"com.affine.pdf.page.render.error": "Failed to render page."
"com.affine.pdf.page.render.error": "Failed to render page.",
"com.affine.editor.at-menu.link-to-doc": "Link to Doc",
"com.affine.editor.at-menu.new-doc": "New Doc",
"com.affine.editor.at-menu.create-doc": "Create \"{{name}}\" Doc",
"com.affine.editor.at-menu.import": "Import",
"com.affine.editor.at-menu.more-docs-hint": "{{count}} more docs",
"com.affine.editor.at-menu.journal": "Journal",
"com.affine.editor.at-menu.date-picker": "Select a specific date"
}