feat(core): integrate google calendar sync (#14248)

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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-01-13 02:38:16 +08:00
committed by GitHub
parent 89f0430242
commit 279b7bb64f
50 changed files with 1349 additions and 1002 deletions

View File

@@ -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!(

View File

@@ -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<SelectMode>('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 (
<div

View File

@@ -63,6 +63,11 @@ export interface DatePickerProps {
*/
onChange?: (value: string) => void;
/**
* when cursor date changes (month navigation/keyboard)
*/
onCursorChange?: (cursor: dayjs.Dayjs) => void;
// style customizations
monthHeaderCellClassName?: string;
monthBodyCellClassName?: string;

View File

@@ -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",

View File

@@ -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 && (
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
)}
<IntegrationsPanel />
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}

View File

@@ -0,0 +1,132 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const panel = style({
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: '12px 0 4px',
});
export const panelHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
});
export const panelTitle = style({
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 14,
lineHeight: '22px',
fontWeight: 500,
color: cssVarV2.text.primary,
});
export const loading = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px 0',
});
export const accountList = style({
display: 'flex',
flexDirection: 'column',
gap: 12,
});
export const accountRow = style({
display: 'flex',
justifyContent: 'space-between',
gap: 16,
padding: '12px 16px',
borderRadius: 8,
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
background: cssVarV2.layer.background.primary,
});
export const accountInfo = style({
display: 'flex',
gap: 12,
alignItems: 'flex-start',
});
export const accountIcon = style({
width: 32,
height: 32,
borderRadius: 8,
background: cssVarV2.layer.background.secondary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2.text.secondary,
});
export const accountDetails = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const accountName = style({
fontSize: 14,
lineHeight: '20px',
fontWeight: 500,
color: cssVarV2.text.primary,
});
export const accountMeta = style({
display: 'flex',
gap: 8,
flexWrap: 'wrap',
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.text.secondary,
});
export const accountStatus = style({
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
lineHeight: '18px',
color: cssVarV2.status.error,
});
export const statusDot = style({
width: 6,
height: 6,
borderRadius: 999,
background: cssVarV2.status.error,
});
export const accountActions = style({
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
justifyContent: 'flex-end',
});
export const interval = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
alignItems: 'flex-end',
});
export const intervalLabel = style({
fontSize: 11,
lineHeight: '16px',
color: cssVarV2.text.secondary,
});
export const empty = style({
fontSize: 12,
lineHeight: '20px',
color: cssVarV2.text.secondary,
padding: '12px 0',
});

View File

@@ -0,0 +1,250 @@
import { Button, Loading, Menu, MenuItem, notify } from '@affine/component';
import { GraphQLService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { UserFriendlyError } from '@affine/error';
import {
type CalendarAccountsQuery,
calendarAccountsQuery,
calendarProvidersQuery,
CalendarProviderType,
linkCalendarAccountMutation,
unlinkCalendarAccountMutation,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { CollapsibleWrapper } from '../layout';
import * as styles from './integrations-panel.css';
type CalendarAccount = CalendarAccountsQuery['calendarAccounts'][number];
const providerMeta = {
[CalendarProviderType.Google]: {
label: 'Google Calendar',
icon: <GoogleIcon />,
},
[CalendarProviderType.CalDAV]: {
label: 'CalDAV',
icon: <LinkIcon />,
},
} satisfies Partial<
Record<CalendarProviderType, { label: string; icon: ReactNode }>
>;
export const IntegrationsPanel = () => {
const t = useI18n();
const gqlService = useService(GraphQLService);
const urlService = useService(UrlService);
const [accounts, setAccounts] = useState<CalendarAccount[]>([]);
const [providers, setProviders] = useState<CalendarProviderType[]>([]);
const [loading, setLoading] = useState(true);
const [linking, setLinking] = useState(false);
const [unlinkingAccountId, setUnlinkingAccountId] = useState<string | null>(
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 (
<CollapsibleWrapper
title={t['com.affine.integration.integrations']()}
caption={t['com.affine.integration.setting.description']()}
>
<div className={styles.panel}>
<div className={styles.panelHeader}>
<div className={styles.panelTitle}>
<TodayIcon />
<span>{t['com.affine.integration.calendar.name']()}</span>
</div>
{providerOptions.length ? (
<Menu
items={providerOptions.map(option => (
<MenuItem
key={option.provider}
prefixIcon={option.icon}
onSelect={() => void handleLink(option.provider)}
>
{option.label}
</MenuItem>
))}
contentOptions={{ align: 'end' }}
>
<Button variant="primary" loading={linking}>
Link
</Button>
</Menu>
) : (
<Button variant="primary" disabled>
Link
</Button>
)}
</div>
{loading ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : accounts.length ? (
<div className={styles.accountList}>
{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 (
<div key={account.id} className={styles.accountRow}>
<div className={styles.accountInfo}>
<div className={styles.accountIcon}>
{meta?.icon ?? <LinkIcon />}
</div>
<div className={styles.accountDetails}>
<div className={styles.accountName}>{title}</div>
<div className={styles.accountMeta}>
{subtitle ? <span>{subtitle}</span> : null}
<span>
{account.calendarsCount} calendar
{account.calendarsCount === 1 ? '' : 's'}
</span>
</div>
{showStatus ? (
<div className={styles.accountStatus}>
<span className={styles.statusDot} />
Authorization failed. Please reconnect your account.
</div>
) : null}
</div>
</div>
<div className={styles.accountActions}>
<Button
variant="error"
disabled={unlinkingAccountId === account.id}
onClick={() => void handleUnlink(account.id)}
>
Unlink
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className={styles.empty}>No calendar accounts linked yet.</div>
)}
</div>
</CollapsibleWrapper>
);
};

View File

@@ -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 (
<PricingCollapsible title={title} caption={caption}>
<CollapsibleWrapper title={title} caption={caption}>
<div className={styles.card}>
<div className={styles.titleBlock}>
<section className={styles.titleCaption1}>
@@ -42,6 +42,6 @@ export const AIPlanLayout = ({
<AIBenefits />
</div>
</PricingCollapsible>
</CollapsibleWrapper>
);
};

View File

@@ -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',

View File

@@ -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<HTMLDivElement>,
'title'
> {
title?: ReactNode;
caption?: ReactNode;
}
export const PricingCollapsible = ({
title,
caption,
children,
}: PricingCollapsibleProps) => {
const [open, setOpen] = useState(true);
const toggle = useCallback(() => setOpen(prev => !prev), []);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<section className={styles.collapsibleHeader}>
<div className={styles.collapsibleHeaderContent}>
<div className={styles.collapsibleHeaderTitle}>{title}</div>
<div className={styles.collapsibleHeaderCaption}>{caption}</div>
</div>
<IconButton onClick={toggle} size="20">
<ArrowUpSmallIcon
style={{
transform: open ? 'rotate(0deg)' : 'rotate(180deg)',
transition: 'transform 0.23s ease',
}}
/>
</IconButton>
</section>
<Collapsible.Content>{children}</Collapsible.Content>
</Collapsible.Root>
);
};
export interface PlanLayoutProps {
cloud?: ReactNode;
ai?: ReactNode;
@@ -112,7 +70,7 @@ export const CloudPlanLayout = ({
scrollRef,
}: PlanCardProps) => {
return (
<PricingCollapsible title={title} caption={caption}>
<CollapsibleWrapper title={title} caption={caption}>
<div className={styles.affineCloudHeader}>
<div>{select}</div>
<div>{toggle}</div>
@@ -134,6 +92,6 @@ export const CloudPlanLayout = ({
{lifetime}
</div>
) : null}
</PricingCollapsible>
</CollapsibleWrapper>
);
};

View File

@@ -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'),
});

View File

@@ -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<HTMLDivElement>,
'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 (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<section className={styles.collapsibleHeader}>
<div className={styles.collapsibleHeaderContent}>
<div className={styles.collapsibleHeaderTitle}>{title}</div>
<div className={styles.collapsibleHeaderCaption}>{caption}</div>
</div>
<IconButton onClick={toggle} size="20">
<ArrowUpSmallIcon
style={{
transform: open ? 'rotate(0deg)' : 'rotate(180deg)',
transition: 'transform 0.23s ease',
}}
/>
</IconButton>
</section>
<Collapsible.Content>{children}</Collapsible.Content>
</Collapsible.Root>
);
};

View File

@@ -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,
});

View File

@@ -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<string>, right: Set<string>) => {
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<Set<string>>(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 (
<>
<IntegrationSettingHeader
@@ -24,117 +94,55 @@ export const CalendarSettingPanel = () => {
divider={false}
/>
<div className={styles.list}>
{subscriptions.map(subscription => (
<SubscriptionSetting
key={subscription.url}
subscription={subscription}
/>
))}
<AddSubscription />
{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 (
<section key={account.id} className={styles.group}>
<div className={styles.groupHeader}>
<div>
<div className={styles.groupTitle}>{title}</div>
{caption ? (
<div className={styles.groupCaption}>{caption}</div>
) : null}
</div>
<div className={styles.groupMeta}>
{calendars.length}{' '}
{t['com.affine.integration.calendar.name']()}
</div>
</div>
<div className={styles.groupList}>
{calendars.map(subscription => (
<SubscriptionSetting
key={subscription.id}
subscription={subscription}
checked={selectedIds.has(subscription.id)}
onToggle={handleToggle}
/>
))}
</div>
</section>
);
})}
{!hasCalendars ? (
<div className={styles.empty}>
{t['com.affine.integration.calendar.no-calendar']()}
</div>
) : null}
</div>
<div className={styles.actions}>
<Button
variant="primary"
onClick={() => void handleSave()}
disabled={!hasChanges || saving}
loading={saving}
>
{t['com.affine.editCollection.save']()}
</Button>
</div>
</>
);
};
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 (
<>
<Button
prefix={<PlusIcon />}
size="large"
onClick={handleOpen}
className={styles.newButton}
>
{t['com.affine.integration.calendar.new-subscription']()}
</Button>
<Modal
open={open}
onOpenChange={setOpen}
persistent
withoutCloseButton
contentOptions={{ className: styles.newDialog }}
>
<header className={styles.newDialogHeader}>
<IntegrationCardIcon>
<TodayIcon />
</IntegrationCardIcon>
<div className={styles.newDialogTitle}>
{t['com.affine.integration.calendar.new-title']()}
</div>
</header>
<div className={styles.newDialogContent}>
<div className={styles.newDialogLabel}>
{t['com.affine.integration.calendar.new-url-label']()}
</div>
<Input
type="text"
value={url}
onChange={handleInputChange}
placeholder="https://example.com/calendar.ics"
onEnter={handleAddSub}
/>
</div>
<footer className={styles.newDialogFooter}>
<Button onClick={handleClose}>{t['Cancel']()}</Button>
<Button variant="primary" onClick={handleAddSub} loading={verifying}>
{t['com.affine.integration.calendar.new-subscription']()}
</Button>
</footer>
</Modal>
</>
);
};

View File

@@ -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,
});

View File

@@ -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 (
<div className={styles.card}>
<div className={styles.header}>
<Menu
rootOptions={{ open: menuOpen, onOpenChange: setMenuOpen }}
contentOptions={{ alignOffset: -6 }}
items={
<ColorPicker
activeColor={config.color}
onChange={handleColorChange}
/>
}
>
<div
className={styles.colorPickerTrigger}
style={{ color: config.color }}
/>
</Menu>
<InlineEdit
className={styles.name}
editable
trigger="click"
value={name}
onChange={handleNameChange}
/>
<UnsubscribeButton url={subscription.url} name={name} />
</div>
<div className={styles.divider} />
<IntegrationSettingToggle
name={t['com.affine.integration.calendar.show-events']()}
desc={t['com.affine.integration.calendar.show-events-desc']()}
checked={!!config.showEvents}
onChange={toggleShowEvents}
<div
className={styles.item}
onClick={() => handleToggle(!checked)}
role="button"
tabIndex={0}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleToggle(!checked);
}
}}
>
<Checkbox
checked={checked}
onChange={(_, nextChecked) => handleToggle(nextChecked)}
onClick={event => event.stopPropagation()}
/>
<div
data-collapsed={!config.showEvents}
className={styles.allDayEventsContainer}
>
<div className={styles.allDayEventsContent}>
<div className={styles.divider} />
<IntegrationSettingToggle
name={t['com.affine.integration.calendar.show-all-day-events']()}
checked={!!config.showAllDayEvents}
onChange={toggleShowAllDayEvents}
/>
className={styles.color}
style={{
backgroundColor: subscription.color || cssVarV2.button.primary,
}}
/>
<div className={styles.info}>
<div className={styles.name}>
{subscription.displayName ?? subscription.externalCalendarId}
</div>
{subscription.timezone ? (
<div className={styles.meta}>{subscription.timezone}</div>
) : null}
</div>
</div>
);
};
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 (
<Button variant="error" onClick={handleUnsubscribe}>
{t['com.affine.integration.calendar.unsubscribe']()}
</Button>
);
};
const ColorPicker = ({
activeColor,
onChange,
}: {
onChange: (color: string) => void;
activeColor: string;
}) => {
const calendar = useService(IntegrationService).calendar;
const colors = useMemo(() => calendar.colors, [calendar]);
return (
<ul className={styles.colorPicker}>
{colors.map(color => (
<li
key={color}
onClick={() => onChange(color)}
data-active={color === activeColor}
className={styles.colorPickerItem}
style={{ color }}
/>
))}
</ul>
);
};

View File

@@ -31,12 +31,13 @@ const INTEGRATION_LIST = [
icon: <IntegrationTypeIcon type="readwise" />,
setting: <ReadwiseSettingPanel />,
},
BUILD_CONFIG.isElectron && {
{
id: 'calendar' as const,
name: 'com.affine.integration.calendar.name',
desc: 'com.affine.integration.calendar.desc',
icon: <TodayIcon />,
setting: <CalendarSettingPanel />,
cloud: true,
},
{
id: 'mcp-server' as const,

View File

@@ -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 (
<ul className={styles.list}>
{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 ? <FullDayIcon /> : <PeriodIcon />}
</div>
</Tooltip>
<div className={styles.eventTitle}>{title}</div>
<div className={styles.eventTitle}>{eventTitle}</div>
{loading ? (
<Loading />
) : (

View File

@@ -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<string>();
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 (
<button
className={styles.journalDateCell}
@@ -149,17 +253,27 @@ export const EditorJournalPanel = () => {
data-not-current-month={cell.notCurrentMonth}
data-selected={cell.selected}
data-is-journal={isJournal}
data-has-journal={hasJournal}
data-has-journal={allJournalDates.has(dateKey)}
data-mobile={mobile}
>
{cell.label}
{hasJournal && !cell.selected ? (
<div className={styles.journalDateCellDot} />
{!cell.selected && dotTypes.length ? (
<div className={styles.journalDateCellDotContainer}>
{dotTypes.map(dotType => (
<div
key={dotType}
className={clsx(
styles.journalDateCellDot,
styles.journalDateCellDotType[dotType]
)}
/>
))}
</div>
) : null}
</button>
);
},
[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}
/>
</div>
<JournalTemplateOnboarding />
{journalDate ? (
<>
<JournalConflictBlock date={journalDate} />
<CalendarEvents date={journalDate} />
<JournalDailyCountBlock date={journalDate} />
</>
) : (
<div className={styles.spacer} />
)}
<JournalConflictBlock date={selectedDate} />
<CalendarEvents date={selectedDate} />
<JournalDailyCountBlock date={selectedDate} />
<JournalTemplateSetting />
</div>
);

View File

@@ -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,
},
});

View File

@@ -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<Server | null>(null);
get server() {
return this.server$.value;
}
bindServer(server: Server) {
this.server = server;
this.server$.setValue(server);
}
}

View File

@@ -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<any>(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();
}
}

View File

@@ -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<CalendarSubscription[]>(subscribe => {
subscribe.next(refs.map(ref => ref.obj));
return () => {
refs.forEach(ref => ref.release());
};
});
})
),
accounts$ = new LiveData<CalendarAccountsQuery['calendarAccounts'][number][]>(
[]
);
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<string, CalendarEvent[]>());
accountCalendars$ = new LiveData<
Map<
string,
CalendarAccountCalendarsQuery['calendarAccountCalendars'][number][]
>
>(new Map());
workspaceCalendars$ = new LiveData<
WorkspaceCalendarsQuery['workspaceCalendars'][number][]
>([]);
readonly eventsByDateMap$ = new LiveData<
Map<string, CalendarEventsQuery['calendarEvents'][number][]>
>(new Map());
readonly eventDates$ = LiveData.computed(get => {
const eventsByDateMap = get(this.eventsByDateMap$);
const dates = new Set<string>();
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<Omit<CalendarSubscriptionConfig, 'url'>>
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);
}
}

View File

@@ -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]);
}

View File

@@ -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<string, CalendarSubscriptionConfig>;
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<CalendarSubscriptionStore>(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<string>(this.getCacheKey(url));
}
getSubscriptionMap() {
return (
this.globalState.get<CalendarSubscriptionStore | undefined>(
this.getStorageKey()
) ?? {}
);
}
addSubscription(url: string, config?: Partial<CalendarSubscriptionConfig>) {
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<Omit<CalendarSubscriptionConfig, 'url'>>
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;
}
}

View File

@@ -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<string, CalendarEvent[]>;

View File

@@ -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}"`);
}
};

View File

@@ -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;
}
};

View File

@@ -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<string, string>) {
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<string>();
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<string, Record<string, string>>
);
const candidateKeys = new Set<string>();
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();

View File

@@ -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`
*/

View File

@@ -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": "عرض أحداث التقويم",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Εμφάνιση γεγονότων ημερολογίου",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "نمایش رویدادهای تقویم",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "カレンダーイベントを表示",

View File

@@ -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": "캘린더 이벤트 표시",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Показать события календаря",

View File

@@ -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",

View File

@@ -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": "Показати події календаря",

View File

@@ -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": "显示日历事件",

View File

@@ -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": "顯示日曆事件",