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,3 +1,4 @@
|
|||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
@@ -9,19 +10,23 @@ export interface LoadingProps {
|
|||||||
speed?: number;
|
speed?: number;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
strokeColor?: string;
|
strokeColor?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Loading = ({
|
export const Loading = ({
|
||||||
size,
|
size,
|
||||||
speed = 1.2,
|
speed = 1.2,
|
||||||
progress = 0.2,
|
progress = 0.2,
|
||||||
|
strokeWidth = 4,
|
||||||
strokeColor,
|
strokeColor,
|
||||||
|
className,
|
||||||
}: LoadingProps) => {
|
}: LoadingProps) => {
|
||||||
// allow `string` such as `16px` | `100%` | `1em`
|
// allow `string` such as `16px` | `100%` | `1em`
|
||||||
const sizeWithUnit = size ? withUnit(size, 'px') : '16px';
|
const sizeWithUnit = size ? withUnit(size, 'px') : '16px';
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={clsx(loading, speed !== 0 && rotateAnimation)}
|
className={clsx(loading, speed !== 0 && rotateAnimation, className)}
|
||||||
width={sizeWithUnit}
|
width={sizeWithUnit}
|
||||||
height={sizeWithUnit}
|
height={sizeWithUnit}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -37,15 +42,15 @@ export const Loading = ({
|
|||||||
cx={12}
|
cx={12}
|
||||||
cy={12}
|
cy={12}
|
||||||
r={8}
|
r={8}
|
||||||
strokeWidth={4}
|
strokeWidth={strokeWidth}
|
||||||
stroke="var(--affine-black-10)"
|
stroke="var(--affine-black-10)"
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={12}
|
cx={12}
|
||||||
cy={12}
|
cy={12}
|
||||||
r={8}
|
r={8}
|
||||||
strokeWidth={4}
|
strokeWidth={strokeWidth}
|
||||||
stroke={strokeColor || 'var(--affine-primary-color)'}
|
stroke={strokeColor || cssVarV2.loading.foreground}
|
||||||
strokeDasharray={`${2 * Math.PI * 8 * progress} ${
|
strokeDasharray={`${2 * Math.PI * 8 * progress} ${
|
||||||
2 * Math.PI * 8 * (1 - progress)
|
2 * Math.PI * 8 * (1 - progress)
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||||
import { ServerService } from '@affine/core/modules/cloud';
|
import { ServerService } from '@affine/core/modules/cloud';
|
||||||
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
|
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 { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import { ServerDeploymentType } from '@affine/graphql';
|
import { ServerDeploymentType } from '@affine/graphql';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import {
|
import {
|
||||||
CollaborationIcon,
|
CollaborationIcon,
|
||||||
|
IntegrationsIcon,
|
||||||
PaymentIcon,
|
PaymentIcon,
|
||||||
PropertyIcon,
|
PropertyIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
@@ -16,6 +18,7 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import type { SettingSidebarItem, SettingState } from '../types';
|
import type { SettingSidebarItem, SettingState } from '../types';
|
||||||
import { WorkspaceSettingBilling } from './billing';
|
import { WorkspaceSettingBilling } from './billing';
|
||||||
|
import { IntegrationSetting } from './integration';
|
||||||
import { WorkspaceSettingLicense } from './license';
|
import { WorkspaceSettingLicense } from './license';
|
||||||
import { MembersPanel } from './members';
|
import { MembersPanel } from './members';
|
||||||
import { WorkspaceSettingDetail } from './preference';
|
import { WorkspaceSettingDetail } from './preference';
|
||||||
@@ -49,6 +52,8 @@ export const WorkspaceSetting = ({
|
|||||||
return <WorkspaceSettingStorage onCloseSetting={onCloseSetting} />;
|
return <WorkspaceSettingStorage onCloseSetting={onCloseSetting} />;
|
||||||
case 'workspace:license':
|
case 'workspace:license':
|
||||||
return <WorkspaceSettingLicense onCloseSetting={onCloseSetting} />;
|
return <WorkspaceSettingLicense onCloseSetting={onCloseSetting} />;
|
||||||
|
case 'workspace:integrations':
|
||||||
|
return <IntegrationSetting />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,6 +63,11 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
|
|||||||
const workspaceService = useService(WorkspaceService);
|
const workspaceService = useService(WorkspaceService);
|
||||||
const information = useWorkspaceInfo(workspaceService.workspace);
|
const information = useWorkspaceInfo(workspaceService.workspace);
|
||||||
const serverService = useService(ServerService);
|
const serverService = useService(ServerService);
|
||||||
|
const featureFlagService = useService(FeatureFlagService);
|
||||||
|
|
||||||
|
const enableIntegration = useLiveData(
|
||||||
|
featureFlagService.flags.enable_integration.$
|
||||||
|
);
|
||||||
|
|
||||||
const isSelfhosted = useLiveData(
|
const isSelfhosted = useLiveData(
|
||||||
serverService.server.config$.selector(
|
serverService.server.config$.selector(
|
||||||
@@ -90,6 +100,12 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
|
|||||||
icon: <CollaborationIcon />,
|
icon: <CollaborationIcon />,
|
||||||
testId: 'workspace-setting:members',
|
testId: 'workspace-setting:members',
|
||||||
},
|
},
|
||||||
|
enableIntegration && {
|
||||||
|
key: 'workspace:integrations',
|
||||||
|
title: t['com.affine.integration.integrations'](),
|
||||||
|
icon: <IntegrationsIcon />,
|
||||||
|
testId: 'workspace-setting:integrations',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'workspace:storage',
|
key: 'workspace:storage',
|
||||||
title: t['Storage'](),
|
title: t['Storage'](),
|
||||||
@@ -109,7 +125,7 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
|
|||||||
testId: 'workspace-setting:license',
|
testId: 'workspace-setting:license',
|
||||||
},
|
},
|
||||||
].filter((item): item is SettingSidebarItem => !!item);
|
].filter((item): item is SettingSidebarItem => !!item);
|
||||||
}, [showBilling, showLicense, t]);
|
}, [enableIntegration, showBilling, showLicense, t]);
|
||||||
|
|
||||||
return items;
|
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,
|
VirtualizedPageList,
|
||||||
} from '@affine/core/components/page-list';
|
} from '@affine/core/components/page-list';
|
||||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||||
|
import { IntegrationService } from '@affine/core/modules/integration';
|
||||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import type { Filter } from '@affine/env/filter';
|
import type { Filter } from '@affine/env/filter';
|
||||||
@@ -29,10 +30,12 @@ export const AllPage = () => {
|
|||||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||||
const globalContext = useService(GlobalContextService).globalContext;
|
const globalContext = useService(GlobalContextService).globalContext;
|
||||||
const permissionService = useService(WorkspacePermissionService);
|
const permissionService = useService(WorkspacePermissionService);
|
||||||
|
const integrationService = useService(IntegrationService);
|
||||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
||||||
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
|
||||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||||
|
const importing = useLiveData(integrationService.importing$);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<Filter[]>([]);
|
const [filters, setFilters] = useState<Filter[]>([]);
|
||||||
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
|
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
|
||||||
@@ -54,6 +57,10 @@ export const AllPage = () => {
|
|||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
|
if (importing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ViewTitle title={t['All pages']()} />
|
<ViewTitle title={t['All pages']()} />
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
const integrationType = f.enum('readwise', 'zotero');
|
||||||
|
|
||||||
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
||||||
folders: {
|
folders: {
|
||||||
id: f.string().primaryKey().optional().default(nanoid),
|
id: f.string().primaryKey().optional().default(nanoid),
|
||||||
@@ -22,6 +24,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
|||||||
journal: f.string().optional(),
|
journal: f.string().optional(),
|
||||||
pageWidth: f.string().optional(),
|
pageWidth: f.string().optional(),
|
||||||
isTemplate: f.boolean().optional(),
|
isTemplate: f.boolean().optional(),
|
||||||
|
integrationType: integrationType.optional(),
|
||||||
}),
|
}),
|
||||||
docCustomPropertyInfo: {
|
docCustomPropertyInfo: {
|
||||||
id: f.string().primaryKey().optional().default(nanoid),
|
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 AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
|
||||||
|
|
||||||
export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
|
export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
|
||||||
|
|
||||||
export type DocCustomPropertyInfo = ORMEntity<
|
export type DocCustomPropertyInfo = ORMEntity<
|
||||||
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
|
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
|
||||||
>;
|
>;
|
||||||
@@ -52,6 +54,20 @@ export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
|
|||||||
key: f.string().primaryKey(),
|
key: f.string().primaryKey(),
|
||||||
value: f.json(),
|
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;
|
} as const satisfies DBSchemaBuilder;
|
||||||
export type AFFiNEWorkspaceUserdataDbSchema =
|
export type AFFiNEWorkspaceUserdataDbSchema =
|
||||||
typeof AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA;
|
typeof AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA;
|
||||||
|
export type DocIntegrationRef = ORMEntity<
|
||||||
|
AFFiNEWorkspaceUserdataDbSchema['docIntegrationRef']
|
||||||
|
>;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type SettingTab =
|
|||||||
| 'experimental-features'
|
| 'experimental-features'
|
||||||
| 'editor'
|
| 'editor'
|
||||||
| 'account'
|
| 'account'
|
||||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license'}`;
|
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations'}`;
|
||||||
|
|
||||||
export type GLOBAL_DIALOG_SCHEMA = {
|
export type GLOBAL_DIALOG_SCHEMA = {
|
||||||
'create-workspace': (props: { serverId?: string }) => {
|
'create-workspace': (props: { serverId?: string }) => {
|
||||||
|
|||||||
@@ -266,6 +266,13 @@ export const AFFINE_FLAGS = {
|
|||||||
configurable: isCanaryBuild,
|
configurable: isCanaryBuild,
|
||||||
defaultState: false,
|
defaultState: false,
|
||||||
},
|
},
|
||||||
|
enable_integration: {
|
||||||
|
category: 'affine',
|
||||||
|
displayName: 'Enable Integration',
|
||||||
|
description: 'Enable Integration',
|
||||||
|
configurable: isCanaryBuild,
|
||||||
|
defaultState: false,
|
||||||
|
},
|
||||||
} satisfies { [key in string]: FlagInfo };
|
} satisfies { [key in string]: FlagInfo };
|
||||||
|
|
||||||
// oxlint-disable-next-line no-redeclare
|
// oxlint-disable-next-line no-redeclare
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { configureGlobalContextModule } from './global-context';
|
|||||||
import { configureI18nModule } from './i18n';
|
import { configureI18nModule } from './i18n';
|
||||||
import { configureImportClipperModule } from './import-clipper';
|
import { configureImportClipperModule } from './import-clipper';
|
||||||
import { configureImportTemplateModule } from './import-template';
|
import { configureImportTemplateModule } from './import-template';
|
||||||
|
import { configureIntegrationModule } from './integration';
|
||||||
import { configureJournalModule } from './journal';
|
import { configureJournalModule } from './journal';
|
||||||
import { configureLifecycleModule } from './lifecycle';
|
import { configureLifecycleModule } from './lifecycle';
|
||||||
import { configureNavigationModule } from './navigation';
|
import { configureNavigationModule } from './navigation';
|
||||||
@@ -104,4 +105,5 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureBlobManagementModule(framework);
|
configureBlobManagementModule(framework);
|
||||||
configureImportClipperModule(framework);
|
configureImportClipperModule(framework);
|
||||||
configureNotificationModule(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} />;
|
||||||
|
};
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
{
|
{
|
||||||
"ar": 98,
|
"ar": 96,
|
||||||
"ca": 4,
|
"ca": 4,
|
||||||
"da": 5,
|
"da": 5,
|
||||||
"de": 98,
|
"de": 97,
|
||||||
"el-GR": 98,
|
"el-GR": 96,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 98,
|
"es-AR": 97,
|
||||||
"es-CL": 100,
|
"es-CL": 98,
|
||||||
"es": 98,
|
"es": 96,
|
||||||
"fa": 98,
|
"fa": 96,
|
||||||
"fr": 98,
|
"fr": 96,
|
||||||
"hi": 2,
|
"hi": 2,
|
||||||
"it-IT": 98,
|
"it-IT": 97,
|
||||||
"it": 1,
|
"it": 1,
|
||||||
"ja": 98,
|
"ja": 96,
|
||||||
"ko": 62,
|
"ko": 61,
|
||||||
"pl": 98,
|
"pl": 96,
|
||||||
"pt-BR": 98,
|
"pt-BR": 96,
|
||||||
"ru": 98,
|
"ru": 96,
|
||||||
"sv-SE": 98,
|
"sv-SE": 96,
|
||||||
"uk": 98,
|
"uk": 96,
|
||||||
"ur": 2,
|
"ur": 2,
|
||||||
"zh-Hans": 98,
|
"zh-Hans": 96,
|
||||||
"zh-Hant": 98
|
"zh-Hant": 96
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7117,6 +7117,144 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Please contact your workspace owner to add more seats.`
|
* `Please contact your workspace owner to add more seats.`
|
||||||
*/
|
*/
|
||||||
["com.affine.fail-to-join-workspace.description-2"](): string;
|
["com.affine.fail-to-join-workspace.description-2"](): string;
|
||||||
|
/**
|
||||||
|
* `Readwise`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.name.readwise"](): string;
|
||||||
|
/**
|
||||||
|
* `Integrations`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.integrations"](): string;
|
||||||
|
/**
|
||||||
|
* `Elevate your AFFiNE experience with diverse add-ons and seamless integrations.`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.setting.description"](): string;
|
||||||
|
/**
|
||||||
|
* `Learn how to develop a integration for AFFiNE`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.setting.learn"](): string;
|
||||||
|
/**
|
||||||
|
* `Readwise`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.name"](): string;
|
||||||
|
/**
|
||||||
|
* `Manually import your content to AFFiNE from Readwise`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.desc"](): string;
|
||||||
|
/**
|
||||||
|
* `Connect`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.connect"](): string;
|
||||||
|
/**
|
||||||
|
* `Connect to Readwise`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.connect.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Paste your access token here`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.connect.placeholder"](): string;
|
||||||
|
/**
|
||||||
|
* `Please enter a valid access token.`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.connect.input-error"](): string;
|
||||||
|
/**
|
||||||
|
* `Access Token failed validation`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.connect.error-notify-title"](): string;
|
||||||
|
/**
|
||||||
|
* `The token could not access Readwise. Please verify access and try again.`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.connect.error-notify-desc"](): string;
|
||||||
|
/**
|
||||||
|
* `Import`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import"](): string;
|
||||||
|
/**
|
||||||
|
* `Disconnect`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.disconnect"](): string;
|
||||||
|
/**
|
||||||
|
* `Disconnect Readwise?`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.disconnect.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Once disconnected, content will no longer be imported. Do you want to keep your existing highlights in AFFiNE?`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.disconnect.desc"](): string;
|
||||||
|
/**
|
||||||
|
* `Keep`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.disconnect.keep"](): string;
|
||||||
|
/**
|
||||||
|
* `Delete`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.disconnect.delete"](): string;
|
||||||
|
/**
|
||||||
|
* `Highlights to be imported this time`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Importing everything from the start`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.desc-from-start"](): string;
|
||||||
|
/**
|
||||||
|
* `Content`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.cell-h-content"](): string;
|
||||||
|
/**
|
||||||
|
* `Todo`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.cell-h-todo"](): string;
|
||||||
|
/**
|
||||||
|
* `Last update on Readwise`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.cell-h-time"](): string;
|
||||||
|
/**
|
||||||
|
* `New`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.todo-new"](): string;
|
||||||
|
/**
|
||||||
|
* `Skip`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.todo-skip"](): string;
|
||||||
|
/**
|
||||||
|
* `Updated`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.todo-update"](): string;
|
||||||
|
/**
|
||||||
|
* `No highlights needs to be imported`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.empty"](): string;
|
||||||
|
/**
|
||||||
|
* `Importing...`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.importing"](): string;
|
||||||
|
/**
|
||||||
|
* `Please keep this app active until it's finished`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.importing-desc"](): string;
|
||||||
|
/**
|
||||||
|
* `Stop Importing`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.importing-stop"](): string;
|
||||||
|
/**
|
||||||
|
* `Importing aborted`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.abort-notify-title"](): string;
|
||||||
|
/**
|
||||||
|
* `Import aborted, with {{finished}} highlights processed`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.abort-notify-desc"](options: {
|
||||||
|
readonly finished: string;
|
||||||
|
}): string;
|
||||||
|
/**
|
||||||
|
* `Author`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise-prop.author"](): string;
|
||||||
|
/**
|
||||||
|
* `Source`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise-prop.source"](): string;
|
||||||
/**
|
/**
|
||||||
* `An internal error occurred.`
|
* `An internal error occurred.`
|
||||||
*/
|
*/
|
||||||
@@ -8119,4 +8257,18 @@ export const TypedTrans: {
|
|||||||
["1"]: JSX.Element;
|
["1"]: JSX.Element;
|
||||||
["2"]: JSX.Element;
|
["2"]: JSX.Element;
|
||||||
}>>;
|
}>>;
|
||||||
|
/**
|
||||||
|
* `Import your Readwise highlights to AFFiNE. Please visit Readwise, click <a>"Get Access Token"</a>, and paste the token below.`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.connect.desc"]: ComponentType<TypedTransProps<Readonly<{}>, {
|
||||||
|
a: JSX.Element;
|
||||||
|
}>>;
|
||||||
|
/**
|
||||||
|
* `Updates to be imported since last successful import on {{lastImportedAt}} <a>Import everything instead</a>`
|
||||||
|
*/
|
||||||
|
["com.affine.integration.readwise.import.desc-from-last"]: ComponentType<TypedTransProps<{
|
||||||
|
readonly lastImportedAt: string;
|
||||||
|
}, {
|
||||||
|
a: JSX.Element;
|
||||||
|
}>>;
|
||||||
} = /*#__PURE__*/ createProxy(createComponent);
|
} = /*#__PURE__*/ createProxy(createComponent);
|
||||||
|
|||||||
@@ -1771,6 +1771,42 @@
|
|||||||
"com.affine.fail-to-join-workspace.title": "Join Failed",
|
"com.affine.fail-to-join-workspace.title": "Join Failed",
|
||||||
"com.affine.fail-to-join-workspace.description-1": "Unable to join <1/> <2>{{workspaceName}}</2> due to insufficient seats available.",
|
"com.affine.fail-to-join-workspace.description-1": "Unable to join <1/> <2>{{workspaceName}}</2> due to insufficient seats available.",
|
||||||
"com.affine.fail-to-join-workspace.description-2": "Please contact your workspace owner to add more seats.",
|
"com.affine.fail-to-join-workspace.description-2": "Please contact your workspace owner to add more seats.",
|
||||||
|
"com.affine.integration.name.readwise": "Readwise",
|
||||||
|
"com.affine.integration.integrations": "Integrations",
|
||||||
|
"com.affine.integration.setting.description": "Elevate your AFFiNE experience with diverse add-ons and seamless integrations.",
|
||||||
|
"com.affine.integration.setting.learn": "Learn how to develop a integration for AFFiNE",
|
||||||
|
"com.affine.integration.readwise.name": "Readwise",
|
||||||
|
"com.affine.integration.readwise.desc": "Manually import your content to AFFiNE from Readwise",
|
||||||
|
"com.affine.integration.readwise.connect": "Connect",
|
||||||
|
"com.affine.integration.readwise.connect.title": "Connect to Readwise",
|
||||||
|
"com.affine.integration.readwise.connect.desc": "Import your Readwise highlights to AFFiNE. Please visit Readwise, click <a>\"Get Access Token\"</a>, and paste the token below.",
|
||||||
|
"com.affine.integration.readwise.connect.placeholder": "Paste your access token here",
|
||||||
|
"com.affine.integration.readwise.connect.input-error": "Please enter a valid access token.",
|
||||||
|
"com.affine.integration.readwise.connect.error-notify-title": "Access Token failed validation",
|
||||||
|
"com.affine.integration.readwise.connect.error-notify-desc": "The token could not access Readwise. Please verify access and try again.",
|
||||||
|
"com.affine.integration.readwise.import": "Import",
|
||||||
|
"com.affine.integration.readwise.disconnect": "Disconnect",
|
||||||
|
"com.affine.integration.readwise.disconnect.title": "Disconnect Readwise?",
|
||||||
|
"com.affine.integration.readwise.disconnect.desc": "Once disconnected, content will no longer be imported. Do you want to keep your existing highlights in AFFiNE?",
|
||||||
|
"com.affine.integration.readwise.disconnect.keep": "Keep",
|
||||||
|
"com.affine.integration.readwise.disconnect.delete": "Delete",
|
||||||
|
"com.affine.integration.readwise.import.title": "Highlights to be imported this time",
|
||||||
|
"com.affine.integration.readwise.import.desc-from-start": "Importing everything from the start",
|
||||||
|
"com.affine.integration.readwise.import.desc-from-last": "Updates to be imported since last successful import on {{lastImportedAt}} <a>Import everything instead</a>",
|
||||||
|
"com.affine.integration.readwise.import.cell-h-content": "Content",
|
||||||
|
"com.affine.integration.readwise.import.cell-h-todo": "Todo",
|
||||||
|
"com.affine.integration.readwise.import.cell-h-time": "Last update on Readwise",
|
||||||
|
"com.affine.integration.readwise.import.todo-new": "New",
|
||||||
|
"com.affine.integration.readwise.import.todo-skip": "Skip",
|
||||||
|
"com.affine.integration.readwise.import.todo-update": "Updated",
|
||||||
|
"com.affine.integration.readwise.import.empty": "No highlights needs to be imported",
|
||||||
|
"com.affine.integration.readwise.import.importing": "Importing...",
|
||||||
|
"com.affine.integration.readwise.import.importing-desc": "Please keep this app active until it's finished",
|
||||||
|
"com.affine.integration.readwise.import.importing-stop": "Stop Importing",
|
||||||
|
"com.affine.integration.readwise.import.abort-notify-title": "Importing aborted",
|
||||||
|
"com.affine.integration.readwise.import.abort-notify-desc": "Import aborted, with {{finished}} highlights processed",
|
||||||
|
"com.affine.integration.readwise-prop.author": "Author",
|
||||||
|
"com.affine.integration.readwise-prop.source": "Source",
|
||||||
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
||||||
"error.NETWORK_ERROR": "Network error.",
|
"error.NETWORK_ERROR": "Network error.",
|
||||||
"error.TOO_MANY_REQUEST": "Too many requests.",
|
"error.TOO_MANY_REQUEST": "Too many requests.",
|
||||||
|
|||||||
Reference in New Issue
Block a user