Full Code of ai-tmarks/tmarks for AI

main 6d976e5e7713 cached
383 files
1.5 MB
410.1k tokens
1104 symbols
1 requests
Download .txt
Showing preview only (1,671K chars total). Download the full file or copy to clipboard to get everything.
Repository: ai-tmarks/tmarks
Branch: main
Commit: 6d976e5e7713
Files: 383
Total size: 1.5 MB

Directory structure:
gitextract_hsh8svta/

├── DEPLOYMENT.md
├── LICENSE
├── README.md
└── tmarks/
    ├── .gitignore
    ├── .prettierignore
    ├── .prettierrc
    ├── API-DATABASE-AUDIT.md
    ├── eslint.config.js
    ├── functions/
    │   ├── api/
    │   │   ├── _middleware.ts
    │   │   ├── index.ts
    │   │   ├── public/
    │   │   │   └── [slug].ts
    │   │   ├── share/
    │   │   │   └── [token].ts
    │   │   ├── shared/
    │   │   │   └── cache.ts
    │   │   ├── snapshot-images/
    │   │   │   └── [hash].ts
    │   │   ├── tab/
    │   │   │   ├── bookmarks/
    │   │   │   │   ├── [id]/
    │   │   │   │   │   ├── click.ts
    │   │   │   │   │   ├── permanent.ts
    │   │   │   │   │   ├── restore.ts
    │   │   │   │   │   ├── snapshot-upload.ts
    │   │   │   │   │   ├── snapshots/
    │   │   │   │   │   │   ├── [snapshotId].ts
    │   │   │   │   │   │   └── cleanup.ts
    │   │   │   │   │   ├── snapshots-v2.ts
    │   │   │   │   │   ├── snapshots.ts
    │   │   │   │   │   └── trash.ts
    │   │   │   │   ├── [id].ts
    │   │   │   │   ├── batch/
    │   │   │   │   │   └── index.ts
    │   │   │   │   ├── batch-handler.ts
    │   │   │   │   ├── bookmark-batch.ts
    │   │   │   │   ├── bookmark-list.ts
    │   │   │   │   ├── index.ts
    │   │   │   │   ├── reorder-pinned.ts
    │   │   │   │   ├── trash/
    │   │   │   │   │   └── empty.ts
    │   │   │   │   └── trash.ts
    │   │   │   ├── me.ts
    │   │   │   ├── search.ts
    │   │   │   ├── statistics/
    │   │   │   │   └── index.ts
    │   │   │   ├── tab-groups/
    │   │   │   │   ├── [id]/
    │   │   │   │   │   ├── items/
    │   │   │   │   │   │   └── batch.ts
    │   │   │   │   │   ├── permanent-delete.ts
    │   │   │   │   │   ├── restore.ts
    │   │   │   │   │   └── share.ts
    │   │   │   │   ├── [id].ts
    │   │   │   │   ├── index.ts
    │   │   │   │   ├── items/
    │   │   │   │   │   ├── [id]/
    │   │   │   │   │   │   └── move.ts
    │   │   │   │   │   └── [id].ts
    │   │   │   │   └── trash.ts
    │   │   │   └── tags/
    │   │   │       ├── [id]/
    │   │   │       │   └── click.ts
    │   │   │       ├── [id].ts
    │   │   │       └── index.ts
    │   │   └── v1/
    │   │       ├── auth/
    │   │       │   ├── login.ts
    │   │       │   ├── logout.ts
    │   │       │   ├── refresh.ts
    │   │       │   └── register.ts
    │   │       ├── bookmarks/
    │   │       │   ├── [id]/
    │   │       │   │   ├── click.ts
    │   │       │   │   ├── permanent.ts
    │   │       │   │   ├── restore.ts
    │   │       │   │   ├── snapshot-cleanup.ts
    │   │       │   │   ├── snapshots/
    │   │       │   │   │   ├── [snapshotId]/
    │   │       │   │   │   │   └── view.ts
    │   │       │   │   │   ├── [snapshotId].ts
    │   │       │   │   │   └── cleanup.ts
    │   │       │   │   └── snapshots.ts
    │   │       │   ├── [id].ts
    │   │       │   ├── bulk.ts
    │   │       │   ├── index.ts
    │   │       │   ├── statistics-helpers.ts
    │   │       │   ├── statistics.ts
    │   │       │   ├── trash/
    │   │       │   │   └── empty.ts
    │   │       │   └── trash.ts
    │   │       ├── change-password.ts
    │   │       ├── export.ts
    │   │       ├── health.ts
    │   │       ├── preferences-helpers.ts
    │   │       ├── preferences.ts
    │   │       ├── settings/
    │   │       │   ├── api-keys/
    │   │       │   │   ├── [id].ts
    │   │       │   │   └── index.ts
    │   │       │   ├── share.ts
    │   │       │   └── storage.ts
    │   │       ├── shared/
    │   │       │   └── cache.ts
    │   │       ├── statistics/
    │   │       │   └── index.ts
    │   │       ├── tab-groups/
    │   │       │   ├── [id]/
    │   │       │   │   ├── items/
    │   │       │   │   │   └── batch.ts
    │   │       │   │   ├── permanent-delete.ts
    │   │       │   │   ├── restore.ts
    │   │       │   │   └── share.ts
    │   │       │   ├── [id].ts
    │   │       │   ├── batch-update.ts
    │   │       │   ├── index.ts
    │   │       │   ├── items/
    │   │       │   │   ├── [id]/
    │   │       │   │   │   └── move.ts
    │   │       │   │   └── [id].ts
    │   │       │   └── trash.ts
    │   │       └── tags/
    │   │           ├── [id].ts
    │   │           └── index.ts
    │   ├── lib/
    │   │   ├── api-key/
    │   │   │   ├── generator.ts
    │   │   │   ├── logger.ts
    │   │   │   ├── rate-limiter-types.ts
    │   │   │   ├── rate-limiter.ts
    │   │   │   └── validator.ts
    │   │   ├── bookmark-utils.ts
    │   │   ├── cache/
    │   │   │   ├── README.md
    │   │   │   ├── bookmark-cache.ts
    │   │   │   ├── config.ts
    │   │   │   ├── index.ts
    │   │   │   ├── service.ts
    │   │   │   ├── strategies.ts
    │   │   │   └── types.ts
    │   │   ├── config.ts
    │   │   ├── crypto.ts
    │   │   ├── data-fetchers.ts
    │   │   ├── error-handler.ts
    │   │   ├── image-sig.ts
    │   │   ├── image-upload.ts
    │   │   ├── import-export/
    │   │   │   ├── collect-export-data.ts
    │   │   │   ├── export-scope.ts
    │   │   │   ├── export-stats.ts
    │   │   │   └── exporters/
    │   │   │       ├── html-exporter.ts
    │   │   │       ├── json-exporter.ts
    │   │   │       └── tab-groups-netscape.ts
    │   │   ├── index.ts
    │   │   ├── input-sanitizer.ts
    │   │   ├── jwt.ts
    │   │   ├── rate-limit.ts
    │   │   ├── response.ts
    │   │   ├── signed-url.ts
    │   │   ├── storage-quota.ts
    │   │   ├── tags.ts
    │   │   ├── types.ts
    │   │   ├── utils.ts
    │   │   └── validation.ts
    │   └── middleware/
    │       ├── api-key-auth-pages.ts
    │       ├── api-key-auth.ts
    │       ├── auth.ts
    │       ├── dual-auth.ts
    │       ├── index.ts
    │       └── security.ts
    ├── index.html
    ├── migrations/
    │   ├── 0001_d1_console.sql
    │   ├── 0002_d1_console_ai_settings.sql
    │   ├── 0103_api_key_rate_limits.sql
    │   └── 0104_rate_limits.sql
    ├── package.json
    ├── postcss.config.js
    ├── public/
    │   ├── _headers
    │   └── _routes.json
    ├── scripts/
    │   ├── auto-migrate.js
    │   ├── check-db-schema.js
    │   ├── check-migrations.js
    │   └── prepare-deploy.js
    ├── shared/
    │   ├── import-export-types.ts
    │   └── permissions.ts
    ├── src/
    │   ├── App.tsx
    │   ├── components/
    │   │   ├── api-keys/
    │   │   │   ├── ApiKeyCard.tsx
    │   │   │   ├── ApiKeyDetailModal.tsx
    │   │   │   ├── CreateApiKeyModal.tsx
    │   │   │   ├── StepBasicInfo.tsx
    │   │   │   ├── StepPermissions.tsx
    │   │   │   └── StepSuccess.tsx
    │   │   ├── auth/
    │   │   │   └── ProtectedRoute.tsx
    │   │   ├── bookmarks/
    │   │   │   ├── BatchActionBar.tsx
    │   │   │   ├── BookmarkCardView.tsx
    │   │   │   ├── BookmarkForm.tsx
    │   │   │   ├── BookmarkListContainer.tsx
    │   │   │   ├── BookmarkListItem.tsx
    │   │   │   ├── BookmarkListLayout.tsx
    │   │   │   ├── BookmarkListView.tsx
    │   │   │   ├── BookmarkMinimalListView.tsx
    │   │   │   ├── BookmarkTitleView.tsx
    │   │   │   ├── DefaultBookmarkIcon.tsx
    │   │   │   ├── SnapshotViewer.tsx
    │   │   │   ├── TagSelector.tsx
    │   │   │   ├── bookmark-utils.ts
    │   │   │   ├── defaultIconOptions.ts
    │   │   │   ├── hooks/
    │   │   │   │   └── useBookmarkFormState.ts
    │   │   │   ├── shared/
    │   │   │   │   ├── BookmarkActions.tsx
    │   │   │   │   ├── BookmarkTagList.tsx
    │   │   │   │   ├── MasonryGrid.tsx
    │   │   │   │   ├── useFaviconFallback.ts
    │   │   │   │   └── useResponsiveColumns.ts
    │   │   │   ├── useBookmarkForm.ts
    │   │   │   └── useSnapshots.ts
    │   │   ├── common/
    │   │   │   ├── AdaptiveImage.tsx
    │   │   │   ├── AlertDialog.tsx
    │   │   │   ├── BookmarkIcons.tsx
    │   │   │   ├── BottomNav.tsx
    │   │   │   ├── CircularProgress.tsx
    │   │   │   ├── ColorThemeSelector.tsx
    │   │   │   ├── ConfirmDialog.tsx
    │   │   │   ├── DialogHost.tsx
    │   │   │   ├── DragDropUpload.tsx
    │   │   │   ├── Drawer.tsx
    │   │   │   ├── DropdownMenu.tsx
    │   │   │   ├── ErrorBoundary.tsx
    │   │   │   ├── ErrorDisplay.tsx
    │   │   │   ├── LanguageSelector.tsx
    │   │   │   ├── LazyImage.tsx
    │   │   │   ├── MobileHeader.tsx
    │   │   │   ├── PaginationFooter.tsx
    │   │   │   ├── ProgressIndicator.tsx
    │   │   │   ├── ResizablePanel.tsx
    │   │   │   ├── SearchToolbar.tsx
    │   │   │   ├── SimpleProgress.tsx
    │   │   │   ├── SortSelector.tsx
    │   │   │   ├── ThemeToggle.tsx
    │   │   │   ├── Toast.tsx
    │   │   │   ├── Toggle.tsx
    │   │   │   ├── progressUtils.ts
    │   │   │   └── useAnimatedProgress.ts
    │   │   ├── import-export/
    │   │   │   ├── ExportOptionsForm.tsx
    │   │   │   └── ExportSection.tsx
    │   │   ├── layout/
    │   │   │   ├── AppShell.tsx
    │   │   │   ├── FullScreenAppShell.tsx
    │   │   │   ├── MobileBottomNav.tsx
    │   │   │   ├── PublicAppShell.tsx
    │   │   │   ├── ShellHeader.tsx
    │   │   │   └── ThemedRoot.tsx
    │   │   ├── settings/
    │   │   │   ├── InfoBox.tsx
    │   │   │   ├── SearchAutoClearSettings.tsx
    │   │   │   ├── SettingsNav.tsx
    │   │   │   ├── SettingsSaveBar.tsx
    │   │   │   ├── SettingsSection.tsx
    │   │   │   ├── SettingsTips.tsx
    │   │   │   ├── TagSelectionAutoClearSettings.tsx
    │   │   │   └── tabs/
    │   │   │       ├── ApiSettingsTab.tsx
    │   │   │       ├── AutomationSettingsTab.tsx
    │   │   │       ├── BasicSettingsTab.tsx
    │   │   │       ├── BrowserSettingsTab.tsx
    │   │   │       ├── DataSettingsTab.tsx
    │   │   │       ├── ShareSettingsTab.tsx
    │   │   │       └── SnapshotSettingsTab.tsx
    │   │   ├── tab-groups/
    │   │   │   ├── BatchActionBar.tsx
    │   │   │   ├── ColorPicker.tsx
    │   │   │   ├── EmptyState.tsx
    │   │   │   ├── InsertionIndicator.tsx
    │   │   │   ├── MoveItemDialog.tsx
    │   │   │   ├── MoveToFolderDialog.tsx
    │   │   │   ├── PinnedItemsSection.tsx
    │   │   │   ├── SearchBar.tsx
    │   │   │   ├── ShareDialog.tsx
    │   │   │   ├── SortSelector.tsx
    │   │   │   ├── TabGroupCard.tsx
    │   │   │   ├── TabGroupHeader.tsx
    │   │   │   ├── TabGroupSidebar.tsx
    │   │   │   ├── TabGroupTree.tsx
    │   │   │   ├── TabItem.tsx
    │   │   │   ├── TabItemList.tsx
    │   │   │   ├── TagsInput.tsx
    │   │   │   ├── TodoItemCard.tsx
    │   │   │   ├── TodoSidebar.tsx
    │   │   │   ├── colorUtils.ts
    │   │   │   ├── sortUtils.ts
    │   │   │   └── tree/
    │   │   │       ├── TreeNode.css
    │   │   │       ├── TreeNode.tsx
    │   │   │       ├── TreeNodeContent.tsx
    │   │   │       ├── TreeNodeMenu.tsx
    │   │   │       ├── TreeNodeSimple.tsx
    │   │   │       ├── TreeUtils.ts
    │   │   │       └── useDragAndDrop.ts
    │   │   └── tags/
    │   │       ├── TagControls.tsx
    │   │       ├── TagFormModal.tsx
    │   │       ├── TagItem.tsx
    │   │       ├── TagManageModal.tsx
    │   │       ├── TagSidebar.tsx
    │   │       └── useTagFiltering.ts
    │   ├── hooks/
    │   │   ├── buildTabOpenerHtml.ts
    │   │   ├── index.ts
    │   │   ├── useAnimatedProgress.ts
    │   │   ├── useApiKeys.ts
    │   │   ├── useBatchActions.ts
    │   │   ├── useBookmarkFilters.ts
    │   │   ├── useBookmarks.ts
    │   │   ├── useClientSideFilter.ts
    │   │   ├── useLanguage.ts
    │   │   ├── useLocalPreferences.ts
    │   │   ├── useMediaQuery.ts
    │   │   ├── usePreferences.ts
    │   │   ├── useShare.ts
    │   │   ├── useStorage.ts
    │   │   ├── useTabGroupActions.ts
    │   │   ├── useTabGroupItemActions.ts
    │   │   ├── useTabGroupMenu.ts
    │   │   ├── useTabGroupsQuery.ts
    │   │   └── useTags.ts
    │   ├── i18n/
    │   │   ├── index.ts
    │   │   └── locales/
    │   │       ├── en/
    │   │       │   ├── auth.json
    │   │       │   ├── bookmarks.json
    │   │       │   ├── common.json
    │   │       │   ├── errors.json
    │   │       │   ├── import.json
    │   │       │   ├── info.json
    │   │       │   ├── settings.json
    │   │       │   ├── share.json
    │   │       │   ├── tabGroups.json
    │   │       │   └── tags.json
    │   │       └── zh-CN/
    │   │           ├── auth.json
    │   │           ├── bookmarks.json
    │   │           ├── common.json
    │   │           ├── errors.json
    │   │           ├── import.json
    │   │           ├── info.json
    │   │           ├── settings.json
    │   │           ├── share.json
    │   │           ├── tabGroups.json
    │   │           └── tags.json
    │   ├── lib/
    │   │   ├── ai/
    │   │   │   ├── client.ts
    │   │   │   ├── constants.ts
    │   │   │   └── models.ts
    │   │   ├── api-client.ts
    │   │   ├── constants/
    │   │   │   ├── bookmarks.ts
    │   │   │   └── z-index.ts
    │   │   ├── image-utils.ts
    │   │   ├── logger.ts
    │   │   ├── query-client.ts
    │   │   ├── search-utils.ts
    │   │   ├── types/
    │   │   │   ├── api.types.ts
    │   │   │   ├── auth.types.ts
    │   │   │   ├── bookmark.types.ts
    │   │   │   ├── index.ts
    │   │   │   ├── preferences.types.ts
    │   │   │   └── tab-group.types.ts
    │   │   └── types.ts
    │   ├── main.tsx
    │   ├── pages/
    │   │   ├── about/
    │   │   │   └── AboutPage.tsx
    │   │   ├── auth/
    │   │   │   ├── LoginPage.tsx
    │   │   │   └── RegisterPage.tsx
    │   │   ├── bookmarks/
    │   │   │   ├── BookmarkStatisticsPage.tsx
    │   │   │   ├── BookmarkTrashPage.tsx
    │   │   │   ├── BookmarksPage.tsx
    │   │   │   ├── TrashBookmarkItem.tsx
    │   │   │   ├── components/
    │   │   │   │   ├── BatchSelectionPrompt.tsx
    │   │   │   │   ├── MobileTagDrawer.tsx
    │   │   │   │   └── StatisticsCards.tsx
    │   │   │   └── hooks/
    │   │   │       ├── useBookmarksEffects.ts
    │   │   │       ├── useBookmarksState.ts
    │   │   │       └── useStatisticsData.ts
    │   │   ├── extension/
    │   │   │   └── ExtensionPage.tsx
    │   │   ├── info/
    │   │   │   ├── AboutPage.tsx
    │   │   │   ├── HelpPage.tsx
    │   │   │   ├── PrivacyPage.tsx
    │   │   │   └── TermsPage.tsx
    │   │   ├── settings/
    │   │   │   ├── ApiKeysPage.tsx
    │   │   │   ├── GeneralSettingsPage.tsx
    │   │   │   ├── ImportExportPage.tsx
    │   │   │   ├── PermissionsPage.tsx
    │   │   │   └── ShareSettingsPage.tsx
    │   │   ├── share/
    │   │   │   ├── PublicSharePage.tsx
    │   │   │   ├── components/
    │   │   │   │   └── ShareTopBar.tsx
    │   │   │   └── hooks/
    │   │   │       └── usePublicShareState.ts
    │   │   └── tab-groups/
    │   │       ├── StatisticsPage.tsx
    │   │       ├── TabGroupDetailHeader.tsx
    │   │       ├── TabGroupDetailPage.tsx
    │   │       ├── TabGroupEmptyState.tsx
    │   │       ├── TabGroupItem.tsx
    │   │       ├── TabGroupsPage.tsx
    │   │       ├── TodoPage.tsx
    │   │       ├── TrashPage.tsx
    │   │       ├── components/
    │   │       │   ├── TabGroupsGrid.tsx
    │   │       │   └── TabGroupsList.tsx
    │   │       └── hooks/
    │   │           ├── useGroupManagement.ts
    │   │           ├── useTabGroupDetailState.ts
    │   │           ├── useTabGroupItemDnD.ts
    │   │           ├── useTabGroupsData.ts
    │   │           └── useTabGroupsState.ts
    │   ├── routes/
    │   │   └── index.tsx
    │   ├── services/
    │   │   ├── api-keys.ts
    │   │   ├── auth.ts
    │   │   ├── bookmarks.ts
    │   │   ├── index.ts
    │   │   ├── preferences.ts
    │   │   ├── share.ts
    │   │   ├── storage.ts
    │   │   ├── tab-groups.ts
    │   │   └── tags.ts
    │   ├── stores/
    │   │   ├── authStore.ts
    │   │   ├── dialogStore.ts
    │   │   ├── index.ts
    │   │   ├── themeStore.ts
    │   │   └── toastStore.ts
    │   ├── styles/
    │   │   ├── components.css
    │   │   ├── index.css
    │   │   └── themes/
    │   │       ├── default.css
    │   │       └── orange.css
    │   └── vite-env.d.ts
    ├── tailwind.config.js
    ├── test-register.js
    ├── tsconfig.json
    ├── vite.config.ts
    └── wrangler.toml.example

================================================
FILE CONTENTS
================================================

================================================
FILE: DEPLOYMENT.md
================================================
# 🚀 TMarks 部署指南

## 📹 视频教程

**完整部署教程视频**: [点击观看](https://bushutmarks.pages.dev/course/tmarks)

跟随视频教程,3 分钟完成部署。

---

## 开源用户一页部署指南

**前置条件**
- 有 Cloudflare 账号
- 有 GitHub 账号

---

### 1. 连接仓库并配置构建

1. 在 GitHub 上 Fork 本仓库
2. 打开 Cloudflare Dashboard → **Workers & Pages** → **Pages** → **创建项目**
3. 选择「连接到 Git」,选中你的 Fork
4. 构建配置:
   - 根目录:`tmarks`
   - 构建命令:`pnpm install && pnpm build:deploy`
   - 构建输出目录:`.deploy`
5. 保存并触发一次部署(第一次失败没关系,后面会修好)

### 2. 创建 Cloudflare 资源

1. **D1 数据库(必需)**
   - Workers & Pages → **D1 SQL Database** → Create database
   - 名称:`tmarks-prod-db`

2. **R2 存储桶(可选,快照用)**
   - R2 对象存储 → 创建存储桶
   - 名称:`tmarks-snapshots`
   - 不创建则快照功能不可用,但其他功能正常

> **注意**:KV 命名空间已不再需要,代码中已移除 KV 依赖

### 3. 在 Pages 项目中绑定资源

进入 Pages 项目 → **设置 → 函数**:

- **D1 绑定(必需)**:
  - 新建 D1 绑定,变量名:`DB` → 选择 `tmarks-prod-db`

- **R2 绑定(可选)**:
  - 新建 R2 绑定,变量名:`SNAPSHOTS_BUCKET` → 选择 `tmarks-snapshots`

> **重要**:如果之前配置过 KV 绑定(变量名 `TMARKS_KV`),请删除该绑定,代码中已不再使用 KV。
> 
> 没有 R2 时,可以跳过 R2 绑定,应用仍然可以启动(快照功能不可用)。

### 4. 配置环境变量

进入 Pages 项目 → **设置 → 环境变量(生产环境)**,复制以下配置:

```
ALLOW_REGISTRATION = "true"
ENVIRONMENT = "production"
JWT_ACCESS_TOKEN_EXPIRES_IN = "365d"
JWT_REFRESH_TOKEN_EXPIRES_IN = "365d"
R2_MAX_TOTAL_BYTES = "7516192768"
JWT_SECRET = "your-long-random-jwt-secret-at-least-48-characters"
ENCRYPTION_KEY = "your-long-random-encryption-key-at-least-48-characters"
```

> **重要**:`JWT_SECRET` 和 `ENCRYPTION_KEY` 必须替换为你自己生成的随机字符串(建议 ≥ 48 位)

### 5. 初始化数据库

1. 打开 **Workers & Pages → D1 SQL Database**
2. 进入 `tmarks-prod-db` → **Console**
3. 打开仓库中的以下 SQL 文件:
   - `tmarks/migrations/0001_d1_console.sql`
   - `tmarks/migrations/0002_d1_console_ai_settings.sql`
   - `tmarks/migrations/0100_d1_console.sql`
   - `tmarks/migrations/0101_d1_console.sql`
4. 复制全部 SQL,粘贴到控制台,点击 **Execute** 执行

### 6. 重新部署

1. 回到 Pages 项目 → 部署
2. 对之前失败的部署点击「重试」,或推送任意提交重新触发
3. 构建成功后,就可以访问你的 TMarks 站点了 🎉

> 之后更新:只要往 GitHub 推代码,Cloudflare 会自动重新构建和部署,之前配置的数据库 / R2 / 环境变量都不会丢。

---

## 常见问题

### 部署失败:KV namespace not found

**原因**:Cloudflare Pages Dashboard 中配置了 KV 绑定,但代码中已不再使用 KV。

**解决方案**:
1. 进入 Pages 项目 → 设置 → 函数
2. 找到 KV namespace bindings
3. 删除名为 `TMARKS_KV` 的绑定
4. 保存并重新部署

### 部署失败:D1 database not found

**原因**:D1 数据库绑定配置不正确或数据库不存在。

**解决方案**:
1. 确认已创建 D1 数据库 `tmarks-prod-db`
2. 进入 Pages 项目 → 设置 → 函数
3. 检查 D1 绑定:变量名必须是 `DB`,选择正确的数据库
4. 保存并重新部署

### 如何更新到最新版本

1. 在 GitHub 上同步你的 Fork(Sync fork 按钮)
2. Cloudflare Pages 会自动检测到更新并重新部署
3. 如果有数据库迁移,需要在 D1 Console 中执行新的 SQL 文件

---

## 本地开发

```bash
# 1. 克隆项目
git clone https://github.com/ai-tmarks/tmarks.git
cd tmarks

# 2. 安装依赖
cd tmarks
pnpm install

# 3. 创建数据库并迁移
wrangler d1 create tmarks-prod-db --local
pnpm db:migrate:local

# 4. 启动开发服务器
pnpm dev

# 访问 http://localhost:5173
```

---

## 技术支持

- [问题反馈](https://github.com/ai-tmarks/tmarks/issues)
- [功能建议](https://github.com/ai-tmarks/tmarks/discussions)
- [视频教程](https://bushutmarks.pages.dev/course/tmarks)


================================================
FILE: LICENSE
================================================
Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)

Copyright (c) 2024 TMarks Team

You are free to:

  Share — copy and redistribute the material in any medium or format
  Adapt — remix, transform, and build upon the material

Under the following terms:

  Attribution — You must give appropriate credit, provide a link to the
  license, and indicate if changes were made. You may do so in any reasonable
  manner, but not in any way that suggests the licensor endorses you or your use.

  NonCommercial — You may not use the material for commercial purposes.

No additional restrictions — You may not apply legal terms or technological
measures that legally restrict others from doing anything the license permits.

Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<div align="center">

# 🔖 TMarks

**AI 驱动的智能书签管理系统**

[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue.svg)](https://www.typescriptlang.org/)
[![React](https://img.shields.io/badge/React-18.3%20%7C%2019-61dafb.svg)](https://reactjs.org/)
[![Vite](https://img.shields.io/badge/Vite-6.0%20%7C%207-646cff.svg)](https://vitejs.dev/)
[![Cloudflare](https://img.shields.io/badge/Cloudflare-Workers-f38020.svg)](https://workers.cloudflare.com/)
[![许可证](https://img.shields.io/badge/许可证-MIT-green.svg)](LICENSE)

简体中文

[在线演示](https://tmarks.669696.xyz) | [视频教程](https://bushutmarks.pages.dev/course/tmarks) | [问题反馈](https://github.com/ai-tmarks/tmarks/issues) | [功能建议](https://github.com/ai-tmarks/tmarks/discussions)

</div>

---

## ✨ 项目简介

TMarks 是一个现代化的智能书签管理系统,结合 AI 技术自动生成标签,让书签管理变得简单高效。

### 核心特性

- 📚 **智能书签管理** - AI自动标签、多维筛选、批量操作、拖拽排序
- 🗂️ **标签页组管理** - 一键收纳标签页、智能分组、快速恢复
- 🌐 **公开分享** - 创建个性化书签展示页、KV缓存加速
- 🔌 **浏览器扩展** - 快速保存、AI推荐、离线支持、自动同步
- 🔐 **安全可靠** - JWT认证、API Key管理、数据加密

### 技术栈

- **前端**: React 18/19 + TypeScript + Vite + TailwindCSS 4
- **后端**: Cloudflare Workers + Pages Functions
- **数据库**: Cloudflare D1 (SQLite)
- **快照存储**: Cloudflare R2(可选,用于存储网页快照 HTML 与图片,支持全局 7GB 配额限制)
- **AI集成**: 支持 OpenAI、Anthropic、DeepSeek、智谱等 8+ 提供商

---

## 🔌 浏览器扩展

登录 TMarks 后,进入 **个人设置** 页面下载并安装浏览器扩展。

### 扩展功能

- **快速保存书签** - 一键保存当前网页,AI 自动推荐标签
- **标签页收纳** - 一键收纳所有标签页,改天再看

### 支持浏览器

Chrome / Edge / Opera / Brave / 360 / QQ / 搜狗

---


## 🚀 部署

📖 **详细部署文档**: [DEPLOYMENT.md](DEPLOYMENT.md)

📹 **视频教程**: [点击观看](https://bushutmarks.pages.dev/course/tmarks)(3 分钟完成部署)

---

## 📄 许可证

本项目采用 [MIT License](LICENSE) 开源协议。


================================================
FILE: tmarks/.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?

# Environment
.env
.env.local
.env.*.local
.dev.vars

# Wrangler
.wrangler
.mf

# Testing
coverage

# Build
build
.deploy

# Database migration history (local only)
.migration-history.json
.migration-history-prod.json


================================================
FILE: tmarks/.prettierignore
================================================
# Dependencies
node_modules

# Build outputs
dist
dist-ssr
build
.wrangler

# Environment
.env
.env.local
.dev.vars

# Logs
*.log

# OS
.DS_Store

# IDE
.vscode
.idea


================================================
FILE: tmarks/.prettierrc
================================================
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100,
  "arrowParens": "always"
}


================================================
FILE: tmarks/API-DATABASE-AUDIT.md
================================================
# 数据库表与 API 接口一致性审计报告

生成时间: 2026-04-15

## 📊 总体统计

- **总 API 端点数**: 73
- **总数据库表数**: 21
- **已使用的表数**: 17 (81.0%)
- **未使用的表数**: 4 (19.0%)
- **中间件/工具使用**: 3 (rate_limits, api_key_rate_limits, bookmark_images)

---

## ✅ 核心功能表使用情况

### 1. 书签相关 (Bookmarks)

| 表名 | 使用端点数 | 状态 |
|------|-----------|------|
| `bookmarks` | 37 | ✅ 核心表 |
| `bookmark_tags` | 24 | ✅ 活跃 |
| `bookmark_snapshots` | 16 | ✅ 活跃 |
| `bookmark_click_events` | 3 | ✅ 正常 |
| `bookmark_images` | 0 (工具函数使用) | ✅ 正常 |

**分析**: 
- 书签核心功能完整,包括标签、快照、点击统计
- `bookmark_images` 通过 `lib/image-upload.ts` 和 `lib/storage-quota.ts` 使用,用于封面图管理

### 2. 标签组相关 (Tab Groups)

| 表名 | 使用端点数 | 状态 |
|------|-----------|------|
| `tab_groups` | 22 | ✅ 核心表 |
| `tab_group_items` | 17 | ✅ 活跃 |
| `shares` | 4 | ✅ 正常 |

**分析**: 标签组功能完整,包括分享功能

### 3. 标签相关 (Tags)

| 表名 | 使用端点数 | 状态 |
|------|-----------|------|
| `tags` | 21 | ✅ 核心表 |

**分析**: 标签功能活跃,与书签紧密集成

### 4. 用户与认证

| 表名 | 使用端点数 | 状态 |
|------|-----------|------|
| `users` | 8 | ✅ 核心表 |
| `auth_tokens` | 3 | ✅ 正常 |
| `user_preferences` | 5 | ✅ 正常 |

**分析**: 用户认证和偏好设置功能正常

### 5. API 密钥管理

| 表名 | 使用端点数 | 状态 |
|------|-----------|------|
| `api_keys` | 2 | ✅ 正常 |
| `api_key_logs` | 1 | ✅ 正常 |
| `api_key_rate_limits` | 0 (中间件使用) | ✅ 正常 |

**分析**: 
- API 密钥基础功能正常
- `api_key_rate_limits` 在中间件 `rate-limiter.ts` 中使用,用于 API Key 速率限制

### 6. 审计与统计

| 表名 | 使用端点数 | 状态 |
|------|-----------|------|
| `audit_logs` | 6 | ✅ 正常 |
| `statistics` | 0 | ⚠️ 未使用 |

**分析**: 
- 审计日志功能正常
- `statistics` 表未使用,统计可能通过实时查询实现

---

## ⚠️ 未使用的数据库表

以下表在数据库中定义,但未被任何 API 端点直接使用:

### 1. `bookmark_images` ✅ 工具函数使用
- **定义位置**: `0001_d1_console.sql`
- **用途**: 存储书签封面图片(去重存储)
- **实际使用**: 
  - `lib/image-upload.ts` - 图片上传和管理
  - `lib/storage-quota.ts` - 存储配额计算
- **状态**: ✅ 正常使用,通过工具函数间接调用

### 2. `statistics`
- **定义位置**: `0001_d1_console.sql`
- **用途**: 存储每日统计数据
- **建议**: 
  - 检查是否有计划使用此表
  - 当前统计通过实时查询实现,考虑是否需要持久化

### 3. `registration_limits`
- **定义位置**: `0001_d1_console.sql`
- **用途**: 限制每日注册数量
- **建议**: 
  - 检查注册限流逻辑是否在其他地方实现
  - 如果功能已实现,添加对应的 API 使用

### 4. `ai_settings`
- **定义位置**: `0002_d1_console_ai_settings.sql`
- **用途**: AI 功能配置
- **建议**: 
  - 这是新功能表,可能尚未实现 API
  - 需要添加 AI 设置相关的 API 端点

### 5. `api_key_rate_limits` ✅ 中间件使用
- **定义位置**: `0103_api_key_rate_limits.sql`
- **用途**: API 密钥速率限制
- **实际使用**: `lib/api-key/rate-limiter.ts` - API Key 速率限制中间件
- **状态**: ✅ 正常使用,在中间件层面使用

### 6. `rate_limits` ✅ 中间件使用
- **定义位置**: `0104_rate_limits.sql`
- **用途**: 通用速率限制
- **实际使用**: `lib/rate-limit.ts` - 通用速率限制工具
- **状态**: ✅ 正常使用,在中间件层面使用

### 7. `schema_migrations`
- **定义位置**: `0001_d1_console.sql`
- **用途**: 数据库迁移版本管理
- **状态**: ✅ 系统表,正常未使用

---

## 📍 API 端点分类

### /api/tab/* (扩展 API - 需要 API Key)
- 共 34 个端点
- 主要功能:书签、标签组、标签、搜索、统计
- 认证方式:API Key (X-API-Key header)

### /api/v1/* (Web API - 需要 JWT)
- 共 35 个端点
- 主要功能:认证、书签、标签组、标签、设置、统计
- 认证方式:JWT Token

### /api/public/* (公开 API)
- 1 个端点:公开分享页面
- 无需认证

### /api/share/* (分享 API)
- 1 个端点:标签组分享
- 通过 token 访问

### /api/snapshot-images/* (快照图片)
- 1 个端点:获取快照图片
- 需要签名验证

---

## 🔍 潜在问题与建议

### 1. 真正未使用的表

**需要确认的表**:
- `ai_settings`: 需要实现 AI 设置 API(新功能)
- `statistics`: 评估是否需要持久化统计数据
- `registration_limits`: 确认注册限流策略

### 2. API 一致性

**建议**:
- `/api/tab/*` 和 `/api/v1/*` 存在功能重复
- 考虑统一 API 设计,减少维护成本
- 明确两套 API 的使用场景和差异

### 3. 缺失的功能

**可能需要添加的 API**:
- AI 设置管理 (`/api/v1/settings/ai`)
- 注册限流管理 (管理员功能)
- 速率限制查询 API(如果需要对外暴露)

### 4. 数据完整性

**建议检查**:
- 所有外键约束是否正确设置
- 级联删除是否符合业务逻辑
- 索引是否覆盖常用查询

---

## 📋 详细端点列表

### 书签管理 (Bookmarks)

#### /api/tab/bookmarks
- `GET` - 获取书签列表
- `POST` - 创建书签
- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_snapshots`

#### /api/tab/bookmarks/:id
- `GET` - 获取书签详情
- `PATCH` - 更新书签
- `DELETE` - 软删除书签
- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_snapshots`

#### /api/tab/bookmarks/:id/click
- `POST` - 记录书签点击
- 使用表: `bookmarks`, `bookmark_click_events`

#### /api/tab/bookmarks/:id/snapshots
- `GET` - 获取快照列表
- `POST` - 创建快照
- 使用表: `bookmarks`, `bookmark_snapshots`, `user_preferences`

#### /api/tab/bookmarks/batch
- `POST` - 批量创建书签
- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `audit_logs`

#### /api/tab/bookmarks/trash
- `GET` - 获取回收站书签
- 使用表: `bookmarks`, `tags`, `bookmark_tags`

#### /api/tab/bookmarks/trash/empty
- `DELETE` - 清空回收站
- 使用表: `bookmarks`, `bookmark_tags`, `bookmark_snapshots`

### 标签组管理 (Tab Groups)

#### /api/tab/tab-groups
- `GET` - 获取标签组列表
- `POST` - 创建标签组
- 使用表: `tab_groups`, `tab_group_items`

#### /api/tab/tab-groups/:id
- `GET` - 获取标签组详情
- `PATCH` - 更新标签组
- `DELETE` - 删除标签组
- 使用表: `tab_groups`, `tab_group_items`

#### /api/tab/tab-groups/:id/share
- `POST` - 创建分享
- `DELETE` - 删除分享
- 使用表: `tab_groups`, `shares`

### 标签管理 (Tags)

#### /api/tab/tags
- `GET` - 获取标签列表
- `POST` - 创建标签
- 使用表: `tags`, `bookmark_tags`

#### /api/tab/tags/:id
- `GET` - 获取标签详情
- `PATCH` - 更新标签
- `DELETE` - 删除标签
- 使用表: `tags`, `bookmark_tags`

### 认证 (Authentication)

#### /api/v1/auth/register
- `POST` - 用户注册
- 使用表: `users`, `user_preferences`, `audit_logs`

#### /api/v1/auth/login
- `POST` - 用户登录
- 使用表: `users`, `auth_tokens`, `audit_logs`

#### /api/v1/auth/logout
- `POST` - 用户登出
- 使用表: `auth_tokens`, `audit_logs`

#### /api/v1/auth/refresh
- `POST` - 刷新 Token
- 使用表: `users`, `auth_tokens`, `audit_logs`

### 设置 (Settings)

#### /api/v1/preferences
- `GET` - 获取用户偏好
- `PUT` - 更新用户偏好
- 使用表: `user_preferences`

#### /api/v1/settings/api-keys
- `GET` - 获取 API 密钥列表
- `POST` - 创建 API 密钥
- 使用表: `api_keys`

#### /api/v1/settings/api-keys/:id
- `GET` - 获取 API 密钥详情
- `PATCH` - 更新 API 密钥
- `DELETE` - 删除 API 密钥
- 使用表: `api_keys`, `api_key_logs`

#### /api/v1/settings/share
- `GET` - 获取公开分享设置
- `PUT` - 更新公开分享设置
- 使用表: `users`

### 统计 (Statistics)

#### /api/tab/statistics
- `GET` - 获取统计数据
- 使用表: `tab_groups`, `tab_group_items`, `shares`

#### /api/v1/statistics
- `GET` - 获取统计数据
- 使用表: `tab_groups`, `tab_group_items`

#### /api/v1/bookmarks/statistics
- `GET` - 获取书签统计
- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_click_events`

---

## 🎯 行动建议

### 立即执行
1. ✅ 修复导入路径问题(已完成)
2. ✅ 确认速率限制功能的实现位置(已确认在中间件中)
3. ✅ 确认 bookmark_images 功能(已确认在工具函数中)

### 短期计划
1. 实现 AI 设置相关 API
2. 评估 `statistics` 表的使用需求
3. 确认 `registration_limits` 的实现方式

### 长期规划
1. 统一 `/api/tab/*` 和 `/api/v1/*` 的设计
2. 完善 API 文档
3. 添加更多的统计和分析功能

---

## 📝 备注

- 本报告基于代码静态分析生成
- 未包含中间件和工具函数中的数据库操作
- 建议定期更新此报告以保持同步


================================================
FILE: tmarks/eslint.config.js
================================================
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'

export default tseslint.config(
  {
    ignores: [
      'node_modules',
      'dist',
      'dist-ssr',
      'build',
      '.wrangler',
      '.deploy',
      '**/*.config.js',
      '**/*.config.ts',
      'vite.config.ts',
    ],
  },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': [
        'warn',
        { allowConstantExport: true },
      ],
    },
  },
)


================================================
FILE: tmarks/functions/api/_middleware.ts
================================================
import type { PagesFunction } from '@cloudflare/workers-types'
import { corsHeaders, securityHeaders, requestLogger } from '../middleware/security'

export const onRequest: PagesFunction[] = [
  requestLogger,
  corsHeaders,
  securityHeaders,
]


================================================
FILE: tmarks/functions/api/index.ts
================================================
/**
 *  API -  API 
 * : /api
 * 
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../lib/types'

export const onRequestGet: PagesFunction<Env> = async () => {
  return Response.json({
    name: 'TMarks API',
    version: 'v1',
    description: 'TMarks Bookmark Management API',
    documentation: '/api/docs',
    endpoints: {
      bookmarks: {
        list: 'GET /api/bookmarks',
        create: 'POST /api/bookmarks',
        get: 'GET /api/bookmarks/:id',
        update: 'PATCH /api/bookmarks/:id',
        delete: 'DELETE /api/bookmarks/:id',
      },
      tags: {
        list: 'GET /api/tags',
        create: 'POST /api/tags',
        get: 'GET /api/tags/:id',
        update: 'PATCH /api/tags/:id',
        delete: 'DELETE /api/tags/:id',
      },
      user: {
        me: 'GET /api/me',
      },
      search: {
        global: 'GET /api/search?q=keyword',
      },
    },
    authentication: {
      type: 'API Key',
      header: 'X-API-Key',
      format: 'tmk_live_xxxxxxxxxxxxxxxxxxxx',
      how_to_get: 'Create an API Key in TMarks Settings > API Keys',
    },
    rate_limits: {
      per_minute: 60,
      per_hour: 1000,
      per_day: 10000,
    },
    support: {
      docs: '/help',
    },
  })
}


================================================
FILE: tmarks/functions/api/public/[slug].ts
================================================
import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, BookmarkRow, PublicProfile } from '../../lib/types'
import { notFound, success, internalError } from '../../lib/response'
import { normalizeBookmark } from '../../lib/bookmark-utils'
import { CacheService } from '../../lib/cache'
import { generateCacheKey } from '../../lib/cache/strategies'

interface PublicSharePayload {
  profile: {
    username: string
    title: string | null
    description: string | null
    slug: string
  }
  bookmarks: Array<ReturnType<typeof normalizeBookmark> & { tags: Array<{ id: string; name: string; color: string | null }> }>
  tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }>
  generated_at: string
}

interface PublicSharePaginatedPayload {
  profile: {
    username: string
    title: string | null
    description: string | null
    slug: string
  }
  bookmarks: Array<ReturnType<typeof normalizeBookmark> & { tags: Array<{ id: string; name: string; color: string | null }> }>
  tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }>
  meta: {
    page_size: number
    count: number
    next_cursor: string | null
    has_more: boolean
  }
}



export const onRequestGet: PagesFunction<Env> = async (context) => {
  const slug = (context.params.slug as string | undefined)?.toLowerCase()

  if (!slug) {
    return notFound('Share link not found')
  }

  const url = new URL(context.request.url)
  const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100)
  const pageCursor = url.searchParams.get('page_cursor') || ''

  // ,()
  const usePagination = url.searchParams.has('page_size') || url.searchParams.has('page_cursor')

  // 
  const cache = new CacheService(context.env)
  const cacheKey = usePagination
    ? generateCacheKey('publicShare', slug, { page_cursor: pageCursor || 'first', page_size: pageSize })
    : generateCacheKey('publicShare', slug)

  try {
    // 
    const user = await context.env.DB.prepare(
      `SELECT id as user_id, username, public_share_enabled, public_slug, public_page_title, public_page_description
       FROM users
       WHERE LOWER(public_slug) = ? AND public_share_enabled = 1`
    )
      .bind(slug)
      .first<PublicProfile>()

    if (!user || !user.public_share_enabled || !user.public_slug) {
      return notFound('Share link not found')
    }

    // 
    const cached = await cache.get<PublicSharePayload | PublicSharePaginatedPayload>('publicShare', cacheKey)
    if (cached) {
      return success({
        ...cached,
        _cached: true, // 
      })
    }

    // 
    let bookmarkQuery = `
      SELECT *
      FROM bookmarks
      WHERE user_id = ?
        AND is_public = 1
        AND deleted_at IS NULL
    `
    const bookmarkParams: (string | number)[] = [user.user_id]

    // 
    if (usePagination && pageCursor) {
      bookmarkQuery += ' AND id < ?'
      bookmarkParams.push(pageCursor)
    }

    bookmarkQuery += ' ORDER BY is_pinned DESC, created_at DESC'

    // , LIMIT
    if (usePagination) {
      bookmarkQuery += ' LIMIT ?'
      bookmarkParams.push(pageSize + 1) // 
    }

    const { results: bookmarkRows } = await context.env.DB.prepare(bookmarkQuery)
      .bind(...bookmarkParams)
      .all<BookmarkRow>()

    // ()
    const hasMore = usePagination && bookmarkRows.length > pageSize
    const bookmarksToProcess = usePagination && hasMore
      ? bookmarkRows.slice(0, pageSize)
      : bookmarkRows
    const nextCursor = usePagination && hasMore && bookmarksToProcess.length > 0
      ? String(bookmarksToProcess[bookmarksToProcess.length - 1].id)
      : null

    const bookmarkIds = bookmarksToProcess.map((row) => row.id)

    let allTags: Array<{ bookmark_id: string; id: string; name: string; color: string | null }> = []

    if (bookmarkIds.length > 0) {
      const placeholders = bookmarkIds.map(() => '?').join(',')
      const { results: tagRows } = await context.env.DB.prepare(
        `SELECT bt.bookmark_id, t.id, t.name, t.color
         FROM bookmark_tags bt
         INNER JOIN tags t ON t.id = bt.tag_id
         WHERE bt.bookmark_id IN (${placeholders})
           AND t.deleted_at IS NULL
         ORDER BY t.name`
      )
        .bind(...bookmarkIds)
        .all<{ bookmark_id: string; id: string; name: string; color: string | null }>()

      allTags = tagRows || []
    }

    const tagsByBookmark = new Map<string, Array<{ id: string; name: string; color: string | null }>>()

    for (const tag of allTags) {
      if (!tagsByBookmark.has(tag.bookmark_id)) {
        tagsByBookmark.set(tag.bookmark_id, [])
      }
      const tags = tagsByBookmark.get(tag.bookmark_id)
      if (tags) {
        tags.push({ id: tag.id, name: tag.name, color: tag.color })
      }
    }

    const bookmarks = bookmarksToProcess.map((row) => ({
      ...normalizeBookmark(row),
      tags: tagsByBookmark.get(row.id) || [],
    }))

    // ()
    let tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }> = []

    if (!usePagination || !pageCursor) {
      const tagsCacheKey = generateCacheKey('publicShare', `${slug}:tags`)
      
      // 
      const cachedTags = await cache.get<typeof tags>('publicShare', tagsCacheKey)

      if (cachedTags) {
        tags = cachedTags
      } else {
        // 
        const { results: tagStats } = await context.env.DB.prepare(
          `SELECT t.id, t.name, t.color, COUNT(DISTINCT bt.bookmark_id) as bookmark_count
           FROM tags t
           INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
           INNER JOIN bookmarks b ON bt.bookmark_id = b.id
           WHERE b.user_id = ?
             AND b.is_public = 1
             AND b.deleted_at IS NULL
             AND t.deleted_at IS NULL
           GROUP BY t.id, t.name, t.color
           ORDER BY t.name`
        )
          .bind(user.user_id)
          .all<{ id: string; name: string; color: string | null; bookmark_count: number }>()

        tags = tagStats || []

        // (30)
        if (tags.length > 0) {
          await cache.set('publicShare', tagsCacheKey, tags, { async: true })
        }
      }
    }

    // 
    if (usePagination) {
      const paginatedPayload: PublicSharePaginatedPayload = {
        profile: {
          username: user.username,
          title: user.public_page_title,
          description: user.public_page_description,
          slug: user.public_slug,
        },
        bookmarks,
        tags,
        meta: {
          page_size: pageSize,
          count: bookmarks.length,
          next_cursor: nextCursor,
          has_more: hasMore,
        },
      }

      //  (30 TTL, Level 0 )
      await cache.set('publicShare', cacheKey, paginatedPayload, { async: true })

      return success(paginatedPayload)
    } else {
      const payload: PublicSharePayload = {
        profile: {
          username: user.username,
          title: user.public_page_title,
          description: user.public_page_description,
          slug: user.public_slug,
        },
        bookmarks,
        tags,
        generated_at: new Date().toISOString(),
      }

      //  (30 TTL, Level 0 )
      await cache.set('publicShare', cacheKey, payload, { async: true })

      return success(payload)
    }
  } catch (error) {
    console.error('Public share error:', error)
    return internalError('Failed to load shared bookmarks')
  }
}


================================================
FILE: tmarks/functions/api/share/[token].ts
================================================
/**
 *  API
 * : /api/share/:token
 * : ()
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../lib/types'
import { success, notFound, internalError } from '../../lib/response'

interface ShareRow {
  id: string
  group_id: string
  user_id: string
  share_token: string
  is_public: number
  view_count: number
  created_at: string
  expires_at: string | null
}

interface TabGroupRow {
  id: string
  user_id: string
  title: string
  color: string | null
  tags: string | null
  created_at: string
  updated_at: string
}

interface TabGroupItemRow {
  id: string
  group_id: string
  title: string
  url: string
  favicon: string | null
  position: number
  is_pinned: number
  is_todo: number
  created_at: string
}

// GET /api/share/:token - 
export const onRequestGet: PagesFunction<Env, RouteParams> = async (context) => {
  const shareToken = context.params.token

  try {
    // Get share
    const share = await context.env.DB.prepare(
      'SELECT * FROM shares WHERE share_token = ?'
    )
      .bind(shareToken)
      .first<ShareRow>()

    if (!share) {
      return notFound('Share not found')
    }

    // Check if share is public
    if (share.is_public !== 1) {
      return notFound('Share is private')
    }

    // Check if share has expired
    if (share.expires_at) {
      const expiresAt = new Date(share.expires_at)
      if (expiresAt < new Date()) {
        return notFound('Share has expired')
      }
    }

    // Get tab group
    const groupRow = await context.env.DB.prepare(
      'SELECT * FROM tab_groups WHERE id = ? AND is_deleted = 0'
    )
      .bind(share.group_id)
      .first<TabGroupRow>()

    if (!groupRow) {
      return notFound('Tab group not found')
    }

    // Get tab group items
    const { results: items } = await context.env.DB.prepare(
      'SELECT * FROM tab_group_items WHERE group_id = ? ORDER BY position ASC'
    )
      .bind(share.group_id)
      .all<TabGroupItemRow>()

    // Parse tags
    let tags: string[] | null = null
    if (groupRow.tags) {
      try {
        tags = JSON.parse(groupRow.tags)
      } catch {
        tags = null
      }
    }

    // Increment view count
    await context.env.DB.prepare(
      'UPDATE shares SET view_count = view_count + 1 WHERE id = ?'
    )
      .bind(share.id)
      .run()

    return success({
      tab_group: {
        ...groupRow,
        tags,
        items: items || [],
        item_count: items?.length || 0,
      },
      share_info: {
        view_count: share.view_count + 1,
        created_at: share.created_at,
        expires_at: share.expires_at,
      },
    })
  } catch (error) {
    console.error('Get shared tab group error:', error)
    return internalError('Failed to get shared tab group')
  }
}



================================================
FILE: tmarks/functions/api/shared/cache.ts
================================================
import type { Env } from '../../lib/types'
import { CacheService } from '../../lib/cache'
import { getCacheInvalidationPrefix } from '../../lib/cache/strategies'

/**
 * 

 */
export async function invalidatePublicShareCache(env: Env, userId: string) {
  //  slug
  const record = await env.DB.prepare(
    `SELECT public_slug FROM users WHERE id = ? AND public_share_enabled = 1`
  )
    .bind(userId)
    .first<{ public_slug: string | null }>()

  if (!record?.public_slug) {
    return
  }

  //  CacheService 
  const cache = new CacheService(env)
  const prefix = getCacheInvalidationPrefix(record.public_slug.toLowerCase(), 'publicShare')
  await cache.invalidate(prefix)
}


================================================
FILE: tmarks/functions/api/snapshot-images/[hash].ts
================================================
/**
 *  API
 * : /api/snapshot-images/:hash
 *  R2 
 * 
 * :  API 
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../lib/types'
import { notFound, internalError } from '../../lib/response'
import { generateImageSig } from '../../lib/image-sig'

// OPTIONS /api/snapshot-images/:hash - CORS 
export const onRequestOptions: PagesFunction<Env, 'hash'> = async () => {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
      'Access-Control-Max-Age': '86400',
    },
  })
}

// GET /api/snapshot-images/:hash - 
export const onRequestGet: PagesFunction<Env, 'hash'> = async (context) => {
  try {
    const hash = context.params.hash as string
    const bucket = context.env.SNAPSHOTS_BUCKET
    const db = context.env.DB

    if (!bucket || !db) {
      return internalError('Storage not configured')
    }

    //  URL 
    const url = new URL(context.request.url)
    const userId = url.searchParams.get('u')
    const bookmarkId = url.searchParams.get('b')
    const version = url.searchParams.get('v')

    console.log(`[Snapshot Image API] Request: hash=${hash}, u=${userId}, b=${bookmarkId}, v=${version}`)

    if (!userId || !bookmarkId || !version) {
      console.warn(`[Snapshot Image API] Missing parameters: u=${userId}, b=${bookmarkId}, v=${version}`)
      return new Response('Missing required parameters', {
        status: 400,
        headers: {
          'Content-Type': 'text/plain',
          'Access-Control-Allow-Origin': '*',
        },
      })
    }

    // 
    // 
    const snapshot = await db
      .prepare(
        `SELECT s.id 
         FROM bookmark_snapshots s
         JOIN bookmarks b ON s.bookmark_id = b.id
         WHERE s.bookmark_id = ? 
           AND s.user_id = ? 
           AND s.version = ?
           AND b.deleted_at IS NULL`
      )
      .bind(bookmarkId, userId, parseInt(version, 10) || 0)
      .first()

    if (!snapshot) {
      console.warn(`[Snapshot Image API] Snapshot not found or access denied: u=${userId}, b=${bookmarkId}, v=${version}, hash=${hash}`)
      return notFound('Snapshot not found or access denied')
    }

    // 
    const sig = url.searchParams.get('sig')
    if (!sig) {
      return new Response('Missing image signature', {
        status: 403,
        headers: {
          'Content-Type': 'text/plain',
          'Access-Control-Allow-Origin': '*',
        },
      })
    }
    const expectedSig = await generateImageSig(hash, userId, bookmarkId, context.env.JWT_SECRET)
    if (sig !== expectedSig) {
      return new Response('Invalid image signature', {
        status: 403,
        headers: {
          'Content-Type': 'text/plain',
          'Access-Control-Allow-Origin': '*',
        },
      })
    }

    //  R2 
    let imageKey = `${userId}/${bookmarkId}/v${version}/images/${hash}`

    console.log(`[Snapshot Image API] Fetching: ${imageKey}`)

    //  R2 
    let r2Object = await bucket.get(imageKey)

    // ,(/)
    if (!r2Object) {
      console.log(`[Snapshot Image API] Not found, trying alternative formats...`)
      
      //  hash ,
      if (hash.includes('.')) {
        const hashWithoutExt = hash.replace(/\.(webp|jpg|jpeg|png|gif)$/i, '')
        const altKey = `${userId}/${bookmarkId}/v${version}/images/${hashWithoutExt}`
        console.log(`[Snapshot Image API] Trying without extension: ${altKey}`)
        r2Object = await bucket.get(altKey)
        if (r2Object) imageKey = altKey
      } else {
        //  hash ,
        const extensions = ['.webp', '.jpg', '.jpeg', '.png', '.gif']
        for (const ext of extensions) {
          const altKey = `${userId}/${bookmarkId}/v${version}/images/${hash}${ext}`
          console.log(`[Snapshot Image API] Trying with extension: ${altKey}`)
          r2Object = await bucket.get(altKey)
          if (r2Object) {
            imageKey = altKey
            break
          }
        }
      }
    }

    if (!r2Object) {
      console.warn(`[Snapshot Image API] Image not found in R2 (tried all formats): ${hash}`)
      return new Response('Image not found', {
        status: 404,
        headers: {
          'Content-Type': 'text/plain',
          'Access-Control-Allow-Origin': '*',
        },
      })
    }

    // 
    const imageData = await r2Object.arrayBuffer()
    const contentType = r2Object.httpMetadata?.contentType || 'image/jpeg'

    console.log(`[Snapshot Image API] Serving: ${imageKey}, ${(imageData.byteLength / 1024).toFixed(1)}KB, type: ${contentType}`)

    return new Response(imageData, {
      headers: {
        'Content-Type': contentType,
        'Cache-Control': 'public, max-age=31536000, immutable', // 
        'Access-Control-Allow-Origin': '*', // ()
        'Access-Control-Allow-Methods': 'GET, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type',
      },
    })
  } catch (error) {
    console.error('[Snapshot Image API] Error:', error)
    // ,
    return new Response('Failed to load image', {
      status: 500,
      headers: {
        'Content-Type': 'text/plain',
        'Access-Control-Allow-Origin': '*',
      },
    })
  }
}


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/click.ts
================================================
/**
 *  API - 
 * : /api/tab/bookmarks/:id/click
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { success, notFound, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'

// POST /api/tab/bookmarks/:id/click - 
export const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.update'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      const db = context.env.DB
      const now = new Date().toISOString()

      // 
      const bookmark = await db.prepare(
        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(bookmarkId, userId)
        .first()

      if (!bookmark) {
        return notFound('Bookmark not found')
      }

      // ,
      await db.batch([
        db.prepare(
          'UPDATE bookmarks SET click_count = click_count + 1, last_clicked_at = ? WHERE id = ?'
        ).bind(now, bookmarkId),
        db.prepare(
          'INSERT INTO bookmark_click_events (bookmark_id, user_id, clicked_at) VALUES (?, ?, ?)'
        ).bind(bookmarkId, userId, now),
      ])

      return success({
        message: 'Click recorded successfully',
        clicked_at: now,
      })
    } catch (error) {
      console.error('Record bookmark click error:', error)
      return internalError('Failed to record click')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/permanent.ts
================================================
/**
 *  API - 
 * : /api/tab/bookmarks/:id/permanent
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { noContent, notFound, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'

// DELETE /api/tab/bookmarks/:id/permanent - ()
export const onRequestDelete: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.delete'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      // 
      const existing = await context.env.DB.prepare(
        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL'
      )
        .bind(bookmarkId, userId)
        .first()

      if (!existing) {
        return notFound('Bookmark not found in trash')
      }

      // 
      await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?')
        .bind(bookmarkId)
        .run()

      // 
      await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?')
        .bind(bookmarkId)
        .run()

      // 
      await context.env.DB.prepare('DELETE FROM bookmarks WHERE id = ?')
        .bind(bookmarkId)
        .run()

      return noContent()
    } catch (error) {
      console.error('Permanent delete bookmark error:', error)
      return internalError('Failed to permanently delete bookmark')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/restore.ts
================================================
/**
 *  API - 
 * : /api/tab/bookmarks/:id/restore
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, BookmarkRow, RouteParams } from '../../../../lib/types'
import { success, notFound, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'
import { normalizeBookmark } from '../../../../lib/bookmark-utils'

// PATCH /api/tab/bookmarks/:id/restore - 
export const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.update'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      // 
      const existing = await context.env.DB.prepare(
        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL'
      )
        .bind(bookmarkId, userId)
        .first()

      if (!existing) {
        return notFound('Bookmark not found in trash')
      }

      const now = new Date().toISOString()

      // : deleted_at
      await context.env.DB.prepare(
        'UPDATE bookmarks SET deleted_at = NULL, updated_at = ? WHERE id = ?'
      )
        .bind(now, bookmarkId)
        .run()

      // 
      const bookmarkRow = await context.env.DB.prepare(
        'SELECT * FROM bookmarks WHERE id = ?'
      )
        .bind(bookmarkId)
        .first<BookmarkRow>()

      if (!bookmarkRow) {
        return internalError('Failed to load bookmark after restore')
      }

      // 
      const { results: tags } = await context.env.DB.prepare(
        `SELECT t.id, t.name, t.color
         FROM tags t
         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
         WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL`
      )
        .bind(bookmarkId)
        .all<{ id: string; name: string; color: string | null }>()

      return success({
        bookmark: {
          ...normalizeBookmark(bookmarkRow),
          tags: tags || [],
        },
      })
    } catch (error) {
      console.error('Restore bookmark error:', error)
      return internalError('Failed to restore bookmark')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshot-upload.ts
================================================
/**
 * 
 *  Base64  R2 
 */

import type { R2Bucket } from '@cloudflare/workers-types'

const R2_UPLOAD_CONCURRENCY = 6

interface ImageInput {
  hash: string
  data: string // base64
  type: string
}

interface DecodedImage {
  hash: string
  bytes: Uint8Array
  type: string
}

/**  Base64 , charCodeAt */
export function decodeBase64Image(image: ImageInput): DecodedImage | null {
  try {
    const base64Data = image.data.includes(',')
      ? image.data.split(',')[1]
      : image.data
    const binaryString = atob(base64Data)
    const bytes = new Uint8Array(binaryString.length)
    for (let i = 0; i < binaryString.length; i++) {
      bytes[i] = binaryString.charCodeAt(i)
    }
    return { hash: image.hash, bytes, type: image.type }
  } catch {
    return null
  }
}

interface UploadResult {
  uploadedHashes: string[]
  totalImageSize: number
}

/**
 *  R2
 *  N  R2_UPLOAD_CONCURRENCY 
 */
export async function uploadImagesConcurrently(
  decoded: DecodedImage[],
  bucket: R2Bucket,
  userId: string,
  bookmarkId: string,
  version: number,
  timestamp: number,
): Promise<UploadResult> {
  const uploadedHashes: string[] = []
  let totalImageSize = 0

  for (let i = 0; i < decoded.length; i += R2_UPLOAD_CONCURRENCY) {
    const batch = decoded.slice(i, i + R2_UPLOAD_CONCURRENCY)
    const results = await Promise.allSettled(
      batch.map(async (img) => {
        const imageKey = `${userId}/${bookmarkId}/v${version}/images/${img.hash}`
        await bucket.put(imageKey, img.bytes, {
          httpMetadata: { contentType: img.type },
          customMetadata: {
            userId,
            bookmarkId,
            version: version.toString(),
            snapshotTimestamp: timestamp.toString(),
          },
        })
        return img
      })
    )

    for (const result of results) {
      if (result.status === 'fulfilled') {
        uploadedHashes.push(result.value.hash)
        totalImageSize += result.value.bytes.length
      } else {
        console.error('[Snapshot Upload] Image upload failed:', result.reason)
      }
    }
  }

  return { uploadedHashes, totalImageSize }
}

/**
 *  HTML  URL
 * , O(n*m)  replace
 */
export function replaceImagePlaceholders(
  html: string,
  uploadedHashes: string[],
  userId: string,
  bookmarkId: string,
  version: number,
): string {
  if (uploadedHashes.length === 0) return html

  const hashSet = new Set(uploadedHashes)
  // Match all /api/snapshot-images/{hash} placeholders in one pass
  return html.replace(
    /\/api\/snapshot-images\/([a-f0-9]+)/g,
    (match, hash) => {
      if (hashSet.has(hash)) {
        return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}`
      }
      return match
    }
  )
}


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots/[snapshotId].ts
================================================
/**
 *  API
 * : /api/tab/bookmarks/:id/snapshots/:snapshotId
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../../../lib/types'
import { success, notFound, internalError } from '../../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../../middleware/api-key-auth-pages'

// GET /api/tab/bookmarks/:id/snapshots/:snapshotId - 
export const onRequestGet: PagesFunction<Env, 'id' | 'snapshotId', ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.read'),
  async (context) => {
    try {
      const userId = context.data.user_id
      const bookmarkId = context.params.id as string
      const snapshotId = context.params.snapshotId as string
      const db = context.env.DB
      const bucket = context.env.SNAPSHOTS_BUCKET

      if (!bucket) {
        return internalError('Storage not configured')
      }

      // 
      const snapshot = await db
        .prepare(
          `SELECT s.*, b.url as bookmark_url
           FROM bookmark_snapshots s
           JOIN bookmarks b ON s.bookmark_id = b.id
           WHERE s.id = ? AND s.bookmark_id = ? AND s.user_id = ?`
        )
        .bind(snapshotId, bookmarkId, userId)
        .first()

      if (!snapshot) {
        return notFound('Snapshot not found')
      }

      //  R2 
      const r2Object = await bucket.get(snapshot.r2_key as string)

      if (!r2Object) {
        return notFound('Snapshot file not found')
      }

      //  HTML 
      let htmlContent = await r2Object.text()
      
      //  data URL ()
      const dataUrlCount = (htmlContent.match(/src="data:/g) || []).length
      const htmlSize = new Blob([htmlContent]).size
      console.log(`[Snapshot API] Retrieved from R2: ${(htmlSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`)

      //  CSP meta  HTML head ()
      const cspMetaTag = '<meta http-equiv="Content-Security-Policy" content="default-src * \'unsafe-inline\' \'unsafe-eval\' data: blob:; img-src * data: blob:; font-src * data:; style-src * \'unsafe-inline\'; script-src * \'unsafe-inline\' \'unsafe-eval\'; frame-src *; connect-src *;">';
      if (htmlContent.includes('<head>')) {
        htmlContent = htmlContent.replace('<head>', `<head>${cspMetaTag}`);
        console.log(`[Snapshot API] Injected CSP meta tag`);
      } else if (htmlContent.includes('<HEAD>')) {
        htmlContent = htmlContent.replace('<HEAD>', `<HEAD>${cspMetaTag}`);
        console.log(`[Snapshot API] Injected CSP meta tag`);
      }

      //  V2 ( /api/snapshot-images/ )
      const isV2 = htmlContent.includes('/api/snapshot-images/')
      
      if (isV2) {
        const version = (snapshot as Record<string, unknown>).version as number || 1
        
        //  URL: URL,
        let replacedCount = 0
        htmlContent = htmlContent.replace(
          /\/api\/snapshot-images\/([a-zA-Z0-9._-]+?)(?:\?[^"\s)]*)?(?=["\s)]|$)/g,
          (_match: string, hash: string) => {
            replacedCount++
            // ,()
            return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}`;
          }
        )
        console.log(`[Snapshot API] V2 format detected, normalized ${replacedCount} image URLs`)
      }

      return new Response(htmlContent, {
        headers: {
          'Content-Type': 'text/html; charset=utf-8',
          'Cache-Control': 'public, max-age=3600',
          'X-Content-Type-Options': 'nosniff',
          //  CSP ()
          'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; img-src * data: blob:; font-src * data:; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'; frame-src *; connect-src *;",
        },
      })
    } catch (error) {
      console.error('Get snapshot error:', error)
      return internalError('Failed to get snapshot')
    }
  },
]

// DELETE /api/tab/bookmarks/:id/snapshots/:snapshotId - 
export const onRequestDelete: PagesFunction<Env, 'id' | 'snapshotId', ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.delete'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id as string
    const snapshotId = context.params.snapshotId as string

    try {
      const db = context.env.DB
      const bucket = context.env.SNAPSHOTS_BUCKET

      if (!bucket) {
        return internalError('Storage not configured')
      }

      // 
      const snapshot = await db
        .prepare(
          `SELECT id, r2_key, is_latest
           FROM bookmark_snapshots
           WHERE id = ? AND bookmark_id = ? AND user_id = ?`
        )
        .bind(snapshotId, bookmarkId, userId)
        .first()

      if (!snapshot) {
        return notFound('Snapshot not found')
      }

      //  R2 
      await bucket.delete(snapshot.r2_key as string)

      // 
      await db
        .prepare('DELETE FROM bookmark_snapshots WHERE id = ?')
        .bind(snapshotId)
        .run()

      // (1)
      await db
        .prepare(
          `UPDATE bookmarks 
           SET snapshot_count = MAX(0, snapshot_count - 1)
           WHERE id = ?`
        )
        .bind(bookmarkId)
        .run()

      // ,
      if (snapshot.is_latest) {
        const nextLatest = await db
          .prepare(
            `SELECT id FROM bookmark_snapshots
             WHERE bookmark_id = ? AND user_id = ?
             ORDER BY version DESC
             LIMIT 1`
          )
          .bind(bookmarkId, userId)
          .first()

        if (nextLatest) {
          await db
            .prepare(
              `UPDATE bookmark_snapshots 
               SET is_latest = 1 
               WHERE id = ?`
            )
            .bind(nextLatest.id)
            .run()
        } else {
          // ,
          await db
            .prepare(
              `UPDATE bookmarks 
               SET has_snapshot = 0, 
                   latest_snapshot_at = NULL,
                   snapshot_count = 0
               WHERE id = ?`
            )
            .bind(bookmarkId)
            .run()
        }
      }

      return success({ message: 'Snapshot deleted successfully' })
    } catch (error) {
      console.error('Delete snapshot error:', error)
      return internalError('Failed to delete snapshot')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots/cleanup.ts
================================================
/**
 *  API
 * : /api/v1/bookmarks/:id/snapshots/cleanup
 * : JWT Token
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../../../lib/types'
import { success, badRequest, notFound, internalError } from '../../../../../lib/response'
import { requireAuth, AuthContext } from '../../../../../middleware/auth'

interface CleanupRequest {
  keep_count?: number
  older_than_days?: number
  verify_and_fix?: boolean
}

interface RouteParams {
  id: string
}

interface SnapshotRow {
  id: string
  r2_key: string
  file_size: number
}

async function deleteSnapshotRows(
  db: D1Database, ids: string[], userId: string,
): Promise<void> {
  const placeholders = ids.map(() => '?').join(',')
  await db
    .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders}) AND user_id = ?`)
    .bind(...ids, userId)
    .run()
}

async function updateBookmarkSnapshotCount(
  db: D1Database, bookmarkId: string, userId: string,
): Promise<void> {
  const remaining = await db
    .prepare(
      `SELECT COUNT(*) as count FROM bookmark_snapshots
       WHERE bookmark_id = ? AND user_id = ?`,
    )
    .bind(bookmarkId, userId)
    .first()

  const remainingCount = (remaining?.count as number) || 0

  if (remainingCount === 0) {
    await db
      .prepare(
        `UPDATE bookmarks
         SET has_snapshot = 0, latest_snapshot_at = NULL, snapshot_count = 0
         WHERE id = ? AND user_id = ?`,
      )
      .bind(bookmarkId, userId)
      .run()
  } else {
    await db
      .prepare(`UPDATE bookmarks SET snapshot_count = ? WHERE id = ? AND user_id = ?`)
      .bind(remainingCount, bookmarkId, userId)
      .run()
  }
}

async function handleVerifyAndFix(
  db: D1Database, bucket: R2Bucket, bookmarkId: string, userId: string,
) {
  const { results: allSnapshots } = await db
    .prepare(
      `SELECT id, r2_key, file_size FROM bookmark_snapshots
       WHERE bookmark_id = ? AND user_id = ?`,
    )
    .bind(bookmarkId, userId)
    .all<SnapshotRow>()

  const orphaned: SnapshotRow[] = []

  for (const snapshot of allSnapshots || []) {
    try {
      const r2Object = await bucket.head(snapshot.r2_key)
      if (!r2Object) orphaned.push(snapshot)
    } catch {
      orphaned.push(snapshot)
    }
  }

  if (orphaned.length === 0) {
    return success({
      deleted_count: 0,
      freed_space: 0,
      message: 'All snapshots are valid, no orphaned records found',
    })
  }

  await deleteSnapshotRows(db, orphaned.map((s) => s.id), userId)
  await updateBookmarkSnapshotCount(db, bookmarkId, userId)

  return success({
    deleted_count: orphaned.length,
    freed_space: 0,
    message: `Fixed ${orphaned.length} orphaned snapshot records`,
  })
}

// POST /api/v1/bookmarks/:id/snapshots/cleanup
export const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [
  requireAuth,
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      const body = await context.request.json() as CleanupRequest
      const { keep_count, older_than_days, verify_and_fix } = body

      const db = context.env.DB
      const bucket = context.env.SNAPSHOTS_BUCKET

      if (!bucket) {
        return internalError('Storage not configured')
      }

      const bookmark = await db
        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
        .bind(bookmarkId, userId)
        .first()

      if (!bookmark) return notFound('Bookmark not found')

      if (verify_and_fix) {
        return handleVerifyAndFix(db, bucket, bookmarkId, userId)
      }

      let toDelete: SnapshotRow[] = []

      if (keep_count !== undefined && keep_count >= 0) {
        const result = await db
          .prepare(
            `SELECT id, r2_key, file_size FROM bookmark_snapshots
             WHERE bookmark_id = ? AND user_id = ?
             ORDER BY version DESC LIMIT -1 OFFSET ?`,
          )
          .bind(bookmarkId, userId, keep_count)
          .all()
        toDelete = result.results || []
      } else if (older_than_days !== undefined && older_than_days > 0) {
        const cutoffDate = new Date()
        cutoffDate.setDate(cutoffDate.getDate() - older_than_days)
        const result = await db
          .prepare(
            `SELECT id, r2_key, file_size FROM bookmark_snapshots
             WHERE bookmark_id = ? AND user_id = ? AND created_at < ?
             ORDER BY version ASC`,
          )
          .bind(bookmarkId, userId, cutoffDate.toISOString())
          .all()
        toDelete = result.results || []
      } else {
        return badRequest('Must specify keep_count or older_than_days')
      }

      if (toDelete.length === 0) {
        return success({ deleted_count: 0, freed_space: 0, message: 'No snapshots to delete' })
      }

      const freedSpace = toDelete.reduce((sum, s) => sum + (s.file_size as number || 0), 0)

      for (const snapshot of toDelete) {
        await bucket.delete(snapshot.r2_key as string)
      }

      await deleteSnapshotRows(db, toDelete.map((s) => s.id), userId)
      await updateBookmarkSnapshotCount(db, bookmarkId, userId)

      return success({
        deleted_count: toDelete.length,
        freed_space: freedSpace,
        message: `Deleted ${toDelete.length} snapshots`,
      })
    } catch (error) {
      console.error('Cleanup snapshots error:', error)
      return internalError('Failed to cleanup snapshots')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots-v2.ts
================================================
/**
 *  API V2 - 
 * : /api/tab/bookmarks/:id/snapshots-v2
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../../lib/types'
import { success, badRequest, notFound, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'
import { generateSignedUrl } from '../../../../lib/signed-url'
import { checkR2Quota } from '../../../../lib/storage-quota'
import {
  decodeBase64Image,
  uploadImagesConcurrently,
  replaceImagePlaceholders,
} from './snapshot-upload'

function generateNanoId(): string {
  const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
  const randomValues = new Uint8Array(21)
  crypto.getRandomValues(randomValues)
  let id = ''
  for (let i = 0; i < 21; i++) {
    id += alphabet[randomValues[i] % alphabet.length]
  }
  return id
}

async function sha256(content: string): Promise<string> {
  const data = new TextEncoder().encode(content)
  const hashBuffer = await crypto.subtle.digest('SHA-256', data)
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

interface CreateSnapshotV2Request {
  html_content: string
  title: string
  url: string
  images: Array<{ hash: string; data: string; type: string }>
  force?: boolean
}

// POST /api/tab/bookmarks/:id/snapshots-v2 - (V2)
export const onRequestPost: PagesFunction<Env, 'id', ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.create'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id as string

    try {
      const body = await context.request.json() as CreateSnapshotV2Request
      const { html_content, title, url, images = [], force = false } = body

      if (!html_content || !title || !url) {
        return badRequest('Missing required fields')
      }

      const db = context.env.DB
      const bucket = context.env.SNAPSHOTS_BUCKET

      if (!bucket) {
        return internalError('Storage not configured')
      }

      // 
      const bookmark = await db
        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
        .bind(bookmarkId, userId)
        .first()

      if (!bookmark) {
        return notFound('Bookmark not found')
      }

      //  & ()
      const [contentHash, latestSnapshot, versionResult] = await Promise.all([
        sha256(html_content),
        force ? Promise.resolve(null) : db
          .prepare('SELECT content_hash FROM bookmark_snapshots WHERE bookmark_id = ? AND is_latest = 1')
          .bind(bookmarkId)
          .first(),
        db
          .prepare('SELECT COALESCE(MAX(version), 0) + 1 as next_version FROM bookmark_snapshots WHERE bookmark_id = ?')
          .bind(bookmarkId)
          .first(),
      ])

      if (!force && latestSnapshot && latestSnapshot.content_hash === contentHash) {
        return success({ message: 'Content unchanged, no new snapshot created', is_duplicate: true })
      }

      const version = (versionResult && typeof versionResult.next_version === 'number')
        ? versionResult.next_version
        : 1
      const timestamp = Date.now()

      // 1. (CPU ,)
      const decoded = images
        .map(decodeBase64Image)
        .filter((d): d is NonNullable<typeof d> => d !== null)

      // 2. :
      const htmlBytes = new TextEncoder().encode(html_content)
      const totalImageBytes = decoded.reduce((sum, d) => sum + d.bytes.length, 0)
      const totalSize = htmlBytes.length + totalImageBytes

      const quota = await checkR2Quota(db, context.env, totalSize)
      if (!quota.allowed) {
        const usedGB = quota.usedBytes / (1024 * 1024 * 1024)
        const limitGB = quota.limitBytes / (1024 * 1024 * 1024)
        return badRequest({
          code: 'R2_STORAGE_LIMIT_EXCEEDED',
          message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB.`,
        })
      }

      // 3.  R2(6)
      const { uploadedHashes, totalImageSize } = await uploadImagesConcurrently(
        decoded, bucket, userId, bookmarkId, version, timestamp
      )

      // 4.  HTML 
      const processedHtml = replaceImagePlaceholders(
        html_content, uploadedHashes, userId, bookmarkId, version
      )

      // 5.  HTML
      const htmlKey = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html`
      const processedHtmlBytes = new TextEncoder().encode(processedHtml)

      await bucket.put(htmlKey, processedHtmlBytes, {
        httpMetadata: { contentType: 'text/html; charset=utf-8' },
        customMetadata: {
          userId,
          bookmarkId,
          version: version.toString(),
          title,
          imageCount: uploadedHashes.length.toString(),
          snapshotVersion: '2',
        },
      })

      // 6. 
      const snapshotId = generateNanoId()
      const now = new Date().toISOString()
      const finalSize = processedHtmlBytes.length + totalImageSize

      await db.batch([
        db.prepare(
          `INSERT INTO bookmark_snapshots
           (id, bookmark_id, user_id, version, is_latest, content_hash,
            r2_key, r2_bucket, file_size, mime_type, snapshot_url,
            snapshot_title, snapshot_status, created_at, updated_at)
           VALUES (?, ?, ?, ?, 1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)`
        ).bind(
          snapshotId, bookmarkId, userId, version, contentHash,
          htmlKey, finalSize, url, title, now, now
        ),
        db.prepare('UPDATE bookmark_snapshots SET is_latest = 0 WHERE bookmark_id = ? AND user_id = ? AND id != ?')
          .bind(bookmarkId, userId, snapshotId),
        db.prepare(
          'UPDATE bookmarks SET has_snapshot = 1, latest_snapshot_at = ?, snapshot_count = snapshot_count + 1 WHERE id = ? AND user_id = ?'
        ).bind(now, bookmarkId, userId),
      ])

      //  URL(24 )
      const baseUrl = new URL(context.request.url).origin
      const { signature, expires } = await generateSignedUrl(
        { userId, resourceId: snapshotId, expiresIn: 24 * 3600, action: 'view' },
        context.env.JWT_SECRET
      )
      const viewUrl = `${baseUrl}/api/v1/bookmarks/${bookmarkId}/snapshots/${snapshotId}/view?sig=${signature}&exp=${expires}&u=${userId}&a=view`

      return success({
        snapshot: {
          id: snapshotId,
          version,
          file_size: finalSize,
          image_count: uploadedHashes.length,
          content_hash: contentHash,
          snapshot_title: title,
          is_latest: true,
          created_at: now,
          view_url: viewUrl,
        },
        message: 'Snapshot created successfully (V2)',
      })
    } catch (error) {
      console.error('[Snapshot V2 API] Error:', error)
      return internalError('Failed to create snapshot')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots.ts
================================================
/**
 *  API
 * : /api/tab/bookmarks/:id/snapshots
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../../lib/types'
import { success, badRequest, notFound, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'
import { checkR2Quota } from '../../../../lib/storage-quota'

//  nanoid  ID(21 )
function generateNanoId(): string {
  const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
  const length = 21
  const randomValues = new Uint8Array(length)
  crypto.getRandomValues(randomValues)
  
  let id = ''
  for (let i = 0; i < length; i++) {
    id += alphabet[randomValues[i] % alphabet.length]
  }
  return id
}

//  Web Crypto API  SHA-256 
async function sha256(content: string): Promise<string> {
  const encoder = new TextEncoder()
  const data = encoder.encode(content)
  const hashBuffer = await crypto.subtle.digest('SHA-256', data)
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

interface CreateSnapshotRequest {
  html_content: string
  title: string
  url: string
  force?: boolean
}

// 
const MAX_SNAPSHOT_SIZE = 50 * 1024 * 1024 // 50MB

// GET /api/tab/bookmarks/:id/snapshots - 
export const onRequestGet: PagesFunction<Env, 'id', ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.read'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      const db = context.env.DB

      // 
      const bookmark = await db
        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
        .bind(bookmarkId, userId)
        .first()

      if (!bookmark) {
        return notFound('Bookmark not found')
      }

      // 
      const snapshots = await db
        .prepare(
          `SELECT id, version, file_size, content_hash, snapshot_title, 
                  is_latest, created_at
           FROM bookmark_snapshots
           WHERE bookmark_id = ? AND user_id = ?
           ORDER BY version DESC`
        )
        .bind(bookmarkId, userId)
        .all()

      return success({
        snapshots: snapshots.results || [],
        total: snapshots.results?.length || 0,
      })
    } catch (error) {
      console.error('Get snapshots error:', error)
      return internalError('Failed to get snapshots')
    }
  },
]

// POST /api/tab/bookmarks/:id/snapshots - 
export const onRequestPost: PagesFunction<Env, 'id', ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.create'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      const body = await context.request.json() as CreateSnapshotRequest
      const { html_content, title, url, force = false } = body

      if (!html_content || !title || !url) {
        return badRequest('Missing required fields')
      }

      // 
      const originalSize = new Blob([html_content]).size
      if (originalSize > MAX_SNAPSHOT_SIZE) {
        return badRequest(
          `Snapshot too large (${(originalSize / 1024 / 1024).toFixed(2)}MB). Maximum size is ${MAX_SNAPSHOT_SIZE / 1024 / 1024}MB.`
        )
      }
      
      //  data URL ()
      const dataUrlCount = (html_content.match(/src="data:/g) || []).length
      console.log(`[Snapshot API] Received HTML: ${(originalSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`)

      const db = context.env.DB
      const bucket = context.env.SNAPSHOTS_BUCKET

      if (!bucket) {
        return internalError('Storage not configured')
      }

      // 
      const bookmark = await db
        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
        .bind(bookmarkId, userId)
        .first()

      if (!bookmark) {
        return notFound('Bookmark not found')
      }

      // 
      const contentHash = await sha256(html_content)

      // ()
      if (!force) {
        const latestSnapshot = await db
          .prepare(
            `SELECT content_hash FROM bookmark_snapshots
             WHERE bookmark_id = ? AND is_latest = 1`
          )
          .bind(bookmarkId)
          .first()

        if (latestSnapshot && latestSnapshot.content_hash === contentHash) {
          return success({
            message: 'Content unchanged, no new snapshot created',
            is_duplicate: true,
          })
        }
      }

      // 
      const versionResult = await db
        .prepare(
          `SELECT COALESCE(MAX(version), 0) + 1 as next_version
           FROM bookmark_snapshots
           WHERE bookmark_id = ?`
        )
        .bind(bookmarkId)
        .first()

      const version = versionResult?.next_version as number || 1

      //  R2 
      const timestamp = Date.now()
      const r2Key = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html`

      //  HTML  UTF-8 
      const encoder = new TextEncoder()
      const htmlBytes = encoder.encode(html_content)

      console.log(`[Snapshot API] Encoded to UTF-8: ${(htmlBytes.length / 1024).toFixed(1)}KB`)

      // 
      const quota = await checkR2Quota(db, context.env, htmlBytes.length)
      if (!quota.allowed) {
        const usedGB = quota.usedBytes / (1024 * 1024 * 1024)
        const limitGB = quota.limitBytes / (1024 * 1024 * 1024)
        return badRequest({
          code: 'R2_STORAGE_LIMIT_EXCEEDED',
          message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB. Please delete some snapshots or images and try again.`,
        })
      }

      //  UTF-8  R2
      await bucket.put(r2Key, htmlBytes, {
        httpMetadata: {
          contentType: 'text/html; charset=utf-8',
        },
        customMetadata: {
          userId,
          bookmarkId,
          version: version.toString(),
          title,
          fileSize: htmlBytes.length.toString(),
          dataUrlCount: dataUrlCount.toString(),
        },
      })

      console.log(`[Snapshot API] Uploaded to R2: ${r2Key}`)
      const snapshotId = generateNanoId()
      const now = new Date().toISOString()

      // ( INSERT ,)
      const batch = [
        // ,
        db.prepare(
          `INSERT INTO bookmark_snapshots
           (id, bookmark_id, user_id, version, is_latest, content_hash,
            r2_key, r2_bucket, file_size, mime_type, snapshot_url,
            snapshot_title, snapshot_status, created_at, updated_at)
           VALUES (?, ?, ?,
            (SELECT COALESCE(MAX(version), 0) + 1 FROM bookmark_snapshots WHERE bookmark_id = ?),
            1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)`
        ).bind(
          snapshotId,
          bookmarkId,
          userId,
          bookmarkId,
          contentHash,
          r2Key,
          originalSize,
          url,
          title,
          now,
          now
        ),

        //  is_latest 
        db.prepare(
          `UPDATE bookmark_snapshots 
           SET is_latest = 0 
           WHERE bookmark_id = ? AND id != ?`
        ).bind(bookmarkId, snapshotId),

        // ()
        db.prepare(
          `UPDATE bookmarks 
           SET has_snapshot = 1, 
               latest_snapshot_at = ?,
               snapshot_count = snapshot_count + 1
           WHERE id = ?`
        ).bind(now, bookmarkId),
      ]

      await db.batch(batch)

      // 
      await cleanupOldSnapshots(db, bucket, bookmarkId, userId)

      return success({
        snapshot: {
          id: snapshotId,
          version,
          file_size: originalSize,
          content_hash: contentHash,
          created_at: now,
        },
        message: 'Snapshot created successfully',
      })
    } catch (error) {
      console.error('Create snapshot error:', error)
      return internalError('Failed to create snapshot')
    }
  },
]

// 
async function cleanupOldSnapshots(
  db: D1Database,
  bucket: R2Bucket,
  bookmarkId: string,
  userId: string
) {
  try {
    // 
    const bookmarkSettings = await db
      .prepare('SELECT snapshot_retention_count FROM bookmarks WHERE id = ?')
      .bind(bookmarkId)
      .first()

    const userSettings = await db
      .prepare('SELECT snapshot_retention_count FROM user_preferences WHERE user_id = ?')
      .bind(userId)
      .first()

    const retentionCount =
      (bookmarkSettings?.snapshot_retention_count as number | null) ??
      (userSettings?.snapshot_retention_count as number | null) ??
      5

    // -1 
    if (retentionCount === -1) {
      return
    }

    // 
    const toDelete = await db
      .prepare(
        `SELECT id, r2_key
         FROM bookmark_snapshots
         WHERE bookmark_id = ? AND user_id = ?
         ORDER BY version DESC
         LIMIT -1 OFFSET ?`
      )
      .bind(bookmarkId, userId, retentionCount)
      .all()

    if (!toDelete.results || toDelete.results.length === 0) {
      return
    }

    //  R2 ()
    const deletedIds: unknown[] = []
    for (const snapshot of toDelete.results) {
      try {
        await bucket.delete(snapshot.r2_key as string)
        deletedIds.push(snapshot.id)
      } catch (error) {
        console.error('Failed to delete R2 file:', snapshot.r2_key, error)
      }
    }

    if (deletedIds.length === 0) return

    //  R2 
    const placeholders = deletedIds.map(() => '?').join(',')
    await db
      .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders})`)
      .bind(...deletedIds)
      .run()
  } catch (error) {
    console.error('Cleanup snapshots error:', error)
  }
}


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id]/trash.ts
================================================
/**
 *  API - 
 * : /api/tab/bookmarks/:id/trash
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { success, notFound, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'

// PATCH /api/tab/bookmarks/:id/trash - ()
export const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.delete'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      // 
      const existing = await context.env.DB.prepare(
        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(bookmarkId, userId)
        .first()

      if (!existing) {
        return notFound('Bookmark not found')
      }

      const now = new Date().toISOString()

      // : deleted_at
      await context.env.DB.prepare(
        'UPDATE bookmarks SET deleted_at = ?, updated_at = ? WHERE id = ?'
      )
        .bind(now, now, bookmarkId)
        .run()

      return success({ message: 'Bookmark moved to trash' })
    } catch (error) {
      console.error('Trash bookmark error:', error)
      return internalError('Failed to trash bookmark')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/[id].ts
================================================
/**
 *  API - 
 * : /api/tab/bookmarks/:id
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, BookmarkRow, RouteParams, SQLParam } from '../../../lib/types'
import { success, badRequest, notFound, noContent, internalError } from '../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'
import { isValidUrl, sanitizeString } from '../../../lib/validation'
import { normalizeBookmark } from '../../../lib/bookmark-utils'
import { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'
import { invalidatePublicShareCache } from '../../shared/cache'

interface UpdateBookmarkRequest {
  title?: string
  url?: string
  description?: string
  cover_image?: string
  favicon?: string
  tag_ids?: string[]  // : ID 
  tags?: string[]     // :()
  is_pinned?: boolean
  is_archived?: boolean
  is_public?: boolean
}

// GET /api/bookmarks/:id - 
export const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.read'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      const bookmarkRow = await context.env.DB.prepare(
        'SELECT * FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(bookmarkId, userId)
        .first<BookmarkRow>()

      if (!bookmarkRow) {
        return notFound('Bookmark not found')
      }

      const { results: tags } = await context.env.DB.prepare(
        `SELECT t.id, t.name, t.color
         FROM tags t
         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
         WHERE bt.bookmark_id = ? AND bt.user_id = ? AND t.deleted_at IS NULL`
      )
        .bind(bookmarkId, userId)
        .all<{ id: string; name: string; color: string | null }>()

      const snapshotCountResult = await context.env.DB.prepare(
        `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`
      )
        .bind(bookmarkId, userId)
        .first<{ count: number }>()

      const snapshotCount = snapshotCountResult?.count || 0

      await invalidatePublicShareCache(context.env, userId)

      return success({
        bookmark: {
          ...normalizeBookmark(bookmarkRow),
          tags: tags || [],
          snapshot_count: snapshotCount,
          has_snapshot: snapshotCount > 0,
        },
      })
    } catch (error) {
      console.error('Get bookmark error:', error)
      return internalError('Failed to get bookmark')
    }
  },
]

// PATCH /api/bookmarks/:id - 
export const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.update'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      const existing = await context.env.DB.prepare(
        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(bookmarkId, userId)
        .first()

      if (!existing) {
        return notFound('Bookmark not found')
      }

      const body = (await context.request.json()) as UpdateBookmarkRequest
      const updates: string[] = []
      const values: SQLParam[] = []

      if (body.title !== undefined) {
        if (!body.title.trim()) return badRequest('Title cannot be empty')
        updates.push('title = ?')
        values.push(sanitizeString(body.title, 500))
      }

      if (body.url !== undefined) {
        if (!body.url.trim()) return badRequest('URL cannot be empty')
        if (!isValidUrl(body.url)) return badRequest('Invalid URL format')
        updates.push('url = ?')
        values.push(sanitizeString(body.url, 2000))
      }

      if (body.description !== undefined) {
        updates.push('description = ?')
        values.push(body.description ? sanitizeString(body.description, 1000) : null)
      }

      if (body.cover_image !== undefined) {
        updates.push('cover_image = ?')
        values.push(body.cover_image ? sanitizeString(body.cover_image, 2000) : null)
      }

      if (body.favicon !== undefined) {
        updates.push('favicon = ?')
        values.push(body.favicon ? sanitizeString(body.favicon, 2000) : null)
      }

      if (body.is_pinned !== undefined) {
        updates.push('is_pinned = ?')
        values.push(body.is_pinned ? 1 : 0)
      }

      if (body.is_archived !== undefined) {
        updates.push('is_archived = ?')
        values.push(body.is_archived ? 1 : 0)
      }

      if (body.is_public !== undefined) {
        updates.push('is_public = ?')
        values.push(body.is_public ? 1 : 0)
      }

      const now = new Date().toISOString()

      if (updates.length > 0) {
        updates.push('updated_at = ?')
        values.push(now)
        values.push(bookmarkId, userId)

        await context.env.DB.prepare(
          `UPDATE bookmarks SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`
        )
          .bind(...values)
          .run()
      }

      if (body.tags !== undefined) {
        await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now)
      } else if (body.tag_ids !== undefined) {
        const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids)
        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now)
      }

      const bookmarkRow = await context.env.DB.prepare(
        'SELECT * FROM bookmarks WHERE id = ? AND user_id = ?'
      )
        .bind(bookmarkId, userId)
        .first<BookmarkRow>()

      const { results: tags } = await context.env.DB.prepare(
        `SELECT t.id, t.name, t.color
         FROM tags t
         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
         WHERE bt.bookmark_id = ? AND bt.user_id = ?`
      )
        .bind(bookmarkId, userId)
        .all<{ id: string; name: string; color: string | null }>()

      if (!bookmarkRow) {
        return internalError('Failed to load bookmark after update')
      }

      return success({
        bookmark: {
          ...normalizeBookmark(bookmarkRow),
          tags: tags || [],
        },
      })
    } catch (error) {
      console.error('Update bookmark error:', error)
      return internalError('Failed to update bookmark')
    }
  },
]

// DELETE /api/bookmarks/:id - 
export const onRequestDelete: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.delete'),
  async (context) => {
    const userId = context.data.user_id
    const bookmarkId = context.params.id

    try {
      const existing = await context.env.DB.prepare(
        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(bookmarkId, userId)
        .first()

      if (!existing) {
        return notFound('Bookmark not found')
      }

      const now = new Date().toISOString()

      await context.env.DB.prepare(
        'UPDATE bookmarks SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?'
      )
        .bind(now, now, bookmarkId, userId)
        .run()

      await invalidatePublicShareCache(context.env, userId)

      return noContent()
    } catch (error) {
      console.error('Delete bookmark error:', error)
      return internalError('Failed to delete bookmark')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/batch/index.ts
================================================
/**
 *  API
 * : /api/tab/bookmarks/batch
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { success, badRequest, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'
import { isValidUrl, sanitizeString } from '../../../../lib/validation'
import { generateUUID } from '../../../../lib/crypto'
import { invalidatePublicShareCache } from '../../../shared/cache'
import { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../../lib/tags'

interface BatchCreateBookmarkItem {
  title: string
  url: string
  description?: string
  cover_image?: string
  favicon?: string
  tags?: string[]
  is_pinned?: boolean
  is_archived?: boolean
  is_public?: boolean
}

interface BatchCreateRequest {
  bookmarks: BatchCreateBookmarkItem[]
}

interface BatchCreateResult {
  success: number
  failed: number
  skipped: number
  total: number
  errors?: Array<{
    index: number
    url: string
    error: string
  }>
  created_bookmarks: Array<{
    id: string
    url: string
    title: string
  }>
}

/**
 * GET /api/tab/bookmarks/batch

 */
export const onRequestGet: PagesFunction<Env, RouteParams>[] = [
  async () => {
    return new Response(JSON.stringify({
      error: {
        code: 'METHOD_NOT_ALLOWED',
        message: 'GET method is not supported for batch operations. Use POST instead.'
      }
    }), {
      status: 405,
      headers: {
        'Content-Type': 'application/json',
        'Allow': 'POST'
      }
    })
  }
]

/**
 * POST /api/tab/bookmarks/batch
 * 
 */
export const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.create'),
  async (context) => {
    const userId = context.data.user_id

    try {
      const body = (await context.request.json()) as BatchCreateRequest

      if (!body.bookmarks || !Array.isArray(body.bookmarks) || body.bookmarks.length === 0) {
        return badRequest('bookmarks array is required and cannot be empty')
      }

      if (body.bookmarks.length > 100) {
        return badRequest('Cannot create more than 100 bookmarks at once')
      }

      const result: BatchCreateResult = {
        success: 0,
        failed: 0,
        skipped: 0,
        total: body.bookmarks.length,
        errors: [],
        created_bookmarks: []
      }

      const now = new Date().toISOString()

      for (let i = 0; i < body.bookmarks.length; i++) {
        const item = body.bookmarks[i]

        try {
          if (!item.title || !item.url) {
            result.failed++
            result.errors!.push({ index: i, url: item.url || '', error: 'Title and URL are required' })
            continue
          }

          if (!isValidUrl(item.url)) {
            result.failed++
            result.errors!.push({ index: i, url: item.url, error: 'Invalid URL format' })
            continue
          }

          const title = sanitizeString(item.title, 500)
          const url = sanitizeString(item.url, 2000)
          const description = item.description ? sanitizeString(item.description, 1000) : null
          const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null
          const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null
          const isPinned = item.is_pinned ? 1 : 0
          const isArchived = item.is_archived ? 1 : 0
          const isPublic = item.is_public ? 1 : 0

          const existing = await context.env.DB.prepare(
            'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'
          )
            .bind(userId, url)
            .first<{ id: string; deleted_at: string | null }>()
          const restoredDeletedBookmark = Boolean(existing?.deleted_at)

          let bookmarkId: string

          if (existing) {
            if (!existing.deleted_at) {
              result.skipped++
              continue
            }

            bookmarkId = existing.id
            await context.env.DB.prepare(
              `UPDATE bookmarks
               SET title = ?, description = ?, cover_image = ?, favicon = ?,
                   is_pinned = ?, is_archived = ?, is_public = ?,
                   deleted_at = NULL, updated_at = ?
               WHERE id = ? AND user_id = ?`
            )
              .bind(title, description, coverImage, favicon, isPinned, isArchived, isPublic, now, bookmarkId, userId)
              .run()
          } else {
            bookmarkId = generateUUID()
            await context.env.DB.prepare(
              `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
            )
              .bind(bookmarkId, userId, title, url, description, coverImage, null, favicon, isPinned, isArchived, isPublic, now, now)
              .run()
          }

          if (item.tags !== undefined) {
            await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, item.tags, userId, now)
          } else if (restoredDeletedBookmark) {
            await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now)
          }

          result.success++
          result.created_bookmarks.push({ id: bookmarkId, url, title })

        } catch (error) {
          result.failed++
          result.errors!.push({ index: i, url: item.url || '', error: 'Failed to create bookmark' })
          console.error(`[Batch Create] Failed to create bookmark ${i}:`, error)
        }
      }

      if (result.errors!.length === 0) {
        delete result.errors
      }

      if (result.success > 0) {
        await context.env.DB.prepare(
          `UPDATE tags
           SET usage_count = (
             SELECT COUNT(*) FROM bookmark_tags
             WHERE tag_id = tags.id AND user_id = ?
           )
           WHERE user_id = ? AND deleted_at IS NULL`
        )
          .bind(userId, userId)
          .run()
      }

      await context.env.DB.prepare(
        `INSERT INTO audit_logs (user_id, event_type, payload, created_at)
         VALUES (?, 'batch_create_bookmarks', ?, datetime('now'))`
      )
        .bind(userId, JSON.stringify({
          total: result.total,
          success: result.success,
          failed: result.failed,
          skipped: result.skipped
        }))
        .run()

      await invalidatePublicShareCache(context.env, userId)

      return success(result)

    } catch (error) {
      console.error('Batch create bookmarks error:', error)
      return internalError('Failed to batch create bookmarks')
    }
  }
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/batch-handler.ts
================================================
/**

 */

import type { EventContext } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../lib/types'
import type { ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'
import { success, badRequest } from '../../../lib/response'
import { isValidUrl, sanitizeString } from '../../../lib/validation'
import { generateUUID } from '../../../lib/crypto'
import { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'
import { invalidatePublicShareCache } from '../../shared/cache'

interface BatchCreateBookmarkItem {
  title: string
  url: string
  description?: string
  cover_image?: string
  favicon?: string
  tags?: string[]
  is_pinned?: boolean
  is_archived?: boolean
  is_public?: boolean
}

interface BatchCreateResult {
  success: number
  failed: number
  skipped: number
  total: number
  errors?: Array<{
    index: number
    url: string
    error: string
  }>
  created_bookmarks: Array<{
    id: string
    url: string
    title: string
  }>
}

export async function batchCreateBookmarks(
  context: EventContext<Env, RouteParams, ApiKeyAuthContext>,
  userId: string,
  bookmarks: BatchCreateBookmarkItem[]
): Promise<Response> {
  console.log('[Batch Handler] Starting batch create')
  console.log('[Batch Handler] User ID:', userId)
  console.log('[Batch Handler] Bookmarks count:', bookmarks?.length)

  if (!bookmarks || !Array.isArray(bookmarks) || bookmarks.length === 0) {
    return badRequest('bookmarks array is required and cannot be empty')
  }

  // 
  if (bookmarks.length > 100) {
    return badRequest('Cannot create more than 100 bookmarks at once')
  }

  const result: BatchCreateResult = {
    success: 0,
    failed: 0,
    skipped: 0,
    total: bookmarks.length,
    errors: [],
    created_bookmarks: []
  }

  const now = new Date().toISOString()

  // 
  for (let i = 0; i < bookmarks.length; i++) {
    const item = bookmarks[i]

    try {
      // 
      if (!item.title || !item.url) {
        result.failed++
        result.errors!.push({
          index: i,
          url: item.url || '',
          error: 'Title and URL are required'
        })
        continue
      }

      //  URL 
      if (!isValidUrl(item.url)) {
        result.failed++
        result.errors!.push({
          index: i,
          url: item.url,
          error: 'Invalid URL format'
        })
        continue
      }

      const title = sanitizeString(item.title, 500)
      const url = sanitizeString(item.url, 2000)
      const description = item.description ? sanitizeString(item.description, 1000) : null
      const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null
      const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null
      const isPinned = item.is_pinned ? 1 : 0
      const isArchived = item.is_archived ? 1 : 0
      const isPublic = item.is_public ? 1 : 0

      const existing = await context.env.DB.prepare(
        'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'
      )
        .bind(userId, url)
        .first<{ id: string; deleted_at: string | null }>()
      const restoredDeletedBookmark = Boolean(existing?.deleted_at)

      let bookmarkId: string

      if (existing) {
        if (!existing.deleted_at) {
          // ,
          result.skipped++
          continue
        }

        //
        bookmarkId = existing.id
        await context.env.DB.prepare(
          `UPDATE bookmarks
           SET title = ?, description = ?, cover_image = ?, favicon = ?,
               is_pinned = ?, is_archived = ?, is_public = ?,
               deleted_at = NULL, updated_at = ?
           WHERE id = ? AND user_id = ?`
        )
          .bind(
            title,
            description,
            coverImage,
            favicon,
            isPinned,
            isArchived,
            isPublic,
            now,
            bookmarkId,
            userId
          )
          .run()
      } else {

        bookmarkId = generateUUID()
        await context.env.DB.prepare(
          `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        )
          .bind(
            bookmarkId,
            userId,
            title,
            url,
            description,
            coverImage,
            null, // cover_image_id
            favicon,
            isPinned,
            isArchived,
            isPublic,
            now,
            now
          )
          .run()
      }

      // 
      if (item.tags !== undefined) {
        await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, item.tags, userId, now)
      } else if (restoredDeletedBookmark) {
        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now)
      }

      result.success++
      result.created_bookmarks.push({
        id: bookmarkId,
        url,
        title
      })

    } catch (error) {
      result.failed++
      console.error(`[Batch Handler] Failed to create bookmark ${i}:`, error)
      result.errors!.push({
        index: i,
        url: item.url || '',
        error: 'Failed to create bookmark'
      })
    }
  }

  if (result.errors!.length === 0) {
    delete result.errors
  }

  //  usage_count
  if (result.success > 0) {
    await context.env.DB.prepare(
      `UPDATE tags
       SET usage_count = (
         SELECT COUNT(*) FROM bookmark_tags
         WHERE tag_id = tags.id AND user_id = ?
       )
       WHERE user_id = ? AND deleted_at IS NULL`
    )
      .bind(userId, userId)
      .run()
  }

  // 
  await invalidatePublicShareCache(context.env, userId)

  console.log('[Batch Handler] Complete:', result)
  return success(result)
}


================================================
FILE: tmarks/functions/api/tab/bookmarks/bookmark-batch.ts
================================================
import type { D1Database } from '../../../lib/types'
import { isValidUrl, sanitizeString } from '../../../lib/validation'
import { generateUUID } from '../../../lib/crypto'
import { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'

export type BatchCreateResult = {
  success: number
  failed: number
  skipped: number
  total: number
  errors?: Array<{ index: number; url: string; error: string }>
  created_bookmarks: Array<{ id: string; url: string; title: string }>
}

export async function handleBatchCreate(
  db: D1Database,
  userId: string,
  bookmarks: Array<{
    title: string
    url: string
    description?: string
    cover_image?: string
    favicon?: string
    tags?: string[]
    is_pinned?: boolean
    is_archived?: boolean
    is_public?: boolean
  }>,
  now: string
): Promise<BatchCreateResult> {
  const result: BatchCreateResult = {
    success: 0,
    failed: 0,
    skipped: 0,
    total: bookmarks.length,
    errors: [],
    created_bookmarks: [],
  }

  for (let i = 0; i < bookmarks.length; i++) {
    const item = bookmarks[i]

    try {
      if (!item.title || !item.url) {
        result.failed++
        result.errors.push({
          index: i,
          url: item.url || '',
          error: 'Title and URL are required',
        })
        continue
      }

      if (!isValidUrl(item.url)) {
        result.failed++
        result.errors.push({
          index: i,
          url: item.url,
          error: 'Invalid URL format',
        })
        continue
      }

      const title = sanitizeString(item.title, 500)
      const url = sanitizeString(item.url, 2000)
      const description = item.description ? sanitizeString(item.description, 1000) : null
      const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null
      const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null
      const isPinned = item.is_pinned ? 1 : 0
      const isArchived = item.is_archived ? 1 : 0
      const isPublic = item.is_public ? 1 : 0

      const existing = await db.prepare(
        'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'
      )
        .bind(userId, url)
        .first<{ id: string; deleted_at: string | null }>()
      const restoredDeletedBookmark = Boolean(existing?.deleted_at)

      let bookmarkId: string

      if (existing) {
        if (!existing.deleted_at) {
          result.skipped++
          continue
        }

        bookmarkId = existing.id
        await db.prepare(
          `UPDATE bookmarks
           SET title = ?, description = ?, cover_image = ?, favicon = ?,
               is_pinned = ?, is_archived = ?, is_public = ?,
               deleted_at = NULL, updated_at = ?
           WHERE id = ? AND user_id = ?`
        )
          .bind(title, description, coverImage, favicon, isPinned, isArchived, isPublic, now, bookmarkId, userId)
          .run()
      } else {
        bookmarkId = generateUUID()
        await db.prepare(
          `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        )
          .bind(bookmarkId, userId, title, url, description, coverImage, null, favicon, isPinned, isArchived, isPublic, now, now)
          .run()
      }

      if (item.tags !== undefined) {
        await replaceBookmarkTagsByNames(db, bookmarkId, item.tags, userId, now)
      } else if (restoredDeletedBookmark) {
        await replaceBookmarkTags(db, bookmarkId, userId, [], now)
      }

      result.success++
      result.created_bookmarks.push({ id: bookmarkId, url, title })
    } catch (error) {
      result.failed++
      result.errors.push({
        index: i,
        url: item.url || '',
        error: 'Failed to create bookmark',
      })
      console.error(`[Batch] Failed to create bookmark ${i}:`, error)
    }
  }

  if (result.errors.length === 0) {
    delete result.errors
  }

  if (result.success > 0) {
    await db.prepare(
      `UPDATE tags
       SET usage_count = (
         SELECT COUNT(*) FROM bookmark_tags
         WHERE tag_id = tags.id AND user_id = ?
       )
       WHERE user_id = ? AND deleted_at IS NULL`
    )
      .bind(userId, userId)
      .run()
  }

  return result
}


================================================
FILE: tmarks/functions/api/tab/bookmarks/bookmark-list.ts
================================================
import type { Bookmark, BookmarkRow, SQLParam, D1Database } from '../../../lib/types'

export interface BookmarkWithTags extends Bookmark {
  tags: Array<{ id: string; name: string; color: string | null }>
}

export interface BookmarkListRow extends BookmarkRow {
  pin_order?: number | null
}

export interface BookmarkPageCursor {
  id: string
  isPinned: boolean
  pinOrder: number | null
  sortValue: string
}

export function parseBookmarkPageCursor(raw: string | null): BookmarkPageCursor | null {
  if (!raw) return null

  try {
    const parsed = JSON.parse(raw) as Partial<BookmarkPageCursor>
    if (
      typeof parsed?.id === 'string' &&
      typeof parsed?.isPinned === 'boolean' &&
      typeof parsed?.sortValue === 'string' &&
      (typeof parsed?.pinOrder === 'number' || parsed?.pinOrder === null || parsed?.pinOrder === undefined)
    ) {
      return {
        id: parsed.id,
        isPinned: parsed.isPinned,
        pinOrder: typeof parsed.pinOrder === 'number' ? parsed.pinOrder : null,
        sortValue: parsed.sortValue,
      }
    }
  } catch {
    return null
  }

  return null
}

export function createBookmarkPageCursor(row: BookmarkListRow, sortBy: 'created' | 'updated' | 'pinned'): string {
  return JSON.stringify({
    id: row.id,
    isPinned: Boolean(row.is_pinned),
    pinOrder: row.is_pinned ? Number(row.pin_order ?? 0) : null,
    sortValue: sortBy === 'updated' ? row.updated_at : row.created_at,
  } satisfies BookmarkPageCursor)
}

export function buildBookmarkListQuery(
  userId: string,
  url: URL
): { query: string; params: SQLParam[]; pageSize: number; sortBy: 'created' | 'updated' | 'pinned' } {
  const keyword = url.searchParams.get('keyword')
  const tags = url.searchParams.get('tags')
  const pageSize = Math.max(1, Math.min(parseInt(url.searchParams.get('page_size') || '100') || 100, 200))
  const pageCursor = url.searchParams.get('page_cursor')
  const parsedCursor = parseBookmarkPageCursor(pageCursor)
  const sortBy = (url.searchParams.get('sort') as 'created' | 'updated' | 'pinned') || 'created'
  const pinnedParam = url.searchParams.get('pinned')
  const pinned = pinnedParam ? pinnedParam === 'true' : undefined
  const sortField = sortBy === 'updated' ? 'b.updated_at' : 'b.created_at'

  // 
  let query = `
    SELECT DISTINCT b.*
    FROM bookmarks b
    WHERE b.user_id = ? AND b.deleted_at IS NULL
  `
  const params: SQLParam[] = [userId]

  // 
  if (pinned !== undefined) {
    query += ` AND b.is_pinned = ?`
    params.push(pinned ? 1 : 0)
  }

  if (keyword) {
    query += ` AND (b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?)`
    const searchPattern = `%${keyword}%`
    params.push(searchPattern, searchPattern, searchPattern)
  }

  // (:)
  if (tags) {
    const tagIds = tags.split(',').filter(Boolean)
    if (tagIds.length > 0) {
      query += ` AND b.id IN (
        SELECT bt.bookmark_id
        FROM bookmark_tags bt
        WHERE bt.tag_id IN (${tagIds.map(() => '?').join(',')})
        GROUP BY bt.bookmark_id
        HAVING COUNT(DISTINCT bt.tag_id) = ?
      )`
      params.push(...tagIds, tagIds.length)
    }
  }

  // 
  if (parsedCursor) {
    if (pinned === true) {
      query += ` AND (
        b.pin_order > ?
        OR (b.pin_order = ? AND (${sortField} < ? OR (${sortField} = ? AND b.id < ?)))
      )`
      params.push(
        parsedCursor.pinOrder ?? 0,
        parsedCursor.pinOrder ?? 0,
        parsedCursor.sortValue,
        parsedCursor.sortValue,
        parsedCursor.id
      )
    } else if (pinned === false) {
      query += ` AND (
        ${sortField} < ?
        OR (${sortField} = ? AND b.id < ?)
      )`
      params.push(parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id)
    } else if (parsedCursor.isPinned) {
      query += ` AND (
        b.is_pinned = 0
        OR (
          b.is_pinned = 1 AND (
            b.pin_order > ?
            OR (b.pin_order = ? AND (${sortField} < ? OR (${sortField} = ? AND b.id < ?)))
          )
        )
      )`
      params.push(
        parsedCursor.pinOrder ?? 0,
        parsedCursor.pinOrder ?? 0,
        parsedCursor.sortValue,
        parsedCursor.sortValue,
        parsedCursor.id
      )
    } else {
      query += ` AND b.is_pinned = 0 AND (
        ${sortField} < ?
        OR (${sortField} = ? AND b.id < ?)
      )`
      params.push(parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id)
    }
  } else if (pageCursor) {
    query += ` AND b.id < ?`
    params.push(pageCursor)
  }

  let orderBy = ''
  switch (sortBy) {
    case 'updated':
      orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.updated_at DESC, b.id DESC'
      break
    case 'pinned':
      orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.created_at DESC, b.id DESC'
      break
    case 'created':
    default:
      orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.created_at DESC, b.id DESC'
      break
  }

  query += ` ${orderBy} LIMIT ?`
  params.push(pageSize + 1)

  return { query, params, pageSize, sortBy }
}

export async function fetchBookmarkTags(
  db: D1Database,
  bookmarkIds: string[]
): Promise<Map<string, Array<{ id: string; name: string; color: string | null }>>> {
  const tagsByBookmarkId = new Map<string, Array<{ id: string; name: string; color: string | null }>>()
  if (bookmarkIds.length === 0) return tagsByBookmarkId

  const placeholders = bookmarkIds.map(() => '?').join(',')
  const { results: tagResults } = await db.prepare(
    `SELECT
       bt.bookmark_id,
       t.id,
       t.name,
       t.color
     FROM tags t
     INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
     WHERE bt.bookmark_id IN (${placeholders})
       AND t.deleted_at IS NULL
     ORDER BY bt.bookmark_id, t.name`
  )
    .bind(...bookmarkIds)
    .all<{ bookmark_id: string; id: string; name: string; color: string | null }>()

  const allTags = tagResults ?? []
  for (const tag of allTags) {
    if (!tagsByBookmarkId.has(tag.bookmark_id)) {
      tagsByBookmarkId.set(tag.bookmark_id, [])
    }
    tagsByBookmarkId.get(tag.bookmark_id)!.push({
      id: tag.id,
      name: tag.name,
      color: tag.color,
    })
  }
  return tagsByBookmarkId
}


================================================
FILE: tmarks/functions/api/tab/bookmarks/index.ts
================================================
/**
 *  API - 
 * : /api/tab/bookmarks
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, BookmarkRow, RouteParams } from '../../../lib/types'
import { success, badRequest, created, internalError } from '../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'
import { isValidUrl, sanitizeString } from '../../../lib/validation'
import { generateUUID } from '../../../lib/crypto'
import { normalizeBookmark } from '../../../lib/bookmark-utils'
import { invalidatePublicShareCache } from '../../shared/cache'
import { uploadCoverImageToR2 } from '../../../lib/image-upload'
import { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'
import { 
  buildBookmarkListQuery, 
  fetchBookmarkTags, 
  createBookmarkPageCursor,
  BookmarkListRow,
  BookmarkWithTags
} from './bookmark-list'
import { handleBatchCreate } from './bookmark-batch'

interface CreateBookmarkRequest {
  title?: string
  url?: string
  description?: string
  cover_image?: string
  favicon?: string
  tag_ids?: string[]  // :
  tags?: string[]     // :(
  is_pinned?: boolean
  is_public?: boolean
  bookmarks?: Array<{  // 
    title: string
    url: string
    description?: string
    cover_image?: string
    favicon?: string
    tags?: string[]
    is_pinned?: boolean
    is_archived?: boolean
    is_public?: boolean
  }>
}

// GET /api/bookmarks - 
export const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.read'),
  async (context) => {
    const userId = context.data.user_id
    const url = new URL(context.request.url)

    try {
      const { query, params, pageSize, sortBy } = buildBookmarkListQuery(userId, url)
      const { results } = await context.env.DB.prepare(query).bind(...params).all<BookmarkListRow>()

      const hasMore = results.length > pageSize
      const bookmarks = hasMore ? results.slice(0, pageSize) : results
      const nextCursor = hasMore && bookmarks.length > 0
        ? createBookmarkPageCursor(bookmarks[bookmarks.length - 1], sortBy)
        : null

      const bookmarkIds = bookmarks.map(b => b.id)
      const tagsByBookmarkId = await fetchBookmarkTags(context.env.DB, bookmarkIds)

      const bookmarksWithTags: BookmarkWithTags[] = bookmarks.map(row => {
        const normalized = normalizeBookmark(row)
        return {
          ...normalized,
          tags: tagsByBookmarkId.get(row.id) || [],
        }
      })

      return success({
        bookmarks: bookmarksWithTags,
        meta: {
          page_size: pageSize,
          count: bookmarks.length,
          next_cursor: nextCursor,
          has_more: hasMore,
        },
      })
    } catch (error) {
      console.error('Get bookmarks error:', error)
      return internalError('Failed to get bookmarks')
    }
  },
]

export const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.create'),
  async (context) => {
    const userId = context.data.user_id

    try {
      const body = (await context.request.json()) as CreateBookmarkRequest

      // 
      if (body.bookmarks && Array.isArray(body.bookmarks) && body.bookmarks.length > 0) {
        if (body.bookmarks.length > 100) {
          return badRequest('Cannot create more than 100 bookmarks at once')
        }

        const now = new Date().toISOString()
        const result = await handleBatchCreate(context.env.DB, userId, body.bookmarks, now)
        await invalidatePublicShareCache(context.env, userId)
        return success(result)
      }

      // 
      if (!body.title || !body.url) {
        return badRequest({
          message: 'Title and URL are required',
          code: 'MISSING_FIELDS'
        })
      }

      if (!isValidUrl(body.url)) {
        return badRequest('Invalid URL format')
      }

      const title = sanitizeString(body.title, 500)
      const url = sanitizeString(body.url, 2000)
      const description = body.description ? sanitizeString(body.description, 1000) : null
      let coverImage = body.cover_image ? sanitizeString(body.cover_image, 2000) : null
      const favicon = body.favicon ? sanitizeString(body.favicon, 2000) : null

      const existing = await context.env.DB.prepare(
        'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'
      )
        .bind(userId, url)
        .first<{ id: string; deleted_at: string | null }>()
      const restoredDeletedBookmark = Boolean(existing?.deleted_at)

      const now = new Date().toISOString()
      let bookmarkId: string
      const isPinned = body.is_pinned ? 1 : 0
      const isPublic = body.is_public ? 1 : 0

      //  R2 bucket, R2
      let coverImageId: string | null = null
      if (coverImage && context.env.SNAPSHOTS_BUCKET && context.env.R2_PUBLIC_URL) {
        const tempBookmarkId = existing?.id || generateUUID()
        const uploadResult = await uploadCoverImageToR2(
          coverImage,
          userId,
          tempBookmarkId,
          context.env.SNAPSHOTS_BUCKET,
          context.env.DB,
          context.env.R2_PUBLIC_URL,
          context.env
        )

        if (uploadResult.success && uploadResult.r2Url) {
          coverImage = uploadResult.r2Url
          coverImageId = uploadResult.imageId || null
        }
      }

      if (existing) {
        if (!existing.deleted_at) {
          // 
          const bookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?')
            .bind(existing.id, userId)
            .first<BookmarkRow>()

          const { results: tags } = await context.env.DB.prepare(
            `SELECT t.id, t.name, t.color
             FROM tags t
             INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
             WHERE bt.bookmark_id = ? AND bt.user_id = ?`
          )
            .bind(existing.id, userId)
            .all<{ id: string; name: string; color: string | null }>()

          const snapshotCountResult = await context.env.DB.prepare(
            `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`
          )
            .bind(existing.id, userId)
            .first<{ count: number }>()

          const snapshotCount = snapshotCountResult?.count || 0

          if (!bookmarkRow) {
            return internalError('Failed to retrieve bookmark')
          }

          const bookmark = normalizeBookmark(bookmarkRow)
          return success(
            {
              bookmark: {
                ...bookmark,
                tags: tags || [],
                snapshot_count: snapshotCount,
                has_snapshot: snapshotCount > 0,
              },
            },
            {
              message: 'Bookmark already exists',
              code: 'BOOKMARK_EXISTS',
            }
          )
        }

        // 
        bookmarkId = existing.id
        await context.env.DB.prepare(
          `UPDATE bookmarks
           SET title = ?, description = ?, cover_image = ?, favicon = ?,
               is_pinned = ?, is_public = ?,
               deleted_at = NULL, updated_at = ?
           WHERE id = ? AND user_id = ?`
        )
          .bind(title, description, coverImage, favicon, isPinned, isPublic, now, bookmarkId, userId)
          .run()
      } else {

        bookmarkId = generateUUID()
        await context.env.DB.prepare(
          `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_public, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        )
          .bind(bookmarkId, userId, title, url, description, coverImage, coverImageId, favicon, isPinned, isPublic, now, now)
          .run()
      }

      // 
      if (body.tags !== undefined) {
        await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now)
      } else if (body.tag_ids !== undefined) {
        const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids)
        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now)
      } else if (restoredDeletedBookmark) {
        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now)
      }

      const bookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?')
        .bind(bookmarkId, userId)
        .first<BookmarkRow>()

      const { results: tags } = await context.env.DB.prepare(
        `SELECT t.id, t.name, t.color
         FROM tags t
         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
         WHERE bt.bookmark_id = ? AND bt.user_id = ?`
      )
        .bind(bookmarkId, userId)
        .all<{ id: string; name: string; color: string | null }>()

      if (!bookmarkRow) {
        return internalError('Failed to load bookmark after creation')
      }

      await invalidatePublicShareCache(context.env, userId)

      return created({
        bookmark: {
          ...normalizeBookmark(bookmarkRow),
          tags: tags || [],
        },
      })
    } catch (error) {
      console.error('Create bookmark error:', error)
      return internalError('Failed to create bookmark')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/reorder-pinned.ts
================================================
/**
 * 
 * POST /api/tab/bookmarks/reorder-pinned
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../lib/types'
import { success, badRequest, internalError } from '../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'

interface ReorderPinnedRequest {
  bookmark_ids: string[]
}

export const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.write'),
  async (context) => {
    try {
      const userId = context.data.user_id
      const body = (await context.request.json()) as ReorderPinnedRequest

      if (!body.bookmark_ids || !Array.isArray(body.bookmark_ids) || body.bookmark_ids.length === 0) {
        return badRequest('bookmark_ids is required and must be a non-empty array')
      }

      // 
      const placeholders = body.bookmark_ids.map(() => '?').join(',')
      const { results: bookmarks } = await context.env.DB.prepare(
        `SELECT id FROM bookmarks 
         WHERE id IN (${placeholders}) 
         AND user_id = ? 
         AND is_pinned = 1 
         AND deleted_at IS NULL`
      )
        .bind(...body.bookmark_ids, userId)
        .all<{ id: string }>()

      if (bookmarks.length !== body.bookmark_ids.length) {
        return badRequest('Some bookmarks are not found, not pinned, or do not belong to you')
      }

      // 
      const now = new Date().toISOString()
      const updates = body.bookmark_ids.map((id, index) => {
        return context.env.DB.prepare(
          'UPDATE bookmarks SET pin_order = ?, updated_at = ? WHERE id = ? AND user_id = ?'
        ).bind(index, now, id, userId)
      })

      await context.env.DB.batch(updates)

      return success({
        message: 'Pinned bookmarks reordered successfully',
        count: body.bookmark_ids.length,
      })
    } catch (error) {
      console.error('Reorder pinned bookmarks error:', error)
      return internalError('Failed to reorder pinned bookmarks')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/trash/empty.ts
================================================
/**

 * : /api/tab/bookmarks/trash/empty
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../../lib/types'
import { success, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'

export const onRequestDelete: PagesFunction<Env, string, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.delete'),
  async (context) => {
    const userId = context.data.user_id

    try {

      const { results: trashBookmarks } = await context.env.DB.prepare(
        'SELECT id FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'
      )
        .bind(userId)
        .all<{ id: string }>()

      if (trashBookmarks.length === 0) {
        return success({ message: 'Trash is already empty', count: 0 })
      }

      const bookmarkIds = trashBookmarks.map(b => b.id)

      // 
      for (const id of bookmarkIds) {
        await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?')
          .bind(id)
          .run()
      }

      // 
      for (const id of bookmarkIds) {
        await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?')
          .bind(id)
          .run()
      }

      // 
      await context.env.DB.prepare(
        'DELETE FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'
      )
        .bind(userId)
        .run()

      return success({
        message: 'Trash emptied successfully',
        count: bookmarkIds.length,
      })
    } catch (error) {
      console.error('Empty trash error:', error)
      return internalError('Failed to empty trash')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/bookmarks/trash.ts
================================================
/**

 * : /api/tab/bookmarks/trash
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, BookmarkRow } from '../../../lib/types'
import { success, internalError } from '../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'
import { normalizeBookmark } from '../../../lib/bookmark-utils'

interface TrashQueryParams {
  page_size?: string
  page_cursor?: string
  sort?: string
}

export const onRequestGet: PagesFunction<Env, string, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.read'),
  async (context) => {
    const userId = context.data.user_id
    const url = new URL(context.request.url)
    
    const params: TrashQueryParams = {
      page_size: url.searchParams.get('page_size') || undefined,
      page_cursor: url.searchParams.get('page_cursor') || undefined,
      sort: url.searchParams.get('sort') || undefined,
    }

    try {
      const pageSize = Math.min(Math.max(parseInt(params.page_size || '20', 10) || 20, 1), 100)
      const sort = params.sort === 'deleted_at_asc' ? 'ASC' : 'DESC'

      let query = `
        SELECT * FROM bookmarks 
        WHERE user_id = ? AND deleted_at IS NOT NULL
      `
      const queryParams: (string | number)[] = [userId]

      // 
      if (params.page_cursor) {
        query += ` AND deleted_at < ?`
        queryParams.push(params.page_cursor)
      }

      query += ` ORDER BY deleted_at ${sort} LIMIT ?`
      queryParams.push(pageSize + 1)

      const { results: bookmarks } = await context.env.DB.prepare(query)
        .bind(...queryParams)
        .all<BookmarkRow>()

      const hasMore = bookmarks.length > pageSize
      const items = hasMore ? bookmarks.slice(0, pageSize) : bookmarks

      const bookmarksWithTags = await Promise.all(
        items.map(async (bookmark) => {
          const { results: tags } = await context.env.DB.prepare(
            `SELECT t.id, t.name, t.color
             FROM tags t
             INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
             WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL`
          )
            .bind(bookmark.id)
            .all<{ id: string; name: string; color: string | null }>()

          return {
            ...normalizeBookmark(bookmark),
            tags: tags || [],
          }
        })
      )

      // 
      const countResult = await context.env.DB.prepare(
        'SELECT COUNT(*) as count FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'
      )
        .bind(userId)
        .first<{ count: number }>()

      return success({
        bookmarks: bookmarksWithTags,
        meta: {
          total: countResult?.count || 0,
          page_size: pageSize,
          has_more: hasMore,
          next_cursor: hasMore && items.length > 0 ? items[items.length - 1].deleted_at : null,
        },
      })
    } catch (error) {
      console.error('Get trash bookmarks error:', error)
      return internalError('Failed to get trash bookmarks')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/me.ts
================================================
/**
 *  API - 
 * : /api/tab/me
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../lib/types'
import { success, internalError } from '../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../middleware/api-key-auth-pages'

// GET /api/me - 
type BookmarkStats = {
  total_bookmarks: number | null
  pinned_bookmarks: number | null
}

export const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('user.read'),
  async (context) => {
    const userId = context.data.user_id

    try {
      // 
      const user = await context.env.DB.prepare(
        'SELECT id, username, email, created_at FROM users WHERE id = ?'
      )
        .bind(userId)
        .first()

      if (!user) {
        return internalError('User not found')
      }

      // 
      const stats = await context.env.DB.prepare(
        `SELECT
          COUNT(CASE WHEN deleted_at IS NULL THEN 1 END) as total_bookmarks,
          COUNT(CASE WHEN deleted_at IS NULL AND is_pinned = 1 THEN 1 END) as pinned_bookmarks
        FROM bookmarks
        WHERE user_id = ?`
      )
        .bind(userId)
        .first<BookmarkStats>()

      const tagCount = await context.env.DB.prepare(
        'SELECT COUNT(*) as count FROM tags WHERE user_id = ? AND deleted_at IS NULL'
      )
        .bind(userId)
        .first<{ count: number }>()

      return success({
        user: {
          ...user,
          stats: {
            total_bookmarks: stats?.total_bookmarks ?? 0,
            pinned_bookmarks: stats?.pinned_bookmarks ?? 0,
            total_tags: tagCount?.count || 0,
          },
        },
        api_key: {
          id: context.data.api_key_id,
          permissions: context.data.api_key_permissions,
        },
      })
    } catch (error) {
      console.error('Get user info error:', error)
      return internalError('Failed to get user info')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/search.ts
================================================
/**
 * External API - Global Search
 * Path: /api/tab/search
 * Auth: API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, Bookmark, RouteParams } from '../../lib/types'
import { success, badRequest, internalError } from '../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../middleware/api-key-auth-pages'

// GET /api/search - Global search for bookmarks and tags
type BookmarkWithTags = Bookmark & {
  tags: Array<{ id: string; name: string; color: string | null }>
}

export const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('bookmarks.read'),
  async (context) => {
    const userId = context.data.user_id
    const url = new URL(context.request.url)
    const query = url.searchParams.get('q')

    if (!query || query.trim().length === 0) {
      return badRequest('Search query is required')
    }

    const searchTerm = `%${query.trim()}%`
    const limit = Math.min(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 100)

    try {
      // Search bookmarks
      const { results: bookmarks } = await context.env.DB.prepare(
        `SELECT b.*
         FROM bookmarks b
         WHERE b.user_id = ? AND b.deleted_at IS NULL
         AND (b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?)
         ORDER BY b.is_pinned DESC, b.updated_at DESC
         LIMIT ?`
      )
        .bind(userId, searchTerm, searchTerm, searchTerm, limit)
        .all<Bookmark>()

      // Optimize: Use single query to get all bookmark tags
      let bookmarksWithTags: BookmarkWithTags[] = (bookmarks || []).map(bookmark => ({
        ...bookmark,
        tags: [],
      }))

      if (bookmarksWithTags.length > 0) {
        const bookmarkIds = bookmarksWithTags.map(b => b.id)

        // Get all tags for bookmarks at once
        const { results: allTags } = await context.env.DB.prepare(
          `SELECT
             bt.bookmark_id,
             t.id,
             t.name,
             t.color
           FROM tags t
           INNER JOIN bookmark_tags bt ON t.id = bt.tag_id
           WHERE bt.bookmark_id IN (${bookmarkIds.map(() => '?').join(',')})
             AND t.deleted_at IS NULL
           ORDER BY bt.bookmark_id, t.name`
        )
          .bind(...bookmarkIds)
          .all<{ bookmark_id: string; id: string; name: string; color: string | null }>()

        // Group tags by bookmark ID
        const tagsByBookmarkId = new Map<string, Array<{ id: string; name: string; color: string | null }>>()
        for (const tag of allTags || []) {
          if (!tagsByBookmarkId.has(tag.bookmark_id)) {
            tagsByBookmarkId.set(tag.bookmark_id, [])
          }
          const tags = tagsByBookmarkId.get(tag.bookmark_id)
          if (tags) {
            tags.push({
              id: tag.id,
              name: tag.name,
              color: tag.color,
            })
          }
        }

        // Assemble bookmarks with tags
        bookmarksWithTags = bookmarksWithTags.map(bookmark => ({
          ...bookmark,
          tags: tagsByBookmarkId.get(bookmark.id) || [],
        }))
      }

      // Search tags
      const { results: tags } = await context.env.DB.prepare(
        `SELECT
          t.id,
          t.name,
          t.color,
          t.created_at,
          t.updated_at,
          COUNT(bt.bookmark_id) as bookmark_count
        FROM tags t
        LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id
        LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL
        WHERE t.user_id = ? AND t.deleted_at IS NULL
        AND t.name LIKE ?
        GROUP BY t.id
        ORDER BY t.name ASC
        LIMIT ?`
      )
        .bind(userId, searchTerm, limit)
        .all()

      return success({
        query,
        results: {
          bookmarks: bookmarksWithTags,
          tags: tags || [],
        },
        meta: {
          bookmark_count: bookmarksWithTags.length,
          tag_count: (tags || []).length,
        },
      })
    } catch (error) {
      console.error('Search error:', error)
      return internalError('Failed to search')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/statistics/index.ts
================================================


import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../lib/types'
import { success, internalError } from '../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'

interface DomainCount {
  domain: string
  count: number
}

// GET /api/tab/statistics - Retrieve tab statistics
export const onRequestGet: PagesFunction<Env, string, DualAuthContext>[] = [
  requireDualAuth('tab_groups.read'),
  async (context) => {
    const userId = context.data.user_id
    const url = new URL(context.request.url)
    const days = parseInt(url.searchParams.get('days') || '30', 10) || 30

    try {

      const startDate = new Date()
      startDate.setDate(startDate.getDate() - days)
      const startDateStr = startDate.toISOString().split('T')[0]

      const [
        groupsResult,
        deletedGroupsResult,
        itemsResult,
        sharesResult,
        groupsTrend,
        itemsTrend,
        domains,
        groupSizes
      ] = await Promise.all([

        context.env.DB.prepare(
          'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 0'
        )
          .bind(userId)
          .all<{ count: number }>(),

        context.env.DB.prepare(
          'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 1'
        )
          .bind(userId)
          .all<{ count: number }>(),

        context.env.DB.prepare(
          'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)'
        )
          .bind(userId)
          .all<{ count: number }>(),

        context.env.DB.prepare(
          'SELECT COUNT(*) as count FROM shares WHERE user_id = ?'
        )
          .bind(userId)
          .all<{ count: number }>(),

        context.env.DB.prepare(
          `SELECT DATE(created_at) as date, COUNT(*) as count 
           FROM tab_groups 
           WHERE user_id = ? AND DATE(created_at) >= ? 
           GROUP BY DATE(created_at) 
           ORDER BY date ASC`
        )
          .bind(userId, startDateStr)
          .all<{ date: string; count: number }>(),

        context.env.DB.prepare(
          `SELECT DATE(created_at) as date, COUNT(*) as count 
           FROM tab_group_items 
           WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?) 
           AND DATE(created_at) >= ? 
           GROUP BY DATE(created_at) 
           ORDER BY date ASC`
        )
          .bind(userId, startDateStr)
          .all<{ date: string; count: number }>(),

        context.env.DB.prepare(
          `SELECT 
            CASE 
              WHEN url LIKE 'http://%' THEN SUBSTR(url, 8, INSTR(SUBSTR(url, 8), '/') - 1)
              WHEN url LIKE 'https://%' THEN SUBSTR(url, 9, INSTR(SUBSTR(url, 9), '/') - 1)
              ELSE url
            END as domain,
            COUNT(*) as count
           FROM tab_group_items 
           WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)
           GROUP BY domain
           ORDER BY count DESC
           LIMIT 10`
        )
          .bind(userId)
          .all<DomainCount>(),

        context.env.DB.prepare(
          `SELECT 
            CASE 
              WHEN item_count = 0 THEN '0'
              WHEN item_count <= 5 THEN '1-5'
              WHEN item_count <= 10 THEN '6-10'
              WHEN item_count <= 20 THEN '11-20'
              WHEN item_count <= 50 THEN '21-50'
              ELSE '50+'
            END as range,
            COUNT(*) as count
           FROM (
             SELECT g.id, COUNT(i.id) as item_count
             FROM tab_groups g
             LEFT JOIN tab_group_items i ON g.id = i.group_id
             WHERE g.user_id = ? AND g.is_deleted = 0
             GROUP BY g.id
           )
           GROUP BY range
           ORDER BY 
             CASE range
               WHEN '0' THEN 1
               WHEN '1-5' THEN 2
               WHEN '6-10' THEN 3
               WHEN '11-20' THEN 4
               WHEN '21-50' THEN 5
               ELSE 6
             END`
        )
          .bind(userId)
          .all<{ range: string; count: number }>()
      ])

      return success({
        summary: {
          total_groups: groupsResult.results?.[0]?.count || 0,
          total_deleted_groups: deletedGroupsResult.results?.[0]?.count || 0,
          total_items: itemsResult.results?.[0]?.count || 0,
          total_shares: sharesResult.results?.[0]?.count || 0,
        },
        trends: {
          groups: groupsTrend.results || [],
          items: itemsTrend.results || [],
        },
        top_domains: domains.results || [],
        group_size_distribution: groupSizes.results || [],
      })
    } catch (error) {
      console.error('Get statistics error:', error)
      return internalError('Failed to get statistics')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/tab-groups/[id]/items/batch.ts
================================================
/**
 *  API - 
 * : /api/tab/tab-groups/:id/items/batch
 * : API Key (X-API-Key header)  JWT Token (Bearer)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../../lib/types'
import { success, badRequest, notFound, internalError } from '../../../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../../../middleware/dual-auth'
import { sanitizeString } from '../../../../../lib/validation'
import { generateUUID } from '../../../../../lib/crypto'

interface TabGroupRow {
  id: string
  user_id: string
  title: string
}

interface BatchAddItemsRequest {
  items: Array<{
    title: string
    url: string
    favicon?: string
  }>
}

// POST /api/tab/tab-groups/:id/items/batch - 
export const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.update'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      const body = (await context.request.json()) as BatchAddItemsRequest

      if (!body.items || !Array.isArray(body.items) || body.items.length === 0) {
        return badRequest('items array is required and must not be empty')
      }

      // 
      const group = await context.env.DB.prepare(
        'SELECT id, user_id, title FROM tab_groups WHERE id = ? AND user_id = ?'
      )
        .bind(groupId, userId)
        .first<TabGroupRow>()

      if (!group) {
        return notFound('Tab group not found')
      }

      //  position
      const maxPositionResult = await context.env.DB.prepare(
        'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?'
      )
        .bind(groupId)
        .first<{ max_position: number | null }>()

      let currentPosition = (maxPositionResult?.max_position ?? -1) + 1

      // 
      const timestamp = new Date().toISOString()
      const insertPromises = body.items.map((item) => {
        const itemId = generateUUID()
        const itemTitle = sanitizeString(item.title, 500)
        const itemUrl = sanitizeString(item.url, 2000)
        const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null

        const promise = context.env.DB.prepare(
          'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
        )
          .bind(itemId, groupId, itemTitle, itemUrl, favicon, currentPosition, timestamp)
          .run()

        currentPosition++
        return promise
      })

      await Promise.all(insertPromises)

      //  (with user_id verification for security)
      const { results: items } = await context.env.DB.prepare(
        `SELECT tgi.*
         FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.group_id = ? AND tg.user_id = ?
         ORDER BY tgi.position ASC`
      )
        .bind(groupId, userId)
        .all()

      return success({
        message: `Successfully added ${body.items.length} items`,
        added_count: body.items.length,
        total_items: items?.length || 0,
        items: items || [],
      })
    } catch (error) {
      console.error('Batch add items error:', error)
      return internalError('Failed to batch add items')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/tab-groups/[id]/permanent-delete.ts
================================================
/**
 *  API
 * : /api/tab/tab-groups/:id/permanent-delete
 * : API Key (X-API-Key header)  JWT Token (Bearer)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { notFound, internalError } from '../../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'

interface TabGroupRow {
  id: string
  user_id: string
  is_deleted: number
}

// DELETE /api/tab/tab-groups/:id/permanent-delete - 
export const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.delete'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      // Check if tab group exists and is deleted
      const groupRow = await context.env.DB.prepare(
        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 1'
      )
        .bind(groupId, userId)
        .first<TabGroupRow>()

      if (!groupRow) {
        return notFound('Tab group not found in trash')
      }

      // Delete tab group items first
      await context.env.DB.prepare('DELETE FROM tab_group_items WHERE group_id = ?')
        .bind(groupId)
        .run()

      // Delete shares
      await context.env.DB.prepare('DELETE FROM shares WHERE group_id = ?')
        .bind(groupId)
        .run()

      // Permanently delete tab group
      await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?')
        .bind(groupId)
        .run()

      return new Response(null, { status: 204 })
    } catch (error) {
      console.error('Permanent delete tab group error:', error)
      return internalError('Failed to permanently delete tab group')
    }
  },
]



================================================
FILE: tmarks/functions/api/tab/tab-groups/[id]/restore.ts
================================================
/**
 *  API
 * : /api/tab/tab-groups/:id/restore
 * : API Key (X-API-Key header)  JWT Token (Bearer)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { success, notFound, internalError } from '../../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'

interface TabGroupRow {
  id: string
  user_id: string
  is_deleted: number
}

// POST /api/tab/tab-groups/:id/restore - 
export const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.update'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      // Check if tab group exists and is deleted
      const groupRow = await context.env.DB.prepare(
        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 1'
      )
        .bind(groupId, userId)
        .first<TabGroupRow>()

      if (!groupRow) {
        return notFound('Tab group not found in trash')
      }

      // Restore tab group
      await context.env.DB.prepare(
        'UPDATE tab_groups SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ?'
      )
        .bind(new Date().toISOString(), groupId)
        .run()

      return success({ message: 'Tab group restored successfully' })
    } catch (error) {
      console.error('Restore tab group error:', error)
      return internalError('Failed to restore tab group')
    }
  },
]



================================================
FILE: tmarks/functions/api/tab/tab-groups/[id]/share.ts
================================================
/**
 *  API
 * : /api/tab/tab-groups/:id/share
 * : API Key (X-API-Key header)  JWT Token (Bearer)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { success, notFound, internalError } from '../../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'
import { generateUUID } from '../../../../lib/crypto'

interface TabGroupRow {
  id: string
  user_id: string
  is_deleted: number
}

interface ShareRow {
  id: string
  group_id: string
  user_id: string
  share_token: string
  is_public: number
  view_count: number
  created_at: string
  expires_at: string | null
}

interface CreateShareRequest {
  is_public?: boolean
  expires_in_days?: number
}

// POST /api/tab/tab-groups/:id/share - 
export const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.update'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      const body = (await context.request.json().catch(() => ({}))) as CreateShareRequest

      // Check if tab group exists and belongs to user
      const groupRow = await context.env.DB.prepare(
        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 0'
      )
        .bind(groupId, userId)
        .first<TabGroupRow>()

      if (!groupRow) {
        return notFound('Tab group not found')
      }

      // Check if share already exists
      const existingShare = await context.env.DB.prepare(
        'SELECT * FROM shares WHERE group_id = ? AND user_id = ?'
      )
        .bind(groupId, userId)
        .first<ShareRow>()

      if (existingShare) {
        return success({
          share: existingShare,
          share_url: `${new URL(context.request.url).origin}/share/${existingShare.share_token}`,
        })
      }

      // Generate share token
      const shareToken = generateUUID().replace(/-/g, '').substring(0, 16)
      const shareId = generateUUID()
      const now = new Date().toISOString()
      const isPublic = body.is_public !== false ? 1 : 0

      let expiresAt: string | null = null
      if (body.expires_in_days && body.expires_in_days > 0) {
        const expiresDate = new Date()
        expiresDate.setDate(expiresDate.getDate() + body.expires_in_days)
        expiresAt = expiresDate.toISOString()
      }

      // Create share
      await context.env.DB.prepare(
        'INSERT INTO shares (id, group_id, user_id, share_token, is_public, view_count, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
      )
        .bind(shareId, groupId, userId, shareToken, isPublic, 0, now, expiresAt)
        .run()

      const share = {
        id: shareId,
        group_id: groupId,
        user_id: userId,
        share_token: shareToken,
        is_public: isPublic,
        view_count: 0,
        created_at: now,
        expires_at: expiresAt,
      }

      return success({
        share,
        share_url: `${new URL(context.request.url).origin}/share/${shareToken}`,
      })
    } catch (error) {
      console.error('Create share error:', error)
      return internalError('Failed to create share')
    }
  },
]

// GET /api/tab/tab-groups/:id/share - 
export const onRequestGet: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.read'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      // Get share
      const share = await context.env.DB.prepare(
        'SELECT * FROM shares WHERE group_id = ? AND user_id = ?'
      )
        .bind(groupId, userId)
        .first<ShareRow>()

      if (!share) {
        return notFound('Share not found')
      }

      return success({
        share,
        share_url: `${new URL(context.request.url).origin}/share/${share.share_token}`,
      })
    } catch (error) {
      console.error('Get share error:', error)
      return internalError('Failed to get share')
    }
  },
]

// DELETE /api/tab/tab-groups/:id/share - 
export const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.delete'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      // Delete share
      await context.env.DB.prepare('DELETE FROM shares WHERE group_id = ? AND user_id = ?')
        .bind(groupId, userId)
        .run()

      return new Response(null, { status: 204 })
    } catch (error) {
      console.error('Delete share error:', error)
      return internalError('Failed to delete share')
    }
  },
]



================================================
FILE: tmarks/functions/api/tab/tab-groups/[id].ts
================================================
/**
 *  API - 
 * : /api/tab/tab-groups/:id
 * : API Key (X-API-Key header)  JWT Token (Bearer)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../lib/types'
import { success, badRequest, notFound, internalError } from '../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'
import { sanitizeString } from '../../../lib/validation'

interface TabGroupRow {
  id: string
  user_id: string
  title: string
  color: string | null
  tags: string | null
  parent_id: string | null
  is_folder: number
  is_deleted: number
  deleted_at: string | null
  position: number
  created_at: string
  updated_at: string
}

interface TabGroupItemRow {
  id: string
  group_id: string
  title: string
  url: string
  favicon: string | null
  position: number
  created_at: string
}

interface UpdateTabGroupRequest {
  title?: string
  color?: string | null
  tags?: string[] | null
  parent_id?: string | null
  position?: number
}

// GET /api/tab/tab-groups/:id - 
export const onRequestGet: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.read'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      // Get tab group (exclude deleted by default)
      let groupRow: TabGroupRow | null = null
      try {
        groupRow = await context.env.DB.prepare(
          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)'
        )
          .bind(groupId, userId)
          .first<TabGroupRow>()
      } catch {
        // Fallback: query without is_deleted column
        groupRow = await context.env.DB.prepare(
          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'
        )
          .bind(groupId, userId)
          .first<TabGroupRow>()
      }

      if (!groupRow) {
        return notFound('Tab group not found')
      }

      // Parse tags if exists
      let tags: string[] | null = null
      if (groupRow.tags) {
        try {
          tags = JSON.parse(groupRow.tags)
        } catch {
          tags = null
        }
      }

      // Get tab group items (with user_id verification for security)
      const { results: items } = await context.env.DB.prepare(
        `SELECT tgi.*
         FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.group_id = ? AND tg.user_id = ?
         ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC`
      )
        .bind(groupId, userId)
        .all<TabGroupItemRow>()

      return success({
        tab_group: {
          ...groupRow,
          tags,
          items: items || [],
          item_count: items?.length || 0,
        },
      })
    } catch (error) {
      console.error('Get tab group error:', error)
      return internalError('Failed to get tab group')
    }
  },
]

// PATCH /api/tab/tab-groups/:id - 
export const onRequestPatch: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.update'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      let body: UpdateTabGroupRequest
      try {
        body = (await context.request.json()) as UpdateTabGroupRequest
      } catch (parseError) {
        console.error('Failed to parse request body:', parseError)
        return badRequest('Invalid request body: ' + (parseError instanceof Error ? parseError.message : 'JSON parse error'))
      }

      // Check if tab group exists and belongs to user
      const groupRow = await context.env.DB.prepare(
        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'
      )
        .bind(groupId, userId)
        .first<TabGroupRow>()

      if (!groupRow) {
        return notFound('Tab group not found')
      }

      // Update tab group
      const updates: string[] = []
      const params: (string | number | null)[] = []

      if (body.title !== undefined) {
        updates.push('title = ?')
        params.push(sanitizeString(body.title, 200))
      }

      // Only add color/tags if they exist in the request
      // Try to update, if column doesn't exist, skip silently
      let hasColorOrTags = false
      if (body.color !== undefined) {
        updates.push('color = ?')
        params.push(body.color)
        hasColorOrTags = true
      }

      if (body.tags !== undefined) {
        updates.push('tags = ?')
        params.push(body.tags ? JSON.stringify(body.tags) : null)
        hasColorOrTags = true
      }

      if (body.parent_id !== undefined) {
        updates.push('parent_id = ?')
        params.push(body.parent_id)
      }

      if (body.position !== undefined) {
        updates.push('position = ?')
        params.push(body.position)
      }

      if (updates.length === 0) {
        return badRequest('No fields to update')
      }

      updates.push('updated_at = ?')
      params.push(new Date().toISOString())
      params.push(groupId)

      // Add user_id to WHERE clause params
      params.push(userId)

      try {
        await context.env.DB.prepare(
          `UPDATE tab_groups SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`
        )
          .bind(...params)
          .run()
      } catch (e) {
        // If update fails (likely due to missing columns), try without color/tags
        if (hasColorOrTags && body.title !== undefined) {
          await context.env.DB.prepare(
            'UPDATE tab_groups SET title = ?, updated_at = ? WHERE id = ? AND user_id = ?'
          )
            .bind(sanitizeString(body.title, 200), new Date().toISOString(), groupId, userId)
            .run()
        } else {
          throw e
        }
      }

      // Get updated tab group with items
      const updatedGroup = await context.env.DB.prepare(
        'SELECT * FROM tab_groups WHERE id = ?'
      )
        .bind(groupId)
        .first<TabGroupRow>()

      const { results: items } = await context.env.DB.prepare(
        `SELECT tgi.*
         FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.group_id = ? AND tg.user_id = ?
         ORDER BY tgi.position ASC`
      )
        .bind(groupId, userId)
        .all<TabGroupItemRow>()

      if (!updatedGroup) {
        return internalError('Failed to load tab group after update')
      }

      return success({
        tab_group: {
          ...updatedGroup,
          items: items || [],
          item_count: items?.length || 0,
        },
      })
    } catch (error) {
      console.error('Update tab group error:', error)
      return internalError('Failed to update tab group')
    }
  },
]

// DELETE /api/tab/tab-groups/:id - ()
export const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.delete'),
  async (context) => {
    const userId = context.data.user_id
    const groupId = context.params.id

    try {
      // Check if tab group exists and belongs to user
      let groupRow: TabGroupRow | null = null
      try {
        groupRow = await context.env.DB.prepare(
          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)'
        )
          .bind(groupId, userId)
          .first<TabGroupRow>()
      } catch {
        // Fallback: query without is_deleted column
        groupRow = await context.env.DB.prepare(
          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'
        )
          .bind(groupId, userId)
          .first<TabGroupRow>()
      }

      if (!groupRow) {
        return notFound('Tab group not found')
      }

      // Soft delete - mark as deleted (only if column exists)
      try {
        await context.env.DB.prepare(
          'UPDATE tab_groups SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ?'
        )
          .bind(new Date().toISOString(), new Date().toISOString(), groupId)
          .run()
      } catch {
        // If is_deleted column doesn't exist, do hard delete
        await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?')
          .bind(groupId)
          .run()
      }

      return new Response(null, { status: 204 })
    } catch (error) {
      console.error('Delete tab group error:', error)
      return internalError('Failed to delete tab group')
    }
  },
]



================================================
FILE: tmarks/functions/api/tab/tab-groups/index.ts
================================================
/**
 *  API - 
 * : /api/tab/tab-groups

 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams, SQLParam } from '../../../lib/types'
import { success, created, internalError } from '../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'
import { sanitizeString } from '../../../lib/validation'
import { generateUUID } from '../../../lib/crypto'

interface TabGroupRow {
  id: string
  user_id: string
  title: string
  color: string | null
  tags: string | null
  parent_id: string | null
  is_folder: number
  is_deleted: number
  deleted_at: string | null
  position: number
  created_at: string
  updated_at: string
}

interface TabGroupItemRow {
  id: string
  group_id: string
  title: string
  url: string
  favicon: string | null
  position: number
  created_at: string
}

interface CreateTabGroupRequest {
  title?: string
  parent_id?: string | null
  is_folder?: boolean
  items?: Array<{
    title: string
    url: string
    favicon?: string
  }>
}

function parseTags(group: TabGroupRow): string[] | null {
  if (!group.tags) return null
  try {
    return JSON.parse(group.tags)
  } catch {
    return null
  }
}

// GET /api/tab/tab-groups
export const onRequestGet: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.read'),
  async (context) => {
    const userId = context.data.user_id
    const url = new URL(context.request.url)

    const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100)
    const pageCursor = url.searchParams.get('page_cursor') || ''

    try {
      let groups: TabGroupRow[] = []
      try {
        let query = `
          SELECT *
          FROM tab_groups
          WHERE user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)
        `
        const params: SQLParam[] = [userId]

        if (pageCursor) {
          query += ` AND created_at < ?`
          params.push(pageCursor)
        }

        query += ` ORDER BY created_at DESC LIMIT ?`
        params.push(pageSize + 1)

        const result = await context.env.DB.prepare(query)
          .bind(...params)
          .all<TabGroupRow>()
        groups = result.results
      } catch {
        let query = `
          SELECT *
          FROM tab_groups
          WHERE user_id = ?
        `
        const params: SQLParam[] = [userId]

        if (pageCursor) {
          query += ` AND created_at < ?`
          params.push(pageCursor)
        }

        query += ` ORDER BY created_at DESC LIMIT ?`
        params.push(pageSize + 1)

        const result = await context.env.DB.prepare(query)
          .bind(...params)
          .all<TabGroupRow>()
        groups = result.results
      }

      const hasMore = groups.length > pageSize
      const tabGroups = hasMore ? groups.slice(0, pageSize) : groups
      const nextCursor = hasMore ? tabGroups[tabGroups.length - 1].created_at : undefined

      // Batch fetch all items (avoids N+1)
      const groupIds = tabGroups.map((g) => g.id)
      let allItems: TabGroupItemRow[] = []

      if (groupIds.length > 0) {
        const placeholders = groupIds.map(() => '?').join(',')
        const { results: items } = await context.env.DB.prepare(
          `SELECT tgi.*
           FROM tab_group_items tgi
           JOIN tab_groups tg ON tgi.group_id = tg.id
           WHERE tgi.group_id IN (${placeholders}) AND tg.user_id = ?
           ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC`
        )
          .bind(...groupIds, userId)
          .all<TabGroupItemRow>()
        allItems = items || []
      }

      const itemsByGroup = new Map<string, TabGroupItemRow[]>()
      for (const item of allItems) {
        const arr = itemsByGroup.get(item.group_id) || []
        arr.push(item)
        itemsByGroup.set(item.group_id, arr)
      }

      const groupsWithItems = tabGroups.map((group) => {
        const items = itemsByGroup.get(group.id) || []
        return {
          ...group,
          tags: parseTags(group),
          items,
          item_count: items.length,
        }
      })

      return success({
        tab_groups: groupsWithItems,
        meta: {
          page_size: pageSize,
          next_cursor: nextCursor,
        },
      })
    } catch (error) {
      console.error('Get tab groups error:', error)
      return internalError('Failed to get tab groups')
    }
  },
]

// POST /api/tab/tab-groups
export const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.create'),
  async (context) => {
    const userId = context.data.user_id

    try {
      const body = (await context.request.json()) as CreateTabGroupRequest

      const isFolder = body.is_folder || false

      const now = new Date()
      const defaultTitle = body.title || (isFolder ? '' : now.toLocaleString('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        hour12: false,
      }).replace(/\//g, '-'))

      const title = sanitizeString(defaultTitle, 200)
      const groupId = generateUUID()
      const timestamp = now.toISOString()
      const parentId = body.parent_id || null

      // Atomic batch: group + all items
      const stmts = [
        context.env.DB.prepare(
          'INSERT INTO tab_groups (id, user_id, title, parent_id, is_folder, is_deleted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)'
        ).bind(groupId, userId, title, parentId, isFolder ? 1 : 0, timestamp, timestamp),
      ]

      if (!isFolder && body.items && body.items.length > 0) {
        for (let i = 0; i < body.items.length; i++) {
          const item = body.items[i]
          const itemId = generateUUID()
          const itemTitle = sanitizeString(item.title, 500)
          const itemUrl = sanitizeString(item.url, 2000)
          const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null

          stmts.push(
            context.env.DB.prepare(
              'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
            ).bind(itemId, groupId, itemTitle, itemUrl, favicon, i, timestamp)
          )
        }
      }

      await context.env.DB.batch(stmts)

      const groupRow = await context.env.DB.prepare(
        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'
      )
        .bind(groupId, userId)
        .first<TabGroupRow>()

      if (!groupRow) {
        return internalError('Failed to load tab group after creation')
      }

      const { results: items } = await context.env.DB.prepare(
        `SELECT tgi.*
         FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.group_id = ? AND tg.user_id = ?
         ORDER BY tgi.position ASC`
      )
        .bind(groupId, userId)
        .all<TabGroupItemRow>()

      return created({
        tab_group: {
          ...groupRow,
          items: items || [],
          item_count: items?.length || 0,
        },
      })
    } catch (error) {
      console.error('Create tab group error:', error)
      return internalError('Failed to create tab group')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/tab-groups/items/[id]/move.ts
================================================
/**
 *  API - 
 * : /api/tab/tab-groups/items/:id/move
 * : API Key (X-API-Key header)  JWT Token (Bearer)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../../lib/types'
import { success, badRequest, notFound, internalError } from '../../../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../../../middleware/dual-auth'

interface TabGroupItemRow {
  id: string
  group_id: string
  title: string
  url: string
  favicon: string | null
  position: number
  created_at: string
  is_pinned?: number
  is_todo?: number
}

interface MoveItemRequest {
  target_group_id: string
  position?: number
}

// POST /api/tab/tab-groups/items/:id/move - 
export const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.update'),
  async (context) => {
    const userId = context.data.user_id
    const itemId = context.params.id

    try {
      const body = (await context.request.json()) as MoveItemRequest

      if (!body.target_group_id) {
        return badRequest('target_group_id is required')
      }

      // 1. 
      const item = await context.env.DB.prepare(
        `SELECT tgi.*, tg.user_id 
         FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.id = ?`
      )
        .bind(itemId)
        .first<TabGroupItemRow & { user_id: string }>()

      if (!item) {
        return notFound('Tab group item not found')
      }

      if (item.user_id !== userId) {
        return notFound('Tab group item not found')
      }

      // 2. 
      const targetGroup = await context.env.DB.prepare(
        'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?'
      )
        .bind(body.target_group_id, userId)
        .first<{ id: string; user_id: string }>()

      if (!targetGroup) {
        return badRequest('Target group not found or access denied')
      }

      // 3. ,
      if (item.group_id === body.target_group_id) {
        if (body.position !== undefined) {
          // 
          await context.env.DB.prepare(
            'UPDATE tab_group_items SET position = ? WHERE id = ?'
          )
            .bind(body.position, itemId)
            .run()

          // 
          await context.env.DB.prepare(
            `UPDATE tab_group_items 
             SET position = position + 1 
             WHERE group_id = ? AND id != ? AND position >= ?`
          )
            .bind(item.group_id, itemId, body.position)
            .run()
        }
      } else {
        // 4. 

        // 4.1 
        const maxPositionResult = await context.env.DB.prepare(
          'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?'
        )
          .bind(body.target_group_id)
          .first<{ max_position: number | null }>()

        const targetPosition =
          body.position !== undefined
            ? body.position
            : (maxPositionResult?.max_position ?? -1) + 1

        // 4.2 
        await context.env.DB.prepare(
          `UPDATE tab_group_items 
           SET group_id = ?, position = ? 
           WHERE id = ?`
        )
          .bind(body.target_group_id, targetPosition, itemId)
          .run()

        // 4.3 ()
        await context.env.DB.prepare(
          `UPDATE tab_group_items 
           SET position = position - 1 
           WHERE group_id = ? AND position > ?`
        )
          .bind(item.group_id, item.position)
          .run()

        // 4.4 ()
        if (body.position !== undefined) {
          await context.env.DB.prepare(
            `UPDATE tab_group_items 
             SET position = position + 1 
             WHERE group_id = ? AND id != ? AND position >= ?`
          )
            .bind(body.target_group_id, itemId, targetPosition)
            .run()
        }
      }

      // 5. 
      const updatedItem = await context.env.DB.prepare(
        'SELECT * FROM tab_group_items WHERE id = ?'
      )
        .bind(itemId)
        .first<TabGroupItemRow>()

      if (!updatedItem) {
        return internalError('Failed to load item after move')
      }

      return success({
        item: updatedItem,
        message: 'Item moved successfully',
      })
    } catch (error) {
      console.error('Move tab group item error:', error)
      return internalError('Failed to move tab group item')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/tab-groups/items/[id].ts
================================================
/**
 *  API - 
 * : /api/tab/tab-groups/items/:id
 * : API Key (X-API-Key header)  JWT Token (Bearer)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { success, badRequest, notFound, internalError } from '../../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'
import { sanitizeString } from '../../../../lib/validation'

interface TabGroupItemRow {
  id: string
  group_id: string
  title: string
  url: string
  favicon: string | null
  position: number
  created_at: string
  is_pinned?: number
  is_todo?: number
  is_archived?: number
}

interface UpdateTabGroupItemRequest {
  title?: string
  is_pinned?: boolean
  is_todo?: boolean
  is_archived?: boolean
  position?: number
}

// PATCH /api/tab/tab-groups/items/:id - 
export const onRequestPatch: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.update'),
  async (context) => {
    const userId = context.data.user_id
    const itemId = context.params.id

    try {
      const body = (await context.request.json()) as UpdateTabGroupItemRequest

      // Check if item exists and user has permission
      const item = await context.env.DB.prepare(
        `SELECT tgi.*, tg.user_id 
         FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.id = ?`
      )
        .bind(itemId)
        .first<TabGroupItemRow & { user_id: string }>()

      if (!item) {
        return notFound('Tab group item not found')
      }

      if (item.user_id !== userId) {
        return notFound('Tab group item not found')
      }

      // Build update query
      const updates: string[] = []
      const params: (string | number)[] = []

      if (body.title !== undefined) {
        updates.push('title = ?')
        params.push(sanitizeString(body.title, 500))
      }

      if (body.is_pinned !== undefined) {
        updates.push('is_pinned = ?')
        params.push(body.is_pinned ? 1 : 0)
        
        // If pinning, set position to 0 and shift others
        if (body.is_pinned) {
          await context.env.DB.prepare(
            'UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ?'
          )
            .bind(item.group_id, itemId)
            .run()
          
          updates.push('position = ?')
          params.push(0)
        }
      }

      if (body.is_todo !== undefined) {
        updates.push('is_todo = ?')
        params.push(body.is_todo ? 1 : 0)
      }

      if (body.is_archived !== undefined) {
        updates.push('is_archived = ?')
        params.push(body.is_archived ? 1 : 0)
      }

      if (body.position !== undefined) {
        updates.push('position = ?')
        params.push(body.position)
      }

      if (updates.length === 0) {
        return badRequest('No fields to update')
      }

      params.push(itemId, item.group_id, userId)

      await context.env.DB.prepare(
        `UPDATE tab_group_items SET ${updates.join(', ')} WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)`
      )
        .bind(...params)
        .run()

      // Get updated item
      const updatedItem = await context.env.DB.prepare(
        `SELECT tgi.* FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.id = ? AND tg.user_id = ?`
      )
        .bind(itemId, userId)
        .first<TabGroupItemRow>()

      if (!updatedItem) {
        return internalError('Failed to load item after update')
      }

      return success({
        item: updatedItem,
      })
    } catch (error) {
      console.error('Update tab group item error:', error)
      return internalError('Failed to update tab group item')
    }
  },
]

// DELETE /api/tab/tab-groups/items/:id - 
export const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [
  requireDualAuth('tab_groups.delete'),
  async (context) => {
    const userId = context.data.user_id
    const itemId = context.params.id

    try {
      // Check if item exists and user has permission
      const item = await context.env.DB.prepare(
        `SELECT tgi.*, tg.user_id 
         FROM tab_group_items tgi
         JOIN tab_groups tg ON tgi.group_id = tg.id
         WHERE tgi.id = ?`
      )
        .bind(itemId)
        .first<TabGroupItemRow & { user_id: string }>()

      if (!item) {
        return notFound('Tab group item not found')
      }

      if (item.user_id !== userId) {
        return notFound('Tab group item not found')
      }

      // Delete item with ownership check
      await context.env.DB.prepare(
        'DELETE FROM tab_group_items WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)'
      )
        .bind(itemId, item.group_id, userId)
        .run()

      // Reorder remaining items
      await context.env.DB.prepare(
        'UPDATE tab_group_items SET position = position - 1 WHERE group_id = ? AND position > ?'
      )
        .bind(item.group_id, item.position)
        .run()

      return new Response(null, { status: 204 })
    } catch (error) {
      console.error('Delete tab group item error:', error)
      return internalError('Failed to delete tab group item')
    }
  },
]



================================================
FILE: tmarks/functions/api/tab/tab-groups/trash.ts
================================================


import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../lib/types'
import { success, internalError } from '../../../lib/response'
import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'

interface TabGroupRow {
  id: string
  user_id: string
  title: string
  color: string | null
  tags: string | null
  is_deleted: number
  deleted_at: string | null
  created_at: string
  updated_at: string
}

// GET /api/tab/tab-groups/trash - Retrieve trashed tab groups
export const onRequestGet: PagesFunction<Env, string, DualAuthContext>[] = [
  requireDualAuth('tab_groups.read'),
  async (context) => {
    const userId = context.data.user_id

    try {

      const { results: groups } = await context.env.DB.prepare(
        'SELECT * FROM tab_groups WHERE user_id = ? AND is_deleted = 1 ORDER BY deleted_at DESC'
      )
        .bind(userId)
        .all<TabGroupRow>()

      const groupsWithCounts = await Promise.all(
        (groups || []).map(async (group) => {
          const { results: items } = await context.env.DB.prepare(
            'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id = ?'
          )
            .bind(group.id)
            .all<{ count: number }>()

          let tags: string[] | null = null
          if (group.tags) {
            try {
              tags = JSON.parse(group.tags)
            } catch {
              tags = null
            }
          }

          return {
            ...group,
            tags,
            item_count: items?.[0]?.count || 0,
          }
        })
      )

      return success({
        tab_groups: groupsWithCounts,
        total: groupsWithCounts.length,
      })
    } catch (error) {
      console.error('Get trash error:', error)
      return internalError('Failed to get trash')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/tags/[id]/click.ts
================================================
/**
 *  API - 
 * : /api/tab/tags/:id/click
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../../lib/types'
import { success, notFound, internalError } from '../../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'

// PATCH /api/tags/:id/click - 
export const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('tags.update'),
  async (context) => {
    const userId = context.data.user_id
    const tagId = context.params.id

    try {
      // 
      const existing = await context.env.DB.prepare(
        'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(tagId, userId)
        .first()

      if (!existing) {
        return notFound('Tag not found')
      }

      const now = new Date().toISOString()

      // 
      await context.env.DB.prepare(
        `UPDATE tags 
         SET click_count = click_count + 1, 
             last_clicked_at = ?,
             updated_at = ?
         WHERE id = ? AND user_id = ?`
      )
        .bind(now, now, tagId, userId)
        .run()

      return success({ message: 'Click count incremented' })
    } catch (error) {
      console.error('Increment tag click count error:', error)
      return internalError('Failed to increment click count')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/tags/[id].ts
================================================
/**
 *  API - 
 * : /api/tab/tags/:id
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams, SQLParam } from '../../../lib/types'
import { success, badRequest, notFound, noContent, internalError } from '../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'
import { sanitizeString } from '../../../lib/validation'

interface UpdateTagRequest {
  name?: string
  color?: string
}

// GET /api/tags/:id - 
export const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('tags.read'),
  async (context) => {
    const userId = context.data.user_id
    const tagId = context.params.id

    try {
      const tag = await context.env.DB.prepare(
        `SELECT
          t.id,
          t.name,
          t.color,
          t.created_at,
          t.updated_at,
          COUNT(bt.bookmark_id) as bookmark_count
        FROM tags t
        LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id
        LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL
        WHERE t.id = ? AND t.user_id = ? AND t.deleted_at IS NULL
        GROUP BY t.id`
      )
        .bind(tagId, userId)
        .first()

      if (!tag) {
        return notFound('Tag not found')
      }

      return success({ tag })
    } catch (error) {
      console.error('Get tag error:', error)
      return internalError('Failed to get tag')
    }
  },
]

// PATCH /api/tags/:id - 
export const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('tags.update'),
  async (context) => {
    const userId = context.data.user_id
    const tagId = context.params.id

    try {
      const existing = await context.env.DB.prepare(
        'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(tagId, userId)
        .first()

      if (!existing) {
        return notFound('Tag not found')
      }

      const body = (await context.request.json()) as UpdateTagRequest
      const updates: string[] = []
      const values: SQLParam[] = []

      // 
      if (body.name !== undefined) {
        if (!body.name.trim()) {
          return badRequest('Tag name cannot be empty')
        }
        const name = sanitizeString(body.name, 50)

        // 
        const duplicate = await context.env.DB.prepare(
          'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL'
        )
          .bind(userId, name, tagId)
          .first()

        if (duplicate) {
          return badRequest('Tag with this name already exists')
        }

        updates.push('name = ?')
        values.push(name)
      }

      // 
      if (body.color !== undefined) {
        updates.push('color = ?')
        values.push(body.color ? sanitizeString(body.color, 20) : null)
      }

      if (updates.length === 0) {
        return badRequest('No fields to update')
      }

      const now = new Date().toISOString()
      updates.push('updated_at = ?')
      values.push(now)
      values.push(tagId, userId)

      await context.env.DB.prepare(
        `UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`
      )
        .bind(...values)
        .run()

      const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?')
        .bind(tagId, userId)
        .first()

      return success({ tag })
    } catch (error) {
      console.error('Update tag error:', error)
      return internalError('Failed to update tag')
    }
  },
]

// DELETE /api/tags/:id - 
export const onRequestDelete: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('tags.delete'),
  async (context) => {
    const userId = context.data.user_id
    const tagId = context.params.id

    try {
      const existing = await context.env.DB.prepare(
        'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'
      )
        .bind(tagId, userId)
        .first()

      if (!existing) {
        return notFound('Tag not found')
      }

      const now = new Date().toISOString()

      // 
      await context.env.DB.prepare(
        'UPDATE tags SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?'
      )
        .bind(now, now, tagId, userId)
        .run()

      await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE tag_id = ? AND user_id = ?')
        .bind(tagId, userId)
        .run()

      return noContent()
    } catch (error) {
      console.error('Delete tag error:', error)
      return internalError('Failed to delete tag')
    }
  },
]


================================================
FILE: tmarks/functions/api/tab/tags/index.ts
================================================
/**
 *  API - 
 * : /api/tab/tags
 * : API Key (X-API-Key header)
 */

import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../lib/types'
import { success, badRequest, created, internalError } from '../../../lib/response'
import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'
import { sanitizeString } from '../../../lib/validation'
import { generateUUID } from '../../../lib/crypto'

interface CreateTagRequest {
  name: string
  color?: string
}

interface TagWithCount {
  id: string
  name: string
  color: string | null
  bookmark_count: number
  created_at: string
  updated_at: string
}

// GET /api/tags - 
export const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('tags.read'),
  async (context) => {
    const userId = context.data.user_id

    try {
      const { results: tags } = await context.env.DB.prepare(
        `SELECT
          t.id,
          t.name,
          t.color,
          t.created_at,
          t.updated_at,
          COUNT(bt.bookmark_id) as bookmark_count
        FROM tags t
        LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id
        LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL
        WHERE t.user_id = ? AND t.deleted_at IS NULL
        GROUP BY t.id
        ORDER BY t.name ASC`
      )
        .bind(userId)
        .all<TagWithCount>()

      return success({ tags: tags || [] })
    } catch (error) {
      console.error('Get tags error:', error)
      return internalError('Failed to get tags')
    }
  },
]

// POST /api/tags - 
export const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [
  requireApiKeyAuth('tags.create'),
  async (context) => {
    const userId = context.data.user_id

    try {
      const body = (await context.request.json()) as CreateTagRequest

      if (!body.name || !body.name.trim()) {
        return badRequest('Tag name is required')
      }

      const name = sanitizeString(body.name, 50)
      const color = body.color ? sanitizeString(body.color, 20) : null

      // 
      const existing = await context.env.DB.prepare(
        'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND deleted_at IS NULL'
      )
        .bind(userId, name)
        .first()

      if (existing) {
        return badRequest('Tag with this name already exists')
      }

      const now = new Date().toISOString()
      const tagId = generateUUID()

      await context.env.DB.prepare(
        `INSERT INTO tags (id, user_id, name, color, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?)`
      )
        .bind(tagId, userId, name, color, now, now)
        .run()

      const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ?')
        .bind(tagId)
        .first()

      return created({ tag })
    } catch (error) {
      console.error('Create tag error:', error)
      return internalError('Failed to create tag')
    }
  },
]


================================================
FILE: tmarks/functions/api/v1/auth/login.ts
================================================
import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, User } from '../../../lib/types'
import { badRequest, unauthorized, success, internalError } from '../../../lib/response'
import { verifyPassword, generateToken, hashRefreshToken, generateUUID } from '../../../lib/crypto'
import { generateJWT, parseExpiry } from '../../../lib/jwt'
import { loginRateLimiter } from '../../../lib/rate-limit'
import { getJwtAccessTokenExpiresIn, getJwtRefreshTokenExpiresIn } from '../../../lib/config'

interface LoginRequest {
  username: string
  password: string
  remember_me?: boolean
}

export const onRequestPost: PagesFunction<Env>[] = [
  loginRateLimiter,
  async (context) => {
  try {
    const body = await context.request.json() as LoginRequest

    if (!body.username || !body.password) {
      return badRequest('Username and password are required')
    }

    // ()
    type DbUser = User & { role?: string | null }

    let user: DbUser | null = null

    try {
      user = await context.env.DB.prepare(
        `SELECT id, username, email, password_hash, role
         FROM users
         WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`
      )
        .bind(body.username, body.username)
        .first<DbUser>()
    } catch (error) {
      if (error instanceof Error && /no such column: role/i.test(error.message)) {
        user = await context.env.DB.prepare(
          `SELECT id, username, email, password_hash
           FROM users
           WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`
        )
          .bind(body.username, body.username)
          .first<DbUser>()
      } else {
        throw error
      }
    }

    if (!user) {

      const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'
      await context.env.DB.prepare(
        `INSERT INTO audit_logs (event_type, payload, ip, created_at)
         VALUES ('auth.login_failed', ?, ?, ?)`
      )
        .bind(
          JSON.stringify({ username: body.username, reason: 'user_not_found' }),
          ip,
          new Date().toISOString()
        )
        .run()

      return unauthorized('Invalid username or password')
    }

    // 
    const isValid = await verifyPassword(body.password, user.password_hash)

    if (!isValid) {

      const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'
      await context.env.DB.prepare(
        `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)
         VALUES (?, 'auth.login_failed', ?, ?, ?)`
      )
        .bind(
          user.id,
          JSON.stringify({ username: body.username, reason: 'invalid_password' }),
          ip,
          new Date().toISOString()
        )
        .run()

      return unauthorized('Invalid username or password')
    }

    //  session_id
    const sessionId = generateUUID()

    const role = user.role ?? 'user'

    // ()
    const accessTokenExpiresInStr = getJwtAccessTokenExpiresIn(context.env)
    const accessTokenExpiresIn = parseExpiry(accessTokenExpiresInStr)

    const accessToken = await generateJWT(
      { sub: user.id, session_id: sessionId },
      context.env.JWT_SECRET,
      accessTokenExpiresInStr
    )

    // 
    const refreshToken = generateToken(32)
    const refreshTokenHash = await hashRefreshToken(refreshToken)

    // 
    const refreshTokenExpiresInStr = getJwtRefreshTokenExpiresIn(context.env)
    const refreshTokenExpiresIn = parseExpiry(refreshTokenExpiresInStr)
    const refreshTokenExpiresAt = new Date(Date.now() + refreshTokenExpiresIn * 1000)

    // 
    await context.env.DB.prepare(
      `INSERT INTO auth_tokens (user_id, refresh_token_hash, expires_at, created_at)
       VALUES (?, ?, ?, ?)`
    )
      .bind(user.id, refreshTokenHash, refreshTokenExpiresAt.toISOString(), new Date().toISOString())
      .run()

    const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'
    const userAgent = context.request.headers.get('User-Agent') || 'unknown'

    await context.env.DB.prepare(
      `INSERT INTO audit_logs (user_id, event_type, payload, ip, user_agent, created_at)
       VALUES (?, 'auth.login_success', ?, ?, ?, ?)`
    )
      .bind(
        user.id,
        JSON.stringify({ session_id: sessionId, remember_me: body.remember_me }),
        ip,
        userAgent,
        new Date().toISOString()
      )
      .run()

    return success({
      access_token: accessToken,
      refresh_token: refreshToken,
      token_type: 'Bearer',
      expires_in: accessTokenExpiresIn,
      user: {
        id: user.id,
        username: user.username,
        email: user.email,
        role,
      },
    })
  } catch (error) {
    console.error('Login error:', error)
    return internalError('Login failed')
  }
},
]


================================================
FILE: tmarks/functions/api/v1/auth/logout.ts
================================================
import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env, RouteParams } from '../../../lib/types'
import { badRequest, noContent, internalError } from '../../../lib/response'
import { hashRefreshToken } from '../../../lib/crypto'
import { requireAuth, AuthContext } from '../../../middleware/auth'

interface LogoutRequest {
  refresh_token: string
  revoke_all?: boolean // 
}

export const onRequest: PagesFunction<Env, RouteParams, AuthContext>[] = [
  requireAuth,
  async (context) => {
    try {
      const body = await context.request.json() as LogoutRequest

      if (!body.refresh_token) {
        return badRequest('Refresh token is required')
      }

      const userId = context.data.user_id
      const now = new Date().toISOString()

      if (body.revoke_all) {
        await context.env.DB.prepare(

          `UPDATE auth_tokens
           SET revoked_at = ?
           WHERE user_id = ? AND revoked_at IS NULL`
        )
          .bind(now, userId)
          .run()

        // 
        const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'
        await context.env.DB.prepare(
          `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)
           VALUES (?, 'auth.logout_all_devices', ?, ?, ?)`
        )
          .bind(userId, JSON.stringify({ revoked_count: 'all' }), ip, now)
          .run()
      } else {
        // 
        const tokenHash = await hashRefreshToken(body.refresh_token)

        await context.env.DB.prepare(
          `UPDATE auth_tokens
           SET revoked_at = ?
           WHERE refresh_token_hash = ? AND user_id = ? AND revoked_at IS NULL`
        )
          .bind(now, tokenHash, userId)
          .run()

        // 
        const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'
        await context.env.DB.prepare(
          `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)
           VALUES (?, 'auth.logout', ?, ?, ?)`
        )
          .bind(userId, JSON.stringify({ single_device: true }), ip, now)
          .run()
      }

      return noContent()
    } catch (error) {
      console.error('Logout error:', error)
      return internalError('Logout failed')
    }
  },
]


================================================
FILE: tmarks/functions/api/v1/auth/refresh.ts
================================================
import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../lib/types'
import { badRequest, unauthorized, success, internalError } from '../../../lib/response'
import { hashRefreshToken, generateUUID } from '../../../lib/crypto'
import { generateJWT } from '../../../lib/jwt'
import { getJwtAccessTokenExpiresIn } from '../../../lib/config'

interface RefreshRequest {
  refresh_token: string
}

export const onRequestPost: PagesFunction<Env> = async (context) => {
  try {
    const body = await context.request.json() as RefreshRequest

    if (!body.refresh_token) {
      return badRequest('Refresh token is required')
    }

    // 
    const tokenHash = await hashRefreshToken(body.refresh_token)

    // 
    const tokenRecord = await context.env.DB.prepare(
      `SELECT id, user_id, expires_at, revoked_at
       FROM auth_tokens
       WHERE refresh_token_hash = ?`
    )
      .bind(tokenHash)
      .first<{
        id: number
        user_id: string
        expires_at: string
        revoked_at: string | null
      }>()

    if (!tokenRecord) {
      return unauthorized('Invalid refresh token')
    }

    // 
    if (tokenRecord.revoked_at) {
      return unauthorized('Refresh token has been revoked')
    }

    const expiresAt = new Date(tokenRecord.expires_at)
    if (expiresAt < new Date()) {
      return unauthorized('Refresh token has expired')
    }

    //  session_id
    const sessionId = generateUUID()

    // 
    const accessToken = await generateJWT(
      { sub: tokenRecord.user_id, session_id: sessionId },
      context.env.JWT_SECRET,
      getJwtAccessTokenExpiresIn(context.env)
    )

    // 
    type DbUser = { id: string; username: string; email: string | null; role?: string | null }

    let user: DbUser | null = null

    try {
      user = await context.env.DB.prepare(
        'SELECT id, username, email, role FROM users WHERE id = ?'
      )
        .bind(tokenRecord.user_id)
        .first<DbUser>()
    } catch (error) {
      if (error instanceof Error && /no such column: role/i.test(error.message)) {
        user = await context.env.DB.prepare(
          'SELECT id, username, email FROM users WHERE id = ?'
        )
          .bind(tokenRecord.user_id)
          .first<DbUser>()
      } else {
        throw error
      }
    }

    if (!user) {
      return unauthorized('User not found')
    }

    const role = user.role ?? 'user'

    // 
    const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'
    await context.env.DB.prepare(
      `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)
       VALUES (?, 'auth.token_refreshed', ?, ?, ?)`
    )
      .bind(
        tokenRecord.user_id,
        JSON.stringify({ session_id: sessionId }),
        ip,
        new Date().toISOString()
      )
      .run()

    return success({
      access_token: accessToken,
      token_type: 'Bearer',
      expires_in: parseExpiresInToSeconds(getJwtAccessTokenExpiresIn(context.env)),
      user: {
        id: user.id,
        username: user.username,
        email: user.email,
        role,
      },
    })
  } catch (error) {
    console.error('Refresh error:', error)
    return internalError('Token refresh failed')
  }
}

function parseExpiresInToSeconds(expiresIn: string): number {
  const match = expiresIn.match(/^(\d+)(s|m|h|d)$/)
  if (!match) return 31536000
  const value = parseInt(match[1], 10)
  switch (match[2]) {
    case 's': return value
    case 'm': return value * 60
    case 'h': return value * 3600
    case 'd': return value * 86400
    default: return 31536000
  }
}


================================================
FILE: tmarks/functions/api/v1/auth/register.ts
================================================
import type { PagesFunction } from '@cloudflare/workers-types'
import type { Env } from '../../../lib/types'
import { badRequest, created, conflict, internalError } from '../../../lib/response'
import { isValidUsername, isValidPassword, isValidEmail, sanitizeString } from '../../../lib/validation'
import { hashPassword, generateUUID } from '../../../lib/crypto'

interface RegisterRequest {
  username: string
  password: string
  email?: string
}

export const onRequestPost: PagesFunction<Env> = async (context) => {
  try {
    const db = context.env.DB

    // Rate limiting: Max 5 registration attempts per IP per hour
    const clientIP = context.request.headers.get('CF-Connecting-IP') || 'unknown'

    // Log registration attempt (ignore errors)
    try {
      await db.prepare(
        `INSERT INTO audit_logs (user_id, event_type, ip, payload, created_at)
         VALUES ('system', 'register_attempt', ?, ?, datetime('now'))`
      ).bind(clientIP, JSON.stringify({ ip: clientIP })).run()
    } catch {
      // Ignore audit log errors
    }

    const rateCheck = await db.prepare(
      `SELECT COUNT(*) as cnt FROM audit_logs
       WHERE event_type = 'register_attempt'
       AND ip = ?
       AND created_at > datetime('now', '-1 hour')`
    ).bind(clientIP).first<{ cnt: number }>()
    
    if (rateCheck && rateCheck.cnt >= 5) {
      return new Response(JSON.stringify({ code: 'RATE_LIMITED', message: 'Too many registration attempts' }), {
        status: 429,
        headers: { 'Content-Type': 'application/json', 'Retry-After': '3600' }
      })
    }

    // Check if registration is
Download .txt
gitextract_hsh8svta/

├── DEPLOYMENT.md
├── LICENSE
├── README.md
└── tmarks/
    ├── .gitignore
    ├── .prettierignore
    ├── .prettierrc
    ├── API-DATABASE-AUDIT.md
    ├── eslint.config.js
    ├── functions/
    │   ├── api/
    │   │   ├── _middleware.ts
    │   │   ├── index.ts
    │   │   ├── public/
    │   │   │   └── [slug].ts
    │   │   ├── share/
    │   │   │   └── [token].ts
    │   │   ├── shared/
    │   │   │   └── cache.ts
    │   │   ├── snapshot-images/
    │   │   │   └── [hash].ts
    │   │   ├── tab/
    │   │   │   ├── bookmarks/
    │   │   │   │   ├── [id]/
    │   │   │   │   │   ├── click.ts
    │   │   │   │   │   ├── permanent.ts
    │   │   │   │   │   ├── restore.ts
    │   │   │   │   │   ├── snapshot-upload.ts
    │   │   │   │   │   ├── snapshots/
    │   │   │   │   │   │   ├── [snapshotId].ts
    │   │   │   │   │   │   └── cleanup.ts
    │   │   │   │   │   ├── snapshots-v2.ts
    │   │   │   │   │   ├── snapshots.ts
    │   │   │   │   │   └── trash.ts
    │   │   │   │   ├── [id].ts
    │   │   │   │   ├── batch/
    │   │   │   │   │   └── index.ts
    │   │   │   │   ├── batch-handler.ts
    │   │   │   │   ├── bookmark-batch.ts
    │   │   │   │   ├── bookmark-list.ts
    │   │   │   │   ├── index.ts
    │   │   │   │   ├── reorder-pinned.ts
    │   │   │   │   ├── trash/
    │   │   │   │   │   └── empty.ts
    │   │   │   │   └── trash.ts
    │   │   │   ├── me.ts
    │   │   │   ├── search.ts
    │   │   │   ├── statistics/
    │   │   │   │   └── index.ts
    │   │   │   ├── tab-groups/
    │   │   │   │   ├── [id]/
    │   │   │   │   │   ├── items/
    │   │   │   │   │   │   └── batch.ts
    │   │   │   │   │   ├── permanent-delete.ts
    │   │   │   │   │   ├── restore.ts
    │   │   │   │   │   └── share.ts
    │   │   │   │   ├── [id].ts
    │   │   │   │   ├── index.ts
    │   │   │   │   ├── items/
    │   │   │   │   │   ├── [id]/
    │   │   │   │   │   │   └── move.ts
    │   │   │   │   │   └── [id].ts
    │   │   │   │   └── trash.ts
    │   │   │   └── tags/
    │   │   │       ├── [id]/
    │   │   │       │   └── click.ts
    │   │   │       ├── [id].ts
    │   │   │       └── index.ts
    │   │   └── v1/
    │   │       ├── auth/
    │   │       │   ├── login.ts
    │   │       │   ├── logout.ts
    │   │       │   ├── refresh.ts
    │   │       │   └── register.ts
    │   │       ├── bookmarks/
    │   │       │   ├── [id]/
    │   │       │   │   ├── click.ts
    │   │       │   │   ├── permanent.ts
    │   │       │   │   ├── restore.ts
    │   │       │   │   ├── snapshot-cleanup.ts
    │   │       │   │   ├── snapshots/
    │   │       │   │   │   ├── [snapshotId]/
    │   │       │   │   │   │   └── view.ts
    │   │       │   │   │   ├── [snapshotId].ts
    │   │       │   │   │   └── cleanup.ts
    │   │       │   │   └── snapshots.ts
    │   │       │   ├── [id].ts
    │   │       │   ├── bulk.ts
    │   │       │   ├── index.ts
    │   │       │   ├── statistics-helpers.ts
    │   │       │   ├── statistics.ts
    │   │       │   ├── trash/
    │   │       │   │   └── empty.ts
    │   │       │   └── trash.ts
    │   │       ├── change-password.ts
    │   │       ├── export.ts
    │   │       ├── health.ts
    │   │       ├── preferences-helpers.ts
    │   │       ├── preferences.ts
    │   │       ├── settings/
    │   │       │   ├── api-keys/
    │   │       │   │   ├── [id].ts
    │   │       │   │   └── index.ts
    │   │       │   ├── share.ts
    │   │       │   └── storage.ts
    │   │       ├── shared/
    │   │       │   └── cache.ts
    │   │       ├── statistics/
    │   │       │   └── index.ts
    │   │       ├── tab-groups/
    │   │       │   ├── [id]/
    │   │       │   │   ├── items/
    │   │       │   │   │   └── batch.ts
    │   │       │   │   ├── permanent-delete.ts
    │   │       │   │   ├── restore.ts
    │   │       │   │   └── share.ts
    │   │       │   ├── [id].ts
    │   │       │   ├── batch-update.ts
    │   │       │   ├── index.ts
    │   │       │   ├── items/
    │   │       │   │   ├── [id]/
    │   │       │   │   │   └── move.ts
    │   │       │   │   └── [id].ts
    │   │       │   └── trash.ts
    │   │       └── tags/
    │   │           ├── [id].ts
    │   │           └── index.ts
    │   ├── lib/
    │   │   ├── api-key/
    │   │   │   ├── generator.ts
    │   │   │   ├── logger.ts
    │   │   │   ├── rate-limiter-types.ts
    │   │   │   ├── rate-limiter.ts
    │   │   │   └── validator.ts
    │   │   ├── bookmark-utils.ts
    │   │   ├── cache/
    │   │   │   ├── README.md
    │   │   │   ├── bookmark-cache.ts
    │   │   │   ├── config.ts
    │   │   │   ├── index.ts
    │   │   │   ├── service.ts
    │   │   │   ├── strategies.ts
    │   │   │   └── types.ts
    │   │   ├── config.ts
    │   │   ├── crypto.ts
    │   │   ├── data-fetchers.ts
    │   │   ├── error-handler.ts
    │   │   ├── image-sig.ts
    │   │   ├── image-upload.ts
    │   │   ├── import-export/
    │   │   │   ├── collect-export-data.ts
    │   │   │   ├── export-scope.ts
    │   │   │   ├── export-stats.ts
    │   │   │   └── exporters/
    │   │   │       ├── html-exporter.ts
    │   │   │       ├── json-exporter.ts
    │   │   │       └── tab-groups-netscape.ts
    │   │   ├── index.ts
    │   │   ├── input-sanitizer.ts
    │   │   ├── jwt.ts
    │   │   ├── rate-limit.ts
    │   │   ├── response.ts
    │   │   ├── signed-url.ts
    │   │   ├── storage-quota.ts
    │   │   ├── tags.ts
    │   │   ├── types.ts
    │   │   ├── utils.ts
    │   │   └── validation.ts
    │   └── middleware/
    │       ├── api-key-auth-pages.ts
    │       ├── api-key-auth.ts
    │       ├── auth.ts
    │       ├── dual-auth.ts
    │       ├── index.ts
    │       └── security.ts
    ├── index.html
    ├── migrations/
    │   ├── 0001_d1_console.sql
    │   ├── 0002_d1_console_ai_settings.sql
    │   ├── 0103_api_key_rate_limits.sql
    │   └── 0104_rate_limits.sql
    ├── package.json
    ├── postcss.config.js
    ├── public/
    │   ├── _headers
    │   └── _routes.json
    ├── scripts/
    │   ├── auto-migrate.js
    │   ├── check-db-schema.js
    │   ├── check-migrations.js
    │   └── prepare-deploy.js
    ├── shared/
    │   ├── import-export-types.ts
    │   └── permissions.ts
    ├── src/
    │   ├── App.tsx
    │   ├── components/
    │   │   ├── api-keys/
    │   │   │   ├── ApiKeyCard.tsx
    │   │   │   ├── ApiKeyDetailModal.tsx
    │   │   │   ├── CreateApiKeyModal.tsx
    │   │   │   ├── StepBasicInfo.tsx
    │   │   │   ├── StepPermissions.tsx
    │   │   │   └── StepSuccess.tsx
    │   │   ├── auth/
    │   │   │   └── ProtectedRoute.tsx
    │   │   ├── bookmarks/
    │   │   │   ├── BatchActionBar.tsx
    │   │   │   ├── BookmarkCardView.tsx
    │   │   │   ├── BookmarkForm.tsx
    │   │   │   ├── BookmarkListContainer.tsx
    │   │   │   ├── BookmarkListItem.tsx
    │   │   │   ├── BookmarkListLayout.tsx
    │   │   │   ├── BookmarkListView.tsx
    │   │   │   ├── BookmarkMinimalListView.tsx
    │   │   │   ├── BookmarkTitleView.tsx
    │   │   │   ├── DefaultBookmarkIcon.tsx
    │   │   │   ├── SnapshotViewer.tsx
    │   │   │   ├── TagSelector.tsx
    │   │   │   ├── bookmark-utils.ts
    │   │   │   ├── defaultIconOptions.ts
    │   │   │   ├── hooks/
    │   │   │   │   └── useBookmarkFormState.ts
    │   │   │   ├── shared/
    │   │   │   │   ├── BookmarkActions.tsx
    │   │   │   │   ├── BookmarkTagList.tsx
    │   │   │   │   ├── MasonryGrid.tsx
    │   │   │   │   ├── useFaviconFallback.ts
    │   │   │   │   └── useResponsiveColumns.ts
    │   │   │   ├── useBookmarkForm.ts
    │   │   │   └── useSnapshots.ts
    │   │   ├── common/
    │   │   │   ├── AdaptiveImage.tsx
    │   │   │   ├── AlertDialog.tsx
    │   │   │   ├── BookmarkIcons.tsx
    │   │   │   ├── BottomNav.tsx
    │   │   │   ├── CircularProgress.tsx
    │   │   │   ├── ColorThemeSelector.tsx
    │   │   │   ├── ConfirmDialog.tsx
    │   │   │   ├── DialogHost.tsx
    │   │   │   ├── DragDropUpload.tsx
    │   │   │   ├── Drawer.tsx
    │   │   │   ├── DropdownMenu.tsx
    │   │   │   ├── ErrorBoundary.tsx
    │   │   │   ├── ErrorDisplay.tsx
    │   │   │   ├── LanguageSelector.tsx
    │   │   │   ├── LazyImage.tsx
    │   │   │   ├── MobileHeader.tsx
    │   │   │   ├── PaginationFooter.tsx
    │   │   │   ├── ProgressIndicator.tsx
    │   │   │   ├── ResizablePanel.tsx
    │   │   │   ├── SearchToolbar.tsx
    │   │   │   ├── SimpleProgress.tsx
    │   │   │   ├── SortSelector.tsx
    │   │   │   ├── ThemeToggle.tsx
    │   │   │   ├── Toast.tsx
    │   │   │   ├── Toggle.tsx
    │   │   │   ├── progressUtils.ts
    │   │   │   └── useAnimatedProgress.ts
    │   │   ├── import-export/
    │   │   │   ├── ExportOptionsForm.tsx
    │   │   │   └── ExportSection.tsx
    │   │   ├── layout/
    │   │   │   ├── AppShell.tsx
    │   │   │   ├── FullScreenAppShell.tsx
    │   │   │   ├── MobileBottomNav.tsx
    │   │   │   ├── PublicAppShell.tsx
    │   │   │   ├── ShellHeader.tsx
    │   │   │   └── ThemedRoot.tsx
    │   │   ├── settings/
    │   │   │   ├── InfoBox.tsx
    │   │   │   ├── SearchAutoClearSettings.tsx
    │   │   │   ├── SettingsNav.tsx
    │   │   │   ├── SettingsSaveBar.tsx
    │   │   │   ├── SettingsSection.tsx
    │   │   │   ├── SettingsTips.tsx
    │   │   │   ├── TagSelectionAutoClearSettings.tsx
    │   │   │   └── tabs/
    │   │   │       ├── ApiSettingsTab.tsx
    │   │   │       ├── AutomationSettingsTab.tsx
    │   │   │       ├── BasicSettingsTab.tsx
    │   │   │       ├── BrowserSettingsTab.tsx
    │   │   │       ├── DataSettingsTab.tsx
    │   │   │       ├── ShareSettingsTab.tsx
    │   │   │       └── SnapshotSettingsTab.tsx
    │   │   ├── tab-groups/
    │   │   │   ├── BatchActionBar.tsx
    │   │   │   ├── ColorPicker.tsx
    │   │   │   ├── EmptyState.tsx
    │   │   │   ├── InsertionIndicator.tsx
    │   │   │   ├── MoveItemDialog.tsx
    │   │   │   ├── MoveToFolderDialog.tsx
    │   │   │   ├── PinnedItemsSection.tsx
    │   │   │   ├── SearchBar.tsx
    │   │   │   ├── ShareDialog.tsx
    │   │   │   ├── SortSelector.tsx
    │   │   │   ├── TabGroupCard.tsx
    │   │   │   ├── TabGroupHeader.tsx
    │   │   │   ├── TabGroupSidebar.tsx
    │   │   │   ├── TabGroupTree.tsx
    │   │   │   ├── TabItem.tsx
    │   │   │   ├── TabItemList.tsx
    │   │   │   ├── TagsInput.tsx
    │   │   │   ├── TodoItemCard.tsx
    │   │   │   ├── TodoSidebar.tsx
    │   │   │   ├── colorUtils.ts
    │   │   │   ├── sortUtils.ts
    │   │   │   └── tree/
    │   │   │       ├── TreeNode.css
    │   │   │       ├── TreeNode.tsx
    │   │   │       ├── TreeNodeContent.tsx
    │   │   │       ├── TreeNodeMenu.tsx
    │   │   │       ├── TreeNodeSimple.tsx
    │   │   │       ├── TreeUtils.ts
    │   │   │       └── useDragAndDrop.ts
    │   │   └── tags/
    │   │       ├── TagControls.tsx
    │   │       ├── TagFormModal.tsx
    │   │       ├── TagItem.tsx
    │   │       ├── TagManageModal.tsx
    │   │       ├── TagSidebar.tsx
    │   │       └── useTagFiltering.ts
    │   ├── hooks/
    │   │   ├── buildTabOpenerHtml.ts
    │   │   ├── index.ts
    │   │   ├── useAnimatedProgress.ts
    │   │   ├── useApiKeys.ts
    │   │   ├── useBatchActions.ts
    │   │   ├── useBookmarkFilters.ts
    │   │   ├── useBookmarks.ts
    │   │   ├── useClientSideFilter.ts
    │   │   ├── useLanguage.ts
    │   │   ├── useLocalPreferences.ts
    │   │   ├── useMediaQuery.ts
    │   │   ├── usePreferences.ts
    │   │   ├── useShare.ts
    │   │   ├── useStorage.ts
    │   │   ├── useTabGroupActions.ts
    │   │   ├── useTabGroupItemActions.ts
    │   │   ├── useTabGroupMenu.ts
    │   │   ├── useTabGroupsQuery.ts
    │   │   └── useTags.ts
    │   ├── i18n/
    │   │   ├── index.ts
    │   │   └── locales/
    │   │       ├── en/
    │   │       │   ├── auth.json
    │   │       │   ├── bookmarks.json
    │   │       │   ├── common.json
    │   │       │   ├── errors.json
    │   │       │   ├── import.json
    │   │       │   ├── info.json
    │   │       │   ├── settings.json
    │   │       │   ├── share.json
    │   │       │   ├── tabGroups.json
    │   │       │   └── tags.json
    │   │       └── zh-CN/
    │   │           ├── auth.json
    │   │           ├── bookmarks.json
    │   │           ├── common.json
    │   │           ├── errors.json
    │   │           ├── import.json
    │   │           ├── info.json
    │   │           ├── settings.json
    │   │           ├── share.json
    │   │           ├── tabGroups.json
    │   │           └── tags.json
    │   ├── lib/
    │   │   ├── ai/
    │   │   │   ├── client.ts
    │   │   │   ├── constants.ts
    │   │   │   └── models.ts
    │   │   ├── api-client.ts
    │   │   ├── constants/
    │   │   │   ├── bookmarks.ts
    │   │   │   └── z-index.ts
    │   │   ├── image-utils.ts
    │   │   ├── logger.ts
    │   │   ├── query-client.ts
    │   │   ├── search-utils.ts
    │   │   ├── types/
    │   │   │   ├── api.types.ts
    │   │   │   ├── auth.types.ts
    │   │   │   ├── bookmark.types.ts
    │   │   │   ├── index.ts
    │   │   │   ├── preferences.types.ts
    │   │   │   └── tab-group.types.ts
    │   │   └── types.ts
    │   ├── main.tsx
    │   ├── pages/
    │   │   ├── about/
    │   │   │   └── AboutPage.tsx
    │   │   ├── auth/
    │   │   │   ├── LoginPage.tsx
    │   │   │   └── RegisterPage.tsx
    │   │   ├── bookmarks/
    │   │   │   ├── BookmarkStatisticsPage.tsx
    │   │   │   ├── BookmarkTrashPage.tsx
    │   │   │   ├── BookmarksPage.tsx
    │   │   │   ├── TrashBookmarkItem.tsx
    │   │   │   ├── components/
    │   │   │   │   ├── BatchSelectionPrompt.tsx
    │   │   │   │   ├── MobileTagDrawer.tsx
    │   │   │   │   └── StatisticsCards.tsx
    │   │   │   └── hooks/
    │   │   │       ├── useBookmarksEffects.ts
    │   │   │       ├── useBookmarksState.ts
    │   │   │       └── useStatisticsData.ts
    │   │   ├── extension/
    │   │   │   └── ExtensionPage.tsx
    │   │   ├── info/
    │   │   │   ├── AboutPage.tsx
    │   │   │   ├── HelpPage.tsx
    │   │   │   ├── PrivacyPage.tsx
    │   │   │   └── TermsPage.tsx
    │   │   ├── settings/
    │   │   │   ├── ApiKeysPage.tsx
    │   │   │   ├── GeneralSettingsPage.tsx
    │   │   │   ├── ImportExportPage.tsx
    │   │   │   ├── PermissionsPage.tsx
    │   │   │   └── ShareSettingsPage.tsx
    │   │   ├── share/
    │   │   │   ├── PublicSharePage.tsx
    │   │   │   ├── components/
    │   │   │   │   └── ShareTopBar.tsx
    │   │   │   └── hooks/
    │   │   │       └── usePublicShareState.ts
    │   │   └── tab-groups/
    │   │       ├── StatisticsPage.tsx
    │   │       ├── TabGroupDetailHeader.tsx
    │   │       ├── TabGroupDetailPage.tsx
    │   │       ├── TabGroupEmptyState.tsx
    │   │       ├── TabGroupItem.tsx
    │   │       ├── TabGroupsPage.tsx
    │   │       ├── TodoPage.tsx
    │   │       ├── TrashPage.tsx
    │   │       ├── components/
    │   │       │   ├── TabGroupsGrid.tsx
    │   │       │   └── TabGroupsList.tsx
    │   │       └── hooks/
    │   │           ├── useGroupManagement.ts
    │   │           ├── useTabGroupDetailState.ts
    │   │           ├── useTabGroupItemDnD.ts
    │   │           ├── useTabGroupsData.ts
    │   │           └── useTabGroupsState.ts
    │   ├── routes/
    │   │   └── index.tsx
    │   ├── services/
    │   │   ├── api-keys.ts
    │   │   ├── auth.ts
    │   │   ├── bookmarks.ts
    │   │   ├── index.ts
    │   │   ├── preferences.ts
    │   │   ├── share.ts
    │   │   ├── storage.ts
    │   │   ├── tab-groups.ts
    │   │   └── tags.ts
    │   ├── stores/
    │   │   ├── authStore.ts
    │   │   ├── dialogStore.ts
    │   │   ├── index.ts
    │   │   ├── themeStore.ts
    │   │   └── toastStore.ts
    │   ├── styles/
    │   │   ├── components.css
    │   │   ├── index.css
    │   │   └── themes/
    │   │       ├── default.css
    │   │       └── orange.css
    │   └── vite-env.d.ts
    ├── tailwind.config.js
    ├── test-register.js
    ├── tsconfig.json
    ├── vite.config.ts
    └── wrangler.toml.example
Download .txt
SYMBOL INDEX (1104 symbols across 305 files)

FILE: tmarks/functions/api/public/[slug].ts
  type PublicSharePayload (line 8) | interface PublicSharePayload {
  type PublicSharePaginatedPayload (line 20) | interface PublicSharePaginatedPayload {

FILE: tmarks/functions/api/share/[token].ts
  type ShareRow (line 11) | interface ShareRow {
  type TabGroupRow (line 22) | interface TabGroupRow {
  type TabGroupItemRow (line 32) | interface TabGroupItemRow {

FILE: tmarks/functions/api/shared/cache.ts
  function invalidatePublicShareCache (line 9) | async function invalidatePublicShareCache(env: Env, userId: string) {

FILE: tmarks/functions/api/tab/bookmarks/[id].ts
  type UpdateBookmarkRequest (line 16) | interface UpdateBookmarkRequest {

FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshot-upload.ts
  constant R2_UPLOAD_CONCURRENCY (line 8) | const R2_UPLOAD_CONCURRENCY = 6
  type ImageInput (line 10) | interface ImageInput {
  type DecodedImage (line 16) | interface DecodedImage {
  function decodeBase64Image (line 23) | function decodeBase64Image(image: ImageInput): DecodedImage | null {
  type UploadResult (line 39) | interface UploadResult {
  function uploadImagesConcurrently (line 48) | async function uploadImagesConcurrently(
  function replaceImagePlaceholders (line 94) | function replaceImagePlaceholders(

FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots-v2.ts
  function generateNanoId (line 18) | function generateNanoId(): string {
  function sha256 (line 29) | async function sha256(content: string): Promise<string> {
  type CreateSnapshotV2Request (line 36) | interface CreateSnapshotV2Request {

FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots.ts
  function generateNanoId (line 14) | function generateNanoId(): string {
  function sha256 (line 28) | async function sha256(content: string): Promise<string> {
  type CreateSnapshotRequest (line 36) | interface CreateSnapshotRequest {
  constant MAX_SNAPSHOT_SIZE (line 44) | const MAX_SNAPSHOT_SIZE = 50 * 1024 * 1024 // 50MB
  function cleanupOldSnapshots (line 271) | async function cleanupOldSnapshots(

FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots/cleanup.ts
  type CleanupRequest (line 12) | interface CleanupRequest {
  type RouteParams (line 18) | interface RouteParams {
  type SnapshotRow (line 22) | interface SnapshotRow {
  function deleteSnapshotRows (line 28) | async function deleteSnapshotRows(
  function updateBookmarkSnapshotCount (line 38) | async function updateBookmarkSnapshotCount(
  function handleVerifyAndFix (line 68) | async function handleVerifyAndFix(

FILE: tmarks/functions/api/tab/bookmarks/batch-handler.ts
  type BatchCreateBookmarkItem (line 14) | interface BatchCreateBookmarkItem {
  type BatchCreateResult (line 26) | interface BatchCreateResult {
  function batchCreateBookmarks (line 43) | async function batchCreateBookmarks(

FILE: tmarks/functions/api/tab/bookmarks/batch/index.ts
  type BatchCreateBookmarkItem (line 16) | interface BatchCreateBookmarkItem {
  type BatchCreateRequest (line 28) | interface BatchCreateRequest {
  type BatchCreateResult (line 32) | interface BatchCreateResult {

FILE: tmarks/functions/api/tab/bookmarks/bookmark-batch.ts
  type BatchCreateResult (line 6) | type BatchCreateResult = {
  function handleBatchCreate (line 15) | async function handleBatchCreate(

FILE: tmarks/functions/api/tab/bookmarks/bookmark-list.ts
  type BookmarkWithTags (line 3) | interface BookmarkWithTags extends Bookmark {
  type BookmarkListRow (line 7) | interface BookmarkListRow extends BookmarkRow {
  type BookmarkPageCursor (line 11) | interface BookmarkPageCursor {
  function parseBookmarkPageCursor (line 18) | function parseBookmarkPageCursor(raw: string | null): BookmarkPageCursor...
  function createBookmarkPageCursor (line 43) | function createBookmarkPageCursor(row: BookmarkListRow, sortBy: 'created...
  function buildBookmarkListQuery (line 52) | function buildBookmarkListQuery(
  function fetchBookmarkTags (line 170) | async function fetchBookmarkTags(

FILE: tmarks/functions/api/tab/bookmarks/index.ts
  type CreateBookmarkRequest (line 26) | interface CreateBookmarkRequest {

FILE: tmarks/functions/api/tab/bookmarks/reorder-pinned.ts
  type ReorderPinnedRequest (line 11) | interface ReorderPinnedRequest {

FILE: tmarks/functions/api/tab/bookmarks/trash.ts
  type TrashQueryParams (line 13) | interface TrashQueryParams {

FILE: tmarks/functions/api/tab/me.ts
  type BookmarkStats (line 13) | type BookmarkStats = {

FILE: tmarks/functions/api/tab/search.ts
  type BookmarkWithTags (line 13) | type BookmarkWithTags = Bookmark & {

FILE: tmarks/functions/api/tab/statistics/index.ts
  type DomainCount (line 8) | interface DomainCount {

FILE: tmarks/functions/api/tab/tab-groups/[id].ts
  type TabGroupRow (line 13) | interface TabGroupRow {
  type TabGroupItemRow (line 28) | interface TabGroupItemRow {
  type UpdateTabGroupRequest (line 38) | interface UpdateTabGroupRequest {

FILE: tmarks/functions/api/tab/tab-groups/[id]/items/batch.ts
  type TabGroupRow (line 14) | interface TabGroupRow {
  type BatchAddItemsRequest (line 20) | interface BatchAddItemsRequest {

FILE: tmarks/functions/api/tab/tab-groups/[id]/permanent-delete.ts
  type TabGroupRow (line 12) | interface TabGroupRow {

FILE: tmarks/functions/api/tab/tab-groups/[id]/restore.ts
  type TabGroupRow (line 12) | interface TabGroupRow {

FILE: tmarks/functions/api/tab/tab-groups/[id]/share.ts
  type TabGroupRow (line 13) | interface TabGroupRow {
  type ShareRow (line 19) | interface ShareRow {
  type CreateShareRequest (line 30) | interface CreateShareRequest {

FILE: tmarks/functions/api/tab/tab-groups/index.ts
  type TabGroupRow (line 14) | interface TabGroupRow {
  type TabGroupItemRow (line 29) | interface TabGroupItemRow {
  type CreateTabGroupRequest (line 39) | interface CreateTabGroupRequest {
  function parseTags (line 50) | function parseTags(group: TabGroupRow): string[] | null {

FILE: tmarks/functions/api/tab/tab-groups/items/[id].ts
  type TabGroupItemRow (line 13) | interface TabGroupItemRow {
  type UpdateTabGroupItemRequest (line 26) | interface UpdateTabGroupItemRequest {

FILE: tmarks/functions/api/tab/tab-groups/items/[id]/move.ts
  type TabGroupItemRow (line 12) | interface TabGroupItemRow {
  type MoveItemRequest (line 24) | interface MoveItemRequest {

FILE: tmarks/functions/api/tab/tab-groups/trash.ts
  type TabGroupRow (line 8) | interface TabGroupRow {

FILE: tmarks/functions/api/tab/tags/[id].ts
  type UpdateTagRequest (line 13) | interface UpdateTagRequest {

FILE: tmarks/functions/api/tab/tags/index.ts
  type CreateTagRequest (line 14) | interface CreateTagRequest {
  type TagWithCount (line 19) | interface TagWithCount {

FILE: tmarks/functions/api/v1/auth/login.ts
  type LoginRequest (line 9) | interface LoginRequest {
  type DbUser (line 26) | type DbUser = User & { role?: string | null }

FILE: tmarks/functions/api/v1/auth/logout.ts
  type LogoutRequest (line 7) | interface LogoutRequest {

FILE: tmarks/functions/api/v1/auth/refresh.ts
  type RefreshRequest (line 8) | interface RefreshRequest {
  type DbUser (line 62) | type DbUser = { id: string; username: string; email: string | null; role...
  function parseExpiresInToSeconds (line 121) | function parseExpiresInToSeconds(expiresIn: string): number {

FILE: tmarks/functions/api/v1/auth/register.ts
  type RegisterRequest (line 7) | interface RegisterRequest {

FILE: tmarks/functions/api/v1/bookmarks/[id].ts
  type UpdateBookmarkRequest (line 10) | interface UpdateBookmarkRequest {

FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshot-cleanup.ts
  function cleanupOldSnapshots (line 5) | async function cleanupOldSnapshots(

FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshots.ts
  function sha256 (line 17) | async function sha256(content: string): Promise<string> {
  type CreateSnapshotRequest (line 25) | interface CreateSnapshotRequest {
  constant MAX_SNAPSHOT_SIZE (line 33) | const MAX_SNAPSHOT_SIZE = 50 * 1024 * 1024 // 50MB

FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshots/cleanup.ts
  type CleanupRequest (line 12) | interface CleanupRequest {
  type RouteParams (line 18) | interface RouteParams {
  type SnapshotRow (line 22) | interface SnapshotRow {
  function deleteSnapshotRows (line 28) | async function deleteSnapshotRows(
  function updateBookmarkSnapshotCount (line 38) | async function updateBookmarkSnapshotCount(
  function handleVerifyAndFix (line 68) | async function handleVerifyAndFix(

FILE: tmarks/functions/api/v1/bookmarks/bulk.ts
  type BatchActionType (line 8) | type BatchActionType = 'delete' | 'update_tags' | 'pin' | 'unpin' | 'arc...
  type BatchActionRequest (line 9) | interface BatchActionRequest {
  type BatchActionResponse (line 15) | interface BatchActionResponse {

FILE: tmarks/functions/api/v1/bookmarks/statistics-helpers.ts
  type BookmarkStatistics (line 1) | interface BookmarkStatistics {
  function getDateGroupSql (line 49) | function getDateGroupSql(granularity: string, field: string) {

FILE: tmarks/functions/api/v1/bookmarks/trash.ts
  type TrashQueryParams (line 13) | interface TrashQueryParams {

FILE: tmarks/functions/api/v1/change-password.ts
  type ChangePasswordRequest (line 13) | interface ChangePasswordRequest {

FILE: tmarks/functions/api/v1/export.ts
  type ExportPreviewRequest (line 17) | interface ExportPreviewRequest {
  function parseCommonOptions (line 23) | function parseCommonOptions(url: URL): {

FILE: tmarks/functions/api/v1/preferences-helpers.ts
  type UserPreferences (line 1) | interface UserPreferences {
  type UpdatePreferencesRequest (line 21) | interface UpdatePreferencesRequest {
  function hasTagLayoutColumn (line 39) | async function hasTagLayoutColumn(db: D1Database): Promise<boolean> {
  function hasSortByColumn (line 51) | async function hasSortByColumn(db: D1Database): Promise<boolean> {
  function hasAutomationColumns (line 63) | async function hasAutomationColumns(db: D1Database): Promise<boolean> {
  function mapPreferences (line 80) | function mapPreferences(preferences: UserPreferences) {
  function validatePreferences (line 101) | function validatePreferences(body: UpdatePreferencesRequest): string | n...

FILE: tmarks/functions/api/v1/settings/api-keys/[id].ts
  type UpdateApiKeyRequest (line 15) | interface UpdateApiKeyRequest {
  type ApiKeyDetail (line 24) | interface ApiKeyDetail {

FILE: tmarks/functions/api/v1/settings/api-keys/index.ts
  type CreateApiKeyRequest (line 10) | interface CreateApiKeyRequest {
  function getUserApiKeyLimit (line 18) | async function getUserApiKeyLimit(_db: D1Database, _userId: string): Pro...
  type ApiKeyRow (line 26) | interface ApiKeyRow {

FILE: tmarks/functions/api/v1/settings/share.ts
  type UpdateShareSettingsRequest (line 9) | interface UpdateShareSettingsRequest {
  constant SLUG_MAX_LENGTH (line 17) | const SLUG_MAX_LENGTH = 64
  function generateUniqueSlug (line 137) | async function generateUniqueSlug(env: Env, userId: string): Promise<str...

FILE: tmarks/functions/api/v1/statistics/index.ts
  type DomainCount (line 12) | interface DomainCount {

FILE: tmarks/functions/api/v1/tab-groups/[id].ts
  type TabGroupRow (line 13) | interface TabGroupRow {
  type TabGroupItemRow (line 28) | interface TabGroupItemRow {
  type UpdateTabGroupRequest (line 40) | interface UpdateTabGroupRequest {

FILE: tmarks/functions/api/v1/tab-groups/[id]/items/batch.ts
  type TabGroupRow (line 14) | interface TabGroupRow {
  type BatchAddItemsRequest (line 20) | interface BatchAddItemsRequest {
  type TabGroupItemRow (line 28) | interface TabGroupItemRow {

FILE: tmarks/functions/api/v1/tab-groups/[id]/permanent-delete.ts
  type TabGroupRow (line 12) | interface TabGroupRow {

FILE: tmarks/functions/api/v1/tab-groups/[id]/restore.ts
  type TabGroupRow (line 12) | interface TabGroupRow {

FILE: tmarks/functions/api/v1/tab-groups/[id]/share.ts
  type TabGroupRow (line 13) | interface TabGroupRow {
  type ShareRow (line 19) | interface ShareRow {
  type CreateShareRequest (line 28) | interface CreateShareRequest {

FILE: tmarks/functions/api/v1/tab-groups/batch-update.ts
  type BatchUpdateItem (line 11) | interface BatchUpdateItem {
  type BatchUpdateRequest (line 17) | interface BatchUpdateRequest {

FILE: tmarks/functions/api/v1/tab-groups/index.ts
  type TabGroupRow (line 14) | interface TabGroupRow {
  type TabGroupItemRow (line 29) | interface TabGroupItemRow {
  type CreateTabGroupRequest (line 41) | interface CreateTabGroupRequest {

FILE: tmarks/functions/api/v1/tab-groups/items/[id].ts
  type TabGroupItemRow (line 13) | interface TabGroupItemRow {
  type UpdateTabGroupItemRequest (line 26) | interface UpdateTabGroupItemRequest {

FILE: tmarks/functions/api/v1/tab-groups/items/[id]/move.ts
  type TabGroupItemRow (line 12) | interface TabGroupItemRow {
  type MoveItemRequest (line 24) | interface MoveItemRequest {

FILE: tmarks/functions/api/v1/tab-groups/trash.ts
  type TabGroupRow (line 8) | interface TabGroupRow {

FILE: tmarks/functions/api/v1/tags/[id].ts
  type UpdateTagRequest (line 7) | interface UpdateTagRequest {

FILE: tmarks/functions/api/v1/tags/index.ts
  type CreateTagRequest (line 8) | interface CreateTagRequest {
  type TagWithCount (line 13) | interface TagWithCount extends Tag {

FILE: tmarks/functions/lib/api-key/generator.ts
  function generateApiKey (line 11) | async function generateApiKey(env: 'live' | 'test' = 'live'): Promise<{
  function hashApiKey (line 41) | async function hashApiKey(key: string): Promise<string> {
  function isValidApiKeyFormat (line 56) | function isValidApiKeyFormat(key: string): boolean {

FILE: tmarks/functions/lib/api-key/logger.ts
  type LogEntry (line 6) | interface LogEntry {
  function logApiKeyUsage (line 20) | async function logApiKeyUsage(entry: LogEntry, db: D1Database): Promise<...
  function cleanupOldLogs (line 50) | async function cleanupOldLogs(apiKeyId: string, db: D1Database): Promise...
  function getApiKeyLogs (line 78) | async function getApiKeyLogs(
  function getApiKeyStats (line 103) | async function getApiKeyStats(

FILE: tmarks/functions/lib/api-key/rate-limiter-types.ts
  type RateLimitWindow (line 5) | type RateLimitWindow = 'minute' | 'hour' | 'day';
  type RateLimitConfig (line 7) | interface RateLimitConfig {
  constant DEFAULT_LIMITS (line 14) | const DEFAULT_LIMITS: RateLimitConfig = {
  type RateLimitResult (line 20) | interface RateLimitResult {

FILE: tmarks/functions/lib/api-key/rate-limiter.ts
  function ensureTable (line 14) | async function ensureTable(db: D1Database): Promise<void> {
  function getWindowMs (line 35) | function getWindowMs(window: RateLimitWindow): number {
  function getLimit (line 41) | function getLimit(limits: RateLimitConfig, window: RateLimitWindow): num...
  function getWindowStart (line 47) | function getWindowStart(now: number, windowMs: number): number {
  function maybeCleanup (line 51) | async function maybeCleanup(db: D1Database, now: number): Promise<void> {
  function getCounts (line 59) | async function getCounts(
  function checkRateLimit (line 95) | async function checkRateLimit(
  function consumeRateLimit (line 168) | async function consumeRateLimit(
  function recordRequest (line 197) | async function recordRequest(

FILE: tmarks/functions/lib/api-key/validator.ts
  type ApiKeyData (line 8) | interface ApiKeyData {
  type ValidationResult (line 18) | interface ValidationResult {
  function validateApiKey (line 31) | async function validateApiKey(
  function checkPermission (line 96) | function checkPermission(permissions: string[], requiredPermission: stri...
  function markAsExpired (line 105) | async function markAsExpired(keyId: string, db: D1Database): Promise<voi...
  function updateLastUsed (line 122) | async function updateLastUsed(

FILE: tmarks/functions/lib/bookmark-utils.ts
  function normalizeBookmark (line 7) | function normalizeBookmark(row: BookmarkRow): Bookmark {

FILE: tmarks/functions/lib/cache/bookmark-cache.ts
  class BookmarkCacheManager (line 12) | class BookmarkCacheManager {
    method constructor (line 13) | constructor(private cache: CacheService) {}
    method getBookmarkList (line 17) | async getBookmarkList<T>(userId: string, params?: QueryParams): Promis...
    method setBookmarkList (line 28) | async setBookmarkList<T>(
    method invalidateUserBookmarks (line 44) | async invalidateUserBookmarks(userId: string): Promise<void> {
    method invalidateQuery (line 51) | async invalidateQuery(userId: string, params?: QueryParams): Promise<v...
    method handleBatchOperation (line 59) | async handleBatchOperation(userId: string): Promise<void> {
    method refreshCommonQueries (line 72) | private refreshCommonQueries(userId: string): void {
  function createBookmarkCacheManager (line 88) | function createBookmarkCacheManager(cache: CacheService): BookmarkCacheM...

FILE: tmarks/functions/lib/cache/config.ts
  constant CACHE_PRESETS (line 11) | const CACHE_PRESETS: Record<CacheLevel, CacheConfig> = {
  function loadCacheConfig (line 151) | function loadCacheConfig(env: Env): CacheConfig {
  function validateCacheConfig (line 197) | function validateCacheConfig(config: CacheConfig): boolean {

FILE: tmarks/functions/lib/cache/service.ts
  class CacheService (line 19) | class CacheService {
    method constructor (line 27) | constructor(env: Env) {
    method get (line 34) | async get<T>(
    method set (line 60) | async set<T>(
    method delete (line 80) | async delete(key: string): Promise<void> {
    method invalidate (line 90) | async invalidate(prefix: string): Promise<void> {
    method shouldCache (line 106) | shouldCache(type: CacheStrategyType, params?: Record<string, unknown>)...
    method getStats (line 115) | getStats(): CacheStats {
    method getConfig (line 130) | getConfig(): CacheConfig {
    method isEnabled (line 137) | private isEnabled(type: CacheStrategyType): boolean {
    method getFromMemory (line 143) | private getFromMemory<T>(key: string): T | null {
    method setToMemory (line 156) | private setToMemory<T>(key: string, data: T, ttlSeconds?: number): void {
    method handleError (line 175) | private handleError(operation: string, error: unknown): void {

FILE: tmarks/functions/lib/cache/strategies.ts
  function generateCacheKey (line 10) | function generateCacheKey(
  function getQueryType (line 63) | function getQueryType(params?: QueryParams): CacheStrategyType {
  function shouldCacheQuery (line 83) | function shouldCacheQuery(
  function getCacheInvalidationPrefix (line 107) | function getCacheInvalidationPrefix(
  function hashQueryParams (line 123) | function hashQueryParams(params: QueryParams): string {

FILE: tmarks/functions/lib/cache/types.ts
  type CacheLevel (line 1) | type CacheLevel = 0 | 1 | 2 | 3
  type CacheStrategyType (line 3) | type CacheStrategyType =
  type CacheConfig (line 11) | interface CacheConfig {
  type CacheEntry (line 26) | interface CacheEntry<T = unknown> {
  type CacheSetOptions (line 31) | interface CacheSetOptions {
  type CacheStats (line 36) | interface CacheStats {
  type QueryParams (line 46) | interface QueryParams {

FILE: tmarks/functions/lib/config.ts
  constant DEFAULT_CONFIG (line 11) | const DEFAULT_CONFIG = {
  function getJwtAccessTokenExpiresIn (line 19) | function getJwtAccessTokenExpiresIn(env?: Env): string {
  function getJwtRefreshTokenExpiresIn (line 26) | function getJwtRefreshTokenExpiresIn(env?: Env): string {
  function isRegistrationAllowed (line 35) | function isRegistrationAllowed(env: Env): boolean {
  function getEnvironment (line 44) | function getEnvironment(env: Env): 'development' | 'production' {

FILE: tmarks/functions/lib/crypto.ts
  constant PBKDF2_ITERATIONS (line 4) | const PBKDF2_ITERATIONS = 100000 // OWASP
  constant SALT_LENGTH (line 5) | const SALT_LENGTH = 16
  constant HASH_LENGTH (line 6) | const HASH_LENGTH = 32
  function hashPassword (line 10) | async function hashPassword(password: string): Promise<string> {
  function verifyPassword (line 45) | async function verifyPassword(password: string, hash: string): Promise<b...
  function generateToken (line 83) | function generateToken(length: number = 32): string {
  function hashRefreshToken (line 90) | async function hashRefreshToken(token: string): Promise<string> {
  function generateUUID (line 99) | function generateUUID(): string {
  function generateShortUUID (line 109) | function generateShortUUID(): string {
  function generateNanoId (line 121) | function generateNanoId(length: number = 21): string {
  function timingSafeEqual (line 135) | function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
  function arrayBufferToBase64 (line 148) | function arrayBufferToBase64(buffer: Uint8Array): string {
  function base64ToArrayBuffer (line 155) | function base64ToArrayBuffer(base64: string): Uint8Array {

FILE: tmarks/functions/lib/data-fetchers.ts
  function fetchFullBookmarks (line 4) | async function fetchFullBookmarks(

FILE: tmarks/functions/lib/error-handler.ts
  type ErrorContext (line 6) | interface ErrorContext {
  type ErrorDetails (line 14) | interface ErrorDetails {
  function handleError (line 24) | function handleError(error: unknown, context?: ErrorContext): Response {
  function normalizeError (line 53) | function normalizeError(error: unknown, context?: ErrorContext): ErrorDe...
  function logError (line 104) | function logError(errorDetails: ErrorDetails): void {
  function createErrorContext (line 118) | function createErrorContext(request: Request, userId?: string): ErrorCon...
  function withErrorHandling (line 133) | function withErrorHandling<T extends unknown[], R>(

FILE: tmarks/functions/lib/image-sig.ts
  function generateImageSig (line 4) | async function generateImageSig(

FILE: tmarks/functions/lib/image-upload.ts
  type UploadImageResult (line 8) | interface UploadImageResult {
  type ExistingImage (line 19) | interface ExistingImage {
  function uploadCoverImageToR2 (line 36) | async function uploadCoverImageToR2(
  function calculateHash (line 164) | async function calculateHash(data: ArrayBuffer): Promise<string> {
  function getExtensionFromContentType (line 173) | function getExtensionFromContentType(contentType: string): string {
  function deleteBookmarkImage (line 187) | async function deleteBookmarkImage(
  function cleanupOrphanedImages (line 219) | async function cleanupOrphanedImages(db: D1Database, bucket: R2Bucket): ...

FILE: tmarks/functions/lib/import-export/collect-export-data.ts
  constant DEFAULT_TAG_COLOR (line 12) | const DEFAULT_TAG_COLOR = '#3b82f6'
  function parseMaybeJsonStringArray (line 14) | function parseMaybeJsonStringArray(raw: unknown): string[] | undefined {
  function collectUser (line 27) | async function collectUser(db: D1Database, userId: string): Promise<Expo...
  function collectBookmarksAndTags (line 60) | async function collectBookmarksAndTags(
  function collectTabGroups (line 170) | async function collectTabGroups(
  function collectExportData (line 239) | async function collectExportData(

FILE: tmarks/functions/lib/import-export/export-scope.ts
  type ExportScope (line 1) | type ExportScope = 'all' | 'bookmarks' | 'tab_groups'
  function parseExportScope (line 3) | function parseExportScope(raw: string | null | undefined): ExportScope {
  function getExportFilename (line 8) | function getExportFilename(exportedAtIso: string, scope: ExportScope): s...

FILE: tmarks/functions/lib/import-export/export-stats.ts
  type ExportStats (line 3) | interface ExportStats {
  function getExportStats (line 10) | async function getExportStats(
  function estimateExportSize (line 52) | function estimateExportSize(stats: ExportStats): number {

FILE: tmarks/functions/lib/import-export/exporters/html-exporter.ts
  class HtmlExporter (line 15) | class HtmlExporter implements Exporter {
    method export (line 18) | async export(data: TMarksExportData, options?: ExportOptions): Promise...
    method generateHtml (line 36) | private generateHtml(data: TMarksExportData, options?: ExportOptions):...
    method organizeBookmarksByFolder (line 68) | private organizeBookmarksByFolder(bookmarks: Array<Record<string, unkn...
    method generateBookmarkFolders (line 103) | private generateBookmarkFolders(folderMap: Map<string, Array<Record<st...
    method generateBookmarkEntry (line 122) | private generateBookmarkEntry(bookmark: Record<string, unknown>): stri...
    method generateMetadataComment (line 151) | private generateMetadataComment(data: TMarksExportData): string {
    method generateFilename (line 172) | private generateFilename(exportedAt: string): string {
    method toUnixTimestamp (line 179) | private toUnixTimestamp(isoString: string): string {
    method escapeHtml (line 183) | private escapeHtml(text: string): string {
    method validateData (line 196) | validateData(data: TMarksExportData): { valid: boolean; errors: string...
    method isValidUrl (line 223) | private isValidUrl(url: string): boolean {
    method getPreview (line 235) | getPreview(data: TMarksExportData, maxItems: number = 5): string {
  function createHtmlExporter (line 252) | function createHtmlExporter(): HtmlExporter {
  function exportToHtml (line 259) | async function exportToHtml(

FILE: tmarks/functions/lib/import-export/exporters/json-exporter.ts
  class JsonExporter (line 13) | class JsonExporter implements Exporter {
    method export (line 16) | async export(data: TMarksExportData, options?: ExportOptions): Promise...
    method filterData (line 36) | private filterData(data: TMarksExportData, options?: ExportOptions): T...
    method formatJson (line 73) | private formatJson(data: TMarksExportData, options?: ExportOptions): s...
    method generateFilename (line 83) | private generateFilename(exportedAt: string): string {
    method validateData (line 94) | validateData(data: TMarksExportData): { valid: boolean; errors: string...
    method isValidUrl (line 125) | private isValidUrl(url: string): boolean {
    method isValidColor (line 134) | private isValidColor(color: string): boolean {
    method getExportStats (line 142) | getExportStats(data: TMarksExportData): {
  function createJsonExporter (line 166) | function createJsonExporter(): JsonExporter {
  function exportToJson (line 173) | async function exportToJson(
  function exportToCompactJson (line 185) | async function exportToCompactJson(data: TMarksExportData): Promise<stri...

FILE: tmarks/functions/lib/import-export/exporters/tab-groups-netscape.ts
  type EscapeHtml (line 3) | type EscapeHtml = (text: string) => string
  type ToUnixTimestamp (line 4) | type ToUnixTimestamp = (isoString: string) => string
  function generateTabGroupsNetscapeSection (line 6) | function generateTabGroupsNetscapeSection(params: {

FILE: tmarks/functions/lib/input-sanitizer.ts
  function escapeHtml (line 7) | function escapeHtml(text: string): string {
  function sanitizeString (line 21) | function sanitizeString(input: string, options: {
  function sanitizeUrl (line 47) | function sanitizeUrl(url: string): string | null {
  function sanitizeEmail (line 66) | function sanitizeEmail(email: string): string | null {
  function sanitizeUsername (line 80) | function sanitizeUsername(username: string): string | null {
  function sanitizeTagName (line 92) | function sanitizeTagName(tagName: string): string | null {
  function sanitizeFileName (line 107) | function sanitizeFileName(fileName: string): string {
  function sanitizeColor (line 117) | function sanitizeColor(color: string): string | null {
  function sanitizeSearchQuery (line 137) | function sanitizeSearchQuery(query: string): string {
  function sanitizePaginationParams (line 146) | function sanitizePaginationParams(params: {
  function sanitizeObject (line 169) | function sanitizeObject<T extends Record<string, unknown>>(
  function validateJsonStructure (line 188) | function validateJsonStructure(

FILE: tmarks/functions/lib/jwt.ts
  type JWTPayload (line 1) | interface JWTPayload {
  function generateJWT (line 10) | async function generateJWT(
  function verifyJWT (line 34) | async function verifyJWT(token: string, secret: string): Promise<JWTPayl...
  function extractJWT (line 56) | function extractJWT(request: Request): string | null {
  function sign (line 66) | async function sign(data: string, secret: string): Promise<string> {
  function base64UrlEncode (line 83) | function base64UrlEncode(data: string | ArrayBuffer): string {
  function base64UrlDecode (line 97) | function base64UrlDecode(data: string): string {
  function parseExpiry (line 107) | function parseExpiry(expiry: string): number {

FILE: tmarks/functions/lib/rate-limit.ts
  type RateLimiterContext (line 4) | type RateLimiterContext = Parameters<PagesFunction<Env>>[0]
  type RateLimitResult (line 6) | interface RateLimitResult {
  function ensureTable (line 15) | async function ensureTable(db: D1Database): Promise<void> {
  function getWindowStart (line 36) | function getWindowStart(now: number, windowSeconds: number): number {
  function checkAndRecord (line 41) | async function checkAndRecord(
  function getClientIP (line 82) | function getClientIP(request: Request): string {
  function createRateLimiter (line 97) | function createRateLimiter(

FILE: tmarks/functions/lib/response.ts
  function success (line 3) | function success<T>(data: T, meta?: ApiResponse['meta']): Response {
  function created (line 11) | function created<T>(data: T): Response {
  function noContent (line 15) | function noContent(): Response {
  function badRequest (line 19) | function badRequest(error: string | ApiError | Partial<ApiError>, code =...
  function unauthorized (line 26) | function unauthorized(error: string | ApiError | Partial<ApiError>, code...
  function forbidden (line 33) | function forbidden(error: string | ApiError | Partial<ApiError>, code?: ...
  function notFound (line 40) | function notFound(message = 'Not found', code = 'NOT_FOUND'): Response {
  function conflict (line 45) | function conflict(message: string, code = 'CONFLICT'): Response {
  function tooManyRequests (line 50) | function tooManyRequests(error: string | ApiError | Partial<ApiError>, h...
  function internalError (line 66) | function internalError(message = 'Internal server error', code = 'INTERN...

FILE: tmarks/functions/lib/signed-url.ts
  type SignedUrlParams (line 7) | interface SignedUrlParams {
  type SignedUrlData (line 14) | interface SignedUrlData {
  function generateSignedUrl (line 27) | async function generateSignedUrl(
  function verifySignedUrl (line 58) | async function verifySignedUrl(
  function extractSignedParams (line 86) | function extractSignedParams(request: Request): {
  function timingSafeEqual (line 118) | function timingSafeEqual(a: string, b: string): boolean {
  function sign (line 130) | async function sign(message: string, secret: string): Promise<string> {

FILE: tmarks/functions/lib/storage-quota.ts
  type UsageRow (line 12) | type UsageRow = {
  function getR2MaxTotalBytes (line 25) | function getR2MaxTotalBytes(env: Env): number {
  function getCurrentR2UsageBytes (line 53) | async function getCurrentR2UsageBytes(db: D1Database): Promise<number> {
  type R2QuotaCheckResult (line 74) | interface R2QuotaCheckResult {
  function checkR2Quota (line 83) | async function checkR2Quota(

FILE: tmarks/functions/lib/tags.ts
  function normalizeTagNames (line 3) | function normalizeTagNames(tagNames: string[]): string[] {
  function uniqueTagIds (line 21) | function uniqueTagIds(tagIds: string[]): string[] {
  function getValidTagIds (line 36) | async function getValidTagIds(
  function resolveOrCreateTagIds (line 57) | async function resolveOrCreateTagIds(
  function createOrLinkTags (line 97) | async function createOrLinkTags(
  function replaceBookmarkTags (line 115) | async function replaceBookmarkTags(
  function replaceBookmarkTagsByNames (line 139) | async function replaceBookmarkTagsByNames(

FILE: tmarks/functions/lib/types.ts
  type Env (line 1) | interface Env {
  type User (line 24) | interface User {
  type Bookmark (line 32) | interface Bookmark {
  type BookmarkRow (line 52) | interface BookmarkRow extends Omit<Bookmark, 'is_pinned' | 'is_archived'...
  type PublicProfile (line 57) | interface PublicProfile {
  type Tag (line 65) | interface Tag {
  type ApiError (line 76) | interface ApiError {
  type ApiResponse (line 81) | interface ApiResponse<T = unknown> {
  type RouteParams (line 91) | type RouteParams = Record<string, string>
  type SQLParam (line 92) | type SQLParam = string | number | boolean | null

FILE: tmarks/functions/lib/utils.ts
  function generateSlug (line 1) | function generateSlug(): string {

FILE: tmarks/functions/lib/validation.ts
  function isValidEmail (line 1) | function isValidEmail(email: string): boolean {
  function isValidUrl (line 7) | function isValidUrl(url: string): boolean {
  function isValidUsername (line 16) | function isValidUsername(username: string): boolean {
  function isValidPassword (line 22) | function isValidPassword(password: string): boolean {
  function sanitizeString (line 27) | function sanitizeString(str: string, maxLength = 1000): string {
  function escapeHtml (line 34) | function escapeHtml(str: string): string {
  function sanitizeForHtml (line 46) | function sanitizeForHtml(str: string, maxLength = 1000): string {

FILE: tmarks/functions/middleware/api-key-auth-pages.ts
  type ApiKeyAuthContext (line 12) | interface ApiKeyAuthContext extends Record<string, unknown> {
  function requireApiKeyAuth (line 21) | function requireApiKeyAuth(

FILE: tmarks/functions/middleware/api-key-auth.ts
  type ApiKeyAuthOptions (line 11) | interface ApiKeyAuthOptions {
  function requireApiKey (line 20) | function requireApiKey(options: ApiKeyAuthOptions) {
  function optionalApiKey (line 129) | function optionalApiKey() {

FILE: tmarks/functions/middleware/auth.ts
  type AuthContext (line 6) | interface AuthContext extends Record<string, unknown> {

FILE: tmarks/functions/middleware/dual-auth.ts
  type DualAuthContext (line 14) | interface DualAuthContext {
  function requireDualAuth (line 25) | function requireDualAuth(

FILE: tmarks/functions/middleware/security.ts
  function getCorsPolicy (line 142) | function getCorsPolicy(request: Request, allowedOriginsEnv?: string): { ...
  function validateInput (line 186) | function validateInput<T>(validator: (data: unknown) => data is T) {
  function rateLimitByIP (line 234) | function rateLimitByIP(_limit: number, _windowSeconds: number) {

FILE: tmarks/migrations/0001_d1_console.sql
  type users (line 1) | CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT...
  type idx_users_username_lower (line 2) | CREATE INDEX IF NOT EXISTS idx_users_username_lower ON users(LOWER(usern...
  type idx_users_email_lower (line 3) | CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users(LOWER(email))
  type idx_users_role (line 4) | CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)
  type idx_users_public_slug (line 5) | CREATE UNIQUE INDEX IF NOT EXISTS idx_users_public_slug ON users(public_...
  type auth_tokens (line 6) | CREATE TABLE IF NOT EXISTS auth_tokens (id INTEGER PRIMARY KEY AUTOINCRE...
  type idx_auth_tokens_user_id (line 7) | CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON auth_tokens(user_id)
  type idx_auth_tokens_hash (line 8) | CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(refresh_t...
  type idx_auth_tokens_expires (line 9) | CREATE INDEX IF NOT EXISTS idx_auth_tokens_expires ON auth_tokens(expire...
  type bookmarks (line 10) | CREATE TABLE IF NOT EXISTS bookmarks (id TEXT PRIMARY KEY, user_id TEXT ...
  type idx_bookmarks_user_created (line 11) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_created ON bookmarks(user_...
  type idx_bookmarks_user_url (line 12) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_url ON bookmarks(user_id, ...
  type idx_bookmarks_url (line 13) | CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url)
  type idx_bookmarks_user_deleted (line 14) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_deleted ON bookmarks(user_...
  type idx_bookmarks_pinned (line 15) | CREATE INDEX IF NOT EXISTS idx_bookmarks_pinned ON bookmarks(user_id, is...
  type idx_bookmarks_click_count (line 16) | CREATE INDEX IF NOT EXISTS idx_bookmarks_click_count ON bookmarks(user_i...
  type idx_bookmarks_last_clicked (line 17) | CREATE INDEX IF NOT EXISTS idx_bookmarks_last_clicked ON bookmarks(user_...
  type idx_bookmarks_user_archived_created (line 18) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_created ON bookma...
  type idx_bookmarks_user_archived_updated (line 19) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_updated ON bookma...
  type idx_bookmarks_user_archived_pinned_created (line 20) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_created ON...
  type idx_bookmarks_user_archived_pinned_updated (line 21) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_updated ON...
  type idx_bookmarks_user_archived_pinned_clicks (line 22) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_clicks ON ...
  type idx_bookmarks_user_deleted_created (line 23) | CREATE INDEX IF NOT EXISTS idx_bookmarks_user_deleted_created ON bookmar...
  type idx_bookmarks_has_snapshot (line 24) | CREATE INDEX IF NOT EXISTS idx_bookmarks_has_snapshot ON bookmarks(user_...
  type idx_bookmarks_cover_image_id (line 25) | CREATE INDEX IF NOT EXISTS idx_bookmarks_cover_image_id ON bookmarks(cov...
  type tags (line 26) | CREATE TABLE IF NOT EXISTS tags (id TEXT PRIMARY KEY, user_id TEXT NOT N...
  type idx_tags_user_name (line 27) | CREATE INDEX IF NOT EXISTS idx_tags_user_name ON tags(user_id, LOWER(name))
  type idx_tags_user_deleted (line 28) | CREATE INDEX IF NOT EXISTS idx_tags_user_deleted ON tags(user_id, delete...
  type idx_tags_click_count (line 29) | CREATE INDEX IF NOT EXISTS idx_tags_click_count ON tags(user_id, click_c...
  type idx_tags_last_clicked (line 30) | CREATE INDEX IF NOT EXISTS idx_tags_last_clicked ON tags(user_id, last_c...
  type bookmark_tags (line 31) | CREATE TABLE IF NOT EXISTS bookmark_tags (bookmark_id TEXT NOT NULL, tag...
  type idx_bookmark_tags_tag_user (line 32) | CREATE INDEX IF NOT EXISTS idx_bookmark_tags_tag_user ON bookmark_tags(t...
  type idx_bookmark_tags_bookmark (line 33) | CREATE INDEX IF NOT EXISTS idx_bookmark_tags_bookmark ON bookmark_tags(b...
  type bookmark_snapshots (line 34) | CREATE TABLE IF NOT EXISTS bookmark_snapshots (id TEXT PRIMARY KEY, book...
  type idx_bookmark_snapshots_bookmark_id (line 35) | CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_id ON bookmar...
  type idx_bookmark_snapshots_user_id (line 36) | CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_user_id ON bookmark_sn...
  type idx_bookmark_snapshots_created_at (line 37) | CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_created_at ON bookmark...
  type idx_bookmark_snapshots_content_hash (line 38) | CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_content_hash ON bookma...
  type idx_bookmark_snapshots_bookmark_latest (line 39) | CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_latest ON boo...
  type idx_bookmark_snapshots_bookmark_version (line 40) | CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_version ON bo...
  type bookmark_images (line 41) | CREATE TABLE IF NOT EXISTS bookmark_images (id TEXT PRIMARY KEY, bookmar...
  type idx_bookmark_images_bookmark_id (line 42) | CREATE INDEX IF NOT EXISTS idx_bookmark_images_bookmark_id ON bookmark_i...
  type idx_bookmark_images_user_id (line 43) | CREATE INDEX IF NOT EXISTS idx_bookmark_images_user_id ON bookmark_image...
  type idx_bookmark_images_hash (line 44) | CREATE INDEX IF NOT EXISTS idx_bookmark_images_hash ON bookmark_images(i...
  type idx_bookmark_images_created_at (line 45) | CREATE INDEX IF NOT EXISTS idx_bookmark_images_created_at ON bookmark_im...
  type user_preferences (line 46) | CREATE TABLE IF NOT EXISTS user_preferences (user_id TEXT PRIMARY KEY, t...
  type api_keys (line 47) | CREATE TABLE IF NOT EXISTS api_keys (id TEXT PRIMARY KEY, user_id TEXT N...
  type idx_api_keys_user (line 48) | CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)
  type idx_api_keys_hash (line 49) | CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)
  type idx_api_keys_status (line 50) | CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(user_id, status)
  type api_key_logs (line 51) | CREATE TABLE IF NOT EXISTS api_key_logs (id INTEGER PRIMARY KEY AUTOINCR...
  type idx_api_logs_key (line 52) | CREATE INDEX IF NOT EXISTS idx_api_logs_key ON api_key_logs(api_key_id, ...
  type idx_api_logs_user (line 53) | CREATE INDEX IF NOT EXISTS idx_api_logs_user ON api_key_logs(user_id, cr...
  type tab_groups (line 54) | CREATE TABLE IF NOT EXISTS tab_groups (id TEXT PRIMARY KEY, user_id TEXT...
  type idx_tab_groups_user_created (line 55) | CREATE INDEX IF NOT EXISTS idx_tab_groups_user_created ON tab_groups(use...
  type idx_tab_groups_user_id (line 56) | CREATE INDEX IF NOT EXISTS idx_tab_groups_user_id ON tab_groups(user_id)
  type idx_tab_groups_parent_id (line 57) | CREATE INDEX IF NOT EXISTS idx_tab_groups_parent_id ON tab_groups(parent...
  type idx_tab_groups_is_folder (line 58) | CREATE INDEX IF NOT EXISTS idx_tab_groups_is_folder ON tab_groups(is_fol...
  type idx_tab_groups_user_parent (line 59) | CREATE INDEX IF NOT EXISTS idx_tab_groups_user_parent ON tab_groups(user...
  type idx_tab_groups_parent_position (line 60) | CREATE INDEX IF NOT EXISTS idx_tab_groups_parent_position ON tab_groups(...
  type idx_tab_groups_user_parent_position (line 61) | CREATE INDEX IF NOT EXISTS idx_tab_groups_user_parent_position ON tab_gr...
  type idx_tab_groups_deleted (line 62) | CREATE INDEX IF NOT EXISTS idx_tab_groups_deleted ON tab_groups(user_id,...
  type tab_group_items (line 63) | CREATE TABLE IF NOT EXISTS tab_group_items (id TEXT PRIMARY KEY, group_i...
  type idx_tab_group_items_group_id (line 64) | CREATE INDEX IF NOT EXISTS idx_tab_group_items_group_id ON tab_group_ite...
  type idx_tab_group_items_group_created (line 65) | CREATE INDEX IF NOT EXISTS idx_tab_group_items_group_created ON tab_grou...
  type idx_tab_group_items_pinned (line 66) | CREATE INDEX IF NOT EXISTS idx_tab_group_items_pinned ON tab_group_items...
  type idx_tab_group_items_archived (line 67) | CREATE INDEX IF NOT EXISTS idx_tab_group_items_archived ON tab_group_ite...
  type idx_tab_group_items_not_archived (line 68) | CREATE INDEX IF NOT EXISTS idx_tab_group_items_not_archived ON tab_group...
  type shares (line 69) | CREATE TABLE IF NOT EXISTS shares (id TEXT PRIMARY KEY, group_id TEXT NO...
  type idx_shares_token (line 70) | CREATE INDEX IF NOT EXISTS idx_shares_token ON shares(share_token)
  type idx_shares_group_id (line 71) | CREATE INDEX IF NOT EXISTS idx_shares_group_id ON shares(group_id)
  type idx_shares_user_id (line 72) | CREATE INDEX IF NOT EXISTS idx_shares_user_id ON shares(user_id)
  type statistics (line 73) | CREATE TABLE IF NOT EXISTS statistics (id TEXT PRIMARY KEY, user_id TEXT...
  type idx_statistics_user_date (line 74) | CREATE UNIQUE INDEX IF NOT EXISTS idx_statistics_user_date ON statistics...
  type idx_statistics_user_id (line 75) | CREATE INDEX IF NOT EXISTS idx_statistics_user_id ON statistics(user_id)
  type idx_statistics_date (line 76) | CREATE INDEX IF NOT EXISTS idx_statistics_date ON statistics(stat_date)
  type audit_logs (line 77) | CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREM...
  type idx_audit_logs_user (line 78) | CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id, cr...
  type idx_audit_logs_event (line 79) | CREATE INDEX IF NOT EXISTS idx_audit_logs_event ON audit_logs(event_type...
  type idx_audit_logs_created (line 80) | CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_...
  type registration_limits (line 81) | CREATE TABLE IF NOT EXISTS registration_limits (date TEXT PRIMARY KEY, c...
  type bookmark_click_events (line 82) | CREATE TABLE IF NOT EXISTS bookmark_click_events (id INTEGER PRIMARY KEY...
  type idx_bookmark_click_events_user_clicked_at (line 83) | CREATE INDEX IF NOT EXISTS idx_bookmark_click_events_user_clicked_at ON ...
  type idx_bookmark_click_events_bookmark_clicked_at (line 84) | CREATE INDEX IF NOT EXISTS idx_bookmark_click_events_bookmark_clicked_at...
  type schema_migrations (line 86) | CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, ...

FILE: tmarks/migrations/0002_d1_console_ai_settings.sql
  type ai_settings (line 1) | CREATE TABLE IF NOT EXISTS ai_settings (
  type idx_ai_settings_user_id (line 16) | CREATE INDEX IF NOT EXISTS idx_ai_settings_user_id ON ai_settings(user_id)

FILE: tmarks/migrations/0103_api_key_rate_limits.sql
  type api_key_rate_limits (line 1) | CREATE TABLE IF NOT EXISTS api_key_rate_limits (
  type idx_api_key_rate_limits_updated_at (line 10) | CREATE INDEX IF NOT EXISTS idx_api_key_rate_limits_updated_at ON api_key...

FILE: tmarks/migrations/0104_rate_limits.sql
  type rate_limits (line 1) | CREATE TABLE IF NOT EXISTS rate_limits (
  type idx_rate_limits_updated_at (line 10) | CREATE INDEX IF NOT EXISTS idx_rate_limits_updated_at ON rate_limits(upd...

FILE: tmarks/scripts/auto-migrate.js
  constant MIGRATIONS_DIR (line 24) | const MIGRATIONS_DIR = join(__dirname, '../migrations')
  constant MIGRATION_HISTORY_FILE (line 25) | const MIGRATION_HISTORY_FILE = join(__dirname, '../.migration-history.js...
  function log (line 37) | function log(message, color = 'reset') {
  function getMigrationHistory (line 42) | function getMigrationHistory() {
  function saveMigrationHistory (line 55) | function saveMigrationHistory(history) {
  function getMigrationFiles (line 60) | function getMigrationFiles() {
  function executeMigration (line 72) | function executeMigration(filename, isLocal = false) {
  function main (line 111) | function main() {

FILE: tmarks/scripts/check-db-schema.js
  function executeQuery (line 71) | function executeQuery(query) {
  function checkTable (line 81) | function checkTable(tableName) {
  function checkTableFields (line 94) | function checkTableFields(tableName, requiredFields) {
  function checkMigrations (line 124) | function checkMigrations() {

FILE: tmarks/scripts/check-migrations.js
  constant MIGRATIONS_DIR (line 15) | const MIGRATIONS_DIR = join(__dirname, '../migrations')
  constant MIGRATION_HISTORY_FILE (line 16) | const MIGRATION_HISTORY_FILE = join(__dirname, '../.migration-history.js...
  function log (line 26) | function log(message, color = 'reset') {
  function getMigrationHistory (line 31) | function getMigrationHistory() {
  function getMigrationFiles (line 43) | function getMigrationFiles() {
  function main (line 54) | function main() {

FILE: tmarks/scripts/prepare-deploy.js
  function copyDir (line 49) | function copyDir(src, dest) {

FILE: tmarks/shared/import-export-types.ts
  type ExportBookmark (line 8) | interface ExportBookmark {
  type ExportTag (line 30) | interface ExportTag {
  type ExportUser (line 42) | interface ExportUser {
  type ExportTabGroupItem (line 49) | interface ExportTabGroupItem {
  type ExportTabGroup (line 61) | interface ExportTabGroup {
  type ExportFormat (line 78) | type ExportFormat = 'json'
  type TMarksExportData (line 80) | interface TMarksExportData {
  type ImportFormat (line 99) | type ImportFormat = 'html' | 'json' | 'tmarks'
  type ParsedBookmark (line 101) | interface ParsedBookmark {
  type ParsedTag (line 111) | interface ParsedTag {
  type ParsedTabGroupItem (line 116) | interface ParsedTabGroupItem {
  type ParsedTabGroup (line 128) | interface ParsedTabGroup {
  type ImportData (line 141) | interface ImportData {
  type ImportResult (line 155) | interface ImportResult {
  type ExportResult (line 168) | interface ExportResult {
  type ImportError (line 177) | interface ImportError {
  type ImportErrorCode (line 184) | type ImportErrorCode =
  type ProgressInfo (line 195) | interface ProgressInfo {
  type ProgressStatus (line 204) | type ProgressStatus =
  type ImportOptions (line 216) | interface ImportOptions {
  type ExportOptions (line 226) | interface ExportOptions {
  type ExportRequest (line 238) | interface ExportRequest {
  type ImportRequest (line 243) | interface ImportRequest {
  type ImportProgressResponse (line 249) | interface ImportProgressResponse {
  type ExportProgressResponse (line 254) | interface ExportProgressResponse {
  type ImportParser (line 261) | interface ImportParser {
  type ValidationResult (line 267) | interface ValidationResult {
  type ValidationError (line 273) | interface ValidationError {
  type ValidationWarning (line 279) | interface ValidationWarning {
  type Exporter (line 287) | interface Exporter {
  type ExportOutput (line 292) | interface ExportOutput {
  type DateParser (line 301) | type DateParser = (dateString: string) => Date | null
  type URLValidator (line 302) | type URLValidator = (url: string) => boolean
  type TagNormalizer (line 303) | type TagNormalizer = (tagName: string) => string
  constant SUPPORTED_IMPORT_FORMATS (line 307) | const SUPPORTED_IMPORT_FORMATS: ImportFormat[] = ['html', 'json', 'tmarks']
  constant SUPPORTED_EXPORT_FORMATS (line 308) | const SUPPORTED_EXPORT_FORMATS: ExportFormat[] = ['json']
  constant DEFAULT_IMPORT_OPTIONS (line 310) | const DEFAULT_IMPORT_OPTIONS: ImportOptions = {
  constant DEFAULT_EXPORT_OPTIONS (line 320) | const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
  constant EXPORT_VERSION (line 330) | const EXPORT_VERSION = '1.0.0'

FILE: tmarks/shared/permissions.ts
  constant PERMISSIONS (line 9) | const PERMISSIONS = {
  type Permission (line 40) | type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS]
  constant PERMISSION_TEMPLATES (line 45) | const PERMISSION_TEMPLATES = {
  type PermissionTemplate (line 82) | type PermissionTemplate = keyof typeof PERMISSION_TEMPLATES
  function hasPermission (line 90) | function hasPermission(
  constant PERMISSION_I18N_KEYS (line 111) | const PERMISSION_I18N_KEYS: Record<string, string> = {
  function getPermissionI18nKey (line 138) | function getPermissionI18nKey(permission: string): string {
  function getPermissionLabel (line 148) | function getPermissionLabel(permission: string, t?: (key: string) => str...
  constant PERMISSION_GROUP_I18N_KEYS (line 160) | const PERMISSION_GROUP_I18N_KEYS = {
  function getPermissionGroups (line 170) | function getPermissionGroups(t?: (key: string) => string): Array<{

FILE: tmarks/src/App.tsx
  function App (line 13) | function App() {

FILE: tmarks/src/components/api-keys/ApiKeyCard.tsx
  type ApiKeyCardProps (line 11) | interface ApiKeyCardProps {
  function ApiKeyCard (line 18) | function ApiKeyCard({ apiKey, onViewDetails, onRevoke, onDelete }: ApiKe...

FILE: tmarks/src/components/api-keys/ApiKeyDetailModal.tsx
  type ApiKeyDetailModalProps (line 14) | interface ApiKeyDetailModalProps {
  function ApiKeyDetailModal (line 19) | function ApiKeyDetailModal({ apiKey, onClose }: ApiKeyDetailModalProps) {

FILE: tmarks/src/components/api-keys/CreateApiKeyModal.tsx
  type CreateApiKeyModalProps (line 17) | interface CreateApiKeyModalProps {
  type Step (line 21) | type Step = 'basic' | 'permissions' | 'expiration' | 'success'
  function CreateApiKeyModal (line 23) | function CreateApiKeyModal({ onClose }: CreateApiKeyModalProps) {

FILE: tmarks/src/components/api-keys/StepBasicInfo.tsx
  type StepBasicInfoProps (line 4) | interface StepBasicInfoProps {
  function StepBasicInfo (line 12) | function StepBasicInfo({

FILE: tmarks/src/components/api-keys/StepPermissions.tsx
  type StepPermissionsProps (line 9) | interface StepPermissionsProps {
  function StepPermissions (line 17) | function StepPermissions({

FILE: tmarks/src/components/api-keys/StepSuccess.tsx
  type StepSuccessProps (line 5) | interface StepSuccessProps {
  function StepSuccess (line 10) | function StepSuccess({ createdKey, onClose }: StepSuccessProps) {

FILE: tmarks/src/components/auth/ProtectedRoute.tsx
  function ProtectedRoute (line 5) | function ProtectedRoute() {

FILE: tmarks/src/components/bookmarks/BatchActionBar.tsx
  type BatchActionBarProps (line 10) | interface BatchActionBarProps {
  function BatchActionBar (line 16) | function BatchActionBar({

FILE: tmarks/src/components/bookmarks/BookmarkCardView.tsx
  type BookmarkCardViewProps (line 14) | interface BookmarkCardViewProps {
  function BookmarkCardView (line 23) | function BookmarkCardView({
  type BookmarkCardProps (line 47) | interface BookmarkCardProps {
  function BookmarkCard (line 57) | function BookmarkCard({

FILE: tmarks/src/components/bookmarks/BookmarkForm.tsx
  type BookmarkFormProps (line 8) | interface BookmarkFormProps {
  function BookmarkForm (line 14) | function BookmarkForm({ bookmark, onClose, onSuccess }: BookmarkFormProp...

FILE: tmarks/src/components/bookmarks/BookmarkListContainer.tsx
  type BookmarkListContainerProps (line 8) | interface BookmarkListContainerProps {
  function BookmarkListContainer (line 20) | function BookmarkListContainer({

FILE: tmarks/src/components/bookmarks/BookmarkListItem.tsx
  type BookmarkListItemProps (line 10) | interface BookmarkListItemProps {

FILE: tmarks/src/components/bookmarks/BookmarkListLayout.tsx
  type BookmarkListLayoutProps (line 19) | interface BookmarkListLayoutProps {
  function BookmarkListLayout (line 81) | function BookmarkListLayout({
  function FullScreenContent (line 169) | function FullScreenContent(props: {
  function FlowContent (line 228) | function FlowContent(props: {

FILE: tmarks/src/components/bookmarks/BookmarkListView.tsx
  type BookmarkListViewProps (line 7) | interface BookmarkListViewProps {
  function BookmarkListView (line 20) | function BookmarkListView({

FILE: tmarks/src/components/bookmarks/BookmarkMinimalListView.tsx
  type BookmarkMinimalListViewProps (line 5) | interface BookmarkMinimalListViewProps {
  function BookmarkMinimalListView (line 14) | function BookmarkMinimalListView({
  type MinimalRowProps (line 51) | interface MinimalRowProps {
  function MinimalRow (line 60) | function MinimalRow({

FILE: tmarks/src/components/bookmarks/BookmarkTitleView.tsx
  type BookmarkTitleViewProps (line 12) | interface BookmarkTitleViewProps {
  function BookmarkTitleView (line 21) | function BookmarkTitleView({
  type TitleOnlyCardProps (line 51) | interface TitleOnlyCardProps {
  function TitleOnlyCard (line 61) | function TitleOnlyCard({
  function FaviconIcon (line 149) | function FaviconIcon(props: {

FILE: tmarks/src/components/bookmarks/DefaultBookmarkIcon.tsx
  type DefaultBookmarkIconProps (line 3) | interface DefaultBookmarkIconProps {
  function DefaultBookmarkIconComponent (line 8) | function DefaultBookmarkIconComponent({

FILE: tmarks/src/components/bookmarks/SnapshotViewer.tsx
  type SnapshotViewerProps (line 11) | interface SnapshotViewerProps {
  function SnapshotViewer (line 26) | function SnapshotViewer({ bookmarkId, bookmarkTitle, snapshotCount = 0 }...

FILE: tmarks/src/components/bookmarks/TagSelector.tsx
  type TagSelectorProps (line 5) | interface TagSelectorProps {
  function TagSelector (line 15) | function TagSelector({

FILE: tmarks/src/components/bookmarks/defaultIconOptions.ts
  constant DEFAULT_ICON_OPTIONS (line 4) | const DEFAULT_ICON_OPTIONS: Array<{ value: DefaultBookmarkIcon; label: s...

FILE: tmarks/src/components/bookmarks/hooks/useBookmarkFormState.ts
  function useBookmarkFormState (line 4) | function useBookmarkFormState(bookmark?: Bookmark | null) {

FILE: tmarks/src/components/bookmarks/shared/BookmarkActions.tsx
  type BatchCheckboxProps (line 7) | interface BatchCheckboxProps {
  function BatchCheckbox (line 14) | function BatchCheckbox({ bookmarkId, isSelected, onToggleSelect, size = ...
  type EditButtonProps (line 38) | interface EditButtonProps {
  function EditButton (line 43) | function EditButton({ onEdit, showHint }: EditButtonProps) {

FILE: tmarks/src/components/bookmarks/shared/BookmarkTagList.tsx
  type BookmarkTagListProps (line 8) | interface BookmarkTagListProps {
  function BookmarkTagList (line 13) | function BookmarkTagList({ bookmark, maxTags = 4 }: BookmarkTagListProps) {

FILE: tmarks/src/components/bookmarks/shared/MasonryGrid.tsx
  type MasonryGridProps (line 10) | interface MasonryGridProps {
  function MasonryGrid (line 24) | function MasonryGrid({

FILE: tmarks/src/components/bookmarks/shared/useFaviconFallback.ts
  function useFaviconFallback (line 7) | function useFaviconFallback(bookmark: Bookmark) {

FILE: tmarks/src/components/bookmarks/shared/useResponsiveColumns.ts
  type ColumnConfig (line 3) | interface ColumnConfig {
  function useResponsiveColumns (line 13) | function useResponsiveColumns(
  function useEditHintVisibility (line 51) | function useEditHintVisibility() {

FILE: tmarks/src/components/bookmarks/useBookmarkForm.ts
  type UseBookmarkFormProps (line 9) | interface UseBookmarkFormProps {
  function useBookmarkForm (line 16) | function useBookmarkForm({ bookmark, onClose, onSuccess, tags }: UseBook...

FILE: tmarks/src/components/bookmarks/useSnapshots.ts
  type Snapshot (line 8) | interface Snapshot {
  constant SNAPSHOTS_QUERY_KEY (line 17) | const SNAPSHOTS_QUERY_KEY = 'snapshots';
  function useSnapshots (line 19) | function useSnapshots(bookmarkId: string) {

FILE: tmarks/src/components/common/AdaptiveImage.tsx
  type AdaptiveImageProps (line 4) | interface AdaptiveImageProps {

FILE: tmarks/src/components/common/AlertDialog.tsx
  type AlertDialogProps (line 6) | interface AlertDialogProps {
  function AlertDialog (line 15) | function AlertDialog({

FILE: tmarks/src/components/common/BookmarkIcons.tsx
  function ViewModeIcon (line 17) | function ViewModeIcon({ mode, className }: { mode: ViewMode, className?:...
  function VisibilityIcon (line 33) | function VisibilityIcon({ filter, className }: { filter: VisibilityFilte...
  function SortIcon (line 47) | function SortIcon({ sort, className }: { sort: SortOption, className?: s...

FILE: tmarks/src/components/common/BottomNav.tsx
  type NavItem (line 5) | interface NavItem {
  function BottomNav (line 29) | function BottomNav() {

FILE: tmarks/src/components/common/CircularProgress.tsx
  type CircularProgressProps (line 3) | interface CircularProgressProps {
  function CircularProgress (line 16) | function CircularProgress({

FILE: tmarks/src/components/common/ColorThemeSelector.tsx
  function ColorThemeSelector (line 4) | function ColorThemeSelector() {

FILE: tmarks/src/components/common/ConfirmDialog.tsx
  type ConfirmDialogProps (line 6) | interface ConfirmDialogProps {
  function ConfirmDialog (line 17) | function ConfirmDialog({

FILE: tmarks/src/components/common/DialogHost.tsx
  function DialogHost (line 5) | function DialogHost() {

FILE: tmarks/src/components/common/DragDropUpload.tsx
  type DragDropUploadProps (line 10) | interface DragDropUploadProps {
  type UploadState (line 19) | interface UploadState {
  function DragDropUpload (line 25) | function DragDropUpload({
  function formatFileSize (line 261) | function formatFileSize(bytes: number): string {

FILE: tmarks/src/components/common/Drawer.tsx
  type DrawerProps (line 7) | interface DrawerProps {
  function Drawer (line 19) | function Drawer({ isOpen, onClose, children, title, side = 'left' }: Dra...

FILE: tmarks/src/components/common/DropdownMenu.tsx
  type MenuItem (line 5) | interface MenuItem {
  type DropdownMenuProps (line 14) | interface DropdownMenuProps {
  function DropdownMenu (line 20) | function DropdownMenu({ trigger, items, align = 'right' }: DropdownMenuP...

FILE: tmarks/src/components/common/ErrorBoundary.tsx
  type Props (line 4) | interface Props {
  type State (line 8) | interface State {
  class ErrorBoundary (line 13) | class ErrorBoundary extends Component<Props, State> {
    method constructor (line 14) | constructor(props: Props) {
    method getDerivedStateFromError (line 19) | static getDerivedStateFromError(error: Error): State {
    method componentDidCatch (line 23) | componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    method render (line 27) | render() {

FILE: tmarks/src/components/common/ErrorDisplay.tsx
  type ErrorItem (line 20) | interface ErrorItem {
  type ErrorDisplayProps (line 29) | interface ErrorDisplayProps {
  function ErrorDisplay (line 42) | function ErrorDisplay({
  type ErrorItemProps (line 197) | interface ErrorItemProps {
  function ErrorItem (line 206) | function ErrorItem({ error, variant, showDetails, onCopy, isCopied }: Er...
  function getDefaultTitle (line 280) | function getDefaultTitle(variant: string, count: number, t: (key: string...

FILE: tmarks/src/components/common/LanguageSelector.tsx
  type LanguageSelectorProps (line 4) | interface LanguageSelectorProps {
  function LanguageSelector (line 9) | function LanguageSelector({ className = '', showLabel = true }: Language...

FILE: tmarks/src/components/common/LazyImage.tsx
  type LazyImageProps (line 9) | interface LazyImageProps {

FILE: tmarks/src/components/common/MobileHeader.tsx
  type MobileHeaderProps (line 4) | interface MobileHeaderProps {
  function MobileHeader (line 17) | function MobileHeader({

FILE: tmarks/src/components/common/PaginationFooter.tsx
  type PaginationFooterProps (line 3) | interface PaginationFooterProps {
  function PaginationFooter (line 11) | function PaginationFooter({

FILE: tmarks/src/components/common/ProgressIndicator.tsx
  type ProgressInfo (line 11) | interface ProgressInfo {
  type ProgressIndicatorProps (line 21) | interface ProgressIndicatorProps {
  function ProgressIndicator (line 29) | function ProgressIndicator({
  function formatTime (line 210) | function formatTime(seconds: number, t: (key: string, options?: Record<s...

FILE: tmarks/src/components/common/ResizablePanel.tsx
  type ResizablePanelProps (line 5) | interface ResizablePanelProps {
  function ResizablePanel (line 14) | function ResizablePanel({

FILE: tmarks/src/components/common/SearchToolbar.tsx
  type SearchToolbarProps (line 14) | interface SearchToolbarProps {
  function SearchToolbar (line 33) | function SearchToolbar({
  function SearchInput (line 103) | function SearchInput({
  function ToolButton (line 138) | function ToolButton({

FILE: tmarks/src/components/common/SimpleProgress.tsx
  type SimpleProgressProps (line 3) | interface SimpleProgressProps {
  function SimpleProgress (line 14) | function SimpleProgress({

FILE: tmarks/src/components/common/SortSelector.tsx
  type SortOption (line 7) | type SortOption = 'created' | 'updated' | 'pinned' | 'popular'
  type SortSelectorProps (line 9) | interface SortSelectorProps {
  type SortOptionConfig (line 15) | interface SortOptionConfig {
  type MenuPosition (line 23) | interface MenuPosition {
  constant SORT_OPTIONS (line 29) | const SORT_OPTIONS: SortOptionConfig[] = [
  function SortSelector (line 62) | function SortSelector({ value, onChange, className = '' }: SortSelectorP...

FILE: tmarks/src/components/common/ThemeToggle.tsx
  function ThemeToggle (line 4) | function ThemeToggle() {

FILE: tmarks/src/components/common/Toast.tsx
  type ToastType (line 6) | type ToastType = 'success' | 'error' | 'info' | 'warning'
  type ToastProps (line 8) | interface ToastProps {
  constant ICONS (line 16) | const ICONS = {
  constant COLORS (line 23) | const COLORS = {
  function Toast (line 54) | function Toast({ id, type, message, duration = 3000, onClose }: ToastPro...
  function ToastContainer (line 88) | function ToastContainer({ toasts, onClose }: { toasts: ToastProps[]; onC...

FILE: tmarks/src/components/common/Toggle.tsx
  type ToggleProps (line 6) | interface ToggleProps {
  function Toggle (line 14) | function Toggle({ checked, onChange, disabled = false, label, descriptio...

FILE: tmarks/src/components/common/progressUtils.ts
  function formatTime (line 7) | function formatTime(

FILE: tmarks/src/components/common/useAnimatedProgress.ts
  function useAnimatedProgress (line 7) | function useAnimatedProgress(percentage: number) {

FILE: tmarks/src/components/import-export/ExportOptionsForm.tsx
  type ExportScope (line 5) | type ExportScope = 'all' | 'bookmarks' | 'tab_groups'
  type ExportOptionsFormProps (line 7) | interface ExportOptionsFormProps {
  function ExportOptionsForm (line 17) | function ExportOptionsForm({

FILE: tmarks/src/components/import-export/ExportSection.tsx
  type ExportSectionProps (line 14) | interface ExportSectionProps {
  type ExportStats (line 18) | interface ExportStats {
  function ExportSection (line 26) | function ExportSection({ onExport }: ExportSectionProps) {
  function formatFileSize (line 259) | function formatFileSize(bytes: number): string {

FILE: tmarks/src/components/layout/AppShell.tsx
  function AppShell (line 11) | function AppShell() {

FILE: tmarks/src/components/layout/FullScreenAppShell.tsx
  function FullScreenAppShell (line 11) | function FullScreenAppShell() {

FILE: tmarks/src/components/layout/MobileBottomNav.tsx
  type NavItem (line 11) | interface NavItem {
  function MobileBottomNav (line 19) | function MobileBottomNav() {

FILE: tmarks/src/components/layout/PublicAppShell.tsx
  function PublicAppShell (line 9) | function PublicAppShell() {

FILE: tmarks/src/components/layout/ShellHeader.tsx
  function ShellHeader (line 4) | function ShellHeader({

FILE: tmarks/src/components/layout/ThemedRoot.tsx
  function ThemedRoot (line 4) | function ThemedRoot({

FILE: tmarks/src/components/settings/InfoBox.tsx
  type InfoBoxProps (line 4) | interface InfoBoxProps {
  function InfoBox (line 11) | function InfoBox({ icon: Icon, title, children, variant = 'info' }: Info...

FILE: tmarks/src/components/settings/SearchAutoClearSettings.tsx
  type SearchAutoClearSettingsProps (line 3) | interface SearchAutoClearSettingsProps {
  function SearchAutoClearSettings (line 10) | function SearchAutoClearSettings({

FILE: tmarks/src/components/settings/SettingsNav.tsx
  type SettingsNavItem (line 10) | interface SettingsNavItem {
  type SettingsNavGroup (line 16) | interface SettingsNavGroup {
  type SettingsNavProps (line 21) | interface SettingsNavProps {
  function SettingsNav (line 28) | function SettingsNav({ groups, activeSection, onSectionChange, children ...

FILE: tmarks/src/components/settings/SettingsSaveBar.tsx
  type SettingsSaveBarProps (line 9) | interface SettingsSaveBarProps {
  function SettingsSaveBar (line 16) | function SettingsSaveBar({ isDirty, isSaving, onSave, onDiscard }: Setti...

FILE: tmarks/src/components/settings/SettingsSection.tsx
  type SettingsSectionProps (line 9) | interface SettingsSectionProps {
  function SettingsSection (line 17) | function SettingsSection({ title, description, icon: Icon, children, cla...
  type SettingsItemProps (line 36) | interface SettingsItemProps {
  function SettingsItem (line 45) | function SettingsItem({ title, description, icon: Icon, iconColor = 'tex...
  function SettingsDivider (line 63) | function SettingsDivider() {

FILE: tmarks/src/components/settings/SettingsTips.tsx
  type SettingsTipsProps (line 3) | interface SettingsTipsProps {
  function SettingsTips (line 7) | function SettingsTips({ tips }: SettingsTipsProps) {

FILE: tmarks/src/components/settings/TagSelectionAutoClearSettings.tsx
  type TagSelectionAutoClearSettingsProps (line 3) | interface TagSelectionAutoClearSettingsProps {
  function TagSelectionAutoClearSettings (line 10) | function TagSelectionAutoClearSettings({

FILE: tmarks/src/components/settings/tabs/ApiSettingsTab.tsx
  function ApiSettingsTab (line 18) | function ApiSettingsTab() {

FILE: tmarks/src/components/settings/tabs/AutomationSettingsTab.tsx
  type AutomationSettingsTabProps (line 12) | interface AutomationSettingsTabProps {
  function AutomationSettingsTab (line 23) | function AutomationSettingsTab({

FILE: tmarks/src/components/settings/tabs/BasicSettingsTab.tsx
  function BasicSettingsTab (line 15) | function BasicSettingsTab() {
  function PasswordInput (line 194) | function PasswordInput({

FILE: tmarks/src/components/settings/tabs/BrowserSettingsTab.tsx
  type BrowserType (line 13) | type BrowserType = 'chrome' | 'edge' | 'opera' | 'brave' | '360' | 'qq' ...
  function BrowserSettingsTab (line 15) | function BrowserSettingsTab() {

FILE: tmarks/src/components/settings/tabs/DataSettingsTab.tsx
  function runWithConcurrency (line 20) | async function runWithConcurrency<T>(
  function DataSettingsTab (line 38) | function DataSettingsTab() {

FILE: tmarks/src/components/settings/tabs/ShareSettingsTab.tsx
  function ShareSettingsTab (line 15) | function ShareSettingsTab() {

FILE: tmarks/src/components/settings/tabs/SnapshotSettingsTab.tsx
  type SnapshotSettingsTabProps (line 11) | interface SnapshotSettingsTabProps {
  function SnapshotSettingsTab (line 16) | function SnapshotSettingsTab({

FILE: tmarks/src/components/tab-groups/BatchActionBar.tsx
  type BatchActionBarProps (line 5) | interface BatchActionBarProps {
  function BatchActionBar (line 16) | function BatchActionBar({

FILE: tmarks/src/components/tab-groups/ColorPicker.tsx
  type ColorPickerProps (line 6) | interface ColorPickerProps {
  function ColorPicker (line 12) | function ColorPicker({ currentColor, onColorChange, onClose }: ColorPick...

FILE: tmarks/src/components/tab-groups/EmptyState.tsx
  type EmptyStateProps (line 4) | interface EmptyStateProps {
  function EmptyState (line 9) | function EmptyState({ isSearching, searchQuery }: EmptyStateProps) {

FILE: tmarks/src/components/tab-groups/InsertionIndicator.tsx
  type InsertionIndicatorProps (line 6) | interface InsertionIndicatorProps {
  function InsertionIndicator (line 10) | function InsertionIndicator({ position }: InsertionIndicatorProps) {

FILE: tmarks/src/components/tab-groups/MoveItemDialog.tsx
  type MoveItemDialogProps (line 9) | interface MoveItemDialogProps {
  function MoveItemDialog (line 18) | function MoveItemDialog({

FILE: tmarks/src/components/tab-groups/MoveToFolderDialog.tsx
  type MoveToFolderDialogProps (line 9) | interface MoveToFolderDialogProps {
  function MoveToFolderDialog (line 17) | function MoveToFolderDialog({

FILE: tmarks/src/components/tab-groups/PinnedItemsSection.tsx
  type PinnedItem (line 10) | interface PinnedItem extends TabGroupItem {
  type PinnedItemsSectionProps (line 15) | interface PinnedItemsSectionProps {
  function PinnedItemsSection (line 20) | function PinnedItemsSection({ tabGroups, onUnpin }: PinnedItemsSectionPr...

FILE: tmarks/src/components/tab-groups/SearchBar.tsx
  type SearchBarProps (line 7) | interface SearchBarProps {
  function SearchBar (line 16) | function SearchBar({

FILE: tmarks/src/components/tab-groups/ShareDialog.tsx
  type ShareDialogProps (line 12) | interface ShareDialogProps {
  function ShareDialog (line 18) | function ShareDialog({ groupId, groupTitle, onClose }: ShareDialogProps) {

FILE: tmarks/src/components/tab-groups/SortSelector.tsx
  type SortSelectorProps (line 5) | interface SortSelectorProps {
  function SortSelector (line 10) | function SortSelector({ value, onChange }: SortSelectorProps) {

FILE: tmarks/src/components/tab-groups/TabGroupCard.tsx
  type TabItem (line 9) | interface TabItem {
  type TabGroupCardProps (line 16) | interface TabGroupCardProps {
  function TabGroupCard (line 33) | function TabGroupCard({

FILE: tmarks/src/components/tab-groups/TabGroupHeader.tsx
  type TabGroupHeaderProps (line 9) | interface TabGroupHeaderProps {
  function TabGroupHeader (line 24) | function TabGroupHeader({

FILE: tmarks/src/components/tab-groups/TabGroupSidebar.tsx
  type TabGroupSidebarProps (line 6) | interface TabGroupSidebarProps {
  function TabGroupSidebar (line 12) | function TabGroupSidebar({

FILE: tmarks/src/components/tab-groups/TabGroupTree.tsx
  type TabGroupTreeProps (line 12) | interface TabGroupTreeProps {
  function TabGroupTree (line 22) | function TabGroupTree({

FILE: tmarks/src/components/tab-groups/TabItem.tsx
  type TabItemProps (line 9) | interface TabItemProps {
  function TabItem (line 29) | function TabItem({

FILE: tmarks/src/components/tab-groups/TabItemList.tsx
  type TabItemListProps (line 9) | interface TabItemListProps {
  function TabItemList (line 29) | function TabItemList({

FILE: tmarks/src/components/tab-groups/TagsInput.tsx
  type TagsInputProps (line 6) | interface TagsInputProps {
  function TagsInput (line 12) | function TagsInput({ tags, onTagsChange, onClose }: TagsInputProps) {
  function TagsList (line 116) | function TagsList({ tags }: { tags: string[] | null }) {

FILE: tmarks/src/components/tab-groups/TodoItemCard.tsx
  type TodoItemCardProps (line 8) | interface TodoItemCardProps {
  function TodoItemCard (line 29) | function TodoItemCard({

FILE: tmarks/src/components/tab-groups/TodoSidebar.tsx
  type TodoSidebarProps (line 13) | interface TodoSidebarProps {
  function TodoSidebar (line 17) | function TodoSidebar({ tabGroups }: TodoSidebarProps) {

FILE: tmarks/src/components/tab-groups/colorUtils.ts
  constant COLORS (line 6) | const COLORS = [
  constant COLOR_MAP (line 18) | const COLOR_MAP: Record<string, string> = {
  constant LEFT_BORDER_MAP (line 35) | const LEFT_BORDER_MAP: Record<string, string> = {
  function getColorClasses (line 52) | function getColorClasses(color: string | null): string {
  function getLeftBorderColor (line 57) | function getLeftBorderColor(color: string | null): string {

FILE: tmarks/src/components/tab-groups/sortUtils.ts
  type SortOption (line 13) | type SortOption = 'created' | 'title' | 'count'
  function sortTabGroupsForView (line 15) | function sortTabGroupsForView<T extends { title: string; created_at: str...

FILE: tmarks/src/components/tab-groups/tree/TreeNode.tsx
  type TreeNodeProps (line 7) | interface TreeNodeProps {
  function TreeNode (line 28) | function TreeNode({

FILE: tmarks/src/components/tab-groups/tree/TreeNodeContent.tsx
  type TreeNodeContentProps (line 18) | interface TreeNodeContentProps {
  function TreeNodeContent (line 42) | function TreeNodeContent({

FILE: tmarks/src/components/tab-groups/tree/TreeNodeMenu.tsx
  type TreeNodeMenuConfig (line 17) | interface TreeNodeMenuConfig {
  function buildTreeNodeMenu (line 28) | function buildTreeNodeMenu({

FILE: tmarks/src/components/tab-groups/tree/TreeNodeSimple.tsx
  type TreeNodeSimpleProps (line 10) | interface TreeNodeSimpleProps {
  function TreeNodeSimple (line 21) | function TreeNodeSimple({

FILE: tmarks/src/components/tab-groups/tree/TreeUtils.ts
  function getTotalItemCount (line 6) | function getTotalItemCount(group: TabGroup): number {
  function buildTree (line 25) | function buildTree(groups: TabGroup[]): TabGroup[] {

FILE: tmarks/src/components/tab-groups/tree/useDragAndDrop.ts
  type DropPosition (line 8) | type DropPosition = 'before' | 'inside' | 'after'
  type UseDragAndDropProps (line 10) | interface UseDragAndDropProps {
  function useDragAndDrop (line 15) | function useDragAndDrop({ tabGroups, onMoveGroup }: UseDragAndDropProps) {

FILE: tmarks/src/components/tags/TagControls.tsx
  type TagControlsProps (line 3) | interface TagControlsProps {
  function TagControls (line 12) | function TagControls({

FILE: tmarks/src/components/tags/TagFormModal.tsx
  type TagFormModalProps (line 5) | interface TagFormModalProps {
  function TagFormModal (line 17) | function TagFormModal({

FILE: tmarks/src/components/tags/TagItem.tsx
  type TagItemProps (line 6) | interface TagItemProps {
  function TagItem (line 15) | function TagItem({ tag, isSelected, isRelated, hasSelection, layout, onT...

FILE: tmarks/src/components/tags/TagManageModal.tsx
  type TagManageModalProps (line 11) | interface TagManageModalProps {
  function TagManageModal (line 16) | function TagManageModal({ tags, onClose }: TagManageModalProps) {

FILE: tmarks/src/components/tags/TagSidebar.tsx
  type TagSidebarProps (line 11) | interface TagSidebarProps {
  function TagSidebar (line 26) | function TagSidebar({

FILE: tmarks/src/components/tags/useTagFiltering.ts
  function useTagFiltering (line 7) | function useTagFiltering(

FILE: tmarks/src/hooks/buildTabOpenerHtml.ts
  type ThemeColors (line 5) | interface ThemeColors {
  type I18nStrings (line 15) | interface I18nStrings {
  type TabItem (line 25) | interface TabItem {
  function escapeHtml (line 30) | function escapeHtml(str: string): string {
  function escapeJsString (line 39) | function escapeJsString(str: string): string {
  function sanitizeCssValue (line 49) | function sanitizeCssValue(str: string): string {
  function getThemeColors (line 53) | function getThemeColors(): ThemeColors {
  function buildTabOpenerHtml (line 67) | function buildTabOpenerHtml(

FILE: tmarks/src/hooks/useAnimatedProgress.ts
  function useAnimatedProgress (line 3) | function useAnimatedProgress(percentage: number) {

FILE: tmarks/src/hooks/useApiKeys.ts
  function useApiKeys (line 31) | function useApiKeys() {
  function useApiKey (line 41) | function useApiKey(id: string) {
  function useApiKeyLogs (line 52) | function useApiKeyLogs(id: string, limit: number = 10) {
  function useCreateApiKey (line 63) | function useCreateApiKey() {
  function useUpdateApiKey (line 78) | function useUpdateApiKey() {
  function useRevokeApiKey (line 95) | function useRevokeApiKey() {
  function useDeleteApiKey (line 110) | function useDeleteApiKey() {

FILE: tmarks/src/hooks/useBatchActions.ts
  type UseBatchActionsProps (line 8) | interface UseBatchActionsProps {
  function useBatchActions (line 27) | function useBatchActions({

FILE: tmarks/src/hooks/useBookmarkFilters.ts
  function isValidViewMode (line 13) | function isValidViewMode(value: string | null): value is ViewMode {
  function getStoredViewMode (line 17) | function getStoredViewMode(): ViewMode | null {
  function getStoredViewModeUpdatedAt (line 23) | function getStoredViewModeUpdatedAt(): number {
  function setStoredViewMode (line 30) | function setStoredViewMode(mode: ViewMode, updatedAt?: number) {
  function useBookmarkFilters (line 39) | function useBookmarkFilters() {

FILE: tmarks/src/hooks/useBookmarks.ts
  constant BOOKMARKS_QUERY_KEY (line 10) | const BOOKMARKS_QUERY_KEY = 'bookmarks'
  function useBookmarks (line 20) | function useBookmarks(params?: BookmarkQueryParams, options?: { staleTim...
  function useInfiniteBookmarks (line 37) | function useInfiniteBookmarks(
  function useCreateBookmark (line 61) | function useCreateBookmark() {
  function useUpdateBookmark (line 81) | function useUpdateBookmark() {
  function useDeleteBookmark (line 101) | function useDeleteBookmark() {
  function useRestoreBookmark (line 120) | function useRestoreBookmark() {
  function useRecordClick (line 139) | function useRecordClick() {
  function useBatchAction (line 148) | function useBatchAction() {

FILE: tmarks/src/hooks/useClientSideFilter.ts
  type UseClientSideFilterOptions (line 11) | interface UseClientSideFilterOptions {
  type UseClientSideFilterResult (line 20) | interface UseClientSideFilterResult {
  function useClientSideFilter (line 33) | function useClientSideFilter({

FILE: tmarks/src/hooks/useLanguage.ts
  function useLanguage (line 9) | function useLanguage() {

FILE: tmarks/src/hooks/useLocalPreferences.ts
  constant PREFERENCES_STORAGE_KEY (line 5) | const PREFERENCES_STORAGE_KEY = 'tmarks:preferences'
  constant DEFAULT_PREFERENCES (line 8) | const DEFAULT_PREFERENCES: Partial<UserPreferences> = {
  function loadPreferencesFromStorage (line 26) | function loadPreferencesFromStorage(): Partial<UserPreferences> {
  function savePreferencesToStorage (line 45) | function savePreferencesToStorage(preferences: Partial<UserPreferences>)...
  function useLocalPreferences (line 59) | function useLocalPreferences() {

FILE: tmarks/src/hooks/useMediaQuery.ts
  function useMediaQuery (line 7) | function useMediaQuery(query: string): boolean {
  function useIsMobile (line 43) | function useIsMobile() {
  function useIsTablet (line 47) | function useIsTablet() {
  function useIsDesktop (line 51) | function useIsDesktop() {
  function useDeviceType (line 58) | function useDeviceType(): 'mobile' | 'tablet' | 'desktop' {

FILE: tmarks/src/hooks/usePreferences.ts
  constant PREFERENCES_QUERY_KEY (line 7) | const PREFERENCES_QUERY_KEY = 'preferences'
  constant PREFERENCES_STORAGE_KEY (line 8) | const PREFERENCES_STORAGE_KEY = 'tmarks:preferences'
  function getStoredPreferences (line 11) | function getStoredPreferences(): UserPreferences | null {
  function saveStoredPreferences (line 27) | function saveStoredPreferences(preferences: UserPreferences): void {
  function getStoredViewMode (line 38) | function getStoredViewMode(): 'list' | 'card' | 'minimal' | 'title' | nu...
  function getDefaultPreferences (line 46) | function getDefaultPreferences(): UserPreferences {
  function usePreferences (line 78) | function usePreferences() {
  function useUpdatePreferences (line 119) | function useUpdatePreferences() {

FILE: tmarks/src/hooks/useShare.ts
  constant SHARE_SETTINGS_QUERY_KEY (line 5) | const SHARE_SETTINGS_QUERY_KEY = 'share-settings'
  function useShareSettings (line 7) | function useShareSettings() {
  function useUpdateShareSettings (line 15) | function useUpdateShareSettings() {
  function usePublicShare (line 25) | function usePublicShare(slug: string, enabled: boolean) {
  function useInfinitePublicShare (line 34) | function useInfinitePublicShare(slug: string, enabled: boolean, pageSize...

FILE: tmarks/src/hooks/useStorage.ts
  function useR2StorageQuota (line 9) | function useR2StorageQuota() {

FILE: tmarks/src/hooks/useTabGroupActions.ts
  type UseTabGroupActionsProps (line 13) | interface UseTabGroupActionsProps {
  function useTabGroupActions (line 30) | function useTabGroupActions({

FILE: tmarks/src/hooks/useTabGroupItemActions.ts
  type UseTabGroupItemActionsProps (line 9) | interface UseTabGroupItemActionsProps {
  function useTabGroupItemActions (line 25) | function useTabGroupItemActions({

FILE: tmarks/src/hooks/useTabGroupMenu.ts
  type TabGroupMenuActions (line 7) | interface TabGroupMenuActions {
  type UseTabGroupMenuProps (line 24) | interface UseTabGroupMenuProps {
  function useTabGroupMenu (line 30) | function useTabGroupMenu({ onRefresh, onStartRename, onOpenMoveDialog }:...

FILE: tmarks/src/hooks/useTabGroupsQuery.ts
  constant TAB_GROUPS_QUERY_KEY (line 4) | const TAB_GROUPS_QUERY_KEY = ['tab-groups', 'all'] as const
  constant TAB_GROUPS_TRASH_QUERY_KEY (line 5) | const TAB_GROUPS_TRASH_QUERY_KEY = ['tab-groups', 'trash'] as const
  constant TAB_GROUP_DETAIL_QUERY_KEY (line 6) | const TAB_GROUP_DETAIL_QUERY_KEY = 'tab-group-detail'
  constant TAB_GROUPS_STATISTICS_QUERY_KEY (line 7) | const TAB_GROUPS_STATISTICS_QUERY_KEY = 'tab-groups-statistics'
  function useTabGroupsQuery (line 9) | function useTabGroupsQuery() {
  function useTabGroupsTrashQuery (line 22) | function useTabGroupsTrashQuery() {
  function useTabGroupDetailQuery (line 35) | function useTabGroupDetailQuery(groupId?: string) {
  function useTabGroupsStatisticsQuery (line 51) | function useTabGroupsStatisticsQuery(days: number) {
  function useInvalidateTabGroups (line 61) | function useInvalidateTabGroups() {

FILE: tmarks/src/hooks/useTags.ts
  constant TAGS_QUERY_KEY (line 5) | const TAGS_QUERY_KEY = 'tags'
  function useTags (line 10) | function useTags(
  function useCreateTag (line 27) | function useCreateTag() {
  function useUpdateTag (line 41) | function useUpdateTag() {
  function useDeleteTag (line 56) | function useDeleteTag() {

FILE: tmarks/src/i18n/index.ts
  type LanguageCode (line 34) | type LanguageCode = (typeof supportedLanguages)[number]['code']

FILE: tmarks/src/lib/ai/client.ts
  constant SYSTEM_PROMPT (line 9) | const SYSTEM_PROMPT = '你是一个智能书签整理助手。请根据用户的要求整理书签数据。返回格式必须是 JSON。'
  type AICallParams (line 12) | interface AICallParams {
  type AICallResult (line 24) | interface AICallResult {
  function resolveEndpoint (line 32) | function resolveEndpoint(baseUrl: string, endpoint: string): string {
  function buildOpenAIRequest (line 45) | function buildOpenAIRequest(params: AICallParams, options?: {
  function buildClaudeRequest (line 83) | function buildClaudeRequest(params: AICallParams) {
  function extractOpenAIContent (line 111) | function extractOpenAIContent(data: unknown): string | undefined {
  function extractClaudeContent (line 134) | function extractClaudeContent(data: unknown): string | undefined {
  function callAI (line 153) | async function callAI(params: AICallParams): Promise<AICallResult> {
  function testAIConnection (line 236) | async function testAIConnection(params: {

FILE: tmarks/src/lib/ai/constants.ts
  type AIProvider (line 6) | type AIProvider = 'openai' | 'deepseek' | 'claude' | 'siliconflow' | 'mo...
  constant AI_SERVICE_URLS (line 8) | const AI_SERVICE_URLS: Record<AIProvider, string> = {
  constant AI_DEFAULT_MODELS (line 17) | const AI_DEFAULT_MODELS: Record<AIProvider, string> = {
  constant AI_TIMEOUT (line 26) | const AI_TIMEOUT = 60_000

FILE: tmarks/src/lib/ai/models.ts
  constant OPENAI_COMPATIBLE_PROVIDERS (line 11) | const OPENAI_COMPATIBLE_PROVIDERS = new Set<AIProvider>([
  function fetchAvailableModels (line 101) | async function fetchAvailableModels(

FILE: tmarks/src/lib/api-client.ts
  constant API_BASE_URL (line 5) | const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
  class ApiError (line 7) | class ApiError extends Error {
    method constructor (line 8) | constructor(
  function subscribeToRefresh (line 24) | function subscribeToRefresh(): { promise: Promise<string>; unsubscribe: ...
  function onRefreshed (line 36) | function onRefreshed(token: string) {
  function rejectSubscribers (line 41) | function rejectSubscribers(error: Error) {
  class HttpClient (line 49) | class HttpClient {
    method constructor (line 52) | constructor(baseURL: string) {
    method getAuthToken (line 56) | private getAuthToken(): string | null {
    method request (line 60) | private async request<T>(
    method clearAuthAndRedirect (line 189) | private clearAuthAndRedirect() {
    method get (line 198) | async get<T>(endpoint: string, options?: RequestInit): Promise<ApiResp...
    method post (line 202) | async post<T>(endpoint: string, body?: unknown, options?: RequestInit)...
    method put (line 210) | async put<T>(endpoint: string, body?: unknown, options?: RequestInit):...
    method patch (line 218) | async patch<T>(endpoint: string, body?: unknown, options?: RequestInit...
    method delete (line 226) | async delete<T>(endpoint: string, options?: RequestInit): Promise<ApiR...

FILE: tmarks/src/lib/constants/bookmarks.ts
  constant VIEW_MODES (line 3) | const VIEW_MODES = ['list', 'card', 'minimal', 'title'] as const
  type ViewMode (line 4) | type ViewMode = typeof VIEW_MODES[number]
  constant SORT_OPTIONS (line 6) | const SORT_OPTIONS: SortOption[] = ['created', 'updated', 'pinned', 'pop...
  type VisibilityFilter (line 8) | type VisibilityFilter = 'all' | 'public' | 'private'
  constant VISIBILITY_FILTERS (line 9) | const VISIBILITY_FILTERS: VisibilityFilter[] = ['all', 'public', 'private']
  constant VIEW_MODE_STORAGE_KEY (line 11) | const VIEW_MODE_STORAGE_KEY = 'tmarks:view_mode'
  constant VIEW_MODE_UPDATED_AT_STORAGE_KEY (line 12) | const VIEW_MODE_UPDATED_AT_STORAGE_KEY = 'tmarks:view_mode_updated_at'

FILE: tmarks/src/lib/constants/z-index.ts
  constant Z_INDEX (line 15) | const Z_INDEX = {
  type ZIndexKey (line 54) | type ZIndexKey = keyof typeof Z_INDEX

FILE: tmarks/src/lib/image-utils.ts
  type ImageType (line 6) | type ImageType = 'favicon' | 'cover' | 'unknown'
  type ImageInfo (line 8) | interface ImageInfo {
  function loadImage (line 18) | function loadImage(url: string): Promise<HTMLImageElement> {
  function detectImageType (line 36) | function detectImageType(width: number, height: number): ImageType {
  function analyzeImage (line 60) | async function analyzeImage(url: string): Promise<ImageInfo> {
  function getImageClassName (line 84) | function getImageClassName(type: ImageType): string {

FILE: tmarks/src/lib/logger.ts
  type LogValue (line 8) | type LogValue = string | number | boolean | null | undefined | Error | R...

FILE: tmarks/src/lib/search-utils.ts
  function fastIncludes (line 8) | function fastIncludes(text: string, query: string): boolean {
  function searchInFields (line 27) | function searchInFields(fields: string[], query: string): boolean {

FILE: tmarks/src/lib/types/api.types.ts
  type ApiResponse (line 1) | interface ApiResponse<T = unknown> {
  type ApiError (line 13) | interface ApiError {

FILE: tmarks/src/lib/types/auth.types.ts
  type User (line 1) | interface User {
  type LoginRequest (line 9) | interface LoginRequest {
  type LoginResponse (line 15) | interface LoginResponse {
  type RegisterRequest (line 23) | interface RegisterRequest {
  type RegisterResponse (line 29) | interface RegisterResponse {
  type RefreshTokenRequest (line 33) | interface RefreshTokenRequest {
  type RefreshTokenResponse (line 37) | interface RefreshTokenResponse {

FILE: tmarks/src/lib/types/bookmark.types.ts
  type Tag (line 1) | interface Tag {
  type Bookmark (line 13) | interface Bookmark {
  type CreateBookmarkRequest (line 36) | interface CreateBookmarkRequest {
  type UpdateBookmarkRequest (line 48) | interface UpdateBookmarkRequest {
  type BookmarksResponse (line 60) | interface BookmarksResponse {
  type CreateTagRequest (line 71) | interface CreateTagRequest {
  type UpdateTagRequest (line 76) | interface UpdateTagRequest {
  type TagsResponse (line 81) | interface TagsResponse {
  type BookmarkQueryParams (line 85) | interface BookmarkQueryParams {
  type TagQueryParams (line 95) | interface TagQueryParams {
  type BatchActionType (line 99) | type BatchActionType = 'delete' | 'update_tags' | 'pin' | 'unpin' | 'arc...
  type BatchActionRequest (line 101) | interface BatchActionRequest {
  type BatchActionResponse (line 108) | interface BatchActionResponse {

FILE: tmarks/src/lib/types/preferences.types.ts
  type TagLayoutPreference (line 3) | type TagLayoutPreference = 'grid' | 'masonry'
  type SortByPreference (line 4) | type SortByPreference = 'created' | 'updated' | 'pinned' | 'popular'
  type DefaultBookmarkIcon (line 6) | type DefaultBookmarkIcon = 'favicon' | 'letter' | 'hash' | 'none' | 'orb...
  type UserPreferences (line 8) | interface UserPreferences {
  type UpdatePreferencesRequest (line 25) | interface UpdatePreferencesRequest {
  type PreferencesResponse (line 56) | interface PreferencesResponse {
  type ShareSettings (line 60) | interface ShareSettings {
  type ShareSettingsResponse (line 67) | interface ShareSettingsResponse {
  type R2StorageQuota (line 71) | interface R2StorageQuota {
  type R2StorageQuotaResponse (line 77) | interface R2StorageQuotaResponse {
  type UpdateShareSettingsRequest (line 81) | interface UpdateShareSettingsRequest {
  type PublicSharePayload (line 89) | interface PublicSharePayload {
  type PublicSharePaginatedPayload (line 101) | interface PublicSharePaginatedPayload {

FILE: tmarks/src/lib/types/tab-group.types.ts
  type TabGroupItem (line 1) | interface TabGroupItem {
  type TabGroup (line 14) | interface TabGroup {
  type CreateTabGroupRequest (line 32) | interface CreateTabGroupRequest {
  type UpdateTabGroupRequest (line 43) | interface UpdateTabGroupRequest {
  type TabGroupsResponse (line 51) | interface TabGroupsResponse {
  type TabGroupResponse (line 61) | interface TabGroupResponse {
  type Share (line 65) | interface Share {
  type ShareResponse (line 76) | interface ShareResponse {
  type StatisticsSummary (line 81) | interface StatisticsSummary {
  type TrendData (line 88) | interface TrendData {
  type DomainCount (line 93) | interface DomainCount {
  type GroupSizeDistribution (line 98) | interface GroupSizeDistribution {
  type StatisticsResponse (line 103) | interface StatisticsResponse {

FILE: tmarks/src/pages/auth/LoginPage.tsx
  function LoginPage (line 7) | function LoginPage() {

FILE: tmarks/src/pages/auth/RegisterPage.tsx
  function RegisterPage (line 7) | function RegisterPage() {

FILE: tmarks/src/pages/bookmarks/BookmarkStatisticsPage.tsx
  type BookmarkStatisticsPageProps (line 10) | interface BookmarkStatisticsPageProps {
  function BookmarkStatisticsPage (line 14) | function BookmarkStatisticsPage({ embedded = false }: BookmarkStatistics...

FILE: tmarks/src/pages/bookmarks/BookmarkTrashPage.tsx
  function BookmarkTrashPage (line 14) | function BookmarkTrashPage() {

FILE: tmarks/src/pages/bookmarks/BookmarksPage.tsx
  function BookmarksPage (line 15) | function BookmarksPage() {

FILE: tmarks/src/pages/bookmarks/TrashBookmarkItem.tsx
  type TrashBookmarkItemProps (line 7) | interface TrashBookmarkItemProps {
  function TrashBookmarkItem (line 13) | function TrashBookmarkItem({ bookmark, onRestore, onDelete }: TrashBookm...

FILE: tmarks/src/pages/bookmarks/components/BatchSelectionPrompt.tsx
  type BatchSelectionPromptProps (line 3) | interface BatchSelectionPromptProps {
  function BatchSelectionPrompt (line 11) | function BatchSelectionPrompt({

FILE: tmarks/src/pages/bookmarks/components/MobileTagDrawer.tsx
  type MobileTagDrawerProps (line 6) | interface MobileTagDrawerProps {
  function MobileTagDrawer (line 20) | function MobileTagDrawer({

FILE: tmarks/src/pages/bookmarks/components/StatisticsCards.tsx
  type StatisticsCardsProps (line 5) | interface StatisticsCardsProps {
  function StatisticsCards (line 11) | function StatisticsCards({ statistics, formatDate, formatDateTime }: Sta...

FILE: tmarks/src/pages/bookmarks/hooks/useBookmarksEffects.ts
  type UseBookmarksEffectsProps (line 7) | interface UseBookmarksEffectsProps {
  function useBookmarksEffects (line 23) | function useBookmarksEffects({

FILE: tmarks/src/pages/bookmarks/hooks/useBookmarksState.ts
  function useBookmarksState (line 8) | function useBookmarksState() {

FILE: tmarks/src/pages/bookmarks/hooks/useStatisticsData.ts
  type BookmarkStatistics (line 6) | interface BookmarkStatistics {
  type Granularity (line 50) | type Granularity = 'day' | 'week' | 'month' | 'year'
  function useStatisticsData (line 52) | function useStatisticsData(granularity: Granularity, currentDate: Date) {

FILE: tmarks/src/pages/extension/ExtensionPage.tsx
  constant EXTENSION_ZIP (line 4) | const EXTENSION_ZIP = 'tmarks-extension-chrome.zip'
  constant CHROMIUM_BROWSERS (line 5) | const CHROMIUM_BROWSERS = ['Chrome', 'Edge', 'Brave', 'Opera', '360', 'Q...
  function ExtensionPage (line 7) | function ExtensionPage() {

FILE: tmarks/src/pages/info/AboutPage.tsx
  function AboutPage (line 4) | function AboutPage() {

FILE: tmarks/src/pages/info/HelpPage.tsx
  function HelpPage (line 5) | function HelpPage() {

FILE: tmarks/src/pages/info/PrivacyPage.tsx
  function PrivacyPage (line 4) | function PrivacyPage() {

FILE: tmarks/src/pages/info/TermsPage.tsx
  function TermsPage (line 4) | function TermsPage() {

FILE: tmarks/src/pages/settings/ApiKeysPage.tsx
  function ApiKeysPage (line 15) | function ApiKeysPage() {

FILE: tmarks/src/pages/settings/GeneralSettingsPage.tsx
  constant VALID_SECTIONS (line 19) | const VALID_SECTIONS = ['basic', 'automation', 'snapshot', 'api', 'share...
  type SectionId (line 20) | type SectionId = typeof VALID_SECTIONS[number]
  function isValidSection (line 22) | function isValidSection(s: string | null): s is SectionId {
  function GeneralSettingsPage (line 26) | function GeneralSettingsPage() {

FILE: tmarks/src/pages/settings/ImportExportPage.tsx
  function ImportExportPage (line 12) | function ImportExportPage() {

FILE: tmarks/src/pages/settings/PermissionsPage.tsx
  function PermissionsPage (line 4) | function PermissionsPage() {

FILE: tmarks/src/pages/settings/ShareSettingsPage.tsx
  function ShareSettingsPage (line 5) | function ShareSettingsPage() {

FILE: tmarks/src/pages/share/PublicSharePage.tsx
  function PublicSharePage (line 9) | function PublicSharePage() {
  function ShareHeader (line 94) | function ShareHeader({ shareInfo, totalCount, filteredCount, t }: {

FILE: tmarks/src/pages/share/components/ShareTopBar.tsx
  function ViewModeIcon (line 20) | function ViewModeIcon({ mode }: { mode: ViewMode }) {
  function VisibilityIcon (line 35) | function VisibilityIcon({ filter }: { filter: VisibilityFilter }) {
  function SortIcon (line 48) | function SortIcon({ sort }: { sort: SortOption }) {
  type ShareTopBarProps (line 63) | interface ShareTopBarProps {
  constant SORT_OPTIONS (line 77) | const SORT_OPTIONS: SortOption[] = ['created', 'updated', 'pinned', 'pop...
  constant VIEW_MODES (line 78) | const VIEW_MODES = ['list', 'card', 'minimal', 'title'] as const
  function ShareTopBar (line 80) | function ShareTopBar({

FILE: tmarks/src/pages/share/hooks/usePublicShareState.ts
  type ViewMode (line 4) | type ViewMode = 'list' | 'card' | 'minimal' | 'title'
  type VisibilityFilter (line 5) | type VisibilityFilter = 'all' | 'public' | 'private'
  function usePublicShareState (line 7) | function usePublicShareState() {

FILE: tmarks/src/pages/tab-groups/StatisticsPage.tsx
  function StatisticsPage (line 10) | function StatisticsPage() {

FILE: tmarks/src/pages/tab-groups/TabGroupDetailHeader.tsx
  type TabGroupDetailHeaderProps (line 20) | interface TabGroupDetailHeaderProps {
  function TabGroupDetailHeader (line 26) | function TabGroupDetailHeader({ tabGroup, onRefresh, onDelete }: TabGrou...

FILE: tmarks/src/pages/tab-groups/TabGroupDetailPage.tsx
  function TabGroupDetailPage (line 15) | function TabGroupDetailPage() {

FILE: tmarks/src/pages/tab-groups/TabGroupEmptyState.tsx
  function TabGroupEmptyState (line 4) | function TabGroupEmptyState() {

FILE: tmarks/src/pages/tab-groups/TabGroupItem.tsx
  type TabGroupItemProps (line 9) | interface TabGroupItemProps {
  function TabGroupItem (line 15) | function TabGroupItem({ item, index, onRefresh }: TabGroupItemProps) {

FILE: tmarks/src/pages/tab-groups/TabGroupsPage.tsx
  function TabGroupsPage (line 26) | function TabGroupsPage() {

FILE: tmarks/src/pages/tab-groups/TodoPage.tsx
  function TodoPage (line 7) | function TodoPage() {

FILE: tmarks/src/pages/tab-groups/TrashPage.tsx
  function TrashPage (line 16) | function TrashPage() {

FILE: tmarks/src/pages/tab-groups/components/TabGroupsGrid.tsx
  type TabGroupsGridProps (line 9) | interface TabGroupsGridProps {
  function renderGroupCard (line 47) | function renderGroupCard(props: TabGroupsGridProps & { group: TabGroup }) {
  function TabGroupsGrid (line 123) | function TabGroupsGrid(props: TabGroupsGridProps) {

FILE: tmarks/src/pages/tab-groups/components/TabGroupsList.tsx
  type TabGroupsListProps (line 7) | interface TabGroupsListProps {
  function TabGroupsList (line 39) | function TabGroupsList({

FILE: tmarks/src/pages/tab-groups/hooks/useGroupManagement.ts
  type UseGroupManagementProps (line 6) | interface UseGroupManagementProps {
  function useGroupManagement (line 16) | function useGroupManagement({

FILE: tmarks/src/pages/tab-groups/hooks/useTabGroupDetailState.ts
  function useTabGroupDetailState (line 4) | function useTabGroupDetailState() {

FILE: tmarks/src/pages/tab-groups/hooks/useTabGroupItemDnD.ts
  type UseTabGroupItemDnDProps (line 8) | interface UseTabGroupItemDnDProps {
  function useTabGroupItemDnD (line 28) | function useTabGroupItemDnD({

FILE: tmarks/src/pages/tab-groups/hooks/useTabGroupsData.ts
  type UseTabGroupsDataProps (line 8) | interface UseTabGroupsDataProps {
  function useTabGroupsData (line 16) | function useTabGroupsData({

FILE: tmarks/src/pages/tab-groups/hooks/useTabGroupsState.ts
  function useTabGroupsState (line 5) | function useTabGroupsState() {

FILE: tmarks/src/routes/index.tsx
  function PageLoader (line 29) | function PageLoader() {
  function AppRouter (line 44) | function AppRouter() {

FILE: tmarks/src/services/api-keys.ts
  type ApiKey (line 9) | interface ApiKey {
  type ApiKeyWithKey (line 23) | interface ApiKeyWithKey extends ApiKey {
  type ApiKeyStats (line 27) | interface ApiKeyStats {
  type ApiKeyWithStats (line 33) | interface ApiKeyWithStats extends ApiKey {
  type ApiKeyLog (line 37) | interface ApiKeyLog {
  type CreateApiKeyRequest (line 47) | interface CreateApiKeyRequest {
  type UpdateApiKeyRequest (line 55) | interface UpdateApiKeyRequest {
  function getApiKeys (line 66) | async function getApiKeys(): Promise<{
  function getApiKey (line 77) | async function getApiKey(id: string): Promise<ApiKeyWithStats> {
  function createApiKey (line 85) | async function createApiKey(data: CreateApiKeyRequest): Promise<ApiKeyWi...
  function updateApiKey (line 93) | async function updateApiKey(id: string, data: UpdateApiKeyRequest): Prom...
  function revokeApiKey (line 101) | async function revokeApiKey(id: string): Promise<void> {
  function deleteApiKey (line 108) | async function deleteApiKey(id: string): Promise<void> {
  function getApiKeyLogs (line 115) | async function getApiKeyLogs(

FILE: tmarks/src/services/auth.ts
  method register (line 16) | async register(data: RegisterRequest) {
  method login (line 24) | async login(data: LoginRequest) {
  method refreshToken (line 32) | async refreshToken(data: RefreshTokenRequest) {
  method logout (line 40) | async logout(refreshToken: string, revokeAll = false) {

FILE: tmarks/src/services/bookmarks.ts
  method getBookmarks (line 17) | async getBookmarks(params?: BookmarkQueryParams) {
  method createBookmark (line 38) | async createBookmark(data: CreateBookmarkRequest) {
  method updateBookmark (line 46) | async updateBookmark(id: string, data: UpdateBookmarkRequest) {
  method deleteBookmark (line 54) | async deleteBookmark(id: string) {
  method restoreBookmark (line 61) | async restoreBookmark(id: string) {
  method recordClick (line 69) | async recordClick(id: string) {
  method batchAction (line 77) | async batchAction(data: BatchActionRequest) {
  method getStatistics (line 85) | async getStatistics(params: {
  method checkUrlExists (line 100) | async checkUrlExists(url: string) {
  method getTrash (line 110) | async getTrash(params?: { page_size?: number; page_cursor?: string }) {
  method restoreFromTrash (line 125) | async restoreFromTrash(id: string) {
  method permanentDelete (line 133) | async permanentDelete(id: string) {
  method emptyTrash (line 140) | async emptyTrash() {

FILE: tmarks/src/services/index.ts
  function assertData (line 15) | function assertData<T>(data: T | undefined, context: string): T {

FILE: tmarks/src/services/preferences.ts
  method getPreferences (line 9) | async getPreferences(): Promise<UserPreferences> {
  method updatePreferences (line 17) | async updatePreferences(data: UpdatePreferencesRequest): Promise<UserPre...

FILE: tmarks/src/services/share.ts
  constant PUBLIC_SHARE_BASE (line 12) | const PUBLIC_SHARE_BASE = import.meta.env.VITE_PUBLIC_SHARE_URL || '/api...
  method getSettings (line 15) | async getSettings(): Promise<ShareSettings> {
  method updateSettings (line 20) | async updateSettings(payload: UpdateShareSettingsRequest): Promise<Share...
  method getPublicShare (line 25) | async getPublicShare(slug: string): Promise<PublicSharePayload> {
  method getPublicSharePaginated (line 44) | async getPublicSharePaginated(

FILE: tmarks/src/services/storage.ts
  method getR2Quota (line 6) | async getR2Quota(): Promise<R2StorageQuota> {

FILE: tmarks/src/services/tab-groups.ts
  method getTabGroups (line 16) | async getTabGroups(params?: { page_size?: number; page_cursor?: string }) {
  method listAllTabGroups (line 32) | async listAllTabGroups() {
  method getAllTabGroups (line 54) | async getAllTabGroups() {
  method getTabGroup (line 61) | async getTabGroup(id: string) {
  method createTabGroup (line 69) | async createTabGroup(data: CreateTabGroupRequest) {
  method createFolder (line 77) | async createFolder(title: string, parentId?: string | null) {
  method updateTabGroup (line 89) | async updateTabGroup(id: string, data: UpdateTabGroupRequest) {
  method deleteTabGroup (line 97) | async deleteTabGroup(id: string) {
  method updateTabGroupItem (line 104) | async updateTabGroupItem(
  method deleteTabGroupItem (line 128) | async deleteTabGroupItem(itemId: string) {
  method moveTabGroupItem (line 135) | async moveTabGroupItem(itemId: string, targetGroupId: string, position?:...
  method addItemsToGroup (line 162) | async addItemsToGroup(groupId: string, items: Array<{ title: string; url...
  method batchUpdatePositions (line 183) | async batchUpdatePositions(updates: Array<{ id: string; position: number...
  method getTrash (line 194) | async getTrash() {
  method restoreTabGroup (line 202) | async restoreTabGroup(id: string) {
  method permanentDeleteTabGroup (line 209) | async permanentDeleteTabGroup(id: string) {
  method createShare (line 216) | async createShare(groupId: string, options?: { is_public?: boolean; expi...
  method getShare (line 224) | async getShare(groupId: string) {
  method deleteShare (line 232) | async deleteShare(groupId: string) {
  method getStatistics (line 239) | async getStatistics(days: number = 30) {

FILE: tmarks/src/services/tags.ts
  method getTags (line 15) | async getTags(params?: TagQueryParams) {
  method createTag (line 30) | async createTag(data: CreateTagRequest) {
  method updateTag (line 38) | async updateTag(id: string, data: UpdateTagRequest) {
  method deleteTag (line 46) | async deleteTag(id: string) {
  method incrementClick (line 53) | async incrementClick(id: string) {

FILE: tmarks/src/stores/authStore.ts
  type AuthState (line 7) | interface AuthState {

FILE: tmarks/src/stores/dialogStore.ts
  type DialogType (line 3) | type DialogType = 'info' | 'warning' | 'error' | 'success'
  type ConfirmDialogState (line 5) | interface ConfirmDialogState {
  type AlertDialogState (line 15) | interface AlertDialogState {
  type DialogState (line 24) | interface DialogState {

FILE: tmarks/src/stores/themeStore.ts
  type Theme (line 4) | type Theme = 'light' | 'dark'
  type ColorTheme (line 5) | type ColorTheme = 'default' | 'violet' | 'green' | 'orange'
  type ThemeStore (line 7) | interface ThemeStore {

FILE: tmarks/src/stores/toastStore.ts
  type ToastState (line 4) | interface ToastState {
Condensed preview — 383 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,724K chars).
[
  {
    "path": "DEPLOYMENT.md",
    "chars": 3052,
    "preview": "# 🚀 TMarks 部署指南\r\n\r\n## 📹 视频教程\r\n\r\n**完整部署教程视频**: [点击观看](https://bushutmarks.pages.dev/course/tmarks)\r\n\r\n跟随视频教程,3 分钟完成部署。\r\n\r"
  },
  {
    "path": "LICENSE",
    "chars": 1318,
    "preview": "Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)\r\n\r\nCopyright (c) 2024 TMarks Team\r\n\r"
  },
  {
    "path": "README.md",
    "chars": 1699,
    "preview": "<div align=\"center\">\r\n\r\n# 🔖 TMarks\r\n\r\n**AI 驱动的智能书签管理系统**\r\n\r\n[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-b"
  },
  {
    "path": "tmarks/.gitignore",
    "chars": 517,
    "preview": "# Logs\r\nlogs\r\n*.log\r\nnpm-debug.log*\r\nyarn-debug.log*\r\nyarn-error.log*\r\npnpm-debug.log*\r\nlerna-debug.log*\r\n\r\nnode_modules"
  },
  {
    "path": "tmarks/.prettierignore",
    "chars": 167,
    "preview": "# Dependencies\nnode_modules\n\n# Build outputs\ndist\ndist-ssr\nbuild\n.wrangler\n\n# Environment\n.env\n.env.local\n.dev.vars\n\n# L"
  },
  {
    "path": "tmarks/.prettierrc",
    "chars": 134,
    "preview": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"arrowParens"
  },
  {
    "path": "tmarks/API-DATABASE-AUDIT.md",
    "chars": 6627,
    "preview": "# 数据库表与 API 接口一致性审计报告\r\n\r\n生成时间: 2026-04-15\r\n\r\n## 📊 总体统计\r\n\r\n- **总 API 端点数**: 73\r\n- **总数据库表数**: 21\r\n- **已使用的表数**: 17 (81.0%"
  },
  {
    "path": "tmarks/eslint.config.js",
    "chars": 957,
    "preview": "import js from '@eslint/js'\r\nimport globals from 'globals'\r\nimport reactHooks from 'eslint-plugin-react-hooks'\r\nimport r"
  },
  {
    "path": "tmarks/functions/api/_middleware.ts",
    "chars": 254,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport { corsHeaders, securityHeaders, requestLogger } f"
  },
  {
    "path": "tmarks/functions/api/index.ts",
    "chars": 1321,
    "preview": "/**\r\n *  API -  API \r\n * : /api\r\n * \r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type"
  },
  {
    "path": "tmarks/functions/api/public/[slug].ts",
    "chars": 7697,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, PublicProfile } from '.."
  },
  {
    "path": "tmarks/functions/api/share/[token].ts",
    "chars": 2937,
    "preview": "/**\r\n *  API\r\n * : /api/share/:token\r\n * : ()\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nim"
  },
  {
    "path": "tmarks/functions/api/shared/cache.ts",
    "chars": 705,
    "preview": "import type { Env } from '../../lib/types'\r\nimport { CacheService } from '../../lib/cache'\r\nimport { getCacheInvalidatio"
  },
  {
    "path": "tmarks/functions/api/snapshot-images/[hash].ts",
    "chars": 5442,
    "preview": "/**\r\n *  API\r\n * : /api/snapshot-images/:hash\r\n *  R2 \r\n * \r\n * :  API \r\n */\r\n\r\nimport type { PagesFunction } from '@clo"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/click.ts",
    "chars": 1676,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/click\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction "
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/permanent.ts",
    "chars": 1632,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/permanent\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunct"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/restore.ts",
    "chars": 2276,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/restore\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunctio"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshot-upload.ts",
    "chars": 2731,
    "preview": "/**\n * \n *  Base64  R2 \n */\n\nimport type { R2Bucket } from '@cloudflare/workers-types'\n\nconst R2_UPLOAD_CONCURRENCY = 6\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots/[snapshotId].ts",
    "chars": 6506,
    "preview": "/**\r\n *  API\r\n * : /api/tab/bookmarks/:id/snapshots/:snapshotId\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { P"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots/cleanup.ts",
    "chars": 5675,
    "preview": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/snapshots/cleanup\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots-v2.ts",
    "chars": 7128,
    "preview": "/**\r\n *  API V2 - \r\n * : /api/tab/bookmarks/:id/snapshots-v2\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/wor"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots.ts",
    "chars": 10039,
    "preview": "/**\r\n *  API\r\n * : /api/tab/bookmarks/:id/snapshots\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/trash.ts",
    "chars": 1444,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/trash\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction "
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id].ts",
    "chars": 7624,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/batch/index.ts",
    "chars": 7001,
    "preview": "/**\r\n *  API\r\n * : /api/tab/bookmarks/batch\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from "
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/batch-handler.ts",
    "chars": 6049,
    "preview": "/**\r\n\r\n */\r\n\r\nimport type { EventContext } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../."
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/bookmark-batch.ts",
    "chars": 4351,
    "preview": "import type { D1Database } from '../../../lib/types'\nimport { isValidUrl, sanitizeString } from '../../../lib/validation"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/bookmark-list.ts",
    "chars": 6330,
    "preview": "import type { Bookmark, BookmarkRow, SQLParam, D1Database } from '../../../lib/types'\n\nexport interface BookmarkWithTags"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/index.ts",
    "chars": 9548,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/bookmarks\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@c"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/reorder-pinned.ts",
    "chars": 2128,
    "preview": "/**\r\n * \r\n * POST /api/tab/bookmarks/reorder-pinned\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-type"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/trash/empty.ts",
    "chars": 1794,
    "preview": "/**\r\n\r\n * : /api/tab/bookmarks/trash/empty\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/trash.ts",
    "chars": 3162,
    "preview": "/**\r\n\r\n * : /api/tab/bookmarks/trash\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloud"
  },
  {
    "path": "tmarks/functions/api/tab/me.ts",
    "chars": 2076,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/me\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudfla"
  },
  {
    "path": "tmarks/functions/api/tab/search.ts",
    "chars": 4203,
    "preview": "/**\n * External API - Global Search\n * Path: /api/tab/search\n * Auth: API Key (X-API-Key header)\n */\n\nimport type { Page"
  },
  {
    "path": "tmarks/functions/api/tab/statistics/index.ts",
    "chars": 4879,
    "preview": "\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env } from '../../../lib/types'\nimport { "
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/items/batch.ts",
    "chars": 3420,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/tab-groups/:id/items/batch\r\n * : API Key (X-API-Key header)  JWT Token (Bearer)\r\n */\r\n\r\ni"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/permanent-delete.ts",
    "chars": 1775,
    "preview": "/**\n *  API\n * : /api/tab/tab-groups/:id/permanent-delete\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimpor"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/restore.ts",
    "chars": 1542,
    "preview": "/**\n *  API\n * : /api/tab/tab-groups/:id/restore\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimport type { "
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/share.ts",
    "chars": 4708,
    "preview": "/**\n *  API\n * : /api/tab/tab-groups/:id/share\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimport type { Pa"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id].ts",
    "chars": 8716,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/tab-groups/:id\r\n * : API Key (X-API-Key header)  JWT Token (Bearer)\r\n */\r\n\r\nimport type {"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/index.ts",
    "chars": 7520,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/tab-groups\r\n\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimpo"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/items/[id]/move.ts",
    "chars": 4576,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/tab-groups/items/:id/move\r\n * : API Key (X-API-Key header)  JWT Token (Bearer)\r\n */\r\n\r\nim"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/items/[id].ts",
    "chars": 5351,
    "preview": "/**\n *  API - \n * : /api/tab/tab-groups/items/:id\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimport type {"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/trash.ts",
    "chars": 1849,
    "preview": "\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env } from '../../../lib/types'\nimport { "
  },
  {
    "path": "tmarks/functions/api/tab/tags/[id]/click.ts",
    "chars": 1509,
    "preview": "/**\r\n *  API - \r\n * : /api/tab/tags/:id/click\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } fro"
  },
  {
    "path": "tmarks/functions/api/tab/tags/[id].ts",
    "chars": 4744,
    "preview": "/**\n *  API - \n * : /api/tab/tags/:id\n * : API Key (X-API-Key header)\n */\n\nimport type { PagesFunction } from '@cloudfla"
  },
  {
    "path": "tmarks/functions/api/tab/tags/index.ts",
    "chars": 3061,
    "preview": "/**\n *  API - \n * : /api/tab/tags\n * : API Key (X-API-Key header)\n */\n\nimport type { PagesFunction } from '@cloudflare/w"
  },
  {
    "path": "tmarks/functions/api/v1/auth/login.ts",
    "chars": 4929,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, User } from '../../../lib/types'\r\nimp"
  },
  {
    "path": "tmarks/functions/api/v1/auth/logout.ts",
    "chars": 2244,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../lib/types"
  },
  {
    "path": "tmarks/functions/api/v1/auth/refresh.ts",
    "chars": 3760,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimport { "
  },
  {
    "path": "tmarks/functions/api/v1/auth/register.ts",
    "chars": 5357,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimport { "
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/click.ts",
    "chars": 1506,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../lib/types'\r\nimport"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/permanent.ts",
    "chars": 1561,
    "preview": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/permanent\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/restore.ts",
    "chars": 2205,
    "preview": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/restore\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@c"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshot-cleanup.ts",
    "chars": 1718,
    "preview": "/**\n *  — \n */\n\nexport async function cleanupOldSnapshots(\n  db: D1Database,\n  bucket: R2Bucket,\n  bookmarkId: string,\n "
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots/[snapshotId]/view.ts",
    "chars": 3987,
    "preview": "/**\r\n *  API -  URL\r\n * : /api/v1/bookmarks/:id/snapshots/:snapshotId/view\r\n * :  URL( JWT Token)\r\n */\r\n\r\nimport type { "
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots/[snapshotId].ts",
    "chars": 7337,
    "preview": "/**\r\n *  API (V1 - JWT Auth)\r\n * : /api/v1/bookmarks/:id/snapshots/:snapshotId\r\n * : JWT Token\r\n */\r\n\r\nimport type { Pag"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots/cleanup.ts",
    "chars": 5675,
    "preview": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/snapshots/cleanup\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots.ts",
    "chars": 8930,
    "preview": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/snapshots\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudfla"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id].ts",
    "chars": 7561,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, Bookmark, BookmarkRow, RouteParams, S"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/bulk.ts",
    "chars": 8457,
    "preview": "import type { PagesFunction, D1PreparedStatement } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } fr"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/index.ts",
    "chars": 3459,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, RouteParams, SQLParam } "
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/statistics-helpers.ts",
    "chars": 1625,
    "preview": "export interface BookmarkStatistics {\n  summary: {\n    total_bookmarks: number\n    total_tags: number\n    total_clicks: "
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/statistics.ts",
    "chars": 7063,
    "preview": "/**\r\n *  API\r\n * : /api/v1/bookmarks/statistics\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/trash/empty.ts",
    "chars": 1727,
    "preview": "/**\r\n\r\n * : /api/v1/bookmarks/trash/empty\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudfla"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/trash.ts",
    "chars": 3097,
    "preview": "/**\r\n\r\n * : /api/v1/bookmarks/trash\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/wor"
  },
  {
    "path": "tmarks/functions/api/v1/change-password.ts",
    "chars": 2356,
    "preview": "/**\r\n * Change Password API\r\n * Path: /api/v1/change-password\r\n * Auth: JWT Token\r\n */\r\n\r\nimport type { PagesFunction } "
  },
  {
    "path": "tmarks/functions/api/v1/export.ts",
    "chars": 5243,
    "preview": "/**\n * Export API endpoint\n * GET  /api/v1/export        -> download JSON export\n * POST /api/v1/export        -> previe"
  },
  {
    "path": "tmarks/functions/api/v1/health.ts",
    "chars": 234,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\n\nexport const onRequestGet: PagesFunction = async () => {"
  },
  {
    "path": "tmarks/functions/api/v1/preferences-helpers.ts",
    "chars": 5307,
    "preview": "export interface UserPreferences {\n  user_id: string\n  theme: 'light' | 'dark' | 'system'\n  page_size: number\n  view_mod"
  },
  {
    "path": "tmarks/functions/api/v1/preferences.ts",
    "chars": 6322,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams, SQLParam } from '../../l"
  },
  {
    "path": "tmarks/functions/api/v1/settings/api-keys/[id].ts",
    "chars": 7267,
    "preview": "/**\n *  API Key \n * GET /api/v1/settings/api-keys/:id -  API Key \n * PATCH /api/v1/settings/api-keys/:id -  API Key\n * D"
  },
  {
    "path": "tmarks/functions/api/v1/settings/api-keys/index.ts",
    "chars": 5711,
    "preview": "\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../l"
  },
  {
    "path": "tmarks/functions/api/v1/settings/share.ts",
    "chars": 4951,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../lib/types"
  },
  {
    "path": "tmarks/functions/api/v1/settings/storage.ts",
    "chars": 1043,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../lib/type"
  },
  {
    "path": "tmarks/functions/api/v1/shared/cache.ts",
    "chars": 150,
    "preview": "// Deprecated shim: keep old import path working but delegate to new implementation\r\nexport { invalidatePublicShareCache"
  },
  {
    "path": "tmarks/functions/api/v1/statistics/index.ts",
    "chars": 5236,
    "preview": "/**\r\n *  API\r\n * : /api/v1/statistics\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/w"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/items/batch.ts",
    "chars": 3705,
    "preview": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/:id/items/batch\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction, D"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/permanent-delete.ts",
    "chars": 1747,
    "preview": "/**\r\n *  API\r\n * : /api/v1/tab-groups/:id/permanent-delete\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction "
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/restore.ts",
    "chars": 1617,
    "preview": "/**\r\n *  API\r\n * : /api/v1/tab-groups/:id/restore\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/share.ts",
    "chars": 5584,
    "preview": "/**\r\n *  API\r\n * : /api/v1/tab-groups/:id/share\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cl"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id].ts",
    "chars": 6514,
    "preview": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/:id\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/wor"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/batch-update.ts",
    "chars": 1639,
    "preview": "/**\n * PATCH /api/v1/tab-groups/batch-update\n * Batch update tab group positions\n */\n\nimport type { PagesFunction } from"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/index.ts",
    "chars": 6789,
    "preview": "/**\r\n *  API - \r\n * : /api/v1/tab-groups\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/items/[id]/move.ts",
    "chars": 4485,
    "preview": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/items/:id/move\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } f"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/items/[id].ts",
    "chars": 5634,
    "preview": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/items/:id\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/trash.ts",
    "chars": 1952,
    "preview": "\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimpor"
  },
  {
    "path": "tmarks/functions/api/v1/tags/[id].ts",
    "chars": 3498,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, Tag, RouteParams, SQLParam } from '../"
  },
  {
    "path": "tmarks/functions/api/v1/tags/index.ts",
    "chars": 3181,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, Tag, RouteParams } from '../../../lib"
  },
  {
    "path": "tmarks/functions/lib/api-key/generator.ts",
    "chars": 1328,
    "preview": "/**\n\n * : tmk_live_[20]\n */\n\n/**\n *  API Key\n * @param env  ('live' | 'test')\n * @returns API Key, , SHA256 \n */\nexport "
  },
  {
    "path": "tmarks/functions/lib/api-key/logger.ts",
    "chars": 3134,
    "preview": "/**\n * API Key Logger - Records API key usage and provides statistics\n * Automatically cleans up old logs, keeping only "
  },
  {
    "path": "tmarks/functions/lib/api-key/rate-limiter-types.ts",
    "chars": 560,
    "preview": "/**\n * API Key Rate Limiter Types\n */\n\nexport type RateLimitWindow = 'minute' | 'hour' | 'day';\n\nexport interface RateLi"
  },
  {
    "path": "tmarks/functions/lib/api-key/rate-limiter.ts",
    "chars": 6523,
    "preview": "/**\r\n * API Key Rate Limiter (D1-backed)\r\n */\r\n\r\nimport {\r\n  RateLimitWindow,\r\n  RateLimitConfig,\r\n  RateLimitResult,\r\n "
  },
  {
    "path": "tmarks/functions/lib/api-key/validator.ts",
    "chars": 3294,
    "preview": "/**\n * API Key Validator\n */\n\nimport { hashApiKey } from './generator'\nimport { hasPermission } from '../../../shared/pe"
  },
  {
    "path": "tmarks/functions/lib/bookmark-utils.ts",
    "chars": 452,
    "preview": "import type { Bookmark, BookmarkRow } from './types'\r\n\r\n/**\r\n *  Bookmark \r\n\n */\r\nexport function normalizeBookmark(row:"
  },
  {
    "path": "tmarks/functions/lib/cache/README.md",
    "chars": 5889,
    "preview": "# TMarks 缓存系统\r\n\r\n## 📖 概述\r\n\r\nTMarks 缓存系统提供灵活、强健、成本可控的多层缓存解决方案。\r\n\r\n### 核心特性\r\n\r\n- ✅ **4 级配置** - 从无缓存到激进缓存\r\n- ✅ **批量操作零成本** "
  },
  {
    "path": "tmarks/functions/lib/cache/bookmark-cache.ts",
    "chars": 2334,
    "preview": "/**\r\n * \r\n * \r\n\n */\r\nimport { CacheService } from './service'\r\nimport { generateCacheKey, getQueryType, getCacheInvalida"
  },
  {
    "path": "tmarks/functions/lib/cache/config.ts",
    "chars": 4621,
    "preview": "/**\r\n * \r\n * \r\n\n */\r\nimport type { CacheConfig, CacheLevel } from './types'\r\nimport type { Env } from '../types'\r\n/**\r\n "
  },
  {
    "path": "tmarks/functions/lib/cache/index.ts",
    "chars": 449,
    "preview": "/**\r\n * \r\n * \r\n\n */\r\nexport type {\r\n  CacheLevel,\r\n  CacheStrategyType,\r\n  CacheConfig,\r\n  CacheEntry,\r\n  CacheSetOption"
  },
  {
    "path": "tmarks/functions/lib/cache/service.ts",
    "chars": 4203,
    "preview": "/**\r\n * \r\n * \r\n\n */\r\nimport type { Env } from '../types'\r\nimport type {\r\n  CacheConfig,\r\n  CacheStrategyType,\r\n  CacheEn"
  },
  {
    "path": "tmarks/functions/lib/cache/strategies.ts",
    "chars": 2839,
    "preview": "/**\r\n * \r\n * \r\n * \r\n */\r\nimport type { CacheStrategyType, QueryParams } from './types'\r\n/**\r\n\n */\r\nexport function gener"
  },
  {
    "path": "tmarks/functions/lib/cache/types.ts",
    "chars": 979,
    "preview": "export type CacheLevel = 0 | 1 | 2 | 3\n\nexport type CacheStrategyType =\n  | 'rateLimit'\n  | 'publicShare'\n  | 'defaultLi"
  },
  {
    "path": "tmarks/functions/lib/config.ts",
    "chars": 879,
    "preview": "/**\r\n * \r\n\n */\r\n\r\nimport type { Env } from './types'\r\n\r\n/**\r\n\n */\r\nexport const DEFAULT_CONFIG = {\r\n  JWT_ACCESS_TOKEN_E"
  },
  {
    "path": "tmarks/functions/lib/crypto.ts",
    "chars": 4369,
    "preview": "/**\r\n\n */\r\nconst PBKDF2_ITERATIONS = 100000 // OWASP \nconst SALT_LENGTH = 16\r\nconst HASH_LENGTH = 32\r\n/**\r\n * \r\n */\r\nexp"
  },
  {
    "path": "tmarks/functions/lib/data-fetchers.ts",
    "chars": 1665,
    "preview": "import type { D1Database } from '@cloudflare/workers-types'\nimport type { Bookmark, BookmarkRow } from '../lib/types'\n\ne"
  },
  {
    "path": "tmarks/functions/lib/error-handler.ts",
    "chars": 3719,
    "preview": "/**\r\n * \r\n\n */\r\nimport { internalError, badRequest, unauthorized, forbidden, notFound, conflict } from './response'\r\nexp"
  },
  {
    "path": "tmarks/functions/lib/image-sig.ts",
    "chars": 836,
    "preview": "/**\n * Generate HMAC signature for snapshot image URLs\n */\nexport async function generateImageSig(\n  hash: string,\n  use"
  },
  {
    "path": "tmarks/functions/lib/image-upload.ts",
    "chars": 6970,
    "preview": "/**\r\n\n */\r\nimport type { R2Bucket, D1Database } from '@cloudflare/workers-types'\r\nimport type { Env } from './types'\r\nim"
  },
  {
    "path": "tmarks/functions/lib/import-export/collect-export-data.ts",
    "chars": 8717,
    "preview": "import type {\n  TMarksExportData,\n  ExportBookmark,\n  ExportTag,\n  ExportUser,\n  ExportTabGroup,\n  ExportTabGroupItem,\n}"
  },
  {
    "path": "tmarks/functions/lib/import-export/export-scope.ts",
    "chars": 715,
    "preview": "export type ExportScope = 'all' | 'bookmarks' | 'tab_groups'\n\nexport function parseExportScope(raw: string | null | unde"
  },
  {
    "path": "tmarks/functions/lib/import-export/export-stats.ts",
    "chars": 2238,
    "preview": "import type { ExportScope } from './export-scope'\n\nexport interface ExportStats {\n  total_bookmarks: number\n  total_tags"
  },
  {
    "path": "tmarks/functions/lib/import-export/exporters/html-exporter.ts",
    "chars": 7292,
    "preview": "/**\n\n *  Netscape ,\n */\n\nimport type { \n  Exporter, \n  TMarksExportData, \n  ExportOptions, \n  ExportOutput\n} from '../.."
  },
  {
    "path": "tmarks/functions/lib/import-export/exporters/json-exporter.ts",
    "chars": 5431,
    "preview": "/**\r\n\n *  TMarks  JSON \r\n */\r\n\r\nimport type { \r\n  Exporter, \r\n  TMarksExportData, \r\n  ExportOptions, \r\n  ExportOutput \r\n"
  },
  {
    "path": "tmarks/functions/lib/import-export/exporters/tab-groups-netscape.ts",
    "chars": 3465,
    "preview": "import type { ExportTabGroup, ExportTabGroupItem } from '../../../../shared/import-export-types'\n\ntype EscapeHtml = (tex"
  },
  {
    "path": "tmarks/functions/lib/index.ts",
    "chars": 507,
    "preview": "// ============ Functions Library Exports ============\r\n\r\nexport * from './bookmark-utils';\r\nexport * from './config';\r\n"
  },
  {
    "path": "tmarks/functions/lib/input-sanitizer.ts",
    "chars": 5029,
    "preview": "/**\r\n\r\n */\r\n/**\r\n * HTML \r\n */\r\nexport function escapeHtml(text: string): string {\r\n  const map: Record<string, string> "
  },
  {
    "path": "tmarks/functions/lib/jwt.ts",
    "chars": 3493,
    "preview": "export interface JWTPayload {\r\n  sub: string // user_id\r\n  exp: number\r\n  iat: number\r\n  session_id?: string\r\n}\r\n/**\r\n *"
  },
  {
    "path": "tmarks/functions/lib/rate-limit.ts",
    "chars": 4113,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env } from './types'\n\ntype RateLimiterConte"
  },
  {
    "path": "tmarks/functions/lib/response.ts",
    "chars": 2926,
    "preview": "import type { ApiResponse, ApiError } from './types'\r\n\r\nexport function success<T>(data: T, meta?: ApiResponse['meta']):"
  },
  {
    "path": "tmarks/functions/lib/signed-url.ts",
    "chars": 4044,
    "preview": "/**\r\n * Signed URL Generator\r\n * Generates secure signed URLs for temporary resource access\r\n * Similar to AWS S3 Presig"
  },
  {
    "path": "tmarks/functions/lib/storage-quota.ts",
    "chars": 2691,
    "preview": "import type { Env } from './types'\r\nimport type { D1Database } from '@cloudflare/workers-types'\r\n\r\n/**\r\n * R2 Storage Qu"
  },
  {
    "path": "tmarks/functions/lib/tags.ts",
    "chars": 4213,
    "preview": "import { generateUUID } from './crypto'\n\nfunction normalizeTagNames(tagNames: string[]): string[] {\n  const seen = new S"
  },
  {
    "path": "tmarks/functions/lib/types.ts",
    "chars": 2839,
    "preview": "export interface Env {\r\n  DB: D1Database\r\n  // TMARKS_KV?: KVNamespace // Unified cache (public sharing, rate limiting, "
  },
  {
    "path": "tmarks/functions/lib/utils.ts",
    "chars": 123,
    "preview": "export function generateSlug(): string {\n  const uuid = crypto.randomUUID().replace(/-/g, '')\n  return uuid.slice(0, 10)"
  },
  {
    "path": "tmarks/functions/lib/validation.ts",
    "chars": 1418,
    "preview": "export function isValidEmail(email: string): boolean {\r\n  if (email.length > 254) return false\r\n  const emailRegex = /^["
  },
  {
    "path": "tmarks/functions/middleware/api-key-auth-pages.ts",
    "chars": 5003,
    "preview": "/**\r\n * API Key Authentication Middleware for Cloudflare Pages Functions\r\n * Validates API Key and checks permissions fo"
  },
  {
    "path": "tmarks/functions/middleware/api-key-auth.ts",
    "chars": 4005,
    "preview": "/**\r\n * API Key Authentication Middleware\r\n * Validates API Key and checks permissions\r\n */\r\n\r\nimport { Context } from '"
  },
  {
    "path": "tmarks/functions/middleware/auth.ts",
    "chars": 1550,
    "preview": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../lib/types'\r\nim"
  },
  {
    "path": "tmarks/functions/middleware/dual-auth.ts",
    "chars": 4833,
    "preview": "/**\r\n * Dual Authentication Middleware\r\n * Supports both JWT Token and API Key authentication methods\r\n */\r\n\r\nimport typ"
  },
  {
    "path": "tmarks/functions/middleware/index.ts",
    "chars": 207,
    "preview": "// ============ Middleware Exports ============\r\n\r\nexport * from './api-key-auth';\r\nexport * from './api-key-auth-pages'"
  },
  {
    "path": "tmarks/functions/middleware/security.ts",
    "chars": 9450,
    "preview": "/**\r\n * Security Middleware\r\n * Provides security headers, CSP policies, input validation, and other security features\r\n"
  },
  {
    "path": "tmarks/index.html",
    "chars": 365,
    "preview": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href"
  },
  {
    "path": "tmarks/migrations/0001_d1_console.sql",
    "chars": 14247,
    "preview": "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash T"
  },
  {
    "path": "tmarks/migrations/0002_d1_console_ai_settings.sql",
    "chars": 706,
    "preview": "CREATE TABLE IF NOT EXISTS ai_settings (\r\n    id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\r\n    user_id TEX"
  },
  {
    "path": "tmarks/migrations/0103_api_key_rate_limits.sql",
    "chars": 469,
    "preview": "CREATE TABLE IF NOT EXISTS api_key_rate_limits (\n  api_key_id TEXT NOT NULL,\n  window TEXT NOT NULL, -- minute|hour|day\n"
  },
  {
    "path": "tmarks/migrations/0104_rate_limits.sql",
    "chars": 402,
    "preview": "CREATE TABLE IF NOT EXISTS rate_limits (\n  key TEXT NOT NULL,\n  window_seconds INTEGER NOT NULL,\n  window_start INTEGER "
  },
  {
    "path": "tmarks/package.json",
    "chars": 2997,
    "preview": "{\r\n  \"name\": \"tmarks\",\r\n  \"version\": \"0.1.2\",\r\n  \"private\": true,\r\n  \"license\": \"CC-BY-NC-4.0\",\r\n  \"type\": \"module\",\r\n  "
  },
  {
    "path": "tmarks/postcss.config.js",
    "chars": 91,
    "preview": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "tmarks/public/_headers",
    "chars": 313,
    "preview": "/*\n  X-Content-Type-Options: nosniff\n  X-Frame-Options: DENY\n  Referrer-Policy: strict-origin-when-cross-origin\n  Permis"
  },
  {
    "path": "tmarks/public/_routes.json",
    "chars": 70,
    "preview": "{\n  \"version\": 1,\n  \"include\": [\n    \"/api/*\"\n  ],\n  \"exclude\": []\n}\n\n"
  },
  {
    "path": "tmarks/scripts/auto-migrate.js",
    "chars": 4691,
    "preview": "#!/usr/bin/env node\r\n\r\n/**\r\n * 自动数据库迁移脚本\r\n * \r\n * 功能:\r\n * 1. 检测新的迁移文件\r\n * 2. 自动执行未应用的迁移\r\n * 3. 记录迁移历史\r\n * \r\n * 使用方式:\r\n *"
  },
  {
    "path": "tmarks/scripts/check-db-schema.js",
    "chars": 3913,
    "preview": "#!/usr/bin/env node\r\n\r\n/**\r\n * 检查数据库结构是否完整\r\n * 用法: node scripts/check-db-schema.js [--local]\r\n */\r\n\r\nimport { execSync }"
  },
  {
    "path": "tmarks/scripts/check-migrations.js",
    "chars": 2251,
    "preview": "#!/usr/bin/env node\r\n\r\n/**\r\n * 检查是否有待执行的迁移\r\n * 在 pnpm install 后自动运行\r\n */\r\n\r\nimport { readFileSync, existsSync, readdirSy"
  },
  {
    "path": "tmarks/scripts/prepare-deploy.js",
    "chars": 1710,
    "preview": "#!/usr/bin/env node\r\n\r\n/**\r\n * 准备Cloudflare Pages部署\r\n * 将dist内容和functions目录合并到同一层级\r\n */\r\n\r\nimport fs from 'fs';\r\nimport "
  },
  {
    "path": "tmarks/shared/import-export-types.ts",
    "chars": 6791,
    "preview": "/**\r\n * 导入导出功能的共享类型定义\r\n * 提供类型安全的接口定义,支持多种格式的数据交换\r\n */\r\n\r\n// ============ 基础数据类型 ============\r\n\r\nexport interface Export"
  },
  {
    "path": "tmarks/shared/permissions.ts",
    "chars": 7915,
    "preview": "/**\r\n * 权限系统 - 前后端共享\r\n * 定义所有 API Key 权限常量和工具函数\r\n */\r\n\r\n/**\r\n * 权限常量\r\n */\r\nexport const PERMISSIONS = {\r\n  // 书签权限\r\n  BO"
  },
  {
    "path": "tmarks/src/App.tsx",
    "chars": 2809,
    "preview": "import { QueryClientProvider } from '@tanstack/react-query'\r\nimport { BrowserRouter } from 'react-router-dom'\r\nimport { "
  },
  {
    "path": "tmarks/src/components/api-keys/ApiKeyCard.tsx",
    "chars": 4077,
    "preview": "/**\r\n * API Key 卡片组件\r\n * 显示单个 API Key 的摘要信息\r\n */\r\n\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { ApiKey"
  },
  {
    "path": "tmarks/src/components/api-keys/ApiKeyDetailModal.tsx",
    "chars": 8585,
    "preview": "/**\r\n * API Key 详情模态框\r\n * 显示 API Key 的详细信息和使用日志\r\n */\r\n\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useApiK"
  },
  {
    "path": "tmarks/src/components/api-keys/CreateApiKeyModal.tsx",
    "chars": 6681,
    "preview": "/**\r\n * 创建 API Key 模态框\r\n * 多步骤流程:基本信息 → 权限设置 → 过期设置 → 显示 Key\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTrans"
  },
  {
    "path": "tmarks/src/components/api-keys/StepBasicInfo.tsx",
    "chars": 2055,
    "preview": "import { useTranslation } from 'react-i18next'\nimport type { CreateApiKeyRequest } from '@/services/api-keys'\n\ninterface"
  },
  {
    "path": "tmarks/src/components/api-keys/StepPermissions.tsx",
    "chars": 3517,
    "preview": "import { useTranslation } from 'react-i18next'\nimport {\n  PERMISSION_TEMPLATES,\n  getPermissionLabel,\n  type PermissionT"
  },
  {
    "path": "tmarks/src/components/api-keys/StepSuccess.tsx",
    "chars": 2357,
    "preview": "import { useState, useRef, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { ApiKeyW"
  },
  {
    "path": "tmarks/src/components/auth/ProtectedRoute.tsx",
    "chars": 1364,
    "preview": "import { Navigate, Outlet } from 'react-router-dom'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { useStat"
  },
  {
    "path": "tmarks/src/components/bookmarks/BatchActionBar.tsx",
    "chars": 9841,
    "preview": "import { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { BatchActionType } from '"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkCardView.tsx",
    "chars": 7292,
    "preview": "import { useTranslation } from 'react-i18next'\r\nimport type { Bookmark, DefaultBookmarkIcon } from '@/lib/types'\r\nimport"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkForm.tsx",
    "chars": 11400,
    "preview": "import { useTags } from '@/hooks/useTags'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { ConfirmDialog } from '@"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListContainer.tsx",
    "chars": 4286,
    "preview": "import { useTranslation } from 'react-i18next'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { BookmarkListView }"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListItem.tsx",
    "chars": 8462,
    "preview": "import { memo, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { Bookmark } from '@/l"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListLayout.tsx",
    "chars": 11743,
    "preview": "/**\n * 统一书签列表布局\n * 公开分享页和私有书签页共享的骨架组件\n * 包含: TagSidebar + SearchToolbar + BookmarkListContainer + PaginationFooter + Mob"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListView.tsx",
    "chars": 3332,
    "preview": "import { useRef, useState, useEffect } from 'react'\r\nimport { useVirtualizer } from '@tanstack/react-virtual'\r\nimport ty"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkMinimalListView.tsx",
    "chars": 5568,
    "preview": "import { useTranslation } from 'react-i18next'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { useRecordClick } f"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkTitleView.tsx",
    "chars": 7411,
    "preview": "import { useRef } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { Bookmark, DefaultBookmarkI"
  },
  {
    "path": "tmarks/src/components/bookmarks/DefaultBookmarkIcon.tsx",
    "chars": 688,
    "preview": "import type { DefaultBookmarkIcon } from '@/lib/types'\r\n\r\ninterface DefaultBookmarkIconProps {\r\n  icon: DefaultBookmarkI"
  },
  {
    "path": "tmarks/src/components/bookmarks/SnapshotViewer.tsx",
    "chars": 9690,
    "preview": "import { useState, useEffect } from 'react';\r\nimport { useTranslation } from 'react-i18next';\r\nimport { createPortal } f"
  },
  {
    "path": "tmarks/src/components/bookmarks/TagSelector.tsx",
    "chars": 4526,
    "preview": "import { useRef } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { Tag } from '@/lib/types'\n\nin"
  },
  {
    "path": "tmarks/src/components/bookmarks/bookmark-utils.ts",
    "chars": 452,
    "preview": "/**\n * 生成Google Favicon URL作为fallback\n */\nexport const getFaviconUrl = (url: string): string => {\n  try {\n    const urlO"
  },
  {
    "path": "tmarks/src/components/bookmarks/defaultIconOptions.ts",
    "chars": 267,
    "preview": "import type { DefaultBookmarkIcon } from '@/lib/types'\r\n\r\n// 图标选项配置 - 仅保留动态图标\r\nexport const DEFAULT_ICON_OPTIONS: Array<"
  },
  {
    "path": "tmarks/src/components/bookmarks/hooks/useBookmarkFormState.ts",
    "chars": 1731,
    "preview": "import { useState } from 'react'\r\nimport type { Bookmark } from '@/lib/types'\r\n\r\nexport function useBookmarkFormState(bo"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/BookmarkActions.tsx",
    "chars": 2231,
    "preview": "/**\n * 共享的书签卡片操作组件:批量选择复选框 + 编辑按钮\n */\n\nimport { useTranslation } from 'react-i18next'\n\ninterface BatchCheckboxProps {\n  "
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/BookmarkTagList.tsx",
    "chars": 1315,
    "preview": "/**\n * 书签标签 + 快照按钮展示(共享)\n */\n\nimport type { Bookmark } from '@/lib/types'\nimport { SnapshotViewer } from '../SnapshotVie"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/MasonryGrid.tsx",
    "chars": 1962,
    "preview": "/**\n * 瀑布流网格容器\n * 负责列计算 + 置顶/普通分组 + 渲染子项\n */\n\nimport { useRef, type ReactNode } from 'react'\nimport type { Bookmark } fr"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/useFaviconFallback.ts",
    "chars": 1557,
    "preview": "import { useState, useMemo } from 'react'\nimport type { Bookmark } from '@/lib/types'\n\n/**\n * 书签图标三级回退:cover_image → fav"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/useResponsiveColumns.ts",
    "chars": 1543,
    "preview": "import { useState, useEffect, type RefObject } from 'react'\n\ninterface ColumnConfig {\n  minColumnWidth: number\n  gap: nu"
  },
  {
    "path": "tmarks/src/components/bookmarks/useBookmarkForm.ts",
    "chars": 7306,
    "preview": "import { useState, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { logger } from '@/lib"
  },
  {
    "path": "tmarks/src/components/bookmarks/useSnapshots.ts",
    "chars": 3101,
    "preview": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQue"
  },
  {
    "path": "tmarks/src/components/common/AdaptiveImage.tsx",
    "chars": 1658,
    "preview": "import { useEffect, useState, useCallback, memo } from 'react'\r\nimport { analyzeImage, type ImageType } from '@/lib/imag"
  },
  {
    "path": "tmarks/src/components/common/AlertDialog.tsx",
    "chars": 4577,
    "preview": "import { useEffect } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { useTranslation } from 'react-i18ne"
  },
  {
    "path": "tmarks/src/components/common/BookmarkIcons.tsx",
    "chars": 1722,
    "preview": "import { \n  LayoutGrid, \n  List, \n  AlignLeft, \n  Type, \n  Eye, \n  Lock, \n  Layers, \n  Calendar, \n  RefreshCw, \n  Bookma"
  },
  {
    "path": "tmarks/src/components/common/BottomNav.tsx",
    "chars": 1735,
    "preview": "import { Link, useLocation } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Layers, C"
  },
  {
    "path": "tmarks/src/components/common/CircularProgress.tsx",
    "chars": 2333,
    "preview": "import { useAnimatedProgress } from './useAnimatedProgress'\n\nexport interface CircularProgressProps {\n  percentage: numb"
  },
  {
    "path": "tmarks/src/components/common/ColorThemeSelector.tsx",
    "chars": 1378,
    "preview": "import { useTranslation } from 'react-i18next'\r\nimport { useThemeStore } from '@/stores/themeStore'\r\n\r\nexport function C"
  },
  {
    "path": "tmarks/src/components/common/ConfirmDialog.tsx",
    "chars": 4935,
    "preview": "import { useEffect } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { useTranslation } from 'react-i18ne"
  },
  {
    "path": "tmarks/src/components/common/DialogHost.tsx",
    "chars": 1237,
    "preview": "import { useDialogStore } from '@/stores/dialogStore'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'"
  },
  {
    "path": "tmarks/src/components/common/DragDropUpload.tsx",
    "chars": 8077,
    "preview": "/**\r\n * 拖拽上传组件\r\n * 支持拖拽和点击上传文件,提供良好的视觉反馈\r\n */\r\n\r\nimport { useState, useRef, useCallback } from 'react'\r\nimport { useTran"
  },
  {
    "path": "tmarks/src/components/common/Drawer.tsx",
    "chars": 3049,
    "preview": "import { useEffect, useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { createPortal } fro"
  },
  {
    "path": "tmarks/src/components/common/DropdownMenu.tsx",
    "chars": 5295,
    "preview": "import { useEffect, useRef, useState } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { Z_INDEX } from '"
  },
  {
    "path": "tmarks/src/components/common/ErrorBoundary.tsx",
    "chars": 1706,
    "preview": "import { Component } from 'react'\nimport type { ErrorInfo, ReactNode } from 'react'\n\ninterface Props {\n  children: React"
  },
  {
    "path": "tmarks/src/components/common/ErrorDisplay.tsx",
    "chars": 9244,
    "preview": "/**\r\n * 错误显示组件\r\n * 提供统一的错误状态显示和处理\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18nex"
  },
  {
    "path": "tmarks/src/components/common/LanguageSelector.tsx",
    "chars": 907,
    "preview": "import { useLanguage } from '@/hooks/useLanguage'\r\nimport { Globe } from 'lucide-react'\r\n\r\ninterface LanguageSelectorPro"
  },
  {
    "path": "tmarks/src/components/common/LazyImage.tsx",
    "chars": 2139,
    "preview": "/**\r\n * 懒加载图片组件\r\n * 支持图片懒加载和错误处理\r\n */\r\n\r\nimport { useState, useRef, useEffect, memo } from 'react'\r\nimport { useTranslat"
  },
  {
    "path": "tmarks/src/components/common/MobileHeader.tsx",
    "chars": 2066,
    "preview": "import { useTranslation } from 'react-i18next'\nimport { Menu, Search, MoreVertical } from 'lucide-react'\n\ninterface Mobi"
  },
  {
    "path": "tmarks/src/components/common/PaginationFooter.tsx",
    "chars": 1473,
    "preview": "import { useTranslation } from 'react-i18next'\r\n\r\ninterface PaginationFooterProps {\r\n  hasMore: boolean\r\n  isLoading: bo"
  },
  {
    "path": "tmarks/src/components/common/ProgressIndicator.tsx",
    "chars": 8532,
    "preview": "/**\r\n * 进度指示器组件\r\n * 提供丰富的进度显示和动画效果\r\n */\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { useTranslation } from '"
  },
  {
    "path": "tmarks/src/components/common/ResizablePanel.tsx",
    "chars": 3194,
    "preview": "import { useState, useRef, useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { GripVertic"
  },
  {
    "path": "tmarks/src/components/common/SearchToolbar.tsx",
    "chars": 5478,
    "preview": "/**\n * 共享搜索工具栏组件\n * 搜索输入 + 排序/可见性/视图模式切换\n * 被 BookmarksPage (TopActionBar) 和 PublicSharePage 复用\n */\n\nimport { ReactNode "
  },
  {
    "path": "tmarks/src/components/common/SimpleProgress.tsx",
    "chars": 1208,
    "preview": "import { useAnimatedProgress } from './useAnimatedProgress'\n\nexport interface SimpleProgressProps {\n  percentage: number"
  },
  {
    "path": "tmarks/src/components/common/SortSelector.tsx",
    "chars": 7402,
    "preview": "import { useState, useRef, useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { createPort"
  },
  {
    "path": "tmarks/src/components/common/ThemeToggle.tsx",
    "chars": 1493,
    "preview": "import { useTranslation } from 'react-i18next'\r\nimport { useThemeStore } from '@/stores/themeStore'\r\n\r\nexport function T"
  },
  {
    "path": "tmarks/src/components/common/Toast.tsx",
    "chars": 2773,
    "preview": "import { useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { X, CheckCircle, AlertCircle,"
  }
]

// ... and 183 more files (download for full content)

About this extraction

This page contains the full source code of the ai-tmarks/tmarks GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 383 files (1.5 MB), approximately 410.1k tokens, and a symbol index with 1104 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!