mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 10:52:40 +08:00
feat(core): intilize integration module and basic readwise impl (#10726)
close AF-2257, AF-2261, AF-2260, AF-2259 ### Feat - New `Integration` Module - Basic Readwise integration - connect - import - disconnect - Common Integration UI - Common Integration Writer (Transform markdown to AFFiNE Doc) ### Not Implemented > will be implemented in down-stack - delete docs when disconnect - readwise settiing UI - integration property rendering
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import { ServerService } from '@affine/core/modules/cloud';
|
||||
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { ServerDeploymentType } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
CollaborationIcon,
|
||||
IntegrationsIcon,
|
||||
PaymentIcon,
|
||||
PropertyIcon,
|
||||
SaveIcon,
|
||||
@@ -16,6 +18,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import type { SettingSidebarItem, SettingState } from '../types';
|
||||
import { WorkspaceSettingBilling } from './billing';
|
||||
import { IntegrationSetting } from './integration';
|
||||
import { WorkspaceSettingLicense } from './license';
|
||||
import { MembersPanel } from './members';
|
||||
import { WorkspaceSettingDetail } from './preference';
|
||||
@@ -49,6 +52,8 @@ export const WorkspaceSetting = ({
|
||||
return <WorkspaceSettingStorage onCloseSetting={onCloseSetting} />;
|
||||
case 'workspace:license':
|
||||
return <WorkspaceSettingLicense onCloseSetting={onCloseSetting} />;
|
||||
case 'workspace:integrations':
|
||||
return <IntegrationSetting />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +63,11 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const information = useWorkspaceInfo(workspaceService.workspace);
|
||||
const serverService = useService(ServerService);
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
|
||||
const enableIntegration = useLiveData(
|
||||
featureFlagService.flags.enable_integration.$
|
||||
);
|
||||
|
||||
const isSelfhosted = useLiveData(
|
||||
serverService.server.config$.selector(
|
||||
@@ -90,6 +100,12 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
|
||||
icon: <CollaborationIcon />,
|
||||
testId: 'workspace-setting:members',
|
||||
},
|
||||
enableIntegration && {
|
||||
key: 'workspace:integrations',
|
||||
title: t['com.affine.integration.integrations'](),
|
||||
icon: <IntegrationsIcon />,
|
||||
testId: 'workspace-setting:integrations',
|
||||
},
|
||||
{
|
||||
key: 'workspace:storage',
|
||||
title: t['Storage'](),
|
||||
@@ -109,7 +125,7 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
|
||||
testId: 'workspace-setting:license',
|
||||
},
|
||||
].filter((item): item is SettingSidebarItem => !!item);
|
||||
}, [showBilling, showLicense, t]);
|
||||
}, [enableIntegration, showBilling, showLicense, t]);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import { spaceY } from './index.css';
|
||||
|
||||
export const card = style({
|
||||
padding: '8px 12px 12px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid ' + cssVarV2.layer.insideBorder.border,
|
||||
height: 186,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: cssVarV2.layer.background.overlayPanel,
|
||||
});
|
||||
export const cardHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
height: 42,
|
||||
marginBottom: 4,
|
||||
});
|
||||
export const cardIcon = style({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 4,
|
||||
background: cssVarV2.integrations.background.iconSolid,
|
||||
boxShadow: cssVar('buttonShadow'),
|
||||
border: `0.5px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
fontSize: 24,
|
||||
padding: 4,
|
||||
lineHeight: 0,
|
||||
});
|
||||
export const cardContent = style([
|
||||
spaceY,
|
||||
{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
},
|
||||
]);
|
||||
export const cardTitle = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2.text.primary,
|
||||
});
|
||||
export const cardDesc = style([
|
||||
spaceY,
|
||||
{
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2.text.secondary,
|
||||
},
|
||||
]);
|
||||
export const cardFooter = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
justifyContent: 'space-between',
|
||||
selectors: {
|
||||
'&:not(:empty)': {
|
||||
marginTop: 8,
|
||||
height: 28,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const settingIcon = style({
|
||||
color: cssVarV2.icon.secondary,
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { IconButton, type IconButtonProps } from '@affine/component';
|
||||
import { SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
card,
|
||||
cardContent,
|
||||
cardDesc,
|
||||
cardFooter,
|
||||
cardHeader,
|
||||
cardIcon,
|
||||
cardTitle,
|
||||
settingIcon,
|
||||
} from './card.css';
|
||||
import { spaceX } from './index.css';
|
||||
|
||||
export const IntegrationCard = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLLIElement>) => {
|
||||
return <li className={clsx(className, card)} {...props} />;
|
||||
};
|
||||
|
||||
export const IntegrationCardIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => {
|
||||
return <div className={clsx(cardIcon, className)} {...props} />;
|
||||
};
|
||||
|
||||
export const IntegrationSettingIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: IconButtonProps) => {
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
icon={<SettingsIcon className={settingIcon} />}
|
||||
variant="plain"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationCardHeader = ({
|
||||
className,
|
||||
icon,
|
||||
onSettingClick,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadElement> & {
|
||||
onSettingClick?: () => void;
|
||||
icon?: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<header className={clsx(cardHeader, className)} {...props}>
|
||||
<IntegrationCardIcon>{icon}</IntegrationCardIcon>
|
||||
<div className={spaceX} />
|
||||
<IntegrationSettingIcon onClick={onSettingClick} />
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationCardContent = ({
|
||||
className,
|
||||
title,
|
||||
desc,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
title?: string;
|
||||
desc?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(cardContent, className)} {...props}>
|
||||
<div className={cardTitle}>{title}</div>
|
||||
<div className={cardDesc}>{desc}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationCardFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLElement>) => {
|
||||
return <footer className={clsx(cardFooter, className)} {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const spaceX = style({
|
||||
width: 0,
|
||||
flexGrow: 1,
|
||||
});
|
||||
export const spaceY = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
});
|
||||
export const list = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gap: '16px',
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { list } from './index.css';
|
||||
import { ReadwiseIntegration } from './readwise';
|
||||
|
||||
export const IntegrationSetting = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.integration.integrations']()}
|
||||
subtitle={
|
||||
<>
|
||||
{t['com.affine.integration.setting.description']()}
|
||||
{/* <br /> */}
|
||||
{/* <a>{t['Learn how to develop a integration for AFFiNE']()}</a> */}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ul className={list}>
|
||||
<ReadwiseIntegration />
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Button, Input, Modal, notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { IntegrationService } from '@affine/core/modules/integration';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { type FormEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { IntegrationCardIcon } from '../card';
|
||||
import {
|
||||
actionButton,
|
||||
connectDesc,
|
||||
connectDialog,
|
||||
connectFooter,
|
||||
connectInput,
|
||||
connectTitle,
|
||||
getTokenLink,
|
||||
inputErrorMsg,
|
||||
} from './index.css';
|
||||
|
||||
const ConnectDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
const t = useI18n();
|
||||
const [status, setStatus] = useState<'idle' | 'verifying' | 'error'>('idle');
|
||||
const [token, setToken] = useState('');
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) handleCancel();
|
||||
},
|
||||
[handleCancel]
|
||||
);
|
||||
|
||||
const handleInput = useCallback((e: FormEvent<HTMLInputElement>) => {
|
||||
setToken(e.currentTarget.value);
|
||||
setStatus('idle');
|
||||
}, []);
|
||||
|
||||
const handleResult = useCallback(
|
||||
(success: boolean, token: string) => {
|
||||
if (success) {
|
||||
readwise.updateSetting('token', token);
|
||||
} else {
|
||||
setStatus('error');
|
||||
notify.error({
|
||||
title:
|
||||
t['com.affine.integration.readwise.connect.error-notify-title'](),
|
||||
message:
|
||||
t['com.affine.integration.readwise.connect.error-notify-desc'](),
|
||||
});
|
||||
}
|
||||
},
|
||||
[readwise, t]
|
||||
);
|
||||
|
||||
const handleConnect = useAsyncCallback(
|
||||
async (token: string) => {
|
||||
setStatus('verifying');
|
||||
try {
|
||||
const success = await readwise.crawler.verifyToken(token);
|
||||
if (!success) return handleResult(false, token);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return handleResult(false, token);
|
||||
}
|
||||
handleResult(true, token);
|
||||
},
|
||||
[handleResult, readwise]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
contentOptions={{ className: connectDialog }}
|
||||
>
|
||||
<header className={connectTitle}>
|
||||
<IntegrationCardIcon>
|
||||
<ReadwiseLogoDuotoneIcon />
|
||||
</IntegrationCardIcon>
|
||||
{t['com.affine.integration.readwise.connect.title']()}
|
||||
</header>
|
||||
<div className={connectDesc}>
|
||||
<Trans
|
||||
i18nKey={'com.affine.integration.readwise.connect.desc'}
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href="https://readwise.io/access_token"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={getTokenLink}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={token}
|
||||
onInput={handleInput}
|
||||
placeholder={t['com.affine.integration.readwise.connect.placeholder']()}
|
||||
type="password"
|
||||
className={connectInput}
|
||||
status={status === 'error' ? 'error' : 'default'}
|
||||
disabled={status === 'verifying'}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={inputErrorMsg} data-show={status === 'error'}>
|
||||
{t['com.affine.integration.readwise.connect.input-error']()}
|
||||
</div>
|
||||
<footer className={connectFooter}>
|
||||
<Button disabled={status === 'verifying'} onClick={handleCancel}>
|
||||
{t['Cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={status === 'verifying' || !token || status === 'error'}
|
||||
loading={status === 'verifying'}
|
||||
onClick={() => handleConnect(token)}
|
||||
>
|
||||
{t['com.affine.integration.readwise.connect']()}
|
||||
</Button>
|
||||
</footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectButton = () => {
|
||||
const t = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <ConnectDialog onClose={handleClose} />}
|
||||
<Button variant="primary" className={actionButton} onClick={handleOpen}>
|
||||
{t['com.affine.integration.readwise.connect']()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const footer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const actions = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
});
|
||||
export const connectDialog = style({
|
||||
width: 480,
|
||||
maxWidth: `calc(100vw - 32px)`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const connectDialogTitle = style({
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
lineHeight: '26px',
|
||||
letterSpacing: '0.24px',
|
||||
color: cssVarV2.text.primary,
|
||||
marginBottom: 12,
|
||||
});
|
||||
export const connectDialogDesc = style({
|
||||
fontSize: 15,
|
||||
fontWeight: 400,
|
||||
lineHeight: '24px',
|
||||
color: cssVarV2.text.primary,
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
marginBottom: 20,
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Button, Modal } from '@affine/component';
|
||||
import { IntegrationService } from '@affine/core/modules/integration';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import * as styles from './connected.css';
|
||||
import { ImportDialog } from './import-dialog';
|
||||
import { actionButton } from './index.css';
|
||||
|
||||
export const DisconnectDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
const t = useI18n();
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleCancel = useCallback(() => onClose(), [onClose]);
|
||||
const handleKeep = useCallback(() => {
|
||||
readwise.disconnect();
|
||||
onClose();
|
||||
}, [onClose, readwise]);
|
||||
// const handleDelete = useAsyncCallback(async () => {
|
||||
// // TODO
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
contentOptions={{ className: styles.connectDialog }}
|
||||
>
|
||||
<div className={styles.connectDialogTitle}>
|
||||
{t['com.affine.integration.readwise.disconnect.title']()}
|
||||
</div>
|
||||
<div className={styles.connectDialogDesc}>
|
||||
{t['com.affine.integration.readwise.disconnect.desc']()}
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<Button onClick={handleCancel}>{t['Cancel']()}</Button>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="error">
|
||||
{t['com.affine.integration.readwise.disconnect.delete']()}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleKeep}>
|
||||
{t['com.affine.integration.readwise.disconnect.keep']()}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectedActions = () => {
|
||||
const t = useI18n();
|
||||
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDisconnectDialog && (
|
||||
<DisconnectDialog onClose={() => setShowDisconnectDialog(false)} />
|
||||
)}
|
||||
{showImportDialog && (
|
||||
<ImportDialog onClose={() => setShowImportDialog(false)} />
|
||||
)}
|
||||
<Button
|
||||
className={actionButton}
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
>
|
||||
{t['com.affine.integration.readwise.import']()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="error"
|
||||
className={actionButton}
|
||||
onClick={() => setShowDisconnectDialog(true)}
|
||||
>
|
||||
{t['com.affine.integration.readwise.disconnect']()}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const importDialog = style({
|
||||
width: 480,
|
||||
maxWidth: `calc(100vw - 32px)`,
|
||||
height: '65vh',
|
||||
maxHeight: '508px',
|
||||
minHeight: 'min(508px, calc(100vh - 32px))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'max-height 0.18s ease, min-height 0.18s ease',
|
||||
selectors: {
|
||||
'&.select': {
|
||||
height: '65vh',
|
||||
maxHeight: '508px',
|
||||
minHeight: 'min(508px, calc(100vh - 32px))',
|
||||
},
|
||||
'&.writing': {
|
||||
height: '65vh',
|
||||
maxHeight: '194px',
|
||||
minHeight: 'min(194px, calc(100vh - 32px))',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const title = style({
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
lineHeight: '24px',
|
||||
color: cssVarV2.text.primary,
|
||||
marginBottom: 2,
|
||||
});
|
||||
|
||||
export const desc = style({
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2.text.secondary,
|
||||
marginBottom: 16,
|
||||
});
|
||||
|
||||
export const resetLastImportedAt = style({
|
||||
color: cssVarV2.button.primary,
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
});
|
||||
export const empty = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
textAlign: 'center',
|
||||
paddingTop: 48,
|
||||
color: cssVarV2.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const footerDivider = style({
|
||||
width: 'calc(100% + 48px)',
|
||||
margin: '8px -24px',
|
||||
});
|
||||
export const actions = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'end',
|
||||
gap: 20,
|
||||
paddingTop: 20,
|
||||
});
|
||||
export const loading = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
color: cssVarV2.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const table = style({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const tableContent = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const tableRow = style({
|
||||
display: 'flex',
|
||||
padding: '6px 0',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const tableHeadRow = style([tableRow, {}]);
|
||||
export const tableBodyRow = style([tableRow, {}]);
|
||||
export const tableCell = style({
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
selectors: {
|
||||
[`${tableHeadRow} &`]: {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2.text.secondary,
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableCellSelect = style([
|
||||
tableCell,
|
||||
{
|
||||
color: cssVarV2.icon.primary,
|
||||
width: 20,
|
||||
marginRight: 8,
|
||||
fontSize: '20px !important',
|
||||
lineHeight: '0px !important',
|
||||
},
|
||||
]);
|
||||
export const tableCellTitle = style([
|
||||
tableCell,
|
||||
{
|
||||
width: 0,
|
||||
flexGrow: 32,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
color: cssVarV2.text.link,
|
||||
},
|
||||
]);
|
||||
export const tableCellLink = style({
|
||||
color: cssVarV2.text.link,
|
||||
});
|
||||
|
||||
export const tableCellTodo = style([
|
||||
tableCell,
|
||||
{
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
width: 64,
|
||||
marginLeft: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
]);
|
||||
export const todoNew = style({
|
||||
color: cssVarV2.button.success,
|
||||
});
|
||||
export const todoSkip = style({
|
||||
color: cssVarV2.text.tertiary,
|
||||
});
|
||||
export const todoUpdate = style({
|
||||
color: cssVarV2.text.primary,
|
||||
});
|
||||
export const tableCellTime = style([
|
||||
tableCell,
|
||||
{
|
||||
width: 0,
|
||||
flexGrow: 29,
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 12,
|
||||
color: cssVarV2.text.secondary,
|
||||
},
|
||||
]);
|
||||
|
||||
export const importingHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
export const importingLoading = style({
|
||||
lineHeight: 0,
|
||||
});
|
||||
export const importingTitle = style({
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
lineHeight: '26px',
|
||||
letterSpacing: '0.24px',
|
||||
color: cssVarV2.text.primary,
|
||||
});
|
||||
export const importingDesc = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: 400,
|
||||
lineHeight: '24px',
|
||||
color: cssVarV2.text.primary,
|
||||
paddingTop: 12,
|
||||
});
|
||||
export const importingFooter = style({
|
||||
paddingTop: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'end',
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Loading,
|
||||
Modal,
|
||||
notify,
|
||||
Scrollable,
|
||||
} from '@affine/component';
|
||||
import { IntegrationService } from '@affine/core/modules/integration';
|
||||
import type { ReadwiseHighlight } from '@affine/core/modules/integration/type';
|
||||
import { i18nTime, Trans, useI18n } from '@affine/i18n';
|
||||
import { InformationFillDuotoneIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type Dispatch,
|
||||
forwardRef,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import * as styles from './import-dialog.css';
|
||||
|
||||
export const ImportDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
const t = useI18n();
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
const crawler = readwise.crawler;
|
||||
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const crawlingData = useLiveData(crawler.data$);
|
||||
const error = useLiveData(crawler.error$);
|
||||
const loading = useLiveData(crawler.crawling$);
|
||||
|
||||
const highlights = useMemo(
|
||||
() => crawlingData?.highlights ?? [],
|
||||
[crawlingData]
|
||||
);
|
||||
const books = useMemo(() => crawlingData?.books ?? {}, [crawlingData]);
|
||||
const timestamp = crawlingData?.startTime;
|
||||
const [stage, setStage] = useState<'select' | 'writing'>('select');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const handleConfirmImport = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setStage('writing');
|
||||
const selectedHighlights = highlights.filter(h => ids.includes(h.id));
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
const signal = abortController.signal;
|
||||
|
||||
readwise
|
||||
.highlightsToAffineDocs(selectedHighlights.reverse(), books, {
|
||||
signal,
|
||||
onProgress: setImportProgress,
|
||||
onComplete: () => {
|
||||
readwise.updateSetting('lastImportedAt', timestamp);
|
||||
onClose();
|
||||
},
|
||||
onAbort: finished => {
|
||||
notify({
|
||||
icon: <InformationFillDuotoneIcon />,
|
||||
style: 'normal',
|
||||
alignMessage: 'icon',
|
||||
title:
|
||||
t[
|
||||
'com.affine.integration.readwise.import.abort-notify-title'
|
||||
](),
|
||||
message: t.t(
|
||||
'com.affine.integration.readwise.import.abort-notify-desc',
|
||||
{ finished }
|
||||
),
|
||||
});
|
||||
},
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
[books, highlights, onClose, readwise, t, timestamp]
|
||||
);
|
||||
const handleStopImport = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setStage('select');
|
||||
setImportProgress(0);
|
||||
}, []);
|
||||
|
||||
const handleRetryCrawl = useCallback(() => {
|
||||
crawler.crawl();
|
||||
}, [crawler]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// reset crawler
|
||||
crawler.reset();
|
||||
|
||||
// stop importing
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [crawler]);
|
||||
|
||||
useEffect(() => {
|
||||
crawler.crawl();
|
||||
|
||||
return () => {
|
||||
crawler.abort();
|
||||
};
|
||||
}, [crawler]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={true}
|
||||
contentOptions={{ className: clsx(styles.importDialog, stage) }}
|
||||
onOpenChange={onOpenChange}
|
||||
withoutCloseButton={stage === 'writing'}
|
||||
persistent={stage === 'writing'}
|
||||
>
|
||||
{stage === 'select' ? (
|
||||
error ? (
|
||||
<CrawlerError onRetry={handleRetryCrawl} />
|
||||
) : (
|
||||
<SelectStage
|
||||
loading={loading}
|
||||
highlights={highlights}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirmImport}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<WritingStage progress={importProgress} onStop={handleStopImport} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const CrawlerError = ({ onRetry }: { onRetry: () => void }) => {
|
||||
return (
|
||||
<>
|
||||
Unexpected error occurred, please try again.
|
||||
<Button onClick={onRetry}>Retry</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectStage = ({
|
||||
loading,
|
||||
highlights,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
loading: boolean;
|
||||
highlights: ReadwiseHighlight[];
|
||||
onClose: () => void;
|
||||
onConfirm: (ids: ReadwiseHighlight['id'][]) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
const settings = useLiveData(readwise.settings$);
|
||||
const lastImportedAt = settings?.lastImportedAt;
|
||||
const [selected, setSelected] = useState<ReadwiseHighlight['id'][]>([]);
|
||||
|
||||
const handleResetLastImportedAt = useCallback(() => {
|
||||
readwise.updateSetting('lastImportedAt', undefined);
|
||||
}, [readwise]);
|
||||
|
||||
const handleConfirmImport = useCallback(() => {
|
||||
onConfirm(selected);
|
||||
}, [onConfirm, selected]);
|
||||
|
||||
// select all highlights when highlights changed
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setSelected(highlights.map(h => h.id));
|
||||
}
|
||||
}, [highlights, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<h3 className={styles.title}>
|
||||
{t['com.affine.integration.readwise.import.title']()}
|
||||
</h3>
|
||||
<div className={styles.desc}>
|
||||
{lastImportedAt ? (
|
||||
<Trans
|
||||
i18nKey="com.affine.integration.readwise.import.desc-from-last"
|
||||
values={{
|
||||
lastImportedAt: i18nTime(lastImportedAt, {
|
||||
absolute: { accuracy: 'second' },
|
||||
}),
|
||||
}}
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href="#"
|
||||
className={styles.resetLastImportedAt}
|
||||
onClick={handleResetLastImportedAt}
|
||||
></a>
|
||||
),
|
||||
}}
|
||||
></Trans>
|
||||
) : (
|
||||
t['com.affine.integration.readwise.import.desc-from-start']()
|
||||
)}
|
||||
</div>
|
||||
<Divider size="thinner" />
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>
|
||||
<Loading />
|
||||
{t['Loading']()}
|
||||
</div>
|
||||
) : highlights.length > 0 ? (
|
||||
<HighlightTable
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
highlights={highlights}
|
||||
/>
|
||||
) : (
|
||||
<HighlightEmpty />
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Divider size="thinner" className={styles.footerDivider} />
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={onClose}>{t['Cancel']()}</Button>
|
||||
<Button
|
||||
disabled={
|
||||
loading || (selected.length === 0 && highlights.length !== 0)
|
||||
}
|
||||
variant="primary"
|
||||
onClick={handleConfirmImport}
|
||||
>
|
||||
{t['Confirm']()}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Scroller = forwardRef<HTMLDivElement>(function Scroller(props, ref) {
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport ref={ref} {...props}></Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
|
||||
const HighlightTable = ({
|
||||
selected,
|
||||
setSelected,
|
||||
highlights,
|
||||
}: {
|
||||
selected: ReadwiseHighlight['id'][];
|
||||
setSelected: Dispatch<SetStateAction<ReadwiseHighlight['id'][]>>;
|
||||
highlights: ReadwiseHighlight[];
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
const [updatedMap, setUpdatedMap] =
|
||||
useState<
|
||||
Record<ReadwiseHighlight['id'], ReadwiseHighlight['updated_at']>
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
readwise
|
||||
.getRefs()
|
||||
.then(refs => {
|
||||
setUpdatedMap(
|
||||
refs.reduce(
|
||||
(acc, ref) => {
|
||||
acc[ref.refMeta.highlightId] = ref.refMeta.updatedAt;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [readwise]);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
setSelected(prev =>
|
||||
prev.length === highlights.length ? [] : highlights.map(h => h.id)
|
||||
);
|
||||
}, [highlights, setSelected]);
|
||||
|
||||
return (
|
||||
<div className={styles.table}>
|
||||
<div className={styles.tableHeadRow}>
|
||||
<div className={styles.tableCellSelect}>
|
||||
<Checkbox
|
||||
checked={selected.length === highlights.length}
|
||||
onChange={handleToggleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.tableCellTitle}>
|
||||
{t['com.affine.integration.readwise.import.cell-h-content']()}
|
||||
</div>
|
||||
<div className={styles.tableCellTodo}>
|
||||
{t['com.affine.integration.readwise.import.cell-h-todo']()}
|
||||
</div>
|
||||
<div className={styles.tableCellTime}>
|
||||
{t['com.affine.integration.readwise.import.cell-h-time']()}
|
||||
</div>
|
||||
</div>
|
||||
<Virtuoso
|
||||
className={styles.tableContent}
|
||||
totalCount={highlights.length}
|
||||
itemContent={idx => {
|
||||
const highlight = highlights[idx];
|
||||
const localUpdatedAt = updatedMap?.[highlight.id];
|
||||
const readwiseUpdatedAt = highlight.updated_at;
|
||||
return (
|
||||
<li className={styles.tableBodyRow}>
|
||||
<div className={styles.tableCellSelect}>
|
||||
<Checkbox
|
||||
checked={selected.includes(highlight.id)}
|
||||
onChange={() => {
|
||||
setSelected(prev => {
|
||||
if (prev.includes(highlight.id)) {
|
||||
return prev.filter(id => id !== highlight.id);
|
||||
} else {
|
||||
return [...prev, highlight.id];
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.tableCellTitle}>
|
||||
<a
|
||||
href={highlight.readwise_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.tableCellLink}
|
||||
>
|
||||
{highlight.text}
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.tableCellTodo}>
|
||||
{!localUpdatedAt ? (
|
||||
<span className={styles.todoNew}>
|
||||
{t['com.affine.integration.readwise.import.todo-new']()}
|
||||
</span>
|
||||
) : localUpdatedAt === readwiseUpdatedAt ? (
|
||||
<span className={styles.todoSkip}>
|
||||
{t['com.affine.integration.readwise.import.todo-skip']()}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.todoUpdate}>
|
||||
{t['com.affine.integration.readwise.import.todo-update']()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.tableCellTime}>
|
||||
{i18nTime(readwiseUpdatedAt, {
|
||||
absolute: { accuracy: 'second' },
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
components={{ Scroller }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const HighlightEmpty = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
{t['com.affine.integration.readwise.import.empty']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WritingStage = ({
|
||||
progress,
|
||||
onStop,
|
||||
}: {
|
||||
progress: number;
|
||||
onStop: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
<header className={styles.importingHeader}>
|
||||
<Loading
|
||||
speed={0}
|
||||
progress={progress}
|
||||
className={styles.importingLoading}
|
||||
size={24}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<h3 className={styles.importingTitle}>
|
||||
{t['com.affine.integration.readwise.import.importing']()}
|
||||
</h3>
|
||||
</header>
|
||||
|
||||
<main className={styles.importingDesc}>
|
||||
{t['com.affine.integration.readwise.import.importing-desc']()}
|
||||
</main>
|
||||
|
||||
<footer className={styles.importingFooter}>
|
||||
<Button variant="error" onClick={onStop}>
|
||||
{t['com.affine.integration.readwise.import.importing-stop']()}
|
||||
</Button>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const actionButton = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const connectDialog = style({
|
||||
width: 480,
|
||||
maxWidth: `calc(100vw - 32px)`,
|
||||
});
|
||||
|
||||
export const connectTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontWeight: 500,
|
||||
fontSize: 15,
|
||||
lineHeight: '24px',
|
||||
marginBottom: 8,
|
||||
});
|
||||
|
||||
export const connectDesc = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2.text.secondary,
|
||||
marginBottom: 16,
|
||||
});
|
||||
|
||||
export const connectInput = style({
|
||||
height: 28,
|
||||
borderRadius: 4,
|
||||
});
|
||||
export const inputErrorMsg = style({
|
||||
fontSize: 10,
|
||||
color: cssVarV2.status.error,
|
||||
lineHeight: '16px',
|
||||
|
||||
paddingTop: 0,
|
||||
height: 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.23s ease',
|
||||
selectors: {
|
||||
'&[data-show="true"]': {
|
||||
paddingTop: 4,
|
||||
height: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const connectFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 20,
|
||||
marginTop: 20,
|
||||
});
|
||||
|
||||
export const getTokenLink = style({
|
||||
color: cssVarV2.text.link,
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationTypeIcon,
|
||||
} from '@affine/core/modules/integration';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import {
|
||||
IntegrationCard,
|
||||
IntegrationCardContent,
|
||||
IntegrationCardFooter,
|
||||
IntegrationCardHeader,
|
||||
} from '../card';
|
||||
import { ConnectButton } from './connect';
|
||||
import { ConnectedActions } from './connected';
|
||||
|
||||
export const ReadwiseIntegration = () => {
|
||||
const t = useI18n();
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
|
||||
const settings = useLiveData(readwise.settings$);
|
||||
const token = settings?.token;
|
||||
|
||||
return (
|
||||
<IntegrationCard>
|
||||
<IntegrationCardHeader icon={<IntegrationTypeIcon type="readwise" />} />
|
||||
<IntegrationCardContent
|
||||
title={t['com.affine.integration.readwise.name']()}
|
||||
desc={t['com.affine.integration.readwise.desc']()}
|
||||
/>
|
||||
<IntegrationCardFooter>
|
||||
{token ? <ConnectedActions /> : <ConnectButton />}
|
||||
</IntegrationCardFooter>
|
||||
</IntegrationCard>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
VirtualizedPageList,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { IntegrationService } from '@affine/core/modules/integration';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
@@ -29,10 +30,12 @@ export const AllPage = () => {
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const globalContext = useService(GlobalContextService).globalContext;
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const integrationService = useService(IntegrationService);
|
||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
||||
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
const importing = useLiveData(integrationService.importing$);
|
||||
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
|
||||
@@ -54,6 +57,10 @@ export const AllPage = () => {
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
if (importing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewTitle title={t['All pages']()} />
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const integrationType = f.enum('readwise', 'zotero');
|
||||
|
||||
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
||||
folders: {
|
||||
id: f.string().primaryKey().optional().default(nanoid),
|
||||
@@ -22,6 +24,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
||||
journal: f.string().optional(),
|
||||
pageWidth: f.string().optional(),
|
||||
isTemplate: f.boolean().optional(),
|
||||
integrationType: integrationType.optional(),
|
||||
}),
|
||||
docCustomPropertyInfo: {
|
||||
id: f.string().primaryKey().optional().default(nanoid),
|
||||
@@ -38,7 +41,6 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
||||
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
|
||||
|
||||
export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
|
||||
|
||||
export type DocCustomPropertyInfo = ORMEntity<
|
||||
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
|
||||
>;
|
||||
@@ -52,6 +54,20 @@ export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
|
||||
key: f.string().primaryKey(),
|
||||
value: f.json(),
|
||||
},
|
||||
docIntegrationRef: {
|
||||
// docId as primary key
|
||||
id: f.string().primaryKey(),
|
||||
type: integrationType,
|
||||
/**
|
||||
* Identify **affine user** and **integration type** and **integration account**
|
||||
* Used to quickly find user's all integrations
|
||||
*/
|
||||
integrationId: f.string(),
|
||||
refMeta: f.json(),
|
||||
},
|
||||
} as const satisfies DBSchemaBuilder;
|
||||
export type AFFiNEWorkspaceUserdataDbSchema =
|
||||
typeof AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA;
|
||||
export type DocIntegrationRef = ORMEntity<
|
||||
AFFiNEWorkspaceUserdataDbSchema['docIntegrationRef']
|
||||
>;
|
||||
|
||||
@@ -12,7 +12,7 @@ export type SettingTab =
|
||||
| 'experimental-features'
|
||||
| 'editor'
|
||||
| 'account'
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license'}`;
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations'}`;
|
||||
|
||||
export type GLOBAL_DIALOG_SCHEMA = {
|
||||
'create-workspace': (props: { serverId?: string }) => {
|
||||
|
||||
@@ -266,6 +266,13 @@ export const AFFINE_FLAGS = {
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_integration: {
|
||||
category: 'affine',
|
||||
displayName: 'Enable Integration',
|
||||
description: 'Enable Integration',
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
} satisfies { [key in string]: FlagInfo };
|
||||
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
|
||||
@@ -28,6 +28,7 @@ import { configureGlobalContextModule } from './global-context';
|
||||
import { configureI18nModule } from './i18n';
|
||||
import { configureImportClipperModule } from './import-clipper';
|
||||
import { configureImportTemplateModule } from './import-template';
|
||||
import { configureIntegrationModule } from './integration';
|
||||
import { configureJournalModule } from './journal';
|
||||
import { configureLifecycleModule } from './lifecycle';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
@@ -104,4 +105,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureBlobManagementModule(framework);
|
||||
configureImportClipperModule(framework);
|
||||
configureNotificationModule(framework);
|
||||
configureIntegrationModule(framework);
|
||||
}
|
||||
|
||||
39
packages/frontend/core/src/modules/integration/constant.ts
Normal file
39
packages/frontend/core/src/modules/integration/constant.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc';
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
import type { IntegrationProperty, IntegrationType } from './type';
|
||||
|
||||
// name
|
||||
export const INTEGRATION_TYPE_NAME_MAP: Record<IntegrationType, I18nString> = {
|
||||
readwise: 'com.affine.integration.name.readwise',
|
||||
zotero: 'Zotero',
|
||||
};
|
||||
|
||||
// schema
|
||||
export const INTEGRATION_PROPERTY_SCHEMA: {
|
||||
[T in IntegrationType]: Record<string, IntegrationProperty<T>>;
|
||||
} = {
|
||||
readwise: {
|
||||
author: {
|
||||
label: 'com.affine.integration.readwise-prop.author',
|
||||
key: 'author',
|
||||
type: 'text',
|
||||
},
|
||||
source: {
|
||||
label: 'com.affine.integration.readwise-prop.source',
|
||||
key: 'readwise_url',
|
||||
type: 'source',
|
||||
},
|
||||
},
|
||||
zotero: {},
|
||||
};
|
||||
|
||||
// icon
|
||||
export const INTEGRATION_ICON_MAP: Record<
|
||||
IntegrationType,
|
||||
React.ComponentType<SVGProps<SVGSVGElement>>
|
||||
> = {
|
||||
readwise: ReadwiseLogoDuotoneIcon,
|
||||
zotero: () => null,
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
effect,
|
||||
Entity,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { catchError, EMPTY, mergeMap, Observable, switchMap } from 'rxjs';
|
||||
|
||||
import type { ReadwiseStore } from '../store/readwise';
|
||||
import type {
|
||||
ReadwiseBook,
|
||||
ReadwiseBookMap,
|
||||
ReadwiseCrawlingData,
|
||||
ReadwiseHighlight,
|
||||
ReadwiseResponse,
|
||||
} from '../type';
|
||||
|
||||
export class ReadwiseCrawler extends Entity {
|
||||
public crawling$ = new LiveData(false);
|
||||
public data$ = new LiveData<ReadwiseCrawlingData | null>({
|
||||
highlights: [],
|
||||
books: {},
|
||||
complete: false,
|
||||
});
|
||||
public error$ = new LiveData<Error | null>(null);
|
||||
|
||||
constructor(private readonly readwiseStore: ReadwiseStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
private authHeaders(token: string) {
|
||||
return { Authorization: `Token ${token}` };
|
||||
}
|
||||
|
||||
private async fetchHighlights(options: {
|
||||
token: string;
|
||||
lastImportedAt?: string;
|
||||
pageCursor?: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (options.pageCursor) {
|
||||
queryParams.set('pageCursor', options.pageCursor);
|
||||
}
|
||||
if (options.lastImportedAt) {
|
||||
queryParams.set('updatedAfter', options.lastImportedAt);
|
||||
}
|
||||
const response = await fetch(
|
||||
'https://readwise.io/api/v2/export/?' + queryParams.toString(),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(options.token),
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
const responseJson = (await response.json()) as ReadwiseResponse;
|
||||
|
||||
const highlights: ReadwiseHighlight[] = [];
|
||||
const books: ReadwiseBookMap = {};
|
||||
highlights.push(
|
||||
...responseJson.results.flatMap((book: ReadwiseBook) => book.highlights)
|
||||
);
|
||||
responseJson.results.forEach((book: ReadwiseBook) => {
|
||||
if (books[book.user_book_id]) return;
|
||||
const { highlights: _, ...copy } = book;
|
||||
books[book.user_book_id] = copy;
|
||||
});
|
||||
|
||||
return { highlights, books, nextPageCursor: responseJson.nextPageCursor };
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
const response = await fetch('https://readwise.io/api/v2/auth/', {
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(token),
|
||||
});
|
||||
return !!(response.ok && response.status === 204);
|
||||
}
|
||||
|
||||
crawl = effect(
|
||||
switchMap(() => {
|
||||
return new Observable<ReadwiseCrawlingData>(sub => {
|
||||
const token = this.readwiseStore.getSetting('token');
|
||||
const lastImportedAt = this.readwiseStore.getSetting('lastImportedAt');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Readwise token is required to crawl highlights');
|
||||
}
|
||||
|
||||
const allHighlights: ReadwiseHighlight[] = [];
|
||||
const allBooks: ReadwiseBookMap = {};
|
||||
const startTime = new Date().toISOString();
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const fetchNextPage = (pageCursor?: number) => {
|
||||
abortController = new AbortController();
|
||||
this.fetchHighlights({
|
||||
token,
|
||||
lastImportedAt,
|
||||
pageCursor: pageCursor?.toString(),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then(({ highlights, books, nextPageCursor }) => {
|
||||
allHighlights.push(...highlights);
|
||||
Object.assign(allBooks, books);
|
||||
|
||||
const complete = !nextPageCursor;
|
||||
|
||||
sub.next({
|
||||
highlights: allHighlights,
|
||||
books: allBooks,
|
||||
complete,
|
||||
startTime,
|
||||
});
|
||||
|
||||
if (!complete) {
|
||||
fetchNextPage(nextPageCursor);
|
||||
} else {
|
||||
sub.complete();
|
||||
}
|
||||
})
|
||||
.catch(error => sub.error(error));
|
||||
};
|
||||
|
||||
fetchNextPage();
|
||||
return () => {
|
||||
abortController?.abort();
|
||||
};
|
||||
}).pipe(
|
||||
mergeMap(data => {
|
||||
this.data$.next(data);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchError(err => {
|
||||
this.error$.next(err);
|
||||
return EMPTY;
|
||||
}),
|
||||
onStart(() => {
|
||||
// reset state
|
||||
this.crawling$.next(true);
|
||||
this.data$.next({
|
||||
highlights: [],
|
||||
books: {},
|
||||
complete: false,
|
||||
});
|
||||
this.error$.next(null);
|
||||
}),
|
||||
onComplete(() => {
|
||||
this.crawling$.next(false);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
abort() {
|
||||
this.crawl.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.abort();
|
||||
this.crawling$.next(false);
|
||||
this.data$.next({
|
||||
highlights: [],
|
||||
books: {},
|
||||
complete: false,
|
||||
});
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this.crawl.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import { chunk } from 'lodash-es';
|
||||
|
||||
import type { DocsService } from '../../doc';
|
||||
import { IntegrationPropertyService } from '../services/integration-property';
|
||||
import type { IntegrationRefStore } from '../store/integration-ref';
|
||||
import type { ReadwiseStore } from '../store/readwise';
|
||||
import type {
|
||||
ReadwiseBook,
|
||||
ReadwiseBookMap,
|
||||
ReadwiseConfig,
|
||||
ReadwiseHighlight,
|
||||
ReadwiseRefMeta,
|
||||
} from '../type';
|
||||
import { encryptPBKDF2 } from '../utils/encrypt';
|
||||
import { ReadwiseCrawler } from './readwise-crawler';
|
||||
import type { IntegrationWriter } from './writer';
|
||||
|
||||
export class ReadwiseIntegration extends Entity<{ writer: IntegrationWriter }> {
|
||||
writer = this.props.writer;
|
||||
crawler = this.framework.createEntity(ReadwiseCrawler);
|
||||
|
||||
constructor(
|
||||
private readonly integrationRefStore: IntegrationRefStore,
|
||||
private readonly readwiseStore: ReadwiseStore,
|
||||
private readonly docsService: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
importing$ = new LiveData(false);
|
||||
settings$ = LiveData.from(this.readwiseStore.watchSetting(), undefined);
|
||||
updateSetting<T extends keyof ReadwiseConfig>(
|
||||
key: T,
|
||||
value: ReadwiseConfig[T]
|
||||
) {
|
||||
this.readwiseStore.setSetting(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all integration metas of current user & token in current workspace
|
||||
*/
|
||||
async getRefs() {
|
||||
const token = this.readwiseStore.getSetting('token');
|
||||
if (!token) return [];
|
||||
|
||||
const integrationId = await encryptPBKDF2(token);
|
||||
|
||||
return this.integrationRefStore
|
||||
.getRefs({ type: 'readwise', integrationId })
|
||||
.map(ref => ({
|
||||
...ref,
|
||||
refMeta: ref.refMeta as ReadwiseRefMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
async highlightsToAffineDocs(
|
||||
highlights: ReadwiseHighlight[],
|
||||
books: ReadwiseBookMap,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: number) => void;
|
||||
onComplete?: () => void;
|
||||
onAbort?: (finished: number) => void;
|
||||
}
|
||||
) {
|
||||
this.importing$.next(true);
|
||||
const disposables: (() => void)[] = [];
|
||||
try {
|
||||
const { signal, onProgress, onComplete, onAbort } = options;
|
||||
const integrationId = await encryptPBKDF2(
|
||||
this.readwiseStore.getSetting('token') ?? ''
|
||||
);
|
||||
const userId = this.readwiseStore.getUserId();
|
||||
const localRefs = await this.getRefs();
|
||||
const localRefsMap = new Map(
|
||||
localRefs.map(ref => [ref.refMeta.highlightId, ref])
|
||||
);
|
||||
const updateStrategy = this.readwiseStore.getSetting('updateStrategy');
|
||||
const chunks = chunk(highlights, 2);
|
||||
const total = highlights.length;
|
||||
let finished = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (signal?.aborted) {
|
||||
disposables.forEach(d => d());
|
||||
this.importing$.next(false);
|
||||
onAbort?.(finished);
|
||||
return;
|
||||
}
|
||||
await Promise.all(
|
||||
chunk.map(async highlight => {
|
||||
await new Promise(resolve => {
|
||||
const id = requestIdleCallback(resolve, { timeout: 500 });
|
||||
disposables.push(() => cancelIdleCallback(id));
|
||||
});
|
||||
const book = books[highlight.book_id];
|
||||
const localRef = localRefsMap.get(highlight.id);
|
||||
const refMeta = localRef?.refMeta;
|
||||
const localUpdatedAt = refMeta?.updatedAt;
|
||||
const localDocId = localRef?.id;
|
||||
// write if not matched
|
||||
if (localUpdatedAt !== highlight.updated_at && !signal?.aborted) {
|
||||
await this.highlightToAffineDoc(highlight, book, localDocId, {
|
||||
updateStrategy,
|
||||
integrationId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
finished++;
|
||||
onProgress?.(finished / total);
|
||||
})
|
||||
);
|
||||
}
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to import readwise highlights', err);
|
||||
} finally {
|
||||
disposables.forEach(d => d());
|
||||
this.importing$.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
async highlightToAffineDoc(
|
||||
highlight: ReadwiseHighlight,
|
||||
book: Omit<ReadwiseBook, 'highlights'>,
|
||||
docId: string | undefined,
|
||||
options: {
|
||||
integrationId: string;
|
||||
userId: string;
|
||||
updateStrategy?: ReadwiseConfig['updateStrategy'];
|
||||
}
|
||||
) {
|
||||
const { updateStrategy, integrationId } = options;
|
||||
const { text, ...highlightWithoutText } = highlight;
|
||||
|
||||
const writtenDocId = await this.writer.writeDoc({
|
||||
content: text,
|
||||
title: book.title,
|
||||
docId,
|
||||
comment: highlight.note,
|
||||
updateStrategy,
|
||||
});
|
||||
|
||||
// write failed
|
||||
if (!writtenDocId) return;
|
||||
|
||||
const { doc, release } = this.docsService.open(writtenDocId);
|
||||
const integrationPropertyService = doc.scope.get(
|
||||
IntegrationPropertyService
|
||||
);
|
||||
|
||||
// write doc properties
|
||||
integrationPropertyService.updateIntegrationProperties('readwise', {
|
||||
...highlightWithoutText,
|
||||
...book,
|
||||
});
|
||||
release();
|
||||
|
||||
// update integration ref
|
||||
this.integrationRefStore.createRef(doc.id, {
|
||||
type: 'readwise',
|
||||
integrationId,
|
||||
refMeta: {
|
||||
highlightId: highlight.id,
|
||||
updatedAt: highlight.updated_at,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.readwiseStore.setSetting('token', undefined);
|
||||
this.readwiseStore.setSetting('lastImportedAt', undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { MarkdownTransformer } from '@blocksuite/affine/blocks/root';
|
||||
import { Entity } from '@toeverything/infra';
|
||||
|
||||
import {
|
||||
getAFFiNEWorkspaceSchema,
|
||||
type WorkspaceService,
|
||||
} from '../../workspace';
|
||||
|
||||
export class IntegrationWriter extends Entity {
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async writeDoc(options: {
|
||||
/**
|
||||
* Title of the doc
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Markdown string
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* Comment of the markdown content
|
||||
*/
|
||||
comment?: string | null;
|
||||
/**
|
||||
* Doc id, if not provided, a new doc will be created
|
||||
*/
|
||||
docId?: string;
|
||||
/**
|
||||
* Update strategy, default is `override`
|
||||
*/
|
||||
updateStrategy?: 'override' | 'append';
|
||||
}) {
|
||||
const {
|
||||
title,
|
||||
content,
|
||||
comment,
|
||||
docId,
|
||||
updateStrategy = 'override',
|
||||
} = options;
|
||||
|
||||
const workspace = this.workspaceService.workspace;
|
||||
let markdown = comment ? `${content}\n---\n${comment}` : content;
|
||||
|
||||
if (!docId) {
|
||||
const newDocId = await MarkdownTransformer.importMarkdownToDoc({
|
||||
collection: workspace.docCollection,
|
||||
schema: getAFFiNEWorkspaceSchema(),
|
||||
markdown,
|
||||
fileName: title,
|
||||
});
|
||||
|
||||
return newDocId;
|
||||
} else {
|
||||
const collection = workspace.docCollection;
|
||||
|
||||
const doc = collection.getDoc(docId);
|
||||
if (!doc) throw new Error('Doc not found');
|
||||
|
||||
doc.workspace.meta.setDocMeta(docId, {
|
||||
updatedDate: Date.now(),
|
||||
});
|
||||
|
||||
if (updateStrategy === 'override') {
|
||||
const pageBlock = doc.getBlocksByFlavour('affine:page')[0];
|
||||
// remove all children of the page block
|
||||
pageBlock.model.children.forEach(child => {
|
||||
doc.deleteBlock(child);
|
||||
});
|
||||
// add a new note block
|
||||
const noteBlockId = doc.addBlock('affine:note', {}, pageBlock.id);
|
||||
// import the markdown to the note block
|
||||
await MarkdownTransformer.importMarkdownToBlock({
|
||||
doc,
|
||||
blockId: noteBlockId,
|
||||
markdown,
|
||||
});
|
||||
} else if (updateStrategy === 'append') {
|
||||
const pageBlockId = doc.getBlocksByFlavour('affine:page')[0]?.id;
|
||||
const blockId = doc.addBlock('affine:note', {}, pageBlockId);
|
||||
await MarkdownTransformer.importMarkdownToBlock({
|
||||
doc,
|
||||
blockId,
|
||||
markdown: `---\n${markdown}`,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Invalid update strategy');
|
||||
}
|
||||
return doc.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/frontend/core/src/modules/integration/index.ts
Normal file
38
packages/frontend/core/src/modules/integration/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceServerService } from '../cloud';
|
||||
import { WorkspaceDBService } from '../db';
|
||||
import { DocScope, DocService, DocsService } from '../doc';
|
||||
import { GlobalState } from '../storage';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { ReadwiseIntegration } from './entities/readwise';
|
||||
import { ReadwiseCrawler } from './entities/readwise-crawler';
|
||||
import { IntegrationWriter } from './entities/writer';
|
||||
import { IntegrationService } from './services/integration';
|
||||
import { IntegrationPropertyService } from './services/integration-property';
|
||||
import { IntegrationRefStore } from './store/integration-ref';
|
||||
import { ReadwiseStore } from './store/readwise';
|
||||
|
||||
export { IntegrationService };
|
||||
export { IntegrationTypeIcon } from './views/icon';
|
||||
|
||||
export function configureIntegrationModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.store(IntegrationRefStore, [WorkspaceDBService, DocsService])
|
||||
.store(ReadwiseStore, [
|
||||
GlobalState,
|
||||
WorkspaceService,
|
||||
WorkspaceServerService,
|
||||
])
|
||||
.service(IntegrationService)
|
||||
.entity(IntegrationWriter, [WorkspaceService])
|
||||
.entity(ReadwiseCrawler, [ReadwiseStore])
|
||||
.entity(ReadwiseIntegration, [
|
||||
IntegrationRefStore,
|
||||
ReadwiseStore,
|
||||
DocsService,
|
||||
])
|
||||
.scope(DocScope)
|
||||
.service(IntegrationPropertyService, [DocService]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type LiveData, Service } from '@toeverything/infra';
|
||||
|
||||
import type { DocService } from '../../doc';
|
||||
import { INTEGRATION_PROPERTY_SCHEMA } from '../constant';
|
||||
import type { IntegrationDocPropertiesMap, IntegrationType } from '../type';
|
||||
|
||||
export class IntegrationPropertyService extends Service {
|
||||
constructor(private readonly docService: DocService) {
|
||||
super();
|
||||
}
|
||||
|
||||
schema$ = this.docService.doc.properties$
|
||||
.selector(p => p.integrationType)
|
||||
.map(type => (type ? INTEGRATION_PROPERTY_SCHEMA[type] : null));
|
||||
|
||||
integrationProperty$<
|
||||
T extends IntegrationType,
|
||||
Key extends keyof IntegrationDocPropertiesMap[T],
|
||||
>(type: T, key: Key) {
|
||||
return this.docService.doc.properties$.selector(
|
||||
p => p[`${type}:${key.toString()}`]
|
||||
) as LiveData<IntegrationDocPropertiesMap[T][Key] | undefined | null>;
|
||||
}
|
||||
|
||||
updateIntegrationProperties<T extends IntegrationType>(
|
||||
type: T,
|
||||
updates: Partial<IntegrationDocPropertiesMap[T]>
|
||||
) {
|
||||
this.docService.doc.updateProperties({
|
||||
integrationType: type,
|
||||
...Object.fromEntries(
|
||||
Object.entries(updates).map(([key, value]) => {
|
||||
return [`${type}:${key.toString()}`, value];
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
|
||||
import { ReadwiseIntegration } from '../entities/readwise';
|
||||
import { IntegrationWriter } from '../entities/writer';
|
||||
|
||||
export class IntegrationService extends Service {
|
||||
writer = this.framework.createEntity(IntegrationWriter);
|
||||
readwise = this.framework.createEntity(ReadwiseIntegration, {
|
||||
writer: this.writer,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
importing$ = LiveData.computed(get => {
|
||||
return get(this.readwise.importing$);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceDBService } from '../../db';
|
||||
import type { DocIntegrationRef } from '../../db/schema/schema';
|
||||
import type { DocsService } from '../../doc';
|
||||
|
||||
export class IntegrationRefStore extends Store {
|
||||
constructor(
|
||||
private readonly dbService: WorkspaceDBService,
|
||||
private readonly docsService: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get userDB() {
|
||||
return this.dbService.userdataDB$.value.db;
|
||||
}
|
||||
get allDocsMap() {
|
||||
return this.docsService.list.docsMap$.value;
|
||||
}
|
||||
|
||||
getRefs(where: Parameters<typeof this.userDB.docIntegrationRef.find>[0]) {
|
||||
const refs = this.userDB.docIntegrationRef.find({
|
||||
...where,
|
||||
});
|
||||
return refs.filter(ref => {
|
||||
const docExists = this.allDocsMap.has(ref.id);
|
||||
if (!docExists) this.deleteRef(ref.id);
|
||||
return docExists;
|
||||
});
|
||||
}
|
||||
|
||||
createRef(docId: string, config: Omit<DocIntegrationRef, 'id'>) {
|
||||
return this.userDB.docIntegrationRef.create({
|
||||
id: docId,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
updateRef(docId: string, config: Partial<DocIntegrationRef>) {
|
||||
return this.userDB.docIntegrationRef.update(docId, config);
|
||||
}
|
||||
|
||||
deleteRef(docId: string) {
|
||||
return this.userDB.docIntegrationRef.delete(docId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { LiveData, Store } from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
|
||||
import { AuthService, type WorkspaceServerService } from '../../cloud';
|
||||
import type { GlobalState } from '../../storage';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import { type ReadwiseConfig } from '../type';
|
||||
|
||||
export class ReadwiseStore extends Store {
|
||||
constructor(
|
||||
private readonly globalState: GlobalState,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceServerService: WorkspaceServerService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private _getKey({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return `readwise:${userId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
authService = this.workspaceServerService.server?.scope.get(AuthService);
|
||||
workspaceId = this.workspaceService.workspace.id;
|
||||
|
||||
userId$ =
|
||||
this.workspaceService.workspace.meta.flavour === 'local' ||
|
||||
!this.authService
|
||||
? new LiveData('__local__')
|
||||
: this.authService.session.account$.map(
|
||||
account => account?.id ?? '__local__'
|
||||
);
|
||||
|
||||
getUserId() {
|
||||
return this.workspaceService.workspace.meta.flavour === 'local' ||
|
||||
!this.authService
|
||||
? '__local__'
|
||||
: (this.authService.session.account$.value?.id ?? '__local__');
|
||||
}
|
||||
|
||||
storageKey$() {
|
||||
const workspaceId = this.workspaceService.workspace.id;
|
||||
return this.userId$.map(userId => this._getKey({ userId, workspaceId }));
|
||||
}
|
||||
|
||||
getStorageKey() {
|
||||
const userId = this.getUserId();
|
||||
const workspaceId = this.workspaceService.workspace.id;
|
||||
return this._getKey({ userId, workspaceId });
|
||||
}
|
||||
|
||||
watchSetting() {
|
||||
return this.storageKey$().pipe(
|
||||
exhaustMap(storageKey => {
|
||||
return this.globalState.watch<ReadwiseConfig>(storageKey);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getSetting(): ReadwiseConfig | undefined;
|
||||
getSetting<Key extends keyof ReadwiseConfig>(
|
||||
key: Key
|
||||
): ReadwiseConfig[Key] | undefined;
|
||||
getSetting(key?: keyof ReadwiseConfig) {
|
||||
const config = this.globalState.get<ReadwiseConfig>(this.getStorageKey());
|
||||
if (!key) return config;
|
||||
return config?.[key];
|
||||
}
|
||||
|
||||
setSetting<Key extends keyof ReadwiseConfig>(
|
||||
key: Key,
|
||||
value: ReadwiseConfig[Key]
|
||||
) {
|
||||
this.globalState.set(this.getStorageKey(), {
|
||||
...this.getSetting(),
|
||||
[key]: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
87
packages/frontend/core/src/modules/integration/type.ts
Normal file
87
packages/frontend/core/src/modules/integration/type.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
|
||||
import type { DocIntegrationRef } from '../db/schema/schema';
|
||||
|
||||
export type IntegrationType = NonNullable<DocIntegrationRef['type']>;
|
||||
|
||||
export type IntegrationDocPropertiesMap = {
|
||||
readwise: ReadwiseDocProperties;
|
||||
zotero: never;
|
||||
};
|
||||
|
||||
export type IntegrationProperty<T extends IntegrationType> = {
|
||||
key: keyof IntegrationDocPropertiesMap[T];
|
||||
label?: I18nString;
|
||||
type: 'link' | 'text' | 'date' | 'source';
|
||||
};
|
||||
|
||||
// ===============================
|
||||
// Readwise
|
||||
// ===============================
|
||||
export interface ReadwiseResponse {
|
||||
count: number;
|
||||
nextPageCursor: number | null;
|
||||
results: ReadwiseBook[];
|
||||
}
|
||||
export interface ReadwiseCrawlingData {
|
||||
highlights: ReadwiseHighlight[];
|
||||
books: ReadwiseBookMap;
|
||||
complete: boolean;
|
||||
startTime?: string;
|
||||
}
|
||||
export interface ReadwiseBook {
|
||||
user_book_id: string | number;
|
||||
is_deleted: boolean;
|
||||
title: string;
|
||||
author: string;
|
||||
highlights: ReadwiseHighlight[];
|
||||
}
|
||||
export interface ReadwiseHighlight {
|
||||
id: string;
|
||||
is_deleted: boolean;
|
||||
text: string;
|
||||
location: number;
|
||||
location_type: 'page' | 'order' | 'time_offset';
|
||||
note: string | null;
|
||||
color: string;
|
||||
highlighted_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
external_id: string;
|
||||
end_location: number | null;
|
||||
url: null;
|
||||
book_id: string | number;
|
||||
tags: string[];
|
||||
is_favorite: boolean;
|
||||
is_discard: boolean;
|
||||
readwise_url: string;
|
||||
}
|
||||
export type ReadwiseDocProperties = Omit<ReadwiseBook, 'highlights'> &
|
||||
ReadwiseHighlight;
|
||||
|
||||
export type ReadwiseBookMap = Record<
|
||||
ReadwiseBook['user_book_id'],
|
||||
Omit<ReadwiseBook, 'highlights'>
|
||||
>;
|
||||
export interface ReadwiseRefMeta {
|
||||
highlightId: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface ReadwiseConfig {
|
||||
/**
|
||||
* User token
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* The last import time
|
||||
*/
|
||||
lastImportedAt?: string;
|
||||
/**
|
||||
* The update strategy
|
||||
*/
|
||||
updateStrategy?: 'override' | 'append';
|
||||
}
|
||||
// ===============================
|
||||
// Zotero
|
||||
// ===============================
|
||||
// TODO
|
||||
@@ -0,0 +1,28 @@
|
||||
export async function encryptPBKDF2(
|
||||
source: string,
|
||||
secret = 'affine',
|
||||
iterations = 100000
|
||||
) {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(source),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
const salt = encoder.encode(secret);
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
salt,
|
||||
iterations,
|
||||
},
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
return Array.from(new Uint8Array(derivedBits))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
import { INTEGRATION_ICON_MAP } from '../constant';
|
||||
import type { IntegrationType } from '../type';
|
||||
|
||||
export const IntegrationTypeIcon = ({
|
||||
type,
|
||||
...props
|
||||
}: {
|
||||
type: IntegrationType;
|
||||
} & SVGProps<SVGSVGElement>) => {
|
||||
const Icon = INTEGRATION_ICON_MAP[type];
|
||||
|
||||
return <Icon {...props} />;
|
||||
};
|
||||
Reference in New Issue
Block a user