mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-31 17:19:56 +08:00
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:
@@ -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} />;
|
||||
};
|
||||
Reference in New Issue
Block a user