feat(core): readwise import settings ui (#10913)

close AF-2308
This commit is contained in:
CatsJuice
2025-03-20 23:20:57 +00:00
parent 48f79d6467
commit f1c8a88a7c
15 changed files with 615 additions and 25 deletions

View File

@@ -30,6 +30,9 @@ export const cardIcon = style({
fontSize: 24,
padding: 4,
lineHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const cardContent = style([
spaceY,

View File

@@ -18,7 +18,13 @@ import {
inputErrorMsg,
} from './index.css';
const ConnectDialog = ({ onClose }: { onClose: () => void }) => {
const ConnectDialog = ({
onClose,
onSuccess,
}: {
onClose: () => void;
onSuccess: (token: string) => void;
}) => {
const t = useI18n();
const [status, setStatus] = useState<'idle' | 'verifying' | 'error'>('idle');
const [token, setToken] = useState('');
@@ -43,7 +49,7 @@ const ConnectDialog = ({ onClose }: { onClose: () => void }) => {
const handleResult = useCallback(
(success: boolean, token: string) => {
if (success) {
readwise.updateSetting('token', token);
onSuccess(token);
} else {
setStatus('error');
notify.error({
@@ -54,7 +60,7 @@ const ConnectDialog = ({ onClose }: { onClose: () => void }) => {
});
}
},
[readwise, t]
[onSuccess, t]
);
const handleConnect = useAsyncCallback(
@@ -129,7 +135,11 @@ const ConnectDialog = ({ onClose }: { onClose: () => void }) => {
);
};
export const ConnectButton = () => {
export const ConnectButton = ({
onSuccess,
}: {
onSuccess: (token: string) => void;
}) => {
const t = useI18n();
const [open, setOpen] = useState(false);
@@ -143,7 +153,7 @@ export const ConnectButton = () => {
return (
<>
{open && <ConnectDialog onClose={handleClose} />}
{open && <ConnectDialog onClose={handleClose} onSuccess={onSuccess} />}
<Button variant="primary" className={actionButton} onClick={handleOpen}>
{t['com.affine.integration.readwise.connect']()}
</Button>

View File

@@ -5,7 +5,6 @@ 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 }) => {
@@ -54,23 +53,16 @@ export const DisconnectDialog = ({ onClose }: { onClose: () => void }) => {
);
};
export const ConnectedActions = () => {
export const ConnectedActions = ({ onImport }: { onImport: () => void }) => {
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)}
>
<Button className={actionButton} onClick={onImport}>
{t['com.affine.integration.readwise.import']()}
</Button>
<Button

View File

@@ -286,6 +286,12 @@ const HighlightTable = ({
useState<
Record<ReadwiseHighlight['id'], ReadwiseHighlight['updated_at']>
>();
const syncNewHighlights = useLiveData(
useMemo(() => readwise.setting$('syncNewHighlights'), [readwise])
);
const updateStrategy = useLiveData(
useMemo(() => readwise.setting$('updateStrategy'), [readwise])
);
useEffect(() => {
readwise
@@ -336,6 +342,12 @@ const HighlightTable = ({
const highlight = highlights[idx];
const localUpdatedAt = updatedMap?.[highlight.id];
const readwiseUpdatedAt = highlight.updated_at;
const action = readwise.getAction({
localUpdatedAt,
remoteUpdatedAt: readwiseUpdatedAt,
updateStrategy,
syncNewHighlights,
});
return (
<li className={styles.tableBodyRow}>
<div className={styles.tableCellSelect}>
@@ -363,11 +375,11 @@ const HighlightTable = ({
</a>
</div>
<div className={styles.tableCellTodo}>
{!localUpdatedAt ? (
{action === 'new' ? (
<span className={styles.todoNew}>
{t['com.affine.integration.readwise.import.todo-new']()}
</span>
) : localUpdatedAt === readwiseUpdatedAt ? (
) : action === 'skip' ? (
<span className={styles.todoSkip}>
{t['com.affine.integration.readwise.import.todo-skip']()}
</span>

View File

@@ -4,6 +4,7 @@ import {
} from '@affine/core/modules/integration';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import {
IntegrationCard,
@@ -13,23 +14,61 @@ import {
} from '../card';
import { ConnectButton } from './connect';
import { ConnectedActions } from './connected';
import { ImportDialog } from './import-dialog';
import { SettingDialog } from './setting-dialog';
export const ReadwiseIntegration = () => {
const t = useI18n();
const readwise = useService(IntegrationService).readwise;
const [openSetting, setOpenSetting] = useState(false);
const [openImportDialog, setOpenImportDialog] = useState(false);
const settings = useLiveData(readwise.settings$);
const token = settings?.token;
const handleOpenSetting = useCallback(() => setOpenSetting(true), []);
const handleCloseSetting = useCallback(() => setOpenSetting(false), []);
const handleConnectSuccess = useCallback(
(token: string) => {
readwise.connect(token);
handleOpenSetting();
},
[handleOpenSetting, readwise]
);
const handleImport = useCallback(() => {
setOpenSetting(false);
setOpenImportDialog(true);
}, []);
return (
<IntegrationCard>
<IntegrationCardHeader icon={<IntegrationTypeIcon type="readwise" />} />
<IntegrationCardHeader
icon={<IntegrationTypeIcon type="readwise" />}
onSettingClick={handleOpenSetting}
/>
<IntegrationCardContent
title={t['com.affine.integration.readwise.name']()}
desc={t['com.affine.integration.readwise.desc']()}
/>
<IntegrationCardFooter>
{token ? <ConnectedActions /> : <ConnectButton />}
{token ? (
<>
<ConnectedActions onImport={handleImport} />
{openSetting && (
<SettingDialog
onClose={handleCloseSetting}
onImport={handleImport}
/>
)}
{openImportDialog && (
<ImportDialog onClose={() => setOpenImportDialog(false)} />
)}
</>
) : (
<ConnectButton onSuccess={handleConnectSuccess} />
)}
</IntegrationCardFooter>
</IntegrationCard>
);

View File

@@ -0,0 +1,81 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const dialog = style({
width: 480,
maxWidth: 'calc(100vw - 32px)',
});
export const header = style({
display: 'flex',
alignItems: 'center',
gap: 8,
paddingBottom: 16,
borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
});
export const headerIcon = style({
width: 40,
height: 40,
fontSize: 30,
borderRadius: 5,
});
export const headerTitle = style({
fontSize: 15,
lineHeight: '24px',
fontWeight: 500,
color: cssVarV2.text.primary,
});
export const headerCaption = style({
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
color: cssVarV2.text.secondary,
});
export const settings = style({
display: 'flex',
flexDirection: 'column',
marginTop: 16,
gap: 8,
alignItems: 'stretch',
});
export const divider = style({
height: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'stretch',
selectors: {
'&::before': {
content: '',
width: '100%',
height: 0,
borderBottom: `0.5px solid ${cssVarV2.layer.insideBorder.border}`,
flexGrow: 1,
},
},
});
export const updateStrategyLabel = style({
fontSize: 12,
fontWeight: 500,
lineHeight: '20px',
color: cssVarV2.text.primary,
marginBottom: 8,
});
export const updateStrategyGroup = style({
overflow: 'hidden',
display: 'grid',
gridTemplateRows: '1fr',
transition:
'grid-template-rows 0.4s cubic-bezier(.07,.83,.46,1), opacity 0.4s ease',
selectors: {
'&[data-collapsed="true"]': {
gridTemplateRows: '0fr',
opacity: 0,
},
},
});
export const updateStrategyGroupContent = style({
overflow: 'hidden',
});

View File

@@ -0,0 +1,176 @@
import { Button, Modal } from '@affine/component';
import {
IntegrationService,
IntegrationTypeIcon,
} from '@affine/core/modules/integration';
import type { ReadwiseConfig } from '@affine/core/modules/integration/type';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { IntegrationCardIcon } from '../card';
import {
IntegrationSettingItem,
IntegrationSettingTextRadioGroup,
type IntegrationSettingTextRadioGroupItem,
IntegrationSettingToggle,
} from '../setting';
import * as styles from './setting-dialog.css';
export const SettingDialog = ({
onClose,
onImport,
}: {
onClose: () => void;
onImport: () => void;
}) => {
const t = useI18n();
return (
<Modal
open
onOpenChange={onClose}
contentOptions={{ className: styles.dialog }}
>
<header className={styles.header}>
<IntegrationCardIcon className={styles.headerIcon}>
<IntegrationTypeIcon type="readwise" />
</IntegrationCardIcon>
<div>
<h1 className={styles.headerTitle}>
{t['com.affine.integration.readwise.name']()}
</h1>
<p className={styles.headerCaption}>
{t['com.affine.integration.readwise.setting.caption']()}
</p>
</div>
</header>
<ul className={styles.settings}>
<NewHighlightSetting />
<Divider />
<UpdateStrategySetting />
<Divider />
<StartImport onImport={onImport} />
</ul>
</Modal>
);
};
const Divider = () => {
return <li className={styles.divider} />;
};
const NewHighlightSetting = () => {
const t = useI18n();
const readwise = useService(IntegrationService).readwise;
const syncNewHighlights = useLiveData(
useMemo(() => readwise.setting$('syncNewHighlights'), [readwise])
);
const toggle = useCallback(
(value: boolean) => {
readwise.updateSetting('syncNewHighlights', value);
},
[readwise]
);
return (
<li>
<IntegrationSettingToggle
checked={!!syncNewHighlights}
name={t['com.affine.integration.readwise.setting.sync-new-name']()}
desc={t['com.affine.integration.readwise.setting.sync-new-desc']()}
onChange={toggle}
/>
</li>
);
};
const UpdateStrategySetting = () => {
const t = useI18n();
const readwise = useService(IntegrationService).readwise;
const updateStrategy = useLiveData(
useMemo(() => readwise.setting$('updateStrategy'), [readwise])
);
const toggle = useCallback(
(value: boolean) => {
if (!value) readwise.updateSetting('updateStrategy', undefined);
else readwise.updateSetting('updateStrategy', 'append');
},
[readwise]
);
const handleUpdate = useCallback(
(value: ReadwiseConfig['updateStrategy']) => {
readwise.updateSetting('updateStrategy', value);
},
[readwise]
);
const strategies = useMemo(
() =>
[
{
name: t[
'com.affine.integration.readwise.setting.update-append-name'
](),
desc: t[
'com.affine.integration.readwise.setting.update-append-desc'
](),
value: 'append',
},
{
name: t[
'com.affine.integration.readwise.setting.update-override-name'
](),
desc: t[
'com.affine.integration.readwise.setting.update-override-desc'
](),
value: 'override',
},
] satisfies IntegrationSettingTextRadioGroupItem[],
[t]
);
return (
<>
<li>
<IntegrationSettingToggle
checked={!!updateStrategy}
name={t['com.affine.integration.readwise.setting.update-name']()}
desc={t['com.affine.integration.readwise.setting.update-desc']()}
onChange={toggle}
/>
</li>
<li
className={styles.updateStrategyGroup}
data-collapsed={!updateStrategy}
>
<div className={styles.updateStrategyGroupContent}>
<h6 className={styles.updateStrategyLabel}>
{t['com.affine.integration.readwise.setting.update-strategy']()}
</h6>
<IntegrationSettingTextRadioGroup
items={strategies}
checked={updateStrategy}
onChange={handleUpdate}
/>
</div>
</li>
</>
);
};
const StartImport = ({ onImport }: { onImport: () => void }) => {
const t = useI18n();
return (
<IntegrationSettingItem
name={t['com.affine.integration.readwise.setting.start-import-name']()}
desc={t['com.affine.integration.readwise.setting.start-import-desc']()}
>
<Button onClick={onImport}>
{t['com.affine.integration.readwise.setting.start-import-button']()}
</Button>
</IntegrationSettingItem>
);
};

View File

@@ -0,0 +1,62 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const settingItem = style({
width: '100%',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 8,
});
export const settingName = style({
fontSize: 14,
lineHeight: '22px',
fontWeight: 500,
color: cssVarV2.text.primary,
});
export const settingDesc = style({
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
color: cssVarV2.text.secondary,
});
export const textRadioGroup = style({
borderRadius: 4,
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
});
export const textRadioGroupItem = style({
padding: '8px 16px',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 8,
cursor: 'pointer',
borderBottom: `1px solid ${cssVarV2.layer.insideBorder.border}`,
selectors: {
'&:last-child': {
borderBottom: 'none',
},
},
});
export const textRadioGroupItemName = style({
fontSize: 14,
lineHeight: '22px',
fontWeight: 500,
color: cssVarV2.text.primary,
});
export const textRadioGroupItemDesc = style({
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
color: cssVarV2.text.secondary,
});
export const textRadioGroupItemCheckWrapper = style({
width: 24,
height: 24,
fontSize: 24,
color: cssVarV2.icon.activated,
flexShrink: 0,
});

View File

@@ -0,0 +1,89 @@
import { Switch } from '@affine/component';
import { DoneIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import type { HTMLAttributes, ReactNode } from 'react';
import * as styles from './setting.css';
// universal
export interface IntegrationSettingItemProps
extends HTMLAttributes<HTMLDivElement> {
name?: ReactNode;
desc?: ReactNode;
}
export const IntegrationSettingItem = ({
name,
desc,
children,
className,
...props
}: IntegrationSettingItemProps) => {
return (
<div className={clsx(styles.settingItem, className)} {...props}>
<div>
{name && <h6 className={styles.settingName}>{name}</h6>}
{desc && <p className={styles.settingDesc}>{desc}</p>}
</div>
<div>{children}</div>
</div>
);
};
// toggle
export interface IntegrationSettingToggleProps {
name: string;
desc?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export const IntegrationSettingToggle = ({
name,
desc,
checked,
onChange,
}: IntegrationSettingToggleProps) => {
return (
<IntegrationSettingItem name={name} desc={desc}>
<Switch checked={checked} onChange={onChange} />
</IntegrationSettingItem>
);
};
// text-radio-group
export interface IntegrationSettingTextRadioGroupItem {
name: string;
desc?: string;
value: any;
}
export interface IntegrationSettingTextRadioGroupProps {
items: IntegrationSettingTextRadioGroupItem[];
checked: any;
onChange: (value: any) => void;
}
export const IntegrationSettingTextRadioGroup = ({
items,
checked,
onChange,
}: IntegrationSettingTextRadioGroupProps) => {
return (
<div className={styles.textRadioGroup}>
{items.map(item => (
<div
onClick={() => onChange(item.value)}
key={item.value}
className={styles.textRadioGroupItem}
>
<div>
<div className={styles.textRadioGroupItemName}>{item.name}</div>
{item.desc && (
<div className={styles.textRadioGroupItemDesc}>{item.desc}</div>
)}
</div>
<div className={styles.textRadioGroupItemCheckWrapper}>
{checked === item.value ? <DoneIcon /> : null}
</div>
</div>
))}
</div>
);
};

View File

@@ -30,6 +30,13 @@ export class ReadwiseIntegration extends Entity<{ writer: IntegrationWriter }> {
importing$ = new LiveData(false);
settings$ = LiveData.from(this.readwiseStore.watchSetting(), undefined);
setting$<T extends keyof ReadwiseConfig>(
key: T
): LiveData<ReadwiseConfig[T]> {
return this.settings$.selector(setting => setting?.[key]);
}
updateSetting<T extends keyof ReadwiseConfig>(
key: T,
value: ReadwiseConfig[T]
@@ -77,6 +84,8 @@ export class ReadwiseIntegration extends Entity<{ writer: IntegrationWriter }> {
localRefs.map(ref => [ref.refMeta.highlightId, ref])
);
const updateStrategy = this.readwiseStore.getSetting('updateStrategy');
const syncNewHighlights =
this.readwiseStore.getSetting('syncNewHighlights');
const chunks = chunk(highlights, 2);
const total = highlights.length;
let finished = 0;
@@ -99,8 +108,14 @@ export class ReadwiseIntegration extends Entity<{ writer: IntegrationWriter }> {
const refMeta = localRef?.refMeta;
const localUpdatedAt = refMeta?.updatedAt;
const localDocId = localRef?.id;
const action = this.getAction({
localUpdatedAt,
remoteUpdatedAt: highlight.updated_at,
updateStrategy,
syncNewHighlights,
});
// write if not matched
if (localUpdatedAt !== highlight.updated_at && !signal?.aborted) {
if (action !== 'skip' && !signal?.aborted) {
await this.highlightToAffineDoc(highlight, book, localDocId, {
updateStrategy,
integrationId,
@@ -139,7 +154,7 @@ export class ReadwiseIntegration extends Entity<{ writer: IntegrationWriter }> {
title: book.title,
docId,
comment: highlight.note,
updateStrategy,
updateStrategy: updateStrategy ?? 'append',
});
// write failed
@@ -168,8 +183,43 @@ export class ReadwiseIntegration extends Entity<{ writer: IntegrationWriter }> {
});
}
getAction(info: {
localUpdatedAt?: string;
remoteUpdatedAt?: string;
updateStrategy?: ReadwiseConfig['updateStrategy'];
syncNewHighlights?: ReadwiseConfig['syncNewHighlights'];
}) {
const {
localUpdatedAt,
remoteUpdatedAt,
updateStrategy,
syncNewHighlights,
} = info;
return !localUpdatedAt
? syncNewHighlights
? 'new'
: 'skip'
: localUpdatedAt !== remoteUpdatedAt
? updateStrategy
? 'update'
: 'skip'
: 'skip';
}
connect(token: string) {
this.readwiseStore.setSettings({
token,
updateStrategy: 'append',
syncNewHighlights: true,
});
}
disconnect() {
this.readwiseStore.setSetting('token', undefined);
this.readwiseStore.setSetting('lastImportedAt', undefined);
this.readwiseStore.setSettings({
token: undefined,
updateStrategy: undefined,
syncNewHighlights: undefined,
});
}
}

View File

@@ -81,4 +81,11 @@ export class ReadwiseStore extends Store {
[key]: value,
});
}
setSettings(settings: Partial<ReadwiseConfig>) {
this.globalState.set(this.getStorageKey(), {
...this.getSetting(),
...settings,
});
}
}

View File

@@ -79,6 +79,10 @@ export interface ReadwiseConfig {
* The last import time
*/
lastImportedAt?: string;
/**
* Whether to sync new highlights
*/
syncNewHighlights?: boolean;
/**
* The update strategy
*/