mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 00:28:33 +00:00
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:
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
*/
|
||||
|
||||
@@ -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": "عرض أحداث التقويم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Εμφάνιση γεγονότων ημερολογίου",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "نمایش رویدادهای تقویم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "カレンダーイベントを表示",
|
||||
|
||||
@@ -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": "캘린더 이벤트 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показать события календаря",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показати події календаря",
|
||||
|
||||
@@ -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": "显示日历事件",
|
||||
|
||||
@@ -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": "顯示日曆事件",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user