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
@@ -6,6 +6,8 @@ import {
} from '@toeverything/infra';
import { nanoid } from 'nanoid';
const integrationType = f.enum('readwise', 'zotero');
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
folders: {
id: f.string().primaryKey().optional().default(nanoid),
@@ -22,6 +24,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
journal: f.string().optional(),
pageWidth: f.string().optional(),
isTemplate: f.boolean().optional(),
integrationType: integrationType.optional(),
}),
docCustomPropertyInfo: {
id: f.string().primaryKey().optional().default(nanoid),
@@ -38,7 +41,6 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
export type DocCustomPropertyInfo = ORMEntity<
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
>;
@@ -52,6 +54,20 @@ export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
key: f.string().primaryKey(),
value: f.json(),
},
docIntegrationRef: {
// docId as primary key
id: f.string().primaryKey(),
type: integrationType,
/**
* Identify **affine user** and **integration type** and **integration account**
* Used to quickly find user's all integrations
*/
integrationId: f.string(),
refMeta: f.json(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNEWorkspaceUserdataDbSchema =
typeof AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA;
export type DocIntegrationRef = ORMEntity<
AFFiNEWorkspaceUserdataDbSchema['docIntegrationRef']
>;
@@ -12,7 +12,7 @@ export type SettingTab =
| 'experimental-features'
| 'editor'
| 'account'
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license'}`;
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations'}`;
export type GLOBAL_DIALOG_SCHEMA = {
'create-workspace': (props: { serverId?: string }) => {
@@ -266,6 +266,13 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: false,
},
enable_integration: {
category: 'affine',
displayName: 'Enable Integration',
description: 'Enable Integration',
configurable: isCanaryBuild,
defaultState: false,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare
@@ -28,6 +28,7 @@ import { configureGlobalContextModule } from './global-context';
import { configureI18nModule } from './i18n';
import { configureImportClipperModule } from './import-clipper';
import { configureImportTemplateModule } from './import-template';
import { configureIntegrationModule } from './integration';
import { configureJournalModule } from './journal';
import { configureLifecycleModule } from './lifecycle';
import { configureNavigationModule } from './navigation';
@@ -104,4 +105,5 @@ export function configureCommonModules(framework: Framework) {
configureBlobManagementModule(framework);
configureImportClipperModule(framework);
configureNotificationModule(framework);
configureIntegrationModule(framework);
}
@@ -0,0 +1,39 @@
import type { I18nString } from '@affine/i18n';
import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc';
import type { SVGProps } from 'react';
import type { IntegrationProperty, IntegrationType } from './type';
// name
export const INTEGRATION_TYPE_NAME_MAP: Record<IntegrationType, I18nString> = {
readwise: 'com.affine.integration.name.readwise',
zotero: 'Zotero',
};
// schema
export const INTEGRATION_PROPERTY_SCHEMA: {
[T in IntegrationType]: Record<string, IntegrationProperty<T>>;
} = {
readwise: {
author: {
label: 'com.affine.integration.readwise-prop.author',
key: 'author',
type: 'text',
},
source: {
label: 'com.affine.integration.readwise-prop.source',
key: 'readwise_url',
type: 'source',
},
},
zotero: {},
};
// icon
export const INTEGRATION_ICON_MAP: Record<
IntegrationType,
React.ComponentType<SVGProps<SVGSVGElement>>
> = {
readwise: ReadwiseLogoDuotoneIcon,
zotero: () => null,
};
@@ -0,0 +1,175 @@
import {
effect,
Entity,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { catchError, EMPTY, mergeMap, Observable, switchMap } from 'rxjs';
import type { ReadwiseStore } from '../store/readwise';
import type {
ReadwiseBook,
ReadwiseBookMap,
ReadwiseCrawlingData,
ReadwiseHighlight,
ReadwiseResponse,
} from '../type';
export class ReadwiseCrawler extends Entity {
public crawling$ = new LiveData(false);
public data$ = new LiveData<ReadwiseCrawlingData | null>({
highlights: [],
books: {},
complete: false,
});
public error$ = new LiveData<Error | null>(null);
constructor(private readonly readwiseStore: ReadwiseStore) {
super();
}
private authHeaders(token: string) {
return { Authorization: `Token ${token}` };
}
private async fetchHighlights(options: {
token: string;
lastImportedAt?: string;
pageCursor?: string;
signal?: AbortSignal;
}) {
const queryParams = new URLSearchParams();
if (options.pageCursor) {
queryParams.set('pageCursor', options.pageCursor);
}
if (options.lastImportedAt) {
queryParams.set('updatedAfter', options.lastImportedAt);
}
const response = await fetch(
'https://readwise.io/api/v2/export/?' + queryParams.toString(),
{
method: 'GET',
headers: this.authHeaders(options.token),
signal: options.signal,
}
);
const responseJson = (await response.json()) as ReadwiseResponse;
const highlights: ReadwiseHighlight[] = [];
const books: ReadwiseBookMap = {};
highlights.push(
...responseJson.results.flatMap((book: ReadwiseBook) => book.highlights)
);
responseJson.results.forEach((book: ReadwiseBook) => {
if (books[book.user_book_id]) return;
const { highlights: _, ...copy } = book;
books[book.user_book_id] = copy;
});
return { highlights, books, nextPageCursor: responseJson.nextPageCursor };
}
async verifyToken(token: string) {
const response = await fetch('https://readwise.io/api/v2/auth/', {
method: 'GET',
headers: this.authHeaders(token),
});
return !!(response.ok && response.status === 204);
}
crawl = effect(
switchMap(() => {
return new Observable<ReadwiseCrawlingData>(sub => {
const token = this.readwiseStore.getSetting('token');
const lastImportedAt = this.readwiseStore.getSetting('lastImportedAt');
if (!token) {
throw new Error('Readwise token is required to crawl highlights');
}
const allHighlights: ReadwiseHighlight[] = [];
const allBooks: ReadwiseBookMap = {};
const startTime = new Date().toISOString();
let abortController: AbortController | null = null;
const fetchNextPage = (pageCursor?: number) => {
abortController = new AbortController();
this.fetchHighlights({
token,
lastImportedAt,
pageCursor: pageCursor?.toString(),
signal: abortController.signal,
})
.then(({ highlights, books, nextPageCursor }) => {
allHighlights.push(...highlights);
Object.assign(allBooks, books);
const complete = !nextPageCursor;
sub.next({
highlights: allHighlights,
books: allBooks,
complete,
startTime,
});
if (!complete) {
fetchNextPage(nextPageCursor);
} else {
sub.complete();
}
})
.catch(error => sub.error(error));
};
fetchNextPage();
return () => {
abortController?.abort();
};
}).pipe(
mergeMap(data => {
this.data$.next(data);
return EMPTY;
}),
catchError(err => {
this.error$.next(err);
return EMPTY;
}),
onStart(() => {
// reset state
this.crawling$.next(true);
this.data$.next({
highlights: [],
books: {},
complete: false,
});
this.error$.next(null);
}),
onComplete(() => {
this.crawling$.next(false);
})
);
})
);
abort() {
this.crawl.reset();
}
reset() {
this.abort();
this.crawling$.next(false);
this.data$.next({
highlights: [],
books: {},
complete: false,
});
}
override dispose(): void {
super.dispose();
this.crawl.unsubscribe();
}
}
@@ -0,0 +1,175 @@
import { Entity, LiveData } from '@toeverything/infra';
import { chunk } from 'lodash-es';
import type { DocsService } from '../../doc';
import { IntegrationPropertyService } from '../services/integration-property';
import type { IntegrationRefStore } from '../store/integration-ref';
import type { ReadwiseStore } from '../store/readwise';
import type {
ReadwiseBook,
ReadwiseBookMap,
ReadwiseConfig,
ReadwiseHighlight,
ReadwiseRefMeta,
} from '../type';
import { encryptPBKDF2 } from '../utils/encrypt';
import { ReadwiseCrawler } from './readwise-crawler';
import type { IntegrationWriter } from './writer';
export class ReadwiseIntegration extends Entity<{ writer: IntegrationWriter }> {
writer = this.props.writer;
crawler = this.framework.createEntity(ReadwiseCrawler);
constructor(
private readonly integrationRefStore: IntegrationRefStore,
private readonly readwiseStore: ReadwiseStore,
private readonly docsService: DocsService
) {
super();
}
importing$ = new LiveData(false);
settings$ = LiveData.from(this.readwiseStore.watchSetting(), undefined);
updateSetting<T extends keyof ReadwiseConfig>(
key: T,
value: ReadwiseConfig[T]
) {
this.readwiseStore.setSetting(key, value);
}
/**
* Get all integration metas of current user & token in current workspace
*/
async getRefs() {
const token = this.readwiseStore.getSetting('token');
if (!token) return [];
const integrationId = await encryptPBKDF2(token);
return this.integrationRefStore
.getRefs({ type: 'readwise', integrationId })
.map(ref => ({
...ref,
refMeta: ref.refMeta as ReadwiseRefMeta,
}));
}
async highlightsToAffineDocs(
highlights: ReadwiseHighlight[],
books: ReadwiseBookMap,
options: {
signal?: AbortSignal;
onProgress?: (progress: number) => void;
onComplete?: () => void;
onAbort?: (finished: number) => void;
}
) {
this.importing$.next(true);
const disposables: (() => void)[] = [];
try {
const { signal, onProgress, onComplete, onAbort } = options;
const integrationId = await encryptPBKDF2(
this.readwiseStore.getSetting('token') ?? ''
);
const userId = this.readwiseStore.getUserId();
const localRefs = await this.getRefs();
const localRefsMap = new Map(
localRefs.map(ref => [ref.refMeta.highlightId, ref])
);
const updateStrategy = this.readwiseStore.getSetting('updateStrategy');
const chunks = chunk(highlights, 2);
const total = highlights.length;
let finished = 0;
for (const chunk of chunks) {
if (signal?.aborted) {
disposables.forEach(d => d());
this.importing$.next(false);
onAbort?.(finished);
return;
}
await Promise.all(
chunk.map(async highlight => {
await new Promise(resolve => {
const id = requestIdleCallback(resolve, { timeout: 500 });
disposables.push(() => cancelIdleCallback(id));
});
const book = books[highlight.book_id];
const localRef = localRefsMap.get(highlight.id);
const refMeta = localRef?.refMeta;
const localUpdatedAt = refMeta?.updatedAt;
const localDocId = localRef?.id;
// write if not matched
if (localUpdatedAt !== highlight.updated_at && !signal?.aborted) {
await this.highlightToAffineDoc(highlight, book, localDocId, {
updateStrategy,
integrationId,
userId,
});
}
finished++;
onProgress?.(finished / total);
})
);
}
onComplete?.();
} catch (err) {
console.error('Failed to import readwise highlights', err);
} finally {
disposables.forEach(d => d());
this.importing$.next(false);
}
}
async highlightToAffineDoc(
highlight: ReadwiseHighlight,
book: Omit<ReadwiseBook, 'highlights'>,
docId: string | undefined,
options: {
integrationId: string;
userId: string;
updateStrategy?: ReadwiseConfig['updateStrategy'];
}
) {
const { updateStrategy, integrationId } = options;
const { text, ...highlightWithoutText } = highlight;
const writtenDocId = await this.writer.writeDoc({
content: text,
title: book.title,
docId,
comment: highlight.note,
updateStrategy,
});
// write failed
if (!writtenDocId) return;
const { doc, release } = this.docsService.open(writtenDocId);
const integrationPropertyService = doc.scope.get(
IntegrationPropertyService
);
// write doc properties
integrationPropertyService.updateIntegrationProperties('readwise', {
...highlightWithoutText,
...book,
});
release();
// update integration ref
this.integrationRefStore.createRef(doc.id, {
type: 'readwise',
integrationId,
refMeta: {
highlightId: highlight.id,
updatedAt: highlight.updated_at,
},
});
}
disconnect() {
this.readwiseStore.setSetting('token', undefined);
this.readwiseStore.setSetting('lastImportedAt', undefined);
}
}
@@ -0,0 +1,94 @@
import { MarkdownTransformer } from '@blocksuite/affine/blocks/root';
import { Entity } from '@toeverything/infra';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceService,
} from '../../workspace';
export class IntegrationWriter extends Entity {
constructor(private readonly workspaceService: WorkspaceService) {
super();
}
public async writeDoc(options: {
/**
* Title of the doc
*/
title?: string;
/**
* Markdown string
*/
content: string;
/**
* Comment of the markdown content
*/
comment?: string | null;
/**
* Doc id, if not provided, a new doc will be created
*/
docId?: string;
/**
* Update strategy, default is `override`
*/
updateStrategy?: 'override' | 'append';
}) {
const {
title,
content,
comment,
docId,
updateStrategy = 'override',
} = options;
const workspace = this.workspaceService.workspace;
let markdown = comment ? `${content}\n---\n${comment}` : content;
if (!docId) {
const newDocId = await MarkdownTransformer.importMarkdownToDoc({
collection: workspace.docCollection,
schema: getAFFiNEWorkspaceSchema(),
markdown,
fileName: title,
});
return newDocId;
} else {
const collection = workspace.docCollection;
const doc = collection.getDoc(docId);
if (!doc) throw new Error('Doc not found');
doc.workspace.meta.setDocMeta(docId, {
updatedDate: Date.now(),
});
if (updateStrategy === 'override') {
const pageBlock = doc.getBlocksByFlavour('affine:page')[0];
// remove all children of the page block
pageBlock.model.children.forEach(child => {
doc.deleteBlock(child);
});
// add a new note block
const noteBlockId = doc.addBlock('affine:note', {}, pageBlock.id);
// import the markdown to the note block
await MarkdownTransformer.importMarkdownToBlock({
doc,
blockId: noteBlockId,
markdown,
});
} else if (updateStrategy === 'append') {
const pageBlockId = doc.getBlocksByFlavour('affine:page')[0]?.id;
const blockId = doc.addBlock('affine:note', {}, pageBlockId);
await MarkdownTransformer.importMarkdownToBlock({
doc,
blockId,
markdown: `---\n${markdown}`,
});
} else {
throw new Error('Invalid update strategy');
}
return doc.id;
}
}
}
@@ -0,0 +1,38 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { WorkspaceDBService } from '../db';
import { DocScope, DocService, DocsService } from '../doc';
import { GlobalState } from '../storage';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { ReadwiseIntegration } from './entities/readwise';
import { ReadwiseCrawler } from './entities/readwise-crawler';
import { IntegrationWriter } from './entities/writer';
import { IntegrationService } from './services/integration';
import { IntegrationPropertyService } from './services/integration-property';
import { IntegrationRefStore } from './store/integration-ref';
import { ReadwiseStore } from './store/readwise';
export { IntegrationService };
export { IntegrationTypeIcon } from './views/icon';
export function configureIntegrationModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.store(IntegrationRefStore, [WorkspaceDBService, DocsService])
.store(ReadwiseStore, [
GlobalState,
WorkspaceService,
WorkspaceServerService,
])
.service(IntegrationService)
.entity(IntegrationWriter, [WorkspaceService])
.entity(ReadwiseCrawler, [ReadwiseStore])
.entity(ReadwiseIntegration, [
IntegrationRefStore,
ReadwiseStore,
DocsService,
])
.scope(DocScope)
.service(IntegrationPropertyService, [DocService]);
}
@@ -0,0 +1,38 @@
import { type LiveData, Service } from '@toeverything/infra';
import type { DocService } from '../../doc';
import { INTEGRATION_PROPERTY_SCHEMA } from '../constant';
import type { IntegrationDocPropertiesMap, IntegrationType } from '../type';
export class IntegrationPropertyService extends Service {
constructor(private readonly docService: DocService) {
super();
}
schema$ = this.docService.doc.properties$
.selector(p => p.integrationType)
.map(type => (type ? INTEGRATION_PROPERTY_SCHEMA[type] : null));
integrationProperty$<
T extends IntegrationType,
Key extends keyof IntegrationDocPropertiesMap[T],
>(type: T, key: Key) {
return this.docService.doc.properties$.selector(
p => p[`${type}:${key.toString()}`]
) as LiveData<IntegrationDocPropertiesMap[T][Key] | undefined | null>;
}
updateIntegrationProperties<T extends IntegrationType>(
type: T,
updates: Partial<IntegrationDocPropertiesMap[T]>
) {
this.docService.doc.updateProperties({
integrationType: type,
...Object.fromEntries(
Object.entries(updates).map(([key, value]) => {
return [`${type}:${key.toString()}`, value];
})
),
});
}
}
@@ -0,0 +1,19 @@
import { LiveData, Service } from '@toeverything/infra';
import { ReadwiseIntegration } from '../entities/readwise';
import { IntegrationWriter } from '../entities/writer';
export class IntegrationService extends Service {
writer = this.framework.createEntity(IntegrationWriter);
readwise = this.framework.createEntity(ReadwiseIntegration, {
writer: this.writer,
});
constructor() {
super();
}
importing$ = LiveData.computed(get => {
return get(this.readwise.importing$);
});
}
@@ -0,0 +1,47 @@
import { Store } from '@toeverything/infra';
import type { WorkspaceDBService } from '../../db';
import type { DocIntegrationRef } from '../../db/schema/schema';
import type { DocsService } from '../../doc';
export class IntegrationRefStore extends Store {
constructor(
private readonly dbService: WorkspaceDBService,
private readonly docsService: DocsService
) {
super();
}
get userDB() {
return this.dbService.userdataDB$.value.db;
}
get allDocsMap() {
return this.docsService.list.docsMap$.value;
}
getRefs(where: Parameters<typeof this.userDB.docIntegrationRef.find>[0]) {
const refs = this.userDB.docIntegrationRef.find({
...where,
});
return refs.filter(ref => {
const docExists = this.allDocsMap.has(ref.id);
if (!docExists) this.deleteRef(ref.id);
return docExists;
});
}
createRef(docId: string, config: Omit<DocIntegrationRef, 'id'>) {
return this.userDB.docIntegrationRef.create({
id: docId,
...config,
});
}
updateRef(docId: string, config: Partial<DocIntegrationRef>) {
return this.userDB.docIntegrationRef.update(docId, config);
}
deleteRef(docId: string) {
return this.userDB.docIntegrationRef.delete(docId);
}
}
@@ -0,0 +1,84 @@
import { LiveData, Store } from '@toeverything/infra';
import { exhaustMap } from 'rxjs';
import { AuthService, type WorkspaceServerService } from '../../cloud';
import type { GlobalState } from '../../storage';
import type { WorkspaceService } from '../../workspace';
import { type ReadwiseConfig } from '../type';
export class ReadwiseStore extends Store {
constructor(
private readonly globalState: GlobalState,
private readonly workspaceService: WorkspaceService,
private readonly workspaceServerService: WorkspaceServerService
) {
super();
}
private _getKey({
userId,
workspaceId,
}: {
userId: string;
workspaceId: string;
}) {
return `readwise:${userId}:${workspaceId}`;
}
authService = this.workspaceServerService.server?.scope.get(AuthService);
workspaceId = this.workspaceService.workspace.id;
userId$ =
this.workspaceService.workspace.meta.flavour === 'local' ||
!this.authService
? new LiveData('__local__')
: this.authService.session.account$.map(
account => account?.id ?? '__local__'
);
getUserId() {
return this.workspaceService.workspace.meta.flavour === 'local' ||
!this.authService
? '__local__'
: (this.authService.session.account$.value?.id ?? '__local__');
}
storageKey$() {
const workspaceId = this.workspaceService.workspace.id;
return this.userId$.map(userId => this._getKey({ userId, workspaceId }));
}
getStorageKey() {
const userId = this.getUserId();
const workspaceId = this.workspaceService.workspace.id;
return this._getKey({ userId, workspaceId });
}
watchSetting() {
return this.storageKey$().pipe(
exhaustMap(storageKey => {
return this.globalState.watch<ReadwiseConfig>(storageKey);
})
);
}
getSetting(): ReadwiseConfig | undefined;
getSetting<Key extends keyof ReadwiseConfig>(
key: Key
): ReadwiseConfig[Key] | undefined;
getSetting(key?: keyof ReadwiseConfig) {
const config = this.globalState.get<ReadwiseConfig>(this.getStorageKey());
if (!key) return config;
return config?.[key];
}
setSetting<Key extends keyof ReadwiseConfig>(
key: Key,
value: ReadwiseConfig[Key]
) {
this.globalState.set(this.getStorageKey(), {
...this.getSetting(),
[key]: value,
});
}
}
@@ -0,0 +1,87 @@
import type { I18nString } from '@affine/i18n';
import type { DocIntegrationRef } from '../db/schema/schema';
export type IntegrationType = NonNullable<DocIntegrationRef['type']>;
export type IntegrationDocPropertiesMap = {
readwise: ReadwiseDocProperties;
zotero: never;
};
export type IntegrationProperty<T extends IntegrationType> = {
key: keyof IntegrationDocPropertiesMap[T];
label?: I18nString;
type: 'link' | 'text' | 'date' | 'source';
};
// ===============================
// Readwise
// ===============================
export interface ReadwiseResponse {
count: number;
nextPageCursor: number | null;
results: ReadwiseBook[];
}
export interface ReadwiseCrawlingData {
highlights: ReadwiseHighlight[];
books: ReadwiseBookMap;
complete: boolean;
startTime?: string;
}
export interface ReadwiseBook {
user_book_id: string | number;
is_deleted: boolean;
title: string;
author: string;
highlights: ReadwiseHighlight[];
}
export interface ReadwiseHighlight {
id: string;
is_deleted: boolean;
text: string;
location: number;
location_type: 'page' | 'order' | 'time_offset';
note: string | null;
color: string;
highlighted_at: string;
created_at: string;
updated_at: string;
external_id: string;
end_location: number | null;
url: null;
book_id: string | number;
tags: string[];
is_favorite: boolean;
is_discard: boolean;
readwise_url: string;
}
export type ReadwiseDocProperties = Omit<ReadwiseBook, 'highlights'> &
ReadwiseHighlight;
export type ReadwiseBookMap = Record<
ReadwiseBook['user_book_id'],
Omit<ReadwiseBook, 'highlights'>
>;
export interface ReadwiseRefMeta {
highlightId: string;
updatedAt: string;
}
export interface ReadwiseConfig {
/**
* User token
*/
token?: string;
/**
* The last import time
*/
lastImportedAt?: string;
/**
* The update strategy
*/
updateStrategy?: 'override' | 'append';
}
// ===============================
// Zotero
// ===============================
// TODO
@@ -0,0 +1,28 @@
export async function encryptPBKDF2(
source: string,
secret = 'affine',
iterations = 100000
) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(source),
{ name: 'PBKDF2' },
false,
['deriveBits']
);
const salt = encoder.encode(secret);
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
hash: 'SHA-256',
salt,
iterations,
},
keyMaterial,
256
);
return Array.from(new Uint8Array(derivedBits))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
@@ -0,0 +1,15 @@
import type { SVGProps } from 'react';
import { INTEGRATION_ICON_MAP } from '../constant';
import type { IntegrationType } from '../type';
export const IntegrationTypeIcon = ({
type,
...props
}: {
type: IntegrationType;
} & SVGProps<SVGSVGElement>) => {
const Icon = INTEGRATION_ICON_MAP[type];
return <Icon {...props} />;
};