diff --git a/DIFF-VS-GOLANG.md b/DIFF-VS-GOLANG.md index 22437ae69..ea444f415 100644 --- a/DIFF-VS-GOLANG.md +++ b/DIFF-VS-GOLANG.md @@ -1,191 +1,52 @@ -# Current Branch (`master`) vs `golang` Branch: Remaining Differences +# TypeScript Branch vs `golang` Branch: Current Differences -> Updated: 2026-04-29. Baseline: `master` vs `golang@9bf648ac` (v0.28.0). -> REST API contract reference: [https://usememos.com/docs/api/0-28-0](https://usememos.com/docs/api/0-28-0) and `golang:proto/gen/openapi.yaml`. +> Updated: 2026-05-10. > -> **Excluded by design** (will not be closed in this fork): -> - Instance `STORAGE` setting backend API + dynamic `supportedStorageTypes` frontend rendering -> - SSE endpoint on Cloudflare Worker (CF streaming is not compatible with long-lived SSE) - ---- - -## 1) Database schema differences - -### 1.1 Table-level comparison (`migrations/0001_initial.sql` vs `store/migration/sqlite/LATEST.sql`) - -The 9 original business tables are **structurally identical** across both branches (column names, types, constraints, and defaults match): - -`system_setting`, `user`, `user_setting`, `memo`, `memo_relation`, `attachment`, `idp`, `inbox`, `reaction`, `memo_share` - -### 1.2 Tables exclusive to `golang` (not yet in `master`) - -| Table | Purpose | -| --- | --- | -| `user_identity` | Stores linked SSO / OAuth2 identities per user; supports the new `linkedIdentities` REST endpoints | - -`user_identity` schema in `golang`: -```sql -CREATE TABLE user_identity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - provider TEXT NOT NULL, - extern_uid TEXT NOT NULL, - created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), - updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), - UNIQUE (provider, extern_uid), - UNIQUE (user_id, provider) -); -CREATE INDEX idx_user_identity_user_id ON user_identity(user_id); -``` - -### 1.3 Tables exclusive to `master` - -| Table | Purpose | -| --- | --- | -| `schema_migrations(version INTEGER PK)` | Node incremental migration bookkeeping; not present in `golang` | +> Baseline: `chore/golang-v0.28.0-alignment@e8d0ff21` vs `golang@9bf648ac` (v0.28.0). +> +> The `golang` branch is the API/proto reference. This document tracks known implementation gaps and intentional TypeScript/Worker differences; it is not permission to introduce new API drift. -### 1.4 DDL-level differences (non-semantic) +## Designed Differences -| Difference | `master` | `golang` | +| Area | TypeScript branch | `golang` branch | | --- | --- | --- | -| `CREATE TABLE` guard | `CREATE TABLE IF NOT EXISTS` | `CREATE TABLE` | -| Index guard | `CREATE INDEX IF NOT EXISTS` | `CREATE INDEX` | +| Backend runtime | Hono REST API, Node.js + SQLite, Cloudflare Workers + D1 | Go server with gRPC-Gateway REST + Connect | +| Frontend hosting | `dist/public/` served by Node or Worker Static Assets | Go Echo fileserver | +| Worker assets | Cloudflare `ASSETS.fetch` with `run_worker_first` | Not applicable | +| Object storage | `DATABASE / LOCAL / S3 / R2` | `DATABASE / LOCAL / S3` | +| SSE | Node-only; Worker SSE is excluded | Go server SSE | -### 1.5 Migration mechanism +## Database -| Item | `master` | `golang` | +| Area | TypeScript branch | `golang` branch | | --- | --- | --- | -| Evolution model | Incremental `migrations/NNNN_*.sql` files | `store/migration/sqlite/*` version dirs + `LATEST.sql` | -| Version tracking | Writes explicit `schema_migrations` records | No equivalent table | - ---- - -## 2) Backend API differences (`server/routes/v1` vs `golang`) +| Schema source | Incremental `migrations/NNNN_*.sql` | `store/migration/sqlite/*` plus `LATEST.sql` | +| Version tracking | `schema_migrations` table | No equivalent table | +| Local runtime DB | SQLite file, default `data/memos.sqlite` | SQLite/PostgreSQL/MySQL support | +| Worker DB | Cloudflare D1, migrated by Wrangler | Not applicable | +| DDL style | Uses `IF NOT EXISTS` guards | Plain DDL | -> Reference: `golang:proto/gen/openapi.yaml` (auto-generated from proto definitions). +## Backend API -### 2.1 Missing endpoints in `master` - -| Method | Path | Notes | +| Area | TypeScript branch | `golang` branch | | --- | --- | --- | -| `GET` | `/api/v1/sse` | SSE endpoint exists in `master` but only for **Node.js** (`enableSSE: true`). CF Worker does not mount it (streaming incompatible). `golang` has it unconditionally. | - -> **Note:** `GET /api/v1/users/{user}:getStats` **is implemented** in `master` (handled inside the wildcard `GET /users/:username` handler via suffix matching), despite not being a named route. - -### 2.2 Path differences (master deviates from golang contract) - -| Resource | `master` path | `golang` / OpenAPI path | Impact | -| --- | --- | --- | --- | -| Instance settings (GET) | `GET /api/v1/instance/settings/{KEY}` | `GET /api/v1/{name=instance/settings/*}` | Both resolve to the same effective path; `master` uses a simpler param extraction. | -| Instance settings (PATCH) | `PATCH /api/v1/instance/settings/{KEY}` | `PATCH /api/v1/{setting.name=instance/settings/*}` | Same as above. | - -### 2.3 Semantic / field differences in existing routes - -| Module | `master` behavior | `golang` behavior | -| --- | --- | --- | -| **Instance `STORAGE` setting** | Returns extra `supportedStorageTypes` (dynamic, includes `R2`) | Fixed enum `DATABASE/LOCAL/S3`; no dynamic field — **excluded by design** | -| **Instance `AI` setting** | `instance/settings/AI` persisted; full `AIService` + AI provider config; `POST /api/v1/ai:transcribe` implemented (aligned) | Full `AIService` + AI instance settings (`InstanceSetting_Key.AI`) | -| **Memo `filter` / CEL** | Subset implementation in `server/lib/memo-filter.ts` (covers common patterns used by the web client: creator, visibility, tag, pinned, time range, content.contains) | Full CEL compilation semantics | -| **API transport** | `web/src/connect.ts` implements a **custom REST client** (~1110 lines) that translates gRPC-style service calls into plain JSON REST calls | `web/src/connect.ts` uses **Connect gRPC/protocol transport** via `@connectrpc/connect-web` (~203 lines); native binary+JSON Connect protocol | - -### 2.4 Resolved gaps +| Transport implementation | REST only: hand-written Hono routes under `/api/v1` | REST via gRPC-Gateway under `/api/v1`, plus Connect handlers | +| Storage setting response | Adds `supportedStorageTypes`, can include `R2` | Fixed proto enum without R2 | +| Memo / attachment filters | Implements a supported CEL-like subset | Full Go/proto behavior | +| SSE route | Mounted only when Node enables SSE | Go server route is available | -| Module | Status | -| --- | --- | -| Auth endpoints (`/signin`, `/signout`, `/refresh`, `/me`) | ✅ Fully aligned | -| User CRUD, PAT, webhook, notification, shortcut endpoints | ✅ Fully aligned | -| Memo CRUD, comments, reactions, relations, shares | ✅ Fully aligned | -| `DELETE /memos/{memo}` soft-delete / `?force=true` | ✅ Aligned — archives by default; `?force=true` hard-deletes | -| Attachment CRUD, `batchDelete`, `motionMedia` field | ✅ Fully aligned | -| `POST /api/v1/users:batchGet` | ✅ Implemented | -| Identity provider CRUD | ✅ Fully aligned | -| GENERAL settings persistence (`additionalScript`, `additionalStyle`, `customProfile`, `weekStartDayOffset`) | ✅ Fixed | -| **Memo `name` field** | ✅ Uses `"memos/{uid}"` (UUID v4 from `memo.uid` column) — aligned with golang | -| **`PATCH /memos/{memo}` updateMask** | ✅ `updateMask` is now required in the request body; server applies only the paths listed | -| **`GET /memos` `showDeleted` param** | ✅ `showDeleted=true` (or `show_deleted=true`) sets state to ARCHIVED — aligned with golang | -| **`PATCH /users/{user}/settings/{setting}` updateMask** | ✅ Already enforced — rejects empty updateMask | -| **MCP endpoint** | ✅ Implemented at `POST/GET/DELETE /mcp` (Streamable HTTP transport) in `server/routes/mcp.ts` | -| **`user_identity` table** | ✅ Added in `migrations/0002_user_identity.sql` | -| **`POST /api/v1/ai:transcribe`** | ✅ Implemented in `server/routes/v1/ai.ts`; reads AI provider from `instance/settings/AI` | -| **`GET/DELETE /api/v1/users/{user}/linkedIdentities[/{id}]`** | ✅ Implemented in `server/routes/v1/users.ts` | -| **Instance `AI` setting persistence** | ✅ `instance/settings/AI` key persisted and served via `server/lib/instance-ai-setting.ts` | - ---- - -## 3) Frontend differences (`web/`) - -### 3.1 Pages (`web/src/pages/`) - -All 14 pages exist in both branches. The following pages have notable differences: - -| Page | Nature of change in `master` vs `golang` | -| --- | --- | -| `SignIn.tsx` | `master` adds SSO sign-in form and extra UI (~87 lines); `golang` baseline is simpler | -| `MemoDetail.tsx` | `master` has sidebar/layout changes (~69 lines) | -| `Setting.tsx` | `golang` adds **AI** settings section and `LinkedIdentitySection`; `master` is missing these | -| `AuthCallback.tsx` | Minor differences (~13 lines) | -| `Inboxes.tsx` | Minor additions in `master` (~4 lines) | - -### 3.2 Components present in `master` but NOT in `golang` - -| Component | Notes | -| --- | --- | -| `MemoAttachment.tsx` | Single-attachment display (audio inline; other files show icon + filename) | -| `MemoResource.tsx` | Wraps `MemoAttachment` to flat-render a memo's attachment list | -| `SsoSignInForm.tsx` | SSO sign-in form component | -| `MemoActionMenu/MemoShareImageDialog.tsx` | Share-as-image dialog | -| `MemoActionMenu/MemoShareImagePreview.tsx` | Image preview for sharing | -| `MemoActionMenu/memoShareImage.ts` | Share image generation logic | -| `MemoContent/constants.ts` | Content rendering constants | -| `MemoEditor/hooks/useVoiceRecorder.ts` | Voice recorder hook (master equivalent of golang's `useAudioRecorder` + `useAudioWaveform`; all three now coexist) | -| `MemoEditor/services/` (6 files) | Service layer: cache, error, memo, upload, validation, index | -| `MemoEditor/state/` (5 files) | State management: actions, context, index, reducer, types | - -### 3.3 Components present in `golang` but NOT in `master` +## Frontend -All `golang`-only components have been added to `master`: +Current frontend differences are primarily adapter/runtime related: -| Component | Status | +| File / area | Difference | | --- | --- | -| `Settings/AISection.tsx` | ✅ Added — AI provider configuration panel (maps to `instance/settings/AI`) | -| `Settings/LinkedIdentitySection.tsx` | ✅ Added — lists and manages linked SSO identities per user | -| `Settings/InfoChip.tsx` | ✅ Added — reusable badge/chip used by `LinkedIdentitySection` and `SSOSection` | -| `router/guards.tsx` | ✅ Added — `LandingRoute`, `RequireAuthRoute`, `RequireGuestRoute` guard components | -| `helpers/sso-display.ts` | ✅ Added — SSO provider display utilities | - -### 3.4 Components that differ between branches +| `web/src/connect.ts` | Large custom REST adapter that converts frontend service calls to the TypeScript backend's JSON API. | +| `web/src/lib/proto-adapters.ts` | Converts REST responses into the existing frontend model types used by hooks/components. | +| Storage settings | `InstanceContext.tsx` and `StorageSection.tsx` support dynamic storage types and R2. | +| User menu | Live SSE connection indicator is removed because SSE is not available on Worker. | -All previously diverged components have been aligned with `golang`: +## Still Excluded -| Component | Status | -| --- | --- | -| `Settings/SSOSection.tsx` | ✅ Aligned — imports `InfoChip`, `sso-display` utilities, uses `IdentityProviderRow` with error handling | -| `Settings/MyAccountSection.tsx` | ✅ Aligned — adds delete-account functionality and renders `LinkedIdentitySection` | -| `router/index.tsx` | ✅ Aligned — uses `RequireAuthRoute`/`RequireGuestRoute` guards; exports `routeConfig` | -| `web/src/App.tsx` | ✅ Aligned — adds `cleanupExpiredOAuthState()` on mount | - -### 3.5 Realtime refresh (SSE) - -The `/api/v1/sse` endpoint is mounted in `master` **Node.js only** (via `enableSSE: true`). CF Worker does not expose SSE. `golang` exposes it unconditionally. - ---- - -## 4) Runtime / deployment differences - -| Area | `master` | `golang` | -| --- | --- | --- | -| Frontend static hosting | Worker `ASSETS` binding / Node local static dir (`dist/public/`) | Built-in Echo fileserver | -| Primary DB | Node → SQLite; Worker → D1 (Cloudflare) | Single runtime (SQLite / PostgreSQL / MySQL via driver) | -| Object storage | `DATABASE / LOCAL / S3 / R2` | `DATABASE / LOCAL / S3` (no R2) | -| Realtime (SSE) | Node.js only; CF Worker excluded | Unconditionally available | -| MCP | ✅ Implemented at `/mcp` (Streamable HTTP, stateless per-request) | `server/router/mcp/*` (stateful sessions) | -| Frontend API transport | Custom REST client in `connect.ts` | Connect gRPC/protocol via `@connectrpc/connect-web` | - ---- - -## 5) CI / Quality gates - -| Item | Notes | -| --- | --- | -| GitHub Actions CI | `.github/workflows/ci.yml` runs type-check, tests, and uploads coverage to Codecov on every push/PR targeting `master` | -| Branch protection | Merging to `master` requires CI to pass | +- Backend/API support for R2 storage intentionally differs from upstream Go. +- Cloudflare Worker SSE remains excluded because long-lived SSE streams are not a good fit for this Worker deployment path. diff --git a/DIFF-VS-GOLANG.zh-CN.md b/DIFF-VS-GOLANG.zh-CN.md index 5be7aba4a..aa96f76ce 100644 --- a/DIFF-VS-GOLANG.zh-CN.md +++ b/DIFF-VS-GOLANG.zh-CN.md @@ -1,191 +1,52 @@ -# 当前分支(`master`)vs `golang` 分支:剩余差异 +# TypeScript 分支 vs `golang` 分支:当前差异 -> 更新时间:2026-04-29。对照基线:`master` vs `golang@9bf648ac`(v0.28.0)。 -> REST API 契约参考:[https://usememos.com/docs/api/0-28-0](https://usememos.com/docs/api/0-28-0) 及 `golang:proto/gen/openapi.yaml`。 +> 更新时间:2026-05-10。 > -> **设计排除项**(本 fork 不计划对齐): -> - Instance `STORAGE` 设置后端 API + 动态 `supportedStorageTypes` 前端渲染 -> - Cloudflare Worker 上的 SSE 接口(CF 流式传输与长连接 SSE 不兼容) - ---- - -## 1) 数据库表结构差异 - -### 1.1 表级对比(`migrations/0001_initial.sql` vs `store/migration/sqlite/LATEST.sql`) - -原有 9 张业务表**结构完全相同**(列名、类型、约束、默认值均一致): - -`system_setting`、`user`、`user_setting`、`memo`、`memo_relation`、`attachment`、`idp`、`inbox`、`reaction`、`memo_share` - -### 1.2 仅 `golang` 存在的表(`master` 尚未添加) - -| 表 | 用途 | -| --- | --- | -| `user_identity` | 存储每个用户的已关联 SSO / OAuth2 身份;支持新增的 `linkedIdentities` REST 接口 | - -`golang` 中 `user_identity` 的表结构: -```sql -CREATE TABLE user_identity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - provider TEXT NOT NULL, - extern_uid TEXT NOT NULL, - created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), - updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), - UNIQUE (provider, extern_uid), - UNIQUE (user_id, provider) -); -CREATE INDEX idx_user_identity_user_id ON user_identity(user_id); -``` - -### 1.3 仅 `master` 存在的表 - -| 表 | 用途 | -| --- | --- | -| `schema_migrations(version INTEGER PK)` | Node 递增迁移版本记录,`golang` 无此表 | +> 对照基线:`chore/golang-v0.28.0-alignment@e8d0ff21` vs `golang@9bf648ac`(v0.28.0)。 +> +> `golang` 分支是 API/proto 的权威参考。本文件只记录已知实现差异和 TypeScript/Worker 的设计差异,不代表可以继续引入新的 API 分叉。 -### 1.4 DDL 层差异(非结构语义) +## 设计差异 -| 差异 | `master` | `golang` | +| 领域 | TypeScript 分支 | `golang` 分支 | | --- | --- | --- | -| `CREATE TABLE` 保护 | `CREATE TABLE IF NOT EXISTS` | `CREATE TABLE` | -| 索引保护 | `CREATE INDEX IF NOT EXISTS` | `CREATE INDEX` | +| 后端运行时 | Hono REST API,Node.js + SQLite,Cloudflare Workers + D1 | Go 服务端,gRPC-Gateway REST + Connect | +| 前端托管 | Node 或 Worker Static Assets 提供 `dist/public/` | Go Echo 文件服务器 | +| Worker 静态资源 | Cloudflare `ASSETS.fetch`,`run_worker_first` | 不适用 | +| 对象存储 | `DATABASE / LOCAL / S3 / R2` | `DATABASE / LOCAL / S3` | +| SSE | 仅 Node;Worker SSE 设计排除 | Go 服务端 SSE | -### 1.5 迁移机制差异 +## 数据库 -| 项目 | `master` | `golang` | +| 领域 | TypeScript 分支 | `golang` 分支 | | --- | --- | --- | -| 演进方式 | 递增 `migrations/NNNN_*.sql` 文件 | `store/migration/sqlite/*` 版本目录 + `LATEST.sql` | -| 版本追踪 | 显式写入 `schema_migrations` 记录 | 无等价表 | - ---- - -## 2) 后端 API 差异(`server/routes/v1` 对照 `golang`) +| 表结构来源 | 递增 `migrations/NNNN_*.sql` | `store/migration/sqlite/*` 加 `LATEST.sql` | +| 版本记录 | `schema_migrations` 表 | 无等价表 | +| 本地运行库 | SQLite 文件,默认 `data/memos.sqlite` | SQLite/PostgreSQL/MySQL | +| Worker 数据库 | Cloudflare D1,由 Wrangler 迁移 | 不适用 | +| DDL 风格 | 使用 `IF NOT EXISTS` 保护 | 原始 DDL | -> 参考:`golang:proto/gen/openapi.yaml`(由 proto 定义自动生成)。 +## 后端 API -### 2.1 `master` 中缺少的接口 - -| 方法 | 路径 | 说明 | +| 领域 | TypeScript 分支 | `golang` 分支 | | --- | --- | --- | -| `GET` | `/api/v1/sse` | SSE 接口在 `master` 中**仅 Node.js** 可用(`enableSSE: true`);CF Worker 不挂载(流式传输不兼容)。`golang` 无条件提供。 | - -> **注:** `GET /api/v1/users/{user}:getStats` **已在 `master` 中实现**(通过通配路由 `GET /users/:username` 按后缀匹配分发),只是未作为独立命名路由注册。 - -### 2.2 路径差异(`master` 与 golang 契约不一致) - -| 资源 | `master` 路径 | `golang` / OpenAPI 路径 | 影响 | -| --- | --- | --- | --- | -| 实例设置(读) | `GET /api/v1/instance/settings/{KEY}` | `GET /api/v1/{name=instance/settings/*}` | 两者实际解析路径相同,`master` 使用更简洁的参数提取方式 | -| 实例设置(写) | `PATCH /api/v1/instance/settings/{KEY}` | `PATCH /api/v1/{setting.name=instance/settings/*}` | 同上 | - -### 2.3 已有接口的语义/字段差异 - -| 模块 | `master` 行为 | `golang` 行为 | -| --- | --- | --- | -| **Instance `STORAGE` 设置** | 额外返回 `supportedStorageTypes`(动态,含 `R2`) | 固定枚举 `DATABASE/LOCAL/S3`,无动态字段 — **设计排除** | -| **Instance `AI` 设置** | `instance/settings/AI` 键已持久化;完整 `AIService` + AI 提供商配置;`POST /api/v1/ai:transcribe` 已实现(已对齐) | 完整 `AIService` + AI 实例设置(`InstanceSetting_Key.AI`) | -| **Memo `filter` / CEL** | `server/lib/memo-filter.ts` 子集实现(覆盖 Web 客户端常用模式:creator、visibility、tag、pinned、时间范围、content.contains) | 完整 CEL 编译语义 | -| **API 传输层** | `web/src/connect.ts` 实现**自定义 REST 客户端**(约 1110 行),将 gRPC 风格的服务调用翻译为普通 JSON REST 请求 | `web/src/connect.ts` 通过 `@connectrpc/connect-web` 使用 **Connect gRPC/协议传输**(约 203 行);原生 binary+JSON Connect 协议 | - -### 2.4 已对齐项 +| 传输实现 | 仅 REST:`/api/v1` 下的手写 Hono 路由 | `/api/v1` 下的 gRPC-Gateway REST,另有 Connect handlers | +| 存储设置响应 | 额外返回 `supportedStorageTypes`,可包含 `R2` | 固定 proto 枚举,无 R2 | +| Memo / 附件过滤 | 实现支持范围内的 CEL-like 子集 | Go/proto 完整行为 | +| SSE 路由 | 仅 Node 开启 SSE 时挂载 | Go 服务端可用 | -| 模块 | 状态 | -| --- | --- | -| 认证接口(`/signin`、`/signout`、`/refresh`、`/me`) | ✅ 完全对齐 | -| 用户 CRUD、PAT、Webhook、通知、快捷方式接口 | ✅ 完全对齐 | -| Memo CRUD、评论、反应、关联、分享接口 | ✅ 完全对齐 | -| `DELETE /memos/{memo}` 软删除 / `?force=true` | ✅ 已对齐 — 默认归档;`?force=true` 硬删除 | -| Attachment CRUD、`batchDelete`、`motionMedia` 字段 | ✅ 完全对齐 | -| `POST /api/v1/users:batchGet` | ✅ 已实现 | -| 身份提供商 CRUD | ✅ 完全对齐 | -| GENERAL 设置持久化(`additionalScript`、`additionalStyle`、`customProfile`、`weekStartDayOffset`) | ✅ 已修复 | -| **Memo `name` 字段** | ✅ 已使用 `"memos/{uid}"`(UUID v4,来自 `memo.uid` 列)— 与 golang 对齐 | -| **`PATCH /memos/{memo}` updateMask** | ✅ 请求体现在必须包含 `updateMask`;服务端仅应用指定路径的字段 | -| **`GET /memos` `showDeleted` 参数** | ✅ `showDeleted=true`(或 `show_deleted=true`)将 state 设为 ARCHIVED — 与 golang 对齐 | -| **`PATCH /users/{user}/settings/{setting}` updateMask** | ✅ 已强制校验 — 空 updateMask 会返回错误 | -| **MCP 接口** | ✅ 已在 `server/routes/mcp.ts` 实现,挂载于 `POST/GET/DELETE /mcp`(Streamable HTTP 传输) | -| **`user_identity` 表** | ✅ 已通过 `migrations/0002_user_identity.sql` 添加 | -| **`POST /api/v1/ai:transcribe`** | ✅ 已在 `server/routes/v1/ai.ts` 实现;读取 `instance/settings/AI` 中的 AI 提供商配置 | -| **`GET/DELETE /api/v1/users/{user}/linkedIdentities[/{id}]`** | ✅ 已在 `server/routes/v1/users.ts` 实现 | -| **Instance `AI` 设置持久化** | ✅ `instance/settings/AI` 键已持久化并通过 `server/lib/instance-ai-setting.ts` 提供服务 | - ---- - -## 3) 前端差异(`web/`) - -### 3.1 页面(`web/src/pages/`) - -两个分支均有全部 14 个页面。以下页面在两分支之间有显著差异: - -| 页面 | `master` vs `golang` 差异性质 | -| --- | --- | -| `SignIn.tsx` | `master` 新增 SSO 登录表单及额外 UI(约 87 行);`golang` 基线更简洁 | -| `MemoDetail.tsx` | `master` 有侧边栏/布局调整(约 69 行) | -| `Setting.tsx` | `golang` 新增 **AI** 设置区块和 `LinkedIdentitySection`;`master` 缺少这些 | -| `AuthCallback.tsx` | 小幅差异(约 13 行) | -| `Inboxes.tsx` | `master` 小幅新增(约 4 行) | - -### 3.2 仅 `master` 存在的组件(master 独有扩展) - -| 组件 | 说明 | -| --- | --- | -| `MemoAttachment.tsx` | 单条附件展示(音频内联播放;其他文件显示图标+文件名) | -| `MemoResource.tsx` | 组合 `MemoAttachment`,将 Memo 附件列表平铺渲染 | -| `SsoSignInForm.tsx` | SSO 登录表单组件 | -| `MemoActionMenu/MemoShareImageDialog.tsx` | 分享为图片弹窗 | -| `MemoActionMenu/MemoShareImagePreview.tsx` | 分享图片预览 | -| `MemoActionMenu/memoShareImage.ts` | 分享图片生成逻辑 | -| `MemoContent/constants.ts` | 内容渲染常量 | -| `MemoEditor/hooks/useVoiceRecorder.ts` | 语音录制 Hook(与 golang 的 `useAudioRecorder` + `useAudioWaveform` 并存) | -| `MemoEditor/services/`(6 个文件) | 服务层:cache、error、memo、upload、validation、index | -| `MemoEditor/state/`(5 个文件) | 状态管理:actions、context、index、reducer、types | - -### 3.3 仅 `golang` 存在的组件(`master` 缺少) +## 前端 -所有 `golang` 独有组件均已添加至 `master`: +当前前端差异主要来自适配层和运行时: -| 组件 | 状态 | +| 文件 / 领域 | 差异 | | --- | --- | -| `Settings/AISection.tsx` | ✅ 已添加 — AI 提供商配置面板(对应 `instance/settings/AI`) | -| `Settings/LinkedIdentitySection.tsx` | ✅ 已添加 — 列出并管理用户已关联的 SSO 身份 | -| `Settings/InfoChip.tsx` | ✅ 已添加 — 可复用 badge/chip 组件 | -| `router/guards.tsx` | ✅ 已添加 — `LandingRoute`、`RequireAuthRoute`、`RequireGuestRoute` 路由守卫组件 | -| `helpers/sso-display.ts` | ✅ 已添加 — SSO 提供商展示工具函数 | - -### 3.4 两分支之间有差异的组件 +| `web/src/connect.ts` | 大型自定义 REST 适配层,把前端 service 调用转换为 TypeScript 后端 JSON API。 | +| `web/src/lib/proto-adapters.ts` | 将 REST 响应转换为现有 hooks/components 使用的前端模型类型。 | +| 存储设置 | `InstanceContext.tsx` 和 `StorageSection.tsx` 支持动态存储类型与 R2。 | +| 用户菜单 | 移除了实时 SSE 连接状态提示,因为 Worker 不提供 SSE。 | -所有此前存在差异的组件均已与 `golang` 对齐: +## 仍然设计排除 -| 组件 | 状态 | -| --- | --- | -| `Settings/SSOSection.tsx` | ✅ 已对齐 — 引入 `InfoChip`、`sso-display` 工具函数,使用 `IdentityProviderRow` 并增加错误处理 | -| `Settings/MyAccountSection.tsx` | ✅ 已对齐 — 新增删除账号功能并渲染 `LinkedIdentitySection` | -| `router/index.tsx` | ✅ 已对齐 — 使用 `RequireAuthRoute`/`RequireGuestRoute` 守卫;导出 `routeConfig` | -| `web/src/App.tsx` | ✅ 已对齐 — 在挂载时调用 `cleanupExpiredOAuthState()` | - -### 3.5 实时刷新(SSE) - -`/api/v1/sse` 接口在 `master` 中**仅 Node.js** 可用(`enableSSE: true`);CF Worker 不挂载。`golang` 无条件提供。 - ---- - -## 4) 运行时 / 部署差异 - -| 领域 | `master` | `golang` | -| --- | --- | --- | -| 前端静态资源托管 | Worker `ASSETS` 绑定 / Node 本地静态目录(`dist/public/`) | echo 内置文件服务器 | -| 主数据库 | Node → SQLite;Worker → D1(Cloudflare) | 单一运行时(SQLite / PostgreSQL / MySQL) | -| 对象存储 | `DATABASE / LOCAL / S3 / R2` | `DATABASE / LOCAL / S3`(无 R2) | -| 实时推送(SSE) | 仅 Node.js;CF Worker 排除 | 无条件可用 | -| MCP 接口 | ✅ 已实现,挂载于 `/mcp`(无状态逐请求模式) | `server/router/mcp/*`(有状态会话模式) | -| 前端 API 传输层 | `connect.ts` 自定义 REST 客户端 | 通过 `@connectrpc/connect-web` 的 Connect gRPC/协议传输 | - ---- - -## 5) CI / 质量门禁 - -| 项目 | 说明 | -| --- | --- | -| GitHub Actions CI | `.github/workflows/ci.yml` 在每次推送及 PR 到 `master` 时执行类型检查、测试,并将覆盖率上报至 Codecov | -| 分支保护 | 合并到 `master` 须 CI 通过 | +- R2 存储的后端/API 支持刻意不同于上游 Go。 +- Cloudflare Worker SSE 仍然排除,因为长连接 SSE 不适合当前 Worker 部署路径。 diff --git a/README.md b/README.md index 5d983b40b..481e07216 100644 --- a/README.md +++ b/README.md @@ -86,20 +86,17 @@ Naming convention: `db::` - actions: `migrate`, `empty`, `clear` - targets: `sqlite`, `d1:local`, `d1:remote` (remote for migrate only) -Examples: - -```bash -# SQLite -npm run db:migrate:sqlite -npm run db:empty:sqlite -npm run db:clear:sqlite +| Goal | Command | +| --- | --- | +| Apply local SQLite migrations | `npm run db:migrate:sqlite` | +| Empty local SQLite data, keep schema | `npm run db:empty:sqlite` | +| Delete local SQLite database file | `npm run db:clear:sqlite` | +| Apply local D1 migrations | `npm run db:migrate:d1:local` | +| Apply remote D1 migrations | `npm run db:migrate:d1:remote` | +| Empty local D1 data, keep schema | `npm run db:empty:d1:local` | +| Delete local Wrangler state | `npm run db:clear:d1:local` | -# D1 -npm run db:migrate:d1:local -npm run db:migrate:d1:remote -npm run db:empty:d1:local -npm run db:clear:d1:local -``` +For `empty` / `clear`, stop the corresponding dev server first. SQLite `empty` / `clear` and D1 local `clear` accept `-- --yes` for non-interactive runs. ## Migrations (Node + D1) @@ -147,16 +144,18 @@ npm run db:migrate:d1:remote 4. Deploy: +**One-step full release** (same as upload + 100% promote): + ```bash -npx wrangler login -npm run deploy:worker +npm run deploy:worker:full ``` -Current deploy scripts in `package.json`: +**Two-step** (upload, then route traffic in `promote`; 100% = full, under 100% = canary — interactive or `--percentage`, see `wrangler versions deploy --help`): -- `npm run deploy:worker` -> versions upload -- `npm run deploy:worker:promote` -> versions deploy -- `npm run deploy:worker:full` -> full deploy +```bash +npm run deploy:worker +npm run deploy:worker:promote +``` ## Environment variables (Node, selected) @@ -172,7 +171,7 @@ Current deploy scripts in `package.json`: ## API contract -For API/proto parity, use this repository's `origin/master` as source of truth: +For API/proto parity, use this repository's `golang` branch as source of truth: - `proto/` - Go tree under `server/` and `plugin/` diff --git a/README.zh-CN.md b/README.zh-CN.md index 4322b64ce..4070c0ea9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,20 +84,17 @@ npm run dev:worker - `action`:`migrate` / `empty` / `clear` - `target`:`sqlite` / `d1:local` / `d1:remote`(`remote` 仅用于 migrate) -示例: - -```bash -# SQLite -npm run db:migrate:sqlite -npm run db:empty:sqlite -npm run db:clear:sqlite +| 目的 | 命令 | +| --- | --- | +| 执行本地 SQLite 迁移 | `npm run db:migrate:sqlite` | +| 清空本地 SQLite 数据,保留表结构 | `npm run db:empty:sqlite` | +| 删除本地 SQLite 数据库文件 | `npm run db:clear:sqlite` | +| 执行本地 D1 迁移 | `npm run db:migrate:d1:local` | +| 执行远程 D1 迁移 | `npm run db:migrate:d1:remote` | +| 清空本地 D1 数据,保留表结构 | `npm run db:empty:d1:local` | +| 删除本地 Wrangler 状态 | `npm run db:clear:d1:local` | -# D1 -npm run db:migrate:d1:local -npm run db:migrate:d1:remote -npm run db:empty:d1:local -npm run db:clear:d1:local -``` +执行 `empty` / `clear` 前请先停掉对应开发服务。SQLite 的 `empty` / `clear` 和本地 D1 的 `clear` 支持加 `-- --yes` 非交互执行。 ## 迁移(Node 与 D1) @@ -145,16 +142,18 @@ npm run db:migrate:d1:remote 4. 部署: +**一步全量**(等同于 upload + 100% promote): + ```bash -npx wrangler login -npm run deploy:worker +npm run deploy:worker:full ``` -当前 `package.json` 中部署相关脚本: +**分步**(先上传,再在 `promote` 时切流;全量选 100%,灰度选小于 100% 或传 `--percentage`,见 `wrangler versions deploy --help`): -- `npm run deploy:worker` -> versions upload -- `npm run deploy:worker:promote` -> versions deploy -- `npm run deploy:worker:full` -> full deploy +```bash +npm run deploy:worker +npm run deploy:worker:promote +``` ## 环境变量(Node 常用) @@ -170,7 +169,7 @@ npm run deploy:worker ## API 契约说明 -API/proto 对齐时,以仓库 `origin/master` 为准,重点看: +API/proto 对齐时,以仓库 `golang` 分支为准,重点看: - `proto/` - Go 树下的 `server/` 与 `plugin/` diff --git a/package-lock.json b/package-lock.json index 326ba1937..e17f46185 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memos", - "version": "0.26.2", + "version": "0.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memos", - "version": "0.26.2", + "version": "0.28.0", "dependencies": { "@aws-sdk/client-s3": "^3.1024.0", "@aws-sdk/s3-request-presigner": "^3.1024.0", @@ -15,6 +15,8 @@ "better-sqlite3": "^12.8.0", "hono": "^4.7.0", "jose": "^6.2.2", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.2", "nodemailer": "^8.0.4", "zod": "^4.3.6" @@ -3463,6 +3465,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3489,6 +3500,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -5202,6 +5219,43 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -5498,6 +5552,28 @@ "micromark-util-symbol": "^2.0.0" } }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, "node_modules/micromark-util-encode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", @@ -6880,6 +6956,19 @@ "pathe": "^2.0.3" } }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index d050d461a..5081022fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "memos", - "version": "0.26.2", + "version": "0.28.0", "private": true, "repository": { "type": "git", @@ -40,6 +40,8 @@ "better-sqlite3": "^12.8.0", "hono": "^4.7.0", "jose": "^6.2.2", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.2", "nodemailer": "^8.0.4", "zod": "^4.3.6" diff --git a/server/app.ts b/server/app.ts index 3716651eb..c28965ec7 100644 --- a/server/app.ts +++ b/server/app.ts @@ -11,7 +11,7 @@ import { verifyRefreshToken } from "./services/jwt-refresh.js"; import { parseInstanceStorageSetting } from "./lib/instance-storage-setting.js"; import { resolveAttachmentStorage } from "./services/attachment-storage-resolver.js"; import { parseCookieHeader, REFRESH_COOKIE_NAME } from "./lib/cookies.js"; -import { createMcpHandler } from "./routes/mcp.js"; +import { parseUserAvatarDataUri } from "./lib/user-avatar-data-uri.js"; /** Shared HTTP app. Mounts `GET /healthz` and `/api/v1` for Node and Worker. */ export function createApp(deps: AppDeps) { @@ -84,6 +84,16 @@ export function createApp(deps: AppDeps) { let attachmentUid = ""; if (p.length >= 2 && p[0] === "attachments") { attachmentUid = p[1] ?? ""; + } else if (p.length >= 3 && p[0] === "users" && p[2] === "avatar") { + const username = decodeURIComponent(p[1] ?? ""); + const user = username ? await repo.getUser(username) : null; + if (!user?.avatar_url) return c.notFound(); + const avatar = parseUserAvatarDataUri(user.avatar_url); + if (!avatar) return c.notFound(); + const headers = new Headers(); + headers.set("Content-Type", avatar.imageType); + headers.set("Cache-Control", "public, max-age=3600"); + return new Response(avatar.bytes, { status: 200, headers }); } else if (p.length >= 1) { attachmentUid = p[0] ?? ""; } @@ -151,11 +161,5 @@ export function createApp(deps: AppDeps) { }); app.route("/api/v1", createV1App(deps)); - // MCP (Model Context Protocol) endpoint — works on Node.js and Cloudflare Worker. - const mcpHandler = createMcpHandler(deps); - app.all("/mcp", async (c) => { - return mcpHandler(c.req.raw); - }); - return app; } diff --git a/server/db/repository.ts b/server/db/repository.ts index eb0347e7e..b3b47ff91 100644 --- a/server/db/repository.ts +++ b/server/db/repository.ts @@ -1715,6 +1715,14 @@ export function createRepository(sql: SqlAdapter) { return rows[0] ?? null; }, + async getUserIdentityByProviderExternUid(provider: string, externUid: string): Promise { + const rows = await sql.queryAll( + "SELECT id, user_id, provider, extern_uid, created_ts, updated_ts FROM user_identity WHERE provider = ? AND extern_uid = ?", + [provider, externUid], + ); + return rows[0] ?? null; + }, + async upsertUserIdentity(userId: number, provider: string, externUid: string): Promise { await sql.execute( `INSERT INTO user_identity (user_id, provider, extern_uid) diff --git a/server/lib/attachment-filter.ts b/server/lib/attachment-filter.ts index 26bef1380..351270fb2 100644 --- a/server/lib/attachment-filter.ts +++ b/server/lib/attachment-filter.ts @@ -2,8 +2,20 @@ export type ParsedAttachmentFilter = { unlinkedOnly?: boolean; linkedOnly?: boolean; memoUid?: string; + filenameContains?: string; + mimeTypeEq?: string; + mimeTypeNe?: string; + mimeTypeIn?: string[]; }; +function parseJsonValue(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + export function parseAttachmentFilter(rawFilter: string): ParsedAttachmentFilter { const filter = rawFilter.trim(); if (!filter) return {}; @@ -17,5 +29,40 @@ export function parseAttachmentFilter(rawFilter: string): ParsedAttachmentFilter if (byMemo) { return { memoUid: byMemo[2] }; } + const filenameContains = filter.match(/^filename\.contains\(([\s\S]+)\)$/); + if (filenameContains) { + const value = parseJsonValue(filenameContains[1]!.trim()); + if (typeof value === "string") return { filenameContains: value }; + } + const mimeEq = filter.match(/^mime_type\s*==\s*("[\s\S]*")$/); + if (mimeEq) { + const value = parseJsonValue(mimeEq[1]!.trim()); + if (typeof value === "string") return { mimeTypeEq: value }; + } + const mimeNe = filter.match(/^mime_type\s*!=\s*("[\s\S]*")$/); + if (mimeNe) { + const value = parseJsonValue(mimeNe[1]!.trim()); + if (typeof value === "string") return { mimeTypeNe: value }; + } + const mimeIn = filter.match(/^mime_type\s+in\s+(\[[\s\S]*\])$/); + if (mimeIn) { + const value = parseJsonValue(mimeIn[1]!.trim()); + if (Array.isArray(value) && value.every((x) => typeof x === "string")) { + return { mimeTypeIn: value }; + } + } throw new Error("unsupported filter expression"); } + +export function attachmentMatchesParsedFilter( + attachment: { filename: string; type: string }, + filter: ParsedAttachmentFilter, +): boolean { + if (filter.filenameContains !== undefined && !attachment.filename.includes(filter.filenameContains)) { + return false; + } + if (filter.mimeTypeEq !== undefined && attachment.type !== filter.mimeTypeEq) return false; + if (filter.mimeTypeNe !== undefined && attachment.type === filter.mimeTypeNe) return false; + if (filter.mimeTypeIn !== undefined && !filter.mimeTypeIn.includes(attachment.type)) return false; + return true; +} diff --git a/server/lib/public-routes.ts b/server/lib/public-routes.ts index a14c39967..9c886dbf3 100644 --- a/server/lib/public-routes.ts +++ b/server/lib/public-routes.ts @@ -13,6 +13,7 @@ export function isPublicApiRoute(method: string, pathname: string): boolean { if (m === "GET" && pathname.startsWith("/api/v1/instance/settings/")) return true; if (pathname === "/api/v1/users" && m === "POST") return true; + if (pathname === "/api/v1/users:batchGet" && m === "POST") return true; if (pathname === "/api/v1/users:stats" && m === "GET") return true; if (m === "GET" && pathname.startsWith("/api/v1/users/")) { diff --git a/server/lib/serializers.ts b/server/lib/serializers.ts index 409bcb385..ed3a424b8 100644 --- a/server/lib/serializers.ts +++ b/server/lib/serializers.ts @@ -2,6 +2,7 @@ import type { DbAttachmentRow, DbMemoRow, DbUserRow } from "../db/repository.js" import type { AuthPrincipal } from "../types/auth.js"; import { deriveMemoProperty } from "../services/memo-content-props.js"; import { extractTags } from "../services/markdown.js"; +import { parseUserAvatarDataUri } from "./user-avatar-data-uri.js"; /** Proto JSON / picky clients (e.g. Swift OpenAPI date-time) often match this shape; see `auth/signin` accessTokenExpiresAt. */ export function protoJsonTimestamp(iso: string): string { @@ -28,13 +29,18 @@ function canViewerSeeUserEmail(viewer: AuthPrincipal | null, target: DbUserRow): * (same as golang `canViewerAccessUserEmail`). */ export function userToJson(u: DbUserRow, viewer: AuthPrincipal | null) { + const storedAvatarUrl = u.avatar_url ?? ""; + const avatarUrl = + storedAvatarUrl && parseUserAvatarDataUri(storedAvatarUrl) + ? `/file/users/${encodeURIComponent(u.username)}/avatar` + : storedAvatarUrl; return { name: `users/${u.username}`, role: u.role === "ADMIN" ? "ADMIN" : "USER", username: u.username, email: canViewerSeeUserEmail(viewer, u) ? (u.email ?? "") : "", displayName: u.display_name ?? "", - avatarUrl: u.avatar_url ?? "", + avatarUrl, description: u.description ?? "", state: u.state, createTime: protoJsonTimestamp(u.create_time), @@ -44,7 +50,12 @@ export function userToJson(u: DbUserRow, viewer: AuthPrincipal | null) { export function memoToJson( m: DbMemoRow, - extras?: { tags?: string[]; attachments?: ReturnType[] }, + extras?: { + tags?: string[]; + attachments?: ReturnType[]; + relations?: unknown[]; + reactions?: unknown[]; + }, ) { const lat = m.location_latitude; const lng = m.location_longitude; @@ -69,8 +80,8 @@ export function memoToJson( (m.payload_tags.length > 0 ? m.payload_tags : extractTags(m.content)), pinned: Boolean(m.pinned), attachments: extras?.attachments ?? [], - relations: [], - reactions: [], + relations: extras?.relations ?? [], + reactions: extras?.reactions ?? [], property: m.payload_property ?? deriveMemoProperty(m.content), snippet: m.snippet ?? "", parent: m.parent_memo_id ? `memos/${m.parent_memo_id}` : undefined, diff --git a/server/lib/update-mask.ts b/server/lib/update-mask.ts new file mode 100644 index 000000000..f4881f1d4 --- /dev/null +++ b/server/lib/update-mask.ts @@ -0,0 +1,25 @@ +import type { Context } from "hono"; + +type MaskBody = { + updateMask?: { paths?: string[] }; + update_mask?: { paths?: string[] }; +}; + +/** Parse FieldMask paths from query (`updateMask` / `update_mask`, comma-separated) per grpc-gateway. */ +export function fieldMaskPathsFromQuery(c: Context): string[] { + const raw = c.req.query("updateMask") ?? c.req.query("update_mask"); + if (!raw) return []; + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); +} + +export function fieldMaskPathsFromBody(body: MaskBody | null | undefined): string[] { + return body?.updateMask?.paths ?? body?.update_mask?.paths ?? []; +} + +/** Merge query + body mask paths (proto: `body: "memo"` leaves FieldMask on the query string). */ +export function resolveFieldMaskPaths(c: Context, body: MaskBody | null | undefined): string[] { + return [...new Set([...fieldMaskPathsFromQuery(c), ...fieldMaskPathsFromBody(body)])]; +} diff --git a/server/lib/user-avatar-data-uri.ts b/server/lib/user-avatar-data-uri.ts index 2562b802c..351a6ea9b 100644 --- a/server/lib/user-avatar-data-uri.ts +++ b/server/lib/user-avatar-data-uri.ts @@ -12,6 +12,23 @@ const ALLOWED_AVATAR_TYPES: Record = { "image/webp": true, }; +export function parseUserAvatarDataUri( + avatarUrl: string, +): { imageType: string; bytes: Uint8Array } | null { + const m = DATA_URI_RE.exec(avatarUrl); + if (!m) return null; + const imageType = m[1] ?? ""; + if (!ALLOWED_AVATAR_TYPES[imageType]) return null; + try { + const bin = atob(m[2] ?? ""); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return { imageType, bytes }; + } catch { + return null; + } +} + /** * @returns error message for INVALID_ARGUMENT, or null if valid (including empty string). */ diff --git a/server/routes/mcp.ts b/server/routes/mcp.ts deleted file mode 100644 index 90a1459c8..000000000 --- a/server/routes/mcp.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * MCP (Model Context Protocol) server for Memos. - * - * Exposes memo operations as MCP tools so any MCP-compatible AI client - * (Claude Desktop, Cursor, Zed, etc.) can interact with the Memos instance. - * - * Endpoint: ANY /mcp (Streamable HTTP transport, MCP spec 2025-03-26) - * - * Authentication: Bearer or Bearer - * Public reads (list/get public memos, list tags) work without auth. - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { z } from "zod"; -import type { AppDeps } from "../types/deps.js"; -import { createRepository } from "../db/repository.js"; -import { verifyAccessToken } from "../services/jwt-access.js"; -import { memoToJson, attachmentToJson } from "../lib/serializers.js"; -import { normalizeMemoStateFromClient, normalizeMemoVisibilityFromClient } from "../lib/memo-enums.js"; -import { parseMemoListFilter, memoRowMatchesFilter, memoListFilterNeedsMemory, MEMO_FILTER_MAX_SCAN } from "../lib/memo-filter.js"; -import type { DbMemoRow } from "../db/repository.js"; - -/** Resolve auth from an HTTP request (Bearer JWT or PAT). */ -async function resolveAuth( - req: Request, - repo: ReturnType, - jwtSecret: string | null, -): Promise<{ username: string; role: "ADMIN" | "USER" } | null> { - const header = req.headers.get("authorization"); - const bearer = header?.match(/^\s*Bearer\s+(.+)$/i)?.[1]?.trim(); - if (!bearer) return null; - if (jwtSecret) { - const access = await verifyAccessToken(bearer, jwtSecret); - if (access) { - let username: string | null = null; - if (access.userId != null) { - const u = await repo.getUserByInternalId(access.userId); - username = u?.username ?? null; - } - if (!username && access.username) username = access.username; - if (username) return { username, role: access.role }; - } - } - if (bearer.startsWith("memos_pat_")) { - const user = await repo.findUserByPat(bearer); - if (user) return { username: user.username, role: user.role === "ADMIN" ? "ADMIN" : "USER" }; - } - return null; -} - -/** Returns true if the Origin header is acceptable (same host or no origin). */ -function isAllowedOrigin(req: Request, instanceUrl: string): boolean { - const origin = req.headers.get("Origin"); - if (!origin) return true; // CLI / desktop clients don't send Origin - try { - const originHost = new URL(origin).host; - const reqHost = req.headers.get("host") ?? ""; - if (originHost === reqHost) return true; - if (instanceUrl) { - const instanceHost = new URL(instanceUrl).host; - if (originHost === instanceHost) return true; - } - } catch { - // malformed origin → deny - } - return false; -} - -function memoIdFromName(name: string): string | null { - const p = name.startsWith("memos/") ? name.slice("memos/".length) : name; - return p.length > 0 ? p : null; -} - -function canViewMemo(m: DbMemoRow, auth: { username: string } | null): boolean { - if (m.visibility === "PUBLIC") return true; - if (!auth) return false; - if (m.visibility === "PROTECTED") return true; - return m.creator_username === auth.username; -} - -/** Build the shared McpServer with all tools registered. */ -function buildMcpServer( - repo: ReturnType, - authRef: { value: { username: string; role: "ADMIN" | "USER" } | null }, -): McpServer { - const server = new McpServer({ name: "Memos", version: "1.0.0" }); - - // ── Memo tools ───────────────────────────────────────────────────────── - - server.tool( - "list_memos", - "List memos. Authenticated users see their own and visible memos; unauthenticated callers see public memos only.", - { - page_size: z.number().int().min(1).max(200).optional().describe("Max results to return (default 50)"), - page: z.number().int().min(1).optional().describe("1-based page number (default 1)"), - state: z.enum(["NORMAL", "ARCHIVED"]).optional().describe("Memo state filter (default NORMAL)"), - order_by_pinned: z.boolean().optional().describe("When true, pinned memos appear first"), - filter: z.string().optional().describe("CEL-style filter (e.g. 'creator == \"users/alice\"')"), - }, - async ({ page_size = 50, page = 1, state = "NORMAL", filter = "" }) => { - const auth = authRef.value; - const pageSize = Math.min(200, Math.max(1, page_size)); - const offset = (Math.max(1, page) - 1) * pageSize; - const parsed = parseMemoListFilter(filter); - const needsMemory = memoListFilterNeedsMemory(parsed); - - let rows: DbMemoRow[]; - const baseArgs = { - state, - ...(auth ? { viewerUsername: auth.username } : { visibility: "PUBLIC" }), - }; - - if (needsMemory) { - const all = await repo.listMemosTopLevel({ - ...baseArgs, - limit: MEMO_FILTER_MAX_SCAN, - offset: 0, - }); - const filtered = all.filter((m) => memoRowMatchesFilter(m, parsed)); - rows = filtered.slice(offset, offset + pageSize); - } else { - rows = await repo.listMemosTopLevel({ - ...baseArgs, - limit: pageSize, - offset, - }); - } - - const memos = await Promise.all( - rows.map(async (m) => { - const atts = await repo.listAttachments({ memoUid: m.id, limit: 100, offset: 0 }); - return memoToJson(m, { attachments: atts.map(attachmentToJson) }); - }), - ); - return { content: [{ type: "text" as const, text: JSON.stringify(memos, null, 2) }] }; - }, - ); - - server.tool( - "get_memo", - "Get a single memo by resource name.", - { name: z.string().describe("Memo resource name, e.g. memos/abc123") }, - async ({ name }) => { - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const auth = authRef.value; - const row = await repo.getMemoById(id); - if (!row || row.parent_memo_id) { - return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - } - if (!canViewMemo(row, auth)) { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - const atts = await repo.listAttachments({ memoUid: row.id, limit: 100, offset: 0 }); - return { - content: [{ type: "text" as const, text: JSON.stringify(memoToJson(row, { attachments: atts.map(attachmentToJson) }), null, 2) }], - }; - }, - ); - - server.tool( - "search_memos", - "Full-text search of memo content.", - { - query: z.string().min(1).describe("Search query string"), - page_size: z.number().int().min(1).max(200).optional(), - }, - async ({ query, page_size = 50 }) => { - const auth = authRef.value; - const all = await repo.listMemosTopLevel({ - state: "NORMAL", - ...(auth ? { viewerUsername: auth.username } : { visibility: "PUBLIC" }), - limit: MEMO_FILTER_MAX_SCAN, - offset: 0, - }); - const matched = all - .filter((m) => m.content.toLowerCase().includes(query.toLowerCase())) - .slice(0, page_size); - const memos = await Promise.all( - matched.map(async (m) => { - const atts = await repo.listAttachments({ memoUid: m.id, limit: 100, offset: 0 }); - return memoToJson(m, { attachments: atts.map(attachmentToJson) }); - }), - ); - return { content: [{ type: "text" as const, text: JSON.stringify(memos, null, 2) }] }; - }, - ); - - server.tool( - "create_memo", - "Create a new memo. Requires authentication.", - { - content: z.string().min(1).describe("Memo content (Markdown)"), - visibility: z.enum(["PUBLIC", "PROTECTED", "PRIVATE"]).optional().describe("Visibility (default PRIVATE)"), - }, - async ({ content, visibility = "PRIVATE" }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const id = crypto.randomUUID(); - const row = await repo.createMemo({ - id, - creator: auth.username, - content, - visibility: normalizeMemoVisibilityFromClient(visibility), - state: "NORMAL", - pinned: false, - }); - return { content: [{ type: "text" as const, text: JSON.stringify(memoToJson(row), null, 2) }] }; - }, - ); - - server.tool( - "update_memo", - "Update a memo you own (or any memo if admin). Requires authentication.", - { - name: z.string().describe("Memo resource name, e.g. memos/abc123"), - content: z.string().optional().describe("New content"), - visibility: z.enum(["PUBLIC", "PROTECTED", "PRIVATE"]).optional(), - pinned: z.boolean().optional(), - state: z.enum(["NORMAL", "ARCHIVED"]).optional(), - }, - async ({ name, content, visibility, pinned, state }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const row = await repo.getMemoById(id); - if (!row) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (row.creator_username !== auth.username && auth.role !== "ADMIN") { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - await repo.updateMemo(id, { - content, - visibility: visibility !== undefined ? normalizeMemoVisibilityFromClient(visibility) : undefined, - pinned, - state: state !== undefined ? normalizeMemoStateFromClient(state) : undefined, - }); - const updated = await repo.getMemoById(id); - if (!updated) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - return { content: [{ type: "text" as const, text: JSON.stringify(memoToJson(updated), null, 2) }] }; - }, - ); - - server.tool( - "delete_memo", - "Delete (archive) a memo you own. Pass force=true to hard-delete. Requires authentication.", - { - name: z.string().describe("Memo resource name"), - force: z.boolean().optional().describe("When true, hard-delete instead of archiving"), - }, - async ({ name, force = false }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const row = await repo.getMemoById(id); - if (!row) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (row.creator_username !== auth.username && auth.role !== "ADMIN") { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - if (force) { - await repo.hardDeleteMemo(id); - } else { - await repo.archiveMemo(id); - } - return { content: [{ type: "text" as const, text: "{}" }] }; - }, - ); - - server.tool( - "list_memo_comments", - "List comments on a memo.", - { name: z.string().describe("Memo resource name") }, - async ({ name }) => { - const auth = authRef.value; - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const parent = await repo.getMemoById(id); - if (!parent) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (!canViewMemo(parent, auth)) { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - const comments = await repo.listCommentsForMemo(id); - return { - content: [{ type: "text" as const, text: JSON.stringify(comments.map((m) => memoToJson(m)), null, 2) }], - }; - }, - ); - - server.tool( - "create_memo_comment", - "Add a comment to a memo. Requires authentication.", - { - name: z.string().describe("Parent memo resource name"), - content: z.string().min(1).describe("Comment content"), - }, - async ({ name, content }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const parentId = memoIdFromName(name); - if (!parentId) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const parent = await repo.getMemoById(parentId); - if (!parent) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (!canViewMemo(parent, auth)) { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - const commentId = crypto.randomUUID(); - const row = await repo.createMemo({ - id: commentId, - creator: auth.username, - content, - visibility: "PRIVATE", - state: "NORMAL", - pinned: false, - parentId, - }); - return { content: [{ type: "text" as const, text: JSON.stringify(memoToJson(row), null, 2) }] }; - }, - ); - - // ── Tag tools ─────────────────────────────────────────────────────────── - - server.tool( - "list_tags", - "List all tags with their memo counts. Results are sorted by count descending, then alphabetically.", - {}, - async () => { - const auth = authRef.value; - const memos = await repo.listMemosTopLevel({ - state: "NORMAL", - ...(auth ? { viewerUsername: auth.username } : { visibility: "PUBLIC" }), - limit: MEMO_FILTER_MAX_SCAN, - offset: 0, - }); - const counts: Record = {}; - for (const m of memos) { - for (const tag of m.payload_tags) { - counts[tag] = (counts[tag] ?? 0) + 1; - } - } - const tags = Object.entries(counts) - .map(([tag, count]) => ({ tag, count })) - .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); - return { content: [{ type: "text" as const, text: JSON.stringify(tags, null, 2) }] }; - }, - ); - - return server; -} - -/** - * Create a Hono-compatible request handler for the MCP endpoint. - * Returns a function `(req: Request) => Promise`. - */ -export function createMcpHandler(deps: AppDeps): (req: Request) => Promise { - const repo = createRepository(deps.sql); - - return async (req: Request): Promise => { - // CORS / origin validation (DNS rebinding protection) - if (!isAllowedOrigin(req, deps.instanceUrl)) { - return new Response(JSON.stringify({ error: "invalid origin" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } - - // CORS preflight - const origin = req.headers.get("Origin"); - if (req.method === "OPTIONS") { - const headers = new Headers(); - if (origin) { - headers.set("Vary", "Origin"); - headers.set("Access-Control-Allow-Origin", origin); - headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID"); - headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - } - return new Response(null, { status: 204, headers }); - } - - // Resolve auth - const jwtSecret = deps.demo ? "usememos" : (await repo.getSecretKey()); - const auth = await resolveAuth(req, repo, jwtSecret); - - if (auth && !auth.username) { - return new Response(JSON.stringify({ error: "invalid or expired token" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - // Build a per-request MCP server + transport (stateless mode) - const authRef: { value: { username: string; role: "ADMIN" | "USER" } | null } = { value: auth }; - const mcpServer = buildMcpServer(repo, authRef); - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // stateless - enableJsonResponse: true, - }); - - await mcpServer.connect(transport); - const response = await transport.handleRequest(req); - - // Attach CORS headers to response - if (origin) { - const headers = new Headers(response.headers); - headers.set("Vary", "Origin"); - headers.set("Access-Control-Allow-Origin", origin); - return new Response(response.body, { - status: response.status, - headers, - }); - } - return response; - }; -} diff --git a/server/routes/v1/ai.ts b/server/routes/v1/ai.ts index 980f4c08d..6c916d972 100644 --- a/server/routes/v1/ai.ts +++ b/server/routes/v1/ai.ts @@ -31,16 +31,18 @@ function isSupportedAudioType(contentType: string): boolean { return SUPPORTED_AUDIO_TYPES.has(base); } -export function createAIRoutes(deps: AppDeps) { +function decodeBase64(content: string): Uint8Array { + const bin = atob(content); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +export function createAIActionRoutes(deps: AppDeps) { const r = new Hono<{ Variables: ApiVariables }>(); const repo = createRepository(deps.sql); - r.post("/:action", async (c) => { - const action = c.req.param("action"); - if (action !== "transcribe") { - return jsonError(c, GrpcCode.NOT_FOUND, "unknown AI action"); - } - + r.post("/ai:transcribe", async (c) => { const auth = c.get("auth"); if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); @@ -66,10 +68,9 @@ export function createAIRoutes(deps: AppDeps) { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "audio.content is required"); } - // Decode base64 audio - let audioBytes: Buffer; + let audioBytes: Uint8Array; try { - audioBytes = Buffer.from(audioContent, "base64"); + audioBytes = decodeBase64(audioContent); } catch { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "audio.content must be valid base64"); } @@ -80,7 +81,6 @@ export function createAIRoutes(deps: AppDeps) { let contentType = typeof body.audio?.contentType === "string" ? body.audio.contentType.trim() : ""; if (!contentType) { - // Detect from magic bytes if (audioBytes[0] === 0x49 && audioBytes[1] === 0x44 && audioBytes[2] === 0x33) { contentType = "audio/mpeg"; } else if (audioBytes[0] === 0x52 && audioBytes[1] === 0x49 && audioBytes[2] === 0x46 && audioBytes[3] === 0x46) { @@ -100,7 +100,6 @@ export function createAIRoutes(deps: AppDeps) { const prompt = typeof body.config?.prompt === "string" ? body.config.prompt.trim() : ""; const language = typeof body.config?.language === "string" ? body.config.language.trim() : ""; - // Look up the AI provider config const aiSetting = parseAISettingFromRaw(await repo.getInstanceSettingRaw("AI")); const provider = aiSetting.providers.find((p) => p.id === providerId); if (!provider) { @@ -110,7 +109,6 @@ export function createAIRoutes(deps: AppDeps) { return jsonError(c, GrpcCode.FAILED_PRECONDITION, "AI provider has no API key configured"); } - // Call OpenAI Whisper (or compatible) API const endpoint = provider.endpoint?.trim() || "https://api.openai.com/v1"; const transcribeUrl = `${endpoint.replace(/\/$/, "")}/audio/transcriptions`; diff --git a/server/routes/v1/attachments.ts b/server/routes/v1/attachments.ts index ebafe3f82..07b023c33 100644 --- a/server/routes/v1/attachments.ts +++ b/server/routes/v1/attachments.ts @@ -8,7 +8,7 @@ import { AttachmentStorageConfigError } from "../../services/attachment-storage. import { parseInstanceStorageSetting } from "../../lib/instance-storage-setting.js"; import { resolveAttachmentStorage } from "../../services/attachment-storage-resolver.js"; import { stripJpegExifMetadata } from "../../lib/strip-jpeg-exif.js"; -import { parseAttachmentFilter } from "../../lib/attachment-filter.js"; +import { attachmentMatchesParsedFilter, parseAttachmentFilter } from "../../lib/attachment-filter.js"; function attachmentIdFromName(name: string): string | null { const p = name.startsWith("attachments/") ? name.slice("attachments/".length) : name; @@ -98,24 +98,38 @@ export function createAttachmentRoutes(deps: AppDeps) { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid page token"); } const filter = c.req.query("filter") ?? ""; - let parsedFilter: { unlinkedOnly?: boolean; linkedOnly?: boolean; memoUid?: string }; + let parsedFilter: ReturnType; try { parsedFilter = parseAttachmentFilter(filter); } catch { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid filter"); } + const needsMemoryFilter = + parsedFilter.filenameContains !== undefined || + parsedFilter.mimeTypeEq !== undefined || + parsedFilter.mimeTypeNe !== undefined || + parsedFilter.mimeTypeIn !== undefined; const rows = await repo.listAttachments({ creatorUsername: auth.username, - limit: pageSize, - offset: Number.isFinite(offset) ? offset : 0, + limit: needsMemoryFilter ? 1000 : pageSize, + offset: needsMemoryFilter ? 0 : Number.isFinite(offset) ? offset : 0, ...(parsedFilter.unlinkedOnly ? { unlinkedOnly: true } : {}), ...(parsedFilter.linkedOnly ? { linkedOnly: true } : {}), ...(parsedFilter.memoUid ? { memoUid: parsedFilter.memoUid } : {}), }); + const filteredRows = needsMemoryFilter + ? rows.filter((x) => attachmentMatchesParsedFilter(x, parsedFilter)) + : rows; + const pagedRows = needsMemoryFilter + ? filteredRows.slice(Number.isFinite(offset) ? offset : 0, (Number.isFinite(offset) ? offset : 0) + pageSize) + : filteredRows; return c.json({ - attachments: rows.map((x) => attachmentToJson(x)), - nextPageToken: rows.length === pageSize ? String((Number.isFinite(offset) ? offset : 0) + pageSize) : "", - totalSize: rows.length, + attachments: pagedRows.map((x) => attachmentToJson(x)), + nextPageToken: + filteredRows.length > (Number.isFinite(offset) ? offset : 0) + pageSize + ? String((Number.isFinite(offset) ? offset : 0) + pageSize) + : "", + totalSize: filteredRows.length, }); }); @@ -209,38 +223,6 @@ export function createAttachmentRoutes(deps: AppDeps) { return c.json(attachmentToJson(row)); }); - r.post("/:action", async (c) => { - if (c.req.param("action") !== ":batchDelete") { - return jsonError(c, GrpcCode.UNIMPLEMENTED, "unknown action"); - } - const auth = c.get("auth"); - if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); - type Body = { names?: string[] }; - let body: Body; - try { - body = (await c.req.json()) as Body; - } catch { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); - } - const names = Array.isArray(body.names) ? body.names : []; - if (names.length > 100) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "too many attachment names; max 100"); - } - for (const name of names) { - const uid = attachmentIdFromName(name); - if (!uid) continue; - const existing = await repo.getAttachmentByUid(uid); - if (!existing) continue; - if (existing.creator_username !== auth.username && auth.role !== "ADMIN") { - return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); - } - const storage = await getStorage(); - await storage.delete(existing.reference); - await repo.deleteAttachment(uid); - } - return c.json({}); - }); - r.get("/:attachment", async (c) => { const uid = attachmentIdFromName(c.req.param("attachment")); if (!uid) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid attachment id"); @@ -299,3 +281,45 @@ export function createAttachmentRoutes(deps: AppDeps) { return r; } + +export function createAttachmentActionRoutes(deps: AppDeps) { + const r = new Hono<{ Variables: ApiVariables }>(); + const repo = createRepository(deps.sql); + + async function getStorage() { + const raw = await repo.getInstanceSettingRaw("STORAGE"); + const setting = parseInstanceStorageSetting(raw, deps.defaultAttachmentStorageType); + return await resolveAttachmentStorage(deps, setting); + } + + r.post("/attachments:batchDelete", async (c) => { + const auth = c.get("auth"); + if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); + type Body = { names?: string[] }; + let body: Body; + try { + body = (await c.req.json()) as Body; + } catch { + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); + } + const names = Array.isArray(body.names) ? body.names : []; + if (names.length > 100) { + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "too many attachment names; max 100"); + } + for (const name of names) { + const uid = attachmentIdFromName(name); + if (!uid) continue; + const existing = await repo.getAttachmentByUid(uid); + if (!existing) continue; + if (existing.creator_username !== auth.username && auth.role !== "ADMIN") { + return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); + } + const storage = await getStorage(); + await storage.delete(existing.reference); + await repo.deleteAttachment(uid); + } + return c.json({}); + }); + + return r; +} diff --git a/server/routes/v1/index.ts b/server/routes/v1/index.ts index dae3a6f06..b78ba4dd5 100644 --- a/server/routes/v1/index.ts +++ b/server/routes/v1/index.ts @@ -9,12 +9,12 @@ import { apiCors } from "../../middleware/cors.js"; import { debugHttpLog } from "../../middleware/debug-http.js"; import { createAuthRoutes } from "./auth.js"; import { createInstanceRoutes } from "./instance.js"; -import { createUserRoutes } from "./users.js"; +import { createUserActionRoutes, createUserRoutes } from "./users.js"; import { createMemoRoutes, createShareByTokenRoute } from "./memos.js"; -import { createAttachmentRoutes } from "./attachments.js"; +import { createAttachmentActionRoutes, createAttachmentRoutes } from "./attachments.js"; import { createIdentityProviderRoutes } from "./idp.js"; import { createSseRoutes } from "./sse.js"; -import { createAIRoutes } from "./ai.js"; +import { createAIActionRoutes } from "./ai.js"; import { userStatsFieldsFromMemoRows } from "../../lib/user-stats-from-memos.js"; export function createV1App(deps: AppDeps) { @@ -106,6 +106,10 @@ export function createV1App(deps: AppDeps) { return c.json({ stats }); }); + v1.route("/", createAIActionRoutes(deps)); + v1.route("/", createUserActionRoutes(deps)); + v1.route("/", createAttachmentActionRoutes(deps)); + v1.route("/auth", createAuthRoutes(deps)); v1.route("/instance", createInstanceRoutes(deps)); v1.route("/users", createUserRoutes(deps)); @@ -113,7 +117,6 @@ export function createV1App(deps: AppDeps) { v1.route("/attachments", createAttachmentRoutes(deps)); v1.route("/identity-providers", createIdentityProviderRoutes(deps)); v1.route("/shares", createShareByTokenRoute(deps)); - v1.route("/ai", createAIRoutes(deps)); // Node.js only — CF Worker streaming is not supported for long-lived SSE connections. if (deps.enableSSE) { v1.route("/sse", createSseRoutes()); diff --git a/server/routes/v1/instance.ts b/server/routes/v1/instance.ts index 8d64ef41e..3c49e44d7 100644 --- a/server/routes/v1/instance.ts +++ b/server/routes/v1/instance.ts @@ -90,6 +90,7 @@ export function createInstanceRoutes(deps: AppDeps) { version: deps.instanceVersion, demo: deps.demo, instanceUrl: deps.instanceUrl, + commit: "", admin: admin ? userToJson(admin, viewer) : null, }); }); @@ -195,65 +196,64 @@ export function createInstanceRoutes(deps: AppDeps) { return jsonError(c, GrpcCode.UNIMPLEMENTED, "this setting cannot be updated via API yet"); } type Body = { - setting?: { - generalSetting?: { - disallowUserRegistration?: boolean; - disallowPasswordAuth?: boolean; - additionalScript?: string; - additionalStyle?: string; - customProfile?: { - title?: string; - description?: string; - logoUrl?: string; - }; - weekStartDayOffset?: number; - disallowChangeUsername?: boolean; - disallowChangeNickname?: boolean; - }; - memoRelatedSetting?: { - displayWithUpdateTime?: boolean; - contentLengthLimit?: number; - enableDoubleClickEdit?: boolean; - reactions?: unknown; + name?: string; + generalSetting?: { + disallowUserRegistration?: boolean; + disallowPasswordAuth?: boolean; + additionalScript?: string; + additionalStyle?: string; + customProfile?: { + title?: string; + description?: string; + logoUrl?: string; }; - tagsSetting?: { tags?: unknown }; - storageSetting?: { - storageType?: unknown; - filepathTemplate?: string; - uploadSizeLimitMb?: number; - s3Config?: { - accessKeyId?: string; - accessKeySecret?: string; - endpoint?: string; - region?: string; - bucket?: string; - usePathStyle?: boolean; - }; - }; - notificationSetting?: { - email?: { - enabled?: boolean; - smtpHost?: string; - smtpPort?: number; - smtpUsername?: string; - smtpPassword?: string; - fromEmail?: string; - fromName?: string; - replyTo?: string; - useTls?: boolean; - useSsl?: boolean; - }; + weekStartDayOffset?: number; + disallowChangeUsername?: boolean; + disallowChangeNickname?: boolean; + }; + memoRelatedSetting?: { + displayWithUpdateTime?: boolean; + contentLengthLimit?: number; + enableDoubleClickEdit?: boolean; + reactions?: unknown; + }; + tagsSetting?: { tags?: unknown }; + storageSetting?: { + storageType?: unknown; + filepathTemplate?: string; + uploadSizeLimitMb?: number; + s3Config?: { + accessKeyId?: string; + accessKeySecret?: string; + endpoint?: string; + region?: string; + bucket?: string; + usePathStyle?: boolean; }; - aiSetting?: { - providers?: Array<{ - id?: string; - title?: string; - type?: unknown; - endpoint?: string; - apiKey?: string; - }>; + }; + notificationSetting?: { + email?: { + enabled?: boolean; + smtpHost?: string; + smtpPort?: number; + smtpUsername?: string; + smtpPassword?: string; + fromEmail?: string; + fromName?: string; + replyTo?: string; + useTls?: boolean; + useSsl?: boolean; }; }; + aiSetting?: { + providers?: Array<{ + id?: string; + title?: string; + type?: unknown; + endpoint?: string; + apiKey?: string; + }>; + }; }; let body: Body; try { @@ -263,9 +263,9 @@ export function createInstanceRoutes(deps: AppDeps) { } if (key === "GENERAL") { - const gs = body.setting?.generalSetting; + const gs = body.generalSetting; if (!gs) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "setting.generalSetting required"); + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "generalSetting required"); } await repo.upsertGeneralSetting({ disallowUserRegistration: gs.disallowUserRegistration, @@ -294,9 +294,9 @@ export function createInstanceRoutes(deps: AppDeps) { } if (key === "MEMO_RELATED") { - const mr = body.setting?.memoRelatedSetting; + const mr = body.memoRelatedSetting; if (!mr) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "setting.memoRelatedSetting required"); + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "memoRelatedSetting required"); } const reactions = Array.isArray(mr.reactions) ? mr.reactions.filter((x): x is string => typeof x === "string") @@ -315,9 +315,9 @@ export function createInstanceRoutes(deps: AppDeps) { } if (key === "TAGS") { - const ts = body.setting?.tagsSetting; + const ts = body.tagsSetting; if (!ts || typeof ts !== "object") { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "setting.tagsSetting required"); + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "tagsSetting required"); } const tags = (ts as { tags?: unknown }).tags; if (!tags || typeof tags !== "object" || Array.isArray(tags)) { @@ -331,9 +331,9 @@ export function createInstanceRoutes(deps: AppDeps) { } if (key === "STORAGE") { - const ss = body.setting?.storageSetting; + const ss = body.storageSetting; if (!ss) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "setting.storageSetting required"); + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "storageSetting required"); } const current = parseInstanceStorageSetting( await repo.getInstanceSettingRaw("STORAGE"), @@ -402,9 +402,9 @@ export function createInstanceRoutes(deps: AppDeps) { } if (key === "NOTIFICATION") { - const ns = body.setting?.notificationSetting; + const ns = body.notificationSetting; if (!ns || typeof ns !== "object") { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "setting.notificationSetting required"); + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "notificationSetting required"); } const current = parseInstanceNotificationSetting(await repo.getInstanceSettingRaw("NOTIFICATION")); const e = ns.email ?? {}; @@ -437,9 +437,9 @@ export function createInstanceRoutes(deps: AppDeps) { } if (key === "AI") { - const as = body.setting?.aiSetting; + const as = body.aiSetting; if (!as || typeof as !== "object") { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "setting.aiSetting required"); + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "aiSetting required"); } const current = parseAISettingFromRaw(await repo.getInstanceSettingRaw("AI")); const incoming = Array.isArray(as.providers) ? as.providers : []; diff --git a/server/routes/v1/memos.ts b/server/routes/v1/memos.ts index a9edf50ad..7cb69439a 100644 --- a/server/routes/v1/memos.ts +++ b/server/routes/v1/memos.ts @@ -4,6 +4,7 @@ import type { AppDeps } from "../../types/deps.js"; import type { AuthPrincipal } from "../../types/auth.js"; import { createRepository, type DbMemoRow } from "../../db/repository.js"; import { GrpcCode, jsonError } from "../../lib/grpc-status.js"; +import { resolveFieldMaskPaths } from "../../lib/update-mask.js"; import { attachmentToJson, memoToJson } from "../../lib/serializers.js"; import { b64urlToUtf8, utf8ToB64url } from "../../lib/b64url.js"; import { normalizeMemoStateFromClient, normalizeMemoVisibilityFromClient } from "../../lib/memo-enums.js"; @@ -50,7 +51,41 @@ export function createMemoRoutes(deps: AppDeps) { limit: 1000, offset: 0, }); - return memoToJson(m, { attachments: attachments.map((a) => attachmentToJson(a)) }); + const relations = await memoRelationsToJson(m); + const reactions = await memoReactionsToJson(m.id); + return memoToJson(m, { + attachments: attachments.map((a) => attachmentToJson(a)), + relations, + reactions, + }); + } + + async function memoRelationsToJson(m: DbMemoRow) { + const rels = await repo.listMemoRelations(m.id); + const out = []; + for (const rel of rels) { + const related = await repo.getMemoById(rel.related_memo_id); + out.push({ + memo: { name: `memos/${m.id}`, snippet: m.snippet ?? "" }, + relatedMemo: { + name: `memos/${rel.related_memo_id}`, + snippet: related?.snippet ?? "", + }, + type: rel.relation_type, + }); + } + return out; + } + + async function memoReactionsToJson(memoId: string) { + const rx = await repo.listReactions(memoId); + return rx.map((x) => ({ + name: `memos/${memoId}/reactions/${x.id}`, + creator: `users/${x.creator_username}`, + contentId: `memos/${memoId}`, + reactionType: x.reaction_type, + createTime: x.create_time, + })); } r.get("/", async (c) => { @@ -189,6 +224,8 @@ export function createMemoRoutes(deps: AppDeps) { state?: unknown; pinned?: boolean; location?: unknown; + attachments?: { name?: string }[]; + relations?: { relatedMemo?: { name?: string }; type?: string }[]; }; // Match golang v0.26.x contract: request body is Memo fields at top-level. const m = (await c.req.json()) as MemoBody; @@ -209,8 +246,28 @@ export function createMemoRoutes(deps: AppDeps) { pinned: Boolean(m.pinned), location: locIn.value, }); + const attachmentIds = + m.attachments + ?.map((a) => (a.name ? a.name.replace(/^attachments\//, "") : null)) + .filter((x): x is string => Boolean(x)) ?? []; + if (attachmentIds.length > 0) { + await repo.setMemoAttachments(row.id, attachmentIds); + } + const pairs = + m.relations?.map((rel) => { + const rn = rel.relatedMemo?.name; + const rid = rn ? memoIdFromName(rn) : null; + return rid ? { relatedId: rid, type: rel.type ?? "REFERENCE" } : null; + }) ?? []; + if (pairs.length > 0) { + await repo.setMemoRelations( + row.id, + pairs.filter((p): p is { relatedId: string; type: string } => Boolean(p)), + ); + } sseBus.emit({ type: "memo.created", name: `memos/${row.id}` }); - return c.json(memoToJson(row)); + const next = await repo.getMemoById(row.id); + return c.json(next ? await memoToJsonWithAttachments(next) : memoToJson(row)); }); r.get("/:id", async (c) => { @@ -254,13 +311,13 @@ export function createMemoRoutes(deps: AppDeps) { displayTime?: string; display_time?: string; location?: unknown; + attachments?: { name?: string }[]; updateMask?: { paths?: string[] }; update_mask?: { paths?: string[] }; }; const body = (await c.req.json()) as MemoBody; if (!body) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "memo required"); - const rawPaths = - body.updateMask?.paths ?? body.update_mask?.paths ?? []; + const rawPaths = resolveFieldMaskPaths(c, body); if (rawPaths.length === 0) { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "update_mask is required"); } @@ -296,6 +353,13 @@ export function createMemoRoutes(deps: AppDeps) { : undefined, ...locUpdate, }); + if (hasPath("attachments") && body.attachments !== undefined) { + const attachmentIds = + body.attachments + .map((a) => (a.name ? a.name.replace(/^attachments\//, "") : null)) + .filter((x): x is string => Boolean(x)) ?? []; + await repo.setMemoAttachments(id, attachmentIds); + } sseBus.emit({ type: "memo.updated", name: `memos/${id}` }); const next = await repo.getMemoById(id); if (!next) return jsonError(c, GrpcCode.NOT_FOUND, "memo not found"); diff --git a/server/routes/v1/users.ts b/server/routes/v1/users.ts index f972a6c35..73199b55f 100644 --- a/server/routes/v1/users.ts +++ b/server/routes/v1/users.ts @@ -9,13 +9,27 @@ import { type StoredUserWebhook, } from "../../lib/user-webhooks-setting.js"; import { GrpcCode, jsonError } from "../../lib/grpc-status.js"; +import { resolveFieldMaskPaths } from "../../lib/update-mask.js"; import { b64urlToUtf8, utf8ToB64url } from "../../lib/b64url.js"; import { hashPassword } from "../../services/password.js"; +import { + exchangeOAuth2Token, + fetchOAuth2UserInfo, + parseOAuth2Config, +} from "../../services/oauth2-idp.js"; import { authPrincipalFromUserRow, userToJson } from "../../lib/serializers.js"; import { userStatsFieldsFromMemoRows } from "../../lib/user-stats-from-memos.js"; import { validateUserAvatarUrl } from "../../lib/user-avatar-data-uri.js"; import { isValidMemosUsername } from "../../lib/user-username.js"; +function extractIdentityProviderUid(name: string): string | null { + const prefix = "identity-providers/"; + if (!name.startsWith(prefix)) return null; + const uid = name.slice(prefix.length).trim(); + if (!uid || !/^[a-z0-9][a-z0-9-]{0,31}$/i.test(uid)) return null; + return uid; +} + /** Proto `User.Role`: ROLE_UNSPECIFIED=0, ADMIN=2, USER=3 — JSON often uses these numbers. */ const createUserRoleField = z .union([ @@ -87,6 +101,86 @@ function hasPath(paths: string[] | undefined, ...names: string[]): boolean { return names.some((n) => paths.includes(n)); } +/** Known FieldMask path spellings -> canonical golang paths (`user_service.go` switch). */ +const USER_UPDATE_MASK_ALIASES: Record = { + username: "username", + display_name: "display_name", + displayName: "display_name", + email: "email", + description: "description", + avatar_url: "avatar_url", + avatarUrl: "avatar_url", + password: "password", + role: "role", + state: "state", +}; + +function normalizeUserUpdateMaskPaths( + paths: string[], +): { ok: true; paths: string[] } | { ok: false; path: string } { + const out: string[] = []; + for (const p of paths) { + const c = USER_UPDATE_MASK_ALIASES[p]; + if (!c) return { ok: false, path: p }; + if (!out.includes(c)) out.push(c); + } + return { ok: true, paths: out }; +} + +function inferUserUpdateMaskPaths(payload: Record): string[] { + const out: string[] = []; + for (const [k, v] of Object.entries(payload)) { + if (v === undefined) continue; + const p = USER_UPDATE_MASK_ALIASES[k]; + if (p && !out.includes(p)) out.push(p); + } + return out; +} + +type UserPatchFields = { + username?: string; + displayName?: string; + email?: string; + password?: string; + role?: string | number; + state?: string | number; + avatarUrl?: string; + description?: string; +}; + +function normalizeUserPatchPayload(o: Record): UserPatchFields { + const out: UserPatchFields = {}; + if (typeof o.username === "string") out.username = o.username; + const disp = o.displayName ?? o.display_name; + if (disp !== undefined) out.displayName = typeof disp === "string" ? disp : String(disp); + if (o.email !== undefined) out.email = typeof o.email === "string" ? o.email : String(o.email); + if (o.description !== undefined) { + out.description = typeof o.description === "string" ? o.description : String(o.description); + } + const av = o.avatarUrl ?? o.avatar_url; + if (av !== undefined) out.avatarUrl = typeof av === "string" ? av : String(av); + if (typeof o.password === "string") out.password = o.password; + if (o.role !== undefined) out.role = o.role as string | number; + if (o.state !== undefined) out.state = o.state as string | number; + return out; +} + +/** + * Connect/web sends `{ user, updateMask }`. gRPC-Gateway / OpenAPI sends the `User` JSON as the body + * and `updateMask` as a query param (`proto` `body: "user"`). + */ +function parseUpdateUserBody(raw: Record): { + userPayload: Record; + usedNestedUser: boolean; +} { + const nested = raw.user; + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + return { userPayload: nested as Record, usedNestedUser: true }; + } + const { updateMask: _a, update_mask: _b, user: _c, ...rest } = raw; + return { userPayload: rest, usedNestedUser: false }; +} + function validateUserWebhookUrl(url: string): string | null { const t = url.trim(); if (!t) return null; @@ -245,32 +339,6 @@ export function createUserRoutes(deps: AppDeps) { return c.json(userToJson(created, authPrincipalFromUserRow(created))); }); - r.post("/:action", async (c) => { - if (c.req.param("action") !== ":batchGet") { - return jsonError(c, GrpcCode.UNIMPLEMENTED, "unknown action"); - } - const viewer = c.get("auth"); - type Body = { usernames?: string[] }; - let body: Body; - try { - body = (await c.req.json()) as Body; - } catch { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); - } - const rawUsernames = Array.isArray(body.usernames) ? body.usernames : []; - if (rawUsernames.length > 100) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "too many usernames; max 100"); - } - const usernames = rawUsernames.map((u) => - typeof u === "string" && u.startsWith("users/") ? u.slice("users/".length) : u, - ); - const users = await Promise.all(usernames.map((u) => repo.getUser(u))); - const active = users.filter( - (u): u is NonNullable => u !== null && u !== undefined && u.state === "NORMAL", - ); - return c.json({ users: active.map((u) => userToJson(u, viewer)) }); - }); - const forUser = new Hono<{ Variables: ApiVariables }>(); forUser.use(async (c, next) => { @@ -325,37 +393,44 @@ export function createUserRoutes(deps: AppDeps) { return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); } const general = await repo.getGeneralSetting(); - type Body = { - user?: { - username?: string; - displayName?: string; - email?: string; - password?: string; - role?: string | number; - state?: string | number; - avatarUrl?: string; - description?: string; - }; - updateMask?: { paths?: string[] }; - }; - let body: Body; + let raw: Record; try { - body = (await c.req.json()) as Body; + raw = (await c.req.json()) as Record; } catch { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); } - const u = body.user; - if (!u) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "user required"); - const paths = body.updateMask?.paths ?? []; + const { userPayload, usedNestedUser } = parseUpdateUserBody(raw); + if (!usedNestedUser && Object.keys(userPayload).length === 0) { + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "user required"); + } + + const patch = normalizeUserPatchPayload(userPayload); + let paths = resolveFieldMaskPaths(c, raw); + if (paths.length === 0) { + if (usedNestedUser) { + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "update mask is empty"); + } + paths = inferUserUpdateMaskPaths(userPayload); + } if (paths.length === 0) { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "update mask is empty"); } + const norm = normalizeUserUpdateMaskPaths(paths); + if (!norm.ok) { + return jsonError( + c, + GrpcCode.INVALID_ARGUMENT, + `invalid update path: ${norm.path}`, + ); + } + paths = norm.paths; + let newUsername: string | null = null; if (hasPath(paths, "username")) { if (general.disallowChangeUsername) { return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); } - const next = typeof u.username === "string" ? u.username.trim() : ""; + const next = patch.username?.trim() ?? ""; if (!next) { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "username required"); } @@ -372,14 +447,14 @@ export function createUserRoutes(deps: AppDeps) { if (general.disallowChangeNickname) { return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); } - if (u.displayName !== undefined) fields.display_name = u.displayName; + if (patch.displayName !== undefined) fields.display_name = patch.displayName; } if (hasPath(paths, "email")) { - if (u.email !== undefined) fields.email = u.email; + if (patch.email !== undefined) fields.email = patch.email; } if (hasPath(paths, "avatar_url", "avatarUrl")) { - if (u.avatarUrl !== undefined) { - const avatarErr = validateUserAvatarUrl(u.avatarUrl); + if (patch.avatarUrl !== undefined) { + const avatarErr = validateUserAvatarUrl(patch.avatarUrl); if (avatarErr) { const msg = avatarErr === "invalid data URI format" @@ -387,27 +462,27 @@ export function createUserRoutes(deps: AppDeps) { : avatarErr; return jsonError(c, GrpcCode.INVALID_ARGUMENT, msg); } - fields.avatar_url = u.avatarUrl; + fields.avatar_url = patch.avatarUrl; } } if (hasPath(paths, "description")) { - if (u.description !== undefined) fields.description = u.description; + if (patch.description !== undefined) fields.description = patch.description; } - if (u.password && hasPath(paths, "password")) { - fields.password_hash = await hashPassword(u.password); + if (patch.password && hasPath(paths, "password")) { + fields.password_hash = await hashPassword(patch.password); } if (hasPath(paths, "role")) { if (auth.role !== "ADMIN") { return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); } - const parsedRole = parseUserRolePatchValue(u.role); + const parsedRole = parseUserRolePatchValue(patch.role); if (!parsedRole) { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid role"); } fields.role = parsedRole; } if (hasPath(paths, "state")) { - const parsedState = parseUserStatePatchValue(u.state); + const parsedState = parseUserStatePatchValue(patch.state); if (!parsedState) { return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid state"); } @@ -1026,6 +1101,94 @@ export function createUserRoutes(deps: AppDeps) { }); }); + forUser.post("/linkedIdentities", async (c) => { + const auth = c.get("auth"); + if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); + const username = c.req.param("username")!; + if (username.includes(":")) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid user resource"); + if (auth.username !== username) { + return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); + } + type Body = { + idpName?: string; + code?: string; + redirectUri?: string; + codeVerifier?: string; + }; + const body = (await c.req.json()) as Body; + const providerUid = extractIdentityProviderUid(body.idpName ?? ""); + if (!providerUid) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid identity provider name"); + if (!body.code) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "code required"); + if (!body.redirectUri) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "redirect uri required"); + + const userId = await repo.getUserInternalId(username); + if (userId === null) return jsonError(c, GrpcCode.NOT_FOUND, "user not found"); + const provider = await repo.getIdentityProviderByUid(providerUid); + if (!provider) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "identity provider not found"); + if (provider.type !== "OAUTH2") return jsonError(c, GrpcCode.INVALID_ARGUMENT, "unsupported identity provider type"); + + let providerConfig: unknown = {}; + try { + providerConfig = JSON.parse(provider.config); + } catch { + return jsonError(c, GrpcCode.INTERNAL, "invalid identity provider config"); + } + const oauth2Config = parseOAuth2Config(providerConfig); + if (!oauth2Config) return jsonError(c, GrpcCode.INTERNAL, "invalid identity provider config"); + + let oauthAccessToken = ""; + try { + oauthAccessToken = await exchangeOAuth2Token({ + config: oauth2Config, + redirectUri: body.redirectUri, + code: body.code, + codeVerifier: body.codeVerifier, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to exchange token"; + return jsonError(c, GrpcCode.INTERNAL, message); + } + + let userInfo: { identifier: string }; + try { + userInfo = await fetchOAuth2UserInfo({ + config: oauth2Config, + accessToken: oauthAccessToken, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to get user info"; + return jsonError(c, GrpcCode.INTERNAL, message); + } + + if (provider.identifier_filter) { + let regex: RegExp; + try { + regex = new RegExp(provider.identifier_filter); + } catch { + return jsonError(c, GrpcCode.INTERNAL, "invalid identity provider identifier filter"); + } + if (!regex.test(userInfo.identifier)) { + return jsonError(c, GrpcCode.PERMISSION_DENIED, "identifier is not allowed"); + } + } + + const existingForUser = await repo.getUserIdentity(userId, providerUid); + if (existingForUser) { + return jsonError(c, GrpcCode.ALREADY_EXISTS, "identity provider already linked"); + } + const existingForIdentity = await repo.getUserIdentityByProviderExternUid(providerUid, userInfo.identifier); + if (existingForIdentity && existingForIdentity.user_id !== userId) { + return jsonError(c, GrpcCode.ALREADY_EXISTS, "identity already linked to another user"); + } + + const row = await repo.upsertUserIdentity(userId, providerUid, userInfo.identifier); + return c.json({ + name: `users/${username}/linkedIdentities/${encodeURIComponent(row.provider)}`, + idpName: `identity-providers/${encodeURIComponent(row.provider)}`, + externUid: row.extern_uid, + }); + }); + forUser.get("/linkedIdentities/:provider", async (c) => { const auth = c.get("auth"); if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); @@ -1066,3 +1229,33 @@ export function createUserRoutes(deps: AppDeps) { return r; } + +export function createUserActionRoutes(deps: AppDeps) { + const r = new Hono<{ Variables: ApiVariables }>(); + const repo = createRepository(deps.sql); + + r.post("/users:batchGet", async (c) => { + const viewer = c.get("auth"); + type Body = { usernames?: string[] }; + let body: Body; + try { + body = (await c.req.json()) as Body; + } catch { + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); + } + const rawUsernames = Array.isArray(body.usernames) ? body.usernames : []; + if (rawUsernames.length > 100) { + return jsonError(c, GrpcCode.INVALID_ARGUMENT, "too many usernames; max 100"); + } + const usernames = rawUsernames.map((u) => + typeof u === "string" && u.startsWith("users/") ? u.slice("users/".length) : u, + ); + const users = await Promise.all(usernames.map((u) => repo.getUser(u))); + const active = users.filter( + (u): u is NonNullable => u !== null && u !== undefined && u.state === "NORMAL", + ); + return c.json({ users: active.map((u) => userToJson(u, viewer)) }); + }); + + return r; +} diff --git a/server/services/memo-content-props.ts b/server/services/memo-content-props.ts index feedcbb44..69b81f861 100644 --- a/server/services/memo-content-props.ts +++ b/server/services/memo-content-props.ts @@ -1,8 +1,11 @@ /** * Derive memo `property` and filter flags from raw markdown content. - * Heuristics aligned loosely with upstream memo payload extraction. + * Title extraction matches golang `internal/markdown` (first block-level H1 only). */ +import { fromMarkdown } from "mdast-util-from-markdown"; +import { toString } from "mdast-util-to-string"; + const CODE_FENCE = /```[\s\S]*?```/; const MD_LINK = /\[[^\]]*]\([^)]+\)/; @@ -27,18 +30,22 @@ export function contentHasIncompleteTasks(content: string): boolean { return UNCHECKED_TASK.test(content); } -/** First markdown ATX heading, else first non-empty line (trimmed, capped). */ +/** + * Title from the first block-level H1 in markdown (plain text, no inline formatting). + * Matches golang `ExtractProperties` / `ExtractAll` (not h2+, not later headings, no fallback). + */ export function extractTitleHint(content: string): string { - const lines = content.split(/\r?\n/); - for (const line of lines) { - const h = /^\s{0,3}#{1,6}\s+(.+?)\s*$/.exec(line); - if (h) return h[1]!.trim().slice(0, 200); + let tree; + try { + tree = fromMarkdown(content); + } catch { + return ""; } - for (const line of lines) { - const t = line.trim(); - if (t) return t.slice(0, 200); + const first = tree.children[0]; + if (!first || first.type !== "heading" || first.depth !== 1) { + return ""; } - return ""; + return toString(first).trim(); } export function deriveMemoProperty(content: string) { diff --git a/tests/integration/attachments-file-route.test.ts b/tests/integration/attachments-file-route.test.ts index aa5d4b3ba..acfc396f1 100644 --- a/tests/integration/attachments-file-route.test.ts +++ b/tests/integration/attachments-file-route.test.ts @@ -30,5 +30,28 @@ describe("integration: attachments file route", () => { const text = await res.text(); expect(text).toBe(content); }); -}); + it("serves user data URI avatar via golang /file/users/:identifier/avatar path", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "avataru", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "avataru", "secret123"); + const avatarBytes = Buffer.from("avatar-bytes", "utf-8"); + const avatarUrl = `data:image/png;base64,${avatarBytes.toString("base64")}`; + + const patch = await apiJson<{ avatarUrl: string }>(app, "/api/v1/users/avataru", { + method: "PATCH", + bearer: accessToken, + json: { + user: { avatarUrl }, + updateMask: { paths: ["avatarUrl"] }, + }, + }); + expect(patch.status).toBe(200); + expect(patch.body.avatarUrl).toBe("/file/users/avataru/avatar"); + + const res = await apiRequest(app, "/file/users/avataru/avatar"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("image/png"); + expect(Buffer.from(await res.arrayBuffer())).toEqual(avatarBytes); + }); +}); diff --git a/tests/integration/attachments.test.ts b/tests/integration/attachments.test.ts new file mode 100644 index 000000000..20d39d791 --- /dev/null +++ b/tests/integration/attachments.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { apiJson } from "../helpers/http.js"; +import { createTestApp } from "../helpers/test-app.js"; +import { postFirstUser, signIn } from "../helpers/seed.js"; + +describe("integration: attachments", () => { + it("GET /attachments supports golang filename and mime_type filters", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "attfilter", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "attfilter", "secret123"); + + for (const attachment of [ + { filename: "report.pdf", type: "application/pdf" }, + { filename: "photo.png", type: "image/png" }, + { filename: "notes.txt", type: "text/plain" }, + ]) { + const created = await apiJson(app, "/api/v1/attachments", { + method: "POST", + bearer: accessToken, + json: { attachment: { ...attachment, content: "ZGF0YQ==" } }, + }); + expect(created.status).toBe(200); + } + + const byFilename = await apiJson<{ attachments: { filename: string }[] }>( + app, + `/api/v1/attachments?filter=${encodeURIComponent('filename.contains("report")')}`, + { bearer: accessToken }, + ); + expect(byFilename.status).toBe(200); + expect(byFilename.body.attachments.map((a) => a.filename)).toEqual(["report.pdf"]); + + const byMime = await apiJson<{ attachments: { filename: string }[] }>( + app, + `/api/v1/attachments?filter=${encodeURIComponent('mime_type in ["image/png", "text/plain"]')}`, + { bearer: accessToken }, + ); + expect(byMime.status).toBe(200); + expect(byMime.body.attachments.map((a) => a.filename).sort()).toEqual(["notes.txt", "photo.png"]); + }); +}); diff --git a/tests/integration/errors-unimplemented.test.ts b/tests/integration/errors-unimplemented.test.ts index 8529197eb..1566959ef 100644 --- a/tests/integration/errors-unimplemented.test.ts +++ b/tests/integration/errors-unimplemented.test.ts @@ -80,4 +80,86 @@ describe("integration: errors and unimplemented", () => { }); expect(patch.status).toBe(200); }); + + it("does not expose master-only non-contract endpoints", async () => { + const app = createTestApp(); + const { accessToken } = await seedAdmin(app, { username: "contract-admin", password: "secret123" }); + + const ai = await apiRequest(app, "/api/v1/ai/transcribe", { + method: "POST", + bearer: accessToken, + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(ai.status).toBe(404); + + const users = await apiRequest(app, "/api/v1/users/:batchGet", { + method: "POST", + bearer: accessToken, + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(users.status).toBe(404); + + const attachments = await apiRequest(app, "/api/v1/attachments/:batchDelete", { + method: "POST", + bearer: accessToken, + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(attachments.status).toBe(404); + + const mcp = await apiRequest(app, "/mcp", { method: "POST" }); + expect(mcp.status).toBe(404); + }); + + it("exposes golang REST action paths with literal colons", async () => { + const app = createTestApp(); + const { accessToken } = await seedAdmin(app, { username: "contract-admin-2", password: "secret123" }); + await postUserAsAdmin(app, accessToken, { username: "batch-user", password: "secret123", role: "USER" }); + + const batchUsers = await apiJson<{ users?: Array<{ username?: string }> }>( + app, + "/api/v1/users:batchGet", + { + method: "POST", + json: { usernames: ["batch-user"] }, + }, + ); + expect(batchUsers.status).toBe(200); + expect(batchUsers.body.users?.map((u) => u.username)).toEqual(["batch-user"]); + + const createdAttachment = await apiJson<{ name: string }>(app, "/api/v1/attachments", { + method: "POST", + bearer: accessToken, + json: { + attachment: { + filename: "contract.txt", + content: "Y29udHJhY3Q=", + type: "text/plain", + }, + }, + }); + expect(createdAttachment.status).toBe(200); + + const batchDelete = await apiJson(app, "/api/v1/attachments:batchDelete", { + method: "POST", + bearer: accessToken, + json: { names: [createdAttachment.body.name] }, + }); + expect(batchDelete.status).toBe(200); + + const afterDelete = await apiJson(app, `/api/v1/${createdAttachment.body.name}`, { + bearer: accessToken, + }); + expect(afterDelete.status).toBe(404); + + const transcribe = await apiJson(app, "/api/v1/ai:transcribe", { + method: "POST", + bearer: accessToken, + json: {}, + }); + expect(transcribe.status).toBe(400); + expect((transcribe.body as { code?: number }).code).toBe(3); + }); }); diff --git a/tests/integration/instance.test.ts b/tests/integration/instance.test.ts index 5832a8120..204032765 100644 --- a/tests/integration/instance.test.ts +++ b/tests/integration/instance.test.ts @@ -4,6 +4,13 @@ import { createTestApp } from "../helpers/test-app.js"; import { seedAdmin } from "../helpers/seed.js"; describe("integration: instance", () => { + it("GET instance/profile includes golang commit field", async () => { + const app = createTestApp(); + const res = await apiJson<{ commit?: string }>(app, "/api/v1/instance/profile"); + expect(res.status).toBe(200); + expect(res.body.commit).toBe(""); + }); + it("PATCH instance/settings/TAGS then GET returns persisted tags", async () => { const app = createTestApp(); const { accessToken } = await seedAdmin(app, { username: "adm", password: "secret123" }); @@ -12,10 +19,8 @@ describe("integration: instance", () => { method: "PATCH", bearer: accessToken, json: { - setting: { - tagsSetting: { - tags: { demo: { blurContent: true, backgroundColor: { red: 1, green: 0, blue: 0 } } }, - }, + tagsSetting: { + tags: { demo: { blurContent: true, backgroundColor: { red: 1, green: 0, blue: 0 } } }, }, }, }); @@ -35,11 +40,9 @@ describe("integration: instance", () => { method: "PATCH", bearer: accessToken, json: { - setting: { - generalSetting: { - disallowUserRegistration: true, - disallowPasswordAuth: false, - }, + generalSetting: { + disallowUserRegistration: true, + disallowPasswordAuth: false, }, }, }); @@ -63,11 +66,9 @@ describe("integration: instance", () => { method: "PATCH", bearer: accessToken, json: { - setting: { - generalSetting: { - additionalScript: "console.log('hello')", - additionalStyle: "body { color: red; }", - }, + generalSetting: { + additionalScript: "console.log('hello')", + additionalStyle: "body { color: red; }", }, }, }); @@ -90,10 +91,8 @@ describe("integration: instance", () => { method: "PATCH", bearer: accessToken, json: { - setting: { - generalSetting: { - customProfile: { title: "My Memos", description: "A personal memo app", logoUrl: "/logo.png" }, - }, + generalSetting: { + customProfile: { title: "My Memos", description: "A personal memo app", logoUrl: "/logo.png" }, }, }, }); @@ -116,10 +115,8 @@ describe("integration: instance", () => { method: "PATCH", bearer: accessToken, json: { - setting: { - generalSetting: { - weekStartDayOffset: 1, - }, + generalSetting: { + weekStartDayOffset: 1, }, }, }); @@ -140,13 +137,11 @@ describe("integration: instance", () => { method: "PATCH", bearer: accessToken, json: { - setting: { - memoRelatedSetting: { - displayWithUpdateTime: true, - contentLengthLimit: 100, - enableDoubleClickEdit: false, - reactions: [], - }, + memoRelatedSetting: { + displayWithUpdateTime: true, + contentLengthLimit: 100, + enableDoubleClickEdit: false, + reactions: [], }, }, }); @@ -184,17 +179,15 @@ describe("integration: instance", () => { method: "PATCH", bearer: accessToken, json: { - setting: { - notificationSetting: { - email: { - enabled: true, - smtpHost: "smtp.example.com", - smtpPort: 587, - smtpUsername: "bot@example.com", - smtpPassword: "secret-value", - fromEmail: "bot@example.com", - useTls: true, - }, + notificationSetting: { + email: { + enabled: true, + smtpHost: "smtp.example.com", + smtpPort: 587, + smtpUsername: "bot@example.com", + smtpPassword: "secret-value", + fromEmail: "bot@example.com", + useTls: true, }, }, }, diff --git a/tests/integration/memos.test.ts b/tests/integration/memos.test.ts index a52e9def5..1cc786986 100644 --- a/tests/integration/memos.test.ts +++ b/tests/integration/memos.test.ts @@ -82,6 +82,24 @@ describe("integration: memos", () => { expect((get.body as { content: string }).content).toBe("v2"); }); + it("PATCH memo accepts update_mask query param (grpc-gateway style)", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "qmask", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "qmask", "secret123"); + const created = await postMemo(app, accessToken, { content: "v1", visibility: "PRIVATE" }); + expect(created.status).toBe(200); + const id = memoIdFromName((created.body as { name: string }).name); + + const patch = await apiJson(app, `/api/v1/memos/${encodeURIComponent(id)}?update_mask=content,visibility`, { + method: "PATCH", + bearer: accessToken, + json: { content: "v2", visibility: "PUBLIC" }, + }); + expect(patch.status).toBe(200); + expect((patch.body as { content: string; visibility: string }).content).toBe("v2"); + expect((patch.body as { visibility: string }).visibility).toBe("PUBLIC"); + }); + it("PATCH memo without updateMask returns 400", async () => { const app = createTestApp(); await postFirstUser(app, { username: "nomask", password: "secret123", role: "USER" }); @@ -306,6 +324,73 @@ describe("integration: memos", () => { expect(get.body.relations[0].relatedMemo.name).toBe(nameB); }); + it("GET memo includes relations and reactions like golang responses", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "fullmemo", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "fullmemo", "secret123"); + const a = await postMemo(app, accessToken, { content: "A", visibility: "PRIVATE" }); + const b = await postMemo(app, accessToken, { content: "B", visibility: "PRIVATE" }); + const idA = memoIdFromName((a.body as { name: string }).name); + const nameB = (b.body as { name: string }).name; + + await apiJson(app, `/api/v1/memos/${encodeURIComponent(idA)}/relations`, { + method: "PATCH", + bearer: accessToken, + json: { relations: [{ relatedMemo: { name: nameB }, type: "REFERENCE" }] }, + }); + await apiJson(app, `/api/v1/memos/${encodeURIComponent(idA)}/reactions`, { + method: "POST", + bearer: accessToken, + json: { reaction: { reactionType: "❤️" } }, + }); + + const got = await apiJson<{ + relations: { relatedMemo: { name: string }; type: string }[]; + reactions: { reactionType: string; creator: string }[]; + }>(app, `/api/v1/memos/${encodeURIComponent(idA)}`, { bearer: accessToken }); + + expect(got.status).toBe(200); + expect(got.body.relations.map((r) => r.relatedMemo.name)).toEqual([nameB]); + expect(got.body.reactions.map((r) => [r.creator, r.reactionType])).toEqual([ + ["users/fullmemo", "❤️"], + ]); + }); + + it("POST memo attaches existing attachments and relations", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "createfull", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "createfull", "secret123"); + const related = await postMemo(app, accessToken, { content: "related", visibility: "PRIVATE" }); + const relatedName = (related.body as { name: string }).name; + const attachment = await apiJson<{ name: string }>(app, "/api/v1/attachments", { + method: "POST", + bearer: accessToken, + json: { + attachment: { + filename: "createfull.txt", + content: "Y3JlYXRl", + type: "text/plain", + }, + }, + }); + expect(attachment.status).toBe(200); + + const created = await postMemo(app, accessToken, { + content: "with extras", + visibility: "PRIVATE", + attachments: [{ name: attachment.body.name }], + relations: [{ relatedMemo: { name: relatedName }, type: "REFERENCE" }], + }); + + expect(created.status).toBe(200); + const body = created.body as { + attachments: { name: string }[]; + relations: { relatedMemo: { name: string }; type: string }[]; + }; + expect(body.attachments.map((a) => a.name)).toEqual([attachment.body.name]); + expect(body.relations.map((r) => r.relatedMemo.name)).toEqual([relatedName]); + }); + it("POST share then GET shares list then anonymous GET /shares/:token returns memo", async () => { const app = createTestApp(); await postFirstUser(app, { username: "shr", password: "secret123", role: "USER" }); diff --git a/tests/integration/users-extras.test.ts b/tests/integration/users-extras.test.ts index 89d5edeb6..4f241809b 100644 --- a/tests/integration/users-extras.test.ts +++ b/tests/integration/users-extras.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { apiJson } from "../helpers/http.js"; import { createTestApp } from "../helpers/test-app.js"; import { postFirstUser, postMemo, signIn } from "../helpers/seed.js"; import { GrpcCode } from "../../server/lib/grpc-status.js"; describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("shortcuts CRUD round-trip", async () => { const app = createTestApp(); await postFirstUser(app, { username: "sc", password: "secret123", role: "USER" }); @@ -109,6 +113,79 @@ describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", expect(Array.isArray((res.body as { notifications: unknown[] }).notifications)).toBe(true); }); + it("links an OAuth identity to the current user", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "idpadmin", password: "secret123", role: "ADMIN" }); + const { accessToken } = await signIn(app, "idpadmin", "secret123"); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + if (url === "https://idp.example/token") { + expect(init?.method).toBe("POST"); + return new Response(JSON.stringify({ access_token: "oauth-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "https://idp.example/userinfo") { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer oauth-token"); + return new Response(JSON.stringify({ sub: "extern-1", name: "Extern User", email: "extern@example.com" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const createProvider = await apiJson(app, "/api/v1/identity-providers", { + method: "POST", + bearer: accessToken, + json: { + identityProviderId: "oauth-test", + identityProvider: { + title: "OAuth Test", + type: "OAUTH2", + config: { + oauth2Config: { + clientId: "client-id", + clientSecret: "client-secret", + authUrl: "https://idp.example/auth", + tokenUrl: "https://idp.example/token", + userInfoUrl: "https://idp.example/userinfo", + scopes: ["openid"], + fieldMapping: { + identifier: "sub", + displayName: "name", + email: "email", + avatarUrl: "", + }, + }, + }, + }, + }, + }); + expect(createProvider.status).toBe(200); + + const linked = await apiJson<{ name: string; idpName: string; externUid: string }>(app, "/api/v1/users/idpadmin/linkedIdentities", { + method: "POST", + bearer: accessToken, + json: { + idpName: "identity-providers/oauth-test", + code: "oauth-code", + redirectUri: "http://localhost/auth/callback", + codeVerifier: "verifier", + }, + }); + + expect(linked.status).toBe(200); + expect(linked.body).toMatchObject({ + name: "users/idpadmin/linkedIdentities/oauth-test", + idpName: "identity-providers/oauth-test", + externUid: "extern-1", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("returns INVALID_ARGUMENT for malformed pageToken on user settings/webhooks/shortcuts", async () => { const app = createTestApp(); await postFirstUser(app, { username: "pg", password: "secret123", role: "USER" }); @@ -164,13 +241,13 @@ describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", expect(hasGeneralSetting).toBe(true); }); - it("ignores unsupported updateMask paths for user/webhook updates (golang-compatible)", async () => { + it("rejects invalid user updateMask paths; ignores unknown webhook mask paths (golang)", async () => { const app = createTestApp(); await postFirstUser(app, { username: "um", password: "secret123", role: "USER" }); const { accessToken } = await signIn(app, "um", "secret123"); const base = "/api/v1/users/um"; - const userPatch = await apiJson<{ displayName: string }>(app, `${base}`, { + const userPatch = await apiJson<{ code: number; message?: string }>(app, `${base}`, { method: "PATCH", bearer: accessToken, json: { @@ -178,9 +255,9 @@ describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", updateMask: { paths: ["nickname"] }, }, }); - expect(userPatch.status).toBe(200); - // Unknown mask path is ignored; no field update. - expect(userPatch.body.displayName).toBe(""); + expect(userPatch.status).toBe(400); + expect(userPatch.body.code).toBe(GrpcCode.INVALID_ARGUMENT); + expect(userPatch.body.message).toMatch(/invalid update path/i); const webhookCreate = await apiJson<{ name: string }>(app, `${base}/webhooks`, { method: "POST", @@ -336,20 +413,18 @@ describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", method: "PATCH", bearer: ownerToken, json: { - setting: { - notificationSetting: { - email: { - enabled: true, - smtpHost: "smtp.example.com", - smtpPort: 587, - smtpUsername: "bot@example.com", - smtpPassword: "secret", - fromEmail: "bot@example.com", - fromName: "memos bot", - replyTo: "noreply@example.com", - useTls: true, - useSsl: false, - }, + notificationSetting: { + email: { + enabled: true, + smtpHost: "smtp.example.com", + smtpPort: 587, + smtpUsername: "bot@example.com", + smtpPassword: "secret", + fromEmail: "bot@example.com", + fromName: "memos bot", + replyTo: "noreply@example.com", + useTls: true, + useSsl: false, }, }, }, @@ -408,17 +483,15 @@ describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", method: "PATCH", bearer: ownerToken, json: { - setting: { - notificationSetting: { - email: { - enabled: true, - smtpHost: "smtp.example.com", - smtpPort: 587, - smtpUsername: "bot@example.com", - smtpPassword: "original-password", - fromEmail: "bot@example.com", - useTls: true, - }, + notificationSetting: { + email: { + enabled: true, + smtpHost: "smtp.example.com", + smtpPort: 587, + smtpUsername: "bot@example.com", + smtpPassword: "original-password", + fromEmail: "bot@example.com", + useTls: true, }, }, }, @@ -428,12 +501,10 @@ describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", method: "PATCH", bearer: ownerToken, json: { - setting: { - notificationSetting: { - email: { - smtpPassword: "", - smtpHost: "smtp2.example.com", - }, + notificationSetting: { + email: { + smtpPassword: "", + smtpHost: "smtp2.example.com", }, }, }, diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts index d5bf9e9fa..af93e96de 100644 --- a/tests/integration/users.test.ts +++ b/tests/integration/users.test.ts @@ -76,6 +76,63 @@ describe("integration: users", () => { expect((get.body as { displayName: string }).displayName).toBe("New Name"); }); + it("PATCH user accepts gRPC-Gateway-style flat JSON body (snake_case) and infers update mask", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "gwflat", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "gwflat", "secret123"); + + const patch = await apiJson(app, "/api/v1/users/gwflat", { + method: "PATCH", + bearer: accessToken, + json: { + display_name: "Magnux", + description: "Hello", + email: "scliqiao@gmail.com", + }, + }); + expect(patch.status).toBe(200); + const b = patch.body as { displayName: string; description: string; email: string }; + expect(b.displayName).toBe("Magnux"); + expect(b.description).toBe("Hello"); + expect(b.email).toBe("scliqiao@gmail.com"); + }); + + it("PATCH user merges updateMask from query with flat body (OpenAPI)", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "gwquery", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "gwquery", "secret123"); + + const patch = await apiJson(app, "/api/v1/users/gwquery?updateMask=email,description", { + method: "PATCH", + bearer: accessToken, + json: { + email: "open@api.test", + description: "via query mask", + }, + }); + expect(patch.status).toBe(200); + const b = patch.body as { email: string; description: string }; + expect(b.email).toBe("open@api.test"); + expect(b.description).toBe("via query mask"); + }); + + it("PATCH user returns INVALID_ARGUMENT for unknown update mask path", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "badmask", password: "secret123", role: "USER" }); + const { accessToken } = await signIn(app, "badmask", "secret123"); + + const patch = await apiJson<{ code: number }>(app, "/api/v1/users/badmask", { + method: "PATCH", + bearer: accessToken, + json: { + user: { displayName: "X" }, + updateMask: { paths: ["not_a_field"] }, + }, + }); + expect(patch.status).toBe(400); + expect(patch.body.code).toBe(3); + }); + it("admin PATCH state archives and restores user with numeric enum values", async () => { const app = createTestApp(); const { accessToken } = await seedAdmin(app, { username: "admstate", password: "secret123" }); diff --git a/tests/unit/memo-content-props.test.ts b/tests/unit/memo-content-props.test.ts new file mode 100644 index 000000000..6f85125e3 --- /dev/null +++ b/tests/unit/memo-content-props.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { deriveMemoProperty, extractTitleHint } from "../../server/services/memo-content-props.js"; + +describe("extractTitleHint", () => { + it("extracts plain text from first H1", () => { + expect(extractTitleHint("# My Article Title\n\nBody text here.")).toBe( + "My Article Title", + ); + }); + + it("does not extract from h2 as first block", () => { + expect(extractTitleHint("## Sub Heading\n\nBody text.")).toBe(""); + }); + + it("does not extract H1 when it is not the first block", () => { + expect(extractTitleHint("Some text\n\n# Heading Later")).toBe(""); + }); + + it("strips inline formatting from H1", () => { + expect(extractTitleHint("# Title with **bold** and *italic*\n\nBody.")).toBe( + "Title with bold and italic", + ); + }); + + it("returns empty for plain text without H1", () => { + expect(extractTitleHint("Just plain text")).toBe(""); + expect(extractTitleHint("")).toBe(""); + }); +}); + +describe("deriveMemoProperty", () => { + it("includes title only for leading H1", () => { + expect(deriveMemoProperty("# Title\n\n[Link](url)").title).toBe("Title"); + expect(deriveMemoProperty("Intro\n\n# Late Heading").title).toBe(""); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index baf832933..c06b61940 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,7 +4,7 @@ "noEmit": false, "outDir": "dist/server", "rootDir": "server", - "types": ["node"] + "types": ["node", "@cloudflare/workers-types"] }, "include": ["server/**/*.ts"], "exclude": [ diff --git a/web/package-lock.json b/web/package-lock.json index 9d5a89e86..c0ab47506 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -30,6 +30,7 @@ "dayjs": "^1.11.20", "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", + "html-to-image": "^1.11.13", "i18next": "^25.8.18", "katex": "^0.16.38", "leaflet": "^1.9.4", @@ -65,6 +66,9 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@bufbuild/protobuf": "^2.11.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/katex": "^0.16.8", @@ -80,16 +84,25 @@ "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.7.0", "baseline-browser-mapping": "^2.10.8", + "jsdom": "^29.0.2", "long": "^5.3.2", "terser": "^5.46.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.4" }, "engines": { "node": ">=24" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -103,6 +116,78 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -560,6 +645,40 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", @@ -627,6 +746,146 @@ "@bufbuild/protobuf": "^2.7.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1215,6 +1474,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2745,6 +3022,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -2956,6 +3240,64 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -3055,6 +3397,98 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3100,6 +3534,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -3362,6 +3807,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3561,10 +4013,130 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@xobotyi/scrollbar-width": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", - "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xobotyi/scrollbar-width": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", + "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "license": "MIT" }, "node_modules/acorn": { @@ -3579,6 +4151,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3591,6 +4186,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -3629,6 +4244,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -3710,6 +4335,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3912,6 +4547,13 @@ "node": ">=0.10.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4426,6 +5068,20 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -4449,6 +5105,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4508,6 +5171,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", @@ -4567,6 +5237,13 @@ "stackframe": "^1.3.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -4641,6 +5318,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5022,6 +5719,19 @@ "react-is": "^16.7.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -5031,6 +5741,12 @@ "void-elements": "3.1.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -5116,6 +5832,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -5217,6 +5943,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -5238,6 +5971,104 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5637,6 +6468,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6621,6 +7462,16 @@ "node": ">=16" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -6691,6 +7542,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -6866,6 +7728,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -6876,6 +7760,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -7158,6 +8052,20 @@ "react-dom": "*" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rehype-katex": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", @@ -7303,6 +8211,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -7422,6 +8340,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -7468,6 +8399,13 @@ "node": ">=6.9" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -7526,6 +8464,13 @@ "stackframe": "^1.3.4" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -7562,6 +8507,13 @@ "stacktrace-gps": "^3.0.4" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7576,6 +8528,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -7612,6 +8577,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -7682,6 +8654,13 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -7708,12 +8687,68 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7785,6 +8820,16 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -8111,6 +9156,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -8169,6 +9304,19 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -8179,6 +9327,75 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 31862aa00..a0fa1e04d 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,10 @@ "release": "vite build --mode release --outDir=../dist/public --emptyOutDir", "lint": "tsc --noEmit --skipLibCheck && biome check src", "lint:fix": "biome check --write src", - "format": "biome format --write src" + "format": "biome format --write src", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@connectrpc/connect": "^2.1.1", @@ -37,6 +40,7 @@ "dayjs": "^1.11.20", "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", + "html-to-image": "^1.11.13", "i18next": "^25.8.18", "katex": "^0.16.38", "leaflet": "^1.9.4", @@ -72,6 +76,9 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@bufbuild/protobuf": "^2.11.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/katex": "^0.16.8", @@ -87,10 +94,12 @@ "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.7.0", "baseline-browser-mapping": "^2.10.8", + "jsdom": "^29.0.2", "long": "^5.3.2", "terser": "^5.46.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.4" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 5cded7f2a..ec13fd8eb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import useNavigateTo from "./hooks/useNavigateTo"; import { useUserLocale } from "./hooks/useUserLocale"; import { useUserTheme } from "./hooks/useUserTheme"; import { cleanupExpiredOAuthState } from "./utils/oauth"; + const App = () => { const navigateTo = useNavigateTo(); const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance(); @@ -14,7 +15,7 @@ const App = () => { useUserLocale(); useUserTheme(); - // Clean up stale / expired OAuth state entries on mount to prevent leakage. + // Clean up expired OAuth states on app initialization useEffect(() => { cleanupExpiredOAuthState(); }, []); diff --git a/web/src/components/ActivityCalendar/CalendarCell.tsx b/web/src/components/ActivityCalendar/CalendarCell.tsx index 5d47dc569..175b5ddc2 100644 --- a/web/src/components/ActivityCalendar/CalendarCell.tsx +++ b/web/src/components/ActivityCalendar/CalendarCell.tsx @@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { cn } from "@/lib/utils"; import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants"; import type { CalendarDayCell, CalendarSize } from "./types"; -import { getCellIntensityClass } from "./utils"; +import { getCalendarCellStateClass, getCellIntensityClass } from "./utils"; export interface CalendarCellProps { day: CalendarDayCell; @@ -44,8 +44,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => { const buttonClasses = cn( baseClasses, intensityClass, - day.isToday && "ring-2 ring-primary/30 ring-offset-1 font-semibold z-10", - day.isSelected && "ring-2 ring-primary ring-offset-1 font-bold z-10", + getCalendarCellStateClass(day), isInteractive ? "cursor-pointer hover:bg-muted/40 hover:border-border/30" : "cursor-default", ); diff --git a/web/src/components/ActivityCalendar/MonthCalendar.tsx b/web/src/components/ActivityCalendar/MonthCalendar.tsx index 891a285be..3c3e9176c 100644 --- a/web/src/components/ActivityCalendar/MonthCalendar.tsx +++ b/web/src/components/ActivityCalendar/MonthCalendar.tsx @@ -23,7 +23,7 @@ const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => ( {weekDays.map((label, index) => (
@@ -35,11 +35,12 @@ const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => ( WeekdayHeader.displayName = "WeekdayHeader"; export const MonthCalendar = memo((props: MonthCalendarProps) => { - const { month, data, maxCount, size = "default", onClick, className, disableTooltips = false } = props; + const { month, data, maxCount, size = "default", onClick, selectedDate, className, disableTooltips = false } = props; const t = useTranslate(); const { generalSetting } = useInstance(); const today = useTodayDate(); const weekDays = useWeekdayLabels(); + const gridStyle = GRID_STYLES[size]; const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({ month, @@ -47,7 +48,7 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => { weekDays, weekStartDayOffset: generalSetting.weekStartDayOffset, today, - selectedDate: "", + selectedDate: selectedDate ?? "", }); const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]); @@ -56,7 +57,7 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => {
-
+
{flatDays.map((day) => ( ; + data: CalendarData; maxCount: number; onDateClick: (date: string) => void; } @@ -87,7 +87,7 @@ export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClic const currentYear = useMemo(() => new Date().getFullYear(), []); const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]); const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]); - const yearMaxCount = useMemo(() => calculateYearMaxCount(yearData), [yearData]); + const yearMaxCount = useMemo(() => calculateMaxCount(yearData), [yearData]); const canGoPrev = selectedYear > MIN_YEAR; const canGoNext = selectedYear < getMaxYear(); diff --git a/web/src/components/ActivityCalendar/constants.ts b/web/src/components/ActivityCalendar/constants.ts index c0913b967..de457971b 100644 --- a/web/src/components/ActivityCalendar/constants.ts +++ b/web/src/components/ActivityCalendar/constants.ts @@ -1,6 +1,5 @@ export const DAYS_IN_WEEK = 7; export const MONTHS_IN_YEAR = 12; -export const WEEKEND_DAYS = [0, 6] as const; export const MIN_COUNT = 1; export const MIN_YEAR = 1970; diff --git a/web/src/components/ActivityCalendar/types.ts b/web/src/components/ActivityCalendar/types.ts index 306b21b9b..119b0d071 100644 --- a/web/src/components/ActivityCalendar/types.ts +++ b/web/src/components/ActivityCalendar/types.ts @@ -1,4 +1,5 @@ export type CalendarSize = "default" | "small"; +export type CalendarData = Record; export interface CalendarDayCell { date: string; @@ -7,7 +8,6 @@ export interface CalendarDayCell { isCurrentMonth: boolean; isToday: boolean; isSelected: boolean; - isWeekend: boolean; } export interface CalendarDayRow { @@ -17,22 +17,22 @@ export interface CalendarDayRow { export interface CalendarMatrixResult { weeks: CalendarDayRow[]; weekDays: string[]; - maxCount: number; } export interface MonthCalendarProps { month: string; - data: Record; + data: CalendarData; maxCount: number; size?: CalendarSize; onClick?: (date: string) => void; + selectedDate?: string; className?: string; disableTooltips?: boolean; } export interface YearCalendarProps { selectedYear: number; - data: Record; + data: CalendarData; onYearChange: (year: number) => void; onDateClick: (date: string) => void; className?: string; diff --git a/web/src/components/ActivityCalendar/useCalendar.ts b/web/src/components/ActivityCalendar/useCalendar.ts index 783767e44..cf45dbeee 100644 --- a/web/src/components/ActivityCalendar/useCalendar.ts +++ b/web/src/components/ActivityCalendar/useCalendar.ts @@ -1,11 +1,11 @@ import dayjs from "dayjs"; import { useMemo } from "react"; -import { DAYS_IN_WEEK, MIN_COUNT, WEEKEND_DAYS } from "./constants"; -import type { CalendarDayCell, CalendarMatrixResult } from "./types"; +import { DAYS_IN_WEEK } from "./constants"; +import type { CalendarData, CalendarDayCell, CalendarMatrixResult } from "./types"; export interface UseCalendarMatrixParams { month: string; - data: Record; + data: CalendarData; weekDays: string[]; weekStartDayOffset: number; today: string; @@ -15,7 +15,7 @@ export interface UseCalendarMatrixParams { const createCalendarDayCell = ( current: dayjs.Dayjs, monthKey: string, - data: Record, + data: CalendarData, today: string, selectedDate: string, ): CalendarDayCell => { @@ -30,7 +30,6 @@ const createCalendarDayCell = ( isCurrentMonth, isToday: isoDate === today, isSelected: isoDate === selectedDate, - isWeekend: WEEKEND_DAYS.includes(current.day() as 0 | 6), }; }; @@ -68,7 +67,6 @@ export const useCalendarMatrix = ({ const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset); const weeks: CalendarMatrixResult["weeks"] = []; - let maxCount = 0; // Iterate through each day in the calendar grid for (let index = 0; index < dayCount; index += 1) { @@ -82,13 +80,11 @@ export const useCalendarMatrix = ({ // Create the day cell object with data and status flags const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate); weeks[weekIndex].days.push(dayCell); - maxCount = Math.max(maxCount, dayCell.count); } return { weeks, weekDays: rotatedWeekDays, - maxCount: Math.max(maxCount, MIN_COUNT), }; }, [month, data, weekDays, weekStartDayOffset, today, selectedDate]); }; diff --git a/web/src/components/ActivityCalendar/utils.ts b/web/src/components/ActivityCalendar/utils.ts index 0ce1029db..368c26abe 100644 --- a/web/src/components/ActivityCalendar/utils.ts +++ b/web/src/components/ActivityCalendar/utils.ts @@ -1,9 +1,10 @@ import dayjs from "dayjs"; import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; +import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants"; -import type { CalendarDayCell } from "./types"; +import type { CalendarData, CalendarDayCell } from "./types"; dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrBefore); @@ -22,11 +23,15 @@ export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): s return CELL_STYLES.MINIMAL; }; +export const getCalendarCellStateClass = (day: Pick): string => { + return cn(day.isToday && "font-semibold z-10", day.isSelected && "font-bold z-10"); +}; + export const generateMonthsForYear = (year: number): string[] => { return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM")); }; -export const calculateYearMaxCount = (data: Record): number => { +export const calculateMaxCount = (data: CalendarData): number => { let max = 0; for (const count of Object.values(data)) { max = Math.max(max, count); @@ -55,10 +60,6 @@ export const filterDataByYear = (data: Record, year: number): Re return filtered; }; -export const hasActivityData = (data: Record): boolean => { - return Object.values(data).some((count) => count > 0); -}; - export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => { if (count === 0) { return date; diff --git a/web/src/components/CreateIdentityProviderDialog.tsx b/web/src/components/CreateIdentityProviderDialog.tsx index 64d370a6d..3197502d1 100644 --- a/web/src/components/CreateIdentityProviderDialog.tsx +++ b/web/src/components/CreateIdentityProviderDialog.tsx @@ -1,12 +1,12 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { toast } from "react-hot-toast"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; import { identityProviderServiceClient } from "@/connect"; import { absolutifyLink } from "@/helpers/utils"; import { handleError } from "@/lib/error"; @@ -22,6 +22,8 @@ import { } from "@/types/proto/api/v1/idp_service_pb"; import { useTranslate } from "@/utils/i18n"; +const DEFAULT_TEMPLATE = "GitHub"; + const templateList: IdentityProvider[] = [ create(IdentityProviderSchema, { name: "", @@ -128,150 +130,216 @@ interface Props { onSuccess?: () => void; } -function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) { - const t = useTranslate(); - const identityProviderTypes = [...new Set(templateList.map((t) => t.type))]; - const [basicInfo, setBasicInfo] = useState({ +interface BasicInfoState { + title: string; + identifier: string; + identifierFilter: string; +} + +function createEmptyFieldMapping(): FieldMapping { + return create(FieldMappingSchema, { + identifier: "", + displayName: "", + email: "", + avatarUrl: "", + }); +} + +function createEmptyOAuth2Config(): OAuth2Config { + return create(OAuth2ConfigSchema, { + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + userInfoUrl: "", + scopes: [], + fieldMapping: createEmptyFieldMapping(), + }); +} + +function createEmptyBasicInfo(): BasicInfoState { + return { title: "", identifier: "", identifierFilter: "", - }); - const [type, setType] = useState(IdentityProvider_Type.OAUTH2); - const [oauth2Config, setOAuth2Config] = useState( - create(OAuth2ConfigSchema, { - clientId: "", - clientSecret: "", - authUrl: "", - tokenUrl: "", - userInfoUrl: "", - scopes: [], - fieldMapping: create(FieldMappingSchema, { - identifier: "", - displayName: "", - email: "", - }), - }), + }; +} + +function sanitizeIdentifier(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/--+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function normalizeScopes(value: string): string[] { + return value + .split(/\s+/) + .map((scope) => scope.trim()) + .filter(Boolean); +} + +function buildDialogStateFromTemplate(templateName: string) { + const template = templateList.find((item) => item.title === templateName) ?? templateList[0]; + const oauth2Config = + template.type === IdentityProvider_Type.OAUTH2 && template.config?.config.case === "oauth2Config" + ? create(OAuth2ConfigSchema, template.config.config.value) + : createEmptyOAuth2Config(); + + return { + basicInfo: { + title: template.title, + identifier: sanitizeIdentifier(template.title), + identifierFilter: template.identifierFilter, + }, + type: template.type, + oauth2Config, + oauth2Scopes: oauth2Config.scopes.join(" "), + }; +} + +function buildDialogStateFromProvider(identityProvider: IdentityProvider) { + const oauth2Config = + identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config.case === "oauth2Config" + ? create(OAuth2ConfigSchema, identityProvider.config.config.value) + : createEmptyOAuth2Config(); + + return { + basicInfo: { + title: identityProvider.title, + identifier: "", + identifierFilter: identityProvider.identifierFilter, + }, + type: identityProvider.type, + oauth2Config, + oauth2Scopes: oauth2Config.scopes.join(" "), + }; +} + +function FormSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+
{children}
+
); +} + +function FormField({ + label, + required = false, + description, + children, +}: { + label: string; + required?: boolean; + description?: string; + children: ReactNode; +}) { + return ( +
+ + {children} + {description ?

{description}

: null} +
+ ); +} + +function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) { + const t = useTranslate(); + const identityProviderTypes = [...new Set(templateList.map((template) => template.type))]; + const [basicInfo, setBasicInfo] = useState(createEmptyBasicInfo); + const [type, setType] = useState(IdentityProvider_Type.OAUTH2); + const [oauth2Config, setOAuth2Config] = useState(createEmptyOAuth2Config); const [oauth2Scopes, setOAuth2Scopes] = useState(""); - const [selectedTemplate, setSelectedTemplate] = useState("GitHub"); + const [selectedTemplate, setSelectedTemplate] = useState(DEFAULT_TEMPLATE); + const [isSubmitting, setIsSubmitting] = useState(false); const isCreating = identityProvider === undefined; + const oauth2FieldMapping = oauth2Config.fieldMapping ?? createEmptyFieldMapping(); - // Reset state when dialog is closed useEffect(() => { if (!open) { - // Reset to default state when dialog is closed - setBasicInfo({ - title: "", - identifier: "", - identifierFilter: "", - }); + setSelectedTemplate(DEFAULT_TEMPLATE); + setBasicInfo(createEmptyBasicInfo()); setType(IdentityProvider_Type.OAUTH2); - setOAuth2Config( - create(OAuth2ConfigSchema, { - clientId: "", - clientSecret: "", - authUrl: "", - tokenUrl: "", - userInfoUrl: "", - scopes: [], - fieldMapping: create(FieldMappingSchema, { - identifier: "", - displayName: "", - email: "", - }), - }), - ); + setOAuth2Config(createEmptyOAuth2Config()); setOAuth2Scopes(""); - setSelectedTemplate("GitHub"); + setIsSubmitting(false); + return; } - }, [open]); - // Load existing identity provider data when editing - useEffect(() => { - if (open && identityProvider) { - setBasicInfo({ - title: identityProvider.title, - identifier: "", - identifierFilter: identityProvider.identifierFilter, - }); - setType(identityProvider.type); - if (identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config?.case === "oauth2Config") { - const oauth2Config = create(OAuth2ConfigSchema, identityProvider.config.config.value || {}); - setOAuth2Config(oauth2Config); - setOAuth2Scopes(oauth2Config.scopes.join(" ")); - } - } - }, [open, identityProvider]); + const nextState = isCreating ? buildDialogStateFromTemplate(selectedTemplate) : buildDialogStateFromProvider(identityProvider!); + setBasicInfo(nextState.basicInfo); + setType(nextState.type); + setOAuth2Config(nextState.oauth2Config); + setOAuth2Scopes(nextState.oauth2Scopes); + }, [open, isCreating, identityProvider, selectedTemplate]); - // Load template data when creating new IDP - useEffect(() => { - if (!isCreating || !open) { + const handleDialogClose = (nextOpen: boolean) => { + if (isSubmitting && !nextOpen) { return; } - - const template = templateList.find((t) => t.title === selectedTemplate); - if (template) { - setBasicInfo({ - title: template.title, - identifier: template.title.toLowerCase().replace(/[^a-z0-9]+/g, "-"), - identifierFilter: template.identifierFilter, - }); - setType(template.type); - if (template.type === IdentityProvider_Type.OAUTH2 && template.config?.config?.case === "oauth2Config") { - const oauth2Config = create(OAuth2ConfigSchema, template.config.config.value || {}); - setOAuth2Config(oauth2Config); - setOAuth2Scopes(oauth2Config.scopes.join(" ")); - } - } - }, [selectedTemplate, isCreating, open]); + onOpenChange(nextOpen); + }; const handleCloseBtnClick = () => { - onOpenChange(false); + if (isSubmitting) { + return; + } + handleDialogClose(false); }; const allowConfirmAction = () => { - if (basicInfo.title === "") { + if (basicInfo.title.trim() === "") { return false; } - if (isCreating && basicInfo.identifier === "") { + if (isCreating && basicInfo.identifier.trim() === "") { return false; } if (type === IdentityProvider_Type.OAUTH2) { if ( - oauth2Config.clientId === "" || - oauth2Config.authUrl === "" || - oauth2Config.tokenUrl === "" || - oauth2Config.userInfoUrl === "" || - oauth2Scopes === "" || - oauth2Config.fieldMapping?.identifier === "" + oauth2Config.clientId.trim() === "" || + oauth2Config.authUrl.trim() === "" || + oauth2Config.tokenUrl.trim() === "" || + oauth2Config.userInfoUrl.trim() === "" || + normalizeScopes(oauth2Scopes).length === 0 || + oauth2FieldMapping.identifier.trim() === "" ) { return false; } - if (isCreating) { - if (oauth2Config.clientSecret === "") { - return false; - } + if (isCreating && oauth2Config.clientSecret.trim() === "") { + return false; } } - return true; + return !isSubmitting; }; const handleConfirmBtnClick = async () => { + setIsSubmitting(true); + const normalizedScopes = normalizeScopes(oauth2Scopes); + try { if (isCreating) { await identityProviderServiceClient.createIdentityProvider({ identityProviderId: basicInfo.identifier, identityProvider: create(IdentityProviderSchema, { - title: basicInfo.title, - identifierFilter: basicInfo.identifierFilter, - type: type, + title: basicInfo.title.trim(), + identifierFilter: basicInfo.identifierFilter.trim(), + type, config: create(IdentityProviderConfigSchema, { config: { case: "oauth2Config", value: { ...oauth2Config, - scopes: oauth2Scopes.split(" "), + scopes: normalizedScopes, }, }, }), @@ -281,15 +349,16 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on } else { await identityProviderServiceClient.updateIdentityProvider({ identityProvider: create(IdentityProviderSchema, { - ...basicInfo, name: identityProvider!.name, - type: type, + title: basicInfo.title.trim(), + identifierFilter: basicInfo.identifierFilter.trim(), + type, config: create(IdentityProviderConfigSchema, { config: { case: "oauth2Config", value: { ...oauth2Config, - scopes: oauth2Scopes.split(" "), + scopes: normalizedScopes, }, }, }), @@ -299,225 +368,239 @@ function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, on toast.success(t("setting.sso.sso-updated", { name: basicInfo.title })); } } catch (error: unknown) { + setIsSubmitting(false); await handleError(error, toast.error, { context: isCreating ? "Create identity provider" : "Update identity provider", }); + return; } + + setIsSubmitting(false); onSuccess?.(); - onOpenChange(false); + handleDialogClose(false); }; const setPartialOAuth2Config = (state: Partial) => { - setOAuth2Config({ - ...oauth2Config, + setOAuth2Config((current) => ({ + ...current, ...state, + })); + }; + + const setPartialFieldMapping = (state: Partial) => { + setPartialOAuth2Config({ + fieldMapping: { + ...oauth2FieldMapping, + ...state, + } as FieldMapping, }); }; return ( - - + + {t(isCreating ? "setting.sso.create-sso" : "setting.sso.update-sso")} + + {t(isCreating ? "setting.sso.create-sso-description" : "setting.sso.update-sso-description")} + -
- {isCreating && ( - <> -

{t("common.type")}

- -

{t("setting.sso.template")}

- - - - )} - {isCreating && ( - <> -

- ID - * -

+ +
+ + {isCreating ? ( +
+ + + + + + + +
+ ) : null} + +
+ {isCreating ? ( + + + setBasicInfo((current) => ({ + ...current, + identifier: sanitizeIdentifier(e.target.value), + })) + } + /> + + ) : null} + + + + setBasicInfo((current) => ({ + ...current, + title: e.target.value, + })) + } + /> + +
+ + - setBasicInfo({ - ...basicInfo, - identifier: e.target.value - .toLowerCase() - .replace(/[^a-z0-9-]/g, "-") - .replace(/--+/g, "-"), - }) + setBasicInfo((current) => ({ + ...current, + identifierFilter: e.target.value, + })) } /> -

- A unique identifier for this provider. Lowercase letters, numbers, and hyphens only. -

- - )} -

- {t("common.name")} - * -

- - setBasicInfo({ - ...basicInfo, - title: e.target.value, - }) - } - /> -

{t("setting.sso.identifier-filter")}

- - setBasicInfo({ - ...basicInfo, - identifierFilter: e.target.value, - }) - } - /> - - {type === IdentityProvider_Type.OAUTH2 && ( +
+
+ + {type === IdentityProvider_Type.OAUTH2 ? ( <> - {isCreating && ( -

- {t("setting.sso.redirect-url")}: {absolutifyLink("/auth/callback")} -

- )} -

- {t("setting.sso.client-id")} - * -

- setPartialOAuth2Config({ clientId: e.target.value })} - /> -

- {t("setting.sso.client-secret")} - * -

- setPartialOAuth2Config({ clientSecret: e.target.value })} - /> -

- {t("setting.sso.authorization-endpoint")} - * -

- setPartialOAuth2Config({ authUrl: e.target.value })} - /> -

- {t("setting.sso.token-endpoint")} - * -

- setPartialOAuth2Config({ tokenUrl: e.target.value })} - /> -

- {t("setting.sso.user-endpoint")} - * -

- setPartialOAuth2Config({ userInfoUrl: e.target.value })} - /> -

- {t("setting.sso.scopes")} - * -

- setOAuth2Scopes(e.target.value)} - /> - -

- {t("setting.sso.identifier")} - * -

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping }) - } - /> -

{t("setting.sso.display-name")}

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping }) - } - /> -

{t("common.email")}

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping }) - } - /> -

Avatar URL

- - setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping }) - } - /> + +
+

{t("setting.sso.redirect-url")}

+

{absolutifyLink("/auth/callback")}

+

{t("setting.sso.redirect-url-description")}

+
+ +
+ + setPartialOAuth2Config({ clientId: e.target.value })} + /> + + + + setPartialOAuth2Config({ clientSecret: e.target.value })} + /> + +
+ +
+ + setPartialOAuth2Config({ authUrl: e.target.value })} + /> + + + + setPartialOAuth2Config({ tokenUrl: e.target.value })} + /> + +
+ +
+ + setPartialOAuth2Config({ userInfoUrl: e.target.value })} + /> + + + + setOAuth2Scopes(e.target.value)} /> + +
+
+ + +
+ + setPartialFieldMapping({ identifier: e.target.value })} + /> + + + + setPartialFieldMapping({ displayName: e.target.value })} + /> + +
+ +
+ + setPartialFieldMapping({ email: e.target.value })} + /> + + + + setPartialFieldMapping({ avatarUrl: e.target.value })} + /> + +
+
- )} + ) : null}
+ - +
+
+ ); + } + + const isUnread = notification.status === UserNotification_Status.UNREAD; + const isCommentMention = Boolean(mentionPayload.relatedMemo); + const targetName = mentionPayload.relatedMemo || mentionPayload.memo; + + const handleNavigate = async () => { + navigateTo(`/${targetName}`); + if (isUnread) { + await handleArchiveMessage(true); + } + }; + + return ( +
+ {isUnread &&
} + +
+
+ +
+ +
+
+ +
+
+
+ {sender?.displayName || sender?.username} + mentioned you {isCommentMention ? "in a comment" : "in a memo"} + + {notification.createTime && + timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "} + at{" "} + {notification.createTime && + timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
+
+ {isUnread ? ( + + ) : ( + + )} +
+
+ + {mentionPayload.relatedMemo && ( +
+

+ Memo: + {mentionPayload.relatedMemoSnippet || Empty memo} +

+
+ )} + +
+
+
+ +
+
+

+ {isCommentMention ? "Comment" : "Memo"} +

+

+ {mentionPayload.memoSnippet || Empty memo} +

+
+
+
+
+
+
+ ); +} + +export default MemoMentionMessage; diff --git a/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx b/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx new file mode 100644 index 000000000..6308e50f1 --- /dev/null +++ b/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx @@ -0,0 +1,137 @@ +import { DownloadIcon, ImageIcon, Loader2Icon, Share2Icon } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useTranslate } from "@/utils/i18n"; +import { useMemoViewContext } from "../MemoView/MemoViewContext"; +import MemoShareImagePreview from "./MemoShareImagePreview"; +import { + buildMemoShareImageFileName, + createMemoShareImageBlob, + getMemoShareDialogWidth, + getMemoSharePreviewWidth, + getMemoShareRenderWidth, +} from "./memoShareImage"; + +interface MemoShareImageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps) => { + const t = useTranslate(); + const { memo, cardWidth } = useMemoViewContext(); + const previewRef = useRef(null); + const [isRendering, setIsRendering] = useState(false); + + const previewWidth = useMemo(() => getMemoSharePreviewWidth(cardWidth), [cardWidth]); + const dialogWidth = useMemo(() => getMemoShareDialogWidth(previewWidth), [previewWidth]); + const previewRenderWidth = useMemo(() => getMemoShareRenderWidth(previewWidth, dialogWidth), [dialogWidth, previewWidth]); + + const createShareBlob = useCallback(async () => { + const preview = previewRef.current; + if (!preview) { + throw new Error("Preview is not ready"); + } + + return createMemoShareImageBlob(preview); + }, []); + + const handleDownload = useCallback(async () => { + setIsRendering(true); + try { + const blob = await createShareBlob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = buildMemoShareImageFileName(memo.name); + anchor.click(); + URL.revokeObjectURL(url); + toast.success(t("memo.share.image-downloaded")); + } catch { + toast.error(t("memo.share.image-download-failed")); + } finally { + setIsRendering(false); + } + }, [createShareBlob, memo.name, t]); + + const handleNativeShare = useCallback(async () => { + if (typeof navigator.share !== "function") { + return; + } + + setIsRendering(true); + try { + const blob = await createShareBlob(); + const file = new File([blob], buildMemoShareImageFileName(memo.name), { type: "image/png" }); + if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) { + toast.error(t("memo.share.image-share-failed")); + return; + } + + await navigator.share({ + files: [file], + title: memo.content.slice(0, 60), + }); + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError")) { + toast.error(t("memo.share.image-share-failed")); + } + } finally { + setIsRendering(false); + } + }, [createShareBlob, memo.content, memo.name, t]); + + const supportsNativeShare = + typeof navigator !== "undefined" && typeof navigator.share === "function" && typeof navigator.canShare === "function"; + + return ( + + +
+ + + + {t("memo.share.image-title")} + + {t("memo.share.image-description", { width: previewRenderWidth })} + + +
+ +
+ + + {supportsNativeShare && ( + + )} + + +
+
+
+ ); +}; + +export default MemoShareImageDialog; diff --git a/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx new file mode 100644 index 000000000..b29acc1e8 --- /dev/null +++ b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx @@ -0,0 +1,87 @@ +import { forwardRef, useMemo } from "react"; +import MemoContent from "@/components/MemoContent"; +import UserAvatar from "@/components/UserAvatar"; +import i18n from "@/i18n"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; +import { useMemoViewContext } from "../MemoView/MemoViewContext"; +import { buildMemoShareImagePreviewModel } from "./memoShareImagePreviewModel"; + +const MemoShareImagePreview = forwardRef(({ width }, ref) => { + const t = useTranslate(); + const { memo, creator, blurred, showBlurredContent } = useMemoViewContext(); + const fallbackDisplayName = t("common.memo"); + const locale = i18n.language; + + const preview = useMemo( + () => + buildMemoShareImagePreviewModel({ + memo, + creator, + fallbackDisplayName, + locale, + }), + [creator, fallbackDisplayName, locale, memo], + ); + + return ( +
+
+
+
+ +
+
{preview.displayName}
+ {preview.formattedDisplayTime &&
{preview.formattedDisplayTime}
} +
+
+
+ +
+
+ +
+
+ + {preview.visualItems.length > 0 && ( +
+ {preview.visualItems.slice(0, 4).map((item, index) => ( +
+ {item.filename} + {index === 3 && preview.visualItems.length > 4 && ( +
+ +{preview.visualItems.length - 4} +
+ )} +
+ ))} +
+ )} + + {preview.footerBadges.length > 0 && ( +
+ {preview.footerBadges.map((badge) => ( + + {badge.count} {t("common.attachments").toLowerCase()} + + ))} +
+ )} +
+
+ ); +}); + +MemoShareImagePreview.displayName = "MemoShareImagePreview"; + +export default MemoShareImagePreview; diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index ddac3895b..c5cbbf289 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -8,6 +8,7 @@ import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; import { userKeys } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -74,7 +75,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use } if (isInMemoDetailPage) { - navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived"); + navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED); } memoUpdatedCallback(); }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]); @@ -109,7 +110,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) }); } if (isInMemoDetailPage) { - navigateTo("/"); + navigateTo(ROUTES.HOME); } memoUpdatedCallback(); }, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]); diff --git a/web/src/components/MemoActionMenu/memoShareImage.ts b/web/src/components/MemoActionMenu/memoShareImage.ts new file mode 100644 index 000000000..ab0516c75 --- /dev/null +++ b/web/src/components/MemoActionMenu/memoShareImage.ts @@ -0,0 +1,117 @@ +import { toBlob } from "html-to-image"; + +const WINDOW_HORIZONTAL_MARGIN = 32; +const PREVIEW_HORIZONTAL_PADDING_IN_DIALOG = 40; +const PREVIEW_WIDTH_BOOST_IN_DIALOG = 48; + +export const MEMO_SHARE_IMAGE_CONFIG = { + dialogExtraWidth: 80, + maxWidth: 520, + minWidth: 260, + previewScale: 0.9, + viewportMargin: 48, +} as const; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const isExportableImageUrl = (value?: string) => { + if (!value) { + return false; + } + + if (value.startsWith("/") || value.startsWith("data:") || value.startsWith("blob:")) { + return true; + } + + try { + return new URL(value, window.location.origin).origin === window.location.origin; + } catch { + return false; + } +}; + +const waitForPreviewAssets = async (node: HTMLElement) => { + try { + await document.fonts?.ready; + } catch { + // Ignore font loading failures and continue with the best available render. + } + + const images = Array.from(node.querySelectorAll("img")); + await Promise.all( + images.map( + (image) => + new Promise((resolve) => { + if (image.complete) { + resolve(); + return; + } + + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + }), + ), + ); +}; + +export const buildMemoShareImageFileName = (memoName: string) => { + const suffix = memoName.split("/").pop() || "memo"; + return `memo-${suffix}.png`; +}; + +export const getMemoSharePreviewWidth = (cardWidth: number) => { + const viewportWidth = + typeof window === "undefined" ? MEMO_SHARE_IMAGE_CONFIG.maxWidth : window.innerWidth - MEMO_SHARE_IMAGE_CONFIG.viewportMargin; + const baseWidth = cardWidth || viewportWidth; + + return clamp( + Math.round(baseWidth * MEMO_SHARE_IMAGE_CONFIG.previewScale), + MEMO_SHARE_IMAGE_CONFIG.minWidth, + MEMO_SHARE_IMAGE_CONFIG.maxWidth, + ); +}; + +export const getMemoShareDialogWidth = (previewWidth: number) => { + const viewportWidth = + typeof window === "undefined" ? previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth : window.innerWidth - WINDOW_HORIZONTAL_MARGIN; + return Math.min(previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth, viewportWidth); +}; + +export const getMemoShareRenderWidth = (previewWidth: number, dialogWidth: number) => { + const maxRenderWidth = Math.max(MEMO_SHARE_IMAGE_CONFIG.minWidth, dialogWidth - PREVIEW_HORIZONTAL_PADDING_IN_DIALOG); + return clamp(previewWidth + PREVIEW_WIDTH_BOOST_IN_DIALOG, MEMO_SHARE_IMAGE_CONFIG.minWidth, maxRenderWidth); +}; + +export const getMemoSharePreviewAvatarUrl = (avatarUrl?: string) => (isExportableImageUrl(avatarUrl) ? avatarUrl : undefined); + +export const createMemoShareImageBlob = async (node: HTMLElement) => { + await waitForPreviewAssets(node); + + const rect = node.getBoundingClientRect(); + const width = Math.ceil(rect.width || node.offsetWidth || node.clientWidth); + const height = Math.ceil(rect.height || node.offsetHeight || node.clientHeight); + + const blob = await toBlob(node, { + cacheBust: true, + height, + pixelRatio: Math.max(2, Math.min(window.devicePixelRatio || 1, 3)), + width, + filter: (currentNode) => { + if (!(currentNode instanceof HTMLElement)) { + return true; + } + + if (currentNode instanceof HTMLImageElement) { + return isExportableImageUrl(currentNode.currentSrc || currentNode.src); + } + + return !(currentNode instanceof HTMLVideoElement); + }, + }); + + if (!blob) { + throw new Error("Failed to render image"); + } + + return blob; +}; diff --git a/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts b/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts new file mode 100644 index 000000000..5d5b2c981 --- /dev/null +++ b/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts @@ -0,0 +1,58 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import type { User } from "@/types/proto/api/v1/user_service_pb"; +import { type AttachmentVisualItem, buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item"; +import { getMemoSharePreviewAvatarUrl } from "./memoShareImage"; + +interface BuildMemoShareImagePreviewModelOptions { + memo: Memo; + creator?: User; + fallbackDisplayName: string; + locale: string; +} + +export interface MemoShareImageAttachmentSummaryBadge { + type: "attachment-summary"; + count: number; +} + +export type MemoShareImageFooterBadge = MemoShareImageAttachmentSummaryBadge; + +export interface MemoShareImagePreviewModel { + displayName: string; + avatarUrl?: string; + formattedDisplayTime?: string; + visualItems: AttachmentVisualItem[]; + footerBadges: MemoShareImageFooterBadge[]; +} + +export const buildMemoShareImagePreviewModel = ({ + memo, + creator, + fallbackDisplayName, + locale, +}: BuildMemoShareImagePreviewModelOptions): MemoShareImagePreviewModel => { + const displayName = creator?.displayName || creator?.username || fallbackDisplayName; + const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl); + const displayTime = memo.createTime ? timestampDate(memo.createTime) : undefined; + const formattedDisplayTime = displayTime?.toLocaleString(locale, { + dateStyle: "medium", + timeStyle: "short", + }); + + const attachmentGroups = separateAttachments(memo.attachments); + const visualItems = buildAttachmentVisualItems(attachmentGroups.visual); + const attachmentCount = countLogicalAttachmentItems(memo.attachments); + const nonVisualAttachmentCount = Math.max(attachmentCount - visualItems.length, 0); + const footerBadges: MemoShareImageFooterBadge[] = + nonVisualAttachmentCount > 0 ? [{ type: "attachment-summary", count: attachmentCount }] : []; + + return { + displayName, + avatarUrl, + formattedDisplayTime, + visualItems, + footerBadges, + }; +}; diff --git a/web/src/components/MemoAttachment.tsx b/web/src/components/MemoAttachment.tsx deleted file mode 100644 index f2914ca55..000000000 --- a/web/src/components/MemoAttachment.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { cn } from "@/lib/utils"; -import { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import { getAttachmentUrl, isMidiFile } from "@/utils/attachment"; -import AttachmentIcon from "./AttachmentIcon"; - -interface Props { - attachment: Attachment; - className?: string; -} - -const MemoAttachment: React.FC = (props: Props) => { - const { className, attachment } = props; - const attachmentUrl = getAttachmentUrl(attachment); - - const handlePreviewBtnClick = () => { - window.open(attachmentUrl); - }; - - return ( -
- {attachment.type.startsWith("audio") && !isMidiFile(attachment.type) ? ( - - ) : ( - <> - - - {attachment.filename} - - - )} -
- ); -}; - -export default MemoAttachment; diff --git a/web/src/components/MemoCommentSection.tsx b/web/src/components/MemoCommentSection.tsx index e8f01b05e..76a70d8d7 100644 --- a/web/src/components/MemoCommentSection.tsx +++ b/web/src/components/MemoCommentSection.tsx @@ -67,7 +67,7 @@ const MemoCommentSection = ({ memo, comments, parentPage }: Props) => {
)} {comments.map((comment) => ( -
+
))} diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index 361a1ec43..72cb3e2d8 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -42,7 +42,7 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa // If the tag is clicked in a memo detail page, we should navigate to the memo list page. if (location.pathname.startsWith("/m")) { - const pathname = parentPage || Routes.ROOT; + const pathname = parentPage || Routes.ENTRY; const searchParams = new URLSearchParams(); searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }])); diff --git a/web/src/components/MemoContent/TaskListItem.tsx b/web/src/components/MemoContent/TaskListItem.tsx index 94545ebb3..0f89f7d2a 100644 --- a/web/src/components/MemoContent/TaskListItem.tsx +++ b/web/src/components/MemoContent/TaskListItem.tsx @@ -61,7 +61,7 @@ export const TaskListItem: React.FC = ({ checked, node: _node name: memo.name, content: newContent, }, - updateMask: ["content"], + updateMask: ["content", "update_time"], }); }; diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index 6a82e5c08..33a0be5e2 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -29,15 +29,19 @@ const TRUSTED_IFRAME_SRC_PATTERNS = [ /^https:\/\/(?:www\.)?draw\.io\/(?:[^?#]+)?(?:\?.*)?$/i, ]; +const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const; +const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const; +const SPAN_CLASS_NAMES = ["mention", "tag"] as const; +const INPUT_ATTRIBUTES = [...(defaultSchema.attributes?.input || []), ["checked", true]] as const; + export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src)); /** * Sanitization schema for markdown HTML content. * Extends the default schema to allow: - * - KaTeX math rendering elements (MathML tags) - * - KaTeX-specific attributes (className, style, aria-*, data-*) - * - Safe HTML elements for rich content - * - iframe embeds for trusted video providers (YouTube, Vimeo, etc.) + * - KaTeX marker classes used before trusted KaTeX rendering runs + * - Mention/tag metadata generated by trusted remark plugins + * - iframe embeds only from trusted video providers * * This prevents XSS attacks while preserving math rendering functionality. */ @@ -45,50 +49,25 @@ export const SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, - div: [...(defaultSchema.attributes?.div || []), "className"], img: [...(defaultSchema.attributes?.img || []), "height", "width"], - span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]], - // iframe attributes for video embeds - iframe: ["src", "width", "height", "frameborder", "allowfullscreen", "allow", "title", "referrerpolicy", "loading"], - // MathML attributes for KaTeX rendering - annotation: ["encoding"], - math: ["xmlns"], - mi: [], - mn: [], - mo: [], - mrow: [], - mspace: [], - mstyle: [], - msup: [], - msub: [], - msubsup: [], - mfrac: [], - mtext: [], - semantics: [], + input: INPUT_ATTRIBUTES, + code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]], + span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]], + iframe: [ + ["src", ...TRUSTED_IFRAME_SRC_PATTERNS], + "width", + "height", + "frameborder", + "allowfullscreen", + "allow", + "title", + "referrerpolicy", + "loading", + ], }, - tagNames: [ - ...(defaultSchema.tagNames || []), - // iframe for video embeds - "iframe", - // MathML elements for KaTeX math rendering - "math", - "annotation", - "semantics", - "mi", - "mn", - "mo", - "mrow", - "mspace", - "mstyle", - "msup", - "msub", - "msubsup", - "mfrac", - "mtext", - ], + tagNames: [...(defaultSchema.tagNames || []), "iframe"], protocols: { ...defaultSchema.protocols, - // Allow HTTPS iframe embeds only for security - iframe: { src: ["https"] }, + src: ["https"], }, }; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 484911e71..232d434bd 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -14,6 +14,7 @@ import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id"; import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext"; import { extractMentionUsernames, remarkMention } from "@/utils/remark-plugins/remark-mention"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; +import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists"; import { remarkTag } from "@/utils/remark-plugins/remark-tag"; import { CodeBlock } from "./CodeBlock"; import { isMentionNode, isTagNode, isTaskListItemNode } from "./ConditionalComponent"; @@ -79,7 +80,16 @@ const MemoContent = (props: MemoContentProps) => { onDoubleClick={onDoubleClick} > void; } interface PropertyBadge { @@ -39,7 +40,10 @@ const PROPERTY_BADGE_CLASSES = const TAG_BADGE_CLASSES = "inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer"; -const MemoDetailSidebar = ({ memo, className }: Props) => { +const SHARE_ACTION_ROW_CLASSES = + "h-auto min-h-0 w-full justify-between rounded-none px-2 py-1.5 text-xs font-normal leading-tight text-muted-foreground transition-colors hover:bg-muted/40 hover:text-muted-foreground focus-visible:ring-offset-0 gap-1.5"; + +const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => { const t = useTranslate(); const currentUser = useCurrentUser(); const [sharePanelOpen, setSharePanelOpen] = useState(false); @@ -64,12 +68,29 @@ const MemoDetailSidebar = ({ memo, className }: Props) => { )} - {canManageShares && ( + {(canManageShares || onShareImageOpen) && ( - +
+ {onShareImageOpen && ( + + )} + {onShareImageOpen && canManageShares &&
} + {canManageShares && ( + + )} +
)} diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx index 9c058a9d3..9a36774fd 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx @@ -8,9 +8,10 @@ import MemoDetailSidebar from "./MemoDetailSidebar"; interface Props { memo: Memo; + onShareImageOpen?: () => void; } -const MemoDetailSidebarDrawer = ({ memo }: Props) => { +const MemoDetailSidebarDrawer = ({ memo, onShareImageOpen }: Props) => { const location = useLocation(); const [open, setOpen] = useState(false); @@ -26,7 +27,7 @@ const MemoDetailSidebarDrawer = ({ memo }: Props) => { - + ); diff --git a/web/src/components/MemoDisplaySettingMenu.tsx b/web/src/components/MemoDisplaySettingMenu.tsx index c1ec24632..7677f3ae9 100644 --- a/web/src/components/MemoDisplaySettingMenu.tsx +++ b/web/src/components/MemoDisplaySettingMenu.tsx @@ -11,8 +11,8 @@ interface Props { function MemoDisplaySettingMenu({ className }: Props) { const t = useTranslate(); - const { orderByTimeAsc, toggleSortOrder } = useView(); - const isApplying = orderByTimeAsc !== false; + const { orderByTimeAsc, timeBasis, setTimeBasis, toggleSortOrder } = useView(); + const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time"; return ( @@ -22,7 +22,19 @@ function MemoDisplaySettingMenu({ className }: Props) {
- {t("memo.direction")} + {t("memo.shown-time")} + +
+
+ {t("memo.order")}
diff --git a/web/src/components/MemoEditor/Editor/useSuggestions.ts b/web/src/components/MemoEditor/Editor/useSuggestions.ts index aff69a149..2548fa435 100644 --- a/web/src/components/MemoEditor/Editor/useSuggestions.ts +++ b/web/src/components/MemoEditor/Editor/useSuggestions.ts @@ -22,6 +22,7 @@ export interface UseSuggestionsReturn { suggestions: T[]; selectedIndex: number; isVisible: boolean; + searchQuery: string; handleItemSelect: (item: T) => void; } @@ -52,12 +53,17 @@ export function useSuggestions({ const hide = () => setPosition(null); const suggestionsRef = useRef([]); + const searchQueryRef = useRef(""); suggestionsRef.current = (() => { const [word] = getCurrentWord(); if (!word.startsWith(triggerChar)) return []; - const searchQuery = word.slice(triggerChar.length).toLowerCase(); - return filterItems(items, searchQuery); + searchQueryRef.current = word.slice(triggerChar.length).toLowerCase(); + return filterItems(items, searchQueryRef.current); })(); + if (suggestionsRef.current.length === 0) { + const [word] = getCurrentWord(); + searchQueryRef.current = word.startsWith(triggerChar) ? word.slice(triggerChar.length).toLowerCase() : ""; + } const isVisibleRef = useRef(false); isVisibleRef.current = !!(position && suggestionsRef.current.length > 0); @@ -153,6 +159,7 @@ export function useSuggestions({ suggestions: suggestionsRef.current, selectedIndex, isVisible: isVisibleRef.current, + searchQuery: searchQueryRef.current, handleItemSelect: handleAutocomplete, }; } diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index cca9578ea..baac1084d 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -2,6 +2,7 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; import { FileIcon, + ImageIcon, LinkIcon, LoaderIcon, type LucideIcon, @@ -16,11 +17,11 @@ import { useDebounce } from "react-use"; import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata"; import { useReverseGeocoding } from "@/components/map"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -131,41 +132,49 @@ const InsertMenu = (props: InsertMenuProps) => { setMoreSubmenuOpen(false); }, [onToggleFocusMode]); + const handleMediaUploadClick = useCallback(() => { + handleUploadClick("image/*,video/*"); + }, [handleUploadClick]); + + const handleFileUploadClick = useCallback(() => { + handleUploadClick(); + }, [handleUploadClick]); + const menuItems = useMemo( () => [ { - key: "upload", - label: t("common.upload"), + key: "upload-media", + label: t("attachment-library.tabs.media"), + icon: ImageIcon, + onClick: handleMediaUploadClick, + }, + { + key: "record-audio", + label: t("editor.audio-recorder.trigger"), + icon: MicIcon, + onClick: () => props.onAudioRecorderClick?.(), + }, + { + key: "upload-file", + label: t("common.file"), icon: FileIcon, - onClick: handleUploadClick, + onClick: handleFileUploadClick, }, { key: "link", - label: t("tooltip.link-memo"), + label: t("editor.insert-menu.link-memo"), icon: LinkIcon, onClick: handleOpenLinkDialog, }, { key: "location", - label: t("tooltip.select-location"), + label: t("editor.insert-menu.add-location"), icon: MapPinIcon, onClick: handleLocationClick, }, - { - key: "voice-note", - label: t("editor.voice-recorder.trigger"), - icon: MicIcon, - onClick: () => props.onVoiceRecorderClick?.(), - }, - ] satisfies Array<{ - key: string; - label: string; - icon: LucideIcon; - onClick: () => void; - notImplemented?: boolean; - }>, - [handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t], + ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, + [handleFileUploadClick, handleLocationClick, handleMediaUploadClick, handleOpenLinkDialog, props, t], ); return ( @@ -177,23 +186,20 @@ const InsertMenu = (props: InsertMenuProps) => { - {menuItems.map((item) => ( - - - {item.label} - {item.notImplemented ? ( - - {t("editor.attachments-not-implemented")} - - ) : null} + {menuItems.slice(0, 3).map((item) => ( + + + {item.label} + + ))} + + {menuItems.slice(3).map((item) => ( + + + {item.label} ))} + {/* View submenu with Focus Mode */} diff --git a/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx b/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx new file mode 100644 index 000000000..f153f670e --- /dev/null +++ b/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx @@ -0,0 +1,100 @@ +import { AudioWaveformIcon, LoaderCircleIcon, SquareIcon, XIcon } from "lucide-react"; +import type { FC } from "react"; +import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; +import { useAudioWaveform } from "../hooks/useAudioWaveform"; +import type { AudioRecorderPanelProps } from "../types/components"; +import { VoiceWaveform } from "./VoiceWaveform"; + +export const AudioRecorderPanel: FC = ({ + audioRecorder, + mediaStream, + onStop, + onCancel, + onTranscribe, + canTranscribe = false, + isTranscribing = false, +}) => { + const t = useTranslate(); + const { status, elapsedSeconds } = audioRecorder; + + const isRequestingPermission = status === "requesting_permission"; + const isRecording = status === "recording"; + const isTranscribeDisabled = isRequestingPermission || isTranscribing; + const waveformLevels = useAudioWaveform(mediaStream, isRecording && mediaStream !== null); + const srStatusText = isTranscribing + ? t("editor.audio-recorder.transcribing") + : isRequestingPermission + ? t("editor.audio-recorder.requesting-permission") + : t("editor.audio-recorder.recording"); + + return ( +
+
+ {isRequestingPermission || isTranscribing ? ( + + ) : null} + {srStatusText} + + + {isTranscribing ? t("editor.audio-recorder.transcribing") : formatAudioTime(elapsedSeconds)} + +
+ +
+ + {canTranscribe && ( + + + + + + + +

{t("editor.audio-recorder.transcribe")}

+
+
+ )} + +
+
+ ); +}; diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 5cc14f784..dab0f32d7 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -13,6 +13,7 @@ export const EditorContent = forwardRef(({ const localFiles: LocalFile[] = Array.from(files).map((file) => ({ file, previewUrl: createBlobUrl(file), + origin: "upload", })); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); }); @@ -49,6 +50,7 @@ export const EditorContent = forwardRef(({ const localFiles: LocalFile[] = files.map((file) => ({ file, previewUrl: createBlobUrl(file), + origin: "upload", })); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); event.preventDefault(); diff --git a/web/src/components/MemoEditor/components/EditorMetadata.tsx b/web/src/components/MemoEditor/components/EditorMetadata.tsx index df01ddf96..ee984e13d 100644 --- a/web/src/components/MemoEditor/components/EditorMetadata.tsx +++ b/web/src/components/MemoEditor/components/EditorMetadata.tsx @@ -12,6 +12,7 @@ export const EditorMetadata: FC = ({ memoName }) => { attachments={state.metadata.attachments} localFiles={state.localFiles} onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))} + onLocalFilesChange={(localFiles) => dispatch(actions.setLocalFiles(localFiles))} onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} /> diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index 6f9ad1b19..5205e0b5b 100644 --- a/web/src/components/MemoEditor/components/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; import VisibilitySelector from "../Toolbar/VisibilitySelector"; import type { EditorToolbarProps } from "../types"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onVoiceRecorderClick }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onAudioRecorderClick }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -35,7 +35,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} memoName={memoName} - onVoiceRecorderClick={onVoiceRecorderClick} + onAudioRecorderClick={onAudioRecorderClick} />
diff --git a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx b/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx deleted file mode 100644 index 121ddbe65..000000000 --- a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { AudioLinesIcon, LoaderCircleIcon, MicIcon, RotateCcwIcon, SquareIcon, Trash2Icon } from "lucide-react"; -import type { FC } from "react"; -import { AudioAttachmentItem } from "@/components/MemoMetadata/Attachment"; -import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { useTranslate } from "@/utils/i18n"; -import type { VoiceRecorderPanelProps } from "../types/components"; - -export const VoiceRecorderPanel: FC = ({ - voiceRecorder, - onStart, - onStop, - onKeep, - onDiscard, - onRecordAgain, - onClose, -}) => { - const t = useTranslate(); - const { status, elapsedSeconds, error, recording } = voiceRecorder; - - const isRecording = status === "recording"; - const isRequestingPermission = status === "requesting_permission"; - const isUnsupported = status === "unsupported"; - const hasRecording = status === "recorded" && recording; - - return ( -
-
-
-
- {isRequestingPermission ? ( - - ) : hasRecording ? ( - - ) : ( - - )} -
- -
-
- {isRecording - ? t("editor.voice-recorder.recording") - : isRequestingPermission - ? t("editor.voice-recorder.requesting-permission") - : hasRecording - ? t("editor.voice-recorder.ready") - : isUnsupported - ? t("editor.voice-recorder.unsupported") - : error - ? t("editor.voice-recorder.error") - : t("editor.voice-recorder.title")} -
- -
- {isRecording - ? t("editor.voice-recorder.recording-description", { duration: formatAudioTime(elapsedSeconds) }) - : isRequestingPermission - ? t("editor.voice-recorder.requesting-permission-description") - : hasRecording - ? t("editor.voice-recorder.ready-description") - : isUnsupported - ? t("editor.voice-recorder.unsupported-description") - : error - ? error - : t("editor.voice-recorder.idle-description")} -
-
-
- - {isRecording && ( -
- - {formatAudioTime(elapsedSeconds)} -
- )} -
- - {hasRecording && ( -
- -
- )} - -
- {hasRecording ? ( - <> - - - - - ) : isRecording ? ( - - ) : ( - <> - - {!isUnsupported && ( - - )} - - )} -
-
- ); -}; diff --git a/web/src/components/MemoEditor/components/VoiceWaveform.tsx b/web/src/components/MemoEditor/components/VoiceWaveform.tsx new file mode 100644 index 000000000..988c84f1f --- /dev/null +++ b/web/src/components/MemoEditor/components/VoiceWaveform.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; +import { cn } from "@/lib/utils"; + +/** Max half-height of each bar (px); bars are centered vertically. */ +const MAX_BAR_PX = 11; +const MIN_BAR_PX = 2; + +type VoiceWaveformProps = { + levels: number[]; + className?: string; +}; + +/** + * Tight-packed vertical bars (rounded caps): fixed bar width + minimal gap — no `flex-1` columns + * so bars stay visually dense like compact voice-memo waveforms. + */ +export const VoiceWaveform: FC = ({ levels, className }) => { + return ( +
+ {levels.map((level, i) => { + const h = Math.max(MIN_BAR_PX, level * MAX_BAR_PX); + const centerDistance = Math.abs(i - (levels.length - 1) / 2) / (levels.length / 2); + const opacity = 0.35 + (1 - centerDistance) * 0.35; + return ( + + ); + })} +
+ ); +}; diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts index eed02ff12..73faf0f16 100644 --- a/web/src/components/MemoEditor/components/index.ts +++ b/web/src/components/MemoEditor/components/index.ts @@ -1,8 +1,8 @@ // UI components for MemoEditor +export * from "./AudioRecorderPanel"; export * from "./EditorContent"; export * from "./EditorMetadata"; export * from "./EditorToolbar"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { TimestampPopover } from "./TimestampPopover"; -export * from "./VoiceRecorderPanel"; diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts index a1ca003e8..5854dfd1d 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -1,4 +1,6 @@ // Custom hooks for MemoEditor (internal use only) +export { useAudioRecorder } from "./useAudioRecorder"; +export { useAudioWaveform } from "./useAudioWaveform"; export { useAutoSave } from "./useAutoSave"; export { useBlobUrls } from "./useBlobUrls"; export { useDragAndDrop } from "./useDragAndDrop"; @@ -8,4 +10,3 @@ export { useKeyboard } from "./useKeyboard"; export { useLinkMemo } from "./useLinkMemo"; export { useLocation } from "./useLocation"; export { useMemoInit } from "./useMemoInit"; -export { useVoiceRecorder } from "./useVoiceRecorder"; diff --git a/web/src/components/MemoEditor/hooks/useAudioRecorder.ts b/web/src/components/MemoEditor/hooks/useAudioRecorder.ts index 7df3b79c5..63722e59e 100644 --- a/web/src/components/MemoEditor/hooks/useAudioRecorder.ts +++ b/web/src/components/MemoEditor/hooks/useAudioRecorder.ts @@ -3,6 +3,7 @@ import type { LocalFile } from "../types/attachment"; import { useBlobUrls } from "./useBlobUrls"; const FALLBACK_AUDIO_MIME_TYPE = "audio/webm"; +export type AudioRecordingCompleteMode = "attach" | "transcribe"; interface AudioRecorderActions { setAudioRecorderSupport: (value: boolean) => void; @@ -10,7 +11,8 @@ interface AudioRecorderActions { setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => void; setAudioRecorderElapsed: (value: number) => void; setAudioRecorderError: (value?: string) => void; - onRecordingComplete: (localFile: LocalFile) => void; + onRecordingComplete: (localFile: LocalFile, mode: AudioRecordingCompleteMode) => void; + onRecordingEmpty?: (mode: AudioRecordingCompleteMode) => void; } const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const; @@ -55,6 +57,7 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { const startedAtRef = useRef(null); const elapsedTimerRef = useRef(null); const recorderMimeTypeRef = useRef(FALLBACK_AUDIO_MIME_TYPE); + const completionModeRef = useRef("attach"); const startRequestIdRef = useRef(0); const { createBlobUrl } = useBlobUrls(); @@ -153,10 +156,13 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0; const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current }); + const completionMode = completionModeRef.current; + completionModeRef.current = "attach"; if (blob.size === 0) { actions.setAudioRecorderElapsed(0); actions.setAudioRecorderError(undefined); actions.setAudioRecorderStatus("idle"); + actions.onRecordingEmpty?.(completionMode); resetRecorderRefs(); return; } @@ -164,14 +170,17 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { const file = createRecordedFile(blob, recorderMimeTypeRef.current); const previewUrl = createBlobUrl(file); - actions.onRecordingComplete({ - file, - previewUrl, - origin: "audio_recording", - audioMeta: { - durationSeconds, + actions.onRecordingComplete( + { + file, + previewUrl, + origin: "audio_recording", + audioMeta: { + durationSeconds, + }, }, - }); + completionMode, + ); actions.setAudioRecorderElapsed(0); actions.setAudioRecorderError(undefined); actions.setAudioRecorderStatus("idle"); @@ -203,17 +212,20 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { } }; - const stopRecording = () => { + const stopRecording = (mode: AudioRecordingCompleteMode = "attach") => { if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") { - return; + return false; } + completionModeRef.current = mode; cleanupTimer(); mediaRecorderRef.current.stop(); + return true; }; const resetRecording = () => { startRequestIdRef.current += 1; + completionModeRef.current = "attach"; resetRecorderRefs(); actions.setAudioRecorderElapsed(0); actions.setAudioRecorderError(undefined); diff --git a/web/src/components/MemoEditor/hooks/useAutoSave.ts b/web/src/components/MemoEditor/hooks/useAutoSave.ts index 556938f1a..18eb903e7 100644 --- a/web/src/components/MemoEditor/hooks/useAutoSave.ts +++ b/web/src/components/MemoEditor/hooks/useAutoSave.ts @@ -1,9 +1,58 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { cacheService } from "../services"; -export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => { +export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => { + const latestContentRef = useRef(content); + const discardedContentRef = useRef(undefined); + + useEffect(() => { + latestContentRef.current = content; + if (discardedContentRef.current !== undefined && discardedContentRef.current !== content) { + discardedContentRef.current = undefined; + } + }, [content]); + useEffect(() => { + if (!enabled) return; + const key = cacheService.key(username, cacheKey); cacheService.save(key, content); - }, [content, username, cacheKey]); + }, [content, username, cacheKey, enabled]); + + useEffect(() => { + if (!enabled) return; + + const key = cacheService.key(username, cacheKey); + const flushDraft = () => { + if (discardedContentRef.current === latestContentRef.current) { + return; + } + + cacheService.saveNow(key, latestContentRef.current); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + flushDraft(); + } + }; + + window.addEventListener("pagehide", flushDraft); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + // Flush on unmount (e.g. editor closes) to ensure the draft is persisted + // before the component is torn down — distinct from the visibility flush above. + flushDraft(); + window.removeEventListener("pagehide", flushDraft); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [username, cacheKey, enabled]); + + const discardDraft = useCallback(() => { + const key = cacheService.key(username, cacheKey); + discardedContentRef.current = latestContentRef.current; + cacheService.clear(key); + }, [username, cacheKey]); + + return { discardDraft }; }; diff --git a/web/src/components/MemoEditor/hooks/useFileUpload.ts b/web/src/components/MemoEditor/hooks/useFileUpload.ts index 66bda31c9..ae69db2b3 100644 --- a/web/src/components/MemoEditor/hooks/useFileUpload.ts +++ b/web/src/components/MemoEditor/hooks/useFileUpload.ts @@ -1,4 +1,6 @@ +import { create } from "@bufbuild/protobuf"; import { useRef } from "react"; +import { type MotionMedia, MotionMediaFamily, MotionMediaRole, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { LocalFile } from "../types/attachment"; export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => { @@ -11,18 +13,26 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void return; } selectingFlagRef.current = true; - const localFiles: LocalFile[] = files.map((file) => ({ - file, - previewUrl: URL.createObjectURL(file), - })); + const localFiles: LocalFile[] = pairAppleLivePhotoFiles( + files.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + origin: "upload", + })), + ); onFilesSelected(localFiles); selectingFlagRef.current = false; // Optionally clear input value to allow re-selecting the same file if (fileInputRef.current) fileInputRef.current.value = ""; }; - const handleUploadClick = () => { - fileInputRef.current?.click(); + const handleUploadClick = (accept = "*") => { + if (!fileInputRef.current) { + return; + } + + fileInputRef.current.accept = accept; + fileInputRef.current.click(); }; return { @@ -32,3 +42,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void handleUploadClick, }; }; + +const pairAppleLivePhotoFiles = (localFiles: LocalFile[]): LocalFile[] => { + const stemMap = new Map(); + for (const localFile of localFiles) { + const stem = normalizeFilenameStem(localFile.file.name); + const group = stemMap.get(stem) ?? []; + group.push(localFile); + stemMap.set(stem, group); + } + + const groupIds = new Map(); + return localFiles.map((localFile) => { + const stem = normalizeFilenameStem(localFile.file.name); + const group = stemMap.get(stem) ?? []; + const images = group.filter((item) => item.file.type.startsWith("image/")); + const videos = group.filter((item) => item.file.type.startsWith("video/")); + if (images.length !== 1 || videos.length !== 1) { + return localFile; + } + + const image = images[0]; + const video = videos[0]; + const groupId = groupIds.get(stem) ?? `${stem}-${crypto.randomUUID()}`; + groupIds.set(stem, groupId); + if (localFile.previewUrl === image.previewUrl) { + return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.STILL) }; + } + if (localFile.previewUrl === video.previewUrl) { + return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.VIDEO) }; + } + return localFile; + }); +}; + +const buildLocalMotionMedia = (groupId: string, role: MotionMediaRole): MotionMedia => + create(MotionMediaSchema, { + family: MotionMediaFamily.APPLE_LIVE_PHOTO, + role, + groupId, + presentationTimestampUs: 0n, + hasEmbeddedVideo: false, + }); + +const normalizeFilenameStem = (filename: string): string => { + const parts = filename.split("."); + if (parts.length <= 1) { + return filename.toLowerCase(); + } + return parts.slice(0, -1).join(".").toLowerCase(); +}; diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index 704a64672..853ce6fc4 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb"; import type { EditorRefActions } from "../Editor"; import { cacheService, memoService } from "../services"; @@ -16,15 +16,19 @@ interface UseMemoInitOptions { export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, defaultVisibility }: UseMemoInitOptions) => { const { actions, dispatch } = useEditorContext(); const initializedRef = useRef(false); + const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { if (initializedRef.current) return; initializedRef.current = true; + const key = cacheService.key(username, cacheKey); if (memo) { - dispatch(actions.initMemo(memoService.fromMemo(memo))); + const initialState = memoService.fromMemo(memo); + cacheService.clear(key); + dispatch(actions.initMemo(initialState)); } else { - const cachedContent = cacheService.load(cacheService.key(username, cacheKey)); + const cachedContent = cacheService.load(key); if (cachedContent) { dispatch(actions.updateContent(cachedContent)); } @@ -36,5 +40,9 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de if (autoFocus) { setTimeout(() => editorRef.current?.focus(), 100); } + + setIsInitialized(true); }, [memo, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef]); + + return { isInitialized }; }; diff --git a/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts b/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts deleted file mode 100644 index eb21d4a67..000000000 --- a/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { LocalFile } from "../types/attachment"; -import { useBlobUrls } from "./useBlobUrls"; - -const FALLBACK_AUDIO_MIME_TYPE = "audio/webm"; - -interface VoiceRecorderActions { - setVoiceRecorderSupport: (value: boolean) => void; - setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => void; - setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => void; - setVoiceRecorderElapsed: (value: number) => void; - setVoiceRecorderError: (value?: string) => void; - setVoiceRecording: (value?: { localFile: LocalFile; durationSeconds: number; mimeType: string }) => void; -} - -const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const; - -function getSupportedAudioMimeType(): string | undefined { - if (typeof window === "undefined" || typeof MediaRecorder === "undefined") { - return undefined; - } - - for (const candidate of AUDIO_MIME_TYPE_CANDIDATES) { - if (MediaRecorder.isTypeSupported(candidate)) { - return candidate; - } - } - - return undefined; -} - -function getFileExtension(mimeType: string): string { - if (mimeType.includes("ogg")) return "ogg"; - if (mimeType.includes("mp4")) return "m4a"; - return "webm"; -} - -function createRecordedFile(blob: Blob, mimeType: string): File { - const extension = getFileExtension(mimeType); - const now = new Date(); - const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join(""); - const timePart = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0")].join(""); - return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType }); -} - -export const useVoiceRecorder = (actions: VoiceRecorderActions) => { - const mediaRecorderRef = useRef(null); - const mediaStreamRef = useRef(null); - const chunksRef = useRef([]); - const startedAtRef = useRef(null); - const elapsedTimerRef = useRef(null); - const recorderMimeTypeRef = useRef(FALLBACK_AUDIO_MIME_TYPE); - const { createBlobUrl } = useBlobUrls(); - - const cleanupTimer = () => { - if (elapsedTimerRef.current !== null) { - window.clearInterval(elapsedTimerRef.current); - elapsedTimerRef.current = null; - } - }; - - const cleanupStream = () => { - mediaStreamRef.current?.getTracks().forEach((track) => track.stop()); - mediaStreamRef.current = null; - }; - - const resetRecorderRefs = () => { - cleanupTimer(); - cleanupStream(); - mediaRecorderRef.current = null; - chunksRef.current = []; - startedAtRef.current = null; - }; - - useEffect(() => { - const isSupported = - typeof window !== "undefined" && - typeof navigator !== "undefined" && - typeof navigator.mediaDevices?.getUserMedia === "function" && - typeof MediaRecorder !== "undefined"; - - actions.setVoiceRecorderSupport(isSupported); - if (!isSupported) { - actions.setVoiceRecorderStatus("unsupported"); - actions.setVoiceRecorderError("Voice recording is not supported in this browser."); - return; - } - - actions.setVoiceRecorderStatus("idle"); - actions.setVoiceRecorderError(undefined); - - return () => { - resetRecorderRefs(); - }; - }, [actions]); - - const startRecording = async () => { - if ( - typeof navigator === "undefined" || - typeof navigator.mediaDevices?.getUserMedia !== "function" || - typeof MediaRecorder === "undefined" - ) { - actions.setVoiceRecorderSupport(false); - actions.setVoiceRecorderStatus("unsupported"); - actions.setVoiceRecorderError("Voice recording is not supported in this browser."); - return; - } - - actions.setVoiceRecorderError(undefined); - actions.setVoiceRecorderStatus("requesting_permission"); - actions.setVoiceRecorderElapsed(0); - actions.setVoiceRecording(undefined); - - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE; - const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined); - - recorderMimeTypeRef.current = mimeType; - mediaStreamRef.current = stream; - mediaRecorderRef.current = mediaRecorder; - chunksRef.current = []; - - mediaRecorder.addEventListener("dataavailable", (event) => { - if (event.data.size > 0) { - chunksRef.current.push(event.data); - } - }); - - mediaRecorder.addEventListener("stop", () => { - const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0; - const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current }); - const file = createRecordedFile(blob, recorderMimeTypeRef.current); - const previewUrl = createBlobUrl(file); - - actions.setVoiceRecording({ - localFile: { - file, - previewUrl, - }, - durationSeconds, - mimeType: recorderMimeTypeRef.current, - }); - actions.setVoiceRecorderElapsed(durationSeconds); - actions.setVoiceRecorderStatus("recorded"); - resetRecorderRefs(); - }); - - mediaRecorder.start(); - startedAtRef.current = Date.now(); - actions.setVoiceRecorderPermission("granted"); - actions.setVoiceRecorderStatus("recording"); - - elapsedTimerRef.current = window.setInterval(() => { - if (startedAtRef.current) { - actions.setVoiceRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000))); - } - }, 250); - } catch (error) { - const permissionDenied = - error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError"); - - actions.setVoiceRecorderPermission(permissionDenied ? "denied" : "unknown"); - actions.setVoiceRecorderStatus("error"); - actions.setVoiceRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start voice recording."); - resetRecorderRefs(); - } - }; - - const stopRecording = () => { - if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") { - return; - } - - cleanupTimer(); - mediaRecorderRef.current.stop(); - }; - - const resetRecording = () => { - resetRecorderRefs(); - actions.setVoiceRecorderElapsed(0); - actions.setVoiceRecorderError(undefined); - actions.setVoiceRecording(undefined); - actions.setVoiceRecorderStatus("idle"); - }; - - return { - startRecording, - stopRecording, - resetRecording, - }; -}; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 38798c312..248e85a42 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,29 +1,37 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { useAuth } from "@/contexts/AuthContext"; +import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; +import { InstanceSetting_AIProviderType, InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb"; import { useTranslate } from "@/utils/i18n"; import { convertVisibilityFromString } from "@/utils/memo"; import { + AudioRecorderPanel, EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover, - VoiceRecorderPanel, } from "./components"; import { FOCUS_MODE_STYLES } from "./constants"; import type { EditorRefActions } from "./Editor"; -import { useAutoSave, useFocusMode, useKeyboard, useMemoInit, useVoiceRecorder } from "./hooks"; -import { cacheService, errorService, memoService, validationService } from "./services"; +import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks"; +import { errorService, memoService, transcriptionService, validationService } from "./services"; import { EditorProvider, useEditorContext } from "./state"; import type { MemoEditorProps } from "./types"; +import type { LocalFile } from "./types/attachment"; + +const TRANSCRIPTION_PROVIDER_TYPES: InstanceSetting_AIProviderType[] = [ + InstanceSetting_AIProviderType.OPENAI, + InstanceSetting_AIProviderType.GEMINI, +]; const MemoEditor = (props: MemoEditorProps) => ( @@ -47,87 +55,158 @@ const MemoEditorImpl: React.FC = ({ const editorRef = useRef(null); const { state, actions, dispatch } = useEditorContext(); const { userGeneralSetting } = useAuth(); - const [isVoiceRecorderOpen, setIsVoiceRecorderOpen] = useState(false); + const { aiSetting, fetchSetting } = useInstance(); + const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false); + const [isTranscribingAudio, setIsTranscribingAudio] = useState(false); const memoName = memo?.name; + const transcriptionProvider = useMemo( + () => aiSetting.providers.find((provider) => provider.apiKeySet && TRANSCRIPTION_PROVIDER_TYPES.includes(provider.type)), + [aiSetting.providers], + ); // Get default visibility from user settings const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined; - useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + const isDraftCacheEnabled = !memo; // Auto-save content to localStorage - useAutoSave(state.content, currentUser?.name ?? "", cacheKey); + const { discardDraft } = useAutoSave(state.content, currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled); // Focus mode management with body scroll lock useFocusMode(state.ui.isFocusMode); - const voiceRecorderActions = useMemo( + useEffect(() => { + if (!currentUser) { + return; + } + + void fetchSetting(InstanceSetting_Key.AI).catch(() => undefined); + }, [currentUser, fetchSetting]); + + const insertTranscribedText = useCallback((text: string) => { + const editor = editorRef.current; + if (!editor) { + return; + } + + const content = editor.getContent(); + const cursor = editor.getCursorPosition(); + const beforeCursor = content.slice(0, cursor); + const afterCursor = content.slice(cursor); + const prefix = beforeCursor.length === 0 || beforeCursor.endsWith("\n\n") ? "" : beforeCursor.endsWith("\n") ? "\n" : "\n\n"; + const suffix = afterCursor.length === 0 || afterCursor.startsWith("\n\n") ? "" : afterCursor.startsWith("\n") ? "\n" : "\n\n"; + + editor.insertText(text, prefix, suffix); + editor.scrollToCursor(); + }, []); + + const handleTranscribeRecordedAudio = useCallback( + async (localFile: LocalFile) => { + if (!transcriptionProvider) { + dispatch(actions.addLocalFile(localFile)); + setIsTranscribingAudio(false); + setIsAudioRecorderOpen(false); + return; + } + + try { + const text = (await transcriptionService.transcribeFile(localFile.file, transcriptionProvider)).trim(); + if (!text) { + dispatch(actions.addLocalFile(localFile)); + toast.error(t("editor.audio-recorder.transcribe-empty")); + return; + } + + insertTranscribedText(text); + toast.success(t("editor.audio-recorder.transcribe-success")); + } catch (error) { + console.error(error); + toast.error(errorService.getErrorMessage(error) || t("editor.audio-recorder.transcribe-error")); + dispatch(actions.addLocalFile(localFile)); + } finally { + setIsTranscribingAudio(false); + setIsAudioRecorderOpen(false); + } + }, + [actions, dispatch, insertTranscribedText, t, transcriptionProvider], + ); + + const audioRecorderActions = useMemo( () => ({ - setVoiceRecorderSupport: (value: boolean) => dispatch(actions.setVoiceRecorderSupport(value)), - setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setVoiceRecorderPermission(value)), - setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => - dispatch(actions.setVoiceRecorderStatus(value)), - setVoiceRecorderElapsed: (value: number) => dispatch(actions.setVoiceRecorderElapsed(value)), - setVoiceRecorderError: (value?: string) => dispatch(actions.setVoiceRecorderError(value)), - setVoiceRecording: (value?: typeof state.voiceRecorder.recording) => dispatch(actions.setVoiceRecording(value)), + setAudioRecorderSupport: (value: boolean) => dispatch(actions.setAudioRecorderSupport(value)), + setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setAudioRecorderPermission(value)), + setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => + dispatch(actions.setAudioRecorderStatus(value)), + setAudioRecorderElapsed: (value: number) => dispatch(actions.setAudioRecorderElapsed(value)), + setAudioRecorderError: (value?: string) => dispatch(actions.setAudioRecorderError(value)), + onRecordingComplete: (localFile: LocalFile, mode: "attach" | "transcribe") => { + if (mode === "transcribe") { + void handleTranscribeRecordedAudio(localFile); + return; + } + + dispatch(actions.addLocalFile(localFile)); + setIsAudioRecorderOpen(false); + }, + onRecordingEmpty: (mode: "attach" | "transcribe") => { + if (mode === "transcribe") { + setIsTranscribingAudio(false); + toast.error(t("editor.audio-recorder.transcribe-empty")); + } + setIsAudioRecorderOpen(false); + }, }), - [actions, dispatch], + [actions, dispatch, handleTranscribeRecordedAudio, t], ); - const voiceRecorder = useVoiceRecorder(voiceRecorderActions); + const audioRecorder = useAudioRecorder(audioRecorderActions); + + useEffect(() => { + if (!isAudioRecorderOpen) { + return; + } + + if (state.audioRecorder.status === "error" || state.audioRecorder.status === "unsupported") { + toast.error(state.audioRecorder.error || t("editor.audio-recorder.error-description")); + setIsAudioRecorderOpen(false); + } + }, [isAudioRecorderOpen, state.audioRecorder.error, state.audioRecorder.status, t]); const handleToggleFocusMode = () => { dispatch(actions.toggleFocusMode()); }; - const handleStartVoiceRecording = async () => { - setIsVoiceRecorderOpen(true); - await voiceRecorder.startRecording(); + const handleStartAudioRecording = async () => { + setIsAudioRecorderOpen(true); + await audioRecorder.startRecording(); }; - const handleVoiceRecorderClick = () => { - setIsVoiceRecorderOpen(true); - - if ( - state.voiceRecorder.status === "recording" || - state.voiceRecorder.status === "requesting_permission" || - state.voiceRecorder.status === "recorded" - ) { + const handleAudioRecorderClick = () => { + if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") { return; } - void handleStartVoiceRecording(); + void handleStartAudioRecording(); }; - const handleKeepVoiceRecording = () => { - const recording = state.voiceRecorder.recording; - if (!recording) { - return; - } - - dispatch(actions.addLocalFile(recording.localFile)); - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); - }; - - const handleDiscardVoiceRecording = () => { - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); + const handleCancelAudioRecording = () => { + setIsTranscribingAudio(false); + audioRecorder.resetRecording(); + setIsAudioRecorderOpen(false); }; - const handleCloseVoiceRecorder = () => { - if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { + const handleTranscribeAudioRecording = () => { + if (!transcriptionProvider || isTranscribingAudio) { return; } - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); - }; - - const handleRecordAgain = async () => { - voiceRecorder.resetRecording(); - await handleStartVoiceRecording(); + setIsTranscribingAudio(true); + const didStop = audioRecorder.stopRecording("transcribe"); + if (!didStop) { + setIsTranscribingAudio(false); + } }; useKeyboard(editorRef, handleSave); @@ -151,8 +230,9 @@ const MemoEditorImpl: React.FC = ({ return; } - // Clear localStorage cache on successful save - cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey)); + // Clear localStorage cache on successful save and prevent the unmount + // flush from writing the just-saved content back as a stale draft. + discardDraft(); // Invalidate React Query cache to refresh memo lists across the app const invalidationPromises = [ @@ -220,22 +300,23 @@ const MemoEditorImpl: React.FC = ({ {/* Editor content grows to fill available space in focus mode */} - {isVoiceRecorderOpen && ( - void handleStartVoiceRecording()} - onStop={voiceRecorder.stopRecording} - onKeep={handleKeepVoiceRecording} - onDiscard={handleDiscardVoiceRecording} - onRecordAgain={() => void handleRecordAgain()} - onClose={handleCloseVoiceRecorder} - /> - )} + {isAudioRecorderOpen && + (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission" || isTranscribingAudio) && ( + + )} {/* Metadata and toolbar grouped together at bottom */}
- +
diff --git a/web/src/components/MemoEditor/services/cacheService.ts b/web/src/components/MemoEditor/services/cacheService.ts index 5c93e3b4f..07826f24e 100644 --- a/web/src/components/MemoEditor/services/cacheService.ts +++ b/web/src/components/MemoEditor/services/cacheService.ts @@ -1,6 +1,33 @@ export const CACHE_DEBOUNCE_DELAY = 500; const pendingSaves = new Map>(); +const STRUCTURED_CACHE_ENTRY_KIND = "memos.editor-cache"; +const STRUCTURED_CACHE_ENTRY_VERSION = 1; + +function deserializeContent(raw: string): string { + try { + const parsed = JSON.parse(raw) as { kind?: unknown; version?: unknown; content?: unknown }; + if ( + parsed.kind === STRUCTURED_CACHE_ENTRY_KIND && + parsed.version === STRUCTURED_CACHE_ENTRY_VERSION && + typeof parsed.content === "string" + ) { + return parsed.content; + } + } catch { + // Drafts have historically been stored as raw markdown strings. + } + + return raw; +} + +function writeEntry(key: string, content: string): void { + if (content.trim()) { + localStorage.setItem(key, content); + } else { + localStorage.removeItem(key); + } +} export const cacheService = { key: (username: string, cacheKey?: string): string => { @@ -16,18 +43,25 @@ export const cacheService = { const timeoutId = window.setTimeout(() => { pendingSaves.delete(key); - if (content.trim()) { - localStorage.setItem(key, content); - } else { - localStorage.removeItem(key); - } + writeEntry(key, content); }, CACHE_DEBOUNCE_DELAY); pendingSaves.set(key, timeoutId); }, + saveNow: (key: string, content: string) => { + const pendingSave = pendingSaves.get(key); + if (pendingSave) { + window.clearTimeout(pendingSave); + pendingSaves.delete(key); + } + + writeEntry(key, content); + }, + load(key: string): string { - return localStorage.getItem(key) || ""; + const raw = localStorage.getItem(key); + return raw ? deserializeContent(raw) : ""; }, clear(key: string): void { diff --git a/web/src/components/MemoEditor/services/errorService.ts b/web/src/components/MemoEditor/services/errorService.ts index 76ce3bbe5..7913fd881 100644 --- a/web/src/components/MemoEditor/services/errorService.ts +++ b/web/src/components/MemoEditor/services/errorService.ts @@ -1,5 +1,9 @@ export const errorService = { getErrorMessage(error: unknown): string { + if (error && typeof error === "object" && "rawMessage" in error) { + return (error as { rawMessage?: string }).rawMessage || "An error occurred"; + } + // Handle ConnectError or errors with details property if (error && typeof error === "object" && "details" in error) { return (error as { details?: string }).details || "An error occurred"; diff --git a/web/src/components/MemoEditor/services/index.ts b/web/src/components/MemoEditor/services/index.ts index 7b9fb3f4c..5bd92a587 100644 --- a/web/src/components/MemoEditor/services/index.ts +++ b/web/src/components/MemoEditor/services/index.ts @@ -1,5 +1,6 @@ export * from "./cacheService"; export * from "./errorService"; export * from "./memoService"; +export * from "./transcriptionService"; export * from "./uploadService"; export * from "./validationService"; diff --git a/web/src/components/MemoEditor/services/memoService.ts b/web/src/components/MemoEditor/services/memoService.ts index d6df24bb6..20eee213b 100644 --- a/web/src/components/MemoEditor/services/memoService.ts +++ b/web/src/components/MemoEditor/services/memoService.ts @@ -2,20 +2,25 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema, timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt"; import { isEqual } from "lodash-es"; import { memoServiceClient } from "@/connect"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; -import { LocationSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; +import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; import type { EditorState } from "../state"; import { uploadService } from "./uploadService"; -function memoLocationFingerprint(l: Memo["location"]): string | null { - if (!l) return null; - return JSON.stringify([l.placeholder, l.latitude, l.longitude]); +/** + * Converts attachments to reference format for API requests. + * The backend only needs the attachment name to link it to a memo. + */ +function toAttachmentReferences(attachments: Attachment[]): Attachment[] { + return attachments.map((a) => create(AttachmentSchema, { name: a.name })); } function buildUpdateMask( prevMemo: Memo, state: EditorState, - _allAttachments: typeof state.metadata.attachments, + allAttachments: typeof state.metadata.attachments, ): { mask: Set; patch: Partial } { const mask = new Set(); const patch: Partial = { @@ -31,22 +36,21 @@ function buildUpdateMask( mask.add("visibility"); patch.visibility = state.metadata.visibility; } + if (!isEqual(allAttachments, prevMemo.attachments)) { + mask.add("attachments"); + patch.attachments = toAttachmentReferences(allAttachments); + } if (!isEqual(state.metadata.relations, prevMemo.relations)) { mask.add("relations"); patch.relations = state.metadata.relations; } - if (memoLocationFingerprint(state.metadata.location) !== memoLocationFingerprint(prevMemo.location)) { + if (!isEqual(state.metadata.location, prevMemo.location)) { mask.add("location"); - if (state.metadata.location) { - patch.location = create(LocationSchema, { - placeholder: state.metadata.location.placeholder, - latitude: state.metadata.location.latitude, - longitude: state.metadata.location.longitude, - }); - } + patch.location = state.metadata.location; } - if (["content", "relations"].some((key) => mask.has(key))) { + // Auto-update timestamp if content changed + if (["content", "attachments", "relations", "location"].some((key) => mask.has(key))) { mask.add("update_time"); } @@ -78,8 +82,8 @@ export const memoService = { }, ): Promise<{ memoName: string; hasChanges: boolean }> { // 1. Upload local files first - const uploadedAttachments = await uploadService.uploadFiles(state.localFiles); - const allAttachments = [...state.metadata.attachments, ...uploadedAttachments]; + const newAttachments = await uploadService.uploadFiles(state.localFiles); + const allAttachments = [...state.metadata.attachments, ...newAttachments]; // 2. Update existing memo if (options.memoName) { @@ -94,10 +98,6 @@ export const memoService = { memo: create(MemoSchema, patch as Record), updateMask: create(FieldMaskSchema, { paths: Array.from(mask) }), }); - await memoServiceClient.setMemoAttachments({ - name: memo.name, - attachments: allAttachments, - }); return { memoName: memo.name, hasChanges: true }; } @@ -105,18 +105,11 @@ export const memoService = { const memoData = create(MemoSchema, { content: state.content, visibility: state.metadata.visibility, + attachments: toAttachmentReferences(allAttachments), relations: state.metadata.relations, + location: state.metadata.location, createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined, updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined, - ...(state.metadata.location - ? { - location: create(LocationSchema, { - placeholder: state.metadata.location.placeholder, - latitude: state.metadata.location.latitude, - longitude: state.metadata.location.longitude, - }), - } - : {}), }); const memo = options.parentMemoName @@ -126,13 +119,6 @@ export const memoService = { }) : await memoServiceClient.createMemo({ memo: memoData }); - if (allAttachments.length > 0) { - await memoServiceClient.setMemoAttachments({ - name: memo.name, - attachments: allAttachments, - }); - } - return { memoName: memo.name, hasChanges: true }; }, @@ -156,13 +142,12 @@ export const memoService = { updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined, }, localFiles: [], - voiceRecorder: { + audioRecorder: { isSupported: true, permission: "unknown", status: "idle", elapsedSeconds: 0, error: undefined, - recording: undefined, }, }; }, diff --git a/web/src/components/MemoEditor/services/transcriptionService.ts b/web/src/components/MemoEditor/services/transcriptionService.ts new file mode 100644 index 000000000..74c078bf0 --- /dev/null +++ b/web/src/components/MemoEditor/services/transcriptionService.ts @@ -0,0 +1,27 @@ +import { aiServiceClient } from "@/connect"; +import type { InstanceSetting_AIProviderConfig } from "@/types/proto/api/v1/instance_service_pb"; + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ""; + for (const byte of bytes) { + bin += String.fromCharCode(byte); + } + return btoa(bin); +} + +export const transcriptionService = { + async transcribeFile(file: File, provider: InstanceSetting_AIProviderConfig): Promise { + const content = new Uint8Array(await file.arrayBuffer()); + const response = await aiServiceClient.transcribe({ + providerId: provider.id, + config: {}, + audio: { + content: bytesToBase64(content), + filename: file.name, + contentType: file.type, + }, + }); + + return response.text; + }, +}; diff --git a/web/src/components/MemoEditor/services/uploadService.ts b/web/src/components/MemoEditor/services/uploadService.ts index 7cf04c2ed..404eae584 100644 --- a/web/src/components/MemoEditor/services/uploadService.ts +++ b/web/src/components/MemoEditor/services/uploadService.ts @@ -1,24 +1,30 @@ import { create } from "@bufbuild/protobuf"; import { attachmentServiceClient } from "@/connect"; -import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { AttachmentSchema, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { LocalFile } from "../types/attachment"; export const uploadService = { async uploadFiles(localFiles: LocalFile[]): Promise { - const uploaded: Attachment[] = []; - for (const local of localFiles) { - const file = local.file; - const content = new Uint8Array(await file.arrayBuffer()); - const created = await attachmentServiceClient.createAttachment({ + if (localFiles.length === 0) return []; + + const attachments: Attachment[] = []; + + for (const localFile of localFiles) { + const { file, motionMedia } = localFile; + const buffer = new Uint8Array(await file.arrayBuffer()); + const attachment = await attachmentServiceClient.createAttachment({ attachment: create(AttachmentSchema, { filename: file.name, - content, - type: file.type || "application/octet-stream", + size: BigInt(file.size), + type: file.type, + content: buffer, + motionMedia: motionMedia ? create(MotionMediaSchema, motionMedia) : undefined, }), }); - uploaded.push(created); + attachments.push(attachment); } - return uploaded; + + return attachments; }, }; diff --git a/web/src/components/MemoEditor/services/validationService.ts b/web/src/components/MemoEditor/services/validationService.ts index ac32c1504..8c2509dcb 100644 --- a/web/src/components/MemoEditor/services/validationService.ts +++ b/web/src/components/MemoEditor/services/validationService.ts @@ -22,9 +22,9 @@ export const validationService = { return { valid: false, reason: "Wait for upload to complete" }; } - // Cannot save while voice recorder is active - if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { - return { valid: false, reason: "Finish voice recording before saving" }; + // Cannot save while audio recorder is active + if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") { + return { valid: false, reason: "Finish audio recording before saving" }; } // Cannot save while already saving diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts index 3b0b1be90..392a96bb3 100644 --- a/web/src/components/MemoEditor/state/actions.ts +++ b/web/src/components/MemoEditor/state/actions.ts @@ -1,7 +1,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import type { LocalFile } from "../types/attachment"; -import type { EditorAction, EditorState, LoadingKey, VoiceRecorderPermission, VoiceRecorderStatus, VoiceRecordingPreview } from "./types"; +import type { AudioRecorderPermission, AudioRecorderStatus, EditorAction, EditorState, LoadingKey } from "./types"; export const editorActions = { initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({ @@ -49,6 +49,11 @@ export const editorActions = { payload: previewUrl, }), + setLocalFiles: (files: LocalFile[]): EditorAction => ({ + type: "SET_LOCAL_FILES", + payload: files, + }), + clearLocalFiles: (): EditorAction => ({ type: "CLEAR_LOCAL_FILES", }), @@ -72,33 +77,28 @@ export const editorActions = { payload: timestamps, }), - setVoiceRecorderSupport: (value: boolean): EditorAction => ({ - type: "SET_VOICE_RECORDER_SUPPORT", - payload: value, - }), - - setVoiceRecorderPermission: (value: VoiceRecorderPermission): EditorAction => ({ - type: "SET_VOICE_RECORDER_PERMISSION", + setAudioRecorderSupport: (value: boolean): EditorAction => ({ + type: "SET_AUDIO_RECORDER_SUPPORT", payload: value, }), - setVoiceRecorderStatus: (value: VoiceRecorderStatus): EditorAction => ({ - type: "SET_VOICE_RECORDER_STATUS", + setAudioRecorderPermission: (value: AudioRecorderPermission): EditorAction => ({ + type: "SET_AUDIO_RECORDER_PERMISSION", payload: value, }), - setVoiceRecorderElapsed: (value: number): EditorAction => ({ - type: "SET_VOICE_RECORDER_ELAPSED", + setAudioRecorderStatus: (value: AudioRecorderStatus): EditorAction => ({ + type: "SET_AUDIO_RECORDER_STATUS", payload: value, }), - setVoiceRecorderError: (value?: string): EditorAction => ({ - type: "SET_VOICE_RECORDER_ERROR", + setAudioRecorderElapsed: (value: number): EditorAction => ({ + type: "SET_AUDIO_RECORDER_ELAPSED", payload: value, }), - setVoiceRecording: (value?: VoiceRecordingPreview): EditorAction => ({ - type: "SET_VOICE_RECORDING", + setAudioRecorderError: (value?: string): EditorAction => ({ + type: "SET_AUDIO_RECORDER_ERROR", payload: value, }), diff --git a/web/src/components/MemoEditor/state/reducer.ts b/web/src/components/MemoEditor/state/reducer.ts index 2ad6ee49d..820282317 100644 --- a/web/src/components/MemoEditor/state/reducer.ts +++ b/web/src/components/MemoEditor/state/reducer.ts @@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload), }; + case "SET_LOCAL_FILES": + return { + ...state, + localFiles: action.payload, + }; + case "CLEAR_LOCAL_FILES": return { ...state, @@ -119,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS }, }; - case "SET_VOICE_RECORDER_SUPPORT": + case "SET_AUDIO_RECORDER_SUPPORT": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, isSupported: action.payload, - status: action.payload ? state.voiceRecorder.status : "unsupported", + status: action.payload ? state.audioRecorder.status : "unsupported", }, }; - case "SET_VOICE_RECORDER_PERMISSION": + case "SET_AUDIO_RECORDER_PERMISSION": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, permission: action.payload, }, }; - case "SET_VOICE_RECORDER_STATUS": + case "SET_AUDIO_RECORDER_STATUS": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, status: action.payload, }, }; - case "SET_VOICE_RECORDER_ELAPSED": + case "SET_AUDIO_RECORDER_ELAPSED": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, elapsedSeconds: action.payload, }, }; - case "SET_VOICE_RECORDER_ERROR": + case "SET_AUDIO_RECORDER_ERROR": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, error: action.payload, }, }; - case "SET_VOICE_RECORDING": - return { - ...state, - voiceRecorder: { - ...state.voiceRecorder, - recording: action.payload, - }, - }; - case "RESET": return { ...initialState, diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts index bae16313f..f06c33366 100644 --- a/web/src/components/MemoEditor/state/types.ts +++ b/web/src/components/MemoEditor/state/types.ts @@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; import type { LocalFile } from "../types/attachment"; export type LoadingKey = "saving" | "uploading" | "loading"; -export type VoiceRecorderPermission = "unknown" | "granted" | "denied"; -export type VoiceRecorderStatus = "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported"; - -export interface VoiceRecordingPreview { - localFile: LocalFile; - durationSeconds: number; - mimeType: string; -} +export type AudioRecorderPermission = "unknown" | "granted" | "denied"; +export type AudioRecorderStatus = "idle" | "requesting_permission" | "recording" | "error" | "unsupported"; export interface EditorState { content: string; @@ -35,13 +29,12 @@ export interface EditorState { updateTime?: Date; }; localFiles: LocalFile[]; - voiceRecorder: { + audioRecorder: { isSupported: boolean; - permission: VoiceRecorderPermission; - status: VoiceRecorderStatus; + permission: AudioRecorderPermission; + status: AudioRecorderStatus; elapsedSeconds: number; error?: string; - recording?: VoiceRecordingPreview; }; } @@ -55,17 +48,17 @@ export type EditorAction = | { type: "REMOVE_RELATION"; payload: string } | { type: "ADD_LOCAL_FILE"; payload: LocalFile } | { type: "REMOVE_LOCAL_FILE"; payload: string } + | { type: "SET_LOCAL_FILES"; payload: LocalFile[] } | { type: "CLEAR_LOCAL_FILES" } | { type: "TOGGLE_FOCUS_MODE" } | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } | { type: "SET_COMPOSING"; payload: boolean } | { type: "SET_TIMESTAMPS"; payload: Partial } - | { type: "SET_VOICE_RECORDER_SUPPORT"; payload: boolean } - | { type: "SET_VOICE_RECORDER_PERMISSION"; payload: VoiceRecorderPermission } - | { type: "SET_VOICE_RECORDER_STATUS"; payload: VoiceRecorderStatus } - | { type: "SET_VOICE_RECORDER_ELAPSED"; payload: number } - | { type: "SET_VOICE_RECORDER_ERROR"; payload?: string } - | { type: "SET_VOICE_RECORDING"; payload?: VoiceRecordingPreview } + | { type: "SET_AUDIO_RECORDER_SUPPORT"; payload: boolean } + | { type: "SET_AUDIO_RECORDER_PERMISSION"; payload: AudioRecorderPermission } + | { type: "SET_AUDIO_RECORDER_STATUS"; payload: AudioRecorderStatus } + | { type: "SET_AUDIO_RECORDER_ELAPSED"; payload: number } + | { type: "SET_AUDIO_RECORDER_ERROR"; payload?: string } | { type: "RESET" }; export const initialState: EditorState = { @@ -90,12 +83,11 @@ export const initialState: EditorState = { updateTime: undefined, }, localFiles: [], - voiceRecorder: { + audioRecorder: { isSupported: true, permission: "unknown", status: "idle", elapsedSeconds: 0, error: undefined, - recording: undefined, }, }; diff --git a/web/src/components/MemoEditor/types/attachment.ts b/web/src/components/MemoEditor/types/attachment.ts index 4d5b914a7..2cb0d6756 100644 --- a/web/src/components/MemoEditor/types/attachment.ts +++ b/web/src/components/MemoEditor/types/attachment.ts @@ -1,11 +1,13 @@ -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import type { Attachment, MotionMedia } from "@/types/proto/api/v1/attachment_service_pb"; +import { MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb"; import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; +import { buildAttachmentVisualItems } from "@/utils/media-item"; -export type FileCategory = "image" | "video" | "audio" | "document"; +export type FileCategory = "image" | "video" | "motion" | "audio" | "document"; -// Unified view model for rendering attachments and local files export interface AttachmentItem { readonly id: string; + readonly memberIds: string[]; readonly filename: string; readonly category: FileCategory; readonly mimeType: string; @@ -13,31 +15,57 @@ export interface AttachmentItem { readonly sourceUrl: string; readonly size?: number; readonly isLocal: boolean; + readonly isVoiceNote: boolean; + readonly audioMeta?: LocalFile["audioMeta"]; } -// For MemoEditor: local files being uploaded export interface LocalFile { readonly file: File; readonly previewUrl: string; readonly origin?: "audio_recording" | "upload"; readonly audioMeta?: { - durationSeconds: number; + readonly durationSeconds: number; }; + readonly motionMedia?: MotionMedia; } -function categorizeFile(mimeType: string): FileCategory { +const AUDIO_RECORDING_FILENAME_RE = /^(?:voice-(?:recording|note)|audio-recording)-(\d{8})-(\d{4,6})/i; + +export const isAudioRecordingFilename = (filename: string): boolean => AUDIO_RECORDING_FILENAME_RE.test(filename); + +export const getAudioRecordingTimeLabel = (filename: string): string | undefined => { + const match = filename.match(AUDIO_RECORDING_FILENAME_RE); + const timePart = match?.[2]; + if (!timePart) { + return undefined; + } + + if (timePart.length === 4) { + return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}`; + } + + if (timePart.length === 6) { + return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}`; + } + + return undefined; +}; + +function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory { + if (motionMedia) return "motion"; if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("audio/")) return "audio"; return "document"; } -export function attachmentToItem(attachment: Attachment): AttachmentItem { +function attachmentGroupToItem(attachment: Attachment): AttachmentItem { const attachmentType = getAttachmentType(attachment); const sourceUrl = getAttachmentUrl(attachment); return { id: attachment.name, + memberIds: [attachment.name], filename: attachment.filename, category: categorizeFile(attachment.type), mimeType: attachment.type, @@ -45,24 +73,109 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem { sourceUrl, size: Number(attachment.size), isLocal: false, + isVoiceNote: categorizeFile(attachment.type) === "audio" && isAudioRecordingFilename(attachment.filename), + audioMeta: undefined, + }; +} + +function visualItemToAttachmentItem(item: ReturnType[number]): AttachmentItem { + return { + id: item.id, + memberIds: item.attachmentNames, + filename: item.filename, + category: item.kind === "motion" ? "motion" : item.kind, + mimeType: item.mimeType, + thumbnailUrl: item.posterUrl, + sourceUrl: item.sourceUrl, + size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0), + isLocal: false, + isVoiceNote: false, + audioMeta: undefined, }; } -export function fileToItem(file: File, blobUrl: string): AttachmentItem { +function fileToItem(file: LocalFile): AttachmentItem { return { - id: blobUrl, - filename: file.name, - category: categorizeFile(file.type), - mimeType: file.type, - thumbnailUrl: blobUrl, - sourceUrl: blobUrl, - size: file.size, + id: file.motionMedia?.groupId || file.previewUrl, + memberIds: [file.previewUrl], + filename: file.file.name, + category: categorizeFile(file.file.type, file.motionMedia), + mimeType: file.file.type, + thumbnailUrl: file.previewUrl, + sourceUrl: file.previewUrl, + size: file.file.size, isLocal: true, + isVoiceNote: + categorizeFile(file.file.type, file.motionMedia) === "audio" && + (file.origin === "audio_recording" || isAudioRecordingFilename(file.file.name)), + audioMeta: file.audioMeta, }; } +function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] { + const grouped = new Map(); + const singles: AttachmentItem[] = []; + + for (const localFile of localFiles) { + const groupId = localFile.motionMedia?.groupId; + if (!groupId) { + singles.push(fileToItem(localFile)); + continue; + } + + const group = grouped.get(groupId) ?? []; + group.push(localFile); + grouped.set(groupId, group); + } + + const groupedItems = Array.from(grouped.entries()).flatMap(([groupId, files]) => { + const still = files.find( + (file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.STILL, + ); + const video = files.find( + (file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.VIDEO, + ); + if (still && video && files.length === 2) { + return [ + { + id: groupId, + memberIds: [still.previewUrl, video.previewUrl], + filename: still.file.name, + category: "motion" as const, + mimeType: still.file.type, + thumbnailUrl: still.previewUrl, + sourceUrl: video.previewUrl, + size: still.file.size + video.file.size, + isLocal: true, + isVoiceNote: false, + audioMeta: undefined, + }, + ]; + } + + return files.map(fileToItem); + }); + + return [...groupedItems, ...singles]; +} + export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] { - return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))]; + const visualAttachments = attachments.filter((attachment) => { + const attachmentType = getAttachmentType(attachment); + return attachmentType === "image/*" || attachmentType === "video/*" || attachment.motionMedia !== undefined; + }); + const attachmentVisualIds = new Set(); + const attachmentVisualItems = buildAttachmentVisualItems(visualAttachments).map((item) => { + item.attachmentNames.forEach((name) => attachmentVisualIds.add(name)); + return visualItemToAttachmentItem(item); + }); + + const nonVisualAttachmentItems = attachments + .filter((attachment) => !attachmentVisualIds.has(attachment.name)) + .map(attachmentGroupToItem) + .filter((item) => item.category === "audio" || item.category === "document"); + + return [...attachmentVisualItems, ...nonVisualAttachmentItems, ...toLocalMotionItems(localFiles)]; } export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] { @@ -75,7 +188,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme const docs: AttachmentItem[] = []; for (const item of items) { - if (item.category === "image" || item.category === "video") { + if (item.category === "image" || item.category === "video" || item.category === "motion") { media.push(item); } else { docs.push(item); diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 6069768b4..367a082d5 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -23,21 +23,22 @@ export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; - onVoiceRecorderClick: () => void; + onAudioRecorderClick: () => void; } export interface EditorMetadataProps { memoName?: string; } -export interface VoiceRecorderPanelProps { - voiceRecorder: EditorState["voiceRecorder"]; - onStart: () => void; +export interface AudioRecorderPanelProps { + audioRecorder: EditorState["audioRecorder"]; + /** Active mic stream while recording; used for live waveform visualization. */ + mediaStream: MediaStream | null; onStop: () => void; - onKeep: () => void; - onDiscard: () => void; - onRecordAgain: () => void; - onClose: () => void; + onCancel: () => void; + onTranscribe?: () => void; + canTranscribe?: boolean; + isTranscribing?: boolean; } export interface FocusModeOverlayProps { @@ -57,7 +58,7 @@ export interface InsertMenuProps { onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; memoName?: string; - onVoiceRecorderClick?: () => void; + onAudioRecorderClick?: () => void; } export interface TagSuggestionsProps { diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx index bdbb8a20e..79c38a5ef 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx @@ -1,165 +1,359 @@ -import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react"; -import type { FC } from "react"; +import { ChevronDownIcon, ChevronUpIcon, FileAudioIcon, FileIcon, PaperclipIcon, PauseIcon, PlayIcon, XIcon } from "lucide-react"; +import { type FC, type MouseEvent, useMemo, useRef, useState } from "react"; import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment"; -import { toAttachmentItems } from "@/components/MemoEditor/types/attachment"; +import { getAudioRecordingTimeLabel, toAttachmentItems } from "@/components/MemoEditor/types/attachment"; import MetadataSection from "@/components/MemoMetadata/MetadataSection"; +import PreviewImageDialog from "@/components/PreviewImageDialog"; import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { formatFileSize, getFileTypeLabel } from "@/utils/format"; +import { useTranslate } from "@/utils/i18n"; +import type { PreviewMediaItem } from "@/utils/media-item"; +import { formatAudioTime } from "./attachmentHelpers"; interface AttachmentListEditorProps { attachments: Attachment[]; localFiles?: LocalFile[]; onAttachmentsChange?: (attachments: Attachment[]) => void; + onLocalFilesChange?: (localFiles: LocalFile[]) => void; onRemoveLocalFile?: (previewUrl: string) => void; } +const AttachmentItemActions: FC<{ + onRemove?: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + canMoveUp?: boolean; + canMoveDown?: boolean; +}> = ({ onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { + const stopPropagation = (event: MouseEvent) => { + event.stopPropagation(); + }; + + return ( +
+ {onMoveUp && ( + + )} + + {onMoveDown && ( + + )} + + {onRemove && ( + + )} +
+ ); +}; + const AttachmentItemCard: FC<{ item: AttachmentItem; + onPreview?: () => void; onRemove?: () => void; onMoveUp?: () => void; onMoveDown?: () => void; canMoveUp?: boolean; canMoveDown?: boolean; -}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { - const { category, filename, thumbnailUrl, mimeType, size } = item; - const fileTypeLabel = getFileTypeLabel(mimeType); - const fileSizeLabel = size ? formatFileSize(size) : undefined; - const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename; +}> = ({ item, onPreview, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { + const t = useTranslate(); + const { category, filename, thumbnailUrl, mimeType, size, sourceUrl, isVoiceNote, audioMeta } = item; + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType); + const isPreviewable = category === "image" || category === "video" || category === "motion"; + const recordingTimeLabel = isVoiceNote ? getAudioRecordingTimeLabel(filename) : undefined; + const titleLabel = + isVoiceNote && recordingTimeLabel + ? t("editor.audio-recorder.attachment-label-with-time", { time: recordingTimeLabel }) + : isVoiceNote + ? t("editor.audio-recorder.attachment-label") + : filename; + const detailParts = [ + audioMeta?.durationSeconds ? formatAudioTime(audioMeta.durationSeconds) : undefined, + fileTypeLabel, + size ? formatFileSize(size) : undefined, + ].filter(Boolean); + + const handleAudioToggle = async (event: MouseEvent) => { + event.stopPropagation(); + + const audio = audioRef.current; + if (!audio) { + return; + } + + if (audio.paused) { + try { + await audio.play(); + } catch { + setIsPlaying(false); + } + return; + } + + audio.pause(); + }; return (
-
- {category === "image" && thumbnailUrl ? ( - +
+ {(category === "image" || category === "motion") && thumbnailUrl ? ( + + ) : isVoiceNote ? ( + <> + +
- {displayName} + {titleLabel}
- {fileTypeLabel} - {fileSizeLabel && ( - <> - - {fileSizeLabel} - - )} + {detailParts.map((part, index) => ( + + {index > 0 && } + {part} + + ))}
-
- {onMoveUp && ( - - )} - - {onMoveDown && ( - - )} - - {onRemove && ( - - )} -
+
); }; -const AttachmentListEditor: FC = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => { - if (attachments.length === 0 && localFiles.length === 0) { - return null; - } - +const AttachmentListEditor: FC = ({ + attachments, + localFiles = [], + onAttachmentsChange, + onLocalFilesChange, + onRemoveLocalFile, +}) => { + const [previewState, setPreviewState] = useState<{ open: boolean; initialIndex: number }>({ open: false, initialIndex: 0 }); const items = toAttachmentItems(attachments, localFiles); + const attachmentItems = items.filter((item) => !item.isLocal); + const localItems = items.filter((item) => item.isLocal); + const previewItems = useMemo( + () => + items.reduce((acc, item) => { + if (item.category === "image") { + acc.push({ id: item.id, kind: "image", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } + + if (item.category === "video") { + acc.push({ id: item.id, kind: "video", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } + + if (item.category === "motion") { + acc.push({ id: item.id, kind: "motion", motionUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } - const handleMoveUp = (index: number) => { - if (index === 0 || !onAttachmentsChange) return; + return acc; + }, []), + [items], + ); + + const handleMoveAttachments = (itemId: string, direction: -1 | 1) => { + if (!onAttachmentsChange) return; + + const itemIndex = attachmentItems.findIndex((item) => item.id === itemId); + const targetIndex = itemIndex + direction; + if (itemIndex < 0 || targetIndex < 0 || targetIndex >= attachmentItems.length) { + return; + } + + const reorderedItems = [...attachmentItems]; + [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]]; - const newAttachments = [...attachments]; - [newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]]; - onAttachmentsChange(newAttachments); + const attachmentMap = new Map(attachments.map((attachment) => [attachment.name, attachment])); + onAttachmentsChange( + reorderedItems.flatMap((item) => item.memberIds.map((memberId) => attachmentMap.get(memberId)).filter(Boolean) as Attachment[]), + ); }; - const handleMoveDown = (index: number) => { - if (index === attachments.length - 1 || !onAttachmentsChange) return; + const handleMoveLocalFiles = (itemId: string, direction: -1 | 1) => { + if (!onLocalFilesChange) return; - const newAttachments = [...attachments]; - [newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]]; - onAttachmentsChange(newAttachments); + const itemIndex = localItems.findIndex((item) => item.id === itemId); + const targetIndex = itemIndex + direction; + if (itemIndex < 0 || targetIndex < 0 || targetIndex >= localItems.length) { + return; + } + + const reorderedItems = [...localItems]; + [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]]; + + const localFileMap = new Map(localFiles.map((localFile) => [localFile.previewUrl, localFile])); + onLocalFilesChange( + reorderedItems.flatMap((item) => item.memberIds.map((memberId) => localFileMap.get(memberId)).filter(Boolean) as LocalFile[]), + ); }; - const handleRemoveAttachment = (name: string) => { + const handleRemoveItem = (item: AttachmentItem) => { + if (item.isLocal) { + const nextLocalFiles = localFiles.filter((file) => !item.memberIds.includes(file.previewUrl)); + onLocalFilesChange?.(nextLocalFiles); + if (!onLocalFilesChange) { + item.memberIds.forEach((previewUrl) => onRemoveLocalFile?.(previewUrl)); + } + return; + } + if (onAttachmentsChange) { - onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name)); + onAttachmentsChange(attachments.filter((attachment) => !item.memberIds.includes(attachment.name))); } }; - const handleRemoveItem = (item: (typeof items)[0]) => { - if (item.isLocal) { - onRemoveLocalFile?.(item.id); - } else { - handleRemoveAttachment(item.id); + const handlePreviewItem = (item: AttachmentItem) => { + const previewIndex = previewItems.findIndex((previewItem) => previewItem.id === item.id); + if (previewIndex < 0) { + return; } + + setPreviewState({ open: true, initialIndex: previewIndex }); }; + if (items.length === 0) { + return null; + } + return ( - - {items.map((item) => { - const isLocalFile = item.isLocal; - const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); - - return ( - handleRemoveItem(item)} - onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} - onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} - canMoveUp={!isLocalFile && attachmentIndex > 0} - canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} - /> - ); - })} - + <> + + {items.map((item) => { + const itemList = item.isLocal ? localItems : attachmentItems; + const itemIndex = itemList.findIndex((entry) => entry.id === item.id); + + return ( + handlePreviewItem(item) + : undefined + } + onRemove={() => handleRemoveItem(item)} + onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)} + onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)} + canMoveUp={itemIndex > 0} + canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1} + /> + ); + })} + + + setPreviewState((state) => ({ ...state, open }))} + items={previewItems} + initialIndex={previewState.initialIndex} + /> + ); }; diff --git a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx index 0aff98c6c..adbd868b3 100644 --- a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx +++ b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx @@ -1,21 +1,31 @@ -import { FileAudioIcon, PauseIcon, PlayIcon } from "lucide-react"; +import { PauseIcon, PlayIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; import { formatFileSize, getFileTypeLabel } from "@/utils/format"; import { formatAudioTime } from "./attachmentHelpers"; const AUDIO_PLAYBACK_RATES = [1, 1.5, 2] as const; +const UNKNOWN_DURATION_LABEL = "--:--"; + +const getDurationLabel = (duration: number): string => (duration > 0 ? formatAudioTime(duration) : UNKNOWN_DURATION_LABEL); + +const getNextPlaybackRate = (currentRate: (typeof AUDIO_PLAYBACK_RATES)[number]): (typeof AUDIO_PLAYBACK_RATES)[number] => { + const currentRateIndex = AUDIO_PLAYBACK_RATES.findIndex((rate) => rate === currentRate); + return AUDIO_PLAYBACK_RATES[(currentRateIndex + 1) % AUDIO_PLAYBACK_RATES.length]; +}; interface AudioProgressBarProps { filename: string; currentTime: number; duration: number; progressPercent: number; - onSeek: (value: string) => void; + onSeek: (value: number) => void; + className?: string; } -const AudioProgressBar = ({ filename, currentTime, duration, progressPercent, onSeek }: AudioProgressBarProps) => ( -
-
+const AudioProgressBar = ({ filename, currentTime, duration, progressPercent, onSeek, className }: AudioProgressBarProps) => ( +
+
onSeek(e.target.value)} + onChange={(e) => onSeek(Number(e.target.value))} aria-label={`Seek ${filename}`} - className="relative z-10 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-default + className="relative z-10 h-3.5 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-default [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent - [&::-webkit-slider-thumb]:mt-[-3px] [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:appearance-none + [&::-webkit-slider-thumb]:mt-[-2.5px] [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-border/50 [&::-webkit-slider-thumb]:bg-background/95 [&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent @@ -38,9 +48,6 @@ const AudioProgressBar = ({ filename, currentTime, duration, progressPercent, on disabled={duration === 0} />
-
- {formatAudioTime(currentTime)} / {duration > 0 ? formatAudioTime(duration) : "--:--"} -
); @@ -54,7 +61,7 @@ interface AudioAttachmentItemProps { className?: string; } -const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: AudioAttachmentItemProps) => { +const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title, compact = false, className }: AudioAttachmentItemProps) => { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); @@ -64,6 +71,9 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud const fileTypeLabel = getFileTypeLabel(mimeType); const fileSizeLabel = size ? formatFileSize(size) : undefined; const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + const currentTimeLabel = formatAudioTime(currentTime); + const durationLabel = getDurationLabel(duration); + const timeLabel = `${currentTimeLabel} / ${durationLabel}`; useEffect(() => { if (!audioRef.current) { @@ -92,9 +102,8 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud audio.pause(); }; - const handleSeek = (value: string) => { + const handleSeek = (nextTime: number) => { const audio = audioRef.current; - const nextTime = Number(value); if (!audio || Number.isNaN(nextTime)) { return; @@ -105,9 +114,7 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud }; const handlePlaybackRateChange = () => { - const currentRateIndex = AUDIO_PLAYBACK_RATES.findIndex((rate) => rate === playbackRate); - const nextRate = AUDIO_PLAYBACK_RATES[(currentRateIndex + 1) % AUDIO_PLAYBACK_RATES.length]; - setPlaybackRate(nextRate); + setPlaybackRate((currentRate) => getNextPlaybackRate(currentRate)); }; const handleDuration = (value: number) => { @@ -115,56 +122,51 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud }; return ( -
-
-
- -
- -
-
-
- {displayTitle} -
-
- {fileTypeLabel} - {fileSizeLabel && ( - <> - - {fileSizeLabel} - - )} -
+
+
+
+
+ {displayTitle}
- -
- - +
+ {fileTypeLabel} + {fileSizeLabel ? ` · ${fileSizeLabel}` : ""}
+ +
- +
+ + +
+
{timeLabel}
+ + +
+