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:
CatsJuice
2025-03-18 08:13:57 +00:00
parent ef00a158fc
commit ff8c3d1cee
33 changed files with 2308 additions and 25 deletions

View File

@@ -1,11 +1,13 @@
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { ServerService } from '@affine/core/modules/cloud';
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { ServerDeploymentType } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import {
CollaborationIcon,
IntegrationsIcon,
PaymentIcon,
PropertyIcon,
SaveIcon,
@@ -16,6 +18,7 @@ import { useMemo } from 'react';
import type { SettingSidebarItem, SettingState } from '../types';
import { WorkspaceSettingBilling } from './billing';
import { IntegrationSetting } from './integration';
import { WorkspaceSettingLicense } from './license';
import { MembersPanel } from './members';
import { WorkspaceSettingDetail } from './preference';
@@ -49,6 +52,8 @@ export const WorkspaceSetting = ({
return <WorkspaceSettingStorage onCloseSetting={onCloseSetting} />;
case 'workspace:license':
return <WorkspaceSettingLicense onCloseSetting={onCloseSetting} />;
case 'workspace:integrations':
return <IntegrationSetting />;
default:
return null;
}
@@ -58,6 +63,11 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
const workspaceService = useService(WorkspaceService);
const information = useWorkspaceInfo(workspaceService.workspace);
const serverService = useService(ServerService);
const featureFlagService = useService(FeatureFlagService);
const enableIntegration = useLiveData(
featureFlagService.flags.enable_integration.$
);
const isSelfhosted = useLiveData(
serverService.server.config$.selector(
@@ -90,6 +100,12 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
icon: <CollaborationIcon />,
testId: 'workspace-setting:members',
},
enableIntegration && {
key: 'workspace:integrations',
title: t['com.affine.integration.integrations'](),
icon: <IntegrationsIcon />,
testId: 'workspace-setting:integrations',
},
{
key: 'workspace:storage',
title: t['Storage'](),
@@ -109,7 +125,7 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
testId: 'workspace-setting:license',
},
].filter((item): item is SettingSidebarItem => !!item);
}, [showBilling, showLicense, t]);
}, [enableIntegration, showBilling, showLicense, t]);
return items;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { IntegrationService } from '@affine/core/modules/integration';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Filter } from '@affine/env/filter';
@@ -29,10 +30,12 @@ export const AllPage = () => {
const currentWorkspace = useService(WorkspaceService).workspace;
const globalContext = useService(GlobalContextService).globalContext;
const permissionService = useService(WorkspacePermissionService);
const integrationService = useService(IntegrationService);
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const importing = useLiveData(integrationService.importing$);
const [filters, setFilters] = useState<Filter[]>([]);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
@@ -54,6 +57,10 @@ export const AllPage = () => {
const t = useI18n();
if (importing) {
return null;
}
return (
<>
<ViewTitle title={t['All pages']()} />

View File

@@ -6,6 +6,8 @@ import {
} from '@toeverything/infra';
import { nanoid } from 'nanoid';
const integrationType = f.enum('readwise', 'zotero');
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
folders: {
id: f.string().primaryKey().optional().default(nanoid),
@@ -22,6 +24,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
journal: f.string().optional(),
pageWidth: f.string().optional(),
isTemplate: f.boolean().optional(),
integrationType: integrationType.optional(),
}),
docCustomPropertyInfo: {
id: f.string().primaryKey().optional().default(nanoid),
@@ -38,7 +41,6 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
export type DocCustomPropertyInfo = ORMEntity<
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
>;
@@ -52,6 +54,20 @@ export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
key: f.string().primaryKey(),
value: f.json(),
},
docIntegrationRef: {
// docId as primary key
id: f.string().primaryKey(),
type: integrationType,
/**
* Identify **affine user** and **integration type** and **integration account**
* Used to quickly find user's all integrations
*/
integrationId: f.string(),
refMeta: f.json(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNEWorkspaceUserdataDbSchema =
typeof AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA;
export type DocIntegrationRef = ORMEntity<
AFFiNEWorkspaceUserdataDbSchema['docIntegrationRef']
>;

View File

@@ -12,7 +12,7 @@ export type SettingTab =
| 'experimental-features'
| 'editor'
| 'account'
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license'}`;
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations'}`;
export type GLOBAL_DIALOG_SCHEMA = {
'create-workspace': (props: { serverId?: string }) => {

View File

@@ -266,6 +266,13 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: false,
},
enable_integration: {
category: 'affine',
displayName: 'Enable Integration',
description: 'Enable Integration',
configurable: isCanaryBuild,
defaultState: false,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare

View File

@@ -28,6 +28,7 @@ import { configureGlobalContextModule } from './global-context';
import { configureI18nModule } from './i18n';
import { configureImportClipperModule } from './import-clipper';
import { configureImportTemplateModule } from './import-template';
import { configureIntegrationModule } from './integration';
import { configureJournalModule } from './journal';
import { configureLifecycleModule } from './lifecycle';
import { configureNavigationModule } from './navigation';
@@ -104,4 +105,5 @@ export function configureCommonModules(framework: Framework) {
configureBlobManagementModule(framework);
configureImportClipperModule(framework);
configureNotificationModule(framework);
configureIntegrationModule(framework);
}

View 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,
};

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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