mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08: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 documents = html_loader.load().unwrap();
|
||||||
|
|
||||||
let expected =
|
let expected = [
|
||||||
"Example Domain\n\n This domain is for use in illustrative examples in documents. You may\n use \
|
"Example Domain",
|
||||||
this domain in literature without prior coordination or asking for\n permission.\n More \
|
"",
|
||||||
information...";
|
" 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!(documents.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ export type { DatePickerProps } from './types';
|
|||||||
*/
|
*/
|
||||||
export const DatePicker = (props: DatePickerProps) => {
|
export const DatePicker = (props: DatePickerProps) => {
|
||||||
const finalProps = { ...defaultDatePickerProps, ...props };
|
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 [mode, setMode] = useState<SelectMode>('day');
|
||||||
const [cursor, setCursor] = useState(dayjs(value));
|
const [cursor, setCursor] = useState(dayjs(value));
|
||||||
@@ -41,12 +49,16 @@ export const DatePicker = (props: DatePickerProps) => {
|
|||||||
[onChange]
|
[onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCursorChange = useCallback((newCursor: dayjs.Dayjs) => {
|
const onCursorChange = useCallback(
|
||||||
// validate range
|
(newCursor: dayjs.Dayjs) => {
|
||||||
if (newCursor.isBefore(DATE_MIN)) newCursor = dayjs(DATE_MIN);
|
// validate range
|
||||||
else if (newCursor.isAfter(DATE_MAX)) newCursor = dayjs(DATE_MAX);
|
if (newCursor.isBefore(DATE_MIN)) newCursor = dayjs(DATE_MIN);
|
||||||
setCursor(newCursor);
|
else if (newCursor.isAfter(DATE_MAX)) newCursor = dayjs(DATE_MAX);
|
||||||
}, []);
|
setCursor(newCursor);
|
||||||
|
handleCursorChange?.(newCursor);
|
||||||
|
},
|
||||||
|
[handleCursorChange]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export interface DatePickerProps {
|
|||||||
*/
|
*/
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* when cursor date changes (month navigation/keyboard)
|
||||||
|
*/
|
||||||
|
onCursorChange?: (cursor: dayjs.Dayjs) => void;
|
||||||
|
|
||||||
// style customizations
|
// style customizations
|
||||||
monthHeaderCellClassName?: string;
|
monthHeaderCellClassName?: string;
|
||||||
monthBodyCellClassName?: string;
|
monthBodyCellClassName?: string;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@
|
|||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"graphql": "^16.9.0",
|
"graphql": "^16.9.0",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"ical.js": "^2.1.0",
|
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"image-blob-reduce": "^4.1.0",
|
"image-blob-reduce": "^4.1.0",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AuthService, ServerService } from '../../../../modules/cloud';
|
|||||||
import type { SettingState } from '../types';
|
import type { SettingState } from '../types';
|
||||||
import { AIUsagePanel } from './ai-usage-panel';
|
import { AIUsagePanel } from './ai-usage-panel';
|
||||||
import { DeleteAccount } from './delete-account';
|
import { DeleteAccount } from './delete-account';
|
||||||
|
import { IntegrationsPanel } from './integrations-panel';
|
||||||
import { StorageProgress } from './storage-progress';
|
import { StorageProgress } from './storage-progress';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ export const AccountSetting = ({
|
|||||||
{serverFeatures?.copilot && (
|
{serverFeatures?.copilot && (
|
||||||
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
|
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
|
||||||
)}
|
)}
|
||||||
|
<IntegrationsPanel />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
name={t[`Sign out`]()}
|
name={t[`Sign out`]()}
|
||||||
desc={t['com.affine.setting.sign.out.message']()}
|
desc={t['com.affine.setting.sign.out.message']()}
|
||||||
|
|||||||
+132
@@ -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',
|
||||||
|
});
|
||||||
+250
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { PricingCollapsible } from '../layout';
|
import { CollapsibleWrapper } from '../../../layout';
|
||||||
import * as styles from './ai-plan.css';
|
import * as styles from './ai-plan.css';
|
||||||
import { AIBenefits } from './benefits';
|
import { AIBenefits } from './benefits';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export const AIPlanLayout = ({
|
|||||||
const title = t['com.affine.payment.ai.pricing-plan.title']();
|
const title = t['com.affine.payment.ai.pricing-plan.title']();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PricingCollapsible title={title} caption={caption}>
|
<CollapsibleWrapper title={title} caption={caption}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.titleBlock}>
|
<div className={styles.titleBlock}>
|
||||||
<section className={styles.titleCaption1}>
|
<section className={styles.titleCaption1}>
|
||||||
@@ -42,6 +42,6 @@ export const AIPlanLayout = ({
|
|||||||
|
|
||||||
<AIBenefits />
|
<AIBenefits />
|
||||||
</div>
|
</div>
|
||||||
</PricingCollapsible>
|
</CollapsibleWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
-21
@@ -44,27 +44,6 @@ export const allPlansLink = style({
|
|||||||
fontSize: cssVar('fontXs'),
|
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({
|
export const affineCloudHeader = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|||||||
+6
-48
@@ -1,17 +1,11 @@
|
|||||||
import { Divider, IconButton } from '@affine/component';
|
import { Divider } from '@affine/component';
|
||||||
import { SettingHeader } from '@affine/component/setting-components';
|
import { SettingHeader } from '@affine/component/setting-components';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc';
|
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
|
||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
import {
|
import { type ReactNode, useRef } from 'react';
|
||||||
type HtmlHTMLAttributes,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
|
import { CollapsibleWrapper } from '../../layout';
|
||||||
import * as styles from './layout.css';
|
import * as styles from './layout.css';
|
||||||
|
|
||||||
export const SeeAllLink = () => {
|
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 {
|
export interface PlanLayoutProps {
|
||||||
cloud?: ReactNode;
|
cloud?: ReactNode;
|
||||||
ai?: ReactNode;
|
ai?: ReactNode;
|
||||||
@@ -112,7 +70,7 @@ export const CloudPlanLayout = ({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
}: PlanCardProps) => {
|
}: PlanCardProps) => {
|
||||||
return (
|
return (
|
||||||
<PricingCollapsible title={title} caption={caption}>
|
<CollapsibleWrapper title={title} caption={caption}>
|
||||||
<div className={styles.affineCloudHeader}>
|
<div className={styles.affineCloudHeader}>
|
||||||
<div>{select}</div>
|
<div>{select}</div>
|
||||||
<div>{toggle}</div>
|
<div>{toggle}</div>
|
||||||
@@ -134,6 +92,6 @@ export const CloudPlanLayout = ({
|
|||||||
{lifetime}
|
{lifetime}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+40
-25
@@ -4,46 +4,61 @@ import { style } from '@vanilla-extract/css';
|
|||||||
export const list = style({
|
export const list = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
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,
|
color: cssVarV2.text.secondary,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newDialog = style({
|
export const groupMeta = style({
|
||||||
maxWidth: 480,
|
fontSize: 12,
|
||||||
|
lineHeight: '20px',
|
||||||
|
color: cssVarV2.text.secondary,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newDialogHeader = style({
|
export const groupList = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newDialogTitle = style({
|
export const empty = style({
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: '24px',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: cssVarV2.text.primary,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const newDialogContent = style({
|
|
||||||
marginTop: 16,
|
|
||||||
marginBottom: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const newDialogLabel = style({
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
fontWeight: 500,
|
color: cssVarV2.text.secondary,
|
||||||
color: cssVarV2.text.primary,
|
padding: '8px 0',
|
||||||
marginBottom: 4,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newDialogFooter = style({
|
export const actions = style({
|
||||||
|
marginTop: 12,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
alignItems: 'center',
|
|
||||||
gap: 20,
|
|
||||||
});
|
});
|
||||||
|
|||||||
+124
-116
@@ -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 { IntegrationService } from '@affine/core/modules/integration';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import track from '@affine/track';
|
import { TodayIcon } from '@blocksuite/icons/rc';
|
||||||
import { PlusIcon, TodayIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
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 { IntegrationSettingHeader } from '../setting';
|
||||||
import * as styles from './setting-panel.css';
|
import * as styles from './setting-panel.css';
|
||||||
import { SubscriptionSetting } from './subscription-setting';
|
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 = () => {
|
export const CalendarSettingPanel = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const calendar = useService(IntegrationService).calendar;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<IntegrationSettingHeader
|
<IntegrationSettingHeader
|
||||||
@@ -24,117 +94,55 @@ export const CalendarSettingPanel = () => {
|
|||||||
divider={false}
|
divider={false}
|
||||||
/>
|
/>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{subscriptions.map(subscription => (
|
{accounts.map(account => {
|
||||||
<SubscriptionSetting
|
const calendars = accountCalendars.get(account.id) ?? [];
|
||||||
key={subscription.url}
|
if (calendars.length === 0) return null;
|
||||||
subscription={subscription}
|
const title = account.displayName ?? account.email ?? account.id;
|
||||||
/>
|
const caption =
|
||||||
))}
|
account.displayName && account.email ? account.email : null;
|
||||||
<AddSubscription />
|
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>
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
+25
-88
@@ -1,104 +1,41 @@
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const card = style({
|
export const item = 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({
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 10,
|
||||||
padding: '7px 0',
|
padding: '6px 8px',
|
||||||
});
|
borderRadius: 6,
|
||||||
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',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-active="true"]': {
|
'&:hover': {
|
||||||
boxShadow: `0 0 0 1px ${cssVarV2.button.primary}`,
|
background: cssVarV2.layer.background.secondary,
|
||||||
},
|
|
||||||
'&:before': {
|
|
||||||
content: '',
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: 'currentColor',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
export const name = style({
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 500,
|
lineHeight: '20px',
|
||||||
lineHeight: '22px',
|
|
||||||
color: cssVarV2.text.primary,
|
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({
|
export const meta = style({
|
||||||
overflow: 'hidden',
|
fontSize: 12,
|
||||||
display: 'grid',
|
lineHeight: '18px',
|
||||||
gridTemplateRows: '1fr',
|
color: cssVarV2.text.secondary,
|
||||||
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',
|
|
||||||
});
|
});
|
||||||
|
|||||||
+42
-141
@@ -1,160 +1,61 @@
|
|||||||
import { Button, InlineEdit, Menu, useConfirmModal } from '@affine/component';
|
import { Checkbox } from '@affine/component';
|
||||||
import {
|
import type { CalendarAccountCalendarsQuery } from '@affine/graphql';
|
||||||
type CalendarSubscription,
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
IntegrationService,
|
import { useCallback } from 'react';
|
||||||
} 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 { IntegrationSettingToggle } from '../setting';
|
|
||||||
import * as styles from './subscription-setting.css';
|
import * as styles from './subscription-setting.css';
|
||||||
|
|
||||||
|
type CalendarSubscription =
|
||||||
|
CalendarAccountCalendarsQuery['calendarAccountCalendars'][number];
|
||||||
|
|
||||||
export const SubscriptionSetting = ({
|
export const SubscriptionSetting = ({
|
||||||
subscription,
|
subscription,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
subscription: CalendarSubscription;
|
subscription: CalendarSubscription;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: (id: string, checked: boolean) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const handleToggle = useCallback(
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
(nextChecked: boolean) => {
|
||||||
const calendar = useService(IntegrationService).calendar;
|
onToggle(subscription.id, nextChecked);
|
||||||
const config = useLiveData(subscription.config$);
|
|
||||||
const name = useLiveData(subscription.name$) || t['Untitled']();
|
|
||||||
|
|
||||||
const handleColorChange = useCallback(
|
|
||||||
(color: string) => {
|
|
||||||
calendar.updateSubscription(subscription.url, { color });
|
|
||||||
setMenuOpen(false);
|
|
||||||
},
|
},
|
||||||
[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 (
|
return (
|
||||||
<div className={styles.card}>
|
<div
|
||||||
<div className={styles.header}>
|
className={styles.item}
|
||||||
<Menu
|
onClick={() => handleToggle(!checked)}
|
||||||
rootOptions={{ open: menuOpen, onOpenChange: setMenuOpen }}
|
role="button"
|
||||||
contentOptions={{ alignOffset: -6 }}
|
tabIndex={0}
|
||||||
items={
|
onKeyDown={event => {
|
||||||
<ColorPicker
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
activeColor={config.color}
|
event.preventDefault();
|
||||||
onChange={handleColorChange}
|
handleToggle(!checked);
|
||||||
/>
|
}
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Checkbox
|
||||||
className={styles.colorPickerTrigger}
|
checked={checked}
|
||||||
style={{ color: config.color }}
|
onChange={(_, nextChecked) => handleToggle(nextChecked)}
|
||||||
/>
|
onClick={event => event.stopPropagation()}
|
||||||
</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
|
<div
|
||||||
data-collapsed={!config.showEvents}
|
className={styles.color}
|
||||||
className={styles.allDayEventsContainer}
|
style={{
|
||||||
>
|
backgroundColor: subscription.color || cssVarV2.button.primary,
|
||||||
<div className={styles.allDayEventsContent}>
|
}}
|
||||||
<div className={styles.divider} />
|
/>
|
||||||
<IntegrationSettingToggle
|
<div className={styles.info}>
|
||||||
name={t['com.affine.integration.calendar.show-all-day-events']()}
|
<div className={styles.name}>
|
||||||
checked={!!config.showAllDayEvents}
|
{subscription.displayName ?? subscription.externalCalendarId}
|
||||||
onChange={toggleShowAllDayEvents}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{subscription.timezone ? (
|
||||||
|
<div className={styles.meta}>{subscription.timezone}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
+2
-1
@@ -31,12 +31,13 @@ const INTEGRATION_LIST = [
|
|||||||
icon: <IntegrationTypeIcon type="readwise" />,
|
icon: <IntegrationTypeIcon type="readwise" />,
|
||||||
setting: <ReadwiseSettingPanel />,
|
setting: <ReadwiseSettingPanel />,
|
||||||
},
|
},
|
||||||
BUILD_CONFIG.isElectron && {
|
{
|
||||||
id: 'calendar' as const,
|
id: 'calendar' as const,
|
||||||
name: 'com.affine.integration.calendar.name',
|
name: 'com.affine.integration.calendar.name',
|
||||||
desc: 'com.affine.integration.calendar.desc',
|
desc: 'com.affine.integration.calendar.desc',
|
||||||
icon: <TodayIcon />,
|
icon: <TodayIcon />,
|
||||||
setting: <CalendarSettingPanel />,
|
setting: <CalendarSettingPanel />,
|
||||||
|
cloud: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mcp-server' as const,
|
id: 'mcp-server' as const,
|
||||||
|
|||||||
+21
-35
@@ -16,21 +16,14 @@ import { useLiveData, useService } from '@toeverything/infra';
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type ICAL from 'ical.js';
|
import { useMemo, useState } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import * as styles from './calendar-events.css';
|
import * as styles from './calendar-events.css';
|
||||||
|
|
||||||
const pad = (val?: number) => (val ?? 0).toString().padStart(2, '0');
|
function formatTime(start?: Dayjs, end?: Dayjs) {
|
||||||
|
|
||||||
function formatTime(start?: ICAL.Time, end?: ICAL.Time) {
|
|
||||||
if (!start || !end) return '';
|
if (!start || !end) return '';
|
||||||
// Use toJSDate which handles timezone conversion for us
|
const from = start.format('HH:mm');
|
||||||
const startDate = start.toJSDate();
|
const to = end.format('HH:mm');
|
||||||
const endDate = end.toJSDate();
|
|
||||||
|
|
||||||
const from = `${pad(startDate.getHours())}:${pad(startDate.getMinutes())}`;
|
|
||||||
const to = `${pad(endDate.getHours())}:${pad(endDate.getMinutes())}`;
|
|
||||||
return from === to ? from : `${from} - ${to}`;
|
return from === to ? from : `${from} - ${to}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,15 +33,6 @@ export const CalendarEvents = ({ date }: { date: Dayjs }) => {
|
|||||||
useMemo(() => calendar.eventsByDate$(date), [calendar, date])
|
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 (
|
return (
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
{events.map(event => (
|
{events.map(event => (
|
||||||
@@ -60,9 +44,9 @@ export const CalendarEvents = ({ date }: { date: Dayjs }) => {
|
|||||||
|
|
||||||
const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
|
const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
|
||||||
const t = useI18n();
|
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 [loading, setLoading] = useState(false);
|
||||||
const calendar = useService(IntegrationService).calendar;
|
|
||||||
const docsService = useService(DocsService);
|
const docsService = useService(DocsService);
|
||||||
const guardService = useService(GuardService);
|
const guardService = useService(GuardService);
|
||||||
const journalService = useService(JournalService);
|
const journalService = useService(JournalService);
|
||||||
@@ -70,21 +54,23 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
|
|||||||
const { createPage } = usePageHelper(
|
const { createPage } = usePageHelper(
|
||||||
workspaceService.workspace.docCollection
|
workspaceService.workspace.docCollection
|
||||||
);
|
);
|
||||||
const subscription = useLiveData(
|
const name = calendarName || t['Untitled']();
|
||||||
useMemo(() => calendar.subscription$(url), [calendar, url])
|
const color = calendarColor || cssVarV2.button.primary;
|
||||||
);
|
const eventTitle = title || t['Untitled']();
|
||||||
const config = useLiveData(
|
|
||||||
useMemo(() => subscription?.config$, [subscription?.config$])
|
|
||||||
);
|
|
||||||
const name = useLiveData(subscription?.name$) || t['Untitled']();
|
|
||||||
const color = config?.color || cssVarV2.button.primary;
|
|
||||||
|
|
||||||
const handleClick = useAsyncCallback(async () => {
|
const handleClick = useAsyncCallback(async () => {
|
||||||
if (!date || loading) return;
|
if (loading) return;
|
||||||
const docs = journalService.journalsByDate$(
|
const docs = journalService.journalsByDate$(
|
||||||
date.format('YYYY-MM-DD')
|
date.format('YYYY-MM-DD')
|
||||||
).value;
|
).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);
|
setLoading(true);
|
||||||
|
|
||||||
@@ -97,7 +83,7 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newDoc = createPage();
|
const newDoc = createPage();
|
||||||
await docsService.changeDocTitle(newDoc.id, title);
|
await docsService.changeDocTitle(newDoc.id, eventTitle);
|
||||||
await docsService.addLinkedDoc(doc.id, newDoc.id);
|
await docsService.addLinkedDoc(doc.id, newDoc.id);
|
||||||
}
|
}
|
||||||
track.doc.sidepanel.journal.createCalendarDocEvent();
|
track.doc.sidepanel.journal.createCalendarDocEvent();
|
||||||
@@ -112,7 +98,7 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
|
|||||||
journalService,
|
journalService,
|
||||||
loading,
|
loading,
|
||||||
t,
|
t,
|
||||||
title,
|
eventTitle,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,7 +129,7 @@ const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
|
|||||||
{allDay ? <FullDayIcon /> : <PeriodIcon />}
|
{allDay ? <FullDayIcon /> : <PeriodIcon />}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className={styles.eventTitle}>{title}</div>
|
<div className={styles.eventTitle}>{eventTitle}</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+128
-19
@@ -10,14 +10,17 @@ import {
|
|||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { Guard } from '@affine/core/components/guard';
|
import { Guard } from '@affine/core/components/guard';
|
||||||
import { MoveToTrash } from '@affine/core/components/page-list';
|
import { MoveToTrash } from '@affine/core/components/page-list';
|
||||||
|
import { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||||
import {
|
import {
|
||||||
type DocRecord,
|
type DocRecord,
|
||||||
DocService,
|
DocService,
|
||||||
DocsService,
|
DocsService,
|
||||||
} from '@affine/core/modules/doc';
|
} from '@affine/core/modules/doc';
|
||||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||||
|
import { IntegrationService } from '@affine/core/modules/integration';
|
||||||
import { JournalService } from '@affine/core/modules/journal';
|
import { JournalService } from '@affine/core/modules/journal';
|
||||||
import {
|
import {
|
||||||
|
ViewService,
|
||||||
WorkbenchLink,
|
WorkbenchLink,
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
} from '@affine/core/modules/workbench';
|
} from '@affine/core/modules/workbench';
|
||||||
@@ -32,7 +35,7 @@ import { assignInlineVars } from '@vanilla-extract/dynamic';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
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 { CalendarEvents } from './calendar-events';
|
||||||
import * as styles from './journal.css';
|
import * as styles from './journal.css';
|
||||||
@@ -103,17 +106,65 @@ interface JournalBlockProps {
|
|||||||
date: dayjs.Dayjs;
|
date: dayjs.Dayjs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DateDotType = 'journal' | 'event' | 'activity';
|
||||||
|
|
||||||
const mobile = environment.isMobile;
|
const mobile = environment.isMobile;
|
||||||
export const EditorJournalPanel = () => {
|
export const EditorJournalPanel = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const doc = useServiceOptional(DocService)?.doc;
|
const doc = useServiceOptional(DocService)?.doc;
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
const viewService = useService(ViewService);
|
||||||
const journalService = useService(JournalService);
|
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(
|
const journalDateStr = useLiveData(
|
||||||
doc ? journalService.journalDate$(doc.id) : null
|
doc ? journalService.journalDate$(doc.id) : null
|
||||||
);
|
);
|
||||||
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
|
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
|
||||||
const isJournal = !!journalDate;
|
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(
|
const openJournal = useCallback(
|
||||||
(date: string) => {
|
(date: string) => {
|
||||||
@@ -129,17 +180,70 @@ export const EditorJournalPanel = () => {
|
|||||||
|
|
||||||
const onDateSelect = useCallback(
|
const onDateSelect = useCallback(
|
||||||
(date: string) => {
|
(date: string) => {
|
||||||
if (journalDate && dayjs(date).isSame(dayjs(journalDate))) return;
|
if (dayjs(date).isSame(selectedDate, 'day')) return;
|
||||||
|
setSelectedDate(dayjs(date));
|
||||||
openJournal(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(
|
const customDayRenderer = useCallback(
|
||||||
(cell: DateCell) => {
|
(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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className={styles.journalDateCell}
|
className={styles.journalDateCell}
|
||||||
@@ -149,17 +253,27 @@ export const EditorJournalPanel = () => {
|
|||||||
data-not-current-month={cell.notCurrentMonth}
|
data-not-current-month={cell.notCurrentMonth}
|
||||||
data-selected={cell.selected}
|
data-selected={cell.selected}
|
||||||
data-is-journal={isJournal}
|
data-is-journal={isJournal}
|
||||||
data-has-journal={hasJournal}
|
data-has-journal={allJournalDates.has(dateKey)}
|
||||||
data-mobile={mobile}
|
data-mobile={mobile}
|
||||||
>
|
>
|
||||||
{cell.label}
|
{cell.label}
|
||||||
{hasJournal && !cell.selected ? (
|
{!cell.selected && dotTypes.length ? (
|
||||||
<div className={styles.journalDateCellDot} />
|
<div className={styles.journalDateCellDotContainer}>
|
||||||
|
{dotTypes.map(dotType => (
|
||||||
|
<div
|
||||||
|
key={dotType}
|
||||||
|
className={clsx(
|
||||||
|
styles.journalDateCellDot,
|
||||||
|
styles.journalDateCellDotType[dotType]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[allJournalDates, isJournal]
|
[allJournalDates, getDotType, isJournal]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -174,21 +288,16 @@ export const EditorJournalPanel = () => {
|
|||||||
monthNames={t['com.affine.calendar-date-picker.month-names']()}
|
monthNames={t['com.affine.calendar-date-picker.month-names']()}
|
||||||
todayLabel={t['com.affine.calendar-date-picker.today']()}
|
todayLabel={t['com.affine.calendar-date-picker.today']()}
|
||||||
customDayRenderer={customDayRenderer}
|
customDayRenderer={customDayRenderer}
|
||||||
value={journalDate?.format('YYYY-MM-DD')}
|
value={selectedDate.format('YYYY-MM-DD')}
|
||||||
onChange={onDateSelect}
|
onChange={onDateSelect}
|
||||||
|
onCursorChange={setCalendarCursor}
|
||||||
cellSize={34}
|
cellSize={34}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<JournalTemplateOnboarding />
|
<JournalTemplateOnboarding />
|
||||||
{journalDate ? (
|
<JournalConflictBlock date={selectedDate} />
|
||||||
<>
|
<CalendarEvents date={selectedDate} />
|
||||||
<JournalConflictBlock date={journalDate} />
|
<JournalDailyCountBlock date={selectedDate} />
|
||||||
<CalendarEvents date={journalDate} />
|
|
||||||
<JournalDailyCountBlock date={journalDate} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className={styles.spacer} />
|
|
||||||
)}
|
|
||||||
<JournalTemplateSetting />
|
<JournalTemplateSetting />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+17
-9
@@ -1,6 +1,6 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style, styleVariants } from '@vanilla-extract/css';
|
||||||
|
|
||||||
const interactive = style({
|
const interactive = style({
|
||||||
position: 'relative',
|
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({
|
export const journalDateCellDot = style({
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: cssVar('primaryColor'),
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
});
|
});
|
||||||
export const spacer = style({
|
export const journalDateCellDotType = styleVariants({
|
||||||
height: 0,
|
journal: {
|
||||||
flexGrow: 1,
|
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';
|
import type { Server } from '../entities/server';
|
||||||
|
|
||||||
export class WorkspaceServerService extends Service {
|
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) {
|
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 {
|
import type {
|
||||||
CalendarStore,
|
CalendarAccountCalendarsQuery,
|
||||||
CalendarSubscriptionConfig,
|
CalendarAccountsQuery,
|
||||||
} from '../store/calendar';
|
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 type { CalendarEvent } from '../type';
|
||||||
import { parseCalendarUrl } from '../utils/calendar-url-parser';
|
|
||||||
import { CalendarSubscription } from './calendar-subscription';
|
|
||||||
|
|
||||||
export class CalendarIntegration extends Entity {
|
export class CalendarIntegration extends Entity {
|
||||||
constructor(private readonly store: CalendarStore) {
|
constructor(private readonly store: CalendarStore) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly subscriptionPool = new ObjectPool<
|
accounts$ = new LiveData<CalendarAccountsQuery['calendarAccounts'][number][]>(
|
||||||
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());
|
|
||||||
};
|
|
||||||
});
|
|
||||||
})
|
|
||||||
),
|
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
subscription$(url: string) {
|
accountCalendars$ = new LiveData<
|
||||||
return this.subscriptions$.map(subscriptions =>
|
Map<
|
||||||
subscriptions.find(sub => sub.url === url)
|
string,
|
||||||
);
|
CalendarAccountCalendarsQuery['calendarAccountCalendars'][number][]
|
||||||
}
|
>
|
||||||
eventsByDateMap$ = LiveData.computed(get => {
|
>(new Map());
|
||||||
return get(this.subscriptions$)
|
workspaceCalendars$ = new LiveData<
|
||||||
.map(sub => get(sub.eventsByDateMap$))
|
WorkspaceCalendarsQuery['workspaceCalendars'][number][]
|
||||||
.reduce((acc, map) => {
|
>([]);
|
||||||
for (const [date, events] of map) {
|
readonly eventsByDateMap$ = new LiveData<
|
||||||
acc.set(
|
Map<string, CalendarEventsQuery['calendarEvents'][number][]>
|
||||||
date,
|
>(new Map());
|
||||||
acc.has(date) ? [...(acc.get(date) ?? []), ...events] : [...events]
|
readonly eventDates$ = LiveData.computed(get => {
|
||||||
);
|
const eventsByDateMap = get(this.eventsByDateMap$);
|
||||||
}
|
const dates = new Set<string>();
|
||||||
return acc;
|
for (const [date, events] of eventsByDateMap) {
|
||||||
}, new Map<string, CalendarEvent[]>());
|
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) {
|
eventsByDate$(date: Dayjs) {
|
||||||
return this.eventsByDateMap$.map(eventsByDateMap => {
|
const dateKey = date.format('YYYY-MM-DD');
|
||||||
const dateKey = date.format('YYYY-MM-DD');
|
return LiveData.computed(get => {
|
||||||
const events = [...(eventsByDateMap.get(dateKey) || [])];
|
const subscriptionInfoById = get(this.subscriptionInfoById$);
|
||||||
|
const eventsByDateMap = get(this.eventsByDateMap$);
|
||||||
|
const events = eventsByDateMap.get(dateKey) ?? [];
|
||||||
|
|
||||||
// sort events by start time
|
return events
|
||||||
return events.sort((a, b) => {
|
.map(event => {
|
||||||
return (
|
const subscriptionInfo = subscriptionInfoById.get(
|
||||||
(a.startAt?.toJSDate().getTime() ?? 0) -
|
event.subscriptionId
|
||||||
(b.startAt?.toJSDate().getTime() ?? 0)
|
);
|
||||||
|
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) {
|
async loadAccountCalendars(signal?: AbortSignal) {
|
||||||
const url = parseCalendarUrl(_url);
|
const accounts = await this.store.fetchAccounts(signal);
|
||||||
try {
|
this.accounts$.setValue(accounts);
|
||||||
const response = await fetch(url);
|
|
||||||
const content = await response.text();
|
const calendarsByAccount = new Map<
|
||||||
ICAL.parse(content);
|
string,
|
||||||
return content;
|
CalendarAccountCalendarsQuery['calendarAccountCalendars'][number][]
|
||||||
} catch (err) {
|
>();
|
||||||
console.error(err);
|
|
||||||
throw new Error('Failed to verify URL');
|
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) {
|
async revalidateEventsRange(
|
||||||
try {
|
rangeStart: Dayjs,
|
||||||
const content = await this.verifyUrl(url);
|
rangeEnd: Dayjs,
|
||||||
this.store.addSubscription(url);
|
signal?: AbortSignal
|
||||||
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'>>
|
|
||||||
) {
|
) {
|
||||||
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 { WorkspaceServerService } from '../cloud';
|
||||||
import { WorkspaceDBService } from '../db';
|
import { WorkspaceDBService } from '../db';
|
||||||
import { DocScope, DocService, DocsService } from '../doc';
|
import { DocScope, DocService, DocsService } from '../doc';
|
||||||
import { CacheStorage, GlobalState } from '../storage';
|
import { GlobalState } from '../storage';
|
||||||
import { TagService } from '../tag';
|
import { TagService } from '../tag';
|
||||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||||
import { CalendarIntegration } from './entities/calendar';
|
import { CalendarIntegration } from './entities/calendar';
|
||||||
import { CalendarSubscription } from './entities/calendar-subscription';
|
|
||||||
import { ReadwiseIntegration } from './entities/readwise';
|
import { ReadwiseIntegration } from './entities/readwise';
|
||||||
import { ReadwiseCrawler } from './entities/readwise-crawler';
|
import { ReadwiseCrawler } from './entities/readwise-crawler';
|
||||||
import { IntegrationWriter } from './entities/writer';
|
import { IntegrationWriter } from './entities/writer';
|
||||||
@@ -19,7 +18,6 @@ import { ReadwiseStore } from './store/readwise';
|
|||||||
|
|
||||||
export { IntegrationService };
|
export { IntegrationService };
|
||||||
export { CalendarIntegration } from './entities/calendar';
|
export { CalendarIntegration } from './entities/calendar';
|
||||||
export { CalendarSubscription } from './entities/calendar-subscription';
|
|
||||||
export type { CalendarEvent } from './type';
|
export type { CalendarEvent } from './type';
|
||||||
export { IntegrationTypeIcon } from './views/icon';
|
export { IntegrationTypeIcon } from './views/icon';
|
||||||
export { DocIntegrationPropertiesTable } from './views/properties-table';
|
export { DocIntegrationPropertiesTable } from './views/properties-table';
|
||||||
@@ -41,14 +39,8 @@ export function configureIntegrationModule(framework: Framework) {
|
|||||||
ReadwiseStore,
|
ReadwiseStore,
|
||||||
DocsService,
|
DocsService,
|
||||||
])
|
])
|
||||||
.store(CalendarStore, [
|
.store(CalendarStore, [WorkspaceService, WorkspaceServerService])
|
||||||
GlobalState,
|
|
||||||
CacheStorage,
|
|
||||||
WorkspaceService,
|
|
||||||
WorkspaceServerService,
|
|
||||||
])
|
|
||||||
.entity(CalendarIntegration, [CalendarStore])
|
.entity(CalendarIntegration, [CalendarStore])
|
||||||
.entity(CalendarSubscription, [CalendarStore])
|
|
||||||
.scope(DocScope)
|
.scope(DocScope)
|
||||||
.service(IntegrationPropertyService, [DocService]);
|
.service(IntegrationPropertyService, [DocService]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,155 +1,107 @@
|
|||||||
import { LiveData, Store } from '@toeverything/infra';
|
import {
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
type CalendarAccountCalendarsQuery,
|
||||||
import { exhaustMap, map } from 'rxjs';
|
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 { WorkspaceServerService } from '../../cloud';
|
||||||
import type { CacheStorage, GlobalState } from '../../storage';
|
|
||||||
import type { WorkspaceService } from '../../workspace';
|
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 {
|
export class CalendarStore extends Store {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly globalState: GlobalState,
|
|
||||||
private readonly cacheStorage: CacheStorage,
|
|
||||||
private readonly workspaceService: WorkspaceService,
|
private readonly workspaceService: WorkspaceService,
|
||||||
private readonly workspaceServerService: WorkspaceServerService
|
private readonly workspaceServerService: WorkspaceServerService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public colors = [
|
private get gql() {
|
||||||
cssVarV2.calendar.red,
|
return this.workspaceServerService.server?.gql;
|
||||||
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 _getKey(userId: string, workspaceId: string) {
|
private get workspaceId() {
|
||||||
return `calendar:${userId}:${workspaceId}:subscriptions`;
|
return this.workspaceService.workspace.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createSubscription() {
|
async fetchAccounts(signal?: AbortSignal) {
|
||||||
return {
|
const gql = this.gql;
|
||||||
showEvents: true,
|
if (!gql) return [] satisfies CalendarAccountsQuery['calendarAccounts'];
|
||||||
showAllDayEvents: true,
|
const data = await gql({
|
||||||
color: this.getRandomColor(),
|
query: calendarAccountsQuery,
|
||||||
};
|
context: { signal },
|
||||||
|
});
|
||||||
|
return data.calendarAccounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
authService = this.workspaceServerService.server?.scope.get(AuthService);
|
async fetchAccountCalendars(accountId: string, signal?: AbortSignal) {
|
||||||
userId$ =
|
const gql = this.gql;
|
||||||
this.workspaceService.workspace.meta.flavour === 'local' ||
|
if (!gql) {
|
||||||
!this.authService
|
return [] satisfies CalendarAccountCalendarsQuery['calendarAccountCalendars'];
|
||||||
? new LiveData('__local__')
|
}
|
||||||
: this.authService.session.account$.map(
|
const data = await gql({
|
||||||
account => account?.id ?? '__local__'
|
query: calendarAccountCalendarsQuery,
|
||||||
);
|
variables: { accountId },
|
||||||
storageKey$() {
|
context: { signal },
|
||||||
const workspaceId = this.workspaceService.workspace.id;
|
});
|
||||||
return this.userId$.map(userId => this._getKey(userId, workspaceId));
|
return data.calendarAccountCalendars;
|
||||||
}
|
|
||||||
getUserId() {
|
|
||||||
return this.workspaceService.workspace.meta.flavour === 'local' ||
|
|
||||||
!this.authService
|
|
||||||
? '__local__'
|
|
||||||
: (this.authService.session.account$.value?.id ?? '__local__');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getStorageKey() {
|
async fetchWorkspaceCalendars(signal?: AbortSignal) {
|
||||||
const workspaceId = this.workspaceService.workspace.id;
|
const gql = this.gql;
|
||||||
return this._getKey(this.getUserId(), workspaceId);
|
if (!gql) {
|
||||||
|
return [] satisfies WorkspaceCalendarsQuery['workspaceCalendars'];
|
||||||
|
}
|
||||||
|
const data = await gql({
|
||||||
|
query: workspaceCalendarsQuery,
|
||||||
|
variables: { workspaceId: this.workspaceId },
|
||||||
|
context: { signal },
|
||||||
|
});
|
||||||
|
return data.workspaceCalendars;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCacheKey(url: string) {
|
async updateWorkspaceCalendars(items: WorkspaceCalendarItemInput[]) {
|
||||||
return `calendar-cache:${url}`;
|
const gql = this.gql;
|
||||||
}
|
if (!gql) {
|
||||||
|
throw new Error('No graphql service available');
|
||||||
watchSubscriptionMap() {
|
}
|
||||||
return this.storageKey$().pipe(
|
const data = await gql({
|
||||||
exhaustMap(storageKey => {
|
query: updateWorkspaceCalendarsMutation,
|
||||||
return this.globalState.watch<CalendarSubscriptionStore>(storageKey);
|
variables: {
|
||||||
})
|
input: {
|
||||||
);
|
workspaceId: this.workspaceId,
|
||||||
}
|
items,
|
||||||
|
},
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return data.updateWorkspaceCalendars satisfies UpdateWorkspaceCalendarsMutation['updateWorkspaceCalendars'];
|
||||||
}
|
}
|
||||||
|
|
||||||
removeSubscription(url: string) {
|
async fetchEvents(
|
||||||
this.globalState.set(
|
workspaceCalendarId: string,
|
||||||
this.getStorageKey(),
|
from: string,
|
||||||
Object.fromEntries(
|
to: string,
|
||||||
Object.entries(this.getSubscriptionMap()).filter(([key]) => key !== url)
|
signal?: AbortSignal
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSubscription(
|
|
||||||
url: string,
|
|
||||||
updates: Partial<Omit<CalendarSubscriptionConfig, 'url'>>
|
|
||||||
) {
|
) {
|
||||||
const subscriptionMap = this.getSubscriptionMap();
|
const gql = this.gql;
|
||||||
this.globalState.set(this.getStorageKey(), {
|
if (!gql) return [] satisfies CalendarEventsQuery['calendarEvents'];
|
||||||
...subscriptionMap,
|
const data = await gql({
|
||||||
[url]: { ...subscriptionMap[url], ...updates },
|
query: calendarEventsQuery,
|
||||||
|
variables: {
|
||||||
|
workspaceCalendarId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
context: { signal },
|
||||||
});
|
});
|
||||||
}
|
return data.calendarEvents;
|
||||||
|
|
||||||
setSubscriptionCache(url: string, cache: string) {
|
|
||||||
return this.cacheStorage.set(this.getCacheKey(url), cache);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { I18nString } from '@affine/i18n';
|
import type { I18nString } from '@affine/i18n';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type ICAL from 'ical.js';
|
|
||||||
import type { ComponentType, SVGProps } from 'react';
|
import type { ComponentType, SVGProps } from 'react';
|
||||||
|
|
||||||
import type { DocIntegrationRef } from '../db/schema/schema';
|
import type { DocIntegrationRef } from '../db/schema/schema';
|
||||||
@@ -103,12 +102,12 @@ export interface ReadwiseConfig {
|
|||||||
// ===============================
|
// ===============================
|
||||||
export type CalendarEvent = {
|
export type CalendarEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
subscriptionId: string;
|
||||||
title: string;
|
title: string;
|
||||||
startAt?: ICAL.Time;
|
startAt: Dayjs;
|
||||||
endAt?: ICAL.Time;
|
endAt: Dayjs;
|
||||||
allDay?: boolean;
|
allDay: boolean;
|
||||||
date?: Dayjs;
|
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 { parse } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { ProjectRoot } from '@affine-tools/utils/path';
|
||||||
import { Package } from '@affine-tools/utils/workspace';
|
import { Package } from '@affine-tools/utils/workspace';
|
||||||
import { runCli } from '@magic-works/i18n-codegen';
|
import { runCli } from '@magic-works/i18n-codegen';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
const isDev = process.argv.includes('--dev');
|
const isDev = process.argv.includes('--dev');
|
||||||
|
const shouldCleanup = process.argv.includes('--cleanup');
|
||||||
|
|
||||||
const i18nPkg = new Package('@affine/i18n');
|
const i18nPkg = new Package('@affine/i18n');
|
||||||
const resourcesDir = i18nPkg.join('src', 'resources').toString();
|
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');
|
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() {
|
function calcCompletenesses() {
|
||||||
const langs = readdirSync(resourcesDir)
|
const langs = readdirSync(resourcesDir)
|
||||||
.filter(file => file.endsWith('.json'))
|
.filter(file => file.endsWith('.json'))
|
||||||
@@ -124,5 +234,8 @@ async function appendErrorI18n() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await appendErrorI18n();
|
await appendErrorI18n();
|
||||||
|
if (shouldCleanup) {
|
||||||
|
await cleanupResources();
|
||||||
|
}
|
||||||
i18nnext();
|
i18nnext();
|
||||||
calcCompletenesses();
|
calcCompletenesses();
|
||||||
|
|||||||
@@ -8227,17 +8227,9 @@ export function useAFFiNEI18N(): {
|
|||||||
*/
|
*/
|
||||||
["com.affine.integration.calendar.new-url-label"](): string;
|
["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;
|
["com.affine.integration.calendar.save-error"](): 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;
|
|
||||||
/**
|
/**
|
||||||
* `All day`
|
* `All day`
|
||||||
*/
|
*/
|
||||||
@@ -8264,6 +8256,16 @@ export function useAFFiNEI18N(): {
|
|||||||
["com.affine.integration.calendar.unsubscribe-content"](options: {
|
["com.affine.integration.calendar.unsubscribe-content"](options: {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
}): 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`
|
* `MCP Server`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2037,9 +2037,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "إلغاء الاشتراك",
|
"com.affine.integration.calendar.unsubscribe": "إلغاء الاشتراك",
|
||||||
"com.affine.integration.calendar.new-title": "إضافة تقويم بواسطة الرابط",
|
"com.affine.integration.calendar.new-title": "إضافة تقويم بواسطة الرابط",
|
||||||
"com.affine.integration.calendar.new-url-label": "رابط التقويم",
|
"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.all-day": "طوال اليوم",
|
||||||
"com.affine.integration.calendar.new-doc": "مستند جديد",
|
"com.affine.integration.calendar.new-doc": "مستند جديد",
|
||||||
"com.affine.integration.calendar.show-events": "عرض أحداث التقويم",
|
"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.desc": "Els nous esdeveniments es programaran al diari d'AFFiNE",
|
||||||
"com.affine.integration.calendar.name": "Calendari",
|
"com.affine.integration.calendar.name": "Calendari",
|
||||||
"com.affine.integration.calendar.new-doc": "Nou document",
|
"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-subscription": "Subscriure's",
|
||||||
"com.affine.integration.calendar.new-title": "Afegir un calendari per URL",
|
"com.affine.integration.calendar.new-title": "Afegir un calendari per URL",
|
||||||
"com.affine.integration.calendar.new-url-label": "URL del calendari",
|
"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.unsubscribe": "Abonnement beenden",
|
||||||
"com.affine.integration.calendar.new-title": "Kalender per URL hinzufügen",
|
"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-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.all-day": "Ganztägig",
|
||||||
"com.affine.integration.calendar.new-doc": "Neue Seite",
|
"com.affine.integration.calendar.new-doc": "Neue Seite",
|
||||||
"com.affine.integration.calendar.show-events": "Kalenderereignisse anzeigen",
|
"com.affine.integration.calendar.show-events": "Kalenderereignisse anzeigen",
|
||||||
|
|||||||
@@ -2037,9 +2037,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Απεγγραφή",
|
"com.affine.integration.calendar.unsubscribe": "Απεγγραφή",
|
||||||
"com.affine.integration.calendar.new-title": "Προσθέστε ημερολόγιο με URL",
|
"com.affine.integration.calendar.new-title": "Προσθέστε ημερολόγιο με URL",
|
||||||
"com.affine.integration.calendar.new-url-label": "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.all-day": "Ολοήμερο",
|
||||||
"com.affine.integration.calendar.new-doc": "Νέο έγγραφο",
|
"com.affine.integration.calendar.new-doc": "Νέο έγγραφο",
|
||||||
"com.affine.integration.calendar.show-events": "Εμφάνιση γεγονότων ημερολογίου",
|
"com.affine.integration.calendar.show-events": "Εμφάνιση γεγονότων ημερολογίου",
|
||||||
|
|||||||
@@ -2065,15 +2065,15 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Unsubscribe",
|
"com.affine.integration.calendar.unsubscribe": "Unsubscribe",
|
||||||
"com.affine.integration.calendar.new-title": "Add a calendar by URL",
|
"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-url-label": "Calendar URL",
|
||||||
"com.affine.integration.calendar.new-duplicate-error-title": "This is a duplicate calendar",
|
"com.affine.integration.calendar.save-error": "An error occurred while saving the calendar settings",
|
||||||
"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.all-day": "All day",
|
"com.affine.integration.calendar.all-day": "All day",
|
||||||
"com.affine.integration.calendar.new-doc": "New doc",
|
"com.affine.integration.calendar.new-doc": "New doc",
|
||||||
"com.affine.integration.calendar.show-events": "Show calendar events",
|
"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-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.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.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.name": "MCP Server",
|
||||||
"com.affine.integration.mcp-server.desc": "Enable other MCP Client to search and read the doc of AFFiNE.",
|
"com.affine.integration.mcp-server.desc": "Enable other MCP Client to search and read the doc of AFFiNE.",
|
||||||
"com.affine.audio.notes": "Notes",
|
"com.affine.audio.notes": "Notes",
|
||||||
|
|||||||
@@ -2037,9 +2037,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Darse de baja",
|
"com.affine.integration.calendar.unsubscribe": "Darse de baja",
|
||||||
"com.affine.integration.calendar.new-title": "Agregar un calendario por URL",
|
"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-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.all-day": "Todo el día",
|
||||||
"com.affine.integration.calendar.new-doc": "Nuevo documento",
|
"com.affine.integration.calendar.new-doc": "Nuevo documento",
|
||||||
"com.affine.integration.calendar.show-events": "Mostrar eventos del calendario",
|
"com.affine.integration.calendar.show-events": "Mostrar eventos del calendario",
|
||||||
|
|||||||
@@ -2037,9 +2037,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "لغو اشتراک",
|
"com.affine.integration.calendar.unsubscribe": "لغو اشتراک",
|
||||||
"com.affine.integration.calendar.new-title": "افزودن یک تقویم از طریق URL",
|
"com.affine.integration.calendar.new-title": "افزودن یک تقویم از طریق URL",
|
||||||
"com.affine.integration.calendar.new-url-label": "آدرس اینترنتی تقویم",
|
"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.all-day": "تمام روز",
|
||||||
"com.affine.integration.calendar.new-doc": "سند جدید",
|
"com.affine.integration.calendar.new-doc": "سند جدید",
|
||||||
"com.affine.integration.calendar.show-events": "نمایش رویدادهای تقویم",
|
"com.affine.integration.calendar.show-events": "نمایش رویدادهای تقویم",
|
||||||
|
|||||||
@@ -2061,9 +2061,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Se désabonner",
|
"com.affine.integration.calendar.unsubscribe": "Se désabonner",
|
||||||
"com.affine.integration.calendar.new-title": "Ajouter un calendrier par URL",
|
"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-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.all-day": "Toute la journée",
|
||||||
"com.affine.integration.calendar.new-doc": "Nouveau document",
|
"com.affine.integration.calendar.new-doc": "Nouveau document",
|
||||||
"com.affine.integration.calendar.show-events": "Afficher les événements du calendrier",
|
"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.unsubscribe": "Annulla iscrizione",
|
||||||
"com.affine.integration.calendar.new-title": "Aggiungi un calendario tramite URL",
|
"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-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.all-day": "Tutto il giorno",
|
||||||
"com.affine.integration.calendar.new-doc": "Nuovo documento",
|
"com.affine.integration.calendar.new-doc": "Nuovo documento",
|
||||||
"com.affine.integration.calendar.show-events": "Mostra gli eventi del calendario",
|
"com.affine.integration.calendar.show-events": "Mostra gli eventi del calendario",
|
||||||
|
|||||||
@@ -2037,9 +2037,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "購読解除",
|
"com.affine.integration.calendar.unsubscribe": "購読解除",
|
||||||
"com.affine.integration.calendar.new-title": "URLでカレンダーを追加",
|
"com.affine.integration.calendar.new-title": "URLでカレンダーを追加",
|
||||||
"com.affine.integration.calendar.new-url-label": "カレンダー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.all-day": "終日",
|
||||||
"com.affine.integration.calendar.new-doc": "新規ドキュмент",
|
"com.affine.integration.calendar.new-doc": "新規ドキュмент",
|
||||||
"com.affine.integration.calendar.show-events": "カレンダーイベントを表示",
|
"com.affine.integration.calendar.show-events": "カレンダーイベントを表示",
|
||||||
|
|||||||
@@ -2048,9 +2048,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "구독 취소",
|
"com.affine.integration.calendar.unsubscribe": "구독 취소",
|
||||||
"com.affine.integration.calendar.new-title": "URL로 캘린더 추가",
|
"com.affine.integration.calendar.new-title": "URL로 캘린더 추가",
|
||||||
"com.affine.integration.calendar.new-url-label": "캘린더 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.all-day": "종일",
|
||||||
"com.affine.integration.calendar.new-doc": "새 문서",
|
"com.affine.integration.calendar.new-doc": "새 문서",
|
||||||
"com.affine.integration.calendar.show-events": "캘린더 이벤트 표시",
|
"com.affine.integration.calendar.show-events": "캘린더 이벤트 표시",
|
||||||
|
|||||||
@@ -684,7 +684,6 @@
|
|||||||
"com.affine.integration.calendar.show-all-day-events": "Vis heldagshendelse",
|
"com.affine.integration.calendar.show-all-day-events": "Vis heldagshendelse",
|
||||||
"com.affine.integration.calendar.new-url-label": "Kalender-URL",
|
"com.affine.integration.calendar.new-url-label": "Kalender-URL",
|
||||||
"com.affine.integration.calendar.new-subscription": "Abonnér",
|
"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.integration.calendar.new-doc": "Nytt dokument",
|
||||||
"com.affine.inactive": "Inaktiv",
|
"com.affine.inactive": "Inaktiv",
|
||||||
"com.affine.inactive-member": "Inaktivt medlem",
|
"com.affine.inactive-member": "Inaktivt medlem",
|
||||||
|
|||||||
@@ -2061,9 +2061,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Anuluj subskrypcję",
|
"com.affine.integration.calendar.unsubscribe": "Anuluj subskrypcję",
|
||||||
"com.affine.integration.calendar.new-title": "Dodaj kalendarz przez URL",
|
"com.affine.integration.calendar.new-title": "Dodaj kalendarz przez URL",
|
||||||
"com.affine.integration.calendar.new-url-label": "URL kalendarza",
|
"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.all-day": "Cały dzień",
|
||||||
"com.affine.integration.calendar.new-doc": "Nowy dokument",
|
"com.affine.integration.calendar.new-doc": "Nowy dokument",
|
||||||
"com.affine.integration.calendar.show-events": "Pokaż wydarzenia kalendarza",
|
"com.affine.integration.calendar.show-events": "Pokaż wydarzenia kalendarza",
|
||||||
|
|||||||
@@ -2037,9 +2037,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Desinscrever",
|
"com.affine.integration.calendar.unsubscribe": "Desinscrever",
|
||||||
"com.affine.integration.calendar.new-title": "Adicionar um calendário por URL",
|
"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-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.all-day": "Dia inteiro",
|
||||||
"com.affine.integration.calendar.new-doc": "Novo documento",
|
"com.affine.integration.calendar.new-doc": "Novo documento",
|
||||||
"com.affine.integration.calendar.show-events": "Mostrar eventos de calendário",
|
"com.affine.integration.calendar.show-events": "Mostrar eventos de calendário",
|
||||||
|
|||||||
@@ -2059,9 +2059,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Отписаться",
|
"com.affine.integration.calendar.unsubscribe": "Отписаться",
|
||||||
"com.affine.integration.calendar.new-title": "Добавить календарь по URL",
|
"com.affine.integration.calendar.new-title": "Добавить календарь по URL",
|
||||||
"com.affine.integration.calendar.new-url-label": "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.all-day": "Весь день",
|
||||||
"com.affine.integration.calendar.new-doc": "Новый документ",
|
"com.affine.integration.calendar.new-doc": "Новый документ",
|
||||||
"com.affine.integration.calendar.show-events": "Показать события календаря",
|
"com.affine.integration.calendar.show-events": "Показать события календаря",
|
||||||
|
|||||||
@@ -2041,9 +2041,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Avsluta prenumeration",
|
"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-title": "Lägg till en kalender via URL",
|
||||||
"com.affine.integration.calendar.new-url-label": "Kalender 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.all-day": "Hela dagen",
|
||||||
"com.affine.integration.calendar.new-doc": "Ny sida",
|
"com.affine.integration.calendar.new-doc": "Ny sida",
|
||||||
"com.affine.integration.calendar.show-events": "Visa kalenderhändelser",
|
"com.affine.integration.calendar.show-events": "Visa kalenderhändelser",
|
||||||
|
|||||||
@@ -2037,9 +2037,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "Відписатися",
|
"com.affine.integration.calendar.unsubscribe": "Відписатися",
|
||||||
"com.affine.integration.calendar.new-title": "Додати календар за URL",
|
"com.affine.integration.calendar.new-title": "Додати календар за URL",
|
||||||
"com.affine.integration.calendar.new-url-label": "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.all-day": "Цілий день",
|
||||||
"com.affine.integration.calendar.new-doc": "Новий документ",
|
"com.affine.integration.calendar.new-doc": "Новий документ",
|
||||||
"com.affine.integration.calendar.show-events": "Показати події календаря",
|
"com.affine.integration.calendar.show-events": "Показати події календаря",
|
||||||
|
|||||||
@@ -2063,9 +2063,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "取消订阅",
|
"com.affine.integration.calendar.unsubscribe": "取消订阅",
|
||||||
"com.affine.integration.calendar.new-title": "添加日历订阅的链接",
|
"com.affine.integration.calendar.new-title": "添加日历订阅的链接",
|
||||||
"com.affine.integration.calendar.new-url-label": "日历订阅链接",
|
"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.all-day": "全天",
|
||||||
"com.affine.integration.calendar.new-doc": "新建文档",
|
"com.affine.integration.calendar.new-doc": "新建文档",
|
||||||
"com.affine.integration.calendar.show-events": "显示日历事件",
|
"com.affine.integration.calendar.show-events": "显示日历事件",
|
||||||
|
|||||||
@@ -2040,9 +2040,6 @@
|
|||||||
"com.affine.integration.calendar.unsubscribe": "取消訂閱",
|
"com.affine.integration.calendar.unsubscribe": "取消訂閱",
|
||||||
"com.affine.integration.calendar.new-title": "添加日曆訂閱的鏈接",
|
"com.affine.integration.calendar.new-title": "添加日曆訂閱的鏈接",
|
||||||
"com.affine.integration.calendar.new-url-label": "日曆訂閱鏈接",
|
"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.all-day": "全天",
|
||||||
"com.affine.integration.calendar.new-doc": "新建文檔",
|
"com.affine.integration.calendar.new-doc": "新建文檔",
|
||||||
"com.affine.integration.calendar.show-events": "顯示日曆事件",
|
"com.affine.integration.calendar.show-events": "顯示日曆事件",
|
||||||
|
|||||||
@@ -457,7 +457,6 @@ __metadata:
|
|||||||
graphemer: "npm:^1.4.0"
|
graphemer: "npm:^1.4.0"
|
||||||
graphql: "npm:^16.9.0"
|
graphql: "npm:^16.9.0"
|
||||||
history: "npm:^5.3.0"
|
history: "npm:^5.3.0"
|
||||||
ical.js: "npm:^2.1.0"
|
|
||||||
idb: "npm:^8.0.0"
|
idb: "npm:^8.0.0"
|
||||||
idb-keyval: "npm:^6.2.2"
|
idb-keyval: "npm:^6.2.2"
|
||||||
image-blob-reduce: "npm:^4.1.0"
|
image-blob-reduce: "npm:^4.1.0"
|
||||||
@@ -27306,13 +27305,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24":
|
||||||
version: 0.4.24
|
version: 0.4.24
|
||||||
resolution: "iconv-lite@npm:0.4.24"
|
resolution: "iconv-lite@npm:0.4.24"
|
||||||
|
|||||||
Reference in New Issue
Block a user