From 279b7bb64feeba65e25df22a74250aec01e3a362 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Tue, 13 Jan 2026 02:38:16 +0800 Subject: [PATCH] feat(core): integrate google calendar sync (#14248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #14170 fix #13893 fix #13673 fix #13543 fix #13308 fix #7607 #### PR Dependency Tree * **PR #14247** * **PR #14248** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Integrations panel in Account Settings to link/unlink calendar providers. * Collapsible settings wrapper for improved layout. * **Improvements** * Calendar system reworked: per-account calendar groups, simplified toggles with explicit Save, richer event display (multi-dot date indicators), improved event time/title handling across journal views. * **Localization** * Added calendar keys: save-error, no-journal, no-calendar; removed legacy duplicate-error keys. ✏️ Tip: You can customize this high-level summary in your review settings. --- .../native/src/doc_loader/loader/html.rs | 13 +- .../src/ui/date-picker/calendar/calendar.tsx | 26 +- .../src/ui/date-picker/calendar/types.tsx | 5 + packages/frontend/core/package.json | 1 - .../dialogs/setting/account-setting/index.tsx | 2 + .../account-setting/integrations-panel.css.ts | 132 ++++++++ .../account-setting/integrations-panel.tsx | 250 +++++++++++++++ .../general-setting/plans/ai/layout.tsx | 6 +- .../general-setting/plans/layout.css.ts | 21 -- .../setting/general-setting/plans/layout.tsx | 54 +--- .../src/desktop/dialogs/setting/layout.css.ts | 66 ++++ .../src/desktop/dialogs/setting/layout.tsx | 44 +++ .../integration/calendar/setting-panel.css.ts | 65 ++-- .../integration/calendar/setting-panel.tsx | 240 ++++++++------- .../calendar/subscription-setting.css.ts | 113 ++----- .../calendar/subscription-setting.tsx | 183 +++-------- .../integration/constants.tsx | 3 +- .../tabs/journal/calendar-events.tsx | 56 ++-- .../detail-page/tabs/journal/index.tsx | 147 +++++++-- .../detail-page/tabs/journal/journal.css.ts | 26 +- .../cloud/services/workspace-server.ts | 10 +- .../entities/calendar-subscription.ts | 138 --------- .../modules/integration/entities/calendar.ts | 291 ++++++++++++------ .../core/src/modules/integration/index.ts | 12 +- .../src/modules/integration/store/calendar.ts | 204 +++++------- .../core/src/modules/integration/type.ts | 15 +- .../integration/utils/calendar-url-parser.ts | 14 - .../modules/integration/utils/is-all-day.ts | 13 - packages/frontend/i18n/build.ts | 113 +++++++ packages/frontend/i18n/src/i18n.gen.ts | 22 +- packages/frontend/i18n/src/resources/ar.json | 3 - packages/frontend/i18n/src/resources/ca.json | 3 - packages/frontend/i18n/src/resources/de.json | 3 - .../frontend/i18n/src/resources/el-GR.json | 3 - packages/frontend/i18n/src/resources/en.json | 6 +- packages/frontend/i18n/src/resources/es.json | 3 - packages/frontend/i18n/src/resources/fa.json | 3 - packages/frontend/i18n/src/resources/fr.json | 3 - .../frontend/i18n/src/resources/it-IT.json | 3 - packages/frontend/i18n/src/resources/ja.json | 3 - packages/frontend/i18n/src/resources/ko.json | 3 - .../frontend/i18n/src/resources/nb-NO.json | 1 - packages/frontend/i18n/src/resources/pl.json | 3 - .../frontend/i18n/src/resources/pt-BR.json | 3 - packages/frontend/i18n/src/resources/ru.json | 3 - .../frontend/i18n/src/resources/sv-SE.json | 3 - packages/frontend/i18n/src/resources/uk.json | 3 - .../frontend/i18n/src/resources/zh-Hans.json | 3 - .../frontend/i18n/src/resources/zh-Hant.json | 3 - yarn.lock | 8 - 50 files changed, 1349 insertions(+), 1002 deletions(-) create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.css.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/layout.css.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/layout.tsx delete mode 100644 packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts delete mode 100644 packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts delete mode 100644 packages/frontend/core/src/modules/integration/utils/is-all-day.ts diff --git a/packages/common/native/src/doc_loader/loader/html.rs b/packages/common/native/src/doc_loader/loader/html.rs index 2b6ed061f..6d02ad8cf 100644 --- a/packages/common/native/src/doc_loader/loader/html.rs +++ b/packages/common/native/src/doc_loader/loader/html.rs @@ -65,10 +65,15 @@ mod tests { let documents = html_loader.load().unwrap(); - let expected = - "Example Domain\n\n This domain is for use in illustrative examples in documents. You may\n use \ - this domain in literature without prior coordination or asking for\n permission.\n More \ - information..."; + let expected = [ + "Example Domain", + "", + " This domain is for use in illustrative examples in documents. You may", + " use this domain in literature without prior coordination or asking for", + " permission.", + " More information...", + ] + .join("\n"); assert_eq!(documents.len(), 1); assert_eq!( diff --git a/packages/frontend/component/src/ui/date-picker/calendar/calendar.tsx b/packages/frontend/component/src/ui/date-picker/calendar/calendar.tsx index bbcbbaf6c..03e2b9684 100644 --- a/packages/frontend/component/src/ui/date-picker/calendar/calendar.tsx +++ b/packages/frontend/component/src/ui/date-picker/calendar/calendar.tsx @@ -18,7 +18,15 @@ export type { DatePickerProps } from './types'; */ export const DatePicker = (props: DatePickerProps) => { const finalProps = { ...defaultDatePickerProps, ...props }; - const { value, gapX, gapY, cellFontSize, cellSize, onChange } = finalProps; + const { + value, + gapX, + gapY, + cellFontSize, + cellSize, + onChange, + onCursorChange: handleCursorChange, + } = finalProps; const [mode, setMode] = useState('day'); const [cursor, setCursor] = useState(dayjs(value)); @@ -41,12 +49,16 @@ export const DatePicker = (props: DatePickerProps) => { [onChange] ); - const onCursorChange = useCallback((newCursor: dayjs.Dayjs) => { - // validate range - if (newCursor.isBefore(DATE_MIN)) newCursor = dayjs(DATE_MIN); - else if (newCursor.isAfter(DATE_MAX)) newCursor = dayjs(DATE_MAX); - setCursor(newCursor); - }, []); + const onCursorChange = useCallback( + (newCursor: dayjs.Dayjs) => { + // validate range + if (newCursor.isBefore(DATE_MIN)) newCursor = dayjs(DATE_MIN); + else if (newCursor.isAfter(DATE_MAX)) newCursor = dayjs(DATE_MAX); + setCursor(newCursor); + handleCursorChange?.(newCursor); + }, + [handleCursorChange] + ); return (
void; + /** + * when cursor date changes (month navigation/keyboard) + */ + onCursorChange?: (cursor: dayjs.Dayjs) => void; + // style customizations monthHeaderCellClassName?: string; monthBodyCellClassName?: string; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 54097e8b3..9dd5eb942 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -65,7 +65,6 @@ "graphemer": "^1.4.0", "graphql": "^16.9.0", "history": "^5.3.0", - "ical.js": "^2.1.0", "idb": "^8.0.0", "idb-keyval": "^6.2.2", "image-blob-reduce": "^4.1.0", diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx index 0b33b2809..923af4863 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx @@ -22,6 +22,7 @@ import { AuthService, ServerService } from '../../../../modules/cloud'; import type { SettingState } from '../types'; import { AIUsagePanel } from './ai-usage-panel'; import { DeleteAccount } from './delete-account'; +import { IntegrationsPanel } from './integrations-panel'; import { StorageProgress } from './storage-progress'; import * as styles from './style.css'; @@ -241,6 +242,7 @@ export const AccountSetting = ({ {serverFeatures?.copilot && ( )} + , + }, + [CalendarProviderType.CalDAV]: { + label: 'CalDAV', + icon: , + }, +} satisfies Partial< + Record +>; + +export const IntegrationsPanel = () => { + const t = useI18n(); + const gqlService = useService(GraphQLService); + const urlService = useService(UrlService); + const [accounts, setAccounts] = useState([]); + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [linking, setLinking] = useState(false); + const [unlinkingAccountId, setUnlinkingAccountId] = useState( + null + ); + const [openedExternalWindow, setOpenedExternalWindow] = useState(false); + + const revalidate = useCallback( + async (signal?: AbortSignal) => { + setLoading(true); + try { + const [accountsData, providersData] = await Promise.all([ + gqlService.gql({ + query: calendarAccountsQuery, + context: { signal }, + }), + gqlService.gql({ + query: calendarProvidersQuery, + context: { signal }, + }), + ]); + setAccounts(accountsData.calendarAccounts); + setProviders(providersData.calendarProviders); + } catch (error) { + if ( + signal?.aborted || + (error instanceof UserFriendlyError && error.is('REQUEST_ABORTED')) + ) { + return; + } + + notify.error({ + title: 'Failed to load calendar accounts', + message: String(error) || undefined, + }); + } finally { + setLoading(false); + } + }, + [gqlService] + ); + + useEffect(() => { + const controller = new AbortController(); + revalidate(controller.signal).catch(() => undefined); + return () => controller.abort(); + }, [revalidate]); + + useEffect(() => { + if (!openedExternalWindow) return; + const handleFocus = () => { + revalidate().catch(() => undefined); + }; + window.addEventListener('focus', handleFocus); + return () => window.removeEventListener('focus', handleFocus); + }, [openedExternalWindow, revalidate]); + + const providerOptions = useMemo(() => { + return providers.map(provider => { + const meta = providerMeta[provider]; + return { + provider, + label: meta?.label ?? provider, + icon: meta?.icon, + }; + }); + }, [providers]); + + const handleLink = useCallback( + async (provider: CalendarProviderType) => { + setLinking(true); + try { + const data = await gqlService.gql({ + query: linkCalendarAccountMutation, + variables: { + input: { + provider, + redirectUri: window.location.href, + }, + }, + }); + urlService.openExternal(data.linkCalendarAccount); + setOpenedExternalWindow(true); + } catch (error) { + notify.error({ title: 'Failed to start calendar authorization' }); + } finally { + setLinking(false); + } + }, + [gqlService, urlService] + ); + + const handleUnlink = useCallback( + async (accountId: string) => { + setUnlinkingAccountId(accountId); + try { + await gqlService.gql({ + query: unlinkCalendarAccountMutation, + variables: { + accountId, + }, + }); + setAccounts(prev => prev.filter(account => account.id !== accountId)); + } catch (error) { + notify.error({ title: 'Failed to unlink calendar account' }); + } finally { + setUnlinkingAccountId(null); + } + }, + [gqlService] + ); + + return ( + +
+
+
+ + {t['com.affine.integration.calendar.name']()} +
+ {providerOptions.length ? ( + ( + void handleLink(option.provider)} + > + {option.label} + + ))} + contentOptions={{ align: 'end' }} + > + + + ) : ( + + )} +
+ + {loading ? ( +
+ +
+ ) : accounts.length ? ( +
+ {accounts.map(account => { + const meta = providerMeta[account.provider]; + const title = account.displayName ?? account.email ?? account.id; + const subtitle = account.displayName ? account.email : null; + const showStatus = + account.status !== 'active' || Boolean(account.lastError); + + return ( +
+
+
+ {meta?.icon ?? } +
+
+
{title}
+
+ {subtitle ? {subtitle} : null} + + {account.calendarsCount} calendar + {account.calendarsCount === 1 ? '' : 's'} + +
+ {showStatus ? ( +
+ + Authorization failed. Please reconnect your account. +
+ ) : null} +
+
+
+ +
+
+ ); + })} +
+ ) : ( +
No calendar accounts linked yet.
+ )} +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/layout.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/layout.tsx index b0196b108..9b19ef64d 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/layout.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/layout.tsx @@ -1,7 +1,7 @@ import { useI18n } from '@affine/i18n'; import type { ReactNode } from 'react'; -import { PricingCollapsible } from '../layout'; +import { CollapsibleWrapper } from '../../../layout'; import * as styles from './ai-plan.css'; import { AIBenefits } from './benefits'; @@ -19,7 +19,7 @@ export const AIPlanLayout = ({ const title = t['com.affine.payment.ai.pricing-plan.title'](); return ( - +
@@ -42,6 +42,6 @@ export const AIPlanLayout = ({
- + ); }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.css.ts index 4a7f3796d..9de5290e7 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.css.ts @@ -44,27 +44,6 @@ export const allPlansLink = style({ fontSize: cssVar('fontXs'), }); -export const collapsibleHeader = style({ - display: 'flex', - alignItems: 'start', - marginBottom: 8, -}); -export const collapsibleHeaderContent = style({ - width: 0, - flex: 1, -}); -export const collapsibleHeaderTitle = style({ - fontWeight: 600, - fontSize: cssVar('fontBase'), - lineHeight: '22px', -}); -export const collapsibleHeaderCaption = style({ - fontWeight: 400, - fontSize: cssVar('fontXs'), - lineHeight: '20px', - color: cssVar('textSecondaryColor'), -}); - export const affineCloudHeader = style({ display: 'flex', justifyContent: 'space-between', diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx index 88bc8456a..262801d4d 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/layout.tsx @@ -1,17 +1,11 @@ -import { Divider, IconButton } from '@affine/component'; +import { Divider } from '@affine/component'; import { SettingHeader } from '@affine/component/setting-components'; import { useI18n } from '@affine/i18n'; -import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc'; -import * as Collapsible from '@radix-ui/react-collapsible'; +import { ArrowRightBigIcon } from '@blocksuite/icons/rc'; import * as ScrollArea from '@radix-ui/react-scroll-area'; -import { - type HtmlHTMLAttributes, - type ReactNode, - useCallback, - useRef, - useState, -} from 'react'; +import { type ReactNode, useRef } from 'react'; +import { CollapsibleWrapper } from '../../layout'; import * as styles from './layout.css'; export const SeeAllLink = () => { @@ -30,42 +24,6 @@ export const SeeAllLink = () => { ); }; -interface PricingCollapsibleProps extends Omit< - HtmlHTMLAttributes, - 'title' -> { - title?: ReactNode; - caption?: ReactNode; -} - -export const PricingCollapsible = ({ - title, - caption, - children, -}: PricingCollapsibleProps) => { - const [open, setOpen] = useState(true); - const toggle = useCallback(() => setOpen(prev => !prev), []); - return ( - -
-
-
{title}
-
{caption}
-
- - - -
- {children} -
- ); -}; - export interface PlanLayoutProps { cloud?: ReactNode; ai?: ReactNode; @@ -112,7 +70,7 @@ export const CloudPlanLayout = ({ scrollRef, }: PlanCardProps) => { return ( - +
{select}
{toggle}
@@ -134,6 +92,6 @@ export const CloudPlanLayout = ({ {lifetime}
) : null} -
+ ); }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/layout.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/layout.css.ts new file mode 100644 index 000000000..971d2286c --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/layout.css.ts @@ -0,0 +1,66 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; +export const plansLayoutRoot = style({ + display: 'flex', + flexDirection: 'column', + gap: '24px', +}); +export const scrollArea = style({ + marginLeft: 'calc(-1 * var(--setting-modal-gap-x))', + paddingLeft: 'var(--setting-modal-gap-x)', + width: 'var(--setting-modal-width)', + overflowX: 'auto', + // scrollSnapType: 'x mandatory', + paddingBottom: '21px', + /** Avoid box-shadow clipping */ + paddingTop: '21px', + marginTop: '-21px', +}); +export const scrollBar = style({ + display: 'flex', + alignItems: 'center', + userSelect: 'none', + touchAction: 'none', + height: '9px', + width: '100%', +}); +export const scrollThumb = style({ + background: cssVar('iconSecondary'), + opacity: 0.6, + overflow: 'hidden', + height: '4px', + borderRadius: '4px', + vars: { + '--radix-scroll-area-thumb-height': '4px', + }, +}); +export const allPlansLink = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', + color: cssVar('linkColor'), + background: 'transparent', + borderColor: 'transparent', + fontSize: cssVar('fontXs'), +}); + +export const collapsibleHeader = style({ + display: 'flex', + alignItems: 'start', + marginBottom: 8, +}); +export const collapsibleHeaderContent = style({ + width: 0, + flex: 1, +}); +export const collapsibleHeaderTitle = style({ + fontWeight: 600, + fontSize: cssVar('fontBase'), + lineHeight: '22px', +}); +export const collapsibleHeaderCaption = style({ + fontWeight: 400, + fontSize: cssVar('fontXs'), + lineHeight: '20px', + color: cssVar('textSecondaryColor'), +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/layout.tsx b/packages/frontend/core/src/desktop/dialogs/setting/layout.tsx new file mode 100644 index 000000000..3dc83ea84 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/layout.tsx @@ -0,0 +1,44 @@ +import { IconButton } from '@affine/component'; +import { ArrowUpSmallIcon } from '@blocksuite/icons/rc'; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { + type HtmlHTMLAttributes, + type ReactNode, + useCallback, + useState, +} from 'react'; + +import * as styles from './layout.css'; + +interface CollapsibleWrapperProps extends Omit< + HtmlHTMLAttributes, + 'title' +> { + title?: ReactNode; + caption?: ReactNode; +} + +export const CollapsibleWrapper = (props: CollapsibleWrapperProps) => { + const { title, caption, children } = props; + const [open, setOpen] = useState(true); + const toggle = useCallback(() => setOpen(prev => !prev), []); + return ( + +
+
+
{title}
+
{caption}
+
+ + + +
+ {children} +
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts index ccbbe03b2..9e7d4afc0 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.css.ts @@ -4,46 +4,61 @@ import { style } from '@vanilla-extract/css'; export const list = style({ display: 'flex', flexDirection: 'column', - gap: 24, + gap: 16, }); -export const newButton = style({ +export const group = style({ + padding: '12px 16px', + borderRadius: 8, + border: `1px solid ${cssVarV2.layer.insideBorder.border}`, + background: cssVarV2.layer.background.primary, + display: 'flex', + flexDirection: 'column', + gap: 12, +}); + +export const groupHeader = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, +}); + +export const groupTitle = style({ + fontSize: 14, + fontWeight: 500, + lineHeight: '22px', + color: cssVarV2.text.primary, +}); + +export const groupCaption = style({ + fontSize: 12, + lineHeight: '18px', color: cssVarV2.text.secondary, }); -export const newDialog = style({ - maxWidth: 480, +export const groupMeta = style({ + fontSize: 12, + lineHeight: '20px', + color: cssVarV2.text.secondary, + whiteSpace: 'nowrap', }); -export const newDialogHeader = style({ +export const groupList = style({ display: 'flex', - alignItems: 'center', + flexDirection: 'column', gap: 8, }); -export const newDialogTitle = style({ - fontSize: 15, - lineHeight: '24px', - fontWeight: 500, - color: cssVarV2.text.primary, -}); - -export const newDialogContent = style({ - marginTop: 16, - marginBottom: 20, -}); - -export const newDialogLabel = style({ +export const empty = style({ fontSize: 12, lineHeight: '20px', - fontWeight: 500, - color: cssVarV2.text.primary, - marginBottom: 4, + color: cssVarV2.text.secondary, + padding: '8px 0', }); -export const newDialogFooter = style({ +export const actions = style({ + marginTop: 12, display: 'flex', justifyContent: 'flex-end', - alignItems: 'center', - gap: 20, }); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx index 42e933ae7..b79bce93a 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/setting-panel.tsx @@ -1,20 +1,90 @@ -import { Button, Input, Modal, notify } from '@affine/component'; +import { Button, notify } from '@affine/component'; +import { WorkspaceServerService } from '@affine/core/modules/cloud'; import { IntegrationService } from '@affine/core/modules/integration'; import { useI18n } from '@affine/i18n'; -import track from '@affine/track'; -import { PlusIcon, TodayIcon } from '@blocksuite/icons/rc'; +import { TodayIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; -import { IntegrationCardIcon } from '../card'; import { IntegrationSettingHeader } from '../setting'; import * as styles from './setting-panel.css'; import { SubscriptionSetting } from './subscription-setting'; +const isSameSelection = (left: Set, right: Set) => { + if (left.size !== right.size) return false; + for (const id of left) { + if (!right.has(id)) return false; + } + return true; +}; + export const CalendarSettingPanel = () => { const t = useI18n(); const calendar = useService(IntegrationService).calendar; - const subscriptions = useLiveData(calendar.subscriptions$); + const workspaceServerService = useService(WorkspaceServerService); + const server = useLiveData(workspaceServerService.server$); + const accounts = useLiveData(calendar.accounts$); + const accountCalendars = useLiveData(calendar.accountCalendars$); + const workspaceCalendars = useLiveData(calendar.workspaceCalendars$); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [saving, setSaving] = useState(false); + + useEffect(() => { + calendar.revalidateWorkspaceCalendars().catch(() => undefined); + calendar.loadAccountCalendars().catch(() => undefined); + }, [calendar, server]); + + useEffect(() => { + const selected = new Set( + workspaceCalendars[0]?.items.map(item => item.subscriptionId) ?? [] + ); + setSelectedIds(selected); + }, [workspaceCalendars]); + + const orderedSubscriptions = useMemo(() => { + return accounts.flatMap(account => accountCalendars.get(account.id) ?? []); + }, [accounts, accountCalendars]); + + const handleToggle = useCallback((id: string, checked: boolean) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (checked) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }, []); + + const hasChanges = useMemo(() => { + const saved = new Set( + workspaceCalendars[0]?.items.map(item => item.subscriptionId) ?? [] + ); + return !isSameSelection(saved, selectedIds); + }, [selectedIds, workspaceCalendars]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const items = orderedSubscriptions + .filter(subscription => selectedIds.has(subscription.id)) + .map((subscription, index) => ({ + subscriptionId: subscription.id, + sortOrder: index, + })); + await calendar.updateWorkspaceCalendars(items); + } catch (error) { + notify.error({ + title: t['com.affine.integration.calendar.save-error'](), + }); + } finally { + setSaving(false); + } + }, [calendar, orderedSubscriptions, selectedIds, t]); + + const hasCalendars = orderedSubscriptions.length > 0; + return ( <> { divider={false} />
- {subscriptions.map(subscription => ( - - ))} - + {accounts.map(account => { + const calendars = accountCalendars.get(account.id) ?? []; + if (calendars.length === 0) return null; + const title = account.displayName ?? account.email ?? account.id; + const caption = + account.displayName && account.email ? account.email : null; + return ( +
+
+
+
{title}
+ {caption ? ( +
{caption}
+ ) : null} +
+
+ {calendars.length}{' '} + {t['com.affine.integration.calendar.name']()} +
+
+
+ {calendars.map(subscription => ( + + ))} +
+
+ ); + })} + {!hasCalendars ? ( +
+ {t['com.affine.integration.calendar.no-calendar']()} +
+ ) : null} +
+
+
); }; - -const AddSubscription = () => { - const t = useI18n(); - const [open, setOpen] = useState(false); - const [url, setUrl] = useState(''); - const [verifying, setVerifying] = useState(false); - const calendar = useService(IntegrationService).calendar; - - const handleOpen = useCallback(() => { - setOpen(true); - }, []); - const handleClose = useCallback(() => { - setOpen(false); - setUrl(''); - }, []); - - const handleInputChange = useCallback((value: string) => { - setUrl(value); - }, []); - - const handleAddSub = useCallback(() => { - const _url = url.trim(); - const exists = calendar.getSubscription(_url); - if (exists) { - notify.error({ - title: t['com.affine.integration.calendar.new-duplicate-error-title'](), - message: - t['com.affine.integration.calendar.new-duplicate-error-content'](), - }); - return; - } - - setVerifying(true); - calendar - .createSubscription(_url) - .then(() => { - setOpen(false); - setUrl(''); - track.$.settingsPanel.integrationList.connectIntegration({ - type: 'calendar', - control: 'Calendar Setting', - result: 'success', - }); - }) - .catch(() => { - notify.error({ - title: t['com.affine.integration.calendar.new-error'](), - }); - }) - .finally(() => { - setVerifying(false); - }); - }, [calendar, t, url]); - - return ( - <> - - -
- - - -
- {t['com.affine.integration.calendar.new-title']()} -
-
- -
-
- {t['com.affine.integration.calendar.new-url-label']()} -
- -
- -
- - -
-
- - ); -}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts index ca76668c0..5668c0461 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.css.ts @@ -1,104 +1,41 @@ import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; -export const card = style({ - padding: '8px 16px 12px 16px', - borderRadius: 8, - background: cssVarV2.layer.background.primary, - border: `1px solid ${cssVarV2.layer.insideBorder.border}`, -}); -export const divider = style({ - height: 8, - display: 'flex', - margin: '4px 0', - alignItems: 'center', - justifyContent: 'center', - ':before': { - content: '', - width: '100%', - height: 0, - borderTop: `0.5px solid ${cssVarV2.tab.divider.divider}`, - }, -}); -export const header = style({ +export const item = style({ display: 'flex', alignItems: 'center', - gap: 8, - padding: '7px 0', -}); -export const colorPickerTrigger = style({ - width: 24, - height: 24, - borderRadius: 4, - cursor: 'pointer', - background: cssVarV2.layer.background.overlayPanel, - border: `1px solid ${cssVarV2.layer.insideBorder.border}`, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - ':before': { - content: '', - width: 11, - height: 11, - borderRadius: 11, - backgroundColor: 'currentColor', - }, -}); -export const colorPicker = style({ - display: 'flex', - gap: 4, - alignItems: 'center', -}); -export const colorPickerItem = style({ - width: 20, - height: 20, - borderRadius: 10, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', + gap: 10, + padding: '6px 8px', + borderRadius: 6, cursor: 'pointer', selectors: { - '&[data-active="true"]': { - boxShadow: `0 0 0 1px ${cssVarV2.button.primary}`, - }, - '&:before': { - content: '', - width: 16, - height: 16, - borderRadius: 8, - background: 'currentColor', + '&:hover': { + background: cssVarV2.layer.background.secondary, }, }, }); + +export const color = style({ + width: 10, + height: 10, + borderRadius: 999, + flexShrink: 0, +}); + +export const info = style({ + display: 'flex', + flexDirection: 'column', + gap: 2, +}); + export const name = style({ fontSize: 14, - fontWeight: 500, - lineHeight: '22px', + lineHeight: '20px', color: cssVarV2.text.primary, - width: 0, - flexGrow: 1, - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - padding: '0px 4px', - display: 'inline-flex', - alignItems: 'center', }); -export const allDayEventsContainer = style({ - overflow: 'hidden', - display: 'grid', - gridTemplateRows: '1fr', - transition: - 'grid-template-rows 0.4s cubic-bezier(.07,.83,.46,1), opacity 0.4s ease', - selectors: { - '&[data-collapsed="true"]': { - gridTemplateRows: '0fr', - opacity: 0, - }, - }, -}); - -export const allDayEventsContent = style({ - overflow: 'hidden', +export const meta = style({ + fontSize: 12, + lineHeight: '18px', + color: cssVarV2.text.secondary, }); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx index 2b7ebcc58..02ddcba9c 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/calendar/subscription-setting.tsx @@ -1,160 +1,61 @@ -import { Button, InlineEdit, Menu, useConfirmModal } from '@affine/component'; -import { - type CalendarSubscription, - IntegrationService, -} from '@affine/core/modules/integration'; -import { useI18n } from '@affine/i18n'; -import track from '@affine/track'; -import { useLiveData, useService } from '@toeverything/infra'; -import { useCallback, useMemo, useState } from 'react'; +import { Checkbox } from '@affine/component'; +import type { CalendarAccountCalendarsQuery } from '@affine/graphql'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { useCallback } from 'react'; -import { IntegrationSettingToggle } from '../setting'; import * as styles from './subscription-setting.css'; +type CalendarSubscription = + CalendarAccountCalendarsQuery['calendarAccountCalendars'][number]; + export const SubscriptionSetting = ({ subscription, + checked, + onToggle, }: { subscription: CalendarSubscription; + checked: boolean; + onToggle: (id: string, checked: boolean) => void; }) => { - const t = useI18n(); - const [menuOpen, setMenuOpen] = useState(false); - const calendar = useService(IntegrationService).calendar; - const config = useLiveData(subscription.config$); - const name = useLiveData(subscription.name$) || t['Untitled'](); - - const handleColorChange = useCallback( - (color: string) => { - calendar.updateSubscription(subscription.url, { color }); - setMenuOpen(false); + const handleToggle = useCallback( + (nextChecked: boolean) => { + onToggle(subscription.id, nextChecked); }, - [calendar, subscription.url] + [onToggle, subscription.id] ); - const toggleShowEvents = useCallback(() => { - calendar.updateSubscription(subscription.url, { - showEvents: !config?.showEvents, - }); - }, [calendar, subscription.url, config?.showEvents]); - - const toggleShowAllDayEvents = useCallback(() => { - calendar.updateSubscription(subscription.url, { - showAllDayEvents: !config?.showAllDayEvents, - }); - }, [calendar, subscription.url, config?.showAllDayEvents]); - - const handleNameChange = useCallback( - (value: string) => { - calendar.updateSubscription(subscription.url, { name: value }); - }, - [calendar, subscription.url] - ); - - if (!config) return null; - return ( -
-
- - } - > -
-
- - -
-
- handleToggle(!checked)} + role="button" + tabIndex={0} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleToggle(!checked); + } + }} + > + handleToggle(nextChecked)} + onClick={event => event.stopPropagation()} />
-
-
- + className={styles.color} + style={{ + backgroundColor: subscription.color || cssVarV2.button.primary, + }} + /> +
+
+ {subscription.displayName ?? subscription.externalCalendarId}
+ {subscription.timezone ? ( +
{subscription.timezone}
+ ) : null}
); }; - -const UnsubscribeButton = ({ url, name }: { url: string; name: string }) => { - const t = useI18n(); - const calendar = useService(IntegrationService).calendar; - const { openConfirmModal } = useConfirmModal(); - - const handleUnsubscribe = useCallback(() => { - openConfirmModal({ - title: t['com.affine.integration.calendar.unsubscribe'](), - children: t.t('com.affine.integration.calendar.unsubscribe-content', { - name, - }), - onConfirm: () => { - calendar.deleteSubscription(url); - track.$.settingsPanel.integrationList.disconnectIntegration({ - type: 'calendar', - control: 'Calendar Setting', - }); - }, - confirmText: t['com.affine.integration.calendar.unsubscribe'](), - confirmButtonOptions: { - variant: 'error', - }, - }); - }, [calendar, name, openConfirmModal, t, url]); - - return ( - - ); -}; - -const ColorPicker = ({ - activeColor, - onChange, -}: { - onChange: (color: string) => void; - activeColor: string; -}) => { - const calendar = useService(IntegrationService).calendar; - const colors = useMemo(() => calendar.colors, [calendar]); - - return ( -
    - {colors.map(color => ( -
  • onChange(color)} - data-active={color === activeColor} - className={styles.colorPickerItem} - style={{ color }} - /> - ))} -
- ); -}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx index 23a7e32d6..8719647ae 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx @@ -31,12 +31,13 @@ const INTEGRATION_LIST = [ icon: , setting: , }, - BUILD_CONFIG.isElectron && { + { id: 'calendar' as const, name: 'com.affine.integration.calendar.name', desc: 'com.affine.integration.calendar.desc', icon: , setting: , + cloud: true, }, { id: 'mcp-server' as const, diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx index c1791fc21..ebf193b22 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/calendar-events.tsx @@ -16,21 +16,14 @@ import { useLiveData, useService } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import type { Dayjs } from 'dayjs'; -import type ICAL from 'ical.js'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import * as styles from './calendar-events.css'; -const pad = (val?: number) => (val ?? 0).toString().padStart(2, '0'); - -function formatTime(start?: ICAL.Time, end?: ICAL.Time) { +function formatTime(start?: Dayjs, end?: Dayjs) { if (!start || !end) return ''; - // Use toJSDate which handles timezone conversion for us - const startDate = start.toJSDate(); - const endDate = end.toJSDate(); - - const from = `${pad(startDate.getHours())}:${pad(startDate.getMinutes())}`; - const to = `${pad(endDate.getHours())}:${pad(endDate.getMinutes())}`; + const from = start.format('HH:mm'); + const to = end.format('HH:mm'); return from === to ? from : `${from} - ${to}`; } @@ -40,15 +33,6 @@ export const CalendarEvents = ({ date }: { date: Dayjs }) => { useMemo(() => calendar.eventsByDate$(date), [calendar, date]) ); - useEffect(() => { - const update = () => { - calendar.subscriptions$.value.forEach(sub => sub.update()); - }; - update(); - const interval = setInterval(update, 5 * 60 * 1000); - return () => clearInterval(interval); - }, [calendar]); - return (
    {events.map(event => ( @@ -60,9 +44,9 @@ export const CalendarEvents = ({ date }: { date: Dayjs }) => { const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => { const t = useI18n(); - const { url, title, startAt, endAt, allDay, date } = event; + const { title, startAt, endAt, allDay, date, calendarName, calendarColor } = + event; const [loading, setLoading] = useState(false); - const calendar = useService(IntegrationService).calendar; const docsService = useService(DocsService); const guardService = useService(GuardService); const journalService = useService(JournalService); @@ -70,21 +54,23 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => { const { createPage } = usePageHelper( workspaceService.workspace.docCollection ); - const subscription = useLiveData( - useMemo(() => calendar.subscription$(url), [calendar, url]) - ); - const config = useLiveData( - useMemo(() => subscription?.config$, [subscription?.config$]) - ); - const name = useLiveData(subscription?.name$) || t['Untitled'](); - const color = config?.color || cssVarV2.button.primary; + const name = calendarName || t['Untitled'](); + const color = calendarColor || cssVarV2.button.primary; + const eventTitle = title || t['Untitled'](); const handleClick = useAsyncCallback(async () => { - if (!date || loading) return; + if (loading) return; const docs = journalService.journalsByDate$( date.format('YYYY-MM-DD') ).value; - if (docs.length === 0) return; + if (docs.length === 0) { + toast( + t['com.affine.integration.calendar.no-journal']({ + date: date.format('YYYY-MM-DD'), + }) + ); + return; + } setLoading(true); @@ -97,7 +83,7 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => { } const newDoc = createPage(); - await docsService.changeDocTitle(newDoc.id, title); + await docsService.changeDocTitle(newDoc.id, eventTitle); await docsService.addLinkedDoc(doc.id, newDoc.id); } track.doc.sidepanel.journal.createCalendarDocEvent(); @@ -112,7 +98,7 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => { journalService, loading, t, - title, + eventTitle, ]); return ( @@ -143,7 +129,7 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => { {allDay ? : }
-
{title}
+
{eventTitle}
{loading ? ( ) : ( diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx index 66ed24b36..48737029e 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/index.tsx @@ -10,14 +10,17 @@ import { } from '@affine/component'; import { Guard } from '@affine/core/components/guard'; import { MoveToTrash } from '@affine/core/components/page-list'; +import { WorkspaceServerService } from '@affine/core/modules/cloud'; import { type DocRecord, DocService, DocsService, } from '@affine/core/modules/doc'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { IntegrationService } from '@affine/core/modules/integration'; import { JournalService } from '@affine/core/modules/journal'; import { + ViewService, WorkbenchLink, WorkbenchService, } from '@affine/core/modules/workbench'; @@ -32,7 +35,7 @@ import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; import dayjs from 'dayjs'; import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CalendarEvents } from './calendar-events'; import * as styles from './journal.css'; @@ -103,17 +106,65 @@ interface JournalBlockProps { date: dayjs.Dayjs; } +type DateDotType = 'journal' | 'event' | 'activity'; + const mobile = environment.isMobile; export const EditorJournalPanel = () => { const t = useI18n(); const doc = useServiceOptional(DocService)?.doc; const workbench = useService(WorkbenchService).workbench; + const viewService = useService(ViewService); const journalService = useService(JournalService); + const calendar = useService(IntegrationService).calendar; + const workspaceServerService = useService(WorkspaceServerService); + const server = useLiveData(workspaceServerService.server$); + const location = useLiveData(viewService.view.location$); const journalDateStr = useLiveData( doc ? journalService.journalDate$(doc.id) : null ); const journalDate = journalDateStr ? dayjs(journalDateStr) : null; const isJournal = !!journalDate; + const routeDate = useMemo(() => { + if (!location.pathname.startsWith('/journals')) return null; + const searchParams = new URLSearchParams(location.search); + const rawDate = searchParams.get('date'); + return rawDate ? dayjs(rawDate) : dayjs(); + }, [location.pathname, location.search]); + const [selectedDate, setSelectedDate] = useState(() => { + return journalDate ?? routeDate ?? dayjs(); + }); + const [calendarCursor, setCalendarCursor] = useState(selectedDate); + const calendarCursorMonthKey = useMemo(() => { + return calendarCursor.format('YYYY-MM'); + }, [calendarCursor]); + const calendarCursorMonthStart = useMemo(() => { + return dayjs(`${calendarCursorMonthKey}-01`); + }, [calendarCursorMonthKey]); + const calendarCursorMonthEnd = useMemo(() => { + return dayjs(`${calendarCursorMonthKey}-01`).endOf('month'); + }, [calendarCursorMonthKey]); + const docRecords = useLiveData(useService(DocsService).list.docs$); + const allJournalDates = useLiveData(journalService.allJournalDates$); + const eventDates = useLiveData(calendar.eventDates$); + const workspaceCalendars = useLiveData(calendar.workspaceCalendars$); + const workspaceCalendarId = workspaceCalendars[0]?.id; + + useEffect(() => { + if (journalDate && !journalDate.isSame(selectedDate, 'day')) { + setSelectedDate(journalDate); + } + }, [journalDate, selectedDate]); + + useEffect(() => { + if (journalDate || !routeDate) return; + if (!routeDate.isSame(selectedDate, 'day')) { + setSelectedDate(routeDate); + } + }, [journalDate, routeDate, selectedDate]); + + useEffect(() => { + setCalendarCursor(selectedDate); + }, [selectedDate]); const openJournal = useCallback( (date: string) => { @@ -129,17 +180,70 @@ export const EditorJournalPanel = () => { const onDateSelect = useCallback( (date: string) => { - if (journalDate && dayjs(date).isSame(dayjs(journalDate))) return; + if (dayjs(date).isSame(selectedDate, 'day')) return; + setSelectedDate(dayjs(date)); openJournal(date); }, - [journalDate, openJournal] + [openJournal, selectedDate] ); - const allJournalDates = useLiveData(journalService.allJournalDates$); + const docActivityDates = useMemo(() => { + const dates = new Set(); + for (const docRecord of docRecords) { + const meta = docRecord.meta$.value; + if (meta.trash) continue; + if (meta.createDate) { + dates.add(dayjs(meta.createDate).format('YYYY-MM-DD')); + } + if (meta.updatedDate) { + dates.add(dayjs(meta.updatedDate).format('YYYY-MM-DD')); + } + } + return dates; + }, [docRecords]); + + useEffect(() => { + calendar.revalidateWorkspaceCalendars().catch(() => undefined); + calendar.loadAccountCalendars().catch(() => undefined); + }, [calendar, server]); + + useEffect(() => { + const update = () => { + calendar + .revalidateEventsRange(calendarCursorMonthStart, calendarCursorMonthEnd) + .catch(() => undefined); + }; + update(); + const interval = setInterval(update, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [ + calendar, + calendarCursorMonthEnd, + calendarCursorMonthStart, + workspaceCalendarId, + ]); + + const getDotType = useCallback( + (dateKey: string): DateDotType[] => { + const dotTypes: DateDotType[] = []; + if (allJournalDates.has(dateKey)) { + dotTypes.push('journal'); + } + if (eventDates.has(dateKey)) { + dotTypes.push('event'); + } + if (docActivityDates.has(dateKey)) { + dotTypes.push('activity'); + } + return dotTypes; + }, + [allJournalDates, docActivityDates, eventDates] + ); const customDayRenderer = useCallback( (cell: DateCell) => { - const hasJournal = allJournalDates.has(cell.date.format('YYYY-MM-DD')); + const dateKey = cell.date.format('YYYY-MM-DD'); + const dotTypes = getDotType(dateKey); return ( ); }, - [allJournalDates, isJournal] + [allJournalDates, getDotType, isJournal] ); return ( @@ -174,21 +288,16 @@ export const EditorJournalPanel = () => { monthNames={t['com.affine.calendar-date-picker.month-names']()} todayLabel={t['com.affine.calendar-date-picker.today']()} customDayRenderer={customDayRenderer} - value={journalDate?.format('YYYY-MM-DD')} + value={selectedDate.format('YYYY-MM-DD')} onChange={onDateSelect} + onCursorChange={setCalendarCursor} cellSize={34} />
- {journalDate ? ( - <> - - - - - ) : ( -
- )} + + +
); diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/journal.css.ts b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/journal.css.ts index 96a88501c..345c2a5e7 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/journal.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/journal/journal.css.ts @@ -1,6 +1,6 @@ import { cssVar } from '@toeverything/theme'; import { cssVarV2 } from '@toeverything/theme/v2'; -import { style } from '@vanilla-extract/css'; +import { style, styleVariants } from '@vanilla-extract/css'; const interactive = style({ position: 'relative', @@ -244,17 +244,25 @@ export const journalDateCell = style([ }, }, ]); +export const journalDateCellDotContainer = style({ + display: 'flex', + gap: 4, + justifyContent: 'center', + marginTop: 4, +}); export const journalDateCellDot = style({ width: 4, height: 4, borderRadius: '50%', - backgroundColor: cssVar('primaryColor'), - position: 'absolute', - bottom: 0, - left: '50%', - transform: 'translateX(-50%)', }); -export const spacer = style({ - height: 0, - flexGrow: 1, +export const journalDateCellDotType = styleVariants({ + journal: { + backgroundColor: cssVarV2.calendar.blue, + }, + event: { + backgroundColor: cssVarV2.calendar.green, + }, + activity: { + backgroundColor: cssVarV2.calendar.red, + }, }); diff --git a/packages/frontend/core/src/modules/cloud/services/workspace-server.ts b/packages/frontend/core/src/modules/cloud/services/workspace-server.ts index e5cfdd27c..468443dfc 100644 --- a/packages/frontend/core/src/modules/cloud/services/workspace-server.ts +++ b/packages/frontend/core/src/modules/cloud/services/workspace-server.ts @@ -1,11 +1,15 @@ -import { Service } from '@toeverything/infra'; +import { LiveData, Service } from '@toeverything/infra'; import type { Server } from '../entities/server'; export class WorkspaceServerService extends Service { - server: Server | null = null; + readonly server$ = new LiveData(null); + + get server() { + return this.server$.value; + } bindServer(server: Server) { - this.server = server; + this.server$.setValue(server); } } diff --git a/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts b/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts deleted file mode 100644 index b619a3366..000000000 --- a/packages/frontend/core/src/modules/integration/entities/calendar-subscription.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - catchErrorInto, - effect, - Entity, - fromPromise, - LiveData, - onComplete, - onStart, -} from '@toeverything/infra'; -import dayjs from 'dayjs'; -import ICAL from 'ical.js'; -import { EMPTY, mergeMap, switchMap, throttleTime } from 'rxjs'; - -import type { - CalendarStore, - CalendarSubscriptionConfig, -} from '../store/calendar'; -import type { CalendarEvent, EventsByDateMap } from '../type'; -import { parseCalendarUrl } from '../utils/calendar-url-parser'; -import { isAllDay } from '../utils/is-all-day'; - -export class CalendarSubscription extends Entity<{ url: string }> { - constructor(private readonly store: CalendarStore) { - super(); - } - - config$ = LiveData.from( - this.store.watchSubscription(this.props.url), - {} as CalendarSubscriptionConfig - ); - content$ = LiveData.from( - this.store.watchSubscriptionCache(this.props.url), - '' - ); - name$ = LiveData.computed(get => { - const config = get(this.config$); - if (config?.name !== undefined) { - return config.name; - } - const content = get(this.content$); - if (!content) return ''; - try { - const jCal = ICAL.parse(content ?? ''); - const vCalendar = new ICAL.Component(jCal); - return (vCalendar.getFirstPropertyValue('x-wr-calname') as string) || ''; - } catch { - return ''; - } - }); - - eventsByDateMap$ = LiveData.computed(get => { - const content = get(this.content$); - const config = get(this.config$); - - const map: EventsByDateMap = new Map(); - - if (!content || !config?.showEvents) return map; - - const jCal = ICAL.parse(content); - const vCalendar = new ICAL.Component(jCal); - const vEvents = vCalendar.getAllSubcomponents('vevent'); - - for (const vEvent of vEvents) { - const event = new ICAL.Event(vEvent); - const calendarEvent: CalendarEvent = { - id: event.uid, - url: this.url, - title: event.summary, - startAt: event.startDate, - endAt: event.endDate, - }; - - // create index for each day of the event - if (event.startDate && event.endDate) { - const start = dayjs(event.startDate.toJSDate()); - const end = dayjs(event.endDate.toJSDate()); - - let current = start; - while (current.isBefore(end) || current.isSame(end, 'day')) { - if ( - current.isSame(end, 'day') && - end.hour() === 0 && - end.minute() === 0 - ) { - break; - } - const todayEvent: CalendarEvent = { ...calendarEvent }; - const dateKey = current.format('YYYY-MM-DD'); - if (!map.has(dateKey)) { - map.set(dateKey, []); - } - todayEvent.allDay = isAllDay(current, start, end); - todayEvent.date = current; - todayEvent.id = `${event.uid}-${dateKey}`; - if ( - config.showAllDayEvents || - (!config.showAllDayEvents && !todayEvent.allDay) - ) { - map.get(dateKey)?.push(todayEvent); - } - current = current.add(1, 'day'); - } - } else { - console.warn("event's start or end date is missing", event); - } - } - - return map; - }); - - url = this.props.url; - loading$ = new LiveData(false); - error$ = new LiveData(null); - - update = effect( - throttleTime(30 * 1000), - switchMap(() => - fromPromise(async () => { - const url = parseCalendarUrl(this.url); - const response = await fetch(url); - return await response.text(); - }).pipe( - mergeMap(value => { - this.store.setSubscriptionCache(this.url, value).catch(console.error); - return EMPTY; - }), - catchErrorInto(this.error$), - onStart(() => this.loading$.setValue(true)), - onComplete(() => this.loading$.setValue(false)) - ) - ) - ); - - override dispose() { - super.dispose(); - this.update.reset(); - } -} diff --git a/packages/frontend/core/src/modules/integration/entities/calendar.ts b/packages/frontend/core/src/modules/integration/entities/calendar.ts index 89bea2e0f..6d370bcd1 100644 --- a/packages/frontend/core/src/modules/integration/entities/calendar.ts +++ b/packages/frontend/core/src/modules/integration/entities/calendar.ts @@ -1,123 +1,216 @@ -import { Entity, LiveData, ObjectPool } from '@toeverything/infra'; -import { type Dayjs } from 'dayjs'; -import ICAL from 'ical.js'; -import { Observable, switchMap } from 'rxjs'; - import type { - CalendarStore, - CalendarSubscriptionConfig, -} from '../store/calendar'; + CalendarAccountCalendarsQuery, + CalendarAccountsQuery, + CalendarEventsQuery, + WorkspaceCalendarItemInput, + WorkspaceCalendarsQuery, +} from '@affine/graphql'; +import { Entity, LiveData } from '@toeverything/infra'; +import dayjs, { type Dayjs } from 'dayjs'; + +import type { CalendarStore } from '../store/calendar'; import type { CalendarEvent } from '../type'; -import { parseCalendarUrl } from '../utils/calendar-url-parser'; -import { CalendarSubscription } from './calendar-subscription'; export class CalendarIntegration extends Entity { constructor(private readonly store: CalendarStore) { super(); } - private readonly subscriptionPool = new ObjectPool< - string, - CalendarSubscription - >(); - - colors = this.store.colors; - subscriptions$ = LiveData.from( - this.store.watchSubscriptionMap().pipe( - switchMap(subs => { - const refs = Object.entries(subs ?? {}).map(([url]) => { - const exists = this.subscriptionPool.get(url); - if (exists) { - return exists; - } - const subscription = this.framework.createEntity( - CalendarSubscription, - { url } - ); - const ref = this.subscriptionPool.put(url, subscription); - return ref; - }); - - return new Observable(subscribe => { - subscribe.next(refs.map(ref => ref.obj)); - return () => { - refs.forEach(ref => ref.release()); - }; - }); - }) - ), + accounts$ = new LiveData( [] ); - subscription$(url: string) { - return this.subscriptions$.map(subscriptions => - subscriptions.find(sub => sub.url === url) - ); - } - eventsByDateMap$ = LiveData.computed(get => { - return get(this.subscriptions$) - .map(sub => get(sub.eventsByDateMap$)) - .reduce((acc, map) => { - for (const [date, events] of map) { - acc.set( - date, - acc.has(date) ? [...(acc.get(date) ?? []), ...events] : [...events] - ); - } - return acc; - }, new Map()); + accountCalendars$ = new LiveData< + Map< + string, + CalendarAccountCalendarsQuery['calendarAccountCalendars'][number][] + > + >(new Map()); + workspaceCalendars$ = new LiveData< + WorkspaceCalendarsQuery['workspaceCalendars'][number][] + >([]); + readonly eventsByDateMap$ = new LiveData< + Map + >(new Map()); + readonly eventDates$ = LiveData.computed(get => { + const eventsByDateMap = get(this.eventsByDateMap$); + const dates = new Set(); + for (const [date, events] of eventsByDateMap) { + if (events.length > 0) { + dates.add(date); + } + } + return dates; + }); + + private readonly subscriptionInfoById$ = LiveData.computed(get => { + const accountCalendars = get(this.accountCalendars$); + const workspaceCalendars = get(this.workspaceCalendars$); + const subscriptionInfo = new Map< + string, + { + subscription: CalendarAccountCalendarsQuery['calendarAccountCalendars'][number]; + colorOverride?: string | null; + } + >(); + + for (const calendars of accountCalendars.values()) { + for (const calendar of calendars) { + subscriptionInfo.set(calendar.id, { subscription: calendar }); + } + } + + for (const item of workspaceCalendars[0]?.items ?? []) { + const existing = subscriptionInfo.get(item.subscriptionId); + if (!existing) continue; + subscriptionInfo.set(item.subscriptionId, { + ...existing, + colorOverride: item.colorOverride, + }); + } + + return subscriptionInfo; }); eventsByDate$(date: Dayjs) { - return this.eventsByDateMap$.map(eventsByDateMap => { - const dateKey = date.format('YYYY-MM-DD'); - const events = [...(eventsByDateMap.get(dateKey) || [])]; + const dateKey = date.format('YYYY-MM-DD'); + return LiveData.computed(get => { + const subscriptionInfoById = get(this.subscriptionInfoById$); + const eventsByDateMap = get(this.eventsByDateMap$); + const events = eventsByDateMap.get(dateKey) ?? []; - // sort events by start time - return events.sort((a, b) => { - return ( - (a.startAt?.toJSDate().getTime() ?? 0) - - (b.startAt?.toJSDate().getTime() ?? 0) + return events + .map(event => { + const subscriptionInfo = subscriptionInfoById.get( + event.subscriptionId + ); + return { + id: event.id, + subscriptionId: event.subscriptionId, + title: event.title ?? '', + startAt: dayjs(event.startAtUtc), + endAt: dayjs(event.endAtUtc), + allDay: event.allDay, + date, + calendarName: + subscriptionInfo?.subscription.displayName ?? + subscriptionInfo?.subscription.externalCalendarId ?? + '', + calendarColor: + subscriptionInfo?.colorOverride ?? + subscriptionInfo?.subscription.color ?? + undefined, + } satisfies CalendarEvent; + }) + .sort( + (left, right) => left.startAt.valueOf() - right.startAt.valueOf() ); - }); }); } - async verifyUrl(_url: string) { - const url = parseCalendarUrl(_url); - try { - const response = await fetch(url); - const content = await response.text(); - ICAL.parse(content); - return content; - } catch (err) { - console.error(err); - throw new Error('Failed to verify URL'); + async loadAccountCalendars(signal?: AbortSignal) { + const accounts = await this.store.fetchAccounts(signal); + this.accounts$.setValue(accounts); + + const calendarsByAccount = new Map< + string, + CalendarAccountCalendarsQuery['calendarAccountCalendars'][number][] + >(); + + await Promise.all( + accounts.map(async account => { + try { + const calendars = await this.store.fetchAccountCalendars( + account.id, + signal + ); + calendarsByAccount.set(account.id, calendars); + } catch (error) { + console.error('Failed to load calendar subscriptions', error); + calendarsByAccount.set(account.id, []); + } + }) + ); + + this.accountCalendars$.setValue(calendarsByAccount); + return calendarsByAccount; + } + + async revalidateWorkspaceCalendars(signal?: AbortSignal) { + const calendars = await this.store.fetchWorkspaceCalendars(signal); + this.workspaceCalendars$.setValue(calendars); + return calendars; + } + + async updateWorkspaceCalendars(items: WorkspaceCalendarItemInput[]) { + const updated = await this.store.updateWorkspaceCalendars(items); + const next = [...this.workspaceCalendars$.value]; + const index = next.findIndex(calendar => calendar.id === updated.id); + if (index >= 0) { + next[index] = updated; + } else { + next.push(updated); } + this.workspaceCalendars$.setValue(next); + return updated; } - async createSubscription(url: string) { - try { - const content = await this.verifyUrl(url); - this.store.addSubscription(url); - this.store.setSubscriptionCache(url, content).catch(console.error); - } catch (err) { - console.error(err); - throw new Error('Failed to verify URL'); - } - } - - getSubscription(url: string) { - return this.store.getSubscription(url); - } - - deleteSubscription(url: string) { - this.store.removeSubscription(url); - } - - updateSubscription( - url: string, - updates: Partial> + async revalidateEventsRange( + rangeStart: Dayjs, + rangeEnd: Dayjs, + signal?: AbortSignal ) { - this.store.updateSubscription(url, updates); + const start = rangeStart.startOf('day'); + const end = rangeEnd.endOf('day'); + const workspaceCalendarId = this.workspaceCalendars$.value[0]?.id; + const next = new Map(this.eventsByDateMap$.value); + let cursor = start; + while (cursor.isBefore(end, 'day') || cursor.isSame(end, 'day')) { + next.set(cursor.format('YYYY-MM-DD'), []); + cursor = cursor.add(1, 'day'); + } + if (!workspaceCalendarId) { + this.eventsByDateMap$.setValue(next); + return []; + } + + const events = await this.store.fetchEvents( + workspaceCalendarId, + start.toISOString(), + end.toISOString(), + signal + ); + for (const event of events) { + const startAt = dayjs(event.startAtUtc); + const endAt = dayjs(event.endAtUtc); + let current = startAt.isBefore(start, 'day') ? start : startAt; + const rangeEndDay = endAt.isAfter(end, 'day') ? end : endAt; + + while ( + current.isBefore(rangeEndDay, 'day') || + current.isSame(rangeEndDay, 'day') + ) { + if ( + current.isSame(endAt, 'day') && + endAt.hour() === 0 && + endAt.minute() === 0 + ) { + break; + } + const dateKey = current.format('YYYY-MM-DD'); + const list = next.get(dateKey); + if (list) { + list.push(event); + } else { + next.set(dateKey, [event]); + } + current = current.add(1, 'day'); + } + } + this.eventsByDateMap$.setValue(next); + return events; + } + + async revalidateEvents(date: Dayjs, signal?: AbortSignal) { + return await this.revalidateEventsRange(date, date, signal); } } diff --git a/packages/frontend/core/src/modules/integration/index.ts b/packages/frontend/core/src/modules/integration/index.ts index bc6fe4727..ae96b2c67 100644 --- a/packages/frontend/core/src/modules/integration/index.ts +++ b/packages/frontend/core/src/modules/integration/index.ts @@ -3,11 +3,10 @@ import type { Framework } from '@toeverything/infra'; import { WorkspaceServerService } from '../cloud'; import { WorkspaceDBService } from '../db'; import { DocScope, DocService, DocsService } from '../doc'; -import { CacheStorage, GlobalState } from '../storage'; +import { GlobalState } from '../storage'; import { TagService } from '../tag'; import { WorkspaceScope, WorkspaceService } from '../workspace'; import { CalendarIntegration } from './entities/calendar'; -import { CalendarSubscription } from './entities/calendar-subscription'; import { ReadwiseIntegration } from './entities/readwise'; import { ReadwiseCrawler } from './entities/readwise-crawler'; import { IntegrationWriter } from './entities/writer'; @@ -19,7 +18,6 @@ import { ReadwiseStore } from './store/readwise'; export { IntegrationService }; export { CalendarIntegration } from './entities/calendar'; -export { CalendarSubscription } from './entities/calendar-subscription'; export type { CalendarEvent } from './type'; export { IntegrationTypeIcon } from './views/icon'; export { DocIntegrationPropertiesTable } from './views/properties-table'; @@ -41,14 +39,8 @@ export function configureIntegrationModule(framework: Framework) { ReadwiseStore, DocsService, ]) - .store(CalendarStore, [ - GlobalState, - CacheStorage, - WorkspaceService, - WorkspaceServerService, - ]) + .store(CalendarStore, [WorkspaceService, WorkspaceServerService]) .entity(CalendarIntegration, [CalendarStore]) - .entity(CalendarSubscription, [CalendarStore]) .scope(DocScope) .service(IntegrationPropertyService, [DocService]); } diff --git a/packages/frontend/core/src/modules/integration/store/calendar.ts b/packages/frontend/core/src/modules/integration/store/calendar.ts index 2cce49c06..6fc5d6bcd 100644 --- a/packages/frontend/core/src/modules/integration/store/calendar.ts +++ b/packages/frontend/core/src/modules/integration/store/calendar.ts @@ -1,155 +1,107 @@ -import { LiveData, Store } from '@toeverything/infra'; -import { cssVarV2 } from '@toeverything/theme/v2'; -import { exhaustMap, map } from 'rxjs'; +import { + type CalendarAccountCalendarsQuery, + calendarAccountCalendarsQuery, + type CalendarAccountsQuery, + calendarAccountsQuery, + type CalendarEventsQuery, + calendarEventsQuery, + type UpdateWorkspaceCalendarsMutation, + updateWorkspaceCalendarsMutation, + type WorkspaceCalendarItemInput, + type WorkspaceCalendarsQuery, + workspaceCalendarsQuery, +} from '@affine/graphql'; +import { Store } from '@toeverything/infra'; -import { AuthService, type WorkspaceServerService } from '../../cloud'; -import type { CacheStorage, GlobalState } from '../../storage'; +import type { WorkspaceServerService } from '../../cloud'; import type { WorkspaceService } from '../../workspace'; -export interface CalendarSubscriptionConfig { - color: string; - name?: string; - showEvents?: boolean; - showAllDayEvents?: boolean; -} -type CalendarSubscriptionStore = Record; - export class CalendarStore extends Store { constructor( - private readonly globalState: GlobalState, - private readonly cacheStorage: CacheStorage, private readonly workspaceService: WorkspaceService, private readonly workspaceServerService: WorkspaceServerService ) { super(); } - public colors = [ - cssVarV2.calendar.red, - cssVarV2.calendar.orange, - cssVarV2.calendar.yellow, - cssVarV2.calendar.green, - cssVarV2.calendar.teal, - cssVarV2.calendar.blue, - cssVarV2.calendar.purple, - cssVarV2.calendar.magenta, - cssVarV2.calendar.grey, - ]; - - public getRandomColor() { - return this.colors[Math.floor(Math.random() * this.colors.length)]; + private get gql() { + return this.workspaceServerService.server?.gql; } - private _getKey(userId: string, workspaceId: string) { - return `calendar:${userId}:${workspaceId}:subscriptions`; + private get workspaceId() { + return this.workspaceService.workspace.id; } - private _createSubscription() { - return { - showEvents: true, - showAllDayEvents: true, - color: this.getRandomColor(), - }; + async fetchAccounts(signal?: AbortSignal) { + const gql = this.gql; + if (!gql) return [] satisfies CalendarAccountsQuery['calendarAccounts']; + const data = await gql({ + query: calendarAccountsQuery, + context: { signal }, + }); + return data.calendarAccounts; } - authService = this.workspaceServerService.server?.scope.get(AuthService); - userId$ = - this.workspaceService.workspace.meta.flavour === 'local' || - !this.authService - ? new LiveData('__local__') - : this.authService.session.account$.map( - account => account?.id ?? '__local__' - ); - storageKey$() { - const workspaceId = this.workspaceService.workspace.id; - return this.userId$.map(userId => this._getKey(userId, workspaceId)); - } - getUserId() { - return this.workspaceService.workspace.meta.flavour === 'local' || - !this.authService - ? '__local__' - : (this.authService.session.account$.value?.id ?? '__local__'); + async fetchAccountCalendars(accountId: string, signal?: AbortSignal) { + const gql = this.gql; + if (!gql) { + return [] satisfies CalendarAccountCalendarsQuery['calendarAccountCalendars']; + } + const data = await gql({ + query: calendarAccountCalendarsQuery, + variables: { accountId }, + context: { signal }, + }); + return data.calendarAccountCalendars; } - getStorageKey() { - const workspaceId = this.workspaceService.workspace.id; - return this._getKey(this.getUserId(), workspaceId); + async fetchWorkspaceCalendars(signal?: AbortSignal) { + const gql = this.gql; + if (!gql) { + return [] satisfies WorkspaceCalendarsQuery['workspaceCalendars']; + } + const data = await gql({ + query: workspaceCalendarsQuery, + variables: { workspaceId: this.workspaceId }, + context: { signal }, + }); + return data.workspaceCalendars; } - getCacheKey(url: string) { - return `calendar-cache:${url}`; - } - - watchSubscriptionMap() { - return this.storageKey$().pipe( - exhaustMap(storageKey => { - return this.globalState.watch(storageKey); - }) - ); - } - - watchSubscription(url: string) { - return this.watchSubscriptionMap().pipe( - map(subscriptionMap => { - if (!subscriptionMap) { - return null; - } - return subscriptionMap[url] ?? null; - }) - ); - } - - getSubscription(url: string) { - return this.getSubscriptionMap()[url]; - } - - watchSubscriptionCache(url: string) { - return this.cacheStorage.watch(this.getCacheKey(url)); - } - - getSubscriptionMap() { - return ( - this.globalState.get( - this.getStorageKey() - ) ?? {} - ); - } - - addSubscription(url: string, config?: Partial) { - const subscriptionMap = this.getSubscriptionMap(); - this.globalState.set(this.getStorageKey(), { - ...subscriptionMap, - [url]: { - // merge default config - ...this._createSubscription(), - // update if exists - ...subscriptionMap[url], - ...config, + async updateWorkspaceCalendars(items: WorkspaceCalendarItemInput[]) { + const gql = this.gql; + if (!gql) { + throw new Error('No graphql service available'); + } + const data = await gql({ + query: updateWorkspaceCalendarsMutation, + variables: { + input: { + workspaceId: this.workspaceId, + items, + }, }, }); + return data.updateWorkspaceCalendars satisfies UpdateWorkspaceCalendarsMutation['updateWorkspaceCalendars']; } - removeSubscription(url: string) { - this.globalState.set( - this.getStorageKey(), - Object.fromEntries( - Object.entries(this.getSubscriptionMap()).filter(([key]) => key !== url) - ) - ); - } - - updateSubscription( - url: string, - updates: Partial> + async fetchEvents( + workspaceCalendarId: string, + from: string, + to: string, + signal?: AbortSignal ) { - const subscriptionMap = this.getSubscriptionMap(); - this.globalState.set(this.getStorageKey(), { - ...subscriptionMap, - [url]: { ...subscriptionMap[url], ...updates }, + const gql = this.gql; + if (!gql) return [] satisfies CalendarEventsQuery['calendarEvents']; + const data = await gql({ + query: calendarEventsQuery, + variables: { + workspaceCalendarId, + from, + to, + }, + context: { signal }, }); - } - - setSubscriptionCache(url: string, cache: string) { - return this.cacheStorage.set(this.getCacheKey(url), cache); + return data.calendarEvents; } } diff --git a/packages/frontend/core/src/modules/integration/type.ts b/packages/frontend/core/src/modules/integration/type.ts index cff2e1339..db3e75f14 100644 --- a/packages/frontend/core/src/modules/integration/type.ts +++ b/packages/frontend/core/src/modules/integration/type.ts @@ -1,6 +1,5 @@ import type { I18nString } from '@affine/i18n'; import type { Dayjs } from 'dayjs'; -import type ICAL from 'ical.js'; import type { ComponentType, SVGProps } from 'react'; import type { DocIntegrationRef } from '../db/schema/schema'; @@ -103,12 +102,12 @@ export interface ReadwiseConfig { // =============================== export type CalendarEvent = { id: string; - url: string; + subscriptionId: string; title: string; - startAt?: ICAL.Time; - endAt?: ICAL.Time; - allDay?: boolean; - date?: Dayjs; + startAt: Dayjs; + endAt: Dayjs; + allDay: boolean; + date: Dayjs; + calendarName?: string; + calendarColor?: string; }; - -export type EventsByDateMap = Map; diff --git a/packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts b/packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts deleted file mode 100644 index e8fec3361..000000000 --- a/packages/frontend/core/src/modules/integration/utils/calendar-url-parser.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const parseCalendarUrl = (_url: string) => { - let url = _url; - try { - const urlObj = new URL(url); - if (urlObj.protocol === 'webcal:') { - urlObj.protocol = 'https'; - } - url = urlObj.toString(); - return url; - } catch (err) { - console.error(err); - throw new Error(`Invalid URL: "${url}"`); - } -}; diff --git a/packages/frontend/core/src/modules/integration/utils/is-all-day.ts b/packages/frontend/core/src/modules/integration/utils/is-all-day.ts deleted file mode 100644 index 1afc8c3a5..000000000 --- a/packages/frontend/core/src/modules/integration/utils/is-all-day.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Dayjs } from 'dayjs'; - -export const isAllDay = (current: Dayjs, start: Dayjs, end: Dayjs): boolean => { - if (current.isSame(start, 'day')) { - return ( - start.hour() === 0 && start.minute() === 0 && !current.isSame(end, 'day') - ); - } else if (current.isSame(end, 'day')) { - return false; - } else { - return true; - } -}; diff --git a/packages/frontend/i18n/build.ts b/packages/frontend/i18n/build.ts index f651f8197..d41212fbb 100644 --- a/packages/frontend/i18n/build.ts +++ b/packages/frontend/i18n/build.ts @@ -2,10 +2,13 @@ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { parse } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { ProjectRoot } from '@affine-tools/utils/path'; import { Package } from '@affine-tools/utils/workspace'; import { runCli } from '@magic-works/i18n-codegen'; +import { glob } from 'glob'; const isDev = process.argv.includes('--dev'); +const shouldCleanup = process.argv.includes('--cleanup'); const i18nPkg = new Package('@affine/i18n'); const resourcesDir = i18nPkg.join('src', 'resources').toString(); @@ -21,6 +24,113 @@ function writeResource(lang: string, resource: Record) { writeFileSync(filePath, JSON.stringify(resource, null, 2) + '\n'); } +async function cleanupResources() { + const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const files = await glob('packages/frontend/**/src/**/*.{js,tsx,ts}', { + ignore: [ + '**/node_modules/**', + '**/packages/frontend/i18n/src/resources/*', + '**/packages/frontend/i18n/src/i18n.gen.ts', + '**/dist/**', + '**/lib/**', + ], + cwd: ProjectRoot.toString(), + absolute: true, + }); + + const filesWithContent = files.map(file => { + return { + path: file, + content: readFileSync(file, 'utf8'), + }; + }); + + const dynamicPrefixes = new Set(); + const templatePrefixRegex = /`[^`]*?(com\.affine\.[^`]*?)\$\{/g; + const concatPrefixRegex = /['"](com\.affine\.[^'"]*?\.)['"]\s*\+/g; + const addDynamicPrefix = (rawPrefix: string) => { + let prefix = rawPrefix; + if (!prefix.endsWith('.')) { + const lastDot = prefix.lastIndexOf('.'); + if (lastDot === -1) { + return; + } + prefix = prefix.slice(0, lastDot + 1); + } + dynamicPrefixes.add(prefix); + }; + + for (const file of filesWithContent) { + templatePrefixRegex.lastIndex = 0; + concatPrefixRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = templatePrefixRegex.exec(file.content)) !== null) { + addDynamicPrefix(match[1]); + } + while ((match = concatPrefixRegex.exec(file.content)) !== null) { + addDynamicPrefix(match[1]); + } + } + + const resources = readdirSync(resourcesDir) + .filter(file => file.endsWith('.json')) + .reduce( + (langs, file) => { + const lang = parse(file).name; + langs[lang] = readResource(lang); + return langs; + }, + {} as Record> + ); + + const candidateKeys = new Set(); + + for (const resource of Object.values(resources)) { + Object.keys(resource).forEach(key => { + if (!key.startsWith('com.affine.payment.modal.')) { + candidateKeys.add(key); + } + }); + } + + const unusedKeys = Array.from(candidateKeys).filter(key => { + const regex1 = new RegExp(`[\`'"]${escapeRegExp(key)}[\`'"]`, 'g'); + const lastDot = key.lastIndexOf('.'); + const keyPrefix = lastDot === -1 ? '' : key.slice(0, lastDot + 1); + if (keyPrefix && dynamicPrefixes.has(keyPrefix)) { + return false; + } + for (const file of filesWithContent) { + const match = file.content.match(regex1); + if (match) { + return false; + } + } + return true; + }); + + if (unusedKeys.length === 0) { + return; + } + + const unusedKeySet = new Set(unusedKeys); + + for (const [lang, resource] of Object.entries(resources)) { + let changed = false; + for (const key of Object.keys(resource)) { + if (unusedKeySet.has(key)) { + delete resource[key]; + changed = true; + } + } + if (changed) { + writeResource(lang, resource); + } + } +} + function calcCompletenesses() { const langs = readdirSync(resourcesDir) .filter(file => file.endsWith('.json')) @@ -124,5 +234,8 @@ async function appendErrorI18n() { } await appendErrorI18n(); +if (shouldCleanup) { + await cleanupResources(); +} i18nnext(); calcCompletenesses(); diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 6dfccdee1..1f61b8083 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8227,17 +8227,9 @@ export function useAFFiNEI18N(): { */ ["com.affine.integration.calendar.new-url-label"](): string; /** - * `This is a duplicate calendar` + * `An error occurred while saving the calendar settings` */ - ["com.affine.integration.calendar.new-duplicate-error-title"](): string; - /** - * `This subscription calendar already exists in the account of subscribed calendars.` - */ - ["com.affine.integration.calendar.new-duplicate-error-content"](): string; - /** - * `An error occurred while adding the calendar` - */ - ["com.affine.integration.calendar.new-error"](): string; + ["com.affine.integration.calendar.save-error"](): string; /** * `All day` */ @@ -8264,6 +8256,16 @@ export function useAFFiNEI18N(): { ["com.affine.integration.calendar.unsubscribe-content"](options: { readonly name: string; }): string; + /** + * `No journal page found for {{date}}. Please create a journal page first.` + */ + ["com.affine.integration.calendar.no-journal"](options: { + readonly date: string; + }): string; + /** + * `No subscribed calendars yet.` + */ + ["com.affine.integration.calendar.no-calendar"](): string; /** * `MCP Server` */ diff --git a/packages/frontend/i18n/src/resources/ar.json b/packages/frontend/i18n/src/resources/ar.json index fbbefe1ce..da504ba72 100644 --- a/packages/frontend/i18n/src/resources/ar.json +++ b/packages/frontend/i18n/src/resources/ar.json @@ -2037,9 +2037,6 @@ "com.affine.integration.calendar.unsubscribe": "إلغاء الاشتراك", "com.affine.integration.calendar.new-title": "إضافة تقويم بواسطة الرابط", "com.affine.integration.calendar.new-url-label": "رابط التقويم", - "com.affine.integration.calendar.new-duplicate-error-title": "هذا تقويم مكرر", - "com.affine.integration.calendar.new-duplicate-error-content": "تقويم الاشتراك هذا موجود بالفعل في حساب التقويمات المشتركة.", - "com.affine.integration.calendar.new-error": "حدث خطأ أثناء إضافة التقويم", "com.affine.integration.calendar.all-day": "طوال اليوم", "com.affine.integration.calendar.new-doc": "مستند جديد", "com.affine.integration.calendar.show-events": "عرض أحداث التقويم", diff --git a/packages/frontend/i18n/src/resources/ca.json b/packages/frontend/i18n/src/resources/ca.json index ae625708d..42ded44a4 100644 --- a/packages/frontend/i18n/src/resources/ca.json +++ b/packages/frontend/i18n/src/resources/ca.json @@ -580,9 +580,6 @@ "com.affine.integration.calendar.desc": "Els nous esdeveniments es programaran al diari d'AFFiNE", "com.affine.integration.calendar.name": "Calendari", "com.affine.integration.calendar.new-doc": "Nou document", - "com.affine.integration.calendar.new-duplicate-error-content": "Aquest calendari de subscripció ja existeix al compte de calendaris subscrits.", - "com.affine.integration.calendar.new-duplicate-error-title": "Aquest és un calendari duplicat", - "com.affine.integration.calendar.new-error": "S'ha produït un error en afegir el calendari", "com.affine.integration.calendar.new-subscription": "Subscriure's", "com.affine.integration.calendar.new-title": "Afegir un calendari per URL", "com.affine.integration.calendar.new-url-label": "URL del calendari", diff --git a/packages/frontend/i18n/src/resources/de.json b/packages/frontend/i18n/src/resources/de.json index 2e225570f..5a1395505 100644 --- a/packages/frontend/i18n/src/resources/de.json +++ b/packages/frontend/i18n/src/resources/de.json @@ -2053,9 +2053,6 @@ "com.affine.integration.calendar.unsubscribe": "Abonnement beenden", "com.affine.integration.calendar.new-title": "Kalender per URL hinzufügen", "com.affine.integration.calendar.new-url-label": "Kalender-URL", - "com.affine.integration.calendar.new-duplicate-error-title": "Dies ist ein doppelter Kalender", - "com.affine.integration.calendar.new-duplicate-error-content": "Dieses Kalender-Abonnement ist bereits im Konto der abonnierten Kalender vorhanden.", - "com.affine.integration.calendar.new-error": "Beim Hinzufügen des Kalenders ist ein Fehler aufgetreten", "com.affine.integration.calendar.all-day": "Ganztägig", "com.affine.integration.calendar.new-doc": "Neue Seite", "com.affine.integration.calendar.show-events": "Kalenderereignisse anzeigen", diff --git a/packages/frontend/i18n/src/resources/el-GR.json b/packages/frontend/i18n/src/resources/el-GR.json index c90f85b30..3557d8c0e 100644 --- a/packages/frontend/i18n/src/resources/el-GR.json +++ b/packages/frontend/i18n/src/resources/el-GR.json @@ -2037,9 +2037,6 @@ "com.affine.integration.calendar.unsubscribe": "Απεγγραφή", "com.affine.integration.calendar.new-title": "Προσθέστε ημερολόγιο με URL", "com.affine.integration.calendar.new-url-label": "URL Ημερολογίου", - "com.affine.integration.calendar.new-duplicate-error-title": "Αυτό είναι ένα διπλό ημερολόγιο", - "com.affine.integration.calendar.new-duplicate-error-content": "Αυτό το ημερολόγιο συνδρομής υπάρχει ήδη στο λογαριασμό των συνδρομητικών ημερολογίων.", - "com.affine.integration.calendar.new-error": "Προέκυψε σφάλμα κατά την προσθήκη του ημερολογίου", "com.affine.integration.calendar.all-day": "Ολοήμερο", "com.affine.integration.calendar.new-doc": "Νέο έγγραφο", "com.affine.integration.calendar.show-events": "Εμφάνιση γεγονότων ημερολογίου", diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index e6dfa328f..dba91a5df 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2065,15 +2065,15 @@ "com.affine.integration.calendar.unsubscribe": "Unsubscribe", "com.affine.integration.calendar.new-title": "Add a calendar by URL", "com.affine.integration.calendar.new-url-label": "Calendar URL", - "com.affine.integration.calendar.new-duplicate-error-title": "This is a duplicate calendar", - "com.affine.integration.calendar.new-duplicate-error-content": "This subscription calendar already exists in the account of subscribed calendars.", - "com.affine.integration.calendar.new-error": "An error occurred while adding the calendar", + "com.affine.integration.calendar.save-error": "An error occurred while saving the calendar settings", "com.affine.integration.calendar.all-day": "All day", "com.affine.integration.calendar.new-doc": "New doc", "com.affine.integration.calendar.show-events": "Show calendar events", "com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE", "com.affine.integration.calendar.show-all-day-events": "Show all day event", "com.affine.integration.calendar.unsubscribe-content": "Are you sure you want to unsubscribe \"{{name}}\"? Unsubscribing this account will remove its data from Journal.", + "com.affine.integration.calendar.no-journal": "No journal page found for {{date}}. Please create a journal page first.", + "com.affine.integration.calendar.no-calendar": "No subscribed calendars yet.", "com.affine.integration.mcp-server.name": "MCP Server", "com.affine.integration.mcp-server.desc": "Enable other MCP Client to search and read the doc of AFFiNE.", "com.affine.audio.notes": "Notes", diff --git a/packages/frontend/i18n/src/resources/es.json b/packages/frontend/i18n/src/resources/es.json index eb5128ff9..7340f234d 100644 --- a/packages/frontend/i18n/src/resources/es.json +++ b/packages/frontend/i18n/src/resources/es.json @@ -2037,9 +2037,6 @@ "com.affine.integration.calendar.unsubscribe": "Darse de baja", "com.affine.integration.calendar.new-title": "Agregar un calendario por URL", "com.affine.integration.calendar.new-url-label": "URL del calendario", - "com.affine.integration.calendar.new-duplicate-error-title": "Este es un calendario duplicado", - "com.affine.integration.calendar.new-duplicate-error-content": "Este calendario de suscripción ya existe en la cuenta de calendarios suscritos.", - "com.affine.integration.calendar.new-error": "Ocurrió un error al agregar el calendario", "com.affine.integration.calendar.all-day": "Todo el día", "com.affine.integration.calendar.new-doc": "Nuevo documento", "com.affine.integration.calendar.show-events": "Mostrar eventos del calendario", diff --git a/packages/frontend/i18n/src/resources/fa.json b/packages/frontend/i18n/src/resources/fa.json index a4e5af779..bdc9cccf1 100644 --- a/packages/frontend/i18n/src/resources/fa.json +++ b/packages/frontend/i18n/src/resources/fa.json @@ -2037,9 +2037,6 @@ "com.affine.integration.calendar.unsubscribe": "لغو اشتراک", "com.affine.integration.calendar.new-title": "افزودن یک تقویم از طریق URL", "com.affine.integration.calendar.new-url-label": "آدرس اینترنتی تقویم", - "com.affine.integration.calendar.new-duplicate-error-title": "این یک تقویم تکراری است", - "com.affine.integration.calendar.new-duplicate-error-content": "این تقویم اشتراکی در حساب تقویم‌های اشتراک‌شده موجود است.", - "com.affine.integration.calendar.new-error": "هنگام افزودن تقویم خطایی رخ داده است", "com.affine.integration.calendar.all-day": "تمام روز", "com.affine.integration.calendar.new-doc": "سند جدید", "com.affine.integration.calendar.show-events": "نمایش رویدادهای تقویم", diff --git a/packages/frontend/i18n/src/resources/fr.json b/packages/frontend/i18n/src/resources/fr.json index 283bce512..e48b387da 100644 --- a/packages/frontend/i18n/src/resources/fr.json +++ b/packages/frontend/i18n/src/resources/fr.json @@ -2061,9 +2061,6 @@ "com.affine.integration.calendar.unsubscribe": "Se désabonner", "com.affine.integration.calendar.new-title": "Ajouter un calendrier par URL", "com.affine.integration.calendar.new-url-label": "URL du calendrier", - "com.affine.integration.calendar.new-duplicate-error-title": "Ceci est un calendrier dupliqué", - "com.affine.integration.calendar.new-duplicate-error-content": "Ce calendrier d'abonnement existe déjà dans le compte des calendriers abonnés.", - "com.affine.integration.calendar.new-error": "Une erreur s'est produite lors de l'ajout du calendrier", "com.affine.integration.calendar.all-day": "Toute la journée", "com.affine.integration.calendar.new-doc": "Nouveau document", "com.affine.integration.calendar.show-events": "Afficher les événements du calendrier", diff --git a/packages/frontend/i18n/src/resources/it-IT.json b/packages/frontend/i18n/src/resources/it-IT.json index f5ae8b7fb..4b0198ff7 100644 --- a/packages/frontend/i18n/src/resources/it-IT.json +++ b/packages/frontend/i18n/src/resources/it-IT.json @@ -2060,9 +2060,6 @@ "com.affine.integration.calendar.unsubscribe": "Annulla iscrizione", "com.affine.integration.calendar.new-title": "Aggiungi un calendario tramite URL", "com.affine.integration.calendar.new-url-label": "URL del calendario", - "com.affine.integration.calendar.new-duplicate-error-title": "Questo è un calendario duplicato", - "com.affine.integration.calendar.new-duplicate-error-content": "Questo calendario di abbonamento esiste già nell'account dei calendari abbonati.", - "com.affine.integration.calendar.new-error": "Si è verificato un errore durante l'aggiunta del calendario", "com.affine.integration.calendar.all-day": "Tutto il giorno", "com.affine.integration.calendar.new-doc": "Nuovo documento", "com.affine.integration.calendar.show-events": "Mostra gli eventi del calendario", diff --git a/packages/frontend/i18n/src/resources/ja.json b/packages/frontend/i18n/src/resources/ja.json index 5dfc90815..f2ffee255 100644 --- a/packages/frontend/i18n/src/resources/ja.json +++ b/packages/frontend/i18n/src/resources/ja.json @@ -2037,9 +2037,6 @@ "com.affine.integration.calendar.unsubscribe": "購読解除", "com.affine.integration.calendar.new-title": "URLでカレンダーを追加", "com.affine.integration.calendar.new-url-label": "カレンダーURL", - "com.affine.integration.calendar.new-duplicate-error-title": "これは重複したカレンダーです", - "com.affine.integration.calendar.new-duplicate-error-content": "このサブスクリプションカレンダーは既にサブスクライブされたカレンダーアカウントに存在します。", - "com.affine.integration.calendar.new-error": "カレンダーの追加中にエラーが発生しました", "com.affine.integration.calendar.all-day": "終日", "com.affine.integration.calendar.new-doc": "新規ドキュмент", "com.affine.integration.calendar.show-events": "カレンダーイベントを表示", diff --git a/packages/frontend/i18n/src/resources/ko.json b/packages/frontend/i18n/src/resources/ko.json index e77b916be..3957e7d1e 100644 --- a/packages/frontend/i18n/src/resources/ko.json +++ b/packages/frontend/i18n/src/resources/ko.json @@ -2048,9 +2048,6 @@ "com.affine.integration.calendar.unsubscribe": "구독 취소", "com.affine.integration.calendar.new-title": "URL로 캘린더 추가", "com.affine.integration.calendar.new-url-label": "캘린더 URL", - "com.affine.integration.calendar.new-duplicate-error-title": "중복된 캘린더입니다", - "com.affine.integration.calendar.new-duplicate-error-content": "이 구독 캘린더는 구독된 캘린더 계정에 이미 존재합니다.", - "com.affine.integration.calendar.new-error": "캘린더 추가 중 오류가 발생했습니다", "com.affine.integration.calendar.all-day": "종일", "com.affine.integration.calendar.new-doc": "새 문서", "com.affine.integration.calendar.show-events": "캘린더 이벤트 표시", diff --git a/packages/frontend/i18n/src/resources/nb-NO.json b/packages/frontend/i18n/src/resources/nb-NO.json index 7794860d3..ea26d8fed 100644 --- a/packages/frontend/i18n/src/resources/nb-NO.json +++ b/packages/frontend/i18n/src/resources/nb-NO.json @@ -684,7 +684,6 @@ "com.affine.integration.calendar.show-all-day-events": "Vis heldagshendelse", "com.affine.integration.calendar.new-url-label": "Kalender-URL", "com.affine.integration.calendar.new-subscription": "Abonnér", - "com.affine.integration.calendar.new-duplicate-error-title": "Dette er en duplikat", "com.affine.integration.calendar.new-doc": "Nytt dokument", "com.affine.inactive": "Inaktiv", "com.affine.inactive-member": "Inaktivt medlem", diff --git a/packages/frontend/i18n/src/resources/pl.json b/packages/frontend/i18n/src/resources/pl.json index 85e784456..24a94adfe 100644 --- a/packages/frontend/i18n/src/resources/pl.json +++ b/packages/frontend/i18n/src/resources/pl.json @@ -2061,9 +2061,6 @@ "com.affine.integration.calendar.unsubscribe": "Anuluj subskrypcję", "com.affine.integration.calendar.new-title": "Dodaj kalendarz przez URL", "com.affine.integration.calendar.new-url-label": "URL kalendarza", - "com.affine.integration.calendar.new-duplicate-error-title": "Jest to duplikat kalendarza", - "com.affine.integration.calendar.new-duplicate-error-content": "Ten subskrybowany kalendarz już istnieje w koncie subskrybowanych kalendarzy.", - "com.affine.integration.calendar.new-error": "Wystąpił błąd podczas dodawania kalendarza", "com.affine.integration.calendar.all-day": "Cały dzień", "com.affine.integration.calendar.new-doc": "Nowy dokument", "com.affine.integration.calendar.show-events": "Pokaż wydarzenia kalendarza", diff --git a/packages/frontend/i18n/src/resources/pt-BR.json b/packages/frontend/i18n/src/resources/pt-BR.json index aca9fffc3..ddc5cbdea 100644 --- a/packages/frontend/i18n/src/resources/pt-BR.json +++ b/packages/frontend/i18n/src/resources/pt-BR.json @@ -2037,9 +2037,6 @@ "com.affine.integration.calendar.unsubscribe": "Desinscrever", "com.affine.integration.calendar.new-title": "Adicionar um calendário por URL", "com.affine.integration.calendar.new-url-label": "URL do Calendário", - "com.affine.integration.calendar.new-duplicate-error-title": "Este é um calendário duplicado", - "com.affine.integration.calendar.new-duplicate-error-content": "Este calendário de inscrição já existe na conta de calendários inscritos.", - "com.affine.integration.calendar.new-error": "Aconteceu um erro ao adicionar o calendário", "com.affine.integration.calendar.all-day": "Dia inteiro", "com.affine.integration.calendar.new-doc": "Novo documento", "com.affine.integration.calendar.show-events": "Mostrar eventos de calendário", diff --git a/packages/frontend/i18n/src/resources/ru.json b/packages/frontend/i18n/src/resources/ru.json index 9036ad0ee..e65fb0ca3 100644 --- a/packages/frontend/i18n/src/resources/ru.json +++ b/packages/frontend/i18n/src/resources/ru.json @@ -2059,9 +2059,6 @@ "com.affine.integration.calendar.unsubscribe": "Отписаться", "com.affine.integration.calendar.new-title": "Добавить календарь по URL", "com.affine.integration.calendar.new-url-label": "URL-адрес календаря", - "com.affine.integration.calendar.new-duplicate-error-title": "Это дублирующийся календарь", - "com.affine.integration.calendar.new-duplicate-error-content": "Календарь подписки уже существует в учетной записи подписанных календарей.", - "com.affine.integration.calendar.new-error": "Произошла ошибка при добавлении календаря", "com.affine.integration.calendar.all-day": "Весь день", "com.affine.integration.calendar.new-doc": "Новый документ", "com.affine.integration.calendar.show-events": "Показать события календаря", diff --git a/packages/frontend/i18n/src/resources/sv-SE.json b/packages/frontend/i18n/src/resources/sv-SE.json index 469968639..a84590f1b 100644 --- a/packages/frontend/i18n/src/resources/sv-SE.json +++ b/packages/frontend/i18n/src/resources/sv-SE.json @@ -2041,9 +2041,6 @@ "com.affine.integration.calendar.unsubscribe": "Avsluta prenumeration", "com.affine.integration.calendar.new-title": "Lägg till en kalender via URL", "com.affine.integration.calendar.new-url-label": "Kalender URL", - "com.affine.integration.calendar.new-duplicate-error-title": "Detta är en duplicerad kalender", - "com.affine.integration.calendar.new-duplicate-error-content": "Denna abonnemangskalender finns redan i kontot för prenumererade kalendrar.", - "com.affine.integration.calendar.new-error": "Ett fel inträffade när kalendern lades till.", "com.affine.integration.calendar.all-day": "Hela dagen", "com.affine.integration.calendar.new-doc": "Ny sida", "com.affine.integration.calendar.show-events": "Visa kalenderhändelser", diff --git a/packages/frontend/i18n/src/resources/uk.json b/packages/frontend/i18n/src/resources/uk.json index 8f44fb7e8..11e6bbf0d 100644 --- a/packages/frontend/i18n/src/resources/uk.json +++ b/packages/frontend/i18n/src/resources/uk.json @@ -2037,9 +2037,6 @@ "com.affine.integration.calendar.unsubscribe": "Відписатися", "com.affine.integration.calendar.new-title": "Додати календар за URL", "com.affine.integration.calendar.new-url-label": "URL календаря", - "com.affine.integration.calendar.new-duplicate-error-title": "Це дубльований календар", - "com.affine.integration.calendar.new-duplicate-error-content": "Цей календар підписки вже існує в акаунті підписаних календарів.", - "com.affine.integration.calendar.new-error": "Під час додавання календаря сталася помилка", "com.affine.integration.calendar.all-day": "Цілий день", "com.affine.integration.calendar.new-doc": "Новий документ", "com.affine.integration.calendar.show-events": "Показати події календаря", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index 2de8e5d7b..02494c775 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -2063,9 +2063,6 @@ "com.affine.integration.calendar.unsubscribe": "取消订阅", "com.affine.integration.calendar.new-title": "添加日历订阅的链接", "com.affine.integration.calendar.new-url-label": "日历订阅链接", - "com.affine.integration.calendar.new-duplicate-error-title": "这是一个重复的日历", - "com.affine.integration.calendar.new-duplicate-error-content": "此订阅日历已存在于帐户中。", - "com.affine.integration.calendar.new-error": "添加日历时出错了", "com.affine.integration.calendar.all-day": "全天", "com.affine.integration.calendar.new-doc": "新建文档", "com.affine.integration.calendar.show-events": "显示日历事件", diff --git a/packages/frontend/i18n/src/resources/zh-Hant.json b/packages/frontend/i18n/src/resources/zh-Hant.json index 0c72cbc38..4169b7cef 100644 --- a/packages/frontend/i18n/src/resources/zh-Hant.json +++ b/packages/frontend/i18n/src/resources/zh-Hant.json @@ -2040,9 +2040,6 @@ "com.affine.integration.calendar.unsubscribe": "取消訂閱", "com.affine.integration.calendar.new-title": "添加日曆訂閱的鏈接", "com.affine.integration.calendar.new-url-label": "日曆訂閱鏈接", - "com.affine.integration.calendar.new-duplicate-error-title": "這是一個重複的日曆", - "com.affine.integration.calendar.new-duplicate-error-content": "此訂閱日曆已存在於賬戶中。", - "com.affine.integration.calendar.new-error": "添加日曆時出錯了", "com.affine.integration.calendar.all-day": "全天", "com.affine.integration.calendar.new-doc": "新建文檔", "com.affine.integration.calendar.show-events": "顯示日曆事件", diff --git a/yarn.lock b/yarn.lock index 7e575db24..0f54e420d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -457,7 +457,6 @@ __metadata: graphemer: "npm:^1.4.0" graphql: "npm:^16.9.0" history: "npm:^5.3.0" - ical.js: "npm:^2.1.0" idb: "npm:^8.0.0" idb-keyval: "npm:^6.2.2" image-blob-reduce: "npm:^4.1.0" @@ -27306,13 +27305,6 @@ __metadata: languageName: node linkType: hard -"ical.js@npm:^2.1.0": - version: 2.1.0 - resolution: "ical.js@npm:2.1.0" - checksum: 10/2c1ac836a3a87a6958ab5386f26965a70328ca5566dd84fc641726aef893adb43433aa35d7ea576b890e8461ce54995f9fa82bfa32ce8b186b9b9d4788a6f894 - languageName: node - linkType: hard - "iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24"