Repository: karakeep-app/karakeep Branch: main Commit: fba7108b1e29 Files: 1606 Total size: 9.9 MB Directory structure: gitextract_zd_j__6h/ ├── .claude/ │ └── settings.json ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── feature_request.yml │ │ └── question.yml │ └── workflows/ │ ├── android.yml │ ├── ci.yml │ ├── claude.yml │ ├── cli.yml │ ├── docker.yml │ ├── extension.yml │ ├── ios.yml │ ├── mcp.yml │ └── sdk.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .npmrc ├── .oxfmtrc.json ├── .oxlintrc.json ├── AGENTS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── apps/ │ ├── browser-extension/ │ │ ├── .gitignore │ │ ├── .oxlintrc.json │ │ ├── components.json │ │ ├── index.html │ │ ├── manifest.json │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── src/ │ │ │ ├── BookmarkDeletedPage.tsx │ │ │ ├── BookmarkSavedPage.tsx │ │ │ ├── CustomHeadersPage.tsx │ │ │ ├── Layout.tsx │ │ │ ├── Logo.tsx │ │ │ ├── NotConfiguredPage.tsx │ │ │ ├── OptionsPage.tsx │ │ │ ├── SavePage.tsx │ │ │ ├── SignInPage.tsx │ │ │ ├── Spinner.tsx │ │ │ ├── background/ │ │ │ │ ├── background.ts │ │ │ │ └── protocol.ts │ │ │ ├── components/ │ │ │ │ ├── BookmarkLists.tsx │ │ │ │ ├── ListsSelector.tsx │ │ │ │ ├── NoteEditor.tsx │ │ │ │ ├── TagList.tsx │ │ │ │ ├── TagsSelector.tsx │ │ │ │ └── ui/ │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dynamic-popover.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── switch.tsx │ │ │ │ └── textarea.tsx │ │ │ ├── index.css │ │ │ ├── main.tsx │ │ │ ├── utils/ │ │ │ │ ├── ThemeProvider.tsx │ │ │ │ ├── badgeCache.ts │ │ │ │ ├── css.ts │ │ │ │ ├── providers.tsx │ │ │ │ ├── settings.ts │ │ │ │ ├── storagePersister.ts │ │ │ │ ├── trpc.ts │ │ │ │ ├── type.ts │ │ │ │ └── url.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── cli/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── .oxlintrc.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── commands/ │ │ │ │ ├── admin.ts │ │ │ │ ├── bookmarks.ts │ │ │ │ ├── dump.ts │ │ │ │ ├── lists.ts │ │ │ │ ├── migrate.ts │ │ │ │ ├── tags.ts │ │ │ │ ├── whoami.ts │ │ │ │ └── wipe.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── globals.ts │ │ │ │ ├── output.ts │ │ │ │ └── trpc.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── landing/ │ │ ├── .oxlintrc.json │ │ ├── README.md │ │ ├── components/ │ │ │ └── ui/ │ │ │ └── button.tsx │ │ ├── components.json │ │ ├── index.html │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── public/ │ │ │ ├── robots.txt │ │ │ └── sitemap.xml │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── Apps.tsx │ │ │ ├── Homepage.tsx │ │ │ ├── Navbar.tsx │ │ │ ├── Pricing.tsx │ │ │ ├── Privacy.tsx │ │ │ ├── SEO.tsx │ │ │ ├── Terms.tsx │ │ │ ├── components/ │ │ │ │ ├── Banner.tsx │ │ │ │ ├── CallToAction.tsx │ │ │ │ ├── FeatureShowcase.tsx │ │ │ │ ├── FeaturesGrid.tsx │ │ │ │ ├── Footer.tsx │ │ │ │ ├── Hero.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── OpenSource.tsx │ │ │ │ └── Platforms.tsx │ │ │ ├── constants.ts │ │ │ └── main.tsx │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ ├── vite-env.d.ts │ │ └── vite.config.ts │ ├── mcp/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── .oxlintrc.json │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── bookmarks.ts │ │ │ ├── index.ts │ │ │ ├── lists.ts │ │ │ ├── shared.ts │ │ │ ├── tags.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── mobile/ │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .oxlintrc.json │ │ ├── app/ │ │ │ ├── +not-found.tsx │ │ │ ├── _layout.tsx │ │ │ ├── dashboard/ │ │ │ │ ├── (tabs)/ │ │ │ │ │ ├── (highlights)/ │ │ │ │ │ │ ├── _layout.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── (home)/ │ │ │ │ │ │ ├── _layout.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── (lists)/ │ │ │ │ │ │ ├── _layout.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── (settings)/ │ │ │ │ │ │ ├── _layout.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── (tags)/ │ │ │ │ │ │ ├── _layout.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── _layout.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── _layout.tsx │ │ │ │ ├── archive.tsx │ │ │ │ ├── bookmarks/ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── info.tsx │ │ │ │ │ │ ├── manage_lists.tsx │ │ │ │ │ │ └── manage_tags.tsx │ │ │ │ │ └── new.tsx │ │ │ │ ├── favourites.tsx │ │ │ │ ├── lists/ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ ├── edit.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── new.tsx │ │ │ │ ├── search.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── bookmark-default-view.tsx │ │ │ │ │ ├── reader-settings.tsx │ │ │ │ │ └── theme.tsx │ │ │ │ └── tags/ │ │ │ │ └── [slug].tsx │ │ │ ├── error.tsx │ │ │ ├── index.tsx │ │ │ ├── server-address.tsx │ │ │ ├── sharing.tsx │ │ │ ├── signin.tsx │ │ │ └── test-connection.tsx │ │ ├── app.config.js │ │ ├── babel.config.js │ │ ├── components/ │ │ │ ├── CustomHeadersModal.tsx │ │ │ ├── FullPageError.tsx │ │ │ ├── Logo.tsx │ │ │ ├── SplashScreenController.tsx │ │ │ ├── TailwindResolver.tsx │ │ │ ├── bookmarks/ │ │ │ │ ├── BookmarkAssetImage.tsx │ │ │ │ ├── BookmarkAssetView.tsx │ │ │ │ ├── BookmarkCard.tsx │ │ │ │ ├── BookmarkHtmlHighlighterDom.tsx │ │ │ │ ├── BookmarkLinkPreview.tsx │ │ │ │ ├── BookmarkLinkTypeSelector.tsx │ │ │ │ ├── BookmarkLinkView.tsx │ │ │ │ ├── BookmarkList.tsx │ │ │ │ ├── BookmarkTextMarkdown.tsx │ │ │ │ ├── BookmarkTextView.tsx │ │ │ │ ├── BottomActions.tsx │ │ │ │ ├── NotePreview.tsx │ │ │ │ ├── PDFViewer.tsx │ │ │ │ ├── TagPill.tsx │ │ │ │ └── UpdatingBookmarkList.tsx │ │ │ ├── highlights/ │ │ │ │ ├── HighlightCard.tsx │ │ │ │ └── HighlightList.tsx │ │ │ ├── navigation/ │ │ │ │ └── stack.tsx │ │ │ ├── reader/ │ │ │ │ └── ReaderPreview.tsx │ │ │ ├── settings/ │ │ │ │ └── UserProfileHeader.tsx │ │ │ ├── sharing/ │ │ │ │ ├── ErrorAnimation.tsx │ │ │ │ ├── LoadingAnimation.tsx │ │ │ │ └── SuccessAnimation.tsx │ │ │ └── ui/ │ │ │ ├── ActionButton.tsx │ │ │ ├── Avatar.tsx │ │ │ ├── Button.tsx │ │ │ ├── ChevronRight.tsx │ │ │ ├── Divider.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── FullPageSpinner.tsx │ │ │ ├── GroupedList.tsx │ │ │ ├── Input.tsx │ │ │ ├── SearchInput/ │ │ │ │ ├── SearchInput.ios.tsx │ │ │ │ ├── SearchInput.tsx │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── Skeleton.tsx │ │ │ ├── Text.tsx │ │ │ └── Toast.tsx │ │ ├── eas.json │ │ ├── globals.css │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── hooks.ts │ │ │ ├── providers.tsx │ │ │ ├── readerSettings.tsx │ │ │ ├── session.ts │ │ │ ├── settings.ts │ │ │ ├── upload.ts │ │ │ ├── useColorScheme.tsx │ │ │ ├── useMenuIconColors.ts │ │ │ └── utils.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── plugins/ │ │ │ ├── camera-not-required.js │ │ │ ├── network_security_config.xml │ │ │ └── trust-local-certs.js │ │ ├── tailwind.config.js │ │ ├── theme/ │ │ │ ├── colors.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── web/ │ │ ├── .oxlintrc.json │ │ ├── @types/ │ │ │ └── i18next.d.ts │ │ ├── README.md │ │ ├── app/ │ │ │ ├── admin/ │ │ │ │ ├── admin_tools/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── background_jobs/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── overview/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── users/ │ │ │ │ └── page.tsx │ │ │ ├── api/ │ │ │ │ ├── [[...route]]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── auth/ │ │ │ │ │ └── [...nextauth]/ │ │ │ │ │ └── route.tsx │ │ │ │ └── bookmarks/ │ │ │ │ └── export/ │ │ │ │ └── route.tsx │ │ │ ├── check-email/ │ │ │ │ └── page.tsx │ │ │ ├── dashboard/ │ │ │ │ ├── @modal/ │ │ │ │ │ ├── (.)preview/ │ │ │ │ │ │ └── [bookmarkId]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── [...catchAll]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── default.tsx │ │ │ │ ├── archive/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── bookmarks/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── cleanups/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── favourites/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── feeds/ │ │ │ │ │ └── [feedId]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── highlights/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── lists/ │ │ │ │ │ ├── [listId]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── preview/ │ │ │ │ │ └── [bookmarkId]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── search/ │ │ │ │ │ └── page.tsx │ │ │ │ └── tags/ │ │ │ │ ├── [tagId]/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── forgot-password/ │ │ │ │ └── page.tsx │ │ │ ├── invite/ │ │ │ │ └── [token]/ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── logout/ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── public/ │ │ │ │ ├── layout.tsx │ │ │ │ └── lists/ │ │ │ │ └── [listId]/ │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ ├── reader/ │ │ │ │ ├── [bookmarkId]/ │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── reset-password/ │ │ │ │ └── page.tsx │ │ │ ├── settings/ │ │ │ │ ├── ai/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── api-keys/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── assets/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── backups/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── broken-links/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── feeds/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── import/ │ │ │ │ │ ├── [sessionId]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── info/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── rules/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── stats/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── subscription/ │ │ │ │ │ └── page.tsx │ │ │ │ └── webhooks/ │ │ │ │ └── page.tsx │ │ │ ├── signin/ │ │ │ │ └── page.tsx │ │ │ ├── signup/ │ │ │ │ └── page.tsx │ │ │ └── verify-email/ │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── DemoModeBanner.tsx │ │ │ ├── KarakeepIcon.tsx │ │ │ ├── admin/ │ │ │ │ ├── AddUserDialog.tsx │ │ │ │ ├── AdminCard.tsx │ │ │ │ ├── AdminNotices.tsx │ │ │ │ ├── BackgroundJobs.tsx │ │ │ │ ├── BasicStats.tsx │ │ │ │ ├── BookmarkDebugger.tsx │ │ │ │ ├── CreateInviteDialog.tsx │ │ │ │ ├── InvitesList.tsx │ │ │ │ ├── InvitesListSkeleton.tsx │ │ │ │ ├── ResetPasswordDialog.tsx │ │ │ │ ├── ServiceConnections.tsx │ │ │ │ ├── UpdateUserDialog.tsx │ │ │ │ ├── UserList.tsx │ │ │ │ └── UserListSkeleton.tsx │ │ │ ├── dashboard/ │ │ │ │ ├── BulkBookmarksAction.tsx │ │ │ │ ├── EditableText.tsx │ │ │ │ ├── ErrorFallback.tsx │ │ │ │ ├── GlobalActions.tsx │ │ │ │ ├── SortOrderToggle.tsx │ │ │ │ ├── UploadDropzone.tsx │ │ │ │ ├── ViewOptions.tsx │ │ │ │ ├── bookmarks/ │ │ │ │ │ ├── AssetCard.tsx │ │ │ │ │ ├── BookmarkActionBar.tsx │ │ │ │ │ ├── BookmarkCard.tsx │ │ │ │ │ ├── BookmarkFormattedCreatedAt.tsx │ │ │ │ │ ├── BookmarkLayoutAdaptingCard.tsx │ │ │ │ │ ├── BookmarkMarkdownComponent.tsx │ │ │ │ │ ├── BookmarkOptions.tsx │ │ │ │ │ ├── BookmarkOwnerIcon.tsx │ │ │ │ │ ├── BookmarkTagsEditor.tsx │ │ │ │ │ ├── BookmarkedTextEditor.tsx │ │ │ │ │ ├── Bookmarks.tsx │ │ │ │ │ ├── BookmarksGrid.tsx │ │ │ │ │ ├── BookmarksGridSkeleton.tsx │ │ │ │ │ ├── BulkManageListsModal.tsx │ │ │ │ │ ├── BulkTagModal.tsx │ │ │ │ │ ├── DeleteBookmarkConfirmationDialog.tsx │ │ │ │ │ ├── EditBookmarkDialog.tsx │ │ │ │ │ ├── EditorCard.tsx │ │ │ │ │ ├── FooterLinkURL.tsx │ │ │ │ │ ├── LinkCard.tsx │ │ │ │ │ ├── ManageListsModal.tsx │ │ │ │ │ ├── NoBookmarksBanner.tsx │ │ │ │ │ ├── NotePreview.tsx │ │ │ │ │ ├── SummarizeBookmarkArea.tsx │ │ │ │ │ ├── TagList.tsx │ │ │ │ │ ├── TagModal.tsx │ │ │ │ │ ├── TagsEditor.tsx │ │ │ │ │ ├── TextCard.tsx │ │ │ │ │ ├── UnknownCard.tsx │ │ │ │ │ ├── UpdatableBookmarksGrid.tsx │ │ │ │ │ ├── action-buttons/ │ │ │ │ │ │ └── ArchiveBookmarkButton.tsx │ │ │ │ │ └── icons.tsx │ │ │ │ ├── cleanups/ │ │ │ │ │ └── TagDuplicationDetention.tsx │ │ │ │ ├── feeds/ │ │ │ │ │ └── FeedSelector.tsx │ │ │ │ ├── header/ │ │ │ │ │ ├── Header.tsx │ │ │ │ │ └── ProfileOptions.tsx │ │ │ │ ├── highlights/ │ │ │ │ │ ├── AllHighlights.tsx │ │ │ │ │ └── HighlightCard.tsx │ │ │ │ ├── lists/ │ │ │ │ │ ├── AllListsView.tsx │ │ │ │ │ ├── BookmarkListSelector.tsx │ │ │ │ │ ├── CollapsibleBookmarkLists.tsx │ │ │ │ │ ├── DeleteListConfirmationDialog.tsx │ │ │ │ │ ├── EditListModal.tsx │ │ │ │ │ ├── LeaveListConfirmationDialog.tsx │ │ │ │ │ ├── ListHeader.tsx │ │ │ │ │ ├── ListOptions.tsx │ │ │ │ │ ├── ManageCollaboratorsModal.tsx │ │ │ │ │ ├── MergeListModal.tsx │ │ │ │ │ ├── PendingInvitationsCard.tsx │ │ │ │ │ ├── PublicListLink.tsx │ │ │ │ │ ├── RssLink.tsx │ │ │ │ │ └── ShareListModal.tsx │ │ │ │ ├── preview/ │ │ │ │ │ ├── ActionBar.tsx │ │ │ │ │ ├── AssetContentSection.tsx │ │ │ │ │ ├── AttachmentBox.tsx │ │ │ │ │ ├── BookmarkPreview.tsx │ │ │ │ │ ├── HighlightsBox.tsx │ │ │ │ │ ├── LinkContentSection.tsx │ │ │ │ │ ├── NoteEditor.tsx │ │ │ │ │ ├── ReaderSettingsPopover.tsx │ │ │ │ │ ├── ReaderView.tsx │ │ │ │ │ ├── ReadingProgressBanner.tsx │ │ │ │ │ ├── TextContentSection.tsx │ │ │ │ │ ├── content-renderers/ │ │ │ │ │ │ ├── AmazonRenderer.tsx │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ ├── TikTokRenderer.tsx │ │ │ │ │ │ ├── XRenderer.tsx │ │ │ │ │ │ ├── YouTubeRenderer.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── registry.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ └── highlights.ts │ │ │ │ ├── rules/ │ │ │ │ │ ├── RuleEngineActionBuilder.tsx │ │ │ │ │ ├── RuleEngineConditionBuilder.tsx │ │ │ │ │ ├── RuleEngineEventSelector.tsx │ │ │ │ │ ├── RuleEngineRuleEditor.tsx │ │ │ │ │ └── RuleEngineRuleList.tsx │ │ │ │ ├── search/ │ │ │ │ │ ├── QueryExplainerTooltip.tsx │ │ │ │ │ ├── SearchInput.tsx │ │ │ │ │ └── useSearchAutocomplete.ts │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── AllLists.tsx │ │ │ │ │ └── InvitationNotificationBadge.tsx │ │ │ │ └── tags/ │ │ │ │ ├── AllTagsView.tsx │ │ │ │ ├── BulkTagAction.tsx │ │ │ │ ├── CreateTagModal.tsx │ │ │ │ ├── DeleteTagConfirmationDialog.tsx │ │ │ │ ├── EditableTagName.tsx │ │ │ │ ├── MergeTagModal.tsx │ │ │ │ ├── MultiTagSelector.tsx │ │ │ │ ├── TagAutocomplete.tsx │ │ │ │ ├── TagOptions.tsx │ │ │ │ └── TagPill.tsx │ │ │ ├── invite/ │ │ │ │ └── InviteAcceptForm.tsx │ │ │ ├── public/ │ │ │ │ └── lists/ │ │ │ │ ├── PublicBookmarkGrid.tsx │ │ │ │ └── PublicListHeader.tsx │ │ │ ├── settings/ │ │ │ │ ├── AISettings.tsx │ │ │ │ ├── AddApiKey.tsx │ │ │ │ ├── ApiKeySettings.tsx │ │ │ │ ├── ApiKeySuccess.tsx │ │ │ │ ├── BackupSettings.tsx │ │ │ │ ├── ChangePassword.tsx │ │ │ │ ├── DeleteAccount.tsx │ │ │ │ ├── DeleteApiKey.tsx │ │ │ │ ├── FeedSettings.tsx │ │ │ │ ├── ImportExport.tsx │ │ │ │ ├── ImportSessionCard.tsx │ │ │ │ ├── ImportSessionDetail.tsx │ │ │ │ ├── ImportSessionsSection.tsx │ │ │ │ ├── ReaderSettings.tsx │ │ │ │ ├── RegenerateApiKey.tsx │ │ │ │ ├── SettingsPage.tsx │ │ │ │ ├── SubscriptionSettings.tsx │ │ │ │ ├── UserAvatar.tsx │ │ │ │ ├── UserDetails.tsx │ │ │ │ ├── UserOptions.tsx │ │ │ │ ├── WebhookEventSelector.tsx │ │ │ │ └── WebhookSettings.tsx │ │ │ ├── shared/ │ │ │ │ └── sidebar/ │ │ │ │ ├── MobileSidebar.tsx │ │ │ │ ├── ModileSidebarItem.tsx │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── SidebarItem.tsx │ │ │ │ ├── SidebarLayout.tsx │ │ │ │ ├── SidebarVersion.tsx │ │ │ │ └── TSidebarItem.ts │ │ │ ├── signin/ │ │ │ │ ├── CredentialsForm.tsx │ │ │ │ ├── ForgotPasswordForm.tsx │ │ │ │ ├── OAuthAutoRedirect.tsx │ │ │ │ ├── ResetPasswordForm.tsx │ │ │ │ ├── SignInForm.tsx │ │ │ │ └── SignInProviderButton.tsx │ │ │ ├── signup/ │ │ │ │ └── SignUpForm.tsx │ │ │ ├── subscription/ │ │ │ │ └── QuotaProgress.tsx │ │ │ ├── theme-provider.tsx │ │ │ ├── ui/ │ │ │ │ ├── action-button.tsx │ │ │ │ ├── action-confirming-dialog.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── back-button.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── calendar.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── collapsible.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── copy-button.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── field.tsx │ │ │ │ ├── file-picker-button.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── full-page-spinner.tsx │ │ │ │ ├── info-tooltip.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── kbd.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── markdown/ │ │ │ │ │ ├── markdown-editor.tsx │ │ │ │ │ ├── markdown-readonly.tsx │ │ │ │ │ ├── plugins/ │ │ │ │ │ │ └── toolbar-plugin.tsx │ │ │ │ │ └── theme.ts │ │ │ │ ├── multiple-choice-dialog.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── radio-group.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── slider.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── spinner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── toggle.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ └── user-avatar.tsx │ │ │ ├── utils/ │ │ │ │ ├── BookmarkAlreadyExistsToast.tsx │ │ │ │ ├── ValidAccountCheck.tsx │ │ │ │ └── useShowArchived.tsx │ │ │ └── wrapped/ │ │ │ ├── ShareButton.tsx │ │ │ ├── WrappedContent.tsx │ │ │ ├── WrappedModal.tsx │ │ │ └── index.ts │ │ ├── components.json │ │ ├── instrumentation.node.ts │ │ ├── instrumentation.ts │ │ ├── lib/ │ │ │ ├── attachments.tsx │ │ │ ├── auth/ │ │ │ │ └── client.ts │ │ │ ├── bookmark-drag.ts │ │ │ ├── bulkActions.ts │ │ │ ├── bulkTagActions.ts │ │ │ ├── clientConfig.tsx │ │ │ ├── drag-and-drop.ts │ │ │ ├── haptic.ts │ │ │ ├── hooks/ │ │ │ │ ├── bookmark-search.ts │ │ │ │ ├── relative-time.ts │ │ │ │ ├── upload-file.ts │ │ │ │ ├── useBookmarkImport.ts │ │ │ │ ├── useDialogFormReset.ts │ │ │ │ └── useImportSessions.ts │ │ │ ├── i18n/ │ │ │ │ ├── client.ts │ │ │ │ ├── locales/ │ │ │ │ │ ├── ar/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── cs/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── da/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── de/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── el/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── en/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── en_US/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── es/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── fa/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── fi/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── fr/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── ga/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── gl/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── hr/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── hu/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── it/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── ja/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── ko/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── nb_NO/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── nl/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── pl/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── pt/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── pt_BR/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── ru/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── sk/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── sl/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── sv/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── tr/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── uk/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── vi/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ ├── zh/ │ │ │ │ │ │ └── translation.json │ │ │ │ │ └── zhtw/ │ │ │ │ │ └── translation.json │ │ │ │ ├── provider.tsx │ │ │ │ ├── server.ts │ │ │ │ └── settings.ts │ │ │ ├── providers.tsx │ │ │ ├── readerSettings.tsx │ │ │ ├── store/ │ │ │ │ ├── useInBookmarkGridStore.ts │ │ │ │ ├── useInSearchPageStore.ts │ │ │ │ └── useSortOrderStore.ts │ │ │ ├── userLocalSettings/ │ │ │ │ ├── bookmarksLayout.tsx │ │ │ │ ├── types.ts │ │ │ │ └── userLocalSettings.ts │ │ │ ├── userSettings.tsx │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.mjs │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── public/ │ │ │ ├── blur.avif │ │ │ └── manifest.json │ │ ├── server/ │ │ │ ├── api/ │ │ │ │ └── client.ts │ │ │ └── auth.ts │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── workers/ │ ├── .oxlintrc.json │ ├── exit.ts │ ├── index.ts │ ├── metascraper-plugins/ │ │ ├── metascraper-amazon-improved.ts │ │ └── metascraper-reddit.ts │ ├── metrics.ts │ ├── network.ts │ ├── package.json │ ├── scripts/ │ │ └── parseHtmlSubprocess.ts │ ├── server.ts │ ├── trpc.ts │ ├── tsconfig.json │ ├── tsdown.config.ts │ ├── utils.ts │ ├── workerTracing.ts │ ├── workerUtils.ts │ └── workers/ │ ├── adminMaintenance/ │ │ └── tasks/ │ │ ├── migrateLinkHtmlContent.ts │ │ └── tidyAssets.ts │ ├── adminMaintenanceWorker.ts │ ├── assetPreprocessingWorker.ts │ ├── backupWorker.ts │ ├── crawlerWorker.ts │ ├── feedWorker.ts │ ├── importWorker.ts │ ├── inference/ │ │ ├── inferenceWorker.ts │ │ ├── summarize.ts │ │ └── tagging.ts │ ├── ruleEngineWorker.ts │ ├── searchWorker.ts │ ├── utils/ │ │ ├── __fixtures__/ │ │ │ └── twz-feed.xml │ │ ├── feedParser.test.ts │ │ ├── feedParser.ts │ │ ├── fetchBookmarks.ts │ │ └── parseHtmlSubprocessIpc.ts │ ├── videoWorker.ts │ └── webhookWorker.ts ├── charts/ │ └── README.md ├── docker/ │ ├── Dockerfile │ ├── Dockerfile.dev │ ├── docker-compose.build.yml │ ├── docker-compose.dev.yml │ ├── docker-compose.yml │ └── root/ │ └── etc/ │ └── s6-overlay/ │ └── s6-rc.d/ │ ├── init-db-migration/ │ │ ├── run │ │ ├── type │ │ └── up │ ├── svc-web/ │ │ ├── dependencies.d/ │ │ │ └── init-db-migration │ │ ├── run │ │ └── type │ ├── svc-workers/ │ │ ├── dependencies.d/ │ │ │ └── init-db-migration │ │ ├── run │ │ └── type │ └── user/ │ └── contents.d/ │ └── .gitkeep ├── docs/ │ ├── .gitignore │ ├── .oxlintrc.json │ ├── README.md │ ├── babel.config.js │ ├── docs/ │ │ ├── 01-getting-started/ │ │ │ ├── 01-intro.md │ │ │ ├── 02-screenshots.md │ │ │ └── _category_.json │ │ ├── 02-installation/ │ │ │ ├── 01-docker.md │ │ │ ├── 02-unraid.md │ │ │ ├── 03-archlinux.md │ │ │ ├── 04-kubernetes.md │ │ │ ├── 06-debuntu.md │ │ │ ├── 07-minimal-install.md │ │ │ ├── 08-truenas.md │ │ │ ├── 09-cloud-hosting.md │ │ │ ├── 10-pikapods.md │ │ │ └── _category_.json │ │ ├── 03-configuration/ │ │ │ ├── 01-environment-variables.md │ │ │ ├── 02-different-ai-providers.md │ │ │ └── _category_.json │ │ ├── 04-using-karakeep/ │ │ │ ├── _category_.json │ │ │ ├── advanced-workflows.md │ │ │ ├── bookmarking.md │ │ │ ├── import.md │ │ │ ├── lists.md │ │ │ ├── quick-sharing.md │ │ │ ├── search-query-language.md │ │ │ └── tags.md │ │ ├── 05-integrations/ │ │ │ ├── 02-command-line.md │ │ │ ├── 03-mcp.md │ │ │ ├── 05-singlefile.md │ │ │ ├── 06-rss-feeds.md │ │ │ └── _category_.json │ │ ├── 06-administration/ │ │ │ ├── 01-security-considerations.md │ │ │ ├── 02-FAQ.md │ │ │ ├── 03-openai.md │ │ │ ├── 05-troubleshooting.md │ │ │ ├── 06-server-migration.md │ │ │ ├── 07-legacy-container-upgrade.md │ │ │ ├── 08-hoarder-to-karakeep-migration.md │ │ │ └── _category_.json │ │ ├── 07-community/ │ │ │ ├── 01-community-projects.md │ │ │ ├── 02-community-channels.md │ │ │ └── _category_.json │ │ ├── 08-development/ │ │ │ ├── 01-setup.md │ │ │ ├── 02-directories.md │ │ │ ├── 03-database.md │ │ │ ├── 04-architecture.md │ │ │ └── _category_.json │ │ └── api/ │ │ ├── _category_.json │ │ ├── add-bookmark-to-list.api.mdx │ │ ├── admin-update-user.api.mdx │ │ ├── attach-asset-to-bookmark.api.mdx │ │ ├── attach-tags-to-bookmark.api.mdx │ │ ├── check-bookmark-url.api.mdx │ │ ├── create-backup.api.mdx │ │ ├── create-bookmark.api.mdx │ │ ├── create-highlight.api.mdx │ │ ├── create-list.api.mdx │ │ ├── create-tag.api.mdx │ │ ├── delete-backup.api.mdx │ │ ├── delete-bookmark.api.mdx │ │ ├── delete-highlight.api.mdx │ │ ├── delete-list.api.mdx │ │ ├── delete-tag.api.mdx │ │ ├── detach-asset-from-bookmark.api.mdx │ │ ├── detach-tags-from-bookmark.api.mdx │ │ ├── download-backup.api.mdx │ │ ├── get-asset.api.mdx │ │ ├── get-backup.api.mdx │ │ ├── get-bookmark-highlights.api.mdx │ │ ├── get-bookmark-lists.api.mdx │ │ ├── get-bookmark.api.mdx │ │ ├── get-current-user-stats.api.mdx │ │ ├── get-current-user.api.mdx │ │ ├── get-highlight.api.mdx │ │ ├── get-list-bookmarks.api.mdx │ │ ├── get-list.api.mdx │ │ ├── get-tag-bookmarks.api.mdx │ │ ├── get-tag.api.mdx │ │ ├── karakeep-api.info.mdx │ │ ├── list-backups.api.mdx │ │ ├── list-bookmarks.api.mdx │ │ ├── list-highlights.api.mdx │ │ ├── list-lists.api.mdx │ │ ├── list-tags.api.mdx │ │ ├── remove-bookmark-from-list.api.mdx │ │ ├── replace-asset-on-bookmark.api.mdx │ │ ├── search-bookmarks.api.mdx │ │ ├── sidebar.ts │ │ ├── summarize-bookmark.api.mdx │ │ ├── update-bookmark.api.mdx │ │ ├── update-highlight.api.mdx │ │ ├── update-list.api.mdx │ │ ├── update-tag.api.mdx │ │ └── upload-asset.api.mdx │ ├── docusaurus.config.ts │ ├── package.json │ ├── sidebars.ts │ ├── src/ │ │ ├── css/ │ │ │ └── custom.css │ │ └── theme/ │ │ └── DocSidebarItem/ │ │ └── Category/ │ │ └── index.tsx │ ├── static/ │ │ ├── .nojekyll │ │ ├── _redirects │ │ └── robots.txt │ ├── tsconfig.json │ ├── versioned_docs/ │ │ ├── version-v0.28.0/ │ │ │ ├── 01-intro.md │ │ │ ├── 02-installation/ │ │ │ │ ├── 01-docker.md │ │ │ │ ├── 02-unraid.md │ │ │ │ ├── 03-archlinux.md │ │ │ │ ├── 04-kubernetes.md │ │ │ │ ├── 05-pikapods.md │ │ │ │ ├── 06-debuntu.md │ │ │ │ ├── 07-minimal-install.md │ │ │ │ ├── 08-truenas.md │ │ │ │ └── _category_.json │ │ │ ├── 03-configuration.md │ │ │ ├── 04-screenshots.md │ │ │ ├── 05-quick-sharing.md │ │ │ ├── 06-openai.md │ │ │ ├── 07-development/ │ │ │ │ ├── 01-setup.md │ │ │ │ ├── 02-directories.md │ │ │ │ ├── 03-database.md │ │ │ │ ├── 04-architecture.md │ │ │ │ └── _category_.json │ │ │ ├── 08-security-considerations.md │ │ │ ├── 09-command-line.md │ │ │ ├── 09-mcp.md │ │ │ ├── 10-import.md │ │ │ ├── 11-FAQ.md │ │ │ ├── 12-troubleshooting.md │ │ │ ├── 13-community-projects.md │ │ │ ├── 14-guides/ │ │ │ │ ├── 01-legacy-container-upgrade.md │ │ │ │ ├── 02-search-query-language.md │ │ │ │ ├── 03-singlefile.md │ │ │ │ ├── 04-hoarder-to-karakeep-migration.md │ │ │ │ ├── 05-different-ai-providers.md │ │ │ │ ├── 06-server-migration.md │ │ │ │ └── _category_.json │ │ │ └── api/ │ │ │ ├── _category_.json │ │ │ ├── add-a-bookmark-to-a-list.api.mdx │ │ │ ├── attach-asset.api.mdx │ │ │ ├── attach-tags-to-a-bookmark.api.mdx │ │ │ ├── create-a-new-bookmark.api.mdx │ │ │ ├── create-a-new-highlight.api.mdx │ │ │ ├── create-a-new-list.api.mdx │ │ │ ├── create-a-new-tag.api.mdx │ │ │ ├── delete-a-bookmark.api.mdx │ │ │ ├── delete-a-highlight.api.mdx │ │ │ ├── delete-a-list.api.mdx │ │ │ ├── delete-a-tag.api.mdx │ │ │ ├── detach-asset.api.mdx │ │ │ ├── detach-tags-from-a-bookmark.api.mdx │ │ │ ├── get-a-single-asset.api.mdx │ │ │ ├── get-a-single-bookmark.api.mdx │ │ │ ├── get-a-single-highlight.api.mdx │ │ │ ├── get-a-single-list.api.mdx │ │ │ ├── get-a-single-tag.api.mdx │ │ │ ├── get-all-bookmarks.api.mdx │ │ │ ├── get-all-highlights.api.mdx │ │ │ ├── get-all-lists.api.mdx │ │ │ ├── get-all-tags.api.mdx │ │ │ ├── get-bookmarks-in-the-list.api.mdx │ │ │ ├── get-bookmarks-with-the-tag.api.mdx │ │ │ ├── get-current-user-info.api.mdx │ │ │ ├── get-current-user-stats.api.mdx │ │ │ ├── get-highlights-of-a-bookmark.api.mdx │ │ │ ├── get-lists-of-a-bookmark.api.mdx │ │ │ ├── karakeep-api.info.mdx │ │ │ ├── remove-a-bookmark-from-a-list.api.mdx │ │ │ ├── replace-asset.api.mdx │ │ │ ├── search-bookmarks.api.mdx │ │ │ ├── sidebar.ts │ │ │ ├── summarize-a-bookmark.api.mdx │ │ │ ├── update-a-bookmark.api.mdx │ │ │ ├── update-a-highlight.api.mdx │ │ │ ├── update-a-list.api.mdx │ │ │ ├── update-a-tag.api.mdx │ │ │ ├── update-user.api.mdx │ │ │ └── upload-a-new-asset.api.mdx │ │ ├── version-v0.29.0/ │ │ │ ├── 01-intro.md │ │ │ ├── 02-installation/ │ │ │ │ ├── 01-docker.md │ │ │ │ ├── 02-unraid.md │ │ │ │ ├── 03-archlinux.md │ │ │ │ ├── 04-kubernetes.md │ │ │ │ ├── 05-pikapods.md │ │ │ │ ├── 06-debuntu.md │ │ │ │ ├── 07-minimal-install.md │ │ │ │ ├── 08-truenas.md │ │ │ │ └── _category_.json │ │ │ ├── 03-configuration.md │ │ │ ├── 04-screenshots.md │ │ │ ├── 05-quick-sharing.md │ │ │ ├── 06-openai.md │ │ │ ├── 07-development/ │ │ │ │ ├── 01-setup.md │ │ │ │ ├── 02-directories.md │ │ │ │ ├── 03-database.md │ │ │ │ ├── 04-architecture.md │ │ │ │ └── _category_.json │ │ │ ├── 08-security-considerations.md │ │ │ ├── 09-command-line.md │ │ │ ├── 09-mcp.md │ │ │ ├── 10-import.md │ │ │ ├── 11-FAQ.md │ │ │ ├── 12-troubleshooting.md │ │ │ ├── 13-community-projects.md │ │ │ ├── 14-guides/ │ │ │ │ ├── 01-legacy-container-upgrade.md │ │ │ │ ├── 02-search-query-language.md │ │ │ │ ├── 03-singlefile.md │ │ │ │ ├── 04-hoarder-to-karakeep-migration.md │ │ │ │ ├── 05-different-ai-providers.md │ │ │ │ ├── 06-server-migration.md │ │ │ │ └── _category_.json │ │ │ └── api/ │ │ │ ├── _category_.json │ │ │ ├── add-a-bookmark-to-a-list.api.mdx │ │ │ ├── attach-asset.api.mdx │ │ │ ├── attach-tags-to-a-bookmark.api.mdx │ │ │ ├── create-a-new-bookmark.api.mdx │ │ │ ├── create-a-new-highlight.api.mdx │ │ │ ├── create-a-new-list.api.mdx │ │ │ ├── create-a-new-tag.api.mdx │ │ │ ├── delete-a-backup.api.mdx │ │ │ ├── delete-a-bookmark.api.mdx │ │ │ ├── delete-a-highlight.api.mdx │ │ │ ├── delete-a-list.api.mdx │ │ │ ├── delete-a-tag.api.mdx │ │ │ ├── detach-asset.api.mdx │ │ │ ├── detach-tags-from-a-bookmark.api.mdx │ │ │ ├── download-a-backup.api.mdx │ │ │ ├── get-a-single-asset.api.mdx │ │ │ ├── get-a-single-backup.api.mdx │ │ │ ├── get-a-single-bookmark.api.mdx │ │ │ ├── get-a-single-highlight.api.mdx │ │ │ ├── get-a-single-list.api.mdx │ │ │ ├── get-a-single-tag.api.mdx │ │ │ ├── get-all-backups.api.mdx │ │ │ ├── get-all-bookmarks.api.mdx │ │ │ ├── get-all-highlights.api.mdx │ │ │ ├── get-all-lists.api.mdx │ │ │ ├── get-all-tags.api.mdx │ │ │ ├── get-bookmarks-in-the-list.api.mdx │ │ │ ├── get-bookmarks-with-the-tag.api.mdx │ │ │ ├── get-current-user-info.api.mdx │ │ │ ├── get-current-user-stats.api.mdx │ │ │ ├── get-highlights-of-a-bookmark.api.mdx │ │ │ ├── get-lists-of-a-bookmark.api.mdx │ │ │ ├── karakeep-api.info.mdx │ │ │ ├── remove-a-bookmark-from-a-list.api.mdx │ │ │ ├── replace-asset.api.mdx │ │ │ ├── search-bookmarks.api.mdx │ │ │ ├── sidebar.ts │ │ │ ├── summarize-a-bookmark.api.mdx │ │ │ ├── trigger-a-new-backup.api.mdx │ │ │ ├── update-a-bookmark.api.mdx │ │ │ ├── update-a-highlight.api.mdx │ │ │ ├── update-a-list.api.mdx │ │ │ ├── update-a-tag.api.mdx │ │ │ ├── update-user.api.mdx │ │ │ └── upload-a-new-asset.api.mdx │ │ ├── version-v0.30.0/ │ │ │ ├── 01-getting-started/ │ │ │ │ ├── 01-intro.md │ │ │ │ ├── 02-screenshots.md │ │ │ │ └── _category_.json │ │ │ ├── 02-installation/ │ │ │ │ ├── 01-docker.md │ │ │ │ ├── 02-unraid.md │ │ │ │ ├── 03-archlinux.md │ │ │ │ ├── 04-kubernetes.md │ │ │ │ ├── 06-debuntu.md │ │ │ │ ├── 07-minimal-install.md │ │ │ │ ├── 08-truenas.md │ │ │ │ ├── 09-cloud-hosting.md │ │ │ │ ├── 10-pikapods.md │ │ │ │ └── _category_.json │ │ │ ├── 03-configuration/ │ │ │ │ ├── 01-environment-variables.md │ │ │ │ ├── 02-different-ai-providers.md │ │ │ │ └── _category_.json │ │ │ ├── 04-using-karakeep/ │ │ │ │ ├── _category_.json │ │ │ │ ├── advanced-workflows.md │ │ │ │ ├── bookmarking.md │ │ │ │ ├── import.md │ │ │ │ ├── lists.md │ │ │ │ ├── quick-sharing.md │ │ │ │ ├── search-query-language.md │ │ │ │ └── tags.md │ │ │ ├── 05-integrations/ │ │ │ │ ├── 02-command-line.md │ │ │ │ ├── 03-mcp.md │ │ │ │ ├── 05-singlefile.md │ │ │ │ ├── 06-rss-feeds.md │ │ │ │ └── _category_.json │ │ │ ├── 06-administration/ │ │ │ │ ├── 01-security-considerations.md │ │ │ │ ├── 02-FAQ.md │ │ │ │ ├── 03-openai.md │ │ │ │ ├── 05-troubleshooting.md │ │ │ │ ├── 06-server-migration.md │ │ │ │ ├── 07-legacy-container-upgrade.md │ │ │ │ ├── 08-hoarder-to-karakeep-migration.md │ │ │ │ └── _category_.json │ │ │ ├── 07-community/ │ │ │ │ ├── 01-community-projects.md │ │ │ │ ├── 02-community-channels.md │ │ │ │ └── _category_.json │ │ │ ├── 08-development/ │ │ │ │ ├── 01-setup.md │ │ │ │ ├── 02-directories.md │ │ │ │ ├── 03-database.md │ │ │ │ ├── 04-architecture.md │ │ │ │ └── _category_.json │ │ │ └── api/ │ │ │ ├── _category_.json │ │ │ ├── add-a-bookmark-to-a-list.api.mdx │ │ │ ├── attach-asset.api.mdx │ │ │ ├── attach-tags-to-a-bookmark.api.mdx │ │ │ ├── create-a-new-bookmark.api.mdx │ │ │ ├── create-a-new-highlight.api.mdx │ │ │ ├── create-a-new-list.api.mdx │ │ │ ├── create-a-new-tag.api.mdx │ │ │ ├── delete-a-backup.api.mdx │ │ │ ├── delete-a-bookmark.api.mdx │ │ │ ├── delete-a-highlight.api.mdx │ │ │ ├── delete-a-list.api.mdx │ │ │ ├── delete-a-tag.api.mdx │ │ │ ├── detach-asset.api.mdx │ │ │ ├── detach-tags-from-a-bookmark.api.mdx │ │ │ ├── download-a-backup.api.mdx │ │ │ ├── get-a-single-asset.api.mdx │ │ │ ├── get-a-single-backup.api.mdx │ │ │ ├── get-a-single-bookmark.api.mdx │ │ │ ├── get-a-single-highlight.api.mdx │ │ │ ├── get-a-single-list.api.mdx │ │ │ ├── get-a-single-tag.api.mdx │ │ │ ├── get-all-backups.api.mdx │ │ │ ├── get-all-bookmarks.api.mdx │ │ │ ├── get-all-highlights.api.mdx │ │ │ ├── get-all-lists.api.mdx │ │ │ ├── get-all-tags.api.mdx │ │ │ ├── get-bookmarks-in-the-list.api.mdx │ │ │ ├── get-bookmarks-with-the-tag.api.mdx │ │ │ ├── get-current-user-info.api.mdx │ │ │ ├── get-current-user-stats.api.mdx │ │ │ ├── get-highlights-of-a-bookmark.api.mdx │ │ │ ├── get-lists-of-a-bookmark.api.mdx │ │ │ ├── karakeep-api.info.mdx │ │ │ ├── remove-a-bookmark-from-a-list.api.mdx │ │ │ ├── replace-asset.api.mdx │ │ │ ├── search-bookmarks.api.mdx │ │ │ ├── sidebar.ts │ │ │ ├── summarize-a-bookmark.api.mdx │ │ │ ├── trigger-a-new-backup.api.mdx │ │ │ ├── update-a-bookmark.api.mdx │ │ │ ├── update-a-highlight.api.mdx │ │ │ ├── update-a-list.api.mdx │ │ │ ├── update-a-tag.api.mdx │ │ │ ├── update-user.api.mdx │ │ │ └── upload-a-new-asset.api.mdx │ │ └── version-v0.31.0/ │ │ ├── 01-getting-started/ │ │ │ ├── 01-intro.md │ │ │ ├── 02-screenshots.md │ │ │ └── _category_.json │ │ ├── 02-installation/ │ │ │ ├── 01-docker.md │ │ │ ├── 02-unraid.md │ │ │ ├── 03-archlinux.md │ │ │ ├── 04-kubernetes.md │ │ │ ├── 06-debuntu.md │ │ │ ├── 07-minimal-install.md │ │ │ ├── 08-truenas.md │ │ │ ├── 09-cloud-hosting.md │ │ │ ├── 10-pikapods.md │ │ │ └── _category_.json │ │ ├── 03-configuration/ │ │ │ ├── 01-environment-variables.md │ │ │ ├── 02-different-ai-providers.md │ │ │ └── _category_.json │ │ ├── 04-using-karakeep/ │ │ │ ├── _category_.json │ │ │ ├── advanced-workflows.md │ │ │ ├── bookmarking.md │ │ │ ├── import.md │ │ │ ├── lists.md │ │ │ ├── quick-sharing.md │ │ │ ├── search-query-language.md │ │ │ └── tags.md │ │ ├── 05-integrations/ │ │ │ ├── 02-command-line.md │ │ │ ├── 03-mcp.md │ │ │ ├── 05-singlefile.md │ │ │ ├── 06-rss-feeds.md │ │ │ └── _category_.json │ │ ├── 06-administration/ │ │ │ ├── 01-security-considerations.md │ │ │ ├── 02-FAQ.md │ │ │ ├── 03-openai.md │ │ │ ├── 05-troubleshooting.md │ │ │ ├── 06-server-migration.md │ │ │ ├── 07-legacy-container-upgrade.md │ │ │ ├── 08-hoarder-to-karakeep-migration.md │ │ │ └── _category_.json │ │ ├── 07-community/ │ │ │ ├── 01-community-projects.md │ │ │ ├── 02-community-channels.md │ │ │ └── _category_.json │ │ ├── 08-development/ │ │ │ ├── 01-setup.md │ │ │ ├── 02-directories.md │ │ │ ├── 03-database.md │ │ │ ├── 04-architecture.md │ │ │ └── _category_.json │ │ └── api/ │ │ ├── _category_.json │ │ ├── add-bookmark-to-list.api.mdx │ │ ├── admin-update-user.api.mdx │ │ ├── attach-asset-to-bookmark.api.mdx │ │ ├── attach-tags-to-bookmark.api.mdx │ │ ├── check-bookmark-url.api.mdx │ │ ├── create-backup.api.mdx │ │ ├── create-bookmark.api.mdx │ │ ├── create-highlight.api.mdx │ │ ├── create-list.api.mdx │ │ ├── create-tag.api.mdx │ │ ├── delete-backup.api.mdx │ │ ├── delete-bookmark.api.mdx │ │ ├── delete-highlight.api.mdx │ │ ├── delete-list.api.mdx │ │ ├── delete-tag.api.mdx │ │ ├── detach-asset-from-bookmark.api.mdx │ │ ├── detach-tags-from-bookmark.api.mdx │ │ ├── download-backup.api.mdx │ │ ├── get-asset.api.mdx │ │ ├── get-backup.api.mdx │ │ ├── get-bookmark-highlights.api.mdx │ │ ├── get-bookmark-lists.api.mdx │ │ ├── get-bookmark.api.mdx │ │ ├── get-current-user-stats.api.mdx │ │ ├── get-current-user.api.mdx │ │ ├── get-highlight.api.mdx │ │ ├── get-list-bookmarks.api.mdx │ │ ├── get-list.api.mdx │ │ ├── get-tag-bookmarks.api.mdx │ │ ├── get-tag.api.mdx │ │ ├── karakeep-api.info.mdx │ │ ├── list-backups.api.mdx │ │ ├── list-bookmarks.api.mdx │ │ ├── list-highlights.api.mdx │ │ ├── list-lists.api.mdx │ │ ├── list-tags.api.mdx │ │ ├── remove-bookmark-from-list.api.mdx │ │ ├── replace-asset-on-bookmark.api.mdx │ │ ├── search-bookmarks.api.mdx │ │ ├── sidebar.ts │ │ ├── summarize-bookmark.api.mdx │ │ ├── update-bookmark.api.mdx │ │ ├── update-highlight.api.mdx │ │ ├── update-list.api.mdx │ │ ├── update-tag.api.mdx │ │ └── upload-asset.api.mdx │ ├── versioned_sidebars/ │ │ ├── version-v0.28.0-sidebars.json │ │ ├── version-v0.29.0-sidebars.json │ │ ├── version-v0.30.0-sidebars.json │ │ └── version-v0.31.0-sidebars.json │ └── versions.json ├── karakeep-linux.sh ├── kubernetes/ │ ├── .env_sample │ ├── .gitignore │ ├── .secrets_sample │ ├── Makefile │ ├── README.md │ ├── chrome-deployment.yaml │ ├── chrome-service.yaml │ ├── data-pvc.yaml │ ├── ingress_sample.yaml │ ├── kustomization.yaml │ ├── meilisearch-deployment.yaml │ ├── meilisearch-pvc.yaml │ ├── meilisearch-service.yaml │ ├── namespace.yaml │ ├── web-deployment.yaml │ └── web-service.yaml ├── package.json ├── packages/ │ ├── api/ │ │ ├── .oxlintrc.json │ │ ├── index.ts │ │ ├── middlewares/ │ │ │ ├── auth.ts │ │ │ └── trpcAdapter.ts │ │ ├── package.json │ │ ├── routes/ │ │ │ ├── admin.ts │ │ │ ├── assets.ts │ │ │ ├── backups.ts │ │ │ ├── bookmarks.ts │ │ │ ├── health.ts │ │ │ ├── highlights.ts │ │ │ ├── lists.ts │ │ │ ├── metrics.ts │ │ │ ├── public/ │ │ │ │ └── assets.ts │ │ │ ├── public.ts │ │ │ ├── rss.ts │ │ │ ├── tags.ts │ │ │ ├── trpc.ts │ │ │ ├── users.ts │ │ │ ├── version.ts │ │ │ └── webhooks.ts │ │ ├── tsconfig.json │ │ └── utils/ │ │ ├── assets.ts │ │ ├── pagination.ts │ │ ├── rss.ts │ │ ├── types.ts │ │ └── upload.ts │ ├── benchmarks/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── package.json │ │ ├── setup/ │ │ │ └── html/ │ │ │ └── hello.html │ │ ├── src/ │ │ │ ├── benchmarks.ts │ │ │ ├── index.ts │ │ │ ├── log.ts │ │ │ ├── seed.ts │ │ │ ├── startContainers.ts │ │ │ ├── trpc.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── db/ │ │ ├── .oxlintrc.json │ │ ├── drizzle/ │ │ │ ├── 0000_luxuriant_johnny_blaze.sql │ │ │ ├── 0001_dapper_trauma.sql │ │ │ ├── 0002_worried_beyonder.sql │ │ │ ├── 0003_parallel_supernaut.sql │ │ │ ├── 0004_skinny_vengeance.sql │ │ │ ├── 0005_quiet_gunslinger.sql │ │ │ ├── 0006_funny_mac_gargan.sql │ │ │ ├── 0007_messy_raza.sql │ │ │ ├── 0008_cloudy_skin.sql │ │ │ ├── 0009_cuddly_cammi.sql │ │ │ ├── 0010_curved_sharon_ventura.sql │ │ │ ├── 0011_ordinary_phalanx.sql │ │ │ ├── 0012_noisy_grim_reaper.sql │ │ │ ├── 0013_square_lady_ursula.sql │ │ │ ├── 0014_lonely_thaddeus_ross.sql │ │ │ ├── 0015_first_reavers.sql │ │ │ ├── 0016_shallow_rawhide_kid.sql │ │ │ ├── 0017_slippery_senator_kelly.sql │ │ │ ├── 0018_bright_infant_terrible.sql │ │ │ ├── 0019_many_vertigo.sql │ │ │ ├── 0020_sudden_dagger.sql │ │ │ ├── 0021_magical_firebrand.sql │ │ │ ├── 0022_tough_nextwave.sql │ │ │ ├── 0023_late_night_nurse.sql │ │ │ ├── 0024_premium_hammerhead.sql │ │ │ ├── 0025_aspiring_skaar.sql │ │ │ ├── 0026_silky_imperial_guard.sql │ │ │ ├── 0027_cute_talon.sql │ │ │ ├── 0028_melodic_norrin_radd.sql │ │ │ ├── 0029_short_gunslinger.sql │ │ │ ├── 0030_blue_synch.sql │ │ │ ├── 0031_yummy_famine.sql │ │ │ ├── 0032_futuristic_shiva.sql │ │ │ ├── 0033_nappy_molten_man.sql │ │ │ ├── 0034_wet_the_stranger.sql │ │ │ ├── 0035_gorgeous_may_parker.sql │ │ │ ├── 0036_luxuriant_white_queen.sql │ │ │ ├── 0037_daily_smiling_tiger.sql │ │ │ ├── 0038_calm_clint_barton.sql │ │ │ ├── 0039_purple_albert_cleary.sql │ │ │ ├── 0040_long_mindworm.sql │ │ │ ├── 0041_fat_bloodstrike.sql │ │ │ ├── 0042_square_gamma_corps.sql │ │ │ ├── 0043_puzzling_blonde_phantom.sql │ │ │ ├── 0044_add_password_salt.sql │ │ │ ├── 0045_add_rule_engine.sql │ │ │ ├── 0046_add_rss_feed_enabled_col.sql │ │ │ ├── 0047_add_summarization_status.sql │ │ │ ├── 0048_add_user_settings.sql │ │ │ ├── 0049_add_rss_token.sql │ │ │ ├── 0050_add_user_settings_archive_display_behaviour.sql │ │ │ ├── 0051_public_lists.sql │ │ │ ├── 0052_add_bookmark_quota.sql │ │ │ ├── 0053_storage_quota.sql │ │ │ ├── 0054_add_timezone.sql │ │ │ ├── 0055_content_asset_id.sql │ │ │ ├── 0056_user_invites.sql │ │ │ ├── 0057_salty_carmella_unuscione.sql │ │ │ ├── 0058_add_subscription.sql │ │ │ ├── 0059_browserless_user_setting.sql │ │ │ ├── 0060_drop_invite_expire_at.sql │ │ │ ├── 0061_merge_user_settings.sql │ │ │ ├── 0062_add_import_session.sql │ │ │ ├── 0063_add_bookmark_source.sql │ │ │ ├── 0064_add_import_tags_to_feeds.sql │ │ │ ├── 0065_collaborative_lists.sql │ │ │ ├── 0066_collaborative_lists_invites.sql │ │ │ ├── 0067_add_backups_table.sql │ │ │ ├── 0068_optimize_bookmark_indicies.sql │ │ │ ├── 0069_fix_pending_summarization.sql │ │ │ ├── 0070_add_reader_settings.sql │ │ │ ├── 0071_add_normalized_tag_name.sql │ │ │ ├── 0072_add_user_ai_preferences.sql │ │ │ ├── 0073_ai_tag_style.sql │ │ │ ├── 0074_reset_tagging_summarization.sql │ │ │ ├── 0075_change_default_tag_style.sql │ │ │ ├── 0076_add_api_key_last_used_tracking.sql │ │ │ ├── 0077_import_listpaths_to_listids.sql │ │ │ ├── 0078_add_import_session_indexes.sql │ │ │ ├── 0079_add_tag_granularity_settings.sql │ │ │ ├── 0080_user_reading_progress.sql │ │ │ └── meta/ │ │ │ ├── 0000_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0003_snapshot.json │ │ │ ├── 0004_snapshot.json │ │ │ ├── 0005_snapshot.json │ │ │ ├── 0006_snapshot.json │ │ │ ├── 0007_snapshot.json │ │ │ ├── 0008_snapshot.json │ │ │ ├── 0009_snapshot.json │ │ │ ├── 0010_snapshot.json │ │ │ ├── 0011_snapshot.json │ │ │ ├── 0012_snapshot.json │ │ │ ├── 0013_snapshot.json │ │ │ ├── 0014_snapshot.json │ │ │ ├── 0015_snapshot.json │ │ │ ├── 0016_snapshot.json │ │ │ ├── 0017_snapshot.json │ │ │ ├── 0018_snapshot.json │ │ │ ├── 0019_snapshot.json │ │ │ ├── 0020_snapshot.json │ │ │ ├── 0021_snapshot.json │ │ │ ├── 0022_snapshot.json │ │ │ ├── 0023_snapshot.json │ │ │ ├── 0024_snapshot.json │ │ │ ├── 0025_snapshot.json │ │ │ ├── 0026_snapshot.json │ │ │ ├── 0027_snapshot.json │ │ │ ├── 0028_snapshot.json │ │ │ ├── 0029_snapshot.json │ │ │ ├── 0030_snapshot.json │ │ │ ├── 0031_snapshot.json │ │ │ ├── 0032_snapshot.json │ │ │ ├── 0033_snapshot.json │ │ │ ├── 0034_snapshot.json │ │ │ ├── 0035_snapshot.json │ │ │ ├── 0036_snapshot.json │ │ │ ├── 0037_snapshot.json │ │ │ ├── 0038_snapshot.json │ │ │ ├── 0039_snapshot.json │ │ │ ├── 0040_snapshot.json │ │ │ ├── 0041_snapshot.json │ │ │ ├── 0042_snapshot.json │ │ │ ├── 0043_snapshot.json │ │ │ ├── 0044_snapshot.json │ │ │ ├── 0045_snapshot.json │ │ │ ├── 0046_snapshot.json │ │ │ ├── 0047_snapshot.json │ │ │ ├── 0048_snapshot.json │ │ │ ├── 0049_snapshot.json │ │ │ ├── 0050_snapshot.json │ │ │ ├── 0051_snapshot.json │ │ │ ├── 0052_snapshot.json │ │ │ ├── 0053_snapshot.json │ │ │ ├── 0054_snapshot.json │ │ │ ├── 0055_snapshot.json │ │ │ ├── 0056_snapshot.json │ │ │ ├── 0057_snapshot.json │ │ │ ├── 0058_snapshot.json │ │ │ ├── 0059_snapshot.json │ │ │ ├── 0060_snapshot.json │ │ │ ├── 0061_snapshot.json │ │ │ ├── 0062_snapshot.json │ │ │ ├── 0063_snapshot.json │ │ │ ├── 0064_snapshot.json │ │ │ ├── 0065_snapshot.json │ │ │ ├── 0066_snapshot.json │ │ │ ├── 0067_snapshot.json │ │ │ ├── 0068_snapshot.json │ │ │ ├── 0069_snapshot.json │ │ │ ├── 0070_snapshot.json │ │ │ ├── 0071_snapshot.json │ │ │ ├── 0072_snapshot.json │ │ │ ├── 0073_snapshot.json │ │ │ ├── 0074_snapshot.json │ │ │ ├── 0075_snapshot.json │ │ │ ├── 0076_snapshot.json │ │ │ ├── 0077_snapshot.json │ │ │ ├── 0078_snapshot.json │ │ │ ├── 0079_snapshot.json │ │ │ ├── 0080_snapshot.json │ │ │ └── _journal.json │ │ ├── drizzle.config.ts │ │ ├── drizzle.ts │ │ ├── index.ts │ │ ├── instrumentation.ts │ │ ├── migrate.ts │ │ ├── package.json │ │ ├── schema.ts │ │ └── tsconfig.json │ ├── e2e_tests/ │ │ ├── .gitignore │ │ ├── .oxlintrc.json │ │ ├── docker-compose.yml │ │ ├── package.json │ │ ├── setup/ │ │ │ ├── html/ │ │ │ │ ├── feed.xml │ │ │ │ └── hello.html │ │ │ ├── seed.ts │ │ │ └── startContainers.ts │ │ ├── tests/ │ │ │ ├── api/ │ │ │ │ ├── assets.test.ts │ │ │ │ ├── backups.test.ts │ │ │ │ ├── bookmarks.test.ts │ │ │ │ ├── highlights.test.ts │ │ │ │ ├── lists.test.ts │ │ │ │ ├── public.test.ts │ │ │ │ ├── rss.test.ts │ │ │ │ ├── tags.test.ts │ │ │ │ └── users.test.ts │ │ │ ├── assetdb/ │ │ │ │ ├── assetdb-utils.ts │ │ │ │ ├── interface-compliance.test.ts │ │ │ │ ├── local-filesystem-store.test.ts │ │ │ │ └── s3-store.test.ts │ │ │ └── workers/ │ │ │ ├── crawler.test.ts │ │ │ ├── feed.test.ts │ │ │ └── import.test.ts │ │ ├── tsconfig.json │ │ ├── utils/ │ │ │ ├── api.ts │ │ │ ├── assets.ts │ │ │ ├── general.ts │ │ │ └── trpc.ts │ │ └── vitest.config.ts │ ├── open-api/ │ │ ├── .oxlintrc.json │ │ ├── index.ts │ │ ├── karakeep-openapi-spec.json │ │ ├── lib/ │ │ │ ├── admin.ts │ │ │ ├── assets.ts │ │ │ ├── backups.ts │ │ │ ├── bookmarks.ts │ │ │ ├── common.ts │ │ │ ├── errors.ts │ │ │ ├── highlights.ts │ │ │ ├── lists.ts │ │ │ ├── pagination.ts │ │ │ ├── tags.ts │ │ │ ├── types.ts │ │ │ └── users.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── plugins/ │ │ ├── .oxlintrc.json │ │ ├── package.json │ │ ├── queue-liteque/ │ │ │ ├── index.ts │ │ │ └── src/ │ │ │ └── index.ts │ │ ├── queue-restate/ │ │ │ ├── index.ts │ │ │ └── src/ │ │ │ ├── admin.ts │ │ │ ├── dispatcher.ts │ │ │ ├── env.ts │ │ │ ├── idProvider.ts │ │ │ ├── index.ts │ │ │ ├── runner.ts │ │ │ ├── semaphore.ts │ │ │ ├── service.ts │ │ │ ├── tests/ │ │ │ │ ├── docker-compose.yml │ │ │ │ ├── queue.test.ts │ │ │ │ ├── setup/ │ │ │ │ │ └── startContainers.ts │ │ │ │ └── utils.ts │ │ │ └── types.ts │ │ ├── ratelimit-memory/ │ │ │ ├── index.ts │ │ │ └── src/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── ratelimit-redis/ │ │ │ ├── index.ts │ │ │ └── src/ │ │ │ ├── index.ts │ │ │ └── tests/ │ │ │ ├── docker-compose.yml │ │ │ ├── ratelimit-redis.test.ts │ │ │ ├── setup/ │ │ │ │ └── startContainers.ts │ │ │ └── utils.ts │ │ ├── search-meilisearch/ │ │ │ ├── index.ts │ │ │ └── src/ │ │ │ ├── env.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── sdk/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── .oxlintrc.json │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── karakeep-api.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── shared/ │ │ ├── .oxlintrc.json │ │ ├── assetdb.ts │ │ ├── concurrency.test.ts │ │ ├── concurrency.ts │ │ ├── config.ts │ │ ├── customFetch.ts │ │ ├── debug.ts │ │ ├── import-export/ │ │ │ ├── exporters.ts │ │ │ ├── fixtures/ │ │ │ │ └── linkwarden-export.json │ │ │ ├── importer.test.ts │ │ │ ├── importer.ts │ │ │ ├── index.ts │ │ │ ├── parsers.test.ts │ │ │ └── parsers.ts │ │ ├── index.ts │ │ ├── inference.ts │ │ ├── langs.ts │ │ ├── logger.ts │ │ ├── package.json │ │ ├── plugins.ts │ │ ├── prompts.server.ts │ │ ├── prompts.ts │ │ ├── queueing.ts │ │ ├── ratelimiting.ts │ │ ├── search.ts │ │ ├── searchQueryParser.test.ts │ │ ├── searchQueryParser.ts │ │ ├── signedTokens.test.ts │ │ ├── signedTokens.ts │ │ ├── storageQuota.ts │ │ ├── trpc.ts │ │ ├── tryCatch.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── admin.ts │ │ │ ├── assets.ts │ │ │ ├── backups.ts │ │ │ ├── bookmarks.ts │ │ │ ├── config.ts │ │ │ ├── feeds.ts │ │ │ ├── highlights.ts │ │ │ ├── importSessions.ts │ │ │ ├── lists.ts │ │ │ ├── pagination.ts │ │ │ ├── prompts.ts │ │ │ ├── readers.ts │ │ │ ├── rules.ts │ │ │ ├── search.ts │ │ │ ├── tags.ts │ │ │ ├── uploads.ts │ │ │ ├── users.ts │ │ │ └── webhooks.ts │ │ ├── utils/ │ │ │ ├── assetUtils.ts │ │ │ ├── bookmarkUtils.ts │ │ │ ├── htmlUtils.ts │ │ │ ├── listUtils.ts │ │ │ ├── reading-progress-dom.test.ts │ │ │ ├── reading-progress-dom.ts │ │ │ ├── redirectUrl.test.ts │ │ │ ├── redirectUrl.ts │ │ │ ├── relativeDateUtils.ts │ │ │ ├── switch.ts │ │ │ └── tag.ts │ │ └── vitest.config.ts │ ├── shared-react/ │ │ ├── .oxlintrc.json │ │ ├── components/ │ │ │ ├── BookmarkHtmlHighlighter.tsx │ │ │ ├── ScrollProgressTracker.tsx │ │ │ ├── highlights.ts │ │ │ └── ui/ │ │ │ ├── button.tsx │ │ │ ├── popover.tsx │ │ │ └── textarea.tsx │ │ ├── hooks/ │ │ │ ├── assets.ts │ │ │ ├── bookmark-grid-context.tsx │ │ │ ├── bookmark-list-context.tsx │ │ │ ├── bookmarks.ts │ │ │ ├── highlights.ts │ │ │ ├── lists.ts │ │ │ ├── reader-settings.tsx │ │ │ ├── reading-progress.ts │ │ │ ├── rules.ts │ │ │ ├── search-history.ts │ │ │ ├── tags.ts │ │ │ ├── use-debounce.ts │ │ │ └── users.ts │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── providers/ │ │ │ └── trpc-provider.tsx │ │ ├── trpc.ts │ │ └── tsconfig.json │ ├── shared-server/ │ │ ├── .oxlintrc.json │ │ ├── index.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── plugins.ts │ │ │ ├── queues.ts │ │ │ ├── services/ │ │ │ │ └── quotaService.ts │ │ │ ├── tracing.ts │ │ │ └── tracingTypes.ts │ │ └── tsconfig.json │ └── trpc/ │ ├── .oxlintrc.json │ ├── auth.ts │ ├── email.ts │ ├── index.ts │ ├── lib/ │ │ ├── __tests__/ │ │ │ ├── ruleEngine.test.ts │ │ │ └── search.test.ts │ │ ├── attachments.ts │ │ ├── impersonate.ts │ │ ├── rateLimit.ts │ │ ├── ruleEngine.ts │ │ ├── search.ts │ │ ├── tracing.ts │ │ └── turnstile.ts │ ├── models/ │ │ ├── assets.ts │ │ ├── backups.ts │ │ ├── bookmarks.ts │ │ ├── feeds.ts │ │ ├── highlights.ts │ │ ├── importSessions.ts │ │ ├── listInvitations.ts │ │ ├── lists.ts │ │ ├── rules.ts │ │ ├── tags.ts │ │ ├── users.ts │ │ └── webhooks.ts │ ├── package.json │ ├── routers/ │ │ ├── _app.ts │ │ ├── admin.test.ts │ │ ├── admin.ts │ │ ├── apiKeys.test.ts │ │ ├── apiKeys.ts │ │ ├── assets.test.ts │ │ ├── assets.ts │ │ ├── backups.ts │ │ ├── bookmarks.test.ts │ │ ├── bookmarks.ts │ │ ├── config.ts │ │ ├── feeds.test.ts │ │ ├── feeds.ts │ │ ├── highlights.test.ts │ │ ├── highlights.ts │ │ ├── importSessions.test.ts │ │ ├── importSessions.ts │ │ ├── invites.test.ts │ │ ├── invites.ts │ │ ├── lists.test.ts │ │ ├── lists.ts │ │ ├── prompts.test.ts │ │ ├── prompts.ts │ │ ├── publicBookmarks.ts │ │ ├── rules.test.ts │ │ ├── rules.ts │ │ ├── sharedLists.test.ts │ │ ├── subscriptions.test.ts │ │ ├── subscriptions.ts │ │ ├── tags.test.ts │ │ ├── tags.ts │ │ ├── users.test.ts │ │ ├── users.ts │ │ ├── webhooks.test.ts │ │ └── webhooks.ts │ ├── stats.ts │ ├── testUtils.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── patches/ │ ├── playwright-extra@4.3.6.patch │ └── xcode@3.0.1.patch ├── pnpm-workspace.yaml ├── start-dev.sh ├── tooling/ │ ├── github/ │ │ ├── package.json │ │ └── setup/ │ │ └── action.yml │ ├── oxlint/ │ │ ├── oxlint-base.json │ │ ├── oxlint-nextjs.json │ │ ├── oxlint-react.json │ │ └── package.json │ ├── prettier/ │ │ ├── index.js │ │ ├── package.json │ │ └── tsconfig.json │ ├── tailwind/ │ │ ├── .oxlintrc.json │ │ ├── base.ts │ │ ├── globals.css │ │ ├── native.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── web.ts │ └── typescript/ │ ├── base.json │ ├── node.json │ └── package.json ├── tools/ │ └── compare-models/ │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── apiClient.ts │ │ ├── bookmarkProcessor.ts │ │ ├── config.ts │ │ ├── index.ts │ │ ├── inferenceClient.ts │ │ ├── interactive.ts │ │ └── types.ts │ └── tsconfig.json └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.json ================================================ { "permissions": { "allow": [ "Bash(pnpm typecheck:*)", "Bash(pnpm --filter * typecheck:*)", "Bash(pnpm lint:*)", "Bash(pnpm --filter * lint:*)", "Bash(pnpm format:*)", "Bash(pnpm --filter * format:*)", "Bash(pnpm test:*)", "Bash(pnpm --filter * test:*)" ], "deny": [] } } ================================================ FILE: .dockerignore ================================================ Dockerfile .dockerignore **/node_modules npm-debug.log README.md **/.next **/*.db **/.env* .turbo .git ./data # Files that don't need to be included in the Docker image docs kubernetes charts apps/mobile apps/landing apps/browser-extension packages/e2e_tests packages/benchmarks # Aider .aider* # next.js .next/ out/ .claude/worktrees ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms buy_me_a_coffee: mbassem github: MohamedBassem ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Create a report to help us fix bugs & issues in existing supported functionality labels: ["bug", "status/untriaged"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out a bug report! Please note that this form is for reporting bugs in existing supported functionality. If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead. - type: textarea id: description attributes: label: Describe the Bug description: Provide a clear and concise description of what the bug is. validations: required: true - type: textarea id: reproduction attributes: label: Steps to Reproduce description: Detail the steps that would replicate this issue. placeholder: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected attributes: label: Expected Behaviour description: Provide clear and concise description of what you expected to happen. validations: required: true - type: textarea id: context attributes: label: Screenshots or Additional Context description: Provide any additional context and screenshots here to help us solve this issue. validations: required: false - type: input id: devicedetails attributes: label: Device Details description: | If this is an issue that occurs when using the Karakeep interface, please provide details of the device/browser used which presents the reported issue. placeholder: (eg. Firefox 97 (64-bit) on Windows 11) validations: required: false - type: input id: bsversion attributes: label: Exact Karakeep Version description: This can be found in the bottom left of the page (e.g. Karakeep v0.18.0) placeholder: (eg. v0.18.0) validations: required: true - type: input id: environment attributes: label: Environment Details description: | Tell us where Karakeep is running, and reverse proxy (e.g. Docker, Bare linux, Proxmox, Unraid, K8s, Cloud). placeholder: (eg. Docker on Ubuntu 22.04 behind Caddy, or Proxmox LXC) validations: required: false - type: textarea id: containerlogs attributes: label: Debug Logs description: | Please provide relevant logs where possible placeholder: (paste logs here) validations: required: false - type: checkboxes id: confirm-troubleshooting attributes: label: Have you checked the troubleshooting guide? description: | We have a troubleshooting guide that you can find [here](https://docs.karakeep.app/administration/troubleshooting). Please check it out as you might find a solution to your problem. options: - label: I have checked the troubleshooting guide and I haven't found a solution to my problem required: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Request a new feature or idea to be added to Karakeep labels: ["feature request", "status/untriaged"] body: - type: textarea id: description attributes: label: Describe the feature you'd like description: Provide a clear description of the feature you'd like implemented in Karakeep validations: required: true - type: textarea id: benefits attributes: label: Describe the benefits this would bring to existing Karakeep users description: | Explain the measurable benefits this feature would achieve for existing Karakeep users. These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation. This helps us understand the core desired goal so that a variety of potential implementations could be explored. This field is important. Lack of input here may lead to early issue closure. validations: required: true - type: textarea id: already_achieved attributes: label: Can the goal of this request already be achieved via other means? description: | Yes/No. If yes, please describe how the requested approach fits in with the existing method. validations: required: true - type: checkboxes id: confirm-search attributes: label: Have you searched for an existing open/closed issue? description: | To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/karakeep-app/karakeep/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request. options: - label: I have searched for existing issues and none cover my fundamental request required: true - type: textarea id: context attributes: label: Additional context description: Add any other context or screenshots about the feature request here. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/question.yml ================================================ name: Question / Support Request description: Get help with anything related to Karakeep labels: ["question"] body: - type: markdown attributes: value: | We use Github discussions for anything that's not a bug report or a feature request. Please ask your question in the [Q&A section](https://github.com/karakeep-app/karakeep/discussions/categories/q-a) and someone will answer it soon! ================================================ FILE: .github/workflows/android.yml ================================================ name: Android App Release Build on: push: tags: - 'android/v[0-9]+.[0-9]+.[0-9]+-[0-9]+' jobs: build: runs-on: ubuntu-latest steps: - name: Setup repo uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Setup Android SDK uses: android-actions/setup-android@v3 - name: Setup Expo uses: expo/expo-github-action@v8 with: expo-version: latest eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Build Android app working-directory: apps/mobile run: eas build --platform android --local --output ${{ github.workspace }}/app-release.aab env: EAS_SKIP_AUTO_FINGERPRINT: "1" SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Upload AAB artifact uses: actions/upload-artifact@v4 with: name: karakeep-android path: ${{ github.workspace }}/app-release.aab ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: branches: ["*"] push: branches: ["main"] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} # You can leverage Vercel Remote Caching with Turbo to speed up your builds env: FORCE_COLOR: 3 jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Lint run: pnpm lint && pnpm exec sherif format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Format run: pnpm format typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Typecheck run: pnpm typecheck tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Shared Package Tests working-directory: packages/shared run: pnpm test - name: TRPC Tests working-directory: packages/trpc run: pnpm test - name: Workers Tests working-directory: apps/workers run: pnpm test - name: E2E Tests working-directory: packages/e2e_tests run: pnpm test - name: Upload Docker Logs if: failure() uses: actions/upload-artifact@v4 with: name: e2e-docker-logs-${{ github.sha }}-${{ github.run_attempt }} path: packages/e2e_tests/setup/docker-logs/ retention-days: 7 open-api-spec: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Regenerate OpenAPI spec working-directory: packages/open-api run: pnpm run generate - name: Check for changes run: | if [[ -n "$(git status --porcelain)" ]]; then echo "Error: Generated files are not up to date!" echo "The following files have changes:" git status --porcelain echo "" echo "Please regenerate the files locally with (pnpm run generate) and commit the changes." git diff exit 1 else echo "✅ Generated files are up to date!" fi ================================================ FILE: .github/workflows/claude.yml ================================================ name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | github.actor == 'MohamedBassem' && ( (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) ) runs-on: ubuntu-latest permissions: contents: write pull-requests: write issues: write id-token: write actions: read steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) # model: "claude-opus-4-20250514" # Optional: Customize the trigger phrase (default: @claude) # trigger_phrase: "/claude" # Optional: Trigger when specific user is assigned to an issue # assignee_trigger: "claude-bot" # Optional: Allow Claude to run specific commands allowed_tools: "Bash(pnpm install),Bash(pnpm typecheck),Bash(pnpm test),Bash(pnpm lint:fix)" # Optional: Add custom instructions for Claude to customize its behavior for your project # custom_instructions: | # Follow our coding standards # Ensure all new code has tests # Use TypeScript for new files # Optional: Custom environment variables for Claude # claude_env: | # NODE_ENV: test ================================================ FILE: .github/workflows/cli.yml ================================================ name: Publish CLI Package to npm on: push: tags: # This is a glob pattern not a regex - "cli/v[0-9]+.[0-9]+.[0-9]+" permissions: id-token: write # Required for OIDC contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Build CLI run: pnpm build working-directory: apps/cli # npm 11.5.1 or later is required for trusted publishing - run: npm install -g npm@latest - run: pnpm publish --access public --no-git-checks working-directory: apps/cli ================================================ FILE: .github/workflows/docker.yml ================================================ name: Build and Push Docker on: release: types: - created push: branches: - main jobs: build: strategy: fail-fast: false matrix: platform: [linux/amd64, linux/arm64] image: [web, workers, cli, mcp, aio] include: - platform: linux/amd64 suffix: amd64 os: ubuntu-latest - platform: linux/arm64 suffix: arm64 os: ubuntu-24.04-arm - image: web name: karakeep-web target: web tags_latest: ghcr.io/hoarder-app/hoarder-web:latest,ghcr.io/mohamedbassem/hoarder-web:latest,ghcr.io/karakeep-app/karakeep-web:latest tags_release: ghcr.io/hoarder-app/hoarder-web:${{ github.event.release.name }},ghcr.io/mohamedbassem/hoarder-web:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-web:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder-web:release,ghcr.io/mohamedbassem/hoarder-web:release,ghcr.io/karakeep-app/karakeep-web:release - image: workers name: karakeep-workers target: workers tags_latest: ghcr.io/hoarder-app/hoarder-workers:latest,ghcr.io/mohamedbassem/hoarder-workers:latest,ghcr.io/karakeep-app/karakeep-workers:latest tags_release: ghcr.io/hoarder-app/hoarder-workers:${{ github.event.release.name }},ghcr.io/mohamedbassem/hoarder-workers:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-workers:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder-workers:release,ghcr.io/mohamedbassem/hoarder-workers:release,ghcr.io/karakeep-app/karakeep-workers:release - image: cli name: karakeep-cli target: cli tags_latest: ghcr.io/hoarder-app/hoarder-cli:latest,ghcr.io/mohamedbassem/hoarder-cli:latest,ghcr.io/karakeep-app/karakeep-cli:latest tags_release: ghcr.io/hoarder-app/hoarder-cli:${{ github.event.release.name }},ghcr.io/mohamedbassem/hoarder-cli:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-cli:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder-cli:release,ghcr.io/mohamedbassem/hoarder-cli:release,ghcr.io/karakeep-app/karakeep-cli:release - image: mcp name: karakeep-mcp target: mcp tags_latest: ghcr.io/karakeep-app/karakeep-mcp:latest tags_release: ghcr.io/karakeep-app/karakeep-mcp:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-mcp:release - image: aio name: karakeep-aio target: aio tags_latest: ghcr.io/hoarder-app/hoarder:latest,ghcr.io/karakeep-app/karakeep:latest tags_release: ghcr.io/hoarder-app/hoarder:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder:release,ghcr.io/karakeep-app/karakeep:release runs-on: ${{ matrix.os }} permissions: packages: write contents: read steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Github Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_GITHUB_PAT }} - name: Prepare tags id: tags run: | set -euo pipefail add_suffix() { local value="$1" local out="" IFS=',' read -ra items <<< "$value" for item in "${items[@]}"; do out+="${item}-${{ matrix.suffix }}," done echo "${out%,}" } all_tags="" # Only push 'latest' tags on pushes to main, not on releases if [[ "${{ github.event_name }}" == "push" ]]; then latest_with_suffix=$(add_suffix "${{ matrix.tags_latest }}") all_tags="${latest_with_suffix}" echo "latest=${latest_with_suffix}" >> "$GITHUB_OUTPUT" fi # Only push release-specific tags on releases if [[ "${{ github.event_name }}" == "release" ]]; then release_with_suffix=$(add_suffix "${{ matrix.tags_release }}") all_tags="${release_with_suffix}" echo "release=${release_with_suffix}" >> "$GITHUB_OUTPUT" fi echo "all=${all_tags}" >> "$GITHUB_OUTPUT" - name: Build ${{ matrix.name }} uses: docker/build-push-action@v5 with: context: . build-args: SERVER_VERSION=${{ github.event_name == 'release' && github.event.release.name || 'nightly' }} file: docker/Dockerfile target: ${{ matrix.target }} platforms: ${{ matrix.platform }} push: true tags: ${{ steps.tags.outputs.all }} cache-from: type=registry,ref=ghcr.io/karakeep-app/karakeep-build-cache:${{ matrix.target }}-${{ matrix.suffix }} cache-to: type=registry,mode=max,ref=ghcr.io/karakeep-app/karakeep-build-cache:${{ matrix.target }}-${{ matrix.suffix }} manifest: needs: build runs-on: ubuntu-latest permissions: packages: write contents: read strategy: fail-fast: false matrix: include: - name: karakeep-web tags_latest: ghcr.io/hoarder-app/hoarder-web:latest,ghcr.io/mohamedbassem/hoarder-web:latest,ghcr.io/karakeep-app/karakeep-web:latest tags_release: ghcr.io/hoarder-app/hoarder-web:${{ github.event.release.name }},ghcr.io/mohamedbassem/hoarder-web:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-web:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder-web:release,ghcr.io/mohamedbassem/hoarder-web:release,ghcr.io/karakeep-app/karakeep-web:release - name: karakeep-workers tags_latest: ghcr.io/hoarder-app/hoarder-workers:latest,ghcr.io/mohamedbassem/hoarder-workers:latest,ghcr.io/karakeep-app/karakeep-workers:latest tags_release: ghcr.io/hoarder-app/hoarder-workers:${{ github.event.release.name }},ghcr.io/mohamedbassem/hoarder-workers:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-workers:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder-workers:release,ghcr.io/mohamedbassem/hoarder-workers:release,ghcr.io/karakeep-app/karakeep-workers:release - name: karakeep-cli tags_latest: ghcr.io/hoarder-app/hoarder-cli:latest,ghcr.io/mohamedbassem/hoarder-cli:latest,ghcr.io/karakeep-app/karakeep-cli:latest tags_release: ghcr.io/hoarder-app/hoarder-cli:${{ github.event.release.name }},ghcr.io/mohamedbassem/hoarder-cli:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-cli:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder-cli:release,ghcr.io/mohamedbassem/hoarder-cli:release,ghcr.io/karakeep-app/karakeep-cli:release - name: karakeep-mcp tags_latest: ghcr.io/karakeep-app/karakeep-mcp:latest tags_release: ghcr.io/karakeep-app/karakeep-mcp:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep-mcp:release - name: karakeep-aio tags_latest: ghcr.io/hoarder-app/hoarder:latest,ghcr.io/karakeep-app/karakeep:latest tags_release: ghcr.io/hoarder-app/hoarder:${{ github.event.release.name }},ghcr.io/karakeep-app/karakeep:${{ github.event.release.name }},ghcr.io/hoarder-app/hoarder:release,ghcr.io/karakeep-app/karakeep:release steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Github Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_GITHUB_PAT }} - name: Create manifests for ${{ matrix.name }} env: TAGS_LATEST: ${{ matrix.tags_latest }} TAGS_RELEASE: ${{ matrix.tags_release }} IS_RELEASE: ${{ github.event_name == 'release' }} IS_PUSH: ${{ github.event_name == 'push' }} run: | set -euo pipefail create_manifest() { local tag="$1" docker buildx imagetools create \ -t "${tag}" \ "${tag}-amd64" \ "${tag}-arm64" } # Only create 'latest' manifests on pushes to main if [[ "${IS_PUSH}" == "true" ]]; then IFS=',' read -ra latest_tags <<< "${TAGS_LATEST}" for tag in "${latest_tags[@]}"; do create_manifest "$tag" done fi # Only create release-specific manifests on releases if [[ "${IS_RELEASE}" == "true" ]]; then IFS=',' read -ra release_tags <<< "${TAGS_RELEASE}" for tag in "${release_tags[@]}"; do create_manifest "$tag" done fi ================================================ FILE: .github/workflows/extension.yml ================================================ name: Extension Release Build on: push: tags: - 'extension/v[0-9]+.[0-9]+.[0-9]+' jobs: build: runs-on: ubuntu-latest steps: - name: Setup repo uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Build the extension (chrome) working-directory: apps/browser-extension run: pnpm run build --outDir dist-chrome - name: Build the extension (firefox) env: VITE_BUILD_FIREFOX: true working-directory: apps/browser-extension run: pnpm run build --outDir dist-firefox - name: Upload Extension Archive (chrome) uses: actions/upload-artifact@v4 with: name: karakeep-extension-chrome path: apps/browser-extension/dist-chrome/* - name: Upload Extension Archive (firefox) uses: actions/upload-artifact@v4 with: name: karakeep-extension-firefox path: apps/browser-extension/dist-firefox/* ================================================ FILE: .github/workflows/ios.yml ================================================ name: iOS App Release Build on: push: tags: - 'ios/v[0-9]+.[0-9]+.[0-9]+-[0-9]+' jobs: build: runs-on: macos-26 steps: - name: Setup repo uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Setup Expo uses: expo/expo-github-action@v8 with: expo-version: latest eas-version: latest token: ${{ secrets.EXPO_TOKEN }} - name: Build iOS app working-directory: apps/mobile run: eas build --platform ios --local --non-interactive --output ${{ github.workspace }}/app-release.ipa env: EAS_SKIP_AUTO_FINGERPRINT: "1" SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Upload IPA artifact uses: actions/upload-artifact@v4 with: name: karakeep-ios path: ${{ github.workspace }}/app-release.ipa ================================================ FILE: .github/workflows/mcp.yml ================================================ name: Publish MCP Package to npm on: push: tags: # This is a glob pattern not a regex - "mcp/v[0-9]+.[0-9]+.[0-9]+" permissions: id-token: write # Required for OIDC contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Build MCP run: pnpm build working-directory: apps/mcp # npm 11.5.1 or later is required for trusted publishing - run: npm install -g npm@latest - run: pnpm publish --access public --no-git-checks working-directory: apps/mcp ================================================ FILE: .github/workflows/sdk.yml ================================================ name: Publish CLI Package to npm on: push: tags: # This is a glob pattern not a regex - "sdk/v[0-9]+.[0-9]+.[0-9]+" permissions: id-token: write # Required for OIDC contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup uses: ./tooling/github/setup - name: Build SDK run: pnpm build working-directory: packages/sdk # npm 11.5.1 or later is required for trusted publishing - run: npm install -g npm@latest - run: pnpm publish --access public --no-git-checks working-directory: packages/sdk ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules .pnp .pnpm-store .pnp.js .yarn/install-state.gz # testing coverage # next.js .next/ out/ # production build dist # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local .env # vercel .vercel # typescript *.tsbuildinfo # The sqlite database **/*.db data # PWA Files **/public/sw.js **/public/workbox-*.js **/public/worker-*.js **/public/sw.js.map **/public/workbox-*.js.map **/public/worker-*.js.map # Turbo .turbo # Idea .idea *.iml # Aider .aider* # VS-Code .vscode auth_failures.log .claude/settings.local.json # Local directory for AI agent contexts .aicontext/ .codex .claude/worktrees jean.json ================================================ FILE: .husky/pre-commit ================================================ pnpm preflight pnpm exec sherif pnpm run --filter @karakeep/open-api check ================================================ FILE: .npmrc ================================================ node-linker=hoisted ================================================ FILE: .oxfmtrc.json ================================================ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "sortTailwindcss": { "config": "tooling/tailwind/web.ts", "functions": [ "cn", "cva" ] }, "importOrder": [ "", "^(react/(.*)$)|^(react$)|^(react-native(.*)$)", "^(next/(.*)$)|^(next$)", "^(expo(.*)$)|^(expo$)", "", "", "^@karakeep", "^@karakeep/(.*)$", "", "^[.|..|~]", "^~/", "^[../]", "^[./]" ], "importOrderParserPlugins": [ "typescript", "jsx", "decorators-legacy" ], "importOrderTypeScriptVersion": "4.4.0", "printWidth": 80, "sortPackageJson": false, "ignorePatterns": [ ".next", "build", "coverage", ".vscode*", "node_modules", "dist", "*.md", "*.json", ".env", ".env.*", "**/migrations/**", "packages/open-api/karakeep-openapi-spec.json", "apps/web/public/workbox-*.js", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "**/.expo/**", "apps/mobile/android", "apps/mobile/ios" ] } ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "node_modules/oxlint/configuration_schema.json", "ignorePatterns": [ "**/*.config.js", "**/*.config.cjs", "**/.eslintrc.cjs", "**/.next", "**/dist", "**/build", "**/pnpm-lock.yaml" ] } ================================================ FILE: AGENTS.md ================================================ # Karakeep Project Overview This document provides context about the Karakeep project for the different agents. ## Project Overview Karakeep is a monorepo project managed with Turborepo. It appears to be a web application with a focus on collecting and organizing information, possibly a bookmarking or "read-it-later" service. The project is built with a modern tech stack, including: - **Frontend:** Next.js, React, TypeScript, Tailwind CSS - **Backend:** Hono (a lightweight web framework), tRPC - **Database:** Drizzle ORM (likely with a relational database like PostgreSQL or SQLite) - **Tooling:** Oxfmt, oxlint, Vitest, pnpm ## Project Structure The project is organized into `apps` and `packages`: ### Applications (`apps/`) - **`web`:** The main web application, built with Next.js. - **`browser-extension`:** A browser extension, likely for saving content to karakeep. - **`cli`:** A command-line interface for interacting with the service. - **`landing`:** A landing page for the project. - **`mobile`:** A mobile application (details unknown). - **`mcp`:** The Model Context Protocol (MCP) server to communicate with Karakeep. - **`workers`:** Background workers for processing tasks. ### Packages (`packages/`) - **`api`:** The main API, built with Hono and tRPC. - **`db`:** Database schema and migrations, using Drizzle ORM. - **`e2e_tests`:** End-to-end tests for the project. - **`open-api`:** OpenAPI specifications for the API. - **`sdk`:** A software development kit for interacting with the API. - **`shared`:** Shared code and types between packages. - **`shared-react`:** Shared React components and hooks. - **`shared-server`:** Shared logic that's meant to be used on the server-side. - **`trpc`:** tRPC router and procedures. Most of the business logic is here. ### Docs - **docs/docs/03-configuration.md**: Explains configuration options for the project. ## Development Workflow - **Package Manager:** pnpm - **Build System:** Turborepo - **Code Formatting:** Oxfmt - **Linting:** oxlint - **Testing:** Vitest ## Other info - This project uses shadcn/ui. The shadcn components in the web app are in `packages/web/components/ui`. - This project uses Tailwind CSS. - For the mobile app, we use [expo](https://expo.dev/). ### Common Commands - `pnpm typecheck`: Typecheck the codebase. - `pnpm lint`: Lint the codebase. - `pnpm lint:fix`: Fix linting issues. - `pnpm format`: Format the codebase. - `pnpm format:fix`: Fix formatting issues. - `pnpm test`: Run tests. - `pnpm db:generate --name description_of_schema_change`: db migration after making schema changes Starting services: - `pnpm web`: Start the web application (this doesn't return, unless you kill it). - `pnpm workers`: Starts the background workers (this doesn't return, unless you kill it). ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Karakeep First off, thank you for considering contributing to our project! This document outlines our contribution process and guidelines to make it easy for you to help improve this project. ## How Can I Contribute? ### Asking Questions If you have questions: * Use the GitHub Discussions Q&A section * Search existing discussions to see if your question has been answered * If not found, create a new discussion with a clear, descriptive title ### Reporting Bugs If in doubt, about whether a problem you're seeing is a bug or not, use the discussions Q&A section instead. If it turns out to be a bug, we'll promote it into an issue. If you're sure it's a bug: * Create a new issue using the bug report template * Include a clear description and steps to reproduce * Wait for triage and labeling by maintainers ### Suggesting Features For feature requests: * If you find a similar feature request, upvote it instead of creating a new one to help us prioritize it * Create a new issue using the feature request template * New features start with the `status/untriaged` label * If the feature request is approved, the maintainers will add the `status/approved` label and assign a priority to the issue * Other issues will get labeled with `status/icebox`. Issues in the icebox are not prioritized, until there's enough interest from the community ### Working on Issues Before starting to work on an issue: * Prefer working on `status/approved` issues to make sure they get prioritized for the review * Comment on the issue to let others know you're working on it * Read the [development documentation](https://docs.karakeep.app/Development/setup) to get started * If you need help, you can find us in the #development channel in the [Karakeep Discord](https://discord.com/invite/NrgeYywsFh). * Once you're done, open a PR and wait for review. Try to include a screenshot of the change in the PR description. Please note that we're all volunteers. We'll aim to review your PR within a week from when they are opened. ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ # Karakeep (previously Hoarder) is a self-hostable bookmark-everything app with a touch of AI for the data hoarders out there. ![homepage screenshot](https://github.com/karakeep-app/karakeep/blob/main/screenshots/homepage.png?raw=true) ## Features - 🔗 Bookmark links, take simple notes and store images and pdfs. - ⬇️ Automatic fetching for link titles, descriptions and images. - 📋 Sort your bookmarks into lists. - 👥 Collaborate with others on the same list. - 🔎 Full text search of all the content stored. - ✨ AI-based (aka chatgpt) automatic tagging and summarization. With supports for local models using ollama! - 🤖 Rule-based engine for customized management. - 🎆 OCR for extracting text from images. - 🔖 [Chrome plugin](https://chromewebstore.google.com/detail/karakeep/kgcjekpmcjjogibpjebkhaanilehneje) and [Firefox addon](https://addons.mozilla.org/en-US/firefox/addon/karakeep/) for quick bookmarking. - 📱 An [iOS app](https://apps.apple.com/us/app/karakeep-app/id6479258022), and an [Android app](https://play.google.com/store/apps/details?id=app.hoarder.hoardermobile&pcampaignid=web_share). - 📰 Auto hoarding from RSS feeds. - 🔌 REST API and multiple clients. - 🌐 Multi-language support. - 🖍️ Mark and store highlights from your hoarded content. - 🗄️ Full page archival (using [monolith](https://github.com/Y2Z/monolith)) to protect against link rot. - ▶️ Auto video archiving using [yt-dlp](https://github.com/yt-dlp/yt-dlp). - ☑️ Bulk actions support. - 🔐 SSO support. - 🌙 Dark mode support. - 💾 Self-hosting first. - ⬇️ Bookmark importers from Chrome, Pocket, Linkwarden, Omnivore, Tab Session Manager. - 🔄 Automatic sync with browser bookmarks via [floccus](https://floccus.org/). - [Planned] Offline reading on mobile, semantic search across bookmarks, ... **⚠️ This app is under heavy development.** ## Documentation - [Installation](https://docs.karakeep.app/Installation/docker) - [Configuration](https://docs.karakeep.app/configuration) - [Screenshots](https://docs.karakeep.app/screenshots) - [Security Considerations](https://docs.karakeep.app/security-considerations) - [Development](https://docs.karakeep.app/Development/setup) ## Demo You can access the demo at [https://try.karakeep.app](https://try.karakeep.app). Login with the following creds: ``` email: demo@karakeep.app password: demodemo ``` The demo is seeded with some content, but it's in read-only mode to prevent abuse. ## About the name The name Karakeep is inspired by the Arabic word "كراكيب" (karakeeb), a colloquial term commonly used to refer to miscellaneous clutter, odds and ends, or items that may seem disorganized but often hold personal value or hidden usefulness. It evokes the image of a messy drawer or forgotten box, full of stuff you can't quite throw away—because somehow, it matters (or more likely, because you're a hoarder!). ## Stack - [NextJS](https://nextjs.org/) for the web app. Using app router. - [Drizzle](https://orm.drizzle.team/) for the database and its migrations. - [NextAuth](https://next-auth.js.org) for authentication. - [tRPC](https://trpc.io) for client->server communication. - [Puppeteer](https://pptr.dev/) for crawling the bookmarks. - [OpenAI](https://openai.com/) because AI is so hot right now. - [Meilisearch](https://meilisearch.com) for the full content search. ## Why did I build it? I browse reddit, twitter and hackernews a lot from my phone. I frequently find interesting stuff (articles, tools, etc) that I'd like to bookmark and read later when I'm in front of a laptop. Typical read-it-later apps usecase. Initially, I was using [Pocket](https://getpocket.com) for that. Then I got into self-hosting and I wanted to self-host this usecase. I used [memos](https://github.com/usememos/memos) for those quick notes and I loved it but it was lacking some features that I found important for that usecase such as link previews and automatic tagging (more on that in the next section). I'm a systems engineer in my day job (and have been for the past 7 years). I didn't want to get too detached from the web development world. I decided to build this app as a way to keep my hand dirty with web development, and at the same time, build something that I care about and use every day. ## Alternatives - [memos](https://github.com/usememos/memos): I love memos. I have it running on my home server and it's one of my most used self-hosted apps. It doesn't, however, archive or preview the links shared in it. It's just that I dump a lot of links there and I'd have loved if I'd be able to figure which link is that by just looking at my timeline. Also, given the variety of things I dump there, I'd have loved if it does some sort of automatic tagging for what I save there. This is exactly the usecase that I'm trying to tackle with Karakeep. - [mymind](https://mymind.com/): Mymind is the closest alternative to this project and from where I drew a lot of inspirations. It's a commercial product though. - [raindrop](https://raindrop.io): A polished open source bookmark manager that supports links, images and files. It's not self-hostable though. - Bookmark managers (mostly focused on bookmarking links): - [Pocket](https://getpocket.com) (Dead): Pocket is what hooked me into the whole idea of read-it-later apps. I used it [a lot](https://blog.mbassem.com/2019/01/27/favorite-articles-2018/). However, I recently got into home-labbing and became obsessed with the idea of running my services in my home server. Karakeep is meant to be a self-hosting first app. Mozilla recently announced that it's shutting down pocket. - [Linkwarden](https://linkwarden.app/): An open-source self-hostable bookmark manager that I ran for a bit in my homelab. It's focused mostly on links and supports collaborative collections. - [Wallabag](https://wallabag.it): Wallabag is a well-established open source read-it-later app written in php. - [Shiori](https://github.com/go-shiori/shiori): Shiori is meant to be an open source pocket clone written in Go. ## Translations Karakeep uses Weblate for managing translations. If you want to help translate Karakeep, you can do so [here](https://hosted.weblate.org/engage/hoarder/). ## Karakeep Cloud ☁️ If you're not comfortable with self-hosting, you can use our managed Karakeep cloud at [cloud.karakeep.app](https://cloud.karakeep.app). Cloud subscriptions support the development of Karakeep. ## Support If you're enjoying using Karakeep, drop a ⭐️ on the repo! Buy Me A Coffee ## Community Channels - Join us on [Discord](https://discord.gg/NrgeYywsFh). - Follow us on Twitter: [@karakeep_app](https://x.com/karakeep_app). ## License Karakeep is licensed under [AGPL-3.0](https://github.com/karakeep-app/karakeep/blob/main/LICENSE) and owned by [Localhost Labs Ltd](https://localhostlabs.co.uk). ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=karakeep-app/karakeep&type=Date)](https://star-history.com/#karakeep-app/karakeep&Date) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Please report security issues to `security@karakeep.app` ================================================ FILE: apps/browser-extension/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: apps/browser-extension/.oxlintrc.json ================================================ { "$schema": "../../node_modules/oxlint/configuration_schema.json", "extends": [ "../../tooling/oxlint/oxlint-base.json", "../../tooling/oxlint/oxlint-react.json" ], "env": { "builtin": true, "commonjs": true, "browser": true, "es2022": true, "node": true }, "globals": { "React": "writeable" }, "settings": { "react": { "version": "19" } }, "ignorePatterns": [ "**/*.config.js", "**/*.config.cjs", "**/.eslintrc.cjs", ".next", "dist", "build", "pnpm-lock.yaml" ] } ================================================ FILE: apps/browser-extension/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", "baseColor": "slate", "cssVariables": true, "prefix": "" }, "aliases": { "components": "src/components", "utils": "src/utils/css" } } ================================================ FILE: apps/browser-extension/index.html ================================================ Karakeep
================================================ FILE: apps/browser-extension/manifest.json ================================================ { "manifest_version": 3, "name": "Karakeep", "description": "An extension to bookmark links to karakeep.app", "version": "1.2.9", "icons": { "16": "public/logo-16.png", "48": "public/logo-48.png", "128": "public/logo-128.png" }, "action": { "default_popup": "index.html", "theme_icons": [ { "light": "logo-16-darkmode.png", "dark": "logo-16.png", "size": 16 }, { "light": "logo-48-darkmode.png", "dark": "logo-48.png", "size": 48 }, { "light": "logo-128-darkmode.png", "dark": "logo-128.png", "size": 128 } ] }, "background": { "service_worker": "src/background/background.ts", "scripts": ["src/background/background.ts"] }, "options_ui": { "page": "index.html#options", "open_in_tab": false }, "browser_specific_settings": { "gecko": { "id": "addon@karakeep.app" } }, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, "permissions": ["storage", "tabs", "contextMenus"], "commands": { "add-link": { "suggested_key": { "default": "Ctrl+Shift+E" }, "description": "Send the current page URL to Karakeep." } } } ================================================ FILE: apps/browser-extension/package.json ================================================ { "name": "@karakeep/browser-extension", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "format": "oxfmt --check .", "format:fix": "oxfmt .", "lint": "oxlint .", "lint:fix": "oxlint . --fix", "preview": "vite preview", "typecheck": "tsc --noEmit" }, "dependencies": { "@karakeep/shared": "workspace:^0.1.0", "@karakeep/shared-react": "workspace:^0.1.0", "@karakeep/trpc": "workspace:^0.1.0", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.4", "@tanstack/query-async-storage-persister": "5.90.2", "@tanstack/react-query": "5.90.2", "@tanstack/react-query-persist-client": "5.90.2", "@trpc/client": "^11.9.0", "@trpc/server": "^11.9.0", "@trpc/tanstack-react-query": "^11.9.0", "@uidotdev/usehooks": "^2.4.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", "lucide-react": "^0.501.0", "react": "^19.2.1", "react-dom": "^19.2.1", "react-router-dom": "^6.22.0", "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.2" }, "devDependencies": { "@crxjs/vite-plugin": "2.2.0", "@karakeep/tailwind-config": "workspace:^0.1.0", "@karakeep/tsconfig": "workspace:^0.1.0", "@types/chrome": "^0.0.260", "@types/react": "^19.2.14", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react-swc": "^4.0.1", "autoprefixer": "^10.4.17", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", "typescript": "^5.9", "vite": "^7.0.6" } } ================================================ FILE: apps/browser-extension/postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: apps/browser-extension/src/BookmarkDeletedPage.tsx ================================================ export default function BookmarkDeletedPage() { return

Bookmark Deleted!

; } ================================================ FILE: apps/browser-extension/src/BookmarkSavedPage.tsx ================================================ import { useState } from "react"; import { ArrowUpRightFromSquare, Trash } from "lucide-react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { useDeleteBookmark } from "@karakeep/shared-react/hooks/bookmarks"; import BookmarkLists from "./components/BookmarkLists"; import { ListsSelector } from "./components/ListsSelector"; import { NoteEditor } from "./components/NoteEditor"; import TagList from "./components/TagList"; import { TagsSelector } from "./components/TagsSelector"; import { Button, buttonVariants } from "./components/ui/button"; import Spinner from "./Spinner"; import { cn } from "./utils/css"; import usePluginSettings from "./utils/settings"; import { MessageType } from "./utils/type"; export default function BookmarkSavedPage() { const { bookmarkId } = useParams(); const navigate = useNavigate(); const [error, setError] = useState(""); const { mutate: deleteBookmark, isPending } = useDeleteBookmark({ onSuccess: async () => { const [currentTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true, }); await chrome.runtime.sendMessage({ type: MessageType.BOOKMARK_REFRESH_BADGE, currentTab: currentTab, }); navigate("/bookmarkdeleted"); }, onError: (e) => { setError(e.message); }, }); const { settings } = usePluginSettings(); if (!bookmarkId) { return
NOT FOUND
; } return (
{error &&

{error}

}

Hoarded!

Open


Notes


Tags


Lists

); } ================================================ FILE: apps/browser-extension/src/CustomHeadersPage.tsx ================================================ import { useEffect, useState } from "react"; import { Plus, Trash2 } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import Logo from "./Logo"; import usePluginSettings from "./utils/settings"; export default function CustomHeadersPage() { const navigate = useNavigate(); const { settings, setSettings } = usePluginSettings(); // Convert headers object to array of entries for easier manipulation const [headers, setHeaders] = useState<{ key: string; value: string }[]>([]); const [newHeaderKey, setNewHeaderKey] = useState(""); const [newHeaderValue, setNewHeaderValue] = useState(""); // Update headers when settings change (e.g., when loaded from storage) useEffect(() => { setHeaders( Object.entries(settings.customHeaders || {}).map(([key, value]) => ({ key, value, })), ); }, [settings.customHeaders]); const handleAddHeader = () => { if (!newHeaderKey.trim() || !newHeaderValue.trim()) { return; } // Check if header already exists const existingIndex = headers.findIndex((h) => h.key === newHeaderKey); if (existingIndex >= 0) { // Update existing header const updatedHeaders = [...headers]; updatedHeaders[existingIndex].value = newHeaderValue; setHeaders(updatedHeaders); } else { // Add new header setHeaders([...headers, { key: newHeaderKey, value: newHeaderValue }]); } setNewHeaderKey(""); setNewHeaderValue(""); }; const handleRemoveHeader = (index: number) => { setHeaders(headers.filter((_, i) => i !== index)); }; const handleSave = () => { // Convert array back to object const headersObject = headers.reduce( (acc, { key, value }) => { if (key.trim() && value.trim()) { acc[key] = value; } return acc; }, {} as Record, ); setSettings((s) => ({ ...s, customHeaders: headersObject })); navigate(-1); }; const handleCancel = () => { navigate(-1); }; return (
Custom Headers

Add custom HTTP headers that will be sent with every API request.


{/* Existing Headers List */}
{headers.length === 0 ? (

No custom headers configured

) : ( headers.map((header, index) => (

{header.key}

{header.value}

)) )}

{/* Add New Header */}

Add New Header

setNewHeaderKey(e.target.value)} autoCapitalize="none" /> setNewHeaderValue(e.target.value)} autoCapitalize="none" />

{/* Action Buttons */}
); } ================================================ FILE: apps/browser-extension/src/Layout.tsx ================================================ import { Home, RefreshCw, Settings, X } from "lucide-react"; import { Outlet, useNavigate } from "react-router-dom"; import { Button } from "./components/ui/button"; import usePluginSettings from "./utils/settings"; export default function Layout() { const navigate = useNavigate(); const { settings, isPending: isInit } = usePluginSettings(); if (!isInit) { return
Loading ...
; } if (!settings.apiKey || !settings.address) { navigate("/notconfigured"); return; } return (

{process.env.NODE_ENV == "development" && ( )}
); } ================================================ FILE: apps/browser-extension/src/Logo.tsx ================================================ import logoImgWhite from "../public/logo-full-white.png"; import logoImg from "../public/logo-full.png"; export default function Logo() { return ( karakeep logo karakeep logo ); } ================================================ FILE: apps/browser-extension/src/NotConfiguredPage.tsx ================================================ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import Logo from "./Logo"; import usePluginSettings from "./utils/settings"; import { isHttpUrl } from "./utils/url"; export default function NotConfiguredPage() { const navigate = useNavigate(); const { settings, setSettings } = usePluginSettings(); const [error, setError] = useState(""); const [serverAddress, setServerAddress] = useState(settings.address); useEffect(() => { setServerAddress(settings.address); }, [settings.address]); const onSave = () => { const input = serverAddress.trim(); if (input == "") { setError("Server address is required"); return; } // Add URL protocol validation if (!isHttpUrl(input)) { setError("Server address must start with http:// or https://"); return; } setSettings((s) => ({ ...s, address: input.replace(/\/$/, "") })); navigate("/signin"); }; return (
To use the plugin, you need to configure it first.

{error}

setServerAddress(e.target.value)} />
); } ================================================ FILE: apps/browser-extension/src/OptionsPage.tsx ================================================ import React, { useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./components/ui/select"; import { Switch } from "./components/ui/switch"; import Logo from "./Logo"; import Spinner from "./Spinner"; import usePluginSettings, { DEFAULT_BADGE_CACHE_EXPIRE_MS, } from "./utils/settings"; import { useTheme } from "./utils/ThemeProvider"; import { useTRPC } from "./utils/trpc"; export default function OptionsPage() { const api = useTRPC(); const queryClient = useQueryClient(); const navigate = useNavigate(); const { settings, setSettings } = usePluginSettings(); const { setTheme, theme } = useTheme(); const { data: whoami, error: whoAmIError } = useQuery( api.users.whoami.queryOptions(undefined, { enabled: settings.address != "", }), ); const { mutate: deleteKey } = useMutation( api.apiKeys.revoke.mutationOptions(), ); const invalidateWhoami = () => { queryClient.refetchQueries(api.users.whoami.queryFilter()); }; useEffect(() => { invalidateWhoami(); }, [settings]); let loggedInMessage: React.ReactNode; if (whoAmIError) { if (whoAmIError.data?.code == "UNAUTHORIZED") { loggedInMessage = Not logged in; } else { loggedInMessage = ( Something went wrong: {whoAmIError.message} ); } } else if (whoami) { loggedInMessage = {whoami.email}; } else { loggedInMessage = ; } const onLogout = () => { if (settings.apiKeyId) { deleteKey({ id: settings.apiKeyId }); } setSettings((s) => ({ ...s, apiKey: "", apiKeyId: undefined })); invalidateWhoami(); navigate("/notconfigured"); }; return (
Settings
Show count badge setSettings((s) => ({ ...s, showCountBadge: checked })) } />
{settings.showCountBadge && ( <>
Use badge cache setSettings((s) => ({ ...s, useBadgeCache: checked })) } />
{settings.useBadgeCache && ( <>
Badge cache expire time (second) setSettings((s) => ({ ...s, badgeCacheExpireMs: parseInt(e.target.value) * 1000 || DEFAULT_BADGE_CACHE_EXPIRE_MS, })) } className="w-32" />
)} )}
Server Address: {settings.address}
Logged in as: {loggedInMessage}
Theme:
); } ================================================ FILE: apps/browser-extension/src/SavePage.tsx ================================================ import { useEffect, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Navigate } from "react-router-dom"; import { BookmarkTypes, ZNewBookmarkRequest, zNewBookmarkRequestSchema, } from "@karakeep/shared/types/bookmarks"; import { NEW_BOOKMARK_REQUEST_KEY_NAME } from "./background/protocol"; import Spinner from "./Spinner"; import { useTRPC } from "./utils/trpc"; import { MessageType } from "./utils/type"; import { isHttpUrl } from "./utils/url"; export default function SavePage() { const api = useTRPC(); const [error, setError] = useState(undefined); const { data, mutate: createBookmark, status, } = useMutation( api.bookmarks.createBookmark.mutationOptions({ onError: (e) => { setError("Something went wrong: " + e.message); }, onSuccess: async () => { // After successful creation, update badge cache and notify background const [currentTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true, }); await chrome.runtime.sendMessage({ type: MessageType.BOOKMARK_REFRESH_BADGE, currentTab: currentTab, }); }, }), ); useEffect(() => { async function getNewBookmarkRequestFromBackgroundScriptIfAny(): Promise { const { [NEW_BOOKMARK_REQUEST_KEY_NAME]: req } = await chrome.storage.session.get(NEW_BOOKMARK_REQUEST_KEY_NAME); if (!req) { return null; } // Delete the request immediately to avoid issues with lingering values await chrome.storage.session.remove(NEW_BOOKMARK_REQUEST_KEY_NAME); return zNewBookmarkRequestSchema.parse(req); } async function runSave() { let newBookmarkRequest = await getNewBookmarkRequestFromBackgroundScriptIfAny(); if (!newBookmarkRequest) { const [currentTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true, }); if (!currentTab.url) { setError("Current tab has no URL to bookmark."); return; } if (!isHttpUrl(currentTab.url)) { setError( "Cannot bookmark this type of URL. Only HTTP/HTTPS URLs are supported.", ); return; } newBookmarkRequest = { type: BookmarkTypes.LINK, title: currentTab.title, url: currentTab.url, source: "extension", }; } createBookmark({ ...newBookmarkRequest, source: newBookmarkRequest.source || "extension", }); } runSave(); }, [createBookmark]); if (error) { return
{error}
; } switch (status) { case "error": { return
{error}
; } case "success": { return ; } case "pending": { return (
Saving Bookmark
); } case "idle": { return
; } } } ================================================ FILE: apps/browser-extension/src/SignInPage.tsx ================================================ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { Button } from "./components/ui/button"; import { Input } from "./components/ui/input"; import Logo from "./Logo"; import usePluginSettings from "./utils/settings"; import { useTRPC } from "./utils/trpc"; const enum LoginState { NONE = "NONE", USERNAME_PASSWORD = "USERNAME/PASSWORD", API_KEY = "API_KEY", } export default function SignInPage() { const api = useTRPC(); const navigate = useNavigate(); const { settings, setSettings } = usePluginSettings(); const { mutate: login, error: usernamePasswordError, isPending: userNamePasswordRequestIsPending, } = useMutation( api.apiKeys.exchange.mutationOptions({ onSuccess: (resp) => { setSettings((s) => ({ ...s, apiKey: resp.key, apiKeyId: resp.id })); navigate("/options"); }, }), ); const { mutate: validateApiKey, error: apiKeyValidationError, isPending: apiKeyValueRequestIsPending, } = useMutation( api.apiKeys.validate.mutationOptions({ onSuccess: () => { setSettings((s) => ({ ...s, apiKey: apiKeyFormData.apiKey })); navigate("/options"); }, }), ); const [lastLoginAttemptSource, setLastLoginAttemptSource] = useState(LoginState.NONE); const [formData, setFormData] = useState<{ email: string; password: string; }>({ email: "", password: "", }); const [apiKeyFormData, setApiKeyFormData] = useState<{ apiKey: string; }>({ apiKey: "", }); const onUserNamePasswordSubmit = (e: React.FormEvent) => { e.preventDefault(); setLastLoginAttemptSource(LoginState.USERNAME_PASSWORD); const randStr = (Math.random() + 1).toString(36).substring(5); login({ ...formData, keyName: `Browser extension: (${randStr})` }); }; const onApiKeySubmit = (e: React.FormEvent) => { e.preventDefault(); setLastLoginAttemptSource(LoginState.API_KEY); validateApiKey({ ...apiKeyFormData }); }; let errorMessage = ""; let loginError; switch (lastLoginAttemptSource) { case LoginState.USERNAME_PASSWORD: loginError = usernamePasswordError; break; case LoginState.API_KEY: loginError = apiKeyValidationError; break; } if (loginError) { errorMessage = loginError.message || "Wrong username or password"; } return (

Login

{errorMessage}

setFormData((f) => ({ ...f, email: e.target.value })) } type="text" name="email" className="h-8 flex-1 rounded-lg border border-gray-300 p-2" />
setFormData((f) => ({ ...f, password: e.target.value, })) } type="password" name="password" className="h-8 flex-1 rounded-lg border border-gray-300 p-2" />

Or
setApiKeyFormData((f) => ({ ...f, apiKey: e.target.value })) } type="text" name="apiKey" className="h-8 flex-1 rounded-lg border border-gray-300 p-2" />
); } ================================================ FILE: apps/browser-extension/src/Spinner.tsx ================================================ export default function Spinner() { return ( ); } ================================================ FILE: apps/browser-extension/src/background/background.ts ================================================ import { BookmarkTypes, ZNewBookmarkRequest, } from "@karakeep/shared/types/bookmarks"; import { clearBadgeStatus, getBadgeStatus } from "../utils/badgeCache"; import { getPluginSettings, Settings, subscribeToSettingsChanges, } from "../utils/settings"; import { getApiClient, initializeClients } from "../utils/trpc"; import { MessageType } from "../utils/type"; import { isHttpUrl } from "../utils/url"; import { NEW_BOOKMARK_REQUEST_KEY_NAME } from "./protocol"; const OPEN_KARAKEEP_ID = "open-karakeep"; const ADD_LINK_TO_KARAKEEP_ID = "add-link"; const CLEAR_CURRENT_CACHE_ID = "clear-current-cache"; const CLEAR_ALL_CACHE_ID = "clear-all-cache"; const SEPARATOR_ID = "separator-1"; const VIEW_PAGE_IN_KARAKEEP = "view-page-in-karakeep"; /** * Check the current settings state and register or remove context menus accordingly. * @param settings The current plugin settings. */ async function checkSettingsState(settings: Settings) { await initializeClients(); if (settings?.address && settings?.apiKey) { registerContextMenus(settings); } else { removeContextMenus(); await clearAllCache(); } } /** * Remove context menus from the browser. */ function removeContextMenus() { try { chrome.contextMenus.removeAll(); } catch (error) { console.error("Failed to remove context menus:", error); } } /** * Register context menus in the browser. * * A context menu button to open a tab with the currently configured karakeep instance. * * * If the "show count badge" setting is enabled, add context menu buttons to clear the cache for the current page or all pages. * * A context menu button to add a link to karakeep without loading the page. * @param settings The current plugin settings. */ function registerContextMenus(settings: Settings) { removeContextMenus(); chrome.contextMenus.create({ id: OPEN_KARAKEEP_ID, title: "Open Karakeep", contexts: ["action"], }); chrome.contextMenus.create({ id: ADD_LINK_TO_KARAKEEP_ID, title: "Add to Karakeep", contexts: ["link", "page", "selection", "image"], }); if (settings?.showCountBadge) { chrome.contextMenus.create({ id: VIEW_PAGE_IN_KARAKEEP, title: "View this page in Karakeep", contexts: ["action", "page"], }); if (settings?.useBadgeCache) { // Add separator chrome.contextMenus.create({ id: SEPARATOR_ID, type: "separator", contexts: ["action"], }); chrome.contextMenus.create({ id: CLEAR_CURRENT_CACHE_ID, title: "Clear Current Page Cache", contexts: ["action"], }); chrome.contextMenus.create({ id: CLEAR_ALL_CACHE_ID, title: "Clear All Cache", contexts: ["action"], }); } } } /** * Handle context menu clicks by opening a new tab with karakeep or adding a link to karakeep. * @param info Information about the context menu click event. * @param tab The current tab. */ async function handleContextMenuClick( info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab, ) { const { menuItemId, selectionText, srcUrl, linkUrl, pageUrl } = info; if (menuItemId === OPEN_KARAKEEP_ID) { getPluginSettings().then((settings: Settings) => { chrome.tabs.create({ url: settings.address, active: true }); }); } else if (menuItemId === CLEAR_CURRENT_CACHE_ID) { await clearCurrentPageCache(); } else if (menuItemId === CLEAR_ALL_CACHE_ID) { await clearAllCache(); } else if (menuItemId === ADD_LINK_TO_KARAKEEP_ID) { // Only pass the current page title when the URL being saved is the // page itself. When saving a link or image, the title would // incorrectly be the current page's title instead of the target's. const isCurrentPage = !srcUrl && !linkUrl; addLinkToKarakeep({ selectionText, srcUrl, linkUrl, pageUrl, title: isCurrentPage ? tab?.title : undefined, }); // NOTE: Firefox only allows opening context menus if it's triggered by a user action. // awaiting on any promise before calling this function will lose the "user action" context. await chrome.action.openPopup(); } else if (menuItemId === VIEW_PAGE_IN_KARAKEEP) { if (tab) { await searchCurrentUrl(tab.url); } } } /** * Add a link to karakeep based on the provided information. * @param options An object containing information about the link to add. */ function addLinkToKarakeep({ selectionText, srcUrl, linkUrl, pageUrl, title, }: { selectionText?: string; srcUrl?: string; linkUrl?: string; pageUrl?: string; title?: string; }) { let newBookmark: ZNewBookmarkRequest | null = null; if (selectionText) { newBookmark = { type: BookmarkTypes.TEXT, text: selectionText, sourceUrl: pageUrl, source: "extension", }; } else { const finalUrl = srcUrl ?? linkUrl ?? pageUrl; if (finalUrl && isHttpUrl(finalUrl)) { newBookmark = { type: BookmarkTypes.LINK, url: finalUrl, source: "extension", title, }; } else { console.warn("Invalid URL, bookmark not created:", finalUrl); } } if (newBookmark) { chrome.storage.session.set({ [NEW_BOOKMARK_REQUEST_KEY_NAME]: newBookmark, }); } } /** * Search current URL and open appropriate page. */ async function searchCurrentUrl(tabUrl?: string) { try { if (!tabUrl || !isHttpUrl(tabUrl)) { console.warn("Invalid URL, cannot search:", tabUrl); return; } console.log("Searching bookmarks for URL:", tabUrl); const settings = await getPluginSettings(); const serverAddress = settings.address; const matchedBookmarkId = await getBadgeStatus(tabUrl); let targetUrl: string; if (matchedBookmarkId) { // Found exact match, open bookmark details page targetUrl = `${serverAddress}/dashboard/preview/${matchedBookmarkId}`; console.log("Opening bookmark details page:", targetUrl); } else { // No exact match, open search results page const searchQuery = encodeURIComponent(`url:${tabUrl}`); targetUrl = `${serverAddress}/dashboard/search?q=${searchQuery}`; console.log("Opening search results page:", targetUrl); } await chrome.tabs.create({ url: targetUrl, active: true }); } catch (error) { console.error("Failed to search current URL:", error); } } /** * Clear badge cache for the current active page. */ async function clearCurrentPageCache() { try { // Get the active tab const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true, }); if (activeTab.url && activeTab.id) { console.log("Clearing cache for current page:", activeTab.url); await clearBadgeStatus(activeTab.url); // Refresh the badge for the current tab await checkAndUpdateIcon(activeTab.id); } } catch (error) { console.error("Failed to clear current page cache:", error); } } /** * Clear all badge cache and refresh badges for all active tabs. */ async function clearAllCache() { try { console.log("Clearing all badge cache"); await clearBadgeStatus(); } catch (error) { console.error("Failed to clear all cache:", error); } } getPluginSettings().then(async (settings: Settings) => { await checkSettingsState(settings); }); subscribeToSettingsChanges(async (settings) => { await checkSettingsState(settings); }); // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Manifest V3 allows async functions for all callbacks chrome.contextMenus.onClicked.addListener(handleContextMenuClick); /** * Handle command events, such as adding a link to karakeep. * @param command The command to handle. * @param tab The current tab. */ function handleCommand(command: string, tab: chrome.tabs.Tab) { if (command === ADD_LINK_TO_KARAKEEP_ID) { addLinkToKarakeep({ selectionText: undefined, srcUrl: undefined, linkUrl: undefined, pageUrl: tab?.url, }); // now try to open the popup chrome.action.openPopup(); } else { console.warn(`Received unknown command: ${command}`); } } chrome.commands.onCommand.addListener(handleCommand); /** * Set the badge text and color based on the provided information. * @param badgeStatus * @param tabId The ID of the tab to update. */ export async function setBadge(badgeStatus: string | null, tabId?: number) { if (!tabId) return; if (badgeStatus) { return await Promise.all([ chrome.action.setBadgeText({ tabId, text: ` ` }), chrome.action.setBadgeBackgroundColor({ tabId, color: "#4CAF50", }), ]); } else { await chrome.action.setBadgeText({ tabId, text: `` }); } } /** * Check and update the badge icon for a given tab ID. * @param tabId The ID of the tab to update. */ async function checkAndUpdateIcon(tabId: number) { const tabInfo = await chrome.tabs.get(tabId); const { showCountBadge } = await getPluginSettings(); const api = await getApiClient(); if ( !api || !showCountBadge || !tabInfo.url || !isHttpUrl(tabInfo.url) || tabInfo.status !== "complete" ) { await chrome.action.setBadgeText({ tabId, text: "" }); return; } console.log("Tab activated", tabId, tabInfo); try { const status = await getBadgeStatus(tabInfo.url); await setBadge(status, tabId); } catch (error) { console.error("Archive check failed:", error); await setBadge(null, tabId); } } chrome.tabs.onActivated.addListener(async (tabActiveInfo) => { await checkAndUpdateIcon(tabActiveInfo.tabId); }); chrome.tabs.onUpdated.addListener(async (tabId) => { await checkAndUpdateIcon(tabId); }); // Listen for REFRESH_BADGE messages from popup and update badge accordingly chrome.runtime.onMessage.addListener(async (msg) => { if (msg && msg.type) { if (msg.currentTab && msg.type === MessageType.BOOKMARK_REFRESH_BADGE) { console.log( "Received REFRESH_BADGE message for tab:", msg.currentTab.url, ); if (msg.currentTab.url) { await clearBadgeStatus(msg.currentTab.url); } if (typeof msg.currentTab.id === "number") { await checkAndUpdateIcon(msg.currentTab.id); } } } }); ================================================ FILE: apps/browser-extension/src/background/protocol.ts ================================================ export const NEW_BOOKMARK_REQUEST_KEY_NAME = "karakeep-new-bookmark"; ================================================ FILE: apps/browser-extension/src/components/BookmarkLists.tsx ================================================ import { useQuery } from "@tanstack/react-query"; import { X } from "lucide-react"; import { useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; import { useTRPC } from "../utils/trpc"; import { Button } from "./ui/button"; export default function BookmarkLists({ bookmarkId }: { bookmarkId: string }) { const api = useTRPC(); const { data: allLists } = useBookmarkLists(); const { mutate: deleteFromList } = useRemoveBookmarkFromList(); const { data: lists } = useQuery( api.lists.getListsOfBookmark.queryOptions({ bookmarkId }), ); if (!lists || !allLists) { return null; } return (
    {lists.lists.map((l) => (
  • {allLists .getPathById(l.id)! .map((l) => `${l.icon} ${l.name}`) .join(" / ")}
  • ))}
); } ================================================ FILE: apps/browser-extension/src/components/ListsSelector.tsx ================================================ import * as React from "react"; import { useQuery } from "@tanstack/react-query"; import { useSet } from "@uidotdev/usehooks"; import { Check, ChevronsUpDown } from "lucide-react"; import { useAddBookmarkToList, useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; import { cn } from "../utils/css"; import { useTRPC } from "../utils/trpc"; import { Button } from "./ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "./ui/command"; import { DynamicPopoverContent } from "./ui/dynamic-popover"; import { Popover, PopoverTrigger } from "./ui/popover"; export function ListsSelector({ bookmarkId }: { bookmarkId: string }) { const api = useTRPC(); const currentlyUpdating = useSet(); const [open, setOpen] = React.useState(false); const { mutate: addToList } = useAddBookmarkToList(); const { mutate: removeFromList } = useRemoveBookmarkFromList(); const { data: existingLists } = useQuery( api.lists.getListsOfBookmark.queryOptions({ bookmarkId, }), ); const { data: allLists } = useBookmarkLists(); const existingListIds = new Set(existingLists?.lists.map((list) => list.id)); const toggleList = (listId: string) => { currentlyUpdating.add(listId); if (existingListIds.has(listId)) { removeFromList( { bookmarkId, listId }, { onSettled: (_resp, _err, req) => currentlyUpdating.delete(req.listId), }, ); } else { addToList( { bookmarkId, listId }, { onSettled: (_resp, _err, req) => currentlyUpdating.delete(req.listId), }, ); } }; return ( You don't have any lists. {allLists?.allPaths .filter((path) => path[path.length - 1].userRole !== "viewer") .map((path) => { const lastItem = path[path.length - 1]; return ( {path .map((item) => `${item.icon} ${item.name}`) .join(" / ")} ); })} ); } ================================================ FILE: apps/browser-extension/src/components/NoteEditor.tsx ================================================ import { useEffect, useState } from "react"; import { Check, Save } from "lucide-react"; import { useAutoRefreshingBookmarkQuery, useUpdateBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; export function NoteEditor({ bookmarkId }: { bookmarkId: string }) { const { data: bookmark } = useAutoRefreshingBookmarkQuery({ bookmarkId }); const [error, setError] = useState(null); const [isSaving, setIsSaving] = useState(false); const [noteValue, setNoteValue] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // Update local state when bookmark changes, but only if there are no unsaved changes // This prevents overwriting user's edits while they're typing useEffect(() => { if (bookmark && !hasUnsavedChanges) { setNoteValue(bookmark.note ?? ""); } }, [bookmark?.note, bookmark, hasUnsavedChanges]); const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { setError(null); setIsSaving(false); setHasUnsavedChanges(false); }, onError: (e) => { setError(e.message || "Failed to save note"); setIsSaving(false); }, }); const handleSave = () => { if (!bookmark || noteValue === bookmark.note || isSaving) { return; } setIsSaving(true); setError(null); updateBookmarkMutator.mutate({ bookmarkId: bookmark.id, note: noteValue, }); }; if (!bookmark) { return null; } return (