diff --git a/packages/frontend/component/src/ui/loading/loading.tsx b/packages/frontend/component/src/ui/loading/loading.tsx index 237f5c5fa1..ec0ebeaff7 100644 --- a/packages/frontend/component/src/ui/loading/loading.tsx +++ b/packages/frontend/component/src/ui/loading/loading.tsx @@ -1,3 +1,4 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; @@ -9,19 +10,23 @@ export interface LoadingProps { speed?: number; progress?: number; strokeColor?: string; + strokeWidth?: number; + className?: string; } export const Loading = ({ size, speed = 1.2, progress = 0.2, + strokeWidth = 4, strokeColor, + className, }: LoadingProps) => { // allow `string` such as `16px` | `100%` | `1em` const sizeWithUnit = size ? withUnit(size, 'px') : '16px'; return ( ; case 'workspace:license': return ; + case 'workspace:integrations': + return ; 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: , testId: 'workspace-setting:members', }, + enableIntegration && { + key: 'workspace:integrations', + title: t['com.affine.integration.integrations'](), + icon: , + 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; }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.css.ts new file mode 100644 index 0000000000..38df092106 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.tsx new file mode 100644 index 0000000000..1aa83adb5a --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.tsx @@ -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) => { + return ; +}; + +export const IntegrationCardIcon = ({ + className, + ...props +}: HTMLAttributes) => { + return ; +}; + +export const IntegrationSettingIcon = ({ + className, + ...props +}: IconButtonProps) => { + return ( + } + variant="plain" + {...props} + /> + ); +}; + +export const IntegrationCardHeader = ({ + className, + icon, + onSettingClick, + ...props +}: HTMLAttributes & { + onSettingClick?: () => void; + icon?: ReactNode; +}) => { + return ( + + {icon} + + + + ); +}; + +export const IntegrationCardContent = ({ + className, + title, + desc, + ...props +}: HTMLAttributes & { + title?: string; + desc?: string; +}) => { + return ( + + {title} + {desc} + + ); +}; + +export const IntegrationCardFooter = ({ + className, + ...props +}: HTMLAttributes) => { + return ; +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.css.ts new file mode 100644 index 0000000000..f55fc60bbb --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx new file mode 100644 index 0000000000..ebe832281d --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx @@ -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 ( + <> + + {t['com.affine.integration.setting.description']()} + {/* */} + {/* {t['Learn how to develop a integration for AFFiNE']()} */} + > + } + /> + + + + > + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connect.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connect.tsx new file mode 100644 index 0000000000..fc000c6be1 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connect.tsx @@ -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) => { + 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 ( + + + + + + {t['com.affine.integration.readwise.connect.title']()} + + + + ), + }} + /> + + + + {t['com.affine.integration.readwise.connect.input-error']()} + + + + ); +}; + +export const ConnectButton = () => { + const t = useI18n(); + const [open, setOpen] = useState(false); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + return ( + <> + {open && } + + {t['com.affine.integration.readwise.connect']()} + + > + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.css.ts new file mode 100644 index 0000000000..665cd1e2f3 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.tsx new file mode 100644 index 0000000000..9b689b1a24 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.tsx @@ -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 ( + + + {t['com.affine.integration.readwise.disconnect.title']()} + + + {t['com.affine.integration.readwise.disconnect.desc']()} + + + + ); +}; + +export const ConnectedActions = () => { + const t = useI18n(); + const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); + const [showImportDialog, setShowImportDialog] = useState(false); + + return ( + <> + {showDisconnectDialog && ( + setShowDisconnectDialog(false)} /> + )} + {showImportDialog && ( + setShowImportDialog(false)} /> + )} + setShowImportDialog(true)} + > + {t['com.affine.integration.readwise.import']()} + + setShowDisconnectDialog(true)} + > + {t['com.affine.integration.readwise.disconnect']()} + + > + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/import-dialog.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/import-dialog.css.ts new file mode 100644 index 0000000000..6e1baa4bf9 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/import-dialog.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/import-dialog.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/import-dialog.tsx new file mode 100644 index 0000000000..c99e168644 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/import-dialog.tsx @@ -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(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: , + 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 ( + + {stage === 'select' ? ( + error ? ( + + ) : ( + + ) + ) : ( + + )} + + ); +}; + +const CrawlerError = ({ onRetry }: { onRetry: () => void }) => { + return ( + <> + Unexpected error occurred, please try again. + Retry + > + ); +}; + +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([]); + + 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 ( + <> + + + {t['com.affine.integration.readwise.import.title']()} + + + {lastImportedAt ? ( + + ), + }} + > + ) : ( + t['com.affine.integration.readwise.import.desc-from-start']() + )} + + + + + + {loading ? ( + + + {t['Loading']()} + + ) : highlights.length > 0 ? ( + + ) : ( + + )} + + + + > + ); +}; + +const Scroller = forwardRef(function Scroller(props, ref) { + return ( + + + + + ); +}); + +const HighlightTable = ({ + selected, + setSelected, + highlights, +}: { + selected: ReadwiseHighlight['id'][]; + setSelected: Dispatch>; + highlights: ReadwiseHighlight[]; +}) => { + const t = useI18n(); + const readwise = useService(IntegrationService).readwise; + const [updatedMap, setUpdatedMap] = + useState< + Record + >(); + + useEffect(() => { + readwise + .getRefs() + .then(refs => { + setUpdatedMap( + refs.reduce( + (acc, ref) => { + acc[ref.refMeta.highlightId] = ref.refMeta.updatedAt; + return acc; + }, + {} as Record + ) + ); + }) + .catch(console.error); + }, [readwise]); + + const handleToggleSelectAll = useCallback(() => { + setSelected(prev => + prev.length === highlights.length ? [] : highlights.map(h => h.id) + ); + }, [highlights, setSelected]); + + return ( + + + + + + + {t['com.affine.integration.readwise.import.cell-h-content']()} + + + {t['com.affine.integration.readwise.import.cell-h-todo']()} + + + {t['com.affine.integration.readwise.import.cell-h-time']()} + + + { + const highlight = highlights[idx]; + const localUpdatedAt = updatedMap?.[highlight.id]; + const readwiseUpdatedAt = highlight.updated_at; + return ( + + + { + setSelected(prev => { + if (prev.includes(highlight.id)) { + return prev.filter(id => id !== highlight.id); + } else { + return [...prev, highlight.id]; + } + }); + }} + /> + + + + {highlight.text} + + + + {!localUpdatedAt ? ( + + {t['com.affine.integration.readwise.import.todo-new']()} + + ) : localUpdatedAt === readwiseUpdatedAt ? ( + + {t['com.affine.integration.readwise.import.todo-skip']()} + + ) : ( + + {t['com.affine.integration.readwise.import.todo-update']()} + + )} + + + {i18nTime(readwiseUpdatedAt, { + absolute: { accuracy: 'second' }, + })} + + + ); + }} + components={{ Scroller }} + /> + + ); +}; +const HighlightEmpty = () => { + const t = useI18n(); + return ( + + {t['com.affine.integration.readwise.import.empty']()} + + ); +}; + +const WritingStage = ({ + progress, + onStop, +}: { + progress: number; + onStop: () => void; +}) => { + const t = useI18n(); + return ( + <> + + + + {t['com.affine.integration.readwise.import.importing']()} + + + + + {t['com.affine.integration.readwise.import.importing-desc']()} + + + + > + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/index.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/index.css.ts new file mode 100644 index 0000000000..a43852cf17 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/index.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/index.tsx new file mode 100644 index 0000000000..80a4ec8b02 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/index.tsx @@ -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 ( + + } /> + + + {token ? : } + + + ); +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page/all-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-page/all-page.tsx index 1acb9b51cb..315e6c0b44 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/all-page/all-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/all-page/all-page.tsx @@ -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([]); const filteredPageMetas = useFilteredPageMetas(pageMetas, { @@ -54,6 +57,10 @@ export const AllPage = () => { const t = useI18n(); + if (importing) { + return null; + } + return ( <> diff --git a/packages/frontend/core/src/modules/db/schema/schema.ts b/packages/frontend/core/src/modules/db/schema/schema.ts index 746dd75bb0..cb5222e480 100644 --- a/packages/frontend/core/src/modules/db/schema/schema.ts +++ b/packages/frontend/core/src/modules/db/schema/schema.ts @@ -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; - 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'] +>; diff --git a/packages/frontend/core/src/modules/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts index 7509ce801d..02173d069e 100644 --- a/packages/frontend/core/src/modules/dialogs/constant.ts +++ b/packages/frontend/core/src/modules/dialogs/constant.ts @@ -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 }) => { diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 6ca45fc6ff..eb5f920822 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -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 diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 0311f0795c..fc2a790b17 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -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); } diff --git a/packages/frontend/core/src/modules/integration/constant.ts b/packages/frontend/core/src/modules/integration/constant.ts new file mode 100644 index 0000000000..274be6b1be --- /dev/null +++ b/packages/frontend/core/src/modules/integration/constant.ts @@ -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 = { + readwise: 'com.affine.integration.name.readwise', + zotero: 'Zotero', +}; + +// schema +export const INTEGRATION_PROPERTY_SCHEMA: { + [T in IntegrationType]: Record>; +} = { + 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> +> = { + readwise: ReadwiseLogoDuotoneIcon, + zotero: () => null, +}; diff --git a/packages/frontend/core/src/modules/integration/entities/readwise-crawler.ts b/packages/frontend/core/src/modules/integration/entities/readwise-crawler.ts new file mode 100644 index 0000000000..e764cc6f3f --- /dev/null +++ b/packages/frontend/core/src/modules/integration/entities/readwise-crawler.ts @@ -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({ + highlights: [], + books: {}, + complete: false, + }); + public error$ = new LiveData(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(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(); + } +} diff --git a/packages/frontend/core/src/modules/integration/entities/readwise.ts b/packages/frontend/core/src/modules/integration/entities/readwise.ts new file mode 100644 index 0000000000..9bb467e7ff --- /dev/null +++ b/packages/frontend/core/src/modules/integration/entities/readwise.ts @@ -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( + 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, + 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); + } +} diff --git a/packages/frontend/core/src/modules/integration/entities/writer.ts b/packages/frontend/core/src/modules/integration/entities/writer.ts new file mode 100644 index 0000000000..3ad501a7bc --- /dev/null +++ b/packages/frontend/core/src/modules/integration/entities/writer.ts @@ -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; + } + } +} diff --git a/packages/frontend/core/src/modules/integration/index.ts b/packages/frontend/core/src/modules/integration/index.ts new file mode 100644 index 0000000000..e844fd3997 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/index.ts @@ -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]); +} diff --git a/packages/frontend/core/src/modules/integration/services/integration-property.ts b/packages/frontend/core/src/modules/integration/services/integration-property.ts new file mode 100644 index 0000000000..99a9f221c9 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/services/integration-property.ts @@ -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; + } + + updateIntegrationProperties( + type: T, + updates: Partial + ) { + this.docService.doc.updateProperties({ + integrationType: type, + ...Object.fromEntries( + Object.entries(updates).map(([key, value]) => { + return [`${type}:${key.toString()}`, value]; + }) + ), + }); + } +} diff --git a/packages/frontend/core/src/modules/integration/services/integration.ts b/packages/frontend/core/src/modules/integration/services/integration.ts new file mode 100644 index 0000000000..93fdfac5ec --- /dev/null +++ b/packages/frontend/core/src/modules/integration/services/integration.ts @@ -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$); + }); +} diff --git a/packages/frontend/core/src/modules/integration/store/integration-ref.ts b/packages/frontend/core/src/modules/integration/store/integration-ref.ts new file mode 100644 index 0000000000..f58682a8c9 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/store/integration-ref.ts @@ -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[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) { + return this.userDB.docIntegrationRef.create({ + id: docId, + ...config, + }); + } + + updateRef(docId: string, config: Partial) { + return this.userDB.docIntegrationRef.update(docId, config); + } + + deleteRef(docId: string) { + return this.userDB.docIntegrationRef.delete(docId); + } +} diff --git a/packages/frontend/core/src/modules/integration/store/readwise.ts b/packages/frontend/core/src/modules/integration/store/readwise.ts new file mode 100644 index 0000000000..b767da64e8 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/store/readwise.ts @@ -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(storageKey); + }) + ); + } + + getSetting(): ReadwiseConfig | undefined; + getSetting( + key: Key + ): ReadwiseConfig[Key] | undefined; + getSetting(key?: keyof ReadwiseConfig) { + const config = this.globalState.get(this.getStorageKey()); + if (!key) return config; + return config?.[key]; + } + + setSetting( + key: Key, + value: ReadwiseConfig[Key] + ) { + this.globalState.set(this.getStorageKey(), { + ...this.getSetting(), + [key]: value, + }); + } +} diff --git a/packages/frontend/core/src/modules/integration/type.ts b/packages/frontend/core/src/modules/integration/type.ts new file mode 100644 index 0000000000..558d2d3c40 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/type.ts @@ -0,0 +1,87 @@ +import type { I18nString } from '@affine/i18n'; + +import type { DocIntegrationRef } from '../db/schema/schema'; + +export type IntegrationType = NonNullable; + +export type IntegrationDocPropertiesMap = { + readwise: ReadwiseDocProperties; + zotero: never; +}; + +export type IntegrationProperty = { + 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 & + ReadwiseHighlight; + +export type ReadwiseBookMap = Record< + ReadwiseBook['user_book_id'], + Omit +>; +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 diff --git a/packages/frontend/core/src/modules/integration/utils/encrypt.ts b/packages/frontend/core/src/modules/integration/utils/encrypt.ts new file mode 100644 index 0000000000..68978f6055 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/utils/encrypt.ts @@ -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(''); +} diff --git a/packages/frontend/core/src/modules/integration/views/icon.tsx b/packages/frontend/core/src/modules/integration/views/icon.tsx new file mode 100644 index 0000000000..ae9a32fa31 --- /dev/null +++ b/packages/frontend/core/src/modules/integration/views/icon.tsx @@ -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) => { + const Icon = INTEGRATION_ICON_MAP[type]; + + return ; +}; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index a4732b8232..5546e8cf8c 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,26 +1,26 @@ { - "ar": 98, + "ar": 96, "ca": 4, "da": 5, - "de": 98, - "el-GR": 98, + "de": 97, + "el-GR": 96, "en": 100, - "es-AR": 98, - "es-CL": 100, - "es": 98, - "fa": 98, - "fr": 98, + "es-AR": 97, + "es-CL": 98, + "es": 96, + "fa": 96, + "fr": 96, "hi": 2, - "it-IT": 98, + "it-IT": 97, "it": 1, - "ja": 98, - "ko": 62, - "pl": 98, - "pt-BR": 98, - "ru": 98, - "sv-SE": 98, - "uk": 98, + "ja": 96, + "ko": 61, + "pl": 96, + "pt-BR": 96, + "ru": 96, + "sv-SE": 96, + "uk": 96, "ur": 2, - "zh-Hans": 98, - "zh-Hant": 98 + "zh-Hans": 96, + "zh-Hant": 96 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 4740bd9eed..7969d0e67f 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -7117,6 +7117,144 @@ export function useAFFiNEI18N(): { * `Please contact your workspace owner to add more seats.` */ ["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.` */ @@ -8119,4 +8257,18 @@ export const TypedTrans: { ["1"]: JSX.Element; ["2"]: JSX.Element; }>>; + /** + * `Import your Readwise highlights to AFFiNE. Please visit Readwise, click "Get Access Token", and paste the token below.` + */ + ["com.affine.integration.readwise.connect.desc"]: ComponentType, { + a: JSX.Element; + }>>; + /** + * `Updates to be imported since last successful import on {{lastImportedAt}} Import everything instead` + */ + ["com.affine.integration.readwise.import.desc-from-last"]: ComponentType>; } = /*#__PURE__*/ createProxy(createComponent); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 3e8c6fe83d..77ecd5ab9f 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1771,6 +1771,42 @@ "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-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 \"Get Access Token\", 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}} Import everything instead", + "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.NETWORK_ERROR": "Network error.", "error.TOO_MANY_REQUEST": "Too many requests.",