diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-10-04 13:40:24 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-04 13:40:24 +0100 |
| commit | 4a580d713621f99abb8baabc9b847ce039d44842 (patch) | |
| tree | a2aa6f3ae8045ad50a9316624e2a7028dd098c6b /packages | |
| parent | 5e331a7d5b8d9666812170547574804d8b6da741 (diff) | |
| download | karakeep-4a580d713621f99abb8baabc9b847ce039d44842.tar.zst | |
feat: Revamp import experience (#2001)
* WIP: import v2
* remove new session button
* don't redirect after import
* store and lint to root list
* models + tests
* redesign the progress
* simplify the import session for ow
* drop status from session schema
* split the import session page
* i18n
* fix test
* remove pagination
* fix some colors in darkmode
* one last fix
* add privacy filter
* privacy check
* fix interactivity of import progress
* fix test
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/db/drizzle/0062_add_import_session.sql | 25 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/0062_snapshot.json | 2489 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | packages/db/schema.ts | 70 | ||||
| -rw-r--r-- | packages/open-api/karakeep-openapi-spec.json | 3 | ||||
| -rw-r--r-- | packages/shared/import-export/importer.test.ts | 12 | ||||
| -rw-r--r-- | packages/shared/import-export/importer.ts | 50 | ||||
| -rw-r--r-- | packages/shared/types/bookmarks.ts | 1 | ||||
| -rw-r--r-- | packages/shared/types/importSessions.ts | 76 | ||||
| -rw-r--r-- | packages/trpc/models/importSessions.ts | 180 | ||||
| -rw-r--r-- | packages/trpc/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 15 | ||||
| -rw-r--r-- | packages/trpc/routers/importSessions.test.ts | 204 | ||||
| -rw-r--r-- | packages/trpc/routers/importSessions.ts | 48 |
14 files changed, 3163 insertions, 19 deletions
diff --git a/packages/db/drizzle/0062_add_import_session.sql b/packages/db/drizzle/0062_add_import_session.sql new file mode 100644 index 00000000..ce2823c4 --- /dev/null +++ b/packages/db/drizzle/0062_add_import_session.sql @@ -0,0 +1,25 @@ +CREATE TABLE `importSessionBookmarks` ( + `id` text PRIMARY KEY NOT NULL, + `importSessionId` text NOT NULL, + `bookmarkId` text NOT NULL, + `createdAt` integer NOT NULL, + FOREIGN KEY (`importSessionId`) REFERENCES `importSessions`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`bookmarkId`) REFERENCES `bookmarks`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `importSessionBookmarks_sessionId_idx` ON `importSessionBookmarks` (`importSessionId`);--> statement-breakpoint +CREATE INDEX `importSessionBookmarks_bookmarkId_idx` ON `importSessionBookmarks` (`bookmarkId`);--> statement-breakpoint +CREATE UNIQUE INDEX `importSessionBookmarks_importSessionId_bookmarkId_unique` ON `importSessionBookmarks` (`importSessionId`,`bookmarkId`);--> statement-breakpoint +CREATE TABLE `importSessions` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `userId` text NOT NULL, + `message` text, + `rootListId` text, + `createdAt` integer NOT NULL, + `modifiedAt` integer, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`rootListId`) REFERENCES `bookmarkLists`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `importSessions_userId_idx` ON `importSessions` (`userId`);
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0062_snapshot.json b/packages/db/drizzle/meta/0062_snapshot.json new file mode 100644 index 00000000..8d8fa60c --- /dev/null +++ b/packages/db/drizzle/meta/0062_snapshot.json @@ -0,0 +1,2489 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "85dc1c66-ccf6-4db8-b722-c5cbaf18f806", + "prevId": "e868f89a-f304-428c-8e96-705453facb18", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentAssetId": { + "name": "contentAssetId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rssToken": { + "name": "rssToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summarizationStatus": { + "name": "summarizationStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessionBookmarks": { + "name": "importSessionBookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "importSessionId": { + "name": "importSessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "importSessionBookmarks_sessionId_idx": { + "name": "importSessionBookmarks_sessionId_idx", + "columns": [ + "importSessionId" + ], + "isUnique": false + }, + "importSessionBookmarks_bookmarkId_idx": { + "name": "importSessionBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "importSessionBookmarks_importSessionId_bookmarkId_unique": { + "name": "importSessionBookmarks_importSessionId_bookmarkId_unique", + "columns": [ + "importSessionId", + "bookmarkId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "importSessionBookmarks_importSessionId_importSessions_id_fk": { + "name": "importSessionBookmarks_importSessionId_importSessions_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "importSessions", + "columnsFrom": [ + "importSessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessionBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessions": { + "name": "importSessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rootListId": { + "name": "rootListId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "importSessions_userId_idx": { + "name": "importSessions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "importSessions_userId_user_id_fk": { + "name": "importSessions_userId_user_id_fk", + "tableFrom": "importSessions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessions_rootListId_bookmarkLists_id_fk": { + "name": "importSessions_rootListId_bookmarkLists_id_fk", + "tableFrom": "importSessions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "rootListId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invites": { + "name": "invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invites_token_unique": { + "name": "invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invites_invitedBy_user_id_fk": { + "name": "invites_invitedBy_user_id_fk", + "tableFrom": "invites", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passwordResetToken": { + "name": "passwordResetToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passwordResetToken_token_unique": { + "name": "passwordResetToken_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "passwordResetTokens_userId_idx": { + "name": "passwordResetTokens_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passwordResetToken_userId_user_id_fk": { + "name": "passwordResetToken_userId_user_id_fk", + "tableFrom": "passwordResetToken", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "priceId": { + "name": "priceId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "startDate": { + "name": "startDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subscriptions_userId_unique": { + "name": "subscriptions_userId_unique", + "columns": [ + "userId" + ], + "isUnique": true + }, + "subscriptions_userId_idx": { + "name": "subscriptions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "subscriptions_stripeCustomerId_idx": { + "name": "subscriptions_stripeCustomerId_idx", + "columns": [ + "stripeCustomerId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_userId_user_id_fk": { + "name": "subscriptions_userId_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "bookmarkQuota": { + "name": "bookmarkQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageQuota": { + "name": "storageQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserCrawlingEnabled": { + "name": "browserCrawlingEnabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkClickAction": { + "name": "bookmarkClickAction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open_original_link'" + }, + "archiveDisplayBehaviour": { + "name": "archiveDisplayBehaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'show'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4c045422..a1e3b96e 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -435,6 +435,13 @@ "when": 1754236017965, "tag": "0061_merge_user_settings", "breakpoints": true + }, + { + "idx": 62, + "version": "6", + "when": 1759573697911, + "tag": "0062_add_import_session", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index d94d3963..c112b075 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -628,6 +628,49 @@ export const subscriptions = sqliteTable( ], ); +export const importSessions = sqliteTable( + "importSessions", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + name: text("name").notNull(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + message: text("message"), + rootListId: text("rootListId").references(() => bookmarkLists.id, { + onDelete: "set null", + }), + createdAt: createdAtField(), + modifiedAt: modifiedAtField(), + }, + (is) => [index("importSessions_userId_idx").on(is.userId)], +); + +export const importSessionBookmarks = sqliteTable( + "importSessionBookmarks", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + importSessionId: text("importSessionId") + .notNull() + .references(() => importSessions.id, { onDelete: "cascade" }), + bookmarkId: text("bookmarkId") + .notNull() + .references(() => bookmarks.id, { onDelete: "cascade" }), + createdAt: createdAtField(), + }, + (isb) => [ + index("importSessionBookmarks_sessionId_idx").on(isb.importSessionId), + index("importSessionBookmarks_bookmarkId_idx").on(isb.bookmarkId), + unique().on(isb.importSessionId, isb.bookmarkId), + ], +); + // Relations export const userRelations = relations(users, ({ many, one }) => ({ @@ -637,6 +680,7 @@ export const userRelations = relations(users, ({ many, one }) => ({ rules: many(ruleEngineRulesTable), invites: many(invites), subscription: one(subscriptions), + importSessions: many(importSessions), })); export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ @@ -660,6 +704,7 @@ export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ bookmarksInLists: many(bookmarksInLists), assets: many(assets), rssFeeds: many(rssFeedImportsTable), + importSessionBookmarks: many(importSessionBookmarks), })); export const assetRelations = relations(assets, ({ one }) => ({ @@ -795,3 +840,28 @@ export const passwordResetTokensRelations = relations( }), }), ); + +export const importSessionsRelations = relations( + importSessions, + ({ one, many }) => ({ + user: one(users, { + fields: [importSessions.userId], + references: [users.id], + }), + bookmarks: many(importSessionBookmarks), + }), +); + +export const importSessionBookmarksRelations = relations( + importSessionBookmarks, + ({ one }) => ({ + importSession: one(importSessions, { + fields: [importSessionBookmarks.importSessionId], + references: [importSessions.id], + }), + bookmark: one(bookmarks, { + fields: [importSessionBookmarks.bookmarkId], + references: [bookmarks.id], + }), + }), +); diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json index ffa9c357..b525d39f 100644 --- a/packages/open-api/karakeep-openapi-spec.json +++ b/packages/open-api/karakeep-openapi-spec.json @@ -693,6 +693,9 @@ "low", "normal" ] + }, + "importSessionId": { + "type": "string" } } }, diff --git a/packages/shared/import-export/importer.test.ts b/packages/shared/import-export/importer.test.ts index 2ea63846..00f892a9 100644 --- a/packages/shared/import-export/importer.test.ts +++ b/packages/shared/import-export/importer.test.ts @@ -85,6 +85,8 @@ describe("importBookmarksFromFile", () => { }, ); + const createImportSession = vi.fn(async () => ({ id: "session-1" })); + const progress: number[] = []; const res = await importBookmarksFromFile( { @@ -96,6 +98,7 @@ describe("importBookmarksFromFile", () => { createBookmark, addBookmarkToLists, updateBookmarkTags, + createImportSession, }, onProgress: (d, t) => progress.push(d / t), }, @@ -167,6 +170,7 @@ describe("importBookmarksFromFile", () => { createBookmark: vi.fn(), addBookmarkToLists: vi.fn(), updateBookmarkTags: vi.fn(), + createImportSession: vi.fn(async () => ({ id: "session-1" })), }, }, { parsers }, @@ -174,6 +178,7 @@ describe("importBookmarksFromFile", () => { expect(res).toEqual({ counts: { successes: 0, failures: 0, alreadyExisted: 0, total: 0 }, rootListId: null, + importSessionId: null, }); }); @@ -244,6 +249,8 @@ describe("importBookmarksFromFile", () => { }, ); + const createImportSession = vi.fn(async () => ({ id: "session-1" })); + const progress: number[] = []; const res = await importBookmarksFromFile( { @@ -255,6 +262,7 @@ describe("importBookmarksFromFile", () => { createBookmark, addBookmarkToLists, updateBookmarkTags, + createImportSession, }, onProgress: (d, t) => progress.push(d / t), }, @@ -353,6 +361,8 @@ describe("importBookmarksFromFile", () => { }, ); + const createImportSession = vi.fn(async () => ({ id: "session-1" })); + const progress: number[] = []; const res = await importBookmarksFromFile( { @@ -364,6 +374,7 @@ describe("importBookmarksFromFile", () => { createBookmark, addBookmarkToLists, updateBookmarkTags, + createImportSession, }, onProgress: (d, t) => progress.push(d / t), }, @@ -371,6 +382,7 @@ describe("importBookmarksFromFile", () => { ); expect(res.rootListId).toBe("Imported"); + expect(res.importSessionId).toBe("session-1"); // All bookmarks are created successfully, but 2 fail in post-processing expect(res.counts).toEqual({ diff --git a/packages/shared/import-export/importer.ts b/packages/shared/import-export/importer.ts index 88c0c3bc..b32c49c1 100644 --- a/packages/shared/import-export/importer.ts +++ b/packages/shared/import-export/importer.ts @@ -17,6 +17,7 @@ export interface ImportDeps { }) => Promise<{ id: string }>; createBookmark: ( bookmark: ParsedBookmark, + sessionId: string, ) => Promise<{ id: string; alreadyExists?: boolean }>; addBookmarkToLists: (input: { bookmarkId: string; @@ -26,6 +27,10 @@ export interface ImportDeps { bookmarkId: string; tags: string[]; }) => Promise<void>; + createImportSession: (input: { + name: string; + rootListId: string; + }) => Promise<{ id: string }>; } export interface ImportOptions { @@ -38,6 +43,7 @@ export interface ImportOptions { export interface ImportResult { counts: ImportCounts; rootListId: string | null; + importSessionId: string | null; } export async function importBookmarksFromFile( @@ -66,10 +72,15 @@ export async function importBookmarksFromFile( return { counts: { successes: 0, failures: 0, alreadyExisted: 0, total: 0 }, rootListId: null, + importSessionId: null, }; } const rootList = await deps.createList({ name: rootListName, icon: "⬆️" }); + const session = await deps.createImportSession({ + name: `${source.charAt(0).toUpperCase() + source.slice(1)} Import - ${new Date().toLocaleDateString()}`, + rootListId: rootList.id, + }); onProgress?.(0, parsedBookmarks.length); @@ -109,22 +120,28 @@ export async function importBookmarksFromFile( pathMap[pathKey] = folderList.id; } + let done = 0; const importPromises = parsedBookmarks.map((bookmark) => async () => { - const listIds = bookmark.paths.map( - (path) => pathMap[path.join(PATH_DELIMITER)] || rootList.id, - ); - if (listIds.length === 0) listIds.push(rootList.id); - - const created = await deps.createBookmark(bookmark); - await deps.addBookmarkToLists({ bookmarkId: created.id, listIds }); - if (bookmark.tags && bookmark.tags.length > 0) { - await deps.updateBookmarkTags({ - bookmarkId: created.id, - tags: bookmark.tags, - }); - } + try { + const listIds = bookmark.paths.map( + (path) => pathMap[path.join(PATH_DELIMITER)] || rootList.id, + ); + if (listIds.length === 0) listIds.push(rootList.id); + + const created = await deps.createBookmark(bookmark, session.id); + await deps.addBookmarkToLists({ bookmarkId: created.id, listIds }); + if (bookmark.tags && bookmark.tags.length > 0) { + await deps.updateBookmarkTags({ + bookmarkId: created.id, + tags: bookmark.tags, + }); + } - return created; + return created; + } finally { + done += 1; + onProgress?.(done, parsedBookmarks.length); + } }); const resultsPromises = limitConcurrency(importPromises, concurrencyLimit); @@ -134,7 +151,6 @@ export async function importBookmarksFromFile( let failures = 0; let alreadyExisted = 0; - let done = 0; for (const r of results) { if (r.status === "fulfilled") { if (r.value.alreadyExists) alreadyExisted++; @@ -142,10 +158,7 @@ export async function importBookmarksFromFile( } else { failures++; } - done += 1; - onProgress?.(done, parsedBookmarks.length); } - return { counts: { successes, @@ -154,5 +167,6 @@ export async function importBookmarksFromFile( total: parsedBookmarks.length, }, rootListId: rootList.id, + importSessionId: session.id, }; } diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index a22e7632..71cf1012 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -142,6 +142,7 @@ export const zNewBookmarkRequestSchema = z // A mechanism to prioritize crawling of bookmarks depending on whether // they were created by a user interaction or by a bulk import. crawlPriority: z.enum(["low", "normal"]).optional(), + importSessionId: z.string().optional(), }) .and( z.discriminatedUnion("type", [ diff --git a/packages/shared/types/importSessions.ts b/packages/shared/types/importSessions.ts new file mode 100644 index 00000000..0c1edd03 --- /dev/null +++ b/packages/shared/types/importSessions.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +export const zImportSessionStatusSchema = z.enum([ + "pending", + "in_progress", + "completed", + "failed", +]); +export type ZImportSessionStatus = z.infer<typeof zImportSessionStatusSchema>; + +export const zImportSessionBookmarkStatusSchema = z.enum([ + "pending", + "processing", + "completed", + "failed", +]); +export type ZImportSessionBookmarkStatus = z.infer< + typeof zImportSessionBookmarkStatusSchema +>; + +export const zImportSessionSchema = z.object({ + id: z.string(), + name: z.string(), + userId: z.string(), + message: z.string().nullable(), + rootListId: z.string().nullable(), + createdAt: z.date(), + modifiedAt: z.date().nullable(), +}); +export type ZImportSession = z.infer<typeof zImportSessionSchema>; + +export const zImportSessionWithStatsSchema = zImportSessionSchema.extend({ + status: z.enum(["pending", "in_progress", "completed", "failed"]), + totalBookmarks: z.number(), + completedBookmarks: z.number(), + failedBookmarks: z.number(), + pendingBookmarks: z.number(), + processingBookmarks: z.number(), +}); +export type ZImportSessionWithStats = z.infer< + typeof zImportSessionWithStatsSchema +>; + +export const zCreateImportSessionRequestSchema = z.object({ + name: z.string().min(1).max(255), + rootListId: z.string().optional(), +}); +export type ZCreateImportSessionRequest = z.infer< + typeof zCreateImportSessionRequestSchema +>; + +export const zGetImportSessionStatsRequestSchema = z.object({ + importSessionId: z.string(), +}); +export type ZGetImportSessionStatsRequest = z.infer< + typeof zGetImportSessionStatsRequestSchema +>; + +export const zListImportSessionsRequestSchema = z.object({}); +export type ZListImportSessionsRequest = z.infer< + typeof zListImportSessionsRequestSchema +>; + +export const zListImportSessionsResponseSchema = z.object({ + sessions: z.array(zImportSessionWithStatsSchema), +}); +export type ZListImportSessionsResponse = z.infer< + typeof zListImportSessionsResponseSchema +>; + +export const zDeleteImportSessionRequestSchema = z.object({ + importSessionId: z.string(), +}); +export type ZDeleteImportSessionRequest = z.infer< + typeof zDeleteImportSessionRequestSchema +>; diff --git a/packages/trpc/models/importSessions.ts b/packages/trpc/models/importSessions.ts new file mode 100644 index 00000000..270c2bce --- /dev/null +++ b/packages/trpc/models/importSessions.ts @@ -0,0 +1,180 @@ +import { TRPCError } from "@trpc/server"; +import { and, count, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { + bookmarkLinks, + bookmarks, + importSessionBookmarks, + importSessions, +} from "@karakeep/db/schema"; +import { + zCreateImportSessionRequestSchema, + ZImportSession, + ZImportSessionWithStats, +} from "@karakeep/shared/types/importSessions"; + +import type { AuthedContext } from "../index"; +import { PrivacyAware } from "./privacy"; + +export class ImportSession implements PrivacyAware { + protected constructor( + protected ctx: AuthedContext, + public session: ZImportSession, + ) {} + + static async fromId( + ctx: AuthedContext, + importSessionId: string, + ): Promise<ImportSession> { + const session = await ctx.db.query.importSessions.findFirst({ + where: and( + eq(importSessions.id, importSessionId), + eq(importSessions.userId, ctx.user.id), + ), + }); + + if (!session) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Import session not found", + }); + } + + return new ImportSession(ctx, session); + } + + static async create( + ctx: AuthedContext, + input: z.infer<typeof zCreateImportSessionRequestSchema>, + ): Promise<ImportSession> { + const [session] = await ctx.db + .insert(importSessions) + .values({ + name: input.name, + userId: ctx.user.id, + rootListId: input.rootListId, + }) + .returning(); + + return new ImportSession(ctx, session); + } + + static async getAll(ctx: AuthedContext): Promise<ImportSession[]> { + const sessions = await ctx.db.query.importSessions.findMany({ + where: eq(importSessions.userId, ctx.user.id), + orderBy: (importSessions, { desc }) => [desc(importSessions.createdAt)], + limit: 50, + }); + + return sessions.map((session) => new ImportSession(ctx, session)); + } + + static async getAllWithStats( + ctx: AuthedContext, + ): Promise<ZImportSessionWithStats[]> { + const sessions = await this.getAll(ctx); + + return await Promise.all( + sessions.map(async (session) => { + return await session.getWithStats(); + }), + ); + } + + ensureCanAccess(ctx: AuthedContext): void { + if (this.session.userId !== ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access this import session", + }); + } + } + + async attachBookmark(bookmarkId: string): Promise<void> { + await this.ctx.db.insert(importSessionBookmarks).values({ + importSessionId: this.session.id, + bookmarkId, + }); + } + + async getWithStats(): Promise<ZImportSessionWithStats> { + // Get bookmark counts by status + const statusCounts = await this.ctx.db + .select({ + crawlStatus: bookmarkLinks.crawlStatus, + taggingStatus: bookmarks.taggingStatus, + count: count(), + }) + .from(importSessionBookmarks) + .innerJoin( + importSessions, + eq(importSessions.id, importSessionBookmarks.importSessionId), + ) + .leftJoin(bookmarks, eq(bookmarks.id, importSessionBookmarks.bookmarkId)) + .leftJoin( + bookmarkLinks, + eq(bookmarkLinks.id, importSessionBookmarks.bookmarkId), + ) + .where( + and( + eq(importSessionBookmarks.importSessionId, this.session.id), + eq(importSessions.userId, this.ctx.user.id), + ), + ) + .groupBy(bookmarkLinks.crawlStatus, bookmarks.taggingStatus); + + const stats = { + totalBookmarks: 0, + completedBookmarks: 0, + failedBookmarks: 0, + pendingBookmarks: 0, + processingBookmarks: 0, + }; + + statusCounts.forEach((statusCount) => { + stats.totalBookmarks += statusCount.count; + if ( + statusCount.crawlStatus === "success" && + statusCount.taggingStatus === "success" + ) { + stats.completedBookmarks += statusCount.count; + } else if ( + statusCount.crawlStatus === "failure" || + statusCount.taggingStatus === "failure" + ) { + stats.failedBookmarks += statusCount.count; + } else if ( + statusCount.crawlStatus === "pending" || + statusCount.taggingStatus === "pending" + ) { + stats.pendingBookmarks += statusCount.count; + } + }); + + return { + ...this.session, + status: stats.pendingBookmarks > 0 ? "in_progress" : "completed", + ...stats, + }; + } + + async delete(): Promise<void> { + // Delete the session (cascade will handle the bookmarks) + const result = await this.ctx.db + .delete(importSessions) + .where( + and( + eq(importSessions.id, this.session.id), + eq(importSessions.userId, this.ctx.user.id), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Import session not found", + }); + } + } +} diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 651c8d88..1d548ee4 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -5,6 +5,7 @@ import { assetsAppRouter } from "./assets"; import { bookmarksAppRouter } from "./bookmarks"; import { feedsAppRouter } from "./feeds"; import { highlightsAppRouter } from "./highlights"; +import { importSessionsRouter } from "./importSessions"; import { invitesAppRouter } from "./invites"; import { listsAppRouter } from "./lists"; import { promptsAppRouter } from "./prompts"; @@ -25,6 +26,7 @@ export const appRouter = router({ admin: adminAppRouter, feeds: feedsAppRouter, highlights: highlightsAppRouter, + importSessions: importSessionsRouter, webhooks: webhooksAppRouter, assets: assetsAppRouter, rules: rulesAppRouter, diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 3399bf19..be3664b3 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -59,6 +59,7 @@ import { authedProcedure, createRateLimitMiddleware, router } from "../index"; import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { getBookmarkIdsFromMatcher } from "../lib/search"; import { Bookmark } from "../models/bookmarks"; +import { ImportSession } from "../models/importSessions"; import { ensureAssetOwnership } from "./assets"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ @@ -272,6 +273,13 @@ export const bookmarksAppRouter = router({ // This doesn't 100% protect from duplicates because of races, but it's more than enough for this usecase. const alreadyExists = await attemptToDedupLink(ctx, input.url); if (alreadyExists) { + if (input.importSessionId) { + const session = await ImportSession.fromId( + ctx, + input.importSessionId, + ); + await session.attachBookmark(alreadyExists.id); + } return { ...alreadyExists, alreadyExists: true }; } } @@ -416,12 +424,16 @@ export const bookmarksAppRouter = router({ }; }); + if (input.importSessionId) { + const session = await ImportSession.fromId(ctx, input.importSessionId); + await session.attachBookmark(bookmark.id); + } + const enqueueOpts: EnqueueOptions = { // The lower the priority number, the sooner the job will be processed priority: input.crawlPriority === "low" ? 50 : 0, }; - // Enqueue crawling request switch (bookmark.content.type) { case BookmarkTypes.LINK: { // The crawling job triggers openai when it's done @@ -454,6 +466,7 @@ export const bookmarksAppRouter = router({ break; } } + await triggerRuleEngineOnEvent( bookmark.id, [ diff --git a/packages/trpc/routers/importSessions.test.ts b/packages/trpc/routers/importSessions.test.ts new file mode 100644 index 00000000..b28d1421 --- /dev/null +++ b/packages/trpc/routers/importSessions.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { z } from "zod"; + +import { + BookmarkTypes, + zNewBookmarkRequestSchema, +} from "@karakeep/shared/types/bookmarks"; +import { + zCreateImportSessionRequestSchema, + zDeleteImportSessionRequestSchema, + zGetImportSessionStatsRequestSchema, +} from "@karakeep/shared/types/importSessions"; +import { zNewBookmarkListSchema } from "@karakeep/shared/types/lists"; + +import type { APICallerType, CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach<CustomTestContext>(defaultBeforeEach(true)); + +describe("ImportSessions Routes", () => { + async function createTestBookmark(api: APICallerType, sessionId: string) { + const newBookmarkInput: z.infer<typeof zNewBookmarkRequestSchema> = { + type: BookmarkTypes.TEXT, + text: "Test bookmark text", + importSessionId: sessionId, + }; + const createdBookmark = + await api.bookmarks.createBookmark(newBookmarkInput); + return createdBookmark.id; + } + + async function createTestList(api: APICallerType) { + const newListInput: z.infer<typeof zNewBookmarkListSchema> = { + name: "Test Import List", + description: "A test list for imports", + icon: "📋", + type: "manual", + }; + const createdList = await api.lists.create(newListInput); + return createdList.id; + } + + test<CustomTestContext>("create import session", async ({ apiCallers }) => { + const api = apiCallers[0].importSessions; + const listId = await createTestList(apiCallers[0]); + + const newSessionInput: z.infer<typeof zCreateImportSessionRequestSchema> = { + name: "Test Import Session", + rootListId: listId, + }; + + const createdSession = await api.createImportSession(newSessionInput); + + expect(createdSession).toMatchObject({ + id: expect.any(String), + }); + + // Verify session appears in list + const sessions = await api.listImportSessions({}); + const sessionFromList = sessions.sessions.find( + (s) => s.id === createdSession.id, + ); + expect(sessionFromList).toBeDefined(); + expect(sessionFromList?.name).toEqual(newSessionInput.name); + expect(sessionFromList?.rootListId).toEqual(listId); + }); + + test<CustomTestContext>("create import session without rootListId", async ({ + apiCallers, + }) => { + const api = apiCallers[0].importSessions; + + const newSessionInput: z.infer<typeof zCreateImportSessionRequestSchema> = { + name: "Test Import Session", + }; + + const createdSession = await api.createImportSession(newSessionInput); + + expect(createdSession).toMatchObject({ + id: expect.any(String), + }); + + // Verify session appears in list + const sessions = await api.listImportSessions({}); + const sessionFromList = sessions.sessions.find( + (s) => s.id === createdSession.id, + ); + expect(sessionFromList?.rootListId).toBeNull(); + }); + + test<CustomTestContext>("get import session stats", async ({ + apiCallers, + }) => { + const api = apiCallers[0]; + + const session = await api.importSessions.createImportSession({ + name: "Test Import Session", + }); + await createTestBookmark(api, session.id); + await createTestBookmark(api, session.id); + + const statsInput: z.infer<typeof zGetImportSessionStatsRequestSchema> = { + importSessionId: session.id, + }; + + const stats = await api.importSessions.getImportSessionStats(statsInput); + + expect(stats).toMatchObject({ + id: session.id, + name: "Test Import Session", + status: "in_progress", + totalBookmarks: 2, + pendingBookmarks: 2, + completedBookmarks: 0, + failedBookmarks: 0, + processingBookmarks: 0, + }); + }); + + test<CustomTestContext>("list import sessions returns all sessions", async ({ + apiCallers, + }) => { + const api = apiCallers[0].importSessions; + + const sessionNames = ["Session 1", "Session 2", "Session 3"]; + for (const name of sessionNames) { + await api.createImportSession({ name }); + } + + const result = await api.listImportSessions({}); + + expect(result.sessions).toHaveLength(3); + expect(result.sessions.map((session) => session.name)).toEqual( + sessionNames, + ); + expect( + result.sessions.every((session) => session.totalBookmarks === 0), + ).toBe(true); + }); + + test<CustomTestContext>("delete import session", async ({ apiCallers }) => { + const api = apiCallers[0].importSessions; + + const session = await api.createImportSession({ + name: "Session to Delete", + }); + + const deleteInput: z.infer<typeof zDeleteImportSessionRequestSchema> = { + importSessionId: session.id, + }; + + const result = await api.deleteImportSession(deleteInput); + expect(result.success).toBe(true); + + // Verify session no longer exists + await expect( + api.getImportSessionStats({ + importSessionId: session.id, + }), + ).rejects.toThrow("Import session not found"); + }); + + test<CustomTestContext>("cannot access other user's session", async ({ + apiCallers, + }) => { + const api1 = apiCallers[0].importSessions; + const api2 = apiCallers[1].importSessions; + + // User 1 creates a session + const session = await api1.createImportSession({ + name: "User 1 Session", + }); + + // User 2 tries to access it + await expect( + api2.getImportSessionStats({ + importSessionId: session.id, + }), + ).rejects.toThrow("Import session not found"); + + await expect( + api2.deleteImportSession({ + importSessionId: session.id, + }), + ).rejects.toThrow("Import session not found"); + }); + + test<CustomTestContext>("cannot attach other user's bookmark", async ({ + apiCallers, + }) => { + const api1 = apiCallers[0]; + const api2 = apiCallers[1]; + + // User 1 creates session and bookmark + const session = await api1.importSessions.createImportSession({ + name: "User 1 Session", + }); + + // User 1 tries to attach User 2's bookmark + await expect( + createTestBookmark(api2, session.id), // User 2's bookmark + ).rejects.toThrow("Import session not found"); + }); +}); diff --git a/packages/trpc/routers/importSessions.ts b/packages/trpc/routers/importSessions.ts new file mode 100644 index 00000000..4bdc4f29 --- /dev/null +++ b/packages/trpc/routers/importSessions.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +import { + zCreateImportSessionRequestSchema, + zDeleteImportSessionRequestSchema, + zGetImportSessionStatsRequestSchema, + zImportSessionWithStatsSchema, + zListImportSessionsRequestSchema, + zListImportSessionsResponseSchema, +} from "@karakeep/shared/types/importSessions"; + +import { authedProcedure, router } from "../index"; +import { ImportSession } from "../models/importSessions"; + +export const importSessionsRouter = router({ + createImportSession: authedProcedure + .input(zCreateImportSessionRequestSchema) + .output(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + const session = await ImportSession.create(ctx, input); + return { id: session.session.id }; + }), + + getImportSessionStats: authedProcedure + .input(zGetImportSessionStatsRequestSchema) + .output(zImportSessionWithStatsSchema) + .query(async ({ input, ctx }) => { + const session = await ImportSession.fromId(ctx, input.importSessionId); + return await session.getWithStats(); + }), + + listImportSessions: authedProcedure + .input(zListImportSessionsRequestSchema) + .output(zListImportSessionsResponseSchema) + .query(async ({ ctx }) => { + const sessions = await ImportSession.getAllWithStats(ctx); + return { sessions }; + }), + + deleteImportSession: authedProcedure + .input(zDeleteImportSessionRequestSchema) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ input, ctx }) => { + const session = await ImportSession.fromId(ctx, input.importSessionId); + await session.delete(); + return { success: true }; + }), +}); |
