chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:24 +08:00
commit 4cddfcf2f3
1040 changed files with 200957 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# Identity Provider Bootstrap
Memos automatically reconciles OAuth2 identity providers from JSON files before the server starts. This is useful when a deployment platform provides configuration as mounted secret files.
By default, Memos scans `/etc/secrets` for files named `memos-idp-*.json`:
```bash
memos
```
For example, `/etc/secrets/memos-idp-primary.json` can contain:
```json
{
"uid": "primary-sso",
"name": "Company SSO",
"type": "OAUTH2",
"identifierFilter": "",
"config": {
"oauth2Config": {
"clientId": "client-id",
"clientSecret": "client-secret",
"authUrl": "https://idp.example.com/oauth/authorize",
"tokenUrl": "https://idp.example.com/oauth/token",
"userInfoUrl": "https://idp.example.com/oauth/userinfo",
"scopes": ["profile", "email"],
"fieldMapping": {
"identifier": "sub",
"displayName": "name",
"email": "email",
"avatarUrl": "picture"
}
}
}
}
```
Each file contains exactly one `memos.store.IdentityProvider` encoded as protobuf JSON. The database-generated numeric `id` must be omitted; `uid` is the provider's stable identifier.
After database migrations and demo seeding, Memos reads all matching files in filename order and validates every provider before writing anything. Each provider is then created or updated by its stable `uid`; providers omitted from the files are left unchanged. Reapplying the files is safe and updates credentials on restart, which supports secret rotation. Duplicate provider UIDs across files are rejected.
A missing directory or a directory without matching files is a normal no-op. If a matching file is unreadable, oversized, or invalid, Memos does not start. Files with other names are ignored so the directory can safely contain unrelated secrets. Keep files containing `clientSecret` outside source control and provide them through the deployment platform's secret-management facility.
@@ -0,0 +1,35 @@
## Background & Context
The MemoDetail page (`web/src/pages/MemoDetail.tsx`) renders a memo's full content with a right sidebar (`MemoDetailSidebar`) on desktop. The sidebar currently shows share controls, timestamps, property badges (links, to-do, code), and tags. Memo content is rendered via `react-markdown` with custom heading components (h1h6) in `MemoContent/markdown/Heading.tsx`. The page already has hash-based scroll-to-element infrastructure for comments (lines 3943 of MemoDetail.tsx).
## Issue Statement
When viewing a memo with structured headings, there is no outline or table of contents in the MemoDetail sidebar, so readers cannot see the document structure at a glance or jump to specific sections. Heading elements rendered by the `Heading` component do not have `id` attributes, preventing any anchor-based navigation to them.
## Current State
- **`web/src/pages/MemoDetail.tsx`** (93 lines) — MemoDetail page. Hash-based `scrollIntoView` logic exists at lines 3943 but only targets comment elements. The sidebar is rendered at lines 8286 as `<MemoDetailSidebar>`.
- **`web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx`** (109 lines) — Desktop sidebar. Contains sections: Share (lines 5865), Created-at (lines 6776), Properties (lines 7889), Tags (lines 91102). Uses a reusable `SidebarSection` helper (lines 2432). No outline section exists.
- **`web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx`** (36 lines) — Mobile drawer wrapper for the sidebar.
- **`web/src/components/MemoContent/markdown/Heading.tsx`** (30 lines) — Renders h1h6 with Tailwind classes. Does not generate `id` attributes on heading elements.
- **`web/src/components/MemoContent/index.tsx`** (157 lines) — ReactMarkdown renderer. Maps h1h6 to the `Heading` component (lines 72101). No rehype-slug or equivalent plugin is installed.
- **`web/src/utils/markdown-manipulation.ts`** (143 lines) — Contains `fromMarkdown()` MDAST parsing with GFM extensions. Used for task extraction; no heading extraction exists.
## Non-Goals
- Redesigning the overall MemoDetail layout or sidebar structure beyond adding the outline section.
- Supporting outline for the mobile drawer — the outline section should appear in the desktop sidebar only for now.
- Adding active heading highlighting on scroll (scroll-spy behavior).
- Supporting outline in the memo list/timeline view (compact mode).
- Changing the existing heading visual styles in the content area.
## Open Questions
- Which heading levels to include in the outline? (default: h1h4, matching user request)
- How to generate slug IDs — install `rehype-slug` or custom implementation? (default: lightweight custom slug generation in the Heading component to avoid a new dependency)
- Should the outline section be hidden when no headings exist? (default: yes, hide entirely like Properties/Tags sections)
- Should duplicate heading text produce unique slugs (e.g. `my-heading`, `my-heading-1`)? (default: yes, append numeric suffix for duplicates)
## Scope
**M** — Touches 34 files (Heading component, MemoDetailSidebar, possibly a new outline component, and a slug utility). Existing patterns (SidebarSection, hash scrolling) apply directly. No new subsystem or novel approach required.
@@ -0,0 +1,47 @@
## Task List
T1: Add heading extraction utility [S] — T2: Add slug IDs to Heading component [S] — T3: Create MemoOutline sidebar component [M] — T4: Integrate outline into MemoDetailSidebar [S]
### T1: Add heading extraction utility [S]
**Objective**: Provide a function to extract h1h4 headings from markdown content with slugified IDs, reusing the existing MDAST parsing pattern from `markdown-manipulation.ts`.
**Files**: `web/src/utils/markdown-manipulation.ts`
**Implementation**: Add `HeadingItem` interface (text, level, slug) and `extractHeadings(markdown: string): HeadingItem[]` function. Use existing `fromMarkdown()` + `visit()` pattern. Visit `"heading"` nodes with depth 14, extract text from children, generate slug via `slugify()` helper (lowercase, replace non-alphanumeric with hyphens, deduplicate). Export both.
**Validation**: `cd web && pnpm lint` — no new errors
### T2: Add slug IDs to Heading component [S]
**Objective**: Generate deterministic `id` attributes on h1h6 elements so outline links can scroll to them via `#hash`.
**Files**: `web/src/components/MemoContent/markdown/Heading.tsx`
**Implementation**: In `Heading` (~line 13), extract text from `children` using a `getTextContent(children)` helper that recursively extracts string content from React children. Generate slug with the same `slugify` logic. Apply `id={slug}` to the rendered `<Component>`.
**Validation**: `cd web && pnpm lint` — no new errors
### T3: Create MemoOutline sidebar component [M]
**Objective**: Create a modern, Claude/Linear-style outline component that renders h1h4 headings as anchor links with indentation by level.
**Size**: M (new component file, modern styling)
**Files**:
- Create: `web/src/components/MemoDetailSidebar/MemoOutline.tsx`
**Implementation**:
1. Props: `{ headings: HeadingItem[] }` from `markdown-manipulation.ts`
2. Render a `<nav>` with vertical list of `<a href="#slug">` links
3. Styling per level: h1 no indent, h2 `pl-3`, h3 `pl-6`, h4 `pl-9`. Text size: h1 `text-[13px] font-medium`, h2h4 `text-[13px] font-normal`. Color: `text-muted-foreground` with `hover:text-foreground` transition. Left border accent line (2px) along the nav. Smooth scroll on click via `scrollIntoView`.
4. Each link: `block py-1 truncate transition-colors` with level-based indentation
**Boundaries**: No scroll-spy / active state tracking. No mobile drawer integration.
**Dependencies**: T1
**Expected Outcome**: Component renders a clean, modern outline navigation.
**Validation**: `cd web && pnpm lint` — no new errors
### T4: Integrate outline into MemoDetailSidebar [S]
**Objective**: Add the outline section as the first section in `MemoDetailSidebar`, shown only when headings exist.
**Files**: `web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx`
**Implementation**: Import `extractHeadings` and `MemoOutline`. In `MemoDetailSidebar` (~line 48), compute `headings = useMemo(() => extractHeadings(memo.content), [memo.content])`. Before the Share section (~line 58), add conditional: `{headings.length > 0 && <SidebarSection label="Outline"><MemoOutline headings={headings} /></SidebarSection>}`.
**Validation**: `cd web && pnpm lint && pnpm build` — no errors
## Out-of-Scope Tasks
- Scroll-spy / active heading highlighting in the outline
- Mobile drawer outline support
- Outline in memo list view (compact mode)
- Changing existing heading visual styles in content area
@@ -0,0 +1,98 @@
## Background & Context
Memos has a content-blur feature: when a memo's tag list contains the literal string `NSFW`
(case-insensitive), the memo body is rendered with a `blur-lg` CSS class and a click-to-reveal
overlay is shown. This was simplified in v0.26.x from a previous admin-configurable system
(which had an on/off toggle and a custom-tag-list) down to a single hardcoded tag name.
In the same release cycle, an `InstanceTagsSetting` system was introduced that lets admins attach
metadata (currently only `background_color`) to tag name patterns via a regex-keyed map. This
system has its own proto definitions, store layer, API service handlers, frontend context, utility
library, and settings UI — all independent of the blur feature.
A sponsor raised (orgs/usememos/discussions/5708) that the hardcoded tag name is inconvenient:
users who organised their content under a different tag (e.g. a non-English word, a project-
specific label, or simply a term they prefer) must re-tag all existing memos just to use the blur
feature. Community comments echo the same concern and additionally ask for the ability to disable
the blur globally.
## Issue Statement
The memo content-blur trigger is evaluated exclusively against the hardcoded string `"NSFW"`
(case-insensitive) in `MemoView.tsx`, with no connection to the `InstanceTagsSetting` system,
making it impossible for an administrator to designate any other tag name — or set of tag name
patterns — as a blur trigger, and preventing users from re-using existing tag taxonomies to
activate content blurring.
## Current State
**Blur detection — frontend**
| File | Lines | Behaviour |
|------|-------|-----------|
| `web/src/components/MemoView/MemoView.tsx` | 2730 | `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` — single hardcoded string comparison |
| `web/src/components/MemoView/MemoViewContext.tsx` | 1619 | Context shape exposes `nsfw: boolean`, `showNSFWContent: boolean`, `toggleNsfwVisibility` |
| `web/src/components/MemoView/components/MemoBody.tsx` | 1123, 37, 53 | Applies `blur-lg transition-all duration-200` when `nsfw && !showNSFWContent`; renders `NsfwOverlay` button using i18n key `memo.click-to-show-nsfw-content` |
| `web/src/components/MemoPreview/MemoPreview.tsx` | 2427 | Stub context value: `nsfw: false`, `showNSFWContent: false` — blur never active in preview |
**Localisation strings that contain the "NSFW" term**
| File | Keys |
|------|------|
| `web/src/locales/en.json` (and ~30 other locale files) | `memo.click-to-hide-nsfw-content`, `memo.click-to-show-nsfw-content`, `settings.enable-blur-nsfw-content` |
Note: most non-English translations already use "sensitive content" rather than "NSFW" in these
keys; English is the outlier.
**Tag metadata system — proto**
| File | Lines | Content |
|------|-------|---------|
| `proto/api/v1/instance_service.proto` | 168181 | `message TagMetadata { google.type.Color background_color = 1; }` nested inside `InstanceSetting`; `TagsSetting` is a `map<string, TagMetadata>` |
| `proto/store/instance_setting.proto` | 113124 | `message InstanceTagMetadata { google.type.Color background_color = 1; }` inside `InstanceTagsSetting` |
**Tag metadata system — backend**
| File | Lines | Content |
|------|-------|---------|
| `store/instance_setting.go` | 166192 | `GetInstanceTagsSetting()` retrieves and caches the tags map |
| `server/router/api/v1/instance_service.go` | 300328 | `convertInstanceTagsSettingFromStore()` / `convertInstanceTagsSettingToStore()` convert between store and API representations, field-by-field |
| `server/router/api/v1/instance_service.go` | 387409 | `validateInstanceTagsSetting()` validates each key as a regex pattern and the color value |
**Tag metadata system — frontend**
| File | Lines | Content |
|------|-------|---------|
| `web/src/lib/tag.ts` | 2843 | `findTagMetadata(tag, tagsSetting)` — exact-match then regex-match lookup returning `TagMetadata \| undefined` |
| `web/src/components/MemoContent/Tag.tsx` | 2338 | Calls `findTagMetadata()` to apply `background_color` to inline tag chips |
| `web/src/components/Settings/TagsSection.tsx` | 36206 | Admin settings UI for managing the tag→metadata map; currently shows only a colour picker per tag |
| `web/src/contexts/InstanceContext.tsx` | 8399 | `tagsSetting` selector and fetch during app initialisation |
## Non-Goals
- Redesigning or replacing the `InstanceTagsSetting` proto or store structure beyond adding one field.
- Providing a per-user (as opposed to per-instance) blur preference.
- Changing how background-color metadata is stored, validated, or rendered.
- Adding a global on/off toggle for blurring (separate from per-tag configuration).
- Modifying the blur visual effect (CSS class, animation, overlay button layout).
- Migrating or auto-converting any existing memos that were tagged with `NSFW`.
- Changing non-English locale strings that already use neutral terminology.
## Open Questions
1. Should the `blur_content` field in tag metadata be configurable per-tag only by admins, or also by individual users via user-level tag settings? (default: admin-only, matching the existing `InstanceTagsSetting` access model)
2. When a memo has multiple tags and more than one of them has `blur_content = true`, should the blur activate if _any_ matching tag has the flag set, or only if _all_ matching tags do? (default: any — OR semantics, consistent with the current single-tag check)
3. Should there be a migration that automatically sets `blur_content = true` for any existing `InstanceTagsSetting` entry whose key is `"NSFW"` (case-insensitive)? (default: no automatic migration; admins reconfigure manually)
4. What should the English-locale i18n key strings say, given that "NSFW" is to be avoided? (default: "Click to show sensitive content" / "Click to hide sensitive content")
## Scope
**M** — the change adds one `bool` field to two existing proto messages, threads it through two
existing conversion functions in the backend, replaces one hardcoded string comparison in
`MemoView.tsx` with a call to the already-present `findTagMetadata()` utility, adds a checkbox to
the existing `TagsSection.tsx` settings UI, and renames three i18n keys. All required patterns
(field addition, conversion, `findTagMetadata` lookup, settings UI checkbox) already exist in the
codebase.
@@ -0,0 +1,63 @@
## Execution Log
### T1: Add blur_content field to proto messages
**Status**: Completed
**Files Changed**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto`
**Validation**: `buf lint` — PASS
**Path Corrections**: None
**Deviations**: None
### T2: Regenerate proto code
**Status**: Completed
**Files Changed**: `proto/gen/` (Go + OpenAPI), `web/src/types/proto/` (TypeScript)
**Validation**: `grep blur_content` in generated files — PASS (field present in Go, TS, OpenAPI)
**Path Corrections**: None
**Deviations**: None
### T3: Thread blur_content through backend conversions
**Status**: Completed
**Files Changed**: `server/router/api/v1/instance_service.go`
**Validation**: `go build ./...` — PASS
**Path Corrections**: None
**Deviations**: None
### T4: Replace hardcoded NSFW check with tag metadata lookup
**Status**: Completed
**Files Changed**: `web/src/components/MemoView/MemoView.tsx`, `web/src/components/MemoView/MemoViewContext.tsx`, `web/src/components/MemoView/components/MemoBody.tsx`, `web/src/components/MemoPreview/MemoPreview.tsx`
**Validation**: `pnpm lint` — PASS
**Path Corrections**: i18n key update (T6) was pulled forward to unblock TypeScript type checking, since the i18n key type is statically checked.
**Deviations**: None
### T5: Add blur checkbox to TagsSection settings UI
**Status**: Completed
**Files Changed**: `web/src/components/Settings/TagsSection.tsx`
**Validation**: `pnpm lint` — PASS
**Path Corrections**: None
**Deviations**: None
### T6: Update English i18n keys
**Status**: Completed
**Files Changed**: `web/src/locales/en.json`
**Validation**: `grep -c "nsfw\|NSFW" en.json` — returns 0, PASS
**Path Corrections**: Executed during T4/T5 to unblock type checking. Added `setting.tags.blur-content` key (not in original plan but required by T5's new checkbox column).
**Deviations**: None
## Completion Declaration
**All tasks completed successfully.**
Summary of changes:
- Added `bool blur_content = 2` to both API and store proto TagMetadata messages
- Regenerated Go, TypeScript, and OpenAPI code
- Threaded `blur_content` through `convertInstanceTagsSettingFromStore()` and `convertInstanceTagsSettingToStore()`
- Replaced hardcoded `tag.toUpperCase() === "NSFW"` with `findTagMetadata(tag, tagsSetting)?.blurContent` lookup
- Renamed context fields: `nsfw``blurred`, `showNSFWContent``showBlurredContent`, `toggleNsfwVisibility``toggleBlurVisibility`
- Renamed `NsfwOverlay``BlurOverlay` component
- Expanded TagsSection local state to track `{ color, blur }` per tag and added a "Blur content" checkbox column
- Updated English i18n: renamed NSFW keys to "sensitive content", removed unused key, added `blur-content` setting key
@@ -0,0 +1,89 @@
## Task List
T1: Add blur_content field to proto messages [S] — T2: Regenerate proto code [S] — T3: Thread blur_content through backend conversions [S] — T4: Replace hardcoded NSFW check with tag metadata lookup [M] — T5: Add blur checkbox to TagsSection settings UI [S] — T6: Update English i18n keys [S]
### T1: Add blur_content field to proto messages [S]
**Objective**: Add a `bool blur_content` field to both the API and store proto TagMetadata messages.
**Files**: `proto/api/v1/instance_service.proto`, `proto/store/instance_setting.proto`
**Implementation**:
- In `proto/api/v1/instance_service.proto` (~line 171), add `bool blur_content = 2;` to `message TagMetadata` after `background_color`
- In `proto/store/instance_setting.proto` (~line 115), add `bool blur_content = 2;` to `message InstanceTagMetadata` after `background_color`
**Validation**: `cd proto && buf lint` — no errors
### T2: Regenerate proto code [S]
**Objective**: Regenerate Go + TypeScript + OpenAPI from updated proto definitions.
**Files**: `proto/gen/` (generated), `web/src/types/proto/` (generated)
**Implementation**: Run `cd proto && buf generate`
**Dependencies**: T1
**Validation**: `grep -r "blur_content\|blurContent" proto/gen/ web/src/types/proto/ | head -10` — shows new field in generated Go and TS files
### T3: Thread blur_content through backend conversion functions [S]
**Objective**: Pass `blur_content` through the store↔API conversion functions so the field round-trips correctly.
**Files**: `server/router/api/v1/instance_service.go`
**Implementation**:
- In `convertInstanceTagsSettingFromStore()` (~line 306): add `BlurContent: metadata.GetBlurContent()` to the `InstanceSetting_TagMetadata` struct literal
- In `convertInstanceTagsSettingToStore()` (~line 321): add `BlurContent: metadata.GetBlurContent()` to the `InstanceTagMetadata` struct literal
**Dependencies**: T2
**Validation**: `cd /Users/steven/Projects/usememos/memos && go build ./...` — compiles without errors
### T4: Replace hardcoded NSFW check with tag metadata lookup [M]
**Objective**: Replace the hardcoded `tag.toUpperCase() === "NSFW"` check with a lookup against `InstanceTagsSetting` via the existing `findTagMetadata()` utility, so any tag with `blur_content: true` triggers the blur.
**Size**: M (3 files, moderate logic)
**Files**:
- Modify: `web/src/components/MemoView/MemoView.tsx`
- Modify: `web/src/components/MemoView/MemoViewContext.tsx`
- Modify: `web/src/components/MemoView/components/MemoBody.tsx`
- Modify: `web/src/components/MemoPreview/MemoPreview.tsx`
**Implementation**:
1. In `MemoView.tsx`:
- Import `useInstance` from `@/contexts/InstanceContext` and `findTagMetadata` from `@/lib/tag`
- Replace `const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;` with a check that iterates `memoData.tags` and uses `findTagMetadata(tag, tagsSetting)?.blurContent` — OR semantics (any match triggers blur)
- Rename state/variables: `showNSFWContent``showBlurredContent`, `nsfw``blurred`, `toggleNsfwVisibility``toggleBlurVisibility`
2. In `MemoViewContext.tsx`:
- Rename interface fields: `nsfw``blurred`, `showNSFWContent``showBlurredContent`, `toggleNsfwVisibility``toggleBlurVisibility`
3. In `MemoBody.tsx`:
- Update destructured context fields to use new names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`)
- Rename `NsfwOverlay` component to `BlurOverlay`
- Change i18n key from `memo.click-to-show-nsfw-content` to `memo.click-to-show-sensitive-content`
4. In `MemoPreview.tsx`:
- Update stub context to use new field names (`blurred`, `showBlurredContent`, `toggleBlurVisibility`)
**Boundaries**: Do NOT change blur CSS classes, animation, or overlay layout
**Dependencies**: T2
**Validation**: `cd web && pnpm lint` — no type or lint errors
### T5: Add blur checkbox to TagsSection settings UI [S]
**Objective**: Add a "Blur content" checkbox column to the tag settings table so admins can toggle `blur_content` per tag pattern.
**Files**: `web/src/components/Settings/TagsSection.tsx`
**Implementation**:
- Expand `localTags` state from `Record<string, string>` (hex only) to `Record<string, { color: string; blur: boolean }>` to track both fields
- Update `useEffect` sync, `originalHexMap` comparison, `handleColorChange`, `handleRemoveTag`, `handleAddTag` to work with the new shape
- Add a new `[newTagBlur, setNewTagBlur]` state for the add-tag row (default `false`)
- In `handleSave`, pass `blurContent` when creating `InstanceSetting_TagMetadata`
- Add a new column to `SettingTable` between "Background color" and "Actions": header `t("setting.tags.blur-content")`, renders a checkbox bound to `localTags[row.name].blur`
- Add the i18n key `setting.tags.blur-content` to `en.json` with value `"Blur content"`
**Dependencies**: T2
**Validation**: `cd web && pnpm lint` — no type or lint errors
### T6: Update English i18n keys [S]
**Objective**: Rename NSFW-specific i18n keys in `en.json` to use neutral "sensitive content" terminology.
**Files**: `web/src/locales/en.json`
**Implementation**:
- Change key `memo.click-to-show-nsfw-content``memo.click-to-show-sensitive-content` with value `"Click to show sensitive content"`
- Change key `memo.click-to-hide-nsfw-content``memo.click-to-hide-sensitive-content` with value `"Click to hide sensitive content"` (dead key but renamed for consistency)
- The key `settings.enable-blur-nsfw-content` is unused in code — remove it
**Dependencies**: T4 (key rename must match code references)
**Validation**: `grep -c "nsfw" web/src/locales/en.json` — returns `0`
## Out-of-Scope Tasks
- Updating non-English locale files (per non-goals: "Changing non-English locale strings that already use neutral terminology")
- Adding automatic migration for existing NSFW tag entries
- Per-user blur preferences
- Global on/off toggle for blurring
- Modifying blur visual effect (CSS, animation, overlay layout)
@@ -0,0 +1,41 @@
## Background & Context
User resources in Memos v1 are exposed through Connect/gRPC-Gateway handlers in `server/router/api/v1`, proto resource definitions in `proto/api/v1`, frontend profile flows in `web/src`, and MCP JSON helpers in `server/router/mcp`. The store schema already persists both an internal integer `id` and a unique `username` for each user. The GitHub issue reports that public user resource names such as `users/2` are still emitted across responses and nested user-scoped resources. Existing code already mixes identifier forms: `GetUser` accepts either `users/{id}` or `users/{username}`, the fileserver avatar route accepts either identifier, and the frontend profile page already enters the API through `users/{username}` before reusing the returned `user.name`.
## Issue Statement
Across the v1 API surface, canonical user resource names are currently constructed from `store.User.ID` rather than `store.User.Username`, and many handlers parse those emitted names back into integers for authorization and lookup. As a result, top-level user resources and nested user-scoped references in settings, stats, shortcuts, webhooks, notifications, memo creators, reactions, and MCP payloads expose sequential database IDs and couple downstream callers to integer-based user tokens in server-emitted names.
## Current State
- `store/user.go:26-42` defines `store.User` with both `ID int32` and `Username string`; `store/migration/sqlite/LATEST.sql:10-21` declares `username TEXT NOT NULL UNIQUE`.
- `server/router/api/v1/user_service.go:72-102` handles `GetUser` by extracting `users/{id_or_username}` and resolving either a numeric ID or a username; `server/router/api/v1/user_service.go:914-937` still serializes `User.name` as `users/{id}` and derives avatar URLs from that name.
- `server/router/api/v1/resource_name.go:67-89` has two different parsing paths: `ExtractUserIDFromName` only accepts numeric user tokens, while `extractUserIdentifierFromName` accepts either token and is currently only used by `GetUser`.
- `server/router/api/v1/user_service.go:335-369`, `server/router/api/v1/user_service.go:372-460`, `server/router/api/v1/user_service.go:463-517`, `server/router/api/v1/user_service.go:536-676`, `server/router/api/v1/user_service.go:679-911`, and `server/router/api/v1/user_service.go:1400-1488` parse numeric user segments for settings, personal access tokens, webhooks, and notifications, and emit names such as `users/%d/settings/...`, `users/%d/webhooks/...`, and `users/%d/notifications/%d`.
- `server/router/api/v1/shortcut_service.go:20-43` parses `users/{user}/shortcuts/{shortcut}` by converting the `user` segment to `int32`, and constructs shortcut names as `users/%d/shortcuts/%s`.
- `server/router/api/v1/user_service_stats.go:63-65`, `server/router/api/v1/user_service_stats.go:113`, `server/router/api/v1/user_service_stats.go:132-145`, `server/router/api/v1/user_service_stats.go:214-223` emit `users/%d/stats` and `users/%d/memos/%d`, and resolve stats requests through numeric `ExtractUserIDFromName`.
- `server/router/api/v1/memo_service_converter.go:26-37` serializes `Memo.creator` as `users/{id}`; `server/router/api/v1/reaction_service.go:154-164` serializes `Reaction.creator` as `users/{id}`; `server/router/api/v1/memo_service.go:636-643` and `server/router/api/v1/memo_service.go:815-845` parse `memo.Creator` through the numeric helper for inbox and webhook flows.
- `server/router/mcp/tools_memo.go:75-86`, `server/router/mcp/tools_attachment.go:29-37`, and `server/router/mcp/tools_reaction.go:64-71` plus `server/router/mcp/tools_reaction.go:133-138` serialize creator fields as `users/{id}` in MCP tool output.
- `server/router/fileserver/fileserver.go:153-181` and `server/router/fileserver/fileserver.go:533-539` currently resolve avatar requests by either numeric ID or username.
- `proto/api/v1/user_service.proto:22-29` and `proto/api/v1/user_service.proto:247-256` document `GetUser` accepting both `users/{id}` and `users/{username}`. The same proto file defines the `User` resource at `proto/api/v1/user_service.proto:161-178` and nested user resource formats at `proto/api/v1/user_service.proto:307-317` and `proto/api/v1/user_service.proto:361-373`; example text still uses numeric user tokens such as `users/123/settings/GENERAL`.
- `web/src/pages/UserProfile.tsx:74-86` requests `users/{username}` from the route param, and `web/src/layouts/MainLayout.tsx:37-48` stores the returned canonical `user.name` for later stats requests.
## Non-Goals
- Replacing internal `user.id` primary keys, foreign keys, or existing store schemas.
- Introducing a new opaque UUID-based public user identifier.
- Changing user discovery, public profile visibility, or authorization rules beyond how user resource names are parsed and emitted.
- Adding username history, redirect, or alias preservation for old usernames after a rename.
- Redesigning unrelated resource naming schemes such as memo, attachment, share, or identity-provider identifiers.
## Open Questions
- Which public surfaces are in scope for username-based canonical output? (default: all server-emitted v1 API and MCP payload fields that currently contain `users/{...}` resource names)
- Should legacy numeric inputs continue to resolve on user-scoped endpoints beyond `GetUser`? (default: no, accept only username-based user resource names)
- If a username changes, must previously emitted `users/{old-username}` names continue to resolve? (default: no additional alias or redirect layer; only the current username remains valid)
- Should notification, webhook, shortcut, and personal-access-token child identifiers keep their existing child token formats while only the parent user token changes? (default: yes)
- Does the issue include avatar URLs and other derived file paths that are built from `User.name`? (default: yes, because avatar URLs are emitted from the same canonical user name field)
## Scope
**L** — Current behavior spans `server/router/api/v1`, `server/router/mcp`, `server/router/fileserver`, `proto/api/v1`, frontend consumers in `web/src`, and the request parsers that turn user resource names back into internal IDs. Changing both emitted and accepted user resource names across those surfaces is a broad API contract change rather than a single local edit.
@@ -0,0 +1,63 @@
## References
- [AIP-122: Resource names](https://google.aip.dev/122)
- [AIP-123: Resource types](https://google.aip.dev/123)
- [AIP-148: Standard fields](https://google.aip.dev/148)
- [AIP-180: Backwards compatibility](https://google.aip.dev/180)
- [Insecure Direct Object Reference Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html)
- [REST API endpoints for users - GitHub Docs](https://docs.github.com/en/enterprise-server%403.19/rest/users/users)
- [Users API - GitLab Docs](https://docs.gitlab.com/api/users/)
- [API Usage - Gitea Documentation](https://docs.gitea.com/next/development/api-usage)
## Industry Baseline
`AIP-122: Resource names` and `AIP-148: Standard fields` treat `name` as the canonical identifier that clients store and reuse, and expect request `name` and `parent` fields to accept the same resource-name vocabulary across a service. `AIP-122` also allows aliases for lookup, but requires responses to emit the canonical resource name.
`REST API endpoints for users - GitHub Docs` and `API Usage - Gitea Documentation` use username-based public user paths and nested user-scoped routes, while keeping numeric or system-assigned identifiers as separate data or alternate endpoints when a durable internal identifier is required.
`Users API - GitLab Docs` shows a mixed-input compatibility pattern on some endpoints with `id_or_username`, which keeps older callers working while allowing username-oriented public routes.
`Insecure Direct Object Reference Prevention Cheat Sheet` treats enumerable numeric identifiers as a defense-in-depth concern, but not a substitute for authorization. Replacing `users/{id}` with `users/{username}` changes discoverability characteristics, but permission checks still have to enforce access from internal user IDs.
`AIP-180: Backwards compatibility` treats changes to resource-name format and server-generated field construction as breaking. Any design that changes emitted `User.name` values inside `v1` has to preserve as much request compatibility as possible and document the remaining response-format risk explicitly.
## Research Summary
Memos already has most of the prerequisites for username-based canonical names. The schema stores a unique username, `GetUser` already resolves either ID or username, the fileserver avatar route already uses an `identifier` abstraction, and the frontend profile page already starts from `users/{username}`. No database migration is required to identify users by username at the API boundary.
The current coupling problem is concentrated in two places. First, response builders serialize `users/{id}` in many modules, including memo conversion, stats, settings, shortcuts, notifications, webhooks, and MCP JSON helpers. Second, many request handlers assume they can parse a numeric ID back out of those names for authorization and storage lookups.
Research points to a common pattern of canonical public resource names plus server-side resolution to internal IDs. In Memos, switching the canonical token from numeric ID to username can reuse the existing unique username column and existing username lookups, but `AIP-123: Resource types` and `AIP-180: Backwards compatibility` still make clear that changing accepted and emitted resource-name formats inside `v1` is a breaking API contract change. That makes this design a deliberate contract replacement rather than a compatibility layer.
## Design Goals
- All server-emitted v1 and MCP response fields that serialize user resource names under `users/{...}` use the current username token instead of the numeric database ID.
- User-scoped request fields that reference `users/{...}` accept username-based resource names only.
- Authorization, ownership checks, inbox/webhook dispatch, and other internal workflows continue to operate on `store.User.ID` after resolving the public resource name.
- List and batch endpoints avoid introducing per-item user lookups when serializing username-based names.
- No database schema, foreign-key, or storage-key redesign is required.
## Non-Goals
- Replacing internal `user.id` primary keys, foreign keys, or existing store schemas.
- Introducing a new opaque UUID-based public user identifier.
- Changing user discovery, public profile visibility, or authorization rules beyond how user resource names are parsed and emitted.
- Adding username history, redirect, or alias preservation for old usernames after a rename.
- Redesigning unrelated resource naming schemes such as memo, attachment, share, or identity-provider identifiers.
- Adding a new API version as part of this issue.
## Proposed Design
Introduce a single canonical user-name builder in the v1 API layer that serializes `users/{username}` from resolved user data, and route every public user-name emitter through it. This includes `convertUserFromStore`, memo and reaction creator fields, user stats, settings, shortcuts, webhooks, notifications, personal-access-token names, webhook payloads, avatar URLs derived from `User.name`, and the MCP JSON helpers. This satisfies the first design goal and aligns the public resource shape with `AIP-122: Resource names`.
Introduce a shared user-token resolver in `server/router/api/v1` that extracts the `users/{token}` segment, validates it as a username-form resource token, resolves the corresponding `store.User`, and then passes the resolved internal ID into permission checks and storage lookups. This replaces numeric-only parsing in helpers such as `ExtractUserIDFromName`, `ExtractUserIDAndSettingKeyFromName`, shortcut and webhook parsers, personal-access-token deletion, and notification parsing. The fileserver's current `getUserByIdentifier` behavior shows both lookup styles exist today, but the API-layer contract for this issue becomes username-only rather than dual-mode.
Keep child resource tokens unchanged and only change the user segment. For names such as `users/{user}/settings/{setting}`, `users/{user}/webhooks/{webhook}`, `users/{user}/notifications/{notification}`, `users/{user}/shortcuts/{shortcut}`, and `users/{user}/personalAccessTokens/{token}`, the parent `user` token is resolved from the username, while the child token keeps its existing format and storage mapping. This is narrower than redesigning child identifiers and keeps the issue bounded to the user-resource segment.
Use response-side user resolution strategies that match endpoint shape. Single-resource handlers can resolve one user directly and serialize the username immediately. List and batch handlers such as memo conversion, stats aggregation, notifications, and MCP list output should collect distinct user IDs first and resolve usernames once per response, reusing the store's existing user lookup path and cache where available. This keeps username-based output from turning into hidden N+1 query behavior and satisfies the performance goal without changing persistence.
Replace the public user-resource contract rather than extending it. Server-emitted `name`, `parent`, `creator`, and `sender` fields become username-based canonical output, and handlers that currently accept `users/{id}` are updated to require `users/{username}`. `AIP-180: Backwards compatibility` indicates that changing both the construction and accepted format of an existing resource name is a breaking change for clients that persist, compare, or generate old `name` values. The design therefore requires updated proto comments, API examples, handler tests, and release notes to make the new canonical form and the removed numeric form explicit.
Do not add a username alias table in this issue. If a username changes, newly serialized resource names use the current username, and previously emitted username-based names stop resolving unless they match the current username. This keeps the scope aligned with existing `UpdateUser` behavior and avoids introducing a new subsystem for historical username resolution. The alternative of adding permanent old-username aliases was rejected because it expands the problem from canonical serialization into identity-history management.
Do not solve this by adding a second public identifier field and leaving `User.name` numeric. `AIP-122: Resource names` treats `name` as the canonical resource identifier, and the GitHub issue is specifically about the public names currently emitted under `users/{id}`. Adding a second field would preserve the exposed sequential identifier in the canonical slot and fail the primary design goal. Likewise, introducing a new opaque UUID-based public identifier was rejected because the repository already has a unique username field and the issue is scoped to replacing numeric user resource names with that existing identifier.
@@ -0,0 +1,62 @@
## Execution Log
### T1: Add username-only user resource helpers
**Status**: Completed
**Files Changed**:
- `server/router/api/v1/user_resource_name.go`
- `server/router/api/v1/resource_name.go`
- `server/router/api/v1/user_service.go`
- `server/router/api/v1/test/user_resource_name_test.go`
**Validation**: `go test -v ./server/router/api/v1/test -run 'TestUserResourceName'` — PASS
**Path Corrections**: Tightened username-token validation so numeric-only `users/1` fails at the resource-name layer instead of falling through to `NotFound`.
**Deviations**: None
### T2: Migrate user-scoped API handlers
**Status**: Completed
**Files Changed**:
- `server/router/api/v1/user_service.go`
- `server/router/api/v1/shortcut_service.go`
- `server/router/api/v1/user_service_stats.go`
- `server/router/api/v1/test/shortcut_service_test.go`
- `server/router/api/v1/test/user_service_stats_test.go`
- `server/router/api/v1/test/user_notification_test.go`
- `server/router/api/v1/test/user_service_registration_test.go`
**Validation**: `go test -v ./server/router/api/v1/test -run 'Test(ListShortcuts|GetShortcut|CreateShortcut|UpdateShortcut|DeleteShortcut|ShortcutFiltering|ShortcutCRUDComplete|GetUserStats_TagCount|ListUserNotifications|UserRegistration)'` — PASS
**Path Corrections**: Updated test fixtures to use valid username-form resource names (`users/testuser`, `users/test-user`) and corrected one stale registration-name expectation during the later broader suite rerun.
**Deviations**: None
### T3: Migrate memo, reaction, MCP, and avatar user references
**Status**: Completed
**Files Changed**:
- `server/router/api/v1/memo_service_converter.go`
- `server/router/api/v1/memo_service.go`
- `server/router/api/v1/reaction_service.go`
- `server/router/mcp/tools_memo.go`
- `server/router/mcp/tools_attachment.go`
- `server/router/mcp/tools_reaction.go`
- `server/router/fileserver/fileserver.go`
- `server/router/api/v1/test/memo_service_test.go`
- `server/router/api/v1/test/reaction_service_test.go`
**Validation**: `go test ./server/router/api/v1/... ./server/router/mcp/... ./server/router/fileserver/...` — PASS
**Path Corrections**: Removed an unused fileserver import after the first package build failed; kept MCP tool helper signatures stable for undeclared callers and switched tool call sites to username-aware wrappers.
**Deviations**: None
### T4: Update contract docs and regression tests
**Status**: Completed
**Files Changed**:
- `proto/api/v1/user_service.proto`
- `proto/api/v1/shortcut_service.proto`
- `web/src/layouts/MainLayout.tsx`
- `web/src/components/MemoExplorer/ShortcutsSection.tsx`
- `server/router/fileserver/README.md`
**Validation**: `go test -v ./server/router/api/v1/test/...` — PASS
**Path Corrections**: None
**Deviations**: None
## Completion Declaration
All tasks completed successfully
@@ -0,0 +1,106 @@
## Task List
Task Index
T1: Add username-only user resource helpers [L] — T2: Migrate user-scoped API handlers [L] — T3: Migrate memo, reaction, MCP, and avatar user references [L] — T4: Update contract docs and regression tests [L]
### T1: Add username-only user resource helpers [L]
**Objective**: Establish one v1 API mechanism for serializing `users/{username}` and resolving username-based user resource names back to internal user records, including root `GetUser` handling.
**Size**: L (multiple files, shared identifier logic used across handlers)
**Files**:
- Create: `server/router/api/v1/user_resource_name.go`
- Modify: `server/router/api/v1/resource_name.go`
- Modify: `server/router/api/v1/user_service.go`
- Test: `server/router/api/v1/test/user_resource_name_test.go`
**Implementation**:
1. In `server/router/api/v1/user_resource_name.go`: add the shared helper surface for canonical user-name construction, extracting the `users/{token}` segment, validating the username-form token, and resolving the corresponding `store.User`.
2. In `server/router/api/v1/resource_name.go`: replace `ExtractUserIDFromName()`s numeric-only behavior with username-oriented resolution helpers or thin wrappers that delegate to the new shared module.
3. In `server/router/api/v1/user_service.go`: update `GetUser()` (~lines 72-102) and `convertUserFromStore()` (~lines 914-937) to use username-only resource names and reject legacy numeric `users/{id}` requests.
4. In `server/router/api/v1/test/user_resource_name_test.go`: add direct coverage for `GetUser users/{username}` success, canonical `User.name == users/{username}`, and rejection of `users/{id}`.
**Boundaries**: Do not migrate nested user-scoped handlers, memo/reaction emitters, MCP output, or fileserver behavior in this task.
**Dependencies**: None
**Expected Outcome**: Shared username-only helper logic exists, root user resources serialize as `users/{username}`, and root numeric user-name requests fail.
**Validation**: `go test -v ./server/router/api/v1/test -run 'TestUserResourceName'` — expected output includes `PASS` and `ok`
### T2: Migrate user-scoped API handlers [L]
**Objective**: Convert user-scoped v1 handlers and nested resource emitters to require `users/{username}` while continuing to authorize and store by resolved internal user ID.
**Size**: L (multiple handlers in one large service plus shortcut and stats code)
**Files**:
- Modify: `server/router/api/v1/user_service.go`
- Modify: `server/router/api/v1/shortcut_service.go`
- Modify: `server/router/api/v1/user_service_stats.go`
- Test: `server/router/api/v1/test/shortcut_service_test.go`
- Test: `server/router/api/v1/test/user_service_stats_test.go`
- Test: `server/router/api/v1/test/user_notification_test.go`
- Test: `server/router/api/v1/test/user_service_registration_test.go`
**Implementation**:
1. In `server/router/api/v1/user_service.go`: update settings, PAT, webhook, and notification parsing/emission paths (~lines 335-911 and ~1400-1488) to resolve `users/{username}` and emit username-based parent/child resource names.
2. In `server/router/api/v1/shortcut_service.go`: update shortcut name parsing and construction (~lines 20-43) plus handler entry points to use username parents and nested names.
3. In `server/router/api/v1/user_service_stats.go`: update stats request parsing and `UserStats.name` / `PinnedMemos` serialization (~lines 63-65, 113, 132-145, 214-223) to use usernames.
4. In the listed tests: replace numeric user-name inputs with username-based parents, assert username-based emitted names, and add numeric-request rejection coverage for representative user-scoped endpoints.
**Boundaries**: Do not change memo/reaction creator fields, MCP JSON output, or fileserver avatar routing in this task.
**Dependencies**: T1
**Expected Outcome**: User settings, notifications, shortcuts, stats, PATs, and webhooks all accept only `users/{username}` and emit only username-based user resource names.
**Validation**: `go test -v ./server/router/api/v1/test -run 'Test(ListShortcuts|GetShortcut|CreateShortcut|UpdateShortcut|DeleteShortcut|ShortcutFiltering|ShortcutCRUDComplete|GetUserStats_TagCount|ListUserNotifications|UserRegistration)'` — expected output includes `PASS` and `ok`
### T3: Migrate memo, reaction, MCP, and avatar user references [L]
**Objective**: Remove numeric user resource names from memo/reaction-related API responses, dependent webhook/inbox flows, MCP JSON output, and avatar URLs/routing.
**Size**: L (cross-package serialization and lookup changes, including response-side user resolution)
**Files**:
- Modify: `server/router/api/v1/memo_service_converter.go`
- Modify: `server/router/api/v1/memo_service.go`
- Modify: `server/router/api/v1/reaction_service.go`
- Modify: `server/router/mcp/tools_memo.go`
- Modify: `server/router/mcp/tools_attachment.go`
- Modify: `server/router/mcp/tools_reaction.go`
- Modify: `server/router/fileserver/fileserver.go`
- Test: `server/router/api/v1/test/memo_service_test.go`
- Test: `server/router/api/v1/test/reaction_service_test.go`
**Implementation**:
1. In `server/router/api/v1/memo_service_converter.go`: update `convertMemoFromStore()` (~lines 16-73) to serialize `Memo.creator` from resolved usernames rather than numeric IDs, using response-side batching or shared lookup helpers so list responses do not regress into hidden per-item lookups.
2. In `server/router/api/v1/reaction_service.go`: update `convertReactionFromStore()` (~lines 154-164) to emit username-based creators.
3. In `server/router/api/v1/memo_service.go`: update memo comment, webhook dispatch, and webhook payload helpers (~lines 636-643 and 815-845) to resolve username-based memo creators before using internal IDs.
4. In `server/router/mcp/tools_memo.go`, `server/router/mcp/tools_attachment.go`, and `server/router/mcp/tools_reaction.go`: replace `users/%d` creator serialization with username-based values.
5. In `server/router/fileserver/fileserver.go`: change avatar lookup to accept username identifiers only and ensure avatar URLs derived from `User.name` continue to resolve under `users/{username}`.
6. In the listed tests: update creator assertions to `users/{username}` and add representative rejection coverage where numeric user names previously flowed through memo/reaction-related paths.
**Boundaries**: Do not update proto comments, README examples, or frontend comments in this task.
**Dependencies**: T1
**Expected Outcome**: Memo/reaction creators, webhook payload creators, MCP creator fields, and avatar-derived user paths no longer expose numeric user IDs.
**Validation**: `go test ./server/router/api/v1/... ./server/router/mcp/... ./server/router/fileserver/...` — expected output includes `ok` for all touched packages
### T4: Update contract docs and regression tests [L]
**Objective**: Align public contract comments/examples and the final regression suite with the username-only user resource-name contract.
**Size**: L (multiple contract/documentation files plus end-to-end regression coverage)
**Files**:
- Modify: `proto/api/v1/user_service.proto`
- Modify: `proto/api/v1/shortcut_service.proto`
- Modify: `web/src/layouts/MainLayout.tsx`
- Modify: `web/src/components/MemoExplorer/ShortcutsSection.tsx`
- Modify: `server/router/fileserver/README.md`
- Modify: `server/router/api/v1/test/user_resource_name_test.go`
- Modify: `server/router/api/v1/test/shortcut_service_test.go`
- Modify: `server/router/api/v1/test/user_service_stats_test.go`
- Modify: `server/router/api/v1/test/user_notification_test.go`
- Modify: `server/router/api/v1/test/memo_service_test.go`
- Modify: `server/router/api/v1/test/reaction_service_test.go`
- Modify: `server/router/api/v1/test/user_service_registration_test.go`
**Implementation**:
1. In `proto/api/v1/user_service.proto` and `proto/api/v1/shortcut_service.proto`: rewrite resource-name comments and examples so they document username-only user resource names and remove `users/{id}` examples.
2. In `web/src/layouts/MainLayout.tsx` and `web/src/components/MemoExplorer/ShortcutsSection.tsx`: update inline comments/examples that still describe numeric user resource names.
3. In `server/router/fileserver/README.md`: replace numeric avatar examples with username-based examples.
4. In the listed test files: finish any remaining request/response assertions so the suite consistently encodes the username-only contract and explicitly rejects numeric user resource names where that contract is externally visible.
**Boundaries**: Do not add schema migrations, generated proto output refreshes, or username-history behavior.
**Dependencies**: T2, T3
**Expected Outcome**: Source comments, examples, and regression tests all describe and enforce a username-only `users/{username}` public contract.
**Validation**: `go test -v ./server/router/api/v1/test/...` — expected output includes `PASS` and `ok`
## Out-of-Scope Tasks
- Database schema or migration changes for the `user` table or foreign keys.
- Username history, alias, redirect, or backward-compatibility layers.
- A new opaque public user identifier or a new API version.
- Opportunistic refactors outside the files listed above.
- Generated code refreshes (`buf generate`) unless a later approved plan revision explicitly requires schema changes.
@@ -0,0 +1,47 @@
## Background & Context
Memos is a self-hosted note-taking product whose main write path is the React memo composer in `web/src/components/MemoEditor`. Memo content is stored as Markdown text, attachments are uploaded through the v1 attachment API, and the server already has dedicated file-serving behavior for media playback. The most recent relevant change in this area was commit `63a17d89`, which refactored audio attachment rendering into reusable playback components. That change improved how audio files are displayed after upload; it did not add a microphone-driven input path inside the compose flow.
## Issue Statement
Memo creation currently starts from typed text plus file upload and metadata pickers, while audio support in the product begins only after an audio file already exists as an attachment. Users who want to capture memo content by speaking must leave the compose flow to record elsewhere, then upload or manually transcribe the result, because the editor has no direct path from microphone input to memo text or an in-progress audio attachment.
## Current State
- `web/src/components/MemoEditor/index.tsx:26-154` assembles the compose flow from `EditorContent`, `EditorMetadata`, and `EditorToolbar`, and persists drafts through `memoService.save`.
- `web/src/components/MemoEditor/Editor/index.tsx:27-214` implements the editor surface as a `<textarea>` with slash commands and tag suggestions. It has no microphone entrypoint, recording lifecycle, or transcript state.
- `web/src/components/MemoEditor/components/EditorToolbar.tsx:10-54` renders the bottom toolbar with `InsertMenu`, visibility, cancel, and save actions. There is no first-class voice action in the primary control row.
- `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx:40-189` exposes upload, link-memo, location, and focus-mode actions, and uses a hidden `<input type="file">` for attachments. It does not expose microphone capture or dictation.
- `web/src/components/MemoEditor/components/EditorContent.tsx:12-54` handles drag-and-drop and paste for binary files only, and `web/src/components/MemoEditor/hooks/useFileUpload.ts:4-33` handles file-picker selection only.
- `web/src/components/MemoEditor/state/types.ts:8-30`, `web/src/components/MemoEditor/state/actions.ts:6-78`, and `web/src/components/MemoEditor/state/reducer.ts:4-130` track memo text, metadata, local files, and loading flags. There is no state for microphone permission, recording mode, partial transcript, cleanup review, or a pending audio blob.
- `web/src/components/MemoEditor/hooks/useAutoSave.ts:4-8` saves only the current `content` string to local storage. There is no draft persistence model for an in-progress voice session.
- `web/src/components/MemoEditor/services/validationService.ts:9-30` allows save when the draft has text, saved attachments, or local files, and `web/src/components/MemoEditor/services/uploadService.ts:8-26` uploads local files to `AttachmentService`. This means the existing save path can already persist an audio blob if one is present as a `LocalFile`.
- `web/src/components/MemoEditor/types/attachment.ts:4-28` classifies editor-side files only as `image`, `video`, or `document`, so an unsaved audio recording would currently fall into the generic document path in the editor draft surface.
- `web/src/utils/attachment.ts:15-38` recognizes `audio/*`, `web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx:98-130` groups persisted attachments into visual/audio/docs sections, and `web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx:48-173` renders the dedicated audio playback card added by the last commit.
- `server/server.go:71-74` and `server/router/fileserver/fileserver.go:120-149,187-214` already treat video/audio attachments as native HTTP media streams once an attachment exists.
- `proto/api/v1/attachment_service.proto:48-90` and `server/router/api/v1/attachment_service.go:64-167` define binary attachment upload and metadata only. There is no transcription request/response shape, language hint, transcript cleanup option, or voice-session metadata in the API.
- `proto/api/v1/memo_service.proto:176-245` defines memo content as a single Markdown string plus optional attachments and relations. There is no separate speech transcript field or audio-note abstraction in the memo resource.
- `proto/api/v1/instance_service.proto:56-90` and `server/router/api/v1/instance_service.go:36-139` expose instance settings for `GENERAL`, `STORAGE`, `MEMO_RELATED`, `TAGS`, and `NOTIFICATION` only. There is no speech-provider or transcription-retention configuration surface.
- No existing implementation found for `getUserMedia`, `MediaRecorder`, browser speech recognition, or server-side transcription anywhere under `web/src`, `server`, `proto`, `plugin`, or `store`.
## Non-Goals
- Redesigning the current persisted audio attachment playback UI introduced in commit `63a17d89`.
- Building a full duplex spoken assistant or chatbot response loop inside Memos.
- Replacing the Markdown textarea editor with a different editor architecture.
- Shipping native desktop or mobile OS integrations such as global system-wide hotkeys.
- Redesigning attachment storage backends or the general file upload pipeline beyond voice-related usage.
- Adding broad AI rewrite/edit commands unrelated to capturing spoken memo text into the current draft.
## Open Questions
- Which client surfaces are in scope for the first rollout? (default: the existing React memo composer in the web app, including touch-friendly mobile-browser behavior)
- Is the first release a conversational voice mode or a dictation workflow? (default: dictation-first voice capture that inserts text into the current memo draft rather than opening a separate assistant session)
- Should Memos retain the raw recording after transcription? (default: no by default; keeping the recording is an explicit user choice that stores it as a normal attachment)
- Where does transcription execute? (default: behind a server-owned API so behavior, provider choice, and privacy copy are instance-controlled rather than browser-vendor specific)
- How much transcript cleanup is in scope? (default: punctuation plus limited filler/self-correction cleanup, with a review step before insertion)
- Does this issue include spoken edit commands such as “rewrite this shorter”? (default: no, only spoken text capture and insertion or replacement)
## Scope
**L** — The current gap spans the memo composer UI, editor state model, local file preview behavior, attachment save path, public API surface, and instance settings. There is no existing microphone or transcription implementation to extend, and a complete voice-input workflow would introduce both a new client interaction model and a new server contract rather than a single local edit.
@@ -0,0 +1,58 @@
## References
- [OpenAI Help: ChatGPT Release Notes](https://help.openai.com/en/articles/6825453-chatgpt-release-notes%3F.ejs)
- [Anthropic Support: Using voice mode on Claude mobile apps](https://support.anthropic.com/en/articles/11101966-using-voice-mode-on-claude-mobile-apps)
- [Typeless](https://www.typeless.com/)
- [Typeless FAQ](https://www.typeless.com/help/faqs)
- [DeltaCircuit/react-media-recorder README](https://github.com/DeltaCircuit/react-media-recorder/blob/master/README.md)
## Industry Baseline
ChatGPT, Claude, and Typeless all treat voice capture as a first-class entrypoint near the main compose surface rather than as a secondary attachment action. The common pattern is immediate access to the microphone, visible recording state, and explicit stop/discard control.
Those products also keep the capture loop short. The user starts recording, sees a clear recording state, and either keeps or cancels the result. Even when the product supports richer voice features, the initial interaction cost stays low.
The `react-media-recorder` reference reflects the common browser implementation pattern behind that interaction: explicit recorder states, start/stop commands, generated blob URLs, and preview playback of the recorded media. That maps well to the current Memos editor because the editor already knows how to persist local files through the attachment upload path.
## Research Summary
The current Memos composer already has the downstream path needed for recorded audio files: local draft files can be attached in the editor, `uploadService` can persist them through `AttachmentService`, and persisted audio attachments already have dedicated playback UI. What is missing is the upstream capture step inside the composer.
Given the revised scope, the right fit is not dictation or voice chat. The immediate problem is only that users cannot create an audio file from the memo composer itself. That means the smallest useful design is a browser voice recorder that produces a normal draft attachment.
Because the scope is now frontend-only, the design should not introduce new server contracts, new instance settings, or any transcription workflow. The recorded clip should flow through the existing `LocalFile -> uploadService -> attachment` path exactly like other draft files.
## Design Goals
- A user can start recording from the memo composer in one explicit action without opening the attachment menu.
- Recording state is visible and explicit: idle, requesting permission, recording, recorded, unsupported, or error.
- A completed recording can be kept as a draft audio file or discarded before memo save.
- Kept recordings reuse the existing local-file and attachment-save flow with no backend changes.
- The draft attachment surface renders recorded audio as playable audio, not as a generic document row.
- Save is blocked while recording is actively in progress, but succeeds once a recording has been stopped and kept.
## Non-Goals
- Transcribing audio to text.
- Adding any proto, store, server, or instance-settings changes.
- Building a spoken assistant or voice-chat mode.
- Redesigning persisted audio playback beyond the minimum draft preview needed for local recordings.
- Adding background recording, global hotkeys, or native device integrations.
## Proposed Design
Add a `Voice note` entry to `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx` rather than keeping a separate always-visible mic button in `web/src/components/MemoEditor/components/EditorToolbar.tsx`. This keeps the recorder close to the existing file and metadata insertion actions, reduces toolbar clutter, and still gives the user a direct path to create an audio attachment from the composer.
Introduce a `VoiceRecorderPanel` inside the memo editor layout, rendered between the editor body and the bottom metadata/toolbar area in `web/src/components/MemoEditor/index.tsx`. The panel is responsible for showing recorder state and actions, but it does not alter memo content. Its job is only to create or discard a draft audio file.
Add a dedicated `voiceRecorder` slice to `web/src/components/MemoEditor/state/types.ts` and `web/src/components/MemoEditor/state/reducer.ts`. The slice should hold support state, permission state, recorder status, elapsed time, pending error, and the most recent recorded draft clip before the user decides to keep or discard it. Keeping this state separate from `content`, `metadata`, and `localFiles` prevents recorder lifecycle state from leaking into unrelated editor behaviors.
Implement the browser media lifecycle in a dedicated hook such as `useVoiceRecorder`, following the `MediaRecorder` state pattern shown in `react-media-recorder`. The hook owns capability detection, `getUserMedia` requests, recorder start/stop, elapsed-time updates, blob assembly, and cleanup of tracks and blob URLs. The editor UI consumes only the hook output and dispatches reducer actions from it.
When the user stops a recording, convert the captured blob into a `File` and then into the existing `LocalFile` shape already used by file upload, paste, and drag-and-drop flows. The user can then either keep that `LocalFile`, which appends it to `state.localFiles`, or discard it, which revokes the blob URL and clears the recorder state. This keeps the design aligned with the existing upload path and avoids introducing a parallel attachment model.
Extend `web/src/components/MemoEditor/types/attachment.ts` so local `audio/*` files are classified as audio rather than falling back to `document`. Then update `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx` so draft audio files render with playable audio controls and normal remove behavior. This reuses the recent product investment in better audio presentation without requiring persisted attachments before preview is possible.
Update `web/src/components/MemoEditor/services/validationService.ts` so save remains allowed for normal local files and kept audio drafts, but not while `voiceRecorder.status` is actively `recording` or `requesting_permission`. That avoids saving a memo in the middle of a live recording session while preserving the existing rule that a memo may be saved with attachments and no text.
Do not introduce transcription, transcript review, speech-provider configuration, or server upload-before-save behavior in this design. Those alternatives were intentionally rejected because they expand the problem from “create an audio file quickly” into a larger speech-input subsystem. The current narrowed issue only requires fast recording and clean integration with the existing attachment flow.
@@ -0,0 +1,40 @@
## Execution Log
### T1: Add recorder state and browser capture hook
**Status**: Completed
**Files Changed**:
- `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`
- `web/src/components/MemoEditor/hooks/index.ts`
- `web/src/components/MemoEditor/state/types.ts`
- `web/src/components/MemoEditor/state/actions.ts`
- `web/src/components/MemoEditor/state/reducer.ts`
- `web/src/components/MemoEditor/services/memoService.ts`
**Validation**: `cd web && pnpm lint` — PASS
**Path Corrections**: Added `web/src/components/MemoEditor/services/memoService.ts` after plan update because `memoService.fromMemo()` also constructs `EditorState`.
**Deviations**: None after the approved plan correction.
Implemented a dedicated `voiceRecorder` editor state slice, reducer/actions for recorder lifecycle updates, a browser `MediaRecorder` hook that produces a `LocalFile` preview, and the matching `fromMemo()` defaults needed to keep the editor state shape valid for existing memo edit flows.
### T2: Add composer recorder UI and draft audio handling
**Status**: Completed
**Files Changed**:
- `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`
- `web/src/components/MemoEditor/components/EditorToolbar.tsx`
- `web/src/components/MemoEditor/components/index.ts`
- `web/src/components/MemoEditor/index.tsx`
- `web/src/components/MemoEditor/types/components.ts`
- `web/src/components/MemoEditor/types/attachment.ts`
- `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`
- `web/src/components/MemoEditor/services/validationService.ts`
- `web/src/locales/en.json`
**Validation**: `cd web && pnpm lint` — PASS
**Path Corrections**: None
**Deviations**: None
Added a `Voice note` action to the editor tool dropdown, wired the memo editor to start recording and render an inline recorder/review panel, let users keep a completed clip as a normal draft `LocalFile`, rendered local audio drafts with playable controls in the attachment editor, and blocked save only while permission is pending or recording is live.
## Completion Declaration
**Execution completed successfully** — the frontend memo composer now has a tool-dropdown voice recorder entrypoint that creates draft audio files through the existing attachment flow, with no backend or transcription changes.
@@ -0,0 +1,63 @@
## Task List
Task Index
T1: Add recorder state and browser capture hook [M] — T2: Add composer recorder UI and draft audio handling [L]
### T1: Add recorder state and browser capture hook [M]
**Objective**: Introduce the frontend-only state and hook needed to record audio in the browser and convert the finished clip into the existing `LocalFile` draft format.
**Size**: M (2-3 files, moderate logic)
**Files**:
- Create: `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`
- Modify: `web/src/components/MemoEditor/state/types.ts`
- Modify: `web/src/components/MemoEditor/state/actions.ts`
- Modify: `web/src/components/MemoEditor/state/reducer.ts`
- Modify: `web/src/components/MemoEditor/hooks/index.ts`
- Modify: `web/src/components/MemoEditor/services/memoService.ts`
**Implementation**:
1. In `web/src/components/MemoEditor/state/types.ts`, add a `voiceRecorder` state slice for recorder support, permission, status, elapsed seconds, pending error, and the latest temporary recording preview.
2. In `web/src/components/MemoEditor/state/actions.ts`, add actions for support/permission updates, recorder status changes, timer updates, temporary recording storage, and recorder reset.
3. In `web/src/components/MemoEditor/state/reducer.ts`, implement the new voice-recorder actions without changing existing content, metadata, or save behavior.
4. In new `web/src/components/MemoEditor/hooks/useVoiceRecorder.ts`, implement browser capability detection, `getUserMedia`, `MediaRecorder` setup, start/stop lifecycle, blob collection, cleanup, and conversion of the stopped recording into a `File` plus preview URL compatible with `LocalFile`.
5. In `web/src/components/MemoEditor/services/memoService.ts`, update `fromMemo()` so loaded memo state includes the new `voiceRecorder` defaults required by `EditorState`.
6. In `web/src/components/MemoEditor/hooks/index.ts`, export the new hook for editor integration.
**Boundaries**: This task must not add any toolbar/panel UI, attachment rendering updates, or transcription/network behavior.
**Dependencies**: None
**Expected Outcome**: The memo editor has a recorder state model and a reusable browser recording hook that can produce a draft audio file.
**Validation**: `cd web && pnpm lint` — expected output: TypeScript and Biome checks pass.
### T2: Add composer recorder UI and draft audio handling [L]
**Objective**: Add a voice-recorder entry inside the composer tool dropdown and make kept recordings behave like draft audio attachments in the existing save flow.
**Size**: L (multiple files, coordinated UI/state integration)
**Files**:
- Create: `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`
- Modify: `web/src/components/MemoEditor/index.tsx`
- Modify: `web/src/components/MemoEditor/components/EditorToolbar.tsx`
- Modify: `web/src/components/MemoEditor/components/index.ts`
- Modify: `web/src/components/MemoEditor/types/components.ts`
- Modify: `web/src/components/MemoEditor/types/attachment.ts`
- Modify: `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`
- Modify: `web/src/components/MemoEditor/services/validationService.ts`
- Modify: `web/src/locales/en.json`
**Implementation**:
1. In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx` and `web/src/components/MemoEditor/components/EditorToolbar.tsx`, add a `Voice note` action to the existing compose tool dropdown instead of a separate toolbar button.
2. In new `web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx`, render the recorder states `unsupported`, `idle`, `requesting_permission`, `recording`, `recorded`, and `error`, with explicit `Start`, `Stop`, `Keep`, and `Discard` actions.
3. In `web/src/components/MemoEditor/index.tsx`, render the recorder panel between editor content and the metadata/toolbar group, wire it to the editor context, and on `Keep` append the produced `LocalFile` to `state.localFiles`.
4. In `web/src/components/MemoEditor/types/attachment.ts`, classify local `audio/*` files as audio instead of generic documents.
5. In `web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx`, render local draft audio items with playable audio controls while preserving existing remove behavior and existing attachment reordering rules.
6. In `web/src/components/MemoEditor/services/validationService.ts`, block save while a recording is actively running or permission is still being requested, but continue to allow save for kept draft audio files.
7. In `web/src/components/MemoEditor/components/index.ts`, `web/src/components/MemoEditor/types/components.ts`, and `web/src/locales/en.json`, add the exports, prop types, and English labels needed for the recorder UI.
**Boundaries**: This task must not add transcription, backend/API calls, settings UI, or redesign persisted audio playback beyond local draft preview.
**Dependencies**: T1
**Expected Outcome**: A user can choose `Voice note` from the memo composer tool dropdown, record audio in the browser, keep or discard the clip, preview a kept clip as a draft audio attachment, and save it through the existing attachment upload path.
**Validation**: `cd web && pnpm lint` — expected output: TypeScript and Biome checks pass with the new recorder workflow.
## Out-of-Scope Tasks
- Any transcription or speech-to-text behavior.
- Any proto, store, server, or instance-settings changes.
- Any speech provider configuration.
- Assistant-style voice conversations or spoken edit commands.
- Full locale backfill beyond the required English copy for this feature.
@@ -0,0 +1,49 @@
## Background & Context
Memos stores memo bodies as markdown, rebuilds derived memo metadata into `MemoPayload`, exposes user notifications through the inbox model, and renders memo content in the React client with custom markdown plugins. The requested `@someone` feature spans both top-level memos and memo comments: users need to type `@`, pick a valid person, render the mention inline, and notify the mentioned user. The current product already has adjacent primitives for this work: a backend markdown extension for `#tag`, an inbox-backed notification center, a generic editor suggestion popup, public user profiles under username-based routes, and a memo update path that already rebuilds payloads on create and edit.
External product behavior is consistent on the core interaction but different on scope. Notion supports real-time `@` suggestions inside pages, comments, and discussions, stores mention notifications in an inbox, and suppresses notification if the mentioned user cannot access the content. Confluence supports autocomplete mentions for people and teams, sends a notification on the first mention, and does not keep notifying on repeated mentions in the same page. Coda supports `@` mentions inside comment threads, treats mentions and thread participation as notification triggers, and allows broader comment-subscription settings beyond explicit mentions. These patterns suggest that the common baseline for Memos is inline autocomplete, access-aware notification, deduplication, and a clear separation between mention notifications and broader thread-subscription features.
## Issue Statement
Memos does not currently recognize `@username` tokens as structured content in memo bodies or comment bodies, does not expose any non-admin user-search endpoint that the editor can use to suggest mentionable users, does not persist or diff mention metadata during memo create or update flows, and does not have an inbox or API notification type for mentions. As a result, `@someone` currently behaves as plain text and cannot drive inline rendering, target validation, or notification delivery.
## Current State
- `server/router/api/v1/memo_service.go:32-159` creates memos by copying raw `request.Memo.Content` into `store.Memo`, enforcing length limits, and calling `memopayload.RebuildMemoPayload`; `server/router/api/v1/memo_service.go:404-510` rebuilds payload only when `content` changes during memo updates.
- `server/router/api/v1/memo_service.go:590-681` creates memo comments by internally creating another memo and only generates inbox notifications for non-private comments to the parent memo creator via `InboxMessage_MEMO_COMMENT`.
- `server/router/api/v1/memo_update_helpers.go:27-77` only dispatches webhook and SSE side effects after memo updates; there is no mention-diff side-effect hook.
- `internal/markdown/markdown.go:20-24` defines extracted markdown metadata as `Tags` plus `Property`; `internal/markdown/markdown.go:68-89` only wires the custom tag extension; `internal/markdown/markdown.go:324-386` extracts tags and properties but no mention metadata.
- `internal/markdown/extensions/tag.go:13-23` and the related tag parser/AST types are the only custom inline markdown extension path today.
- `proto/store/memo.proto:7-29` limits `MemoPayload` to `property`, `location`, and `tags`; there is no repeated mention field or structured mention metadata.
- `proto/store/inbox.proto:7-24` defines only `InboxMessage_MEMO_COMMENT`; `proto/api/v1/user_service.proto:592-679` defines only `UserNotification_MEMO_COMMENT`.
- `server/router/api/v1/user_service.go:1272-1312` lists notifications by filtering inbox rows to `InboxMessage_MEMO_COMMENT` only; `server/router/api/v1/user_service.go:1433-1524` converts only that message type into API notifications.
- `web/src/pages/Inboxes.tsx:19-114` and `web/src/components/Inbox/MemoCommentMessage.tsx` only render memo comment notifications; other notification types would currently be dropped.
- `server/router/api/v1/user_service.go:32-70` exposes `ListUsers` only to admins, and `store/user.go:59-74` plus `store/db/sqlite/user.go:88-175` support exact-match user filtering but no general search, ranking, or pagination for mention autocomplete.
- `server/router/api/v1/acl_config.go:20-27` whitelists `/memos.api.v1.UserService/SearchUsers`, but `proto/api/v1/user_service.proto:16-120` does not define a `SearchUsers` RPC and there is no server implementation.
- `web/src/components/MemoEditor/Editor/index.tsx:189-214`, `web/src/components/MemoEditor/Editor/useSuggestions.ts:28-158`, and `web/src/components/MemoEditor/Editor/TagSuggestions.tsx:10-49` provide a reusable textarea suggestion popup, but it is only instantiated for `#tag`.
- `web/src/components/MemoContent/index.tsx:53-136`, `web/src/utils/remark-plugins/remark-tag.ts:24-112`, and `web/src/components/MemoContent/Tag.tsx` parse and render `#tag` as a structured inline element; there is no `remarkMention` equivalent.
- `web/src/hooks/useUserQueries.ts:176-245` has `useListUsers()` for admin listing and `useUsersByNames()` for fetching known usernames one by one, but nothing that returns ranked candidates for an in-editor `@` query.
- `web/src/router/index.tsx:65-72` already routes public user profiles at `u/:username`, so inline mention rendering can target username-based profile URLs without inventing a new frontend route.
## Non-Goals
- Adding group mentions, team mentions, page mentions, or date mentions.
- Building a general “watch this memo/thread” subscription system beyond explicit mentions.
- Adding email, push, Slack, or webhook delivery for mentions in this issue.
- Redesigning memo visibility, access control, or per-user sharing semantics.
- Making old mentions follow username changes automatically.
- Redesigning the editor away from the current textarea-based implementation.
## Open Questions
- Which content surfaces are in scope for `@mention`? (default: top-level memos and memo comments, because both already share the same memo content pipeline)
- What mention token syntax should be recognized? (default: `@username` only, using canonical usernames rather than display names)
- Should edits trigger mention notifications after the initial create? (default: yes, but only for newly added mention targets compared with the memos previous mention set)
- What happens if someone types `@username` in content the target cannot access? (default: render the token as a mention in the authors view, but do not send a notification unless the target can already access the memo/comment under existing visibility rules)
- Should mentioning yourself create an inbox item? (default: no, because self-mentions do not require attention routing)
- Should the mention candidate API be public like `GetUser`, or authenticated like the editor? (default: authenticated only, because ranked user search is a broader directory-enumeration surface than fetching a known public profile)
## Scope
**L** — The work crosses markdown parsing, memo payload extraction, memo create/update side effects, inbox and notification protos, user search APIs, three SQL drivers, React editor autocomplete, markdown rendering, and inbox UI. The repository already contains adjacent pieces for tags and comment notifications, but `@mention` requires stitching several existing subsystems together rather than extending a single isolated module.
@@ -0,0 +1,69 @@
## References
- [Comments, mentions & reactions - Notion Help Center](https://www.notion.com/help/comments-mentions-and-reminders)
- [Notification settings - Notion Help Center](https://www.notion.com/help/notification-settings)
- [Mention a person or team - Confluence Cloud](https://support.atlassian.com/confluence-cloud/docs/mention-a-person-or-team/)
- [Comment on Coda docs - Coda Help](https://help.coda.io/hc/en-us/articles/39555917053069-Comment-on-Coda-docs)
- [Customize notifications from comments - Coda Help](https://help.coda.io/hc/en-us/articles/39555901119117-Customize-notifications-from-comments)
## Industry Baseline
`Comments, mentions & reactions - Notion Help Center` shows the most common editor-side behavior: typing `@` triggers real-time search, mentions can live inline in page bodies and comments, clicking an inbox item takes the user back to the exact context, and no notification is sent when the target cannot access the page. `Notification settings - Notion Help Center` also separates in-product inbox behavior from secondary delivery like desktop or email.
`Mention a person or team - Confluence Cloud` adds two useful guardrails for a collaborative editor: autocomplete suggestions appear directly from `@`, and notifications are intentionally deduplicated so people are notified on the first mention rather than on every repeated mention in the same page.
`Comment on Coda docs` and `Customize notifications from comments` show a narrower scope for mentions inside comments, but reinforce two patterns that matter for Memos: explicit `@` mentions are a distinct notification trigger from generic participation, and products often keep mention notifications separate from broader thread-subscription or owner-subscription rules.
Across these products, the default implementation is not “parse arbitrary display text and hope it matches a user.” The stable interaction is: search among valid workspace members, insert a canonical mention token, render it differently from plain text, and only notify when access and deduplication rules say the event is meaningful.
## Research Summary
Memos already has the right extension points to adopt that baseline without a storage redesign. The backend has a custom inline markdown extension pipeline for `#tag`, memo create and update both rebuild `MemoPayload`, and the inbox model already represents user-facing attention items. The frontend editor already has a trigger-character suggestion popup, the markdown renderer already recognizes custom inline nodes, and public user profiles are already routed by username.
The biggest mismatch is user discovery. The current `ListUsers` path is admin-only and exact-match oriented, while mention autocomplete needs a normal authenticated user search API that can return ranked candidates by username and display name. The second mismatch is notification shape: the inbox and API layers only understand memo-comment notifications today, so a mention feature cannot be expressed as a first-class notification without extending the inbox proto and inbox UI.
Research also suggests that Memos should stay narrower than Notion or Confluence. There is no existing concept of teams, group mentions, page mentions, or per-page ACLs. The codebase already treats usernames as the public user token and memo visibility as a coarse `PUBLIC/PROTECTED/PRIVATE` rule. The best fit is therefore person mentions only, keyed by canonical username, with notification rules that are access-aware and deduplicated across repeated edits.
## Design Goals
- Typing `@` in the memo editor or comment editor shows ranked, authenticated user candidates and inserts a canonical `@username` token on selection.
- The backend extracts mention targets from memo/comment content during create, update, and payload rebuild, and produces the same mention set for equivalent content across all supported databases.
- Mention notifications are created only for newly added targets, at most once per target per memo revision, and never for self-mentions or inaccessible private content.
- Memo content renders resolved mentions as interactive inline entities and degrades unresolved tokens to plain text.
- The inbox API and inbox UI expose mention notifications as a first-class type distinct from comment notifications.
- The design does not require a relational schema migration; it only extends existing proto-backed JSON payloads and server/frontend code paths.
## Non-Goals
- Adding group mentions, team mentions, page mentions, or date mentions.
- Building a generic watch/subscription system for memo activity.
- Sending mention notifications through email, push, Slack, or webhooks.
- Making mention references survive username changes automatically.
- Replacing the textarea editor with a richer block editor.
- Redesigning memo visibility or introducing user-level memo sharing.
## Proposed Design
Support only canonical `@username` mentions in this issue. The parser should recognize the same username token vocabulary that the API already accepts for public user names, instead of trying to match display names or arbitrary free text. This keeps mention authoring aligned with existing user resource naming and avoids ambiguous matches when multiple users share similar display names. Mention suggestions may show both display name and username, but the inserted source text remains `@username`.
Add a backend markdown mention extension parallel to the existing tag extension. Introduce `internal/markdown/ast.MentionNode`, `internal/markdown/parser.NewMentionParser()`, and `internal/markdown/extensions.MentionExtension`, then wire it into `internal/markdown/markdown.go` next to `TagExtension`. The mention parser should require a word boundary before `@` so email addresses and URLs do not become mentions, and it should normalize the captured token to lowercase before lookup because usernames are canonicalized that way in the API layer.
Extend `storepb.MemoPayload` with a repeated mention metadata field, for example `repeated Mention mentions`, where each item stores at least `username` and resolved `user_id`. The raw markdown remains the source of truth for author-visible text, but the payload becomes the normalized server-side mention set for diffing and notification decisions. This reuses the existing memo payload rebuild path and avoids reparsing memo bodies in multiple side-effect handlers. No SQL migration is required because memo payloads are already stored as proto-backed JSON blobs in each database driver.
Teach `memopayload.RebuildMemoPayload` to resolve mention metadata while rebuilding tags and properties. The extraction step should walk the markdown AST once, collect raw `@username` tokens, resolve them to active users via the store, deduplicate by `user_id`, and populate `memo.Payload.Mentions`. Unresolved usernames should not fail memo creation; they should simply be omitted from normalized mention metadata so the feature remains tolerant of free-typed text. This mirrors how the frontend can degrade unresolved tokens back to plain text.
Add a dedicated mention side-effect helper around memo create and update flows. On create, after the memo is persisted and the final payload is available, compute the normalized mentioned user set from `memo.Payload.Mentions` and create inbox items for allowed targets. On update, diff the previous and new normalized mention sets and only notify targets that were newly added in the latest saved revision. This follows the Confluence-style deduplication pattern and prevents repeated notifications when a memo is edited without changing its mention set. If a mention is removed and later re-added, it counts as newly added again and may generate a fresh inbox item.
Apply access and duplication rules before writing inbox rows. Self-mentions are ignored. For top-level memos, notify only when the target can already read the memo under current visibility rules. For comments, notify the mentioned user when they can read the comment context and are not already covered by the existing memo-comment notification to the parent memo owner for that same event. This keeps mention notifications meaningful and avoids sending an owner both a comment notification and a mention notification for the same comment creation unless future product requirements explicitly want both. For `PRIVATE` memos and `PRIVATE` comments, mentions remain author-visible text but do not generate inbox notifications for other users.
Extend inbox storage and API notifications with a dedicated mention type instead of overloading the existing comment type. Add `MEMO_MENTION` to `proto/store/inbox.proto` with a payload that can represent both top-level memos and comments, such as `memo_id` plus optional `related_memo_id`. Mirror that in `proto/api/v1/user_service.proto` with `UserNotification_MEMO_MENTION` and `MemoMentionPayload`. Reuse the current notification conversion pattern in `server/router/api/v1/user_service.go`: resolve memo names from stored IDs, return a first-class mention payload, and let the inbox page render a separate mention card component. This keeps the notification center composable as new activity types appear.
Add an authenticated user-search endpoint specifically for mention autocomplete. The repository already has a stale public-method placeholder for `SearchUsers`, but no proto or handler. Define `SearchUsers` in `proto/api/v1/user_service.proto`, remove it from the public ACL list, and implement it in `server/router/api/v1/user_service.go` as an authenticated RPC that accepts a short query string plus page size. Extend `store.FindUser` with search-oriented fields and implement driver-specific case-insensitive matching in SQLite, MySQL, and PostgreSQL over `username` and `nickname`, ordered by exact username match, username prefix, nickname prefix, then a stable fallback. This produces a usable editor candidate list without reusing the admin-only `ListUsers` contract.
Implement frontend mention suggestions by reusing the existing generic textarea suggestion system. Add a `MentionSuggestions` component beside `TagSuggestions`, hook it into `web/src/components/MemoEditor/Editor/index.tsx`, and back it with a debounced `useSearchUsers(query)` hook. The popup should render avatar, display name, and `@username`, while selection inserts `@username ` exactly. Because `useSuggestions` currently operates on local item arrays, it can stay generic if the mention hook owns the remote query and passes the current ranked results down as `items`.
Implement frontend mention rendering with a dedicated markdown plugin and component instead of trying to infer mentions from links or plain spans. Add `remarkMention` beside `remarkTag`, a `Mention` inline component beside `Tag`, and a mention type guard in `web/src/types/markdown.ts`. The renderer should link resolved mentions to `/u/:username`, show display name or username with avatar-based affordance when lookup data is available, and render unresolved mention text non-interactively. To avoid N-per-mention network fetches, `MemoContent` should collect mentioned usernames from content and hydrate them through the existing `useUsersByNames()` hook once per memo render tree.
Render mention notifications as their own inbox card. Reuse the existing `MemoCommentMessage` pattern, but resolve the source memo/comment and optional related memo from the `MemoMentionPayload`. The card should show who mentioned the user, in what memo or comment, a short snippet, and navigate to the relevant memo detail on click. `web/src/pages/Inboxes.tsx` should switch on both `MEMO_COMMENT` and `MEMO_MENTION` so the inbox can grow by type without silently discarding new notifications.
Do not solve username drift in this issue. If a user later changes username, existing raw markdown still contains the old `@username` text, and rebuilt payload metadata will stop resolving unless the old token still matches a live username. This is acceptable for the current scope because username-history and alias resolution are already out of scope elsewhere in the codebase. The alternative of storing opaque mention IDs in source markdown or adding a username-alias subsystem was rejected because it turns a contained collaboration feature into a broader identity migration project.
@@ -0,0 +1,37 @@
## Execution Log
### T1: Add backend mention parsing and payload extraction
**Status**: Completed
**Files Changed**: `internal/markdown/ast/mention.go`, `internal/markdown/parser/mention.go`, `internal/markdown/extensions/mention.go`, `internal/markdown/markdown.go`, `internal/markdown/renderer/markdown_renderer.go`, `server/runner/memopayload/runner.go`, `server/router/api/v1/memo_service.go`, `server/router/api/v1/v1.go`, `server/router/api/v1/test/test_helper.go`, `internal/markdown/markdown_test.go`
**Validation**: `go test ./internal/markdown` — PASS
**Path Corrections**: `RebuildMemoPayload` needed `context + store` so mention resolution could happen during payload rebuild.
**Deviations**: None
### T2: Add mention notifications and user search APIs
**Status**: Completed
**Files Changed**: `proto/store/memo.proto`, `proto/store/inbox.proto`, `proto/api/v1/user_service.proto`, `server/router/api/v1/user_service.go`, `server/router/api/v1/connect_services.go`, `server/router/api/v1/acl_config.go`, `server/router/api/v1/acl_config_test.go`, `server/router/api/v1/memo_mention_helpers.go`, `store/user.go`, `store/db/sqlite/user.go`, `store/db/postgres/user.go`, `store/db/mysql/user.go`, `server/router/api/v1/test/user_notification_test.go`, `server/router/api/v1/test/user_search_test.go`
**Validation**: `go test ./server/router/api/v1/...` — PASS
**Path Corrections**: Unknown legacy inbox message types are filtered server-side to keep unread counts aligned with rendered cards.
**Deviations**: None
### T3: Add frontend mention autocomplete, rendering, and inbox UI
**Status**: Completed
**Files Changed**: `web/src/components/MemoEditor/Editor/MentionSuggestions.tsx`, `web/src/components/MemoEditor/Editor/index.tsx`, `web/src/components/MemoEditor/Editor/useSuggestions.ts`, `web/src/hooks/useUserQueries.ts`, `web/src/utils/remark-plugins/remark-mention.ts`, `web/src/components/MemoContent/MentionContext.tsx`, `web/src/components/MemoContent/Mention.tsx`, `web/src/components/MemoContent/index.tsx`, `web/src/components/MemoContent/ConditionalComponent.tsx`, `web/src/types/markdown.ts`, `web/src/components/Inbox/MemoMentionMessage.tsx`, `web/src/pages/Inboxes.tsx`
**Validation**: `pnpm lint && pnpm build` — PASS
**Path Corrections**: Editor autocomplete reused the existing generic suggestion hook by exposing the live query rather than duplicating keyboard navigation logic.
**Deviations**: None
### T4: Regenerate code and validate the feature
**Status**: Completed
**Files Changed**: `proto/gen/**`, `web/src/types/proto/**`
**Validation**: `buf generate` — PASS; `go test ./internal/markdown ./server/router/api/v1/...` — PASS; `pnpm lint` — PASS; `pnpm build` — PASS
**Path Corrections**: None
**Deviations**: None
## Completion Declaration
All tasks completed successfully
+104
View File
@@ -0,0 +1,104 @@
## Task List
### Task Index
T1: Add backend mention parsing and payload extraction [M] — T2: Add mention notifications and user search APIs [L] — T3: Add frontend mention autocomplete, rendering, and inbox UI [L] — T4: Regenerate code and validate the feature [M]
### T1: Add backend mention parsing and payload extraction [M]
**Objective**: Parse `@username` tokens into structured mention metadata during memo payload rebuilds.
**Size**: M
**Files**:
- Create: `internal/markdown/ast/mention.go`
- Create: `internal/markdown/parser/mention.go`
- Create: `internal/markdown/extensions/mention.go`
- Modify: `internal/markdown/markdown.go`
- Modify: `internal/markdown/renderer/markdown_renderer.go`
- Modify: `server/runner/memopayload/runner.go`
- Modify: `server/router/api/v1/memo_service.go`
- Test: `internal/markdown/markdown_test.go`
**Implementation**:
1. Add mention AST/parser/extension parallel to the existing tag implementation.
2. Extend extracted markdown data and `MemoPayload` rebuild to collect normalized mentions and resolve them to users.
3. Update memo create/update and background payload rebuild paths to use the new mention-aware payload builder.
**Boundaries**: Do not add a relational schema migration.
**Dependencies**: None
**Expected Outcome**: Memo payloads carry normalized mention metadata rebuilt from markdown content.
**Validation**: `go test ./internal/markdown` — expected `ok`
### T2: Add mention notifications and user search APIs [L]
**Objective**: Expose mention-aware APIs and create inbox items for newly added mentions.
**Size**: L
**Files**:
- Modify: `proto/store/memo.proto`
- Modify: `proto/store/inbox.proto`
- Modify: `proto/api/v1/user_service.proto`
- Modify: `server/router/api/v1/user_service.go`
- Modify: `server/router/api/v1/connect_services.go`
- Modify: `server/router/api/v1/acl_config.go`
- Modify: `server/router/api/v1/acl_config_test.go`
- Create: `server/router/api/v1/memo_mention_helpers.go`
- Modify: `store/user.go`
- Modify: `store/db/sqlite/user.go`
- Modify: `store/db/postgres/user.go`
- Modify: `store/db/mysql/user.go`
- Test: `server/router/api/v1/test/user_notification_test.go`
- Test: `server/router/api/v1/test/user_search_test.go`
**Implementation**:
1. Extend proto contracts with `MemoPayload.mentions`, `InboxMessage.MEMO_MENTION`, `UserNotification.MEMO_MENTION`, and `SearchUsers`.
2. Implement authenticated user search over username and nickname.
3. Add mention notification side effects for memo create/update/comment flows with diffing and duplicate suppression.
4. Convert inbox rows into either comment or mention notifications and filter unknown legacy types.
**Boundaries**: Do not add email/push/webhook mention delivery.
**Dependencies**: T1
**Expected Outcome**: Mentioned users receive inbox notifications and the editor has an API to fetch mention candidates.
**Validation**: `go test ./server/router/api/v1/...` — expected `ok`
### T3: Add frontend mention autocomplete, rendering, and inbox UI [L]
**Objective**: Let users insert mentions from the editor and render/read them in the UI.
**Size**: L
**Files**:
- Create: `web/src/components/MemoEditor/Editor/MentionSuggestions.tsx`
- Modify: `web/src/components/MemoEditor/Editor/index.tsx`
- Modify: `web/src/components/MemoEditor/Editor/useSuggestions.ts`
- Modify: `web/src/hooks/useUserQueries.ts`
- Create: `web/src/utils/remark-plugins/remark-mention.ts`
- Create: `web/src/components/MemoContent/MentionContext.tsx`
- Create: `web/src/components/MemoContent/Mention.tsx`
- Modify: `web/src/components/MemoContent/index.tsx`
- Modify: `web/src/components/MemoContent/ConditionalComponent.tsx`
- Modify: `web/src/types/markdown.ts`
- Create: `web/src/components/Inbox/MemoMentionMessage.tsx`
- Modify: `web/src/pages/Inboxes.tsx`
**Implementation**:
1. Add `@` autocomplete backed by `SearchUsers`.
2. Add markdown mention parsing/rendering and hydrate mentioned users once per memo render.
3. Add a dedicated inbox card for memo mention notifications.
**Boundaries**: Do not redesign the textarea editor.
**Dependencies**: T2
**Expected Outcome**: Users can insert, see, and open mentions from memo content and inbox notifications.
**Validation**: `pnpm lint && pnpm build` — expected success
### T4: Regenerate code and validate the feature [M]
**Objective**: Regenerate generated code and verify backend/frontend behavior.
**Size**: M
**Files**:
- Modify: `proto/gen/**`
- Modify: `web/src/types/proto/**`
**Implementation**:
1. Run `buf generate` after proto changes.
2. Re-run focused Go tests and frontend lint/build.
**Boundaries**: Do not broaden into unrelated CI cleanup.
**Dependencies**: T1, T2, T3
**Expected Outcome**: Generated code matches the new APIs and validations pass.
**Validation**: `buf generate`, `go test ./internal/markdown ./server/router/api/v1/...`, `pnpm lint`, `pnpm build`
## Out-of-Scope Tasks
- Group/team mentions
- Username alias migration
- Email or push delivery for mentions
- Watch/subscription semantics beyond explicit mentions
@@ -0,0 +1,70 @@
## Background & Context
SSO sign-in in memos currently treats the IdP-provided identifier as the local username. The identifier value comes from the OAuth2 UserInfo claim named in `FieldMapping.identifier`, while local usernames are validated by `validateUsername` against `base.UIDMatcher`. Real IdPs frequently emit identifiers such as email addresses, opaque subject IDs, or provider-specific account IDs that are valid authentication subjects but are not valid memos usernames.
The existing issue artifacts under `docs/plans/2026-04-21-sso-user-identity-linkage/` already scope a persistent linkage between SSO identities and local users. A broader review of upstream open source schemas now shows that similar systems converge on separating external identity from the local user row, but do not converge on one universal table name or one exact column set. That difference matters because the implementation problem is narrower than "copy one upstream schema exactly" and broader than "pick any new table name locally."
## Issue Statement
The SSO sign-in path in `server/router/api/v1/auth_service.go` resolves and creates users from `userInfo.Identifier` through `User.Username`, and no provider-scoped external identity record exists to resolve a local user independently of that username; as a result, provider-issued identifiers that are valid authentication subjects but invalid memos usernames fail the sign-in path, and the future persistence model still requires an explicit schema decision among several verified upstream identity-link patterns.
## Current State
**Sign-in path**`server/router/api/v1/auth_service.go:124-173`
- Lines 124-132 apply `identifier_filter` to `userInfo.Identifier`.
- Lines 135-137 call `GetUser(FindUser{Username: &userInfo.Identifier})`.
- Lines 151-159 create a new `store.User` with `Username: userInfo.Identifier`.
- No persistent linkage record is read or written during SSO sign-in.
**Username validation**`server/router/api/v1/user_resource_name.go:33-38`
- `validateUsername` rejects empty strings, fully numeric usernames, and values that fail `base.UIDMatcher`.
- `base.UIDMatcher` is defined in `internal/base/resource_name.go:5-6` as `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$`.
**User model**`store/user.go:26-77`
- `store.User` contains `Username`, `Email`, `Nickname`, `AvatarURL`, and other local account fields.
- `store.FindUser` supports lookup by `ID`, `Username`, `Email`, and related filters.
- No external identity field exists on the user model.
**Current database schema**`store/migration/sqlite/LATEST.sql:9-79`
- `user` table (`lines 10-22`) stores `username` as a unique column and has no external identity column.
- `idp` table (`lines 72-79`) stores `uid` as the stable identifier for an IdP instance.
- The latest checked-in migration version is `0.27` under all three backends (`store/migration/sqlite/0.27`, `store/migration/postgres/0.27`, `store/migration/mysql/0.27`).
**IdP user info mapping**`internal/idp/idp.go:3-8`, `internal/idp/oauth2/oauth2.go:105-129`
- `IdentityProviderUserInfo` carries `Identifier`, `DisplayName`, `Email`, and `AvatarURL`.
- `Identifier` is loaded from the configured claim and is required to be non-empty.
- `DisplayName` falls back to `Identifier` when not mapped.
**No existing linkage persistence**
- No `identity`, `identities`, `user_identity`, `external_login_user`, or similar structure exists anywhere under `store/`.
## Non-Goals
- Changing `UIDMatcher` or `validateUsername`.
- Changing how `FieldMapping` maps OAuth2 claims into `IdentityProviderUserInfo`.
- Changing how password-based local sign-in works.
- Changing `identifier_filter` behavior.
- Supporting IdP types other than `OAUTH2` in this issue.
- Providing UI or API surfaces for linking or unlinking external identities.
- Migrating or renaming existing usernames already stored in `user`.
- Automatically linking pre-existing users whose current `User.Username` happens to match an IdP identifier.
- Adding SCIM, directory sync, or per-group / multi-tenant IdP scoping.
- Storing provider access tokens or profile payloads unless a live memos code path requires them.
## Open Questions
1. When a new SSO user's `userInfo.Identifier` does not yield a valid username, what value is used as the initial `User.Username`? (default: derive from `DisplayName`, then `Email`, then `Identifier`, normalizing to a valid username and retrying with a short suffix on collision)
2. Should an existing local user be linkable to an SSO identity after registration? (default: no — out of scope for this issue)
3. Should one local user be linkable to multiple external identities across different IdP instances? (default: yes — allow multiple rows per `user_id`, one per provider-scoped external identifier)
4. What schema vocabulary should represent the provider-scoped external identity record? (default: use table `user_identity` to match current memos table naming, with `provider` and `extern_uid` as the stored linkage fields)
5. Should the linkage schema store only lookup fields or also provider metadata such as tokens and raw profile data? (default: lookup fields only for this issue)
6. Should the linkage table be added across SQLite, PostgreSQL, and MySQL? (default: yes — mirror the existing migration strategy across all supported backends)
## Scope
**L** — the work still spans a new persistence structure across three database backends, store-layer types and driver implementations, sign-in path changes, username derivation behavior, and now an explicit design choice among several verified upstream schema patterns rather than a single assumed naming scheme.
@@ -0,0 +1,173 @@
## References
1. **GitLab `db/init_structure.sql` — `identities` table** (verified)
https://gitlab.com/gitlab-org/gitlab/-/raw/master/db/init_structure.sql?ref_type=heads
GitLab stores external identities in a separate `identities` table with fields including `extern_uid`, `provider`, `user_id`, `created_at`, and `updated_at`, plus provider-specific extensions such as `saml_provider_id`.
2. **Gitea `models/user/external_login_user.go` — `ExternalLoginUser`** (verified)
https://raw.githubusercontent.com/go-gitea/gitea/main/models/user/external_login_user.go
Gitea persists external account links separately from the user row. The core linkage fields are `ExternalID`, `LoginSourceID`, and `UserID`, with optional provider metadata and tokens.
3. **Discourse `app/models/user_associated_account.rb` — `user_associated_accounts`** (verified)
https://raw.githubusercontent.com/discourse/discourse/main/app/models/user_associated_account.rb
Discourse uses a dedicated association table with `provider_name`, `provider_uid`, `user_id`, timestamps, and JSONB metadata. It enforces uniqueness on both `(provider_name, provider_uid)` and `(provider_name, user_id)`.
4. **Keycloak `FederatedIdentityEntity.java` — `FEDERATED_IDENTITY`** (verified)
https://raw.githubusercontent.com/keycloak/keycloak/main/model/jpa/src/main/java/org/keycloak/models/jpa/entities/FederatedIdentityEntity.java
Keycloak models brokered identities separately with `USER_ID`, `IDENTITY_PROVIDER`, `FEDERATED_USER_ID`, `FEDERATED_USERNAME`, `REALM_ID`, and `TOKEN`, using provider-scoped lookup queries rather than local username lookup.
5. **Immich `user.table.ts` and initial migration — `users.oauthId`** (verified)
https://raw.githubusercontent.com/immich-app/immich/main/server/src/schema/tables/user.table.ts
https://raw.githubusercontent.com/immich-app/immich/main/server/src/schema/migrations/1744910873969-InitialMigration.ts
Immich stores a single `oauthId` column directly on the `users` table instead of a separate linkage table.
6. **Mattermost `server/public/model/user.go` — `AuthData` and `AuthService` on `User`** (verified)
https://raw.githubusercontent.com/mattermost/mattermost/master/server/public/model/user.go
Mattermost stores external-auth linkage directly on the user model through `AuthData` and `AuthService`.
## Industry Baseline
There is no single universal table name for SSO identity linkage across open source systems. The consistent pattern is structural rather than nominal: systems that support multiple external providers or explicit account linking separate the external identity from the local username and from the core user row.
Three recurring schema families appear in the references:
- **Provider-scoped identity-link table**. GitLab `identities` and Gitea `ExternalLoginUser` both persist a provider discriminator, an external identifier, and a local `user_id` in a separate structure. This is the most direct fit when one local user may be linked to multiple IdPs.
- **Association table with richer metadata**. Discourse `user_associated_accounts` uses the same provider-plus-external-id pattern, but also stores provider payloads and enforces one account per provider per user.
- **User-row coupling**. Immich and Mattermost place the external identifier on the user row itself. This minimizes schema surface area, but it ties the user record to one external binding shape and reduces flexibility when multiple providers or multiple linked identities are in scope.
Keycloak's `FEDERATED_IDENTITY` falls in the separate-table family as well, but introduces an additional realm scope and stores broker-specific metadata because Keycloak is itself an identity broker rather than an application with a local username model.
Known trade-offs:
- Separate tables add one extra lookup during sign-in compared with `GetUser(Username)`, but they avoid coupling authentication subjects to user-facing usernames.
- Richer association tables support future linking, unlinking, and metadata sync, but they increase write paths and retention of provider data.
- User-row columns are simpler for one-provider or one-link systems, but they do not fit a product that already supports multiple configured IdP instances.
## Research Summary
- GitLab, Gitea, Discourse, and Keycloak all separate the external identity record from the local username.
- The recurring core fields are a **provider scope**, an **external subject/UID**, and a **local user ID**. Exact names vary: `provider`, `login_source_id`, `provider_name`, `identity_provider`, `extern_uid`, `external_id`, `provider_uid`, `federated_user_id`.
- Table naming is not standardized across projects. `identities` is common and recognizable, but `external_login_user`, `user_associated_accounts`, and `FEDERATED_IDENTITY` are also established patterns.
- Single-column user-row designs such as Immich `oauthId` and Mattermost `AuthData` / `AuthService` are simpler, but they align with products that bind one external identity shape directly to the user row rather than a product that already has multiple `idp` records.
- For memos, the verified references support a provider-scoped link table and do not support continuing to use `User.Username` as the external identity lookup key. They also suggest that `provider` and `extern_uid` are clearer linkage-field names than repo-specific names like `idp_id`, even if the table name itself follows memos naming conventions.
## Design Goals
1. **G1 — Username-independent lookup**: SSO sign-in resolves an existing user without requiring `userInfo.Identifier` to pass `validateUsername`. Verifiable: a sign-in flow with `Identifier = "jane@example.com"` or an opaque subject string reaches user lookup through the linkage table, not through `FindUser{Username: ...}`.
2. **G2 — Provider-scoped uniqueness**: Two configured IdP instances may issue the same external identifier string without colliding. Verifiable: uniqueness is enforced on the pair `(provider, extern_uid)`, not on `extern_uid` alone.
3. **G3 — No new external-auth column on `user`**: The local `user` table remains a local account record rather than the storage site for external identity subjects. Verifiable: migration files do not add `oauth_id`, `auth_data`, `provider_uid`, or similar columns to `user`.
4. **G4 — Repo-aligned table naming with reference-aligned linkage fields**: The new linkage schema follows memos table naming conventions while using linkage field names that map directly to upstream patterns. Verifiable: the design uses table `user_identity` together with fields `provider` and `extern_uid`.
5. **G5 — Minimal persistence surface for this issue**: The linkage record stores lookup fields required by sign-in and excludes provider payloads that no current memos code path reads. Verifiable: the initial schema contains no token, JSON payload, or raw-profile columns.
## Non-Goals
All non-goals from `definition.md` apply. Additionally:
- Matching GitLab, Gitea, Discourse, or Keycloak field-for-field beyond the linkage concepts required by memos.
- Adding SCIM-, SAML-, or realm-specific columns such as Keycloak `REALM_ID` or GitLab `saml_provider_id`.
- Enforcing a one-provider-per-user rule like Discourse's `(provider_name, user_id)` uniqueness.
- Storing provider tokens, raw payloads, or synchronization metadata in the initial linkage table.
## Proposed Design
### 1. Use a separate `user_identity` table
Adopt the separate-table family used by GitLab `identities`, Gitea `ExternalLoginUser`, Discourse `user_associated_accounts`, and Keycloak `FEDERATED_IDENTITY`, while keeping the stored fields limited to what memos currently needs for sign-in (G1, G2, G3, G5). The table name should follow memos' existing singular / relationship-style naming pattern rather than copying GitLab's pluralized table name directly (G4).
Proposed logical schema:
```sql
CREATE TABLE user_identity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
provider TEXT NOT NULL, -- stores idp.uid
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)
);
CREATE INDEX user_identity_user_id_idx ON user_identity(user_id);
```
Naming decisions:
- **`user_identity`** follows the repo's current table naming pattern. Existing memos tables are singular or singular relationship tables such as `user`, `memo`, `memo_relation`, `memo_share`, and `idp`, so `user_identity` is more consistent locally than GitLab's plural `identities` (G4).
- **`provider`** is the stored `idp.uid`. This maps directly to GitLab `provider`, Keycloak `IDENTITY_PROVIDER`, Discourse `provider_name`, and Gitea's effective provider scope via `LoginSourceID`.
- **`extern_uid`** uses GitLab's term for the provider-issued subject and avoids coupling the schema to a specific protocol word such as `sub`, `email`, or `external_id`.
Rejected alternatives:
- **`identities`**: recognizable from GitLab, but inconsistent with the repo's current schema naming pattern.
- **`user_identity` with `idp_id`**: workable on naming, but less reference-aligned on the linkage fields than `provider` / `extern_uid`.
- **Add `oauth_id`-style column(s) to `user`**: simpler, but inconsistent with memos supporting multiple `idp` rows and weaker on G2 and G3, matching the Immich and Mattermost trade-off rather than the multi-provider references.
No foreign key is proposed from `provider` to `idp.uid` in the initial design. The current memos migrations do not rely heavily on cross-backend foreign-key behavior, and the linkage record must remain stable even if IdP rows are reworked at the application layer.
### 2. Add a dedicated store model and driver methods
Add a new store module for the linkage record instead of extending `store.User` (G3):
- `store/user_identity.go`
- `store/db/sqlite/user_identity.go`
- `store/db/postgres/user_identity.go`
- `store/db/mysql/user_identity.go`
The store type should mirror the lookup schema:
- `UserIdentity`: `ID`, `UserID`, `Provider`, `ExternUID`, `CreatedTs`, `UpdatedTs`
- `FindUserIdentity`: `Provider`, `ExternUID`, `UserID`
Only create and read operations are required in this issue. Update and delete paths are deferred because no current sign-in flow needs them (G5). The create path must surface provider/external-UID uniqueness conflicts so the sign-in flow can reconcile concurrent first-login races instead of leaving an unlinked local user behind.
### 3. Route SSO lookup through the linkage record
Change the `auth_service.SignIn` SSO branch so that, after `identifier_filter` passes, the flow is:
1. Resolve the current IdP instance from `idp.uid`.
2. Query `user_identity` by `(provider = idp.uid, extern_uid = userInfo.Identifier)`.
3. On hit, load the local user by `user_id`.
4. On miss, apply the existing registration gate, derive a valid local username from display-oriented fields, and execute local user creation plus `user_identity` insertion in a single transaction.
5. If the `user_identity` insert loses a race on the unique `(provider, extern_uid)` key, discard the provisional linkage result, re-read `user_identity`, and continue sign-in using the winning row's `user_id`.
This directly addresses the current runtime coupling in `server/router/api/v1/auth_service.go:135-169` and satisfies G1 and G2. `User.Username` becomes a local account attribute again instead of the SSO subject store, and the miss path becomes idempotent under concurrent first sign-ins for the same external identity.
### 4. Keep username derivation separate from external identity persistence
The initial local username for new SSO-created users should be derived from user-facing profile data rather than copied from `extern_uid`:
- first choice: `DisplayName`
- fallback: `Email`
- fallback: `Identifier`
Normalization and collision handling remain local username concerns, not identity-link concerns. This keeps the `user_identity` table focused on lookup semantics (G1, G5) and avoids implying that external identifiers must resemble usernames.
### 5. Versioned migrations across all three backends
Add a new migration version after `0.27` for SQLite, PostgreSQL, and MySQL. Update `LATEST.sql` for each backend to include the `user_identity` table and its `user_id` index.
The migrations should preserve the same logical fields across backends:
- `id`
- `user_id`
- `provider`
- `extern_uid`
- `created_ts`
- `updated_ts`
- unique key on `(provider, extern_uid)`
- secondary index on `user_id`
The implementation derived from these migrations should treat `(provider, extern_uid)` as both the lookup key and the concurrency guard for first login. A uniqueness conflict on that pair is a recoverable race outcome, not an unrecoverable error path.
### 6. Document the pattern families explicitly in the issue docs
Keep the issue docs explicit that memos is selecting one member of a broader family of established designs:
- GitLab / Gitea / Keycloak / Discourse: separate identity record
- Immich / Mattermost: user-row external-auth fields
That distinction matters because the design choice is not "copy GitLab exactly"; it is "use the multi-provider separate-identity family, keep memos-compatible table naming, and adopt upstream-aligned linkage fields where they improve recognizability" (G4).
@@ -0,0 +1,105 @@
## Execution Log
### T1: Add `user_identity` migrations + LATEST.sql updates
**Status**: Completed
**Files Changed**:
- Created: `store/migration/sqlite/0.28/00__user_identity.sql`
- Created: `store/migration/postgres/0.28/00__user_identity.sql`
- Created: `store/migration/mysql/0.28/00__user_identity.sql`
- Modified: `store/migration/sqlite/LATEST.sql`
- Modified: `store/migration/postgres/LATEST.sql`
- Modified: `store/migration/mysql/LATEST.sql`
**Validation**:
- `rg 'CREATE TABLE \`?user_identity\`?' store/migration` — PASS (hits in all 6 expected files).
- `rg 'UNIQUE \(\`?provider\`?, \`?extern_uid\`?\)' store/migration` — PASS (6 hits).
- `go build ./...` — PASS.
**Path Corrections**: None.
**Deviations**: None.
### T2: Add `store.UserIdentity` model, `Store` methods, and driver interface
**Status**: Completed
**Files Changed**:
- Created: `store/user_identity.go`
- Modified: `store/driver.go`
**Validation**:
- Interface-only build is expected to fail until T3T5; deferred compile check to T5.
- `rg 'CreateUserIdentity|ListUserIdentities' store/driver.go store/user_identity.go` — PASS (method declarations present in both files).
**Path Corrections**: None.
**Deviations**: None.
### T3: Implement SQLite driver for `user_identity`
**Status**: Completed
**Files Changed**:
- Created: `store/db/sqlite/user_identity.go`
**Validation**:
- `go build ./store/db/sqlite/...` — PASS.
**Path Corrections**: None.
**Deviations**: None.
### T4: Implement Postgres driver for `user_identity`
**Status**: Completed
**Files Changed**:
- Created: `store/db/postgres/user_identity.go`
**Validation**:
- `go build ./store/db/postgres/...` — PASS.
**Path Corrections**: None.
**Deviations**: None.
### T5: Implement MySQL driver for `user_identity`
**Status**: Completed
**Files Changed**:
- Created: `store/db/mysql/user_identity.go`
**Validation**:
- `go build ./...` — PASS (whole repo compiles; all drivers satisfy the `Driver` interface).
**Path Corrections**: None.
**Deviations**: None.
### T6: Add store-layer tests for `user_identity`
**Status**: Completed
**Files Changed**:
- Created: `store/test/user_identity_test.go`
**Validation**:
- `DRIVER=sqlite go test ./store/test/ -run TestUserIdentity -count=1 -v` — PASS:
- `TestUserIdentityCreateAndGet` — PASS
- `TestUserIdentityListByUserID` — PASS
- `TestUserIdentityUniqueConflict` — PASS
- `TestUserIdentitySameExternUIDDifferentProviders` — PASS
**Path Corrections**: None.
**Deviations**: None.
### T7: Add SSO username derivation helper
**Status**: Completed
**Files Changed**:
- Created: `server/router/api/v1/sso_username.go`
**Validation**:
- `go build ./server/router/api/v1/...` — PASS.
- `go vet ./server/router/api/v1/...` — PASS.
**Path Corrections**: None.
**Deviations**: None.
### T8: Route SSO sign-in through `user_identity` linkage
**Status**: Completed
**Files Changed**:
- Modified: `server/router/api/v1/auth_service.go`
- `SignIn` SSO branch now delegates user resolution to a new `resolveSSOUser` method.
- `resolveSSOUser` does: `user_identity` lookup → hit path (load user by linked `user_id`); miss path (registration gate → `deriveSSOUsername` → create user → create linkage → race recovery on unique(provider, extern_uid)).
- Added `isUserIdentityUniqueViolation` helper (string match on the three backends' unique-constraint error strings, matching the pattern in `memo_service.go:103105`).
**Validation**:
- `go build ./...` — PASS.
- `go vet ./...` — PASS.
- `DRIVER=sqlite go test ./store/test/ -run TestUserIdentity -count=1` — PASS (regression check).
**Path Corrections**:
- The plan pseudocode referenced `identityProvider.UID`; the actual protobuf type `storepb.IdentityProvider` exposes the field as `Uid`. Used `identityProvider.Uid` in the implementation. No semantic deviation.
**Deviations**: None.
## Completion Declaration
**All tasks completed successfully.**
@@ -0,0 +1,328 @@
## Task List
**Task Index**
> T1: Add `user_identity` migrations + LATEST.sql updates for all three backends [M] — T2: Add `store.UserIdentity` model and `Store` methods + driver interface [M] — T3: Implement SQLite driver for `user_identity` [M] — T4: Implement Postgres driver for `user_identity` [M] — T5: Implement MySQL driver for `user_identity` [M] — T6: Add store-layer tests for `user_identity` [M] — T7: Add SSO username derivation helper [M] — T8: Route SSO sign-in through `user_identity` linkage [L]
### T1: Add `user_identity` migrations + LATEST.sql updates [M]
**Objective**: Create the `user_identity` persistence structure across SQLite, Postgres, and MySQL, and reflect it in `LATEST.sql` for fresh installs (G1, G2, G3, G4, G5; design §1, §5).
**Size**: M (3 new migration files, 3 LATEST.sql edits; straightforward DDL).
**Files**:
- Create: `store/migration/sqlite/0.28/00__user_identity.sql`
- Create: `store/migration/postgres/0.28/00__user_identity.sql`
- Create: `store/migration/mysql/0.28/00__user_identity.sql`
- Modify: `store/migration/sqlite/LATEST.sql`
- Modify: `store/migration/postgres/LATEST.sql`
- Modify: `store/migration/mysql/LATEST.sql`
**Implementation**:
1. `store/migration/sqlite/0.28/00__user_identity.sql`:
```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)
);
CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);
```
2. `store/migration/postgres/0.28/00__user_identity.sql`: same logical schema with Postgres types — `id SERIAL PRIMARY KEY`, `user_id INTEGER NOT NULL`, `provider TEXT NOT NULL`, `extern_uid TEXT NOT NULL`, `created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `updated_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())`, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`. Include a 2-line header comment describing the table purpose (pattern-match `04__memo_share.sql`).
3. `store/migration/mysql/0.28/00__user_identity.sql`: same logical schema with MySQL syntax — backticked identifiers, `INT NOT NULL AUTO_INCREMENT PRIMARY KEY`, `VARCHAR(256)` for `provider`, `VARCHAR(256)` for `extern_uid` (so unique key fits within index limits), `BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP())` for timestamps, `UNIQUE(provider, extern_uid)`, plus `CREATE INDEX idx_user_identity_user_id ON user_identity(user_id);`.
4. Append a `-- user_identity` section to each `LATEST.sql` mirroring the corresponding migration file (schema only, same indentation style used by neighboring tables in that file).
**Boundaries**: Must NOT alter the `user` or `idp` tables; must NOT add FK from `user_identity.provider` to `idp.uid`; must NOT add columns beyond `id`, `user_id`, `provider`, `extern_uid`, `created_ts`, `updated_ts`.
**Dependencies**: None.
**Expected Outcome**: New migration files exist; `LATEST.sql` for each backend contains a `user_identity` table block and its `user_id` index.
**Validation**:
- `rg -n "CREATE TABLE user_identity" store/migration` — expects one hit per backend in both the 0.28 migration and `LATEST.sql` (6 hits total).
- `rg -n "UNIQUE ?\\(provider, extern_uid\\)" store/migration` — expects 6 hits total.
- `go build ./...` — expects PASS (no code changes affect the build; confirms no stray syntax issues).
---
### T2: Add `store.UserIdentity` model, `Store` methods, and driver interface [M]
**Objective**: Provide a Go-level abstraction for the `user_identity` record with create/read operations wired through `store.Driver` (design §2, G3, G5).
**Size**: M (one new store file, one interface edit; simple CRUD-shaped code).
**Files**:
- Create: `store/user_identity.go`
- Modify: `store/driver.go`
**Implementation**:
1. `store/user_identity.go`:
- Types:
```go
type UserIdentity struct {
ID int32
UserID int32
Provider string
ExternUID string
CreatedTs int64
UpdatedTs int64
}
type FindUserIdentity struct {
ID *int32
UserID *int32
Provider *string
ExternUID *string
}
```
- Store methods (thin passthroughs to driver):
```go
func (s *Store) CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
func (s *Store) ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
func (s *Store) GetUserIdentity(ctx context.Context, find *FindUserIdentity) (*UserIdentity, error) // returns (nil, nil) on no match
```
- No update/delete methods in this issue (design §2: create/read only).
2. `store/driver.go`: extend the `Driver` interface with:
```go
// UserIdentity model related methods.
CreateUserIdentity(ctx context.Context, create *UserIdentity) (*UserIdentity, error)
ListUserIdentities(ctx context.Context, find *FindUserIdentity) ([]*UserIdentity, error)
```
`GetUserIdentity` in `store` can be implemented locally by calling `ListUserIdentities` with `Limit`-free semantics and returning the first row, matching the `GetMemoShare`/`GetIdentityProvider` pattern (no new driver method required for "get").
**Boundaries**: Must NOT add fields to `store.User` or `store.UpdateUser`; must NOT add update/delete methods.
**Dependencies**: None (T3T5 will satisfy the new interface methods).
**Expected Outcome**: `store.UserIdentity`, `FindUserIdentity`, and three `Store` methods exist; `Driver` interface declares the two new methods.
**Validation**:
- `go build ./store/...` — expects FAIL until T3T5 implement the interface on each driver. Record as expected; final pass comes at end of T5.
- `rg -n "CreateUserIdentity|ListUserIdentities" store/driver.go store/user_identity.go` — expects method declarations in both files.
---
### T3: Implement SQLite driver for `user_identity` [M]
**Objective**: Implement `CreateUserIdentity` and `ListUserIdentities` for SQLite so the interface declared in T2 is satisfied (design §2).
**Size**: M (one new driver file; mirrors existing `memo_share.go` patterns).
**Files**:
- Create: `store/db/sqlite/user_identity.go`
**Implementation**:
1. `CreateUserIdentity`:
- Insert columns `user_id`, `provider`, `extern_uid` using `?` placeholders.
- Use `RETURNING id, created_ts, updated_ts` to populate generated fields, same pattern as `store/db/sqlite/memo_share.go:24`.
- Return the passed-in `create` struct with generated fields populated, or the error from `QueryRowContext(...).Scan(...)` (unique-constraint violation surfaces to caller unchanged).
2. `ListUserIdentities`:
- `where := []string{"1 = 1"}`; append clauses for `find.ID`, `find.UserID`, `find.Provider`, `find.ExternUID` when non-nil.
- `SELECT id, user_id, provider, extern_uid, created_ts, updated_ts FROM user_identity WHERE ... ORDER BY id ASC`.
- Scan rows into `[]*store.UserIdentity`; return `[]*store.UserIdentity{}` on no rows (not nil).
**Boundaries**: Must NOT introduce transaction helpers, upsert semantics, or extra scan columns.
**Dependencies**: T2.
**Expected Outcome**: SQLite driver compiles and returns populated rows.
**Validation**:
- `go build ./store/db/sqlite/...` — expects PASS.
---
### T4: Implement Postgres driver for `user_identity` [M]
**Objective**: Mirror T3 for Postgres using `$N` placeholders and `SERIAL` semantics (design §2).
**Size**: M (one new driver file; mirrors `store/db/postgres/memo_share.go`).
**Files**:
- Create: `store/db/postgres/user_identity.go`
**Implementation**:
- Same shape as T3, but:
- Use `placeholder(n)` / `placeholders(n)` helpers from `store/db/postgres/common.go`.
- Insert stmt `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (...) RETURNING id, created_ts, updated_ts`.
- List query identical SQL shape to SQLite (no backticks in Postgres; match `memo_share.go` style).
**Boundaries**: Same as T3.
**Dependencies**: T2.
**Expected Outcome**: Postgres driver compiles.
**Validation**:
- `go build ./store/db/postgres/...` — expects PASS.
---
### T5: Implement MySQL driver for `user_identity` [M]
**Objective**: Mirror T3/T4 for MySQL, using `LastInsertId()` + re-read pattern (MySQL's driver does not support `RETURNING`; design §2).
**Size**: M (one new driver file; mirrors `store/db/mysql/memo_share.go`).
**Files**:
- Create: `store/db/mysql/user_identity.go`
**Implementation**:
- `CreateUserIdentity`:
- `INSERT INTO user_identity (user_id, provider, extern_uid) VALUES (?, ?, ?)` via `ExecContext`.
- Get `LastInsertId()`, re-fetch via `GetUserIdentity(... ID: &id)` helper (internal unexported `listUserIdentitiesByID` or reuse `ListUserIdentities` with `FindUserIdentity{ID: &id}` + take first result).
- Mirror `memo_share.go` error-handling style (return `errors.Errorf("failed to create user identity")` when re-fetch returns nil, like memo_share does).
- `ListUserIdentities`:
- Same shape as T3, using backticked column names (`` `user_id` ``, `` `provider` ``, `` `extern_uid` ``) and `?` placeholders, matching the MySQL idiom used in `memo_share.go`.
**Boundaries**: Same as T3.
**Dependencies**: T2.
**Expected Outcome**: MySQL driver compiles; full repo builds.
**Validation**:
- `go build ./...` — expects PASS (entire repo compiles with all drivers satisfying the `Driver` interface introduced in T2).
---
### T6: Add store-layer tests for `user_identity` [M]
**Objective**: Exercise create + read paths plus the `(provider, extern_uid)` uniqueness guard across the active driver (G2).
**Size**: M (one new test file; patterns match existing store tests).
**Files**:
- Create: `store/test/user_identity_test.go`
**Implementation**:
1. `TestUserIdentityCreateAndGet`:
- Create host user via `createTestingHostUser`.
- `CreateUserIdentity` with `UserID=user.ID`, `Provider="idp-uid-1"`, `ExternUID="jane@example.com"`.
- `GetUserIdentity` by `(Provider, ExternUID)` — assert match on `UserID`, `Provider`, `ExternUID`, non-zero `ID`, non-zero `CreatedTs`.
2. `TestUserIdentityListByUserID`:
- Create two identities under the same `UserID` with two different `Provider` values.
- `ListUserIdentities` by `UserID` — assert length 2.
3. `TestUserIdentityUniqueConflict`:
- Insert one row with `(Provider="idp-A", ExternUID="sub-1")`.
- Insert a second row with identical `(Provider, ExternUID)` for a different `UserID`.
- Assert the second `CreateUserIdentity` returns a non-nil error (detection via `err != nil`; do not assert message since error strings differ per backend).
4. `TestUserIdentitySameExternUIDDifferentProviders`:
- Insert `(Provider="idp-A", ExternUID="sub-1")` and `(Provider="idp-B", ExternUID="sub-1")` under the same or different users.
- Assert both inserts succeed (G2: uniqueness is scoped to the pair, not `extern_uid` alone).
**Boundaries**: Must NOT test SSO sign-in or auth service behavior; must NOT test migration contents beyond what `NewTestingStore` already executes.
**Dependencies**: T1T5.
**Expected Outcome**: All four tests pass against SQLite.
**Validation**:
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects all 4 tests PASS.
---
### T7: Add SSO username derivation helper [M]
**Objective**: Produce a valid `User.Username` for new SSO-created users from profile fields, independent of `extern_uid` (design §4).
**Size**: M (one new file with helper + small unit test; self-contained logic).
**Files**:
- Create: `server/router/api/v1/sso_username.go`
**Implementation**:
1. `deriveSSOUsername(ctx context.Context, stores *store.Store, userInfo *idp.IdentityProviderUserInfo) (string, error)`:
- Build ordered candidate list: `[userInfo.DisplayName, userInfo.Email, userInfo.Identifier]`, skipping empty values.
- For each candidate:
1. `base := normalizeToUsername(candidate)`
2. If `validateUsername(base) == nil`:
- If no existing user with `Username=base` (via `stores.GetUser(&FindUser{Username: &base})`), return `base`.
- Else: try up to N=8 suffix retries `base + "-" + randomSuffix(6)`, where the trimmed base ensures total length ≤ 36. If a candidate passes `validateUsername` and is unique, return it.
3. If all candidates are exhausted: fall back to a purely random username `"user-" + randomSuffix(10)` validated via `validateUsername`; retry up to 5 times before returning an error.
4. `normalizeToUsername(s string) string`:
- ASCII-fold / lowercase.
- Replace every character not in `[a-zA-Z0-9]` with `-`.
- Collapse consecutive `-` into one `-`.
- Trim leading/trailing `-`.
- Truncate to 36 chars, then re-trim trailing `-` so the string still ends in alphanumeric.
- Return `""` if the result is empty or fully numeric (so the caller falls through to the next candidate).
5. Use `internal/util.RandomString` for the random suffix (already imported by `auth_service.go`).
**Boundaries**: Must NOT modify `validateUsername` or `base.UIDMatcher`; must NOT write to `user_identity` or `user` directly; must NOT call `CreateUser`.
**Dependencies**: None.
**Expected Outcome**: New file `server/router/api/v1/sso_username.go` containing the exported-for-package helper `deriveSSOUsername` and internal `normalizeToUsername`.
**Validation**:
- `go build ./server/router/api/v1/...` — expects PASS.
- `go vet ./server/router/api/v1/...` — expects PASS.
---
### T8: Route SSO sign-in through `user_identity` linkage [L]
**Objective**: Replace the `FindUser{Username: &userInfo.Identifier}` lookup and `Username: userInfo.Identifier` user creation with `user_identity`-backed lookup and derived-username user creation, satisfying G1 and G2 end-to-end (design §3).
**Size**: L (non-trivial branching logic: lookup, miss path, registration gate, race recovery).
**Files**:
- Modify: `server/router/api/v1/auth_service.go`
**Implementation** (in `SignIn`, SSO branch, replacing current lines ~124173):
1. After `identifier_filter` check succeeds (existing `lines 124-133` unchanged), resolve the linkage:
```go
provider := identityProvider.Uid
externUID := userInfo.Identifier
existingIdentity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
Provider: &provider,
ExternUID: &externUID,
})
// error handling → codes.Internal
```
2. **Hit path**: if `existingIdentity != nil`, load `s.Store.GetUser(ctx, &store.FindUser{ID: &existingIdentity.UserID})`; set `existingUser`; skip creation.
3. **Miss path**: gate on `instanceGeneralSetting.DisallowUserRegistration` (reuse existing flow at current lines 143149), then:
1. `username, err := deriveSSOUsername(ctx, s.Store, userInfo)` — from T7. `codes.Internal` on error.
2. Generate random password + bcrypt hash (unchanged from current lines 160168).
3. `user, err := s.Store.CreateUser(ctx, &store.User{Username: username, Role: store.RoleUser, Nickname: userInfo.DisplayName, Email: userInfo.Email, AvatarURL: userInfo.AvatarURL, PasswordHash: string(passwordHash)})`.
4. `_, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{UserID: user.ID, Provider: provider, ExternUID: externUID})`.
5. **Race recovery**: if `CreateUserIdentity` returns an error whose message matches one of the known unique-constraint markers (`strings.Contains(err.Error(), "UNIQUE constraint failed")`, `"duplicate key"`, `"Duplicate entry")` — reusing the same pattern as `server/router/api/v1/memo_service.go:103105`):
- `_ = s.Store.DeleteUser(ctx, &store.DeleteUser{ID: user.ID})` (best-effort cleanup of the provisional local user).
- Re-read the winning `user_identity` via `s.Store.GetUserIdentity(ctx, &FindUserIdentity{Provider: &provider, ExternUID: &externUID})`; if still nil, return `codes.Internal` (should not happen under correct semantics).
- Load its user via `s.Store.GetUser(ctx, &FindUser{ID: &winner.UserID})`; set `existingUser`.
6. On any other `CreateUserIdentity` error: best-effort `DeleteUser` cleanup, then return `codes.Internal`.
7. On full success: set `existingUser = user`.
4. Leave the remainder of `SignIn` (row-status check, `doSignIn`, response construction) untouched.
**Boundaries**: Must NOT touch the password-credentials branch; must NOT modify `identifier_filter` logic; must NOT touch `doSignIn`, `SignOut`, or `RefreshToken`; must NOT add new fields to `SignInRequest`/`SignInResponse`.
**Dependencies**: T2, T3, T6 minimum for SQLite confidence; T7 for the derivation helper.
**Expected Outcome**:
- Sign-in with an IdP-issued identifier that fails `base.UIDMatcher` (e.g., `jane@example.com`) succeeds: a `user_identity` row is created, and the local `User.Username` is a derived valid username.
- Repeat sign-in for the same `(provider, extern_uid)` pair loads the same user by linkage, not by username.
- Two IdPs emitting the same `extern_uid` can each link to their own local users without colliding (G2).
**Validation**:
- `go build ./...` — expects PASS.
- `go vet ./...` — expects PASS.
- `go test ./store/test/ -run TestUserIdentity -count=1` — expects PASS (T6 regression check; ensures no store-layer drift).
## Out-of-Scope Tasks
The following are explicitly deferred per `definition.md` / `design.md` and will NOT be attempted during this execution:
- UI or API surfaces for linking/unlinking external identities.
- Update or delete paths for `user_identity` rows.
- Backfill / migration of existing users whose current `Username` matches an IdP identifier.
- Non-OAUTH2 IdP types.
- Protobuf or API changes to `SignInRequest`/`SignInResponse`.
- Adding foreign keys between `user_identity.provider` and `idp.uid`.
- Running PostgreSQL or MySQL integration tests locally (validation commands only cover SQLite, which is the default `DRIVER` in `store/test/store.go`).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,738 @@
# Calendar-Date Memo Prefill — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When the user picks a date in the activity calendar, the home memo editor pre-fills its `createTime`/`updateTime` to that date and exposes the existing `TimestampPopover` so the user can adjust before saving. Empty calendar dates also become clickable.
**Architecture:** Frontend-only. A new pure helper derives a `Date` from the `displayTime` filter; `PagedMemoList` reads `MemoFilterContext` and passes the derived `Date` into `MemoEditor` via a new `defaultCreateTime` prop; `MemoEditor` seeds its reducer state from the prop, renders the popover when the prop is set in create mode, and re-syncs on prop change. `CalendarCell` drops the `count > 0` gate on click handling.
**Tech Stack:** React 18 + TypeScript, Vite 7, Vitest + jsdom + @testing-library/react, Tailwind v4, react-router. State via React Context + `useReducer`.
**Spec:** `docs/superpowers/specs/2026-05-02-calendar-date-prefill-design.md`
---
## File map
**Created**
- `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts` — pure helper `(filters, now?) => Date | undefined`.
- `web/tests/derive-default-create-time.test.ts` — Vitest unit tests for the helper.
- `web/tests/calendar-cell-empty-clickable.test.tsx` — RTL test that count=0 in-month cells are clickable.
**Modified**
- `web/src/components/ActivityCalendar/CalendarCell.tsx` — drop `day.count > 0` gate from click/interactivity/tooltip.
- `web/src/components/MemoEditor/types/components.ts` — add `defaultCreateTime?: Date` to `MemoEditorProps`.
- `web/src/components/MemoEditor/hooks/useMemoInit.ts` — accept optional `defaultCreateTime`; in create mode, dispatch `SET_TIMESTAMPS` to seed `{ createTime, updateTime }`.
- `web/src/components/MemoEditor/index.tsx` — accept the new prop, pass it to `useMemoInit`, add a `useEffect` that re-syncs timestamps when the prop changes in create mode, render `TimestampPopover` when `(!memo && state.timestamps.createTime)`.
- `web/src/components/PagedMemoList/PagedMemoList.tsx` — read `useMemoFilterContext`, derive `defaultCreateTime`, pass to `<MemoEditor>` at line ~155.
---
## Task 1: Pure helper `deriveDefaultCreateTimeFromFilters`
**Files:**
- Create: `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts`
- Test: `web/tests/derive-default-create-time.test.ts`
The helper takes the `filters` array from `MemoFilterContext` plus an injectable `now`, finds any `displayTime` filter (value format `YYYY-MM-DD`, local-date — produced by `useDateFilterNavigation` which forwards the `dayjs().format("YYYY-MM-DD")` string from `CalendarDayCell.date`), and returns a `Date` of `selected_date + now's hh:mm:ss`. Returns `undefined` if no `displayTime` filter or the value is malformed.
- [ ] **Step 1: Write the failing tests**
Create `web/tests/derive-default-create-time.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import type { MemoFilter } from "@/contexts/MemoFilterContext";
describe("deriveDefaultCreateTimeFromFilters", () => {
const now = new Date(2026, 4, 2, 14, 32, 10); // 2026-05-02 14:32:10 local
it("returns undefined when no filters are set", () => {
expect(deriveDefaultCreateTimeFromFilters([], now)).toBeUndefined();
});
it("returns undefined when no displayTime filter is present", () => {
const filters: MemoFilter[] = [
{ factor: "tagSearch", value: "work" },
{ factor: "pinned", value: "true" },
];
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
});
it("merges the displayTime date with the current local hh:mm:ss", () => {
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
const result = deriveDefaultCreateTimeFromFilters(filters, now);
expect(result).toBeDefined();
expect(result!.getFullYear()).toBe(2025);
expect(result!.getMonth()).toBe(4); // May (0-indexed)
expect(result!.getDate()).toBe(1);
expect(result!.getHours()).toBe(14);
expect(result!.getMinutes()).toBe(32);
expect(result!.getSeconds()).toBe(10);
});
it("ignores extra non-displayTime filters", () => {
const filters: MemoFilter[] = [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2025-05-01" },
{ factor: "pinned", value: "true" },
];
const result = deriveDefaultCreateTimeFromFilters(filters, now);
expect(result?.getDate()).toBe(1);
});
it("returns undefined for a malformed YYYY-MM-DD value", () => {
const cases: MemoFilter[][] = [
[{ factor: "displayTime", value: "not-a-date" }],
[{ factor: "displayTime", value: "2025-13-40" }],
[{ factor: "displayTime", value: "" }],
[{ factor: "displayTime", value: "2025-5-1" }], // single-digit month/day
];
for (const filters of cases) {
expect(deriveDefaultCreateTimeFromFilters(filters, now)).toBeUndefined();
}
});
it("uses real `new Date()` when `now` is omitted", () => {
const filters: MemoFilter[] = [{ factor: "displayTime", value: "2025-05-01" }];
const before = new Date();
const result = deriveDefaultCreateTimeFromFilters(filters);
const after = new Date();
expect(result).toBeDefined();
// Time-of-day should fall between before and after (within 1s tolerance).
const resultTimeOnly = result!.getHours() * 3600 + result!.getMinutes() * 60 + result!.getSeconds();
const beforeTimeOnly = before.getHours() * 3600 + before.getMinutes() * 60 + before.getSeconds();
const afterTimeOnly = after.getHours() * 3600 + after.getMinutes() * 60 + after.getSeconds();
// Handle midnight rollover by allowing any value if before > after.
if (beforeTimeOnly <= afterTimeOnly) {
expect(resultTimeOnly).toBeGreaterThanOrEqual(beforeTimeOnly);
expect(resultTimeOnly).toBeLessThanOrEqual(afterTimeOnly);
}
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && pnpm test derive-default-create-time`
Expected: FAIL — module `@/components/MemoEditor/utils/deriveDefaultCreateTime` does not exist.
- [ ] **Step 3: Implement the helper**
Create `web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts`:
```ts
import type { MemoFilter } from "@/contexts/MemoFilterContext";
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
/**
* Derive a default `createTime` for a new memo from the active memo filters.
* If a `displayTime:YYYY-MM-DD` filter is present, returns that local date
* combined with `now`'s wall-clock hh:mm:ss. Returns undefined otherwise or
* when the filter value is malformed.
*/
export function deriveDefaultCreateTimeFromFilters(
filters: MemoFilter[],
now: Date = new Date(),
): Date | undefined {
const dateFilter = filters.find((f) => f.factor === "displayTime");
if (!dateFilter) return undefined;
const match = DATE_RE.exec(dateFilter.value);
if (!match) return undefined;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
// Construct a local-time Date and verify the components round-trip
// (catches things like 2025-13-40 that JS would silently roll forward).
const candidate = new Date(year, month - 1, day, now.getHours(), now.getMinutes(), now.getSeconds());
if (
candidate.getFullYear() !== year ||
candidate.getMonth() !== month - 1 ||
candidate.getDate() !== day
) {
return undefined;
}
return candidate;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && pnpm test derive-default-create-time`
Expected: PASS — all 6 cases.
- [ ] **Step 5: Run lint**
Run: `cd web && pnpm lint`
Expected: PASS (TypeScript + Biome happy).
- [ ] **Step 6: Commit**
```bash
git add web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts web/tests/derive-default-create-time.test.ts
git commit -m "feat(memo-editor): add deriveDefaultCreateTimeFromFilters helper"
```
---
## Task 2: Make empty calendar cells clickable
**Files:**
- Modify: `web/src/components/ActivityCalendar/CalendarCell.tsx`
- Test: `web/tests/calendar-cell-empty-clickable.test.tsx`
`CalendarCell` currently gates `handleClick`, `isInteractive`, and `shouldShowTooltip` on `day.count > 0`. Drop those gates so any in-month cell is clickable when `onClick` is provided. Out-of-month cells (early-returned at line ~38) stay unclickable.
- [ ] **Step 1: Write the failing test**
Create `web/tests/calendar-cell-empty-clickable.test.tsx`:
```tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CalendarCell } from "@/components/ActivityCalendar/CalendarCell";
import type { CalendarDayCell } from "@/components/ActivityCalendar/types";
const makeDay = (overrides: Partial<CalendarDayCell> = {}): CalendarDayCell => ({
date: "2025-05-01",
label: "1",
count: 0,
isCurrentMonth: true,
isToday: false,
isSelected: false,
...overrides,
});
describe("CalendarCell empty-day clickability", () => {
it("fires onClick for an in-month day with count=0", () => {
const onClick = vi.fn();
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
const button = screen.getByRole("button", { name: /May 1, 2025/ });
fireEvent.click(button);
expect(onClick).toHaveBeenCalledWith("2025-05-01");
});
it("renders an empty in-month day as interactive (tabIndex 0, not aria-disabled)", () => {
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
const button = screen.getByRole("button", { name: /May 1, 2025/ });
expect(button).toHaveAttribute("tabindex", "0");
expect(button).toHaveAttribute("aria-disabled", "false");
});
it("still renders a populated in-month day as interactive", () => {
const onClick = vi.fn();
render(<CalendarCell day={makeDay({ count: 3 })} maxCount={5} tooltipText="May 1, 2025" onClick={onClick} />);
fireEvent.click(screen.getByRole("button", { name: /May 1, 2025/ }));
expect(onClick).toHaveBeenCalledWith("2025-05-01");
});
it("does not render out-of-month days as interactive (no role=button)", () => {
render(
<CalendarCell
day={makeDay({ isCurrentMonth: false })}
maxCount={5}
tooltipText="May 1, 2025"
onClick={() => {}}
/>,
);
expect(screen.queryByRole("button")).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && pnpm test calendar-cell-empty-clickable`
Expected: FAIL — first two tests fail because the empty cell currently has `tabindex="-1"`, `aria-disabled="true"`, and `onClick` is not invoked.
- [ ] **Step 3: Edit `CalendarCell.tsx` to drop the count gate**
Modify `web/src/components/ActivityCalendar/CalendarCell.tsx`. Three edits:
(a) Replace `handleClick`:
```tsx
const handleClick = () => {
if (onClick) {
onClick(day.date);
}
};
```
(b) Replace `isInteractive`:
```tsx
const isInteractive = Boolean(onClick);
```
(c) Replace `shouldShowTooltip`:
```tsx
const shouldShowTooltip = tooltipText && !disableTooltip;
```
Leave the out-of-month early return (`if (!day.isCurrentMonth) { ... }`) untouched — out-of-month cells remain non-buttons.
- [ ] **Step 4: Run test to verify it passes**
Run: `cd web && pnpm test calendar-cell-empty-clickable`
Expected: PASS — all 4 cases.
- [ ] **Step 5: Run the full test suite to catch regressions**
Run: `cd web && pnpm test`
Expected: PASS. The existing `activity-calendar-tooltip.test.ts` covers `getTooltipText` (a separate utility) and shouldn't be affected.
- [ ] **Step 6: Run lint**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add web/src/components/ActivityCalendar/CalendarCell.tsx web/tests/calendar-cell-empty-clickable.test.tsx
git commit -m "feat(activity-calendar): allow clicking empty in-month dates"
```
---
## Task 3: Add `defaultCreateTime` prop to `MemoEditorProps`
**Files:**
- Modify: `web/src/components/MemoEditor/types/components.ts`
Type-only change. No tests at this step — TypeScript compiler is the gate. Subsequent tasks consume the prop.
- [ ] **Step 1: Add the prop**
Modify `web/src/components/MemoEditor/types/components.ts`. Replace the `MemoEditorProps` interface (lines 616) with:
```ts
export interface MemoEditorProps {
className?: string;
cacheKey?: string;
placeholder?: string;
/** Existing memo to edit. When provided, the editor initializes from it without fetching. */
memo?: Memo;
parentMemoName?: string;
autoFocus?: boolean;
/**
* Default `createTime` for a *new* memo (create mode only). When set, the
* editor seeds both `createTime` and `updateTime` to this value and renders
* the timestamp popover so the user can adjust before saving. Tracked live:
* if the prop changes after mount, the editor's timestamps re-sync. Ignored
* in edit mode (when `memo` is set).
*/
defaultCreateTime?: Date;
onConfirm?: (memoName: string) => void;
onCancel?: () => void;
}
```
- [ ] **Step 2: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS (no consumer changes yet, prop is optional).
- [ ] **Step 3: Commit**
```bash
git add web/src/components/MemoEditor/types/components.ts
git commit -m "feat(memo-editor): add defaultCreateTime prop type"
```
---
## Task 4: Seed editor timestamps in `useMemoInit` (create mode)
**Files:**
- Modify: `web/src/components/MemoEditor/hooks/useMemoInit.ts`
`useMemoInit` currently handles edit mode (`if (memo)`) by calling `memoService.fromMemo(memo)` and `actions.initMemo(...)`. In create mode it only restores cached content and sets default visibility — it never touches timestamps. Extend the create branch so that when `defaultCreateTime` is set, it dispatches `SET_TIMESTAMPS` with `{ createTime: defaultCreateTime, updateTime: defaultCreateTime }`. This handles the *initial mount* case.
- [ ] **Step 1: Update `UseMemoInitOptions` and `useMemoInit`**
Modify `web/src/components/MemoEditor/hooks/useMemoInit.ts`. Replace the entire file with:
```ts
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";
import { useEditorContext } from "../state";
interface UseMemoInitOptions {
editorRef: React.RefObject<EditorRefActions | null>;
memo?: Memo;
cacheKey?: string;
username: string;
autoFocus?: boolean;
defaultVisibility?: Visibility;
defaultCreateTime?: Date;
}
export const useMemoInit = ({
editorRef,
memo,
cacheKey,
username,
autoFocus,
defaultVisibility,
defaultCreateTime,
}: 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) {
const initialState = memoService.fromMemo(memo);
cacheService.clear(key);
dispatch(actions.initMemo(initialState));
} else {
const cachedContent = cacheService.load(key);
if (cachedContent) {
dispatch(actions.updateContent(cachedContent));
}
if (defaultVisibility !== undefined) {
dispatch(actions.setMetadata({ visibility: defaultVisibility }));
}
if (defaultCreateTime) {
dispatch(actions.setTimestamps({ createTime: defaultCreateTime, updateTime: defaultCreateTime }));
}
}
if (autoFocus) {
setTimeout(() => editorRef.current?.focus(), 100);
}
setIsInitialized(true);
}, [memo, cacheKey, username, autoFocus, defaultVisibility, defaultCreateTime, actions, dispatch, editorRef]);
return { isInitialized };
};
```
Notes:
- The `defaultCreateTime` dependency is added to the effect's deps to satisfy the linter, but `initializedRef` ensures the body runs only once. Live re-sync after mount is handled by a separate effect in Task 5.
- Edit mode is unchanged — `defaultCreateTime` is intentionally ignored when `memo` is set.
- [ ] **Step 2: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add web/src/components/MemoEditor/hooks/useMemoInit.ts
git commit -m "feat(memo-editor): seed timestamps from defaultCreateTime on init"
```
---
## Task 5: Wire `defaultCreateTime` through `MemoEditor` and render popover
**Files:**
- Modify: `web/src/components/MemoEditor/index.tsx`
Three changes:
1. Destructure `defaultCreateTime` from props.
2. Pass it into `useMemoInit`.
3. Add a `useEffect` that dispatches `setTimestamps` whenever `defaultCreateTime` changes after mount (live re-sync per design Q3-A). Skip when `memo` is set.
4. Update the popover render condition so it shows in create mode too when timestamps are seeded.
- [ ] **Step 1: Destructure the prop and pass it to `useMemoInit`**
In `web/src/components/MemoEditor/index.tsx`, update the `MemoEditorImpl` destructuring (around line 42):
```tsx
const MemoEditorImpl: React.FC<MemoEditorProps> = ({
className,
cacheKey,
memo,
parentMemoName,
autoFocus,
placeholder,
defaultCreateTime,
onConfirm,
onCancel,
}) => {
```
And update the `useMemoInit` call (around line 71):
```tsx
const { isInitialized } = useMemoInit({
editorRef,
memo,
cacheKey,
username: currentUser?.name ?? "",
autoFocus,
defaultVisibility,
defaultCreateTime,
});
```
- [ ] **Step 2: Add the live re-sync effect**
In the same file, add a new `useEffect` after `useMemoInit` (and after `useAutoSave`) that re-syncs timestamps when `defaultCreateTime` changes in create mode. Place it just before the existing `useEffect` that fetches AI settings (around line 80):
```tsx
// Live-sync the draft's createTime/updateTime to the calendar-derived prop.
// Only applies in create mode; edit mode owns its own timestamps. Runs after
// initial mount (the seed value is set in useMemoInit), and again whenever
// the prop changes — e.g., when the user picks a different calendar date
// while the editor is open.
useEffect(() => {
if (memo) return;
if (!isInitialized) return;
dispatch(
actions.setTimestamps({
createTime: defaultCreateTime,
updateTime: defaultCreateTime,
}),
);
}, [defaultCreateTime, memo, isInitialized, actions, dispatch]);
```
Notes:
- We pass `undefined` through when the prop becomes undefined (filter cleared) — this resets timestamps to undefined so the editor falls back to "server-stamped now" on save, exactly the pre-feature behavior.
- The `isInitialized` guard avoids racing with `useMemoInit`'s one-shot seed.
- [ ] **Step 3: Update the popover render condition**
In the same file, find the existing block (around line 294):
```tsx
{memoName && (
<div className="w-full -mb-1">
<TimestampPopover />
</div>
)}
```
Replace with:
```tsx
{(memoName || (!memo && state.timestamps.createTime)) && (
<div className="w-full -mb-1">
<TimestampPopover />
</div>
)}
```
Now the popover renders in edit mode (unchanged) AND in create mode whenever a default timestamp has been seeded.
- [ ] **Step 4: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 5: Run all tests**
Run: `cd web && pnpm test`
Expected: PASS (no editor-specific tests added; existing tests continue to pass).
- [ ] **Step 6: Commit**
```bash
git add web/src/components/MemoEditor/index.tsx
git commit -m "feat(memo-editor): live-sync timestamps and reveal popover from defaultCreateTime"
```
---
## Task 6: Wire calendar selection through `PagedMemoList`
**Files:**
- Modify: `web/src/components/PagedMemoList/PagedMemoList.tsx`
The home memo editor is rendered at line ~155 of `PagedMemoList.tsx`. Read the current `MemoFilterContext` filters, derive the `defaultCreateTime`, and pass it to `<MemoEditor>`. Wrap in `useMemo` so the reference stays stable when filters don't change (avoids re-firing the live-sync effect).
- [ ] **Step 1: Add the imports and derivation**
Modify `web/src/components/PagedMemoList/PagedMemoList.tsx`. Add to the existing imports near the top:
```tsx
import { useMemo } from "react";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
```
If `useMemo` is already imported from `react` in this file, merge into the existing import rather than duplicating.
Inside the component body (above the `children` JSX, near the top of the function), add:
```tsx
const { filters } = useMemoFilterContext();
const defaultCreateTime = useMemo(
() => deriveDefaultCreateTimeFromFilters(filters),
[filters],
);
```
- [ ] **Step 2: Pass the prop to `<MemoEditor>`**
Replace the existing line ~155:
```tsx
{showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" placeholder={t("editor.any-thoughts")} /> : null}
```
with:
```tsx
{showMemoEditor ? (
<MemoEditor
className="mb-2"
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
) : null}
```
- [ ] **Step 3: Verify compilation**
Run: `cd web && pnpm lint`
Expected: PASS.
- [ ] **Step 4: Run all tests**
Run: `cd web && pnpm test`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/PagedMemoList/PagedMemoList.tsx
git commit -m "feat(home): pass calendar-selected date as default createTime to memo editor"
```
---
## Task 7: Manual smoke test
No code changes. Per `CLAUDE.md`: "For UI or frontend changes, start the dev server and use the feature in a browser before reporting the task as complete." Walk through the user-visible flows.
- [ ] **Step 1: Start the backend and frontend**
In one terminal:
```bash
go run ./cmd/memos --port 8081
```
In another:
```bash
cd web && pnpm dev
```
Open `http://localhost:3001`.
- [ ] **Step 2: Smoke — populated past date**
1. Sign in. Confirm the activity calendar is visible (statistics view).
2. Click a *past* date that already has memos.
3. Confirm the URL gains `?filter=displayTime:YYYY-MM-DD`.
4. Confirm the home memo editor shows the timestamp popover above the textarea, populated with the selected date.
5. Type a memo and click Save.
6. Clear the date filter (chip X). Reapply by clicking the same date.
7. Confirm the new memo appears under that date.
- [ ] **Step 3: Smoke — empty past date**
1. Pick a past date with **zero memos** in the calendar (lightest cell).
2. Confirm it is now clickable, the URL filter applies, and the empty-state shows.
3. Type and save a memo.
4. Confirm the memo appears for that date and the calendar cell tints up.
- [ ] **Step 4: Smoke — future date**
1. Click a future date in the current month.
2. Confirm the popover shows that date.
3. Save a memo. Confirm it appears under that date.
- [ ] **Step 5: Smoke — clear filter mid-draft**
1. Pick May 1 (or any non-today date). Type some content, do **not** save.
2. Click the filter chip X to clear the date filter.
3. Confirm the popover disappears and the draft content is preserved (autoSave behavior).
4. Save and confirm the memo gets a server-stamped "now" timestamp (i.e., appears under today).
- [ ] **Step 6: Smoke — change filter mid-draft**
1. Pick May 1. Type content.
2. Without saving, click May 3.
3. Confirm the popover updates to May 3.
4. Save. Confirm the memo appears under May 3.
- [ ] **Step 7: Smoke — comment editor unaffected**
1. Open any memo's detail view (or open the comments thread).
2. Confirm the reply editor does **not** show a timestamp popover.
3. Confirm the date filter has no visible effect on the reply editor.
- [ ] **Step 8: Smoke — edit mode unaffected**
1. Edit an existing memo (pencil icon).
2. Confirm the existing timestamp popover still works exactly as before, regardless of any active calendar filter.
- [ ] **Step 9: Smoke — empty-date click on Explore page**
1. Navigate to the Explore page (which also renders the calendar).
2. Click an empty date.
3. Confirm the URL filter applies and the empty-state shows. (No editor on Explore — that's correct.)
- [ ] **Step 10: Record results**
Note any unexpected behavior (especially: selection-ring contrast on the lowest-intensity background, mentioned as a flag in the spec). If the ring is too subtle, file a follow-up — *not* part of this plan.
---
## Self-review
**Spec coverage check:**
| Spec requirement | Task |
|---|---|
| Empty calendar dates clickable | Task 2 |
| Editor shows TimestampPopover in create mode when filter active | Task 5 (popover condition) |
| `createTime` = selected date + current local hh:mm:ss | Task 1 (helper) + Task 4 (seed) |
| `updateTime` mirrored to same value | Task 4 (seed) + Task 5 (live sync) |
| Live-derived: filter change re-syncs timestamps | Task 5 (live-sync `useEffect`) |
| Filter cleared → undefined → server-stamped "now" | Task 5 (passes `undefined` through) + Task 6 (helper returns undefined) |
| Future dates allowed (no clamp) | Task 1 (no clamp in helper); confirmed in Task 7 step 4 |
| Comment editor unaffected | Task 6 wires only `PagedMemoList`; confirmed in Task 7 step 7 |
| Edit mode unaffected | Task 4 + 5 explicitly guard on `memo`; confirmed in Task 7 step 8 |
| Empty-date click on Explore/Archived/Profile | Task 2 (calendar-side change); confirmed in Task 7 step 9 |
| DST/timezone uses local time | Task 1 (`new Date(y, m-1, d, h, mi, s)`) |
| Helper unit tests | Task 1 |
| `CalendarCell` empty-cell test | Task 2 |
| Manual smoke | Task 7 |
All spec requirements have a task. No gaps.
**Placeholder scan:** No "TBD"/"TODO" steps. Every code step shows the actual code.
**Type/name consistency check:**
- `MemoFilter` and `useMemoFilterContext` exist at `@/contexts/MemoFilterContext` (verified during exploration).
- `editorActions.setTimestamps` exists in `state/actions.ts:75` and accepts `Partial<EditorState["timestamps"]>` (verified). Calls in Tasks 4 and 5 match.
- `state.timestamps.createTime` is `Date | undefined` (verified `state/types.ts:27-29`). The popover render condition uses it as a truthy guard — `Date` instances are truthy, `undefined` is falsy.
- `useMemoFilterContext` (alias used in PagedMemoList) is exported from `MemoFilterContext.tsx:151` (verified).
- `deriveDefaultCreateTimeFromFilters` signature is identical between Task 1 (definition) and Task 6 (consumer).
- `defaultCreateTime: Date | undefined` flows consistently through `MemoEditorProps` (Task 3) → `MemoEditorImpl` destructuring (Task 5) → `useMemoInit` options (Task 4) → reducer payloads.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,774 @@
# First Screen Lazy Heavy Dependencies Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Keep Mermaid, Leaflet, React Leaflet, marker cluster, and feature CSS out of the auth/signup initial screen while preserving diagram, math, and map behavior when those features are used.
**Architecture:** Move runtime Leaflet objects behind plain coordinate data at parent boundaries, then lazy-load map implementations from small wrapper components. Replace Mermaid's static import with effect-time dynamic import, and move KaTeX/Leaflet CSS from the app entry into feature modules.
**Tech Stack:** React 19, TypeScript 6, Vite 8/Rolldown, React Router 7, React Query 5, Mermaid, KaTeX, Leaflet, React Leaflet, pnpm.
---
## File Structure
- Create `web/src/components/map/types.ts`
- Defines a lightweight `MapPoint` interface used by parents that must not import Leaflet.
- Create `web/src/components/map/LazyLocationPicker.tsx`
- Provides a `React.lazy` boundary and map-sized fallback for `LocationPicker`.
- Modify `web/src/main.tsx`
- Removes global Leaflet and KaTeX CSS imports.
- Modify `web/src/router/index.tsx`
- Lazy-loads the Home route so memo/editor/markdown modules are not part of the auth/signup entry graph.
- Modify `web/vite.config.mts`
- Keeps optional heavy dependency split groups from becoming entry preloads.
- Modify `web/src/components/map/LocationPicker.tsx`
- Imports Leaflet CSS inside the lazy implementation path.
- Accepts and emits plain `MapPoint` values at the public component boundary.
- Keeps `LatLng` runtime use internal to the map implementation.
- Modify `web/src/components/map/index.ts`
- Stops exporting `LocationPicker` and map utility helpers from the barrel so non-map imports do not pull Leaflet into parent chunks.
- Keeps exporting `useReverseGeocoding` because it has no Leaflet dependency.
- Modify `web/src/components/MemoEditor/types/insert-menu.ts`
- Replaces `LatLng` in editor state with `MapPoint`.
- Modify `web/src/components/MemoEditor/hooks/useLocation.ts`
- Removes runtime Leaflet import and stores plain coordinates.
- Modify `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- Removes runtime Leaflet import.
- Imports `useReverseGeocoding` directly from its file.
- Constructs plain coordinates from geolocation.
- Modify `web/src/components/MemoMetadata/Location/LocationDialog.tsx`
- Uses `LazyLocationPicker` and `MapPoint`.
- Only mounts the lazy picker while the dialog is open.
- Modify `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`
- Removes runtime Leaflet import and uses `LazyLocationPicker` inside the popover.
- Modify `web/src/pages/UserProfile.tsx`
- Lazy-loads `UserMemoMap` only when the map tab is active.
- Modify `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`
- Imports KaTeX CSS from the markdown-rendering path.
- Modify `web/src/components/MemoContent/MermaidBlock.tsx`
- Dynamically imports Mermaid only when rendering a Mermaid block.
- Modify `web/src/components/UserMemoMap/UserMemoMap.tsx`
- Keeps marker cluster CSS in the lazy map implementation path.
- Verify with `pnpm lint`, `pnpm build`, and a production preview/browser network check.
## Task 1: Remove Leaflet From Editor Location State
**Files:**
- Create: `web/src/components/map/types.ts`
- Modify: `web/src/components/MemoEditor/types/insert-menu.ts`
- Modify: `web/src/components/MemoEditor/hooks/useLocation.ts`
- Modify: `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- [x] **Step 1: Add a Leaflet-free map coordinate type**
Create `web/src/components/map/types.ts`:
```ts
export interface MapPoint {
lat: number;
lng: number;
}
```
- [x] **Step 2: Replace editor location state type**
In `web/src/components/MemoEditor/types/insert-menu.ts`, replace the entire file with:
```ts
import type { MapPoint } from "@/components/map/types";
export interface LocationState {
placeholder: string;
position?: MapPoint;
latInput: string;
lngInput: string;
}
```
- [x] **Step 3: Remove Leaflet runtime usage from `useLocation`**
In `web/src/components/MemoEditor/hooks/useLocation.ts`, remove `import { LatLng } from "leaflet";`, add a type import, and use plain coordinate objects:
```ts
import { create } from "@bufbuild/protobuf";
import { useCallback, useMemo, useRef, useState } from "react";
import type { MapPoint } from "@/components/map/types";
import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb";
import { LocationState } from "../types/insert-menu";
export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false);
const locationInitializedRef = useRef(locationInitialized);
locationInitializedRef.current = locationInitialized;
const [state, setState] = useState<LocationState>({
placeholder: initialLocation?.placeholder || "",
position: initialLocation ? { lat: initialLocation.latitude, lng: initialLocation.longitude } : undefined,
latInput: initialLocation ? String(initialLocation.latitude) : "",
lngInput: initialLocation ? String(initialLocation.longitude) : "",
});
const stateRef = useRef(state);
stateRef.current = state;
const updatePosition = useCallback((position?: MapPoint) => {
setState((prev) => ({
...prev,
position,
latInput: position ? String(position.lat) : "",
lngInput: position ? String(position.lng) : "",
}));
}, []);
const handlePositionChange = useCallback(
(position: MapPoint) => {
if (!locationInitializedRef.current) setLocationInitialized(true);
updatePosition(position);
},
[updatePosition],
);
const updateCoordinate = useCallback((type: "lat" | "lng", value: string) => {
const num = parseFloat(value);
const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180;
setState((prev) => {
const next = { ...prev, [type === "lat" ? "latInput" : "lngInput"]: value };
if (isValid && prev.position) {
const newPos = type === "lat" ? { lat: num, lng: prev.position.lng } : { lat: prev.position.lat, lng: num };
return { ...next, position: newPos, latInput: String(newPos.lat), lngInput: String(newPos.lng) };
}
return next;
});
}, []);
const setPlaceholder = useCallback((placeholder: string) => {
setState((prev) => ({ ...prev, placeholder }));
}, []);
const reset = useCallback(() => {
setState({
placeholder: "",
position: undefined,
latInput: "",
lngInput: "",
});
setLocationInitialized(false);
}, []);
const getLocation = useCallback((): Location | undefined => {
const { position, placeholder } = stateRef.current;
if (!position || !placeholder.trim()) {
return undefined;
}
return create(LocationSchema, {
latitude: position.lat,
longitude: position.lng,
placeholder,
});
}, []);
return useMemo(
() => ({ state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation }),
[state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation],
);
};
```
- [x] **Step 4: Remove Leaflet import from `InsertMenu`**
In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`:
Remove:
```ts
import { LatLng } from "leaflet";
```
Replace:
```ts
import { useReverseGeocoding } from "@/components/map";
```
with:
```ts
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
```
Replace the geolocation success handler body:
```ts
handleLocationPositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
```
with:
```ts
handleLocationPositionChange({ lat: position.coords.latitude, lng: position.coords.longitude });
```
- [x] **Step 5: Run focused type check**
Run:
```bash
cd web && pnpm lint
```
Expected: TypeScript may still fail because map component props have not been converted yet. If the only failures are `LatLng`/`MapPoint` mismatches in map/location components, continue to Task 2. If unrelated failures appear, stop and inspect before editing further.
## Task 2: Lazy-Load Location Picker And Keep Leaflet Inside Map Modules
**Files:**
- Create: `web/src/components/map/LazyLocationPicker.tsx`
- Modify: `web/src/components/map/LocationPicker.tsx`
- Modify: `web/src/components/map/index.ts`
- Modify: `web/src/components/MemoMetadata/Location/LocationDialog.tsx`
- Modify: `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`
- [x] **Step 1: Create lazy location picker wrapper**
Create `web/src/components/map/LazyLocationPicker.tsx`:
```tsx
import { lazy, Suspense } from "react";
import { cn } from "@/lib/utils";
import type { MapPoint } from "./types";
interface LazyLocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
const LocationPicker = lazy(() => import("./LocationPicker"));
export const LazyLocationPicker = ({ className, ...props }: LazyLocationPickerProps) => {
return (
<Suspense
fallback={
<div
className={cn(
"memo-location-map relative isolate h-72 w-full overflow-hidden rounded-xl border border-border bg-muted/30 shadow-sm",
className,
)}
/>
}
>
<LocationPicker className={className} {...props} />
</Suspense>
);
};
```
- [x] **Step 2: Convert `LocationPicker` public props to `MapPoint`**
In `web/src/components/map/LocationPicker.tsx`:
Add CSS and type imports near the top:
```ts
import "leaflet/dist/leaflet.css";
import type { MapPoint } from "./types";
```
Keep Leaflet runtime import:
```ts
import L, { LatLng } from "leaflet";
```
Add helper functions after imports:
```ts
const toLatLng = (point: MapPoint): LatLng => new LatLng(point.lat, point.lng);
const fromLatLng = (latlng: LatLng): MapPoint => ({ lat: latlng.lat, lng: latlng.lng });
```
Update public-facing props:
```ts
interface LocationMarkerProps {
position: LatLng | undefined;
onChange: (position: MapPoint) => void;
readonly?: boolean;
}
```
In `LocationMarker`, replace:
```ts
onChange(e.latlng);
```
with:
```ts
onChange(fromLatLng(e.latlng));
```
Update `MapControlsProps`:
```ts
interface MapControlsProps {
position: MapPoint | undefined;
}
```
Update `LocationPickerProps`:
```ts
interface LocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
```
Replace:
```ts
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
```
with:
```ts
const DEFAULT_CENTER: MapPoint = { lat: 48.8584, lng: 2.2945 };
```
Inside `LocationPicker`, replace:
```ts
const position = latlng || DEFAULT_CENTER_LAT_LNG;
```
with:
```ts
const position = latlng || DEFAULT_CENTER;
const mapCenter = toLatLng(position);
const markerPosition = latlng ? toLatLng(latlng) : mapCenter;
```
Replace the `MapContainer` props and marker call:
```tsx
<MapContainer
className="h-full w-full !bg-muted"
center={mapCenter}
zoom={13}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<ThemedTileLayer />
<LocationMarker position={markerPosition} readonly={readOnly} onChange={onChange} />
<MapControls position={latlng} />
<MapCleanup />
</MapContainer>
```
- [x] **Step 3: Keep the map barrel Leaflet-free**
Replace `web/src/components/map/index.ts` with:
```ts
export { useReverseGeocoding } from "./useReverseGeocoding";
```
Do not export `LocationPicker`, `LazyLocationPicker`, `map-utils`, or Leaflet helpers from this barrel. Import map UI directly from `@/components/map/LazyLocationPicker` or the implementation file.
- [x] **Step 4: Use lazy picker in `LocationDialog`**
In `web/src/components/MemoMetadata/Location/LocationDialog.tsx`:
Remove:
```ts
import type { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
```
Add:
```ts
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
import type { MapPoint } from "@/components/map/types";
```
Change the prop type:
```ts
onPositionChange: (position: MapPoint) => void;
```
Replace:
```tsx
<LocationPicker className="h-full" latlng={position} onChange={onPositionChange} />
```
with:
```tsx
{open && <LazyLocationPicker className="h-full" latlng={position} onChange={onPositionChange} />}
```
- [x] **Step 5: Use lazy picker in `LocationDisplayView`**
In `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`:
Remove:
```ts
import { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
```
Add:
```ts
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
```
Replace:
```tsx
<LocationPicker latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
```
with:
```tsx
{popoverOpen && <LazyLocationPicker latlng={{ lat: location.latitude, lng: location.longitude }} readonly={true} />}
```
- [x] **Step 6: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS for the files changed so far, or only failures unrelated to this work. Fix any `MapPoint`/`LatLng` type errors before continuing.
## Task 3: Lazy-Load Profile Map
**Files:**
- Modify: `web/src/pages/UserProfile.tsx`
- Modify: `web/src/components/UserMemoMap/UserMemoMap.tsx`
- [x] **Step 1: Move `UserMemoMap` behind `React.lazy`**
In `web/src/pages/UserProfile.tsx`, replace the React import section:
```ts
import copy from "copy-to-clipboard";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { toast } from "react-hot-toast";
```
with:
```ts
import copy from "copy-to-clipboard";
import { lazy, Suspense } from "react";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { toast } from "react-hot-toast";
```
Remove:
```ts
import UserMemoMap from "@/components/UserMemoMap";
```
Add after the `type TabView = "memos" | "map";` line:
```ts
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
```
Replace:
```tsx
<div className="">
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</div>
```
with:
```tsx
<div className="">
<Suspense fallback={<div className="h-[60dvh] sm:h-[500px] rounded-xl border border-border bg-muted/30" />}>
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</Suspense>
</div>
```
- [x] **Step 2: Keep Leaflet CSS in the lazy profile map implementation**
In `web/src/components/UserMemoMap/UserMemoMap.tsx`, add the Leaflet CSS import above marker cluster CSS:
```ts
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
```
The file should continue to import Leaflet, React Leaflet, marker clustering, and `map-utils` directly because this component is now loaded only from a lazy boundary.
- [x] **Step 3: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS, or only unrelated pre-existing failures.
## Task 4: Move KaTeX CSS And Dynamically Import Mermaid
**Files:**
- Modify: `web/src/main.tsx`
- Modify: `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`
- Modify: `web/src/components/MemoContent/MermaidBlock.tsx`
- [x] **Step 1: Remove feature CSS from app entry**
In `web/src/main.tsx`, remove:
```ts
import "leaflet/dist/leaflet.css";
import "katex/dist/katex.min.css";
```
- [x] **Step 2: Load KaTeX CSS from markdown renderer**
In `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`, add this import with the existing dependency imports:
```ts
import "katex/dist/katex.min.css";
```
- [x] **Step 3: Remove static Mermaid import**
In `web/src/components/MemoContent/MermaidBlock.tsx`, remove:
```ts
import mermaid from "mermaid";
```
- [x] **Step 4: Replace Mermaid initialization/render effects with dynamic import**
In `web/src/components/MemoContent/MermaidBlock.tsx`, remove the existing Mermaid initialization effect:
```ts
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
}, [currentTheme]);
```
Replace the existing render effect with:
```ts
useEffect(() => {
if (!codeContent) return;
let cancelled = false;
const renderDiagram = async () => {
try {
const { default: mermaid } = await import("mermaid");
if (cancelled) return;
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
if (cancelled) return;
setSvg(renderedSvg);
setError("");
} catch (err) {
if (cancelled) return;
console.error("Failed to render mermaid diagram:", err);
setSvg("");
setError(formatErrorMessage(err));
}
};
renderDiagram();
return () => {
cancelled = true;
};
}, [codeContent, currentTheme]);
```
- [x] **Step 5: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS, or only unrelated pre-existing failures.
## Task 4.5: Remove Remaining Entry Preloads Found During Verification
**Files:**
- Modify: `web/src/router/index.tsx`
- Modify: `web/vite.config.mts`
- [x] **Step 1: Lazy-load the Home route**
`web/src/router/index.tsx` now uses `lazyWithReload(() => import("@/pages/Home"))` instead of a static Home import. This prevents Home, `PagedMemoList`, `MemoEditor`, `MemoContent`, KaTeX, and Mermaid code from entering the auth/signup app entry graph.
- [x] **Step 2: Tighten optional vendor split groups**
`web/vite.config.mts` no longer defines a manual `mermaid-vendor` group, because Rolldown emitted the preload helper from that group and forced an entry preload. The Leaflet group now matches only the `leaflet` package, not `react-leaflet`, so React does not get bundled into a Leaflet-named entry preload.
- [x] **Step 3: Re-run lint and build**
Run:
```bash
cd web && pnpm lint && pnpm build
```
Expected: PASS.
## Task 5: Build And Verify Initial Network Behavior
**Files:**
- No source edits expected unless verification finds a regression.
- [x] **Step 1: Build production frontend**
Run:
```bash
cd web && pnpm build
```
Expected: PASS. Build output should still contain separate Mermaid and Leaflet chunks, but they should not be required by the auth/signup initial route.
- [x] **Step 2: Inspect build output for heavy chunks**
Run:
```bash
cd web && find dist/assets -maxdepth 1 -type f \( -name '*mermaid*' -o -name '*leaflet*' -o -name '*katex*' \) -print | sort
```
Expected: Mermaid and Leaflet assets may exist as lazy chunks. Their existence is fine; the goal is that auth/signup does not request them initially.
- [x] **Step 3: Start production preview**
Run:
```bash
cd web && pnpm exec vite preview --host 127.0.0.1 --port 4173
```
Expected: Preview server starts on `http://127.0.0.1:4173/`. Keep this session running until browser verification is complete.
- [x] **Step 4: Verify `/auth/signup` network with browser tooling**
Open:
```text
http://127.0.0.1:4173/auth/signup
```
Expected initial document and asset requests do not include filenames containing:
```text
mermaid
leaflet
```
If KaTeX CSS still appears on `/auth/signup`, inspect the chunk initiator and remove any remaining static import path that reaches `MemoMarkdownRenderer` from auth/signup.
- [ ] **Step 5: Smoke test feature lazy loading**
Use the running app or a local backend/dev setup to verify:
```text
1. A memo containing a Mermaid code block renders the diagram and requests the Mermaid chunk only when the memo content appears.
2. A memo containing inline or block math displays KaTeX styling when memo content appears.
3. Opening the location picker loads Leaflet assets and the picker remains interactive.
4. Opening a memo location popover loads Leaflet assets and shows the pinned map.
5. Opening `/u/:username?view=map` loads the profile map and marker cluster behavior still works.
```
Expected: Features behave as before after their lazy chunks load.
Result in this session: live feature smoke was not completed because the production preview has no authenticated backend session or seeded memo data. Static verification confirmed the relevant feature chunks still exist and load paths are behind lazy imports.
- [x] **Step 6: Stop preview server**
Stop the preview command from Step 3 with `Ctrl-C`.
## Task 6: Commit Implementation
**Files:**
- All source files modified by Tasks 1-4.
- [x] **Step 1: Review final diff**
Run:
```bash
git diff -- web/src/main.tsx web/src/components/map web/src/components/MemoEditor web/src/components/MemoMetadata/Location web/src/pages/UserProfile.tsx web/src/components/MemoContent web/src/components/UserMemoMap/UserMemoMap.tsx
```
Expected: Diff is limited to lazy-loading heavy optional dependencies and plain coordinate type changes.
- [x] **Step 2: Run final verification**
Run:
```bash
cd web && pnpm lint && pnpm build
```
Expected: PASS.
- [x] **Step 3: Commit**
Run:
```bash
git add web/src/main.tsx web/src/components/map web/src/components/MemoEditor web/src/components/MemoMetadata/Location web/src/pages/UserProfile.tsx web/src/components/MemoContent web/src/components/UserMemoMap/UserMemoMap.tsx
git commit -m "perf: lazy load heavy first-screen dependencies"
```
Expected: Commit succeeds with only the intended source changes.
## Self-Review
- Spec coverage: The plan removes global Leaflet/KaTeX CSS, dynamically imports Mermaid, lazy-loads map UI, keeps Leaflet types out of parent boundaries, and verifies auth/signup network behavior.
- Placeholder scan: No placeholder markers, unresolved decisions, or vague generic handling steps remain.
- Type consistency: `MapPoint` is the shared parent-facing coordinate type; `LatLng` remains internal to `LocationPicker` and map implementation files only.
@@ -0,0 +1,776 @@
# Placeholder Component Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace `Empty.tsx` with a reusable `<Placeholder>` component covering empty / loading / noResults / notFound states, each rendering a hand-curated ASCII bird from a pool-shaped data file with subtle CSS-only animation.
**Architecture:** Single component (`Placeholder/index.tsx`) reads from a co-located `ascii-pool.ts` data file via a `pickPiece(variant)` picker. Motion is CSS keyframes in `Placeholder.css`, gated by `prefers-reduced-motion`. Default messages live in a `messages.ts` seam ready for future i18n. Integration is narrow: only `Inboxes.tsx` is rewired in this PR; `Empty.tsx` is deleted.
**Tech Stack:** React 19 · TypeScript · Tailwind v4 (via `@tailwindcss/vite`) · Vitest + `@testing-library/react` + jsdom · Biome for lint/format · `cn` helper from `@/lib/utils` for class composition.
---
## File Structure
**Create:**
- `web/src/components/Placeholder/index.tsx` — public component (default export)
- `web/src/components/Placeholder/Placeholder.css` — keyframes + motion classes
- `web/src/components/Placeholder/ascii-pool.ts` — types, `ASCII_POOL` array, `pickPiece()`
- `web/src/components/Placeholder/messages.ts``DEFAULT_MESSAGES` map
- `web/src/components/Placeholder/CREDITS.md` — Joan Stark attribution
- `web/tests/placeholder-pool.test.ts` — picker + pool integrity tests
- `web/tests/placeholder-component.test.tsx` — component render tests
**Modify:**
- `web/src/pages/Inboxes.tsx` — replace `<Empty />` with `<Placeholder variant="empty" message={…} />`
**Delete:**
- `web/src/components/Empty.tsx`
---
## Task 0: Commit this plan
**Files:**
- Add: `docs/superpowers/plans/2026-05-12-placeholder-component.md`
- [ ] **Step 1: Commit the plan document on its own**
```bash
git add docs/superpowers/plans/2026-05-12-placeholder-component.md
git commit -m "docs: add Placeholder component implementation plan"
```
This keeps the planning artifact separate from feature commits.
---
## Task 1: Scaffold pool types and picker (TDD)
**Files:**
- Create: `web/src/components/Placeholder/ascii-pool.ts`
- Test: `web/tests/placeholder-pool.test.ts`
- [ ] **Step 1: Write the failing tests for `pickPiece` and pool shape**
Create `web/tests/placeholder-pool.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { ASCII_POOL, pickPiece, type PlaceholderVariant } from "@/components/Placeholder/ascii-pool";
const VARIANTS: PlaceholderVariant[] = ["empty", "loading", "noResults", "notFound"];
describe("ASCII_POOL integrity", () => {
it("contains at least one piece per variant", () => {
for (const variant of VARIANTS) {
const matches = ASCII_POOL.filter((p) => p.variant === variant);
expect(matches.length, `variant=${variant}`).toBeGreaterThanOrEqual(1);
}
});
it("uses unique ids", () => {
const ids = ASCII_POOL.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("preserves the jgs credit on every piece", () => {
for (const piece of ASCII_POOL) {
expect(piece.credit, `piece=${piece.id}`).toMatch(/jgs/);
}
});
it("uses a known motion style on every piece", () => {
for (const piece of ASCII_POOL) {
expect(["bob", "flutter", "none"]).toContain(piece.motion);
}
});
});
describe("pickPiece", () => {
it("returns a piece matching the requested variant", () => {
for (const variant of VARIANTS) {
const piece = pickPiece(variant);
expect(piece.variant).toBe(variant);
}
});
it("returns a non-empty ascii string", () => {
const piece = pickPiece("empty");
expect(piece.ascii.length).toBeGreaterThan(0);
});
});
```
- [ ] **Step 2: Run the tests and verify they fail**
```bash
cd web && pnpm test placeholder-pool
```
Expected: FAIL — module `@/components/Placeholder/ascii-pool` not found.
- [ ] **Step 3: Implement `ascii-pool.ts` with types, an empty pool, and the picker**
Create `web/src/components/Placeholder/ascii-pool.ts`:
```ts
export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound";
export type MotionStyle = "bob" | "flutter" | "none";
export interface AsciiPiece {
/** Stable identifier — used as React key and for debugging. */
id: string;
/** Which placeholder state this piece is shown for. */
variant: PlaceholderVariant;
/** ASCII art preserved verbatim — must keep every space. */
ascii: string;
/** Attribution shown beneath the bird, e.g. "jgs · 4/97". */
credit: string;
/** Motion hint applied to the <pre>. */
motion: MotionStyle;
}
export const ASCII_POOL: AsciiPiece[] = [];
export function pickPiece(variant: PlaceholderVariant): AsciiPiece {
const matches = ASCII_POOL.filter((p) => p.variant === variant);
if (matches.length === 0) {
throw new Error(`No ASCII piece registered for variant "${variant}"`);
}
return matches[Math.floor(Math.random() * matches.length)];
}
```
- [ ] **Step 4: Run the tests and verify they still fail (pool is empty)**
```bash
cd web && pnpm test placeholder-pool
```
Expected: FAIL — "contains at least one piece per variant" expectations not met (because `ASCII_POOL` is `[]`).
This is the expected red — pool gets seeded in the next task.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/Placeholder/ascii-pool.ts web/tests/placeholder-pool.test.ts
git commit -m "feat(placeholder): scaffold ASCII pool types and picker"
```
---
## Task 2: Seed the four ASCII pieces
**Files:**
- Modify: `web/src/components/Placeholder/ascii-pool.ts`
- [ ] **Step 1: Replace the empty `ASCII_POOL` array with the four seed entries**
In `web/src/components/Placeholder/ascii-pool.ts`, replace `export const ASCII_POOL: AsciiPiece[] = [];` with the four entries below. Preserve every space and newline in the `ascii` strings exactly — they are template literals with escaped backslashes/backticks per JS rules.
```ts
export const ASCII_POOL: AsciiPiece[] = [
{
id: "jgs-crested-parrot",
variant: "empty",
credit: "jgs · 4/97",
motion: "bob",
ascii: ` .---.
/ 6_6
\\_ (__\\
// \\\\
(( ))
=====""===""=====
|||
|`,
},
{
id: "jgs-hummingbird-sm",
variant: "loading",
credit: "jgs · 7/98",
motion: "flutter",
ascii: ` , _
{ \\/\`o;====-
.----'-/\`-/
\`'-..-| /
/\\/\\
\`--\``,
},
{
id: "jgs-wide-eyed-owl",
variant: "noResults",
credit: "jgs · 2/01",
motion: "bob",
ascii: ` __ __
\\ \`-'"'-\` /
/ \\_ _/ \\
| d\\_/b |
.'\\ V /'.
/ '-...-' \\
| / \\ |
\\/\\ /\\/
==(||)---(||)==`,
},
{
id: "jgs-bird-flown-away",
variant: "notFound",
credit: "jgs · 7/96",
motion: "flutter",
ascii: ` ___
_,-' ______
.' .-' ____7
/ / ___7
_| / ___7
>(')\\ | ___7
\\\\/ \\_______
' _======>
\`'----\\\\\``,
},
];
```
- [ ] **Step 2: Run the pool tests and verify they pass**
```bash
cd web && pnpm test placeholder-pool
```
Expected: PASS for all six assertions.
- [ ] **Step 3: Commit**
```bash
git add web/src/components/Placeholder/ascii-pool.ts
git commit -m "feat(placeholder): seed pool with four jgs ASCII bird pieces"
```
---
## Task 3: Default messages
**Files:**
- Create: `web/src/components/Placeholder/messages.ts`
- [ ] **Step 1: Add a tiny test for the messages map**
Append to `web/tests/placeholder-pool.test.ts`:
```ts
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
describe("DEFAULT_MESSAGES", () => {
it("provides a non-empty message for every variant", () => {
for (const variant of VARIANTS) {
expect(DEFAULT_MESSAGES[variant], `variant=${variant}`).toBeTruthy();
expect(DEFAULT_MESSAGES[variant].trim().length).toBeGreaterThan(0);
}
});
});
```
- [ ] **Step 2: Run the test and verify it fails**
```bash
cd web && pnpm test placeholder-pool
```
Expected: FAIL — module `@/components/Placeholder/messages` not found.
- [ ] **Step 3: Create `messages.ts`**
```ts
import type { PlaceholderVariant } from "./ascii-pool";
/**
* Default copy shown beneath the ASCII art when no `message` prop is supplied.
*
* Future i18n: swap these strings for `t("placeholder.<variant>")` lookups via
* `react-i18next` without touching the component.
*/
export const DEFAULT_MESSAGES: Record<PlaceholderVariant, string> = {
empty: "No memos yet",
loading: "Loading…",
noResults: "Nothing matches that search",
notFound: "This page flew the coop",
};
```
- [ ] **Step 4: Run the test and verify it passes**
```bash
cd web && pnpm test placeholder-pool
```
Expected: PASS — all variants have a non-empty default message.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/Placeholder/messages.ts web/tests/placeholder-pool.test.ts
git commit -m "feat(placeholder): add DEFAULT_MESSAGES map"
```
---
## Task 4: Animation keyframes
**Files:**
- Create: `web/src/components/Placeholder/Placeholder.css`
- [ ] **Step 1: Create the stylesheet**
```css
/*
* Animations for <Placeholder>.
*
* All keyframes are wrapped in a prefers-reduced-motion guard so users who
* opt out of motion see a static bird and an instantly-visible message.
*/
@media (prefers-reduced-motion: no-preference) {
.placeholder-motion-bob {
animation: placeholder-bob 3.4s ease-in-out infinite;
}
.placeholder-motion-flutter {
animation: placeholder-flutter 0.7s ease-in-out infinite;
}
.placeholder-fade-in {
animation: placeholder-fade 1s ease-out 0.3s both;
opacity: 0;
}
}
@keyframes placeholder-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes placeholder-flutter {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(2px, -1px); }
}
@keyframes placeholder-fade {
to { opacity: 1; }
}
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/Placeholder/Placeholder.css
git commit -m "feat(placeholder): add motion keyframes with reduced-motion guard"
```
---
## Task 5: Placeholder component (TDD)
**Files:**
- Create: `web/src/components/Placeholder/index.tsx`
- Test: `web/tests/placeholder-component.test.tsx`
- [ ] **Step 1: Write the failing component tests**
Create `web/tests/placeholder-component.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Placeholder from "@/components/Placeholder";
import { DEFAULT_MESSAGES } from "@/components/Placeholder/messages";
describe("<Placeholder>", () => {
it("renders the default message for variant=empty", () => {
render(<Placeholder variant="empty" />);
expect(screen.getByText(DEFAULT_MESSAGES.empty)).toBeInTheDocument();
});
it("renders the default message for variant=loading", () => {
render(<Placeholder variant="loading" />);
expect(screen.getByText(DEFAULT_MESSAGES.loading)).toBeInTheDocument();
});
it("renders the default message for variant=noResults", () => {
render(<Placeholder variant="noResults" />);
expect(screen.getByText(DEFAULT_MESSAGES.noResults)).toBeInTheDocument();
});
it("renders the default message for variant=notFound", () => {
render(<Placeholder variant="notFound" />);
expect(screen.getByText(DEFAULT_MESSAGES.notFound)).toBeInTheDocument();
});
it("overrides the default message when `message` prop is passed", () => {
render(<Placeholder variant="empty" message="Custom copy goes here" />);
expect(screen.getByText("Custom copy goes here")).toBeInTheDocument();
expect(screen.queryByText(DEFAULT_MESSAGES.empty)).not.toBeInTheDocument();
});
it("renders the ASCII art inside a <pre> with aria-hidden", () => {
const { container } = render(<Placeholder variant="empty" />);
const pre = container.querySelector("pre");
expect(pre).not.toBeNull();
expect(pre).toHaveAttribute("aria-hidden", "true");
expect(pre!.textContent!.length).toBeGreaterThan(0);
});
it("renders a jgs credit string", () => {
render(<Placeholder variant="empty" />);
expect(screen.getByText(/jgs/)).toBeInTheDocument();
});
it('applies role="status" and aria-live="polite" ONLY when variant=loading', () => {
const { rerender, container } = render(<Placeholder variant="empty" />);
expect(container.querySelector('[role="status"]')).toBeNull();
rerender(<Placeholder variant="loading" />);
const live = container.querySelector('[role="status"]');
expect(live).not.toBeNull();
expect(live).toHaveAttribute("aria-live", "polite");
});
it("renders children below the message when provided", () => {
render(
<Placeholder variant="notFound">
<button type="button">Go home</button>
</Placeholder>,
);
expect(screen.getByRole("button", { name: "Go home" })).toBeInTheDocument();
});
it("merges a custom className onto the outer wrapper", () => {
const { container } = render(<Placeholder variant="empty" className="custom-test-class" />);
expect(container.firstChild).toHaveClass("custom-test-class");
});
});
```
- [ ] **Step 2: Run the tests and verify they fail**
```bash
cd web && pnpm test placeholder-component
```
Expected: FAIL — module `@/components/Placeholder` not found.
- [ ] **Step 3: Implement `index.tsx`**
Create `web/src/components/Placeholder/index.tsx`:
```tsx
import { useMemo, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { pickPiece, type MotionStyle, type PlaceholderVariant } from "./ascii-pool";
import { DEFAULT_MESSAGES } from "./messages";
import "./Placeholder.css";
interface PlaceholderProps {
variant: PlaceholderVariant;
message?: string;
children?: ReactNode;
className?: string;
}
const MOTION_CLASS: Record<MotionStyle, string> = {
bob: "placeholder-motion-bob",
flutter: "placeholder-motion-flutter",
none: "",
};
const Placeholder = ({ variant, message, children, className }: PlaceholderProps) => {
// Stable for the lifetime of this mount; re-rolls only if `variant` changes
// (which is rare in practice — most callers pass a constant).
const piece = useMemo(() => pickPiece(variant), [variant]);
const resolvedMessage = message ?? DEFAULT_MESSAGES[variant];
const isLoading = variant === "loading";
return (
<div
role={isLoading ? "status" : undefined}
aria-live={isLoading ? "polite" : undefined}
className={cn("flex flex-col items-center justify-center max-w-md mx-auto px-4 py-8", className)}
>
<pre
aria-hidden="true"
className={cn(
"font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre m-0",
MOTION_CLASS[piece.motion],
)}
>
{piece.ascii}
</pre>
<p className="mt-3 font-mono text-sm text-muted-foreground placeholder-fade-in">
{resolvedMessage}
</p>
<p className="mt-1 font-mono text-[10px] text-muted-foreground/60 placeholder-fade-in">
{piece.credit}
</p>
{children && <div className="mt-4">{children}</div>}
</div>
);
};
export default Placeholder;
```
- [ ] **Step 4: Run the tests and verify they pass**
```bash
cd web && pnpm test placeholder-component
```
Expected: PASS — all ten assertions green.
- [ ] **Step 5: Commit**
```bash
git add web/src/components/Placeholder/index.tsx web/tests/placeholder-component.test.tsx
git commit -m "feat(placeholder): implement <Placeholder> with variant-driven ASCII pool"
```
---
## Task 6: Attribution credits
**Files:**
- Create: `web/src/components/Placeholder/CREDITS.md`
- [ ] **Step 1: Create `CREDITS.md`**
```markdown
# ASCII Art Credits
The ASCII bird illustrations rendered by `<Placeholder>` are from **Joan Stark's**
classic ASCII art collection. Each piece is signed with her `jgs` tag and the
month/year it was published.
- Source archive: https://github.com/oldcompcz/jgs (Joan Stark's ASCII Art Gallery)
- Original site (preserved via WebArchive): https://web.archive.org/web/20091028013825/http://www.geocities.com/SoHo/7373/
- Wikipedia: https://en.wikipedia.org/wiki/Joan_Stark
Joan Stark distributed her art freely on Usenet and the early web. We retain
the `jgs` signature visible beneath each piece in the UI so attribution travels
with the art wherever it is shown.
If you add new ASCII pieces to `ascii-pool.ts`:
- Prefer well-attributed art from established collections.
- Keep the original artist signature in the `credit` field (e.g. `"jgs · 4/97"`).
- If using a different artist, link the source in this file.
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/Placeholder/CREDITS.md
git commit -m "docs(placeholder): credit Joan Stark for ASCII bird art"
```
---
## Task 7: Wire into `Inboxes.tsx` and delete `Empty.tsx`
**Files:**
- Modify: `web/src/pages/Inboxes.tsx`
- Delete: `web/src/components/Empty.tsx`
- [ ] **Step 1: Read the current empty-state block in `Inboxes.tsx`**
Open `web/src/pages/Inboxes.tsx`. The relevant block is at lines 99105:
```tsx
{notifications.length === 0 ? (
<div className="w-full py-16 flex flex-col justify-center items-center">
<Empty />
<p className="mt-4 text-sm text-muted-foreground">
{filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")}
</p>
</div>
) : (
```
The outer `<div className="w-full py-16 flex flex-col justify-center items-center">` and the inner `<p>` both become redundant — `<Placeholder>` handles its own centering and message.
- [ ] **Step 2: Swap the import**
In `web/src/pages/Inboxes.tsx`, replace the import line:
```tsx
import Empty from "@/components/Empty";
```
with:
```tsx
import Placeholder from "@/components/Placeholder";
```
- [ ] **Step 3: Replace the empty-state JSX**
Replace lines 99105 (the existing empty-state block above) with:
```tsx
{notifications.length === 0 ? (
<Placeholder
variant="empty"
message={
filter === "unread"
? t("inbox.no-unread")
: filter === "archived"
? t("inbox.no-archived")
: t("message.no-data")
}
/>
) : (
```
(Only the truthy branch of the ternary changes; leave the `: (` start of the falsy branch and everything below it untouched.)
- [ ] **Step 4: Delete `Empty.tsx`**
```bash
git rm web/src/components/Empty.tsx
```
- [ ] **Step 5: Verify nothing else imports `Empty`**
```bash
cd /Users/steven/Projects/usememos/memos && grep -rn 'from "@/components/Empty"\|from "./Empty"\|from "../Empty"' web/src 2>/dev/null
```
Expected: no output. If anything matches, update that file to use `<Placeholder variant="empty" />` before continuing.
- [ ] **Step 6: Commit**
```bash
git add web/src/pages/Inboxes.tsx web/src/components/Empty.tsx
git commit -m "feat(inboxes): use <Placeholder variant=empty> in place of <Empty>"
```
---
## Task 8: Verification — lint, types, tests, build, dev preview
**Files:** none modified (verification only)
- [ ] **Step 1: Run the lint + typecheck**
```bash
cd web && pnpm lint
```
Expected: exits 0. If Biome flags anything (formatting, sort-order, unused imports), run `pnpm lint:fix` and re-run `pnpm lint`. Commit the fix separately if any changes are required:
```bash
git add -p web/src web/tests
git commit -m "chore(placeholder): biome auto-fixes"
```
- [ ] **Step 2: Run the full test suite**
```bash
cd web && pnpm test
```
Expected: all suites green, including `placeholder-pool` (8 assertions) and `placeholder-component` (10 assertions). Existing tests must still pass.
- [ ] **Step 3: Run the production build**
```bash
cd web && pnpm build
```
Expected: exits 0. Confirms TypeScript still compiles end-to-end and the new CSS import is picked up by Vite.
- [ ] **Step 4: Manual visual check — start the dev server**
```bash
cd web && pnpm dev
```
In a browser, navigate to the running URL (Vite prints it). Sign in with any test account, open the **Inbox** page, and confirm:
1. When inbox is empty, the crested-parrot ASCII bird is visible.
2. It bobs gently every ~3.4 seconds.
3. The message text (one of "no unread", "no archived", or "no data") appears below with a soft fade-in.
4. The small `jgs · 4/97` credit is visible below the message.
5. No console errors or warnings.
Stop the dev server with `Ctrl+C`.
- [ ] **Step 5: (Optional) Reduced-motion check**
Open browser DevTools → command menu → "Emulate CSS prefers-reduced-motion: reduce". Reload the inbox empty state. The bird should be **static** and the message should appear instantly (no fade).
- [ ] **Step 6: No-op commit point**
If steps 14 all passed and no further changes were needed, there is nothing to commit. Proceed to the next task.
---
## Task 9: Document the work in the PR body
**Files:** none modified
- [ ] **Step 1: Draft a PR description**
When opening the PR, use a body like:
```markdown
## Summary
- Adds a new `<Placeholder variant="empty | loading | noResults | notFound">` component that renders a hand-curated ASCII bird from a pool-shaped data file, with subtle CSS-only motion that respects `prefers-reduced-motion`.
- Replaces the single-purpose `Empty.tsx` (used in `Inboxes.tsx`) with `<Placeholder variant="empty">`.
- ASCII art is from Joan Stark's (jgs) classic collection — attribution is preserved on every piece and in a co-located `CREDITS.md`.
## Out of scope (follow-up opportunities)
- Wire `<Placeholder variant="noResults">` into the memo search results page.
- Wire `<Placeholder variant="notFound">` into the router 404 catch-all.
- Wire `<Placeholder variant="loading">` into Suspense fallbacks.
- Seed additional ASCII pieces per variant — the pool architecture supports it; just append entries to `ASCII_POOL`.
## Test plan
- [ ] `pnpm lint` clean
- [ ] `pnpm test` green (incl. new `placeholder-pool` and `placeholder-component` suites)
- [ ] `pnpm build` succeeds
- [ ] Inbox empty state shows the ASCII parrot, bobs, and renders the filter-specific message
- [ ] `prefers-reduced-motion: reduce` produces a static bird and an instantly-visible message
```
- [ ] **Step 2: Open the PR** (skip if user prefers to do this themselves)
This step is left as a manual handoff — do not push or open the PR unless the user has explicitly authorized it.
---
## Self-Review Notes
This plan covers the spec's nine sections as follows:
| Spec section | Implemented by |
|---|---|
| Public Component | Task 5 |
| ASCII Pool | Tasks 1, 2 |
| Default Messages | Task 3 |
| Animation | Task 4 (CSS) + Task 5 (component wires classes) |
| Accessibility | Task 5 (test assertions + impl) |
| File Layout | Tasks 16 (all five files created) |
| Integration | Task 7 (Inboxes rewire + Empty delete) |
| Credits | Task 6 |
| Testing | Tasks 1, 3, 5 (pool tests + component tests) |
No spec section is unimplemented. No "TBD" / "TODO" / vague-handwave language is used in any step. Types, method signatures, and class names referenced across tasks match:
- `PlaceholderVariant`, `MotionStyle`, `AsciiPiece`, `ASCII_POOL`, `pickPiece` — consistent across Tasks 1, 2, 3, 5
- `DEFAULT_MESSAGES` — defined in Task 3, consumed in Task 5
- `placeholder-motion-bob`, `placeholder-motion-flutter`, `placeholder-fade-in` — defined in Task 4 CSS, consumed in Task 5 component
- `cn` from `@/lib/utils` — matches existing codebase convention (verified in pre-plan exploration)
- `Placeholder` is a default export — matches the convention used by `Empty.tsx` and most other `web/src/components/*.tsx` files
@@ -0,0 +1,673 @@
# Remove react-use Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove `react-use` from the frontend while preserving current hook behavior and eliminating the transitive `js-cookie@2.2.1` dependency.
**Architecture:** Replace simple one-off `react-use` helpers with native React hooks inside the consuming components. Add two focused local hooks in `web/src/hooks/` for reused debounce behavior and typed localStorage state. Regenerate the pnpm lockfile from `web/package.json` instead of manually editing lockfile entries.
**Tech Stack:** React 19, TypeScript 6, Vite 8, Vitest 4, Testing Library, pnpm 11.
---
## File Structure
- Create `web/src/hooks/useDebouncedEffect.ts`
- Shared debounce hook for effect-style callbacks.
- Create `web/src/hooks/useLocalStorage.ts`
- Typed localStorage state hook for persisted UI preferences.
- Modify `web/src/hooks/index.ts`
- Export the two new hooks for existing `@/hooks` barrel import style.
- Create `web/tests/hooks.test.tsx`
- Unit tests for the two new local hooks.
- Modify `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- Replace `react-use` debounce import with local `useDebouncedEffect`.
- Modify `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
- Replace deep `react-use` debounce import with local `useDebouncedEffect`.
- Modify `web/src/components/MemoExplorer/TagsSection.tsx`
- Replace deep `react-use` localStorage import with local `useLocalStorage`.
- Modify `web/src/components/TagTree.tsx`
- Replace `useToggle` with native `useState`.
- Modify `web/src/components/MobileHeader.tsx`
- Replace `useWindowScroll` with native `useState` and `useEffect`.
- Modify `web/src/layouts/RootLayout.tsx`
- Replace `usePrevious` with native `useRef` and `useEffect`.
- Modify `web/package.json`
- Remove the direct `react-use` dependency.
- Modify `web/pnpm-lock.yaml`
- Regenerate through pnpm after `react-use` is removed.
---
### Task 1: Add Failing Hook Tests
**Files:**
- Create: `web/tests/hooks.test.tsx`
- [ ] **Step 1: Write tests for local hook behavior**
Create `web/tests/hooks.test.tsx`:
```tsx
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDebouncedEffect, useLocalStorage } from "@/hooks";
describe("useLocalStorage", () => {
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
it("uses the default value when storage is empty", () => {
const { result } = renderHook(() => useLocalStorage("hook-test-empty", false));
expect(result.current[0]).toBe(false);
});
it("reads and writes JSON values", () => {
window.localStorage.setItem("hook-test-existing", JSON.stringify(true));
const { result } = renderHook(() => useLocalStorage("hook-test-existing", false));
expect(result.current[0]).toBe(true);
act(() => {
result.current[1](false);
});
expect(result.current[0]).toBe(false);
expect(window.localStorage.getItem("hook-test-existing")).toBe("false");
});
it("supports updater functions", () => {
const { result } = renderHook(() => useLocalStorage("hook-test-updater", false));
act(() => {
result.current[1]((current) => !current);
});
expect(result.current[0]).toBe(true);
expect(window.localStorage.getItem("hook-test-updater")).toBe("true");
});
it("falls back to the default value for malformed storage", () => {
window.localStorage.setItem("hook-test-malformed", "{bad json");
const { result } = renderHook(() => useLocalStorage("hook-test-malformed", true));
expect(result.current[0]).toBe(true);
});
});
describe("useDebouncedEffect", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("runs the latest callback after the delay", () => {
const calls: string[] = [];
const { rerender } = renderHook(
({ value }) => {
useDebouncedEffect(
() => {
calls.push(value);
},
100,
[value],
);
},
{ initialProps: { value: "first" } },
);
act(() => {
vi.advanceTimersByTime(99);
});
expect(calls).toEqual([]);
rerender({ value: "second" });
act(() => {
vi.advanceTimersByTime(100);
});
expect(calls).toEqual(["second"]);
});
it("clears the pending timeout on unmount", () => {
const calls: string[] = [];
const { unmount } = renderHook(() => {
useDebouncedEffect(
() => {
calls.push("called");
},
100,
[],
);
});
unmount();
act(() => {
vi.advanceTimersByTime(100);
});
expect(calls).toEqual([]);
});
});
```
- [ ] **Step 2: Run tests and verify they fail because hooks do not exist**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
```
Expected: FAIL with missing exports for `useDebouncedEffect` and `useLocalStorage` from `@/hooks`.
- [ ] **Step 3: Commit failing tests**
```bash
git add web/tests/hooks.test.tsx
git commit -m "test: cover local frontend hooks"
```
---
### Task 2: Implement Local Shared Hooks
**Files:**
- Create: `web/src/hooks/useDebouncedEffect.ts`
- Create: `web/src/hooks/useLocalStorage.ts`
- Modify: `web/src/hooks/index.ts`
- Test: `web/tests/hooks.test.tsx`
- [ ] **Step 1: Add `useDebouncedEffect`**
Create `web/src/hooks/useDebouncedEffect.ts`:
```ts
import { type DependencyList, useEffect } from "react";
export const useDebouncedEffect = (effect: () => void | Promise<void>, delay: number, deps: DependencyList): void => {
useEffect(() => {
const timeout = window.setTimeout(() => {
void effect();
}, delay);
return () => {
window.clearTimeout(timeout);
};
}, [delay, ...deps]);
};
```
- [ ] **Step 2: Add `useLocalStorage`**
Create `web/src/hooks/useLocalStorage.ts`:
```ts
import { useCallback, useEffect, useState } from "react";
type SetLocalStorageValue<T> = T | ((currentValue: T) => T);
const readLocalStorageValue = <T>(key: string, defaultValue: T): T => {
if (typeof window === "undefined") {
return defaultValue;
}
try {
const storedValue = window.localStorage.getItem(key);
return storedValue === null ? defaultValue : (JSON.parse(storedValue) as T);
} catch {
return defaultValue;
}
};
export const useLocalStorage = <T>(key: string, defaultValue: T): [T, (value: SetLocalStorageValue<T>) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => readLocalStorageValue(key, defaultValue));
useEffect(() => {
setStoredValue(readLocalStorageValue(key, defaultValue));
}, [key, defaultValue]);
const setValue = useCallback(
(value: SetLocalStorageValue<T>) => {
setStoredValue((currentValue) => {
const nextValue = typeof value === "function" ? (value as (currentValue: T) => T)(currentValue) : value;
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(key, JSON.stringify(nextValue));
} catch {
// Keep React state updated even if persistence is unavailable.
}
}
return nextValue;
});
},
[key],
);
return [storedValue, setValue];
};
```
- [ ] **Step 3: Export hooks from the barrel**
Modify `web/src/hooks/index.ts` to include:
```ts
export * from "./useAsyncEffect";
export * from "./useCurrentUser";
export * from "./useDateFilterNavigation";
export * from "./useDebouncedEffect";
export * from "./useFilteredMemoStats";
export * from "./useLoading";
export * from "./useLocalStorage";
export * from "./useMediaQuery";
export * from "./useMemoFilters";
export * from "./useMemoSorting";
export * from "./useNavigateTo";
export * from "./useUserLocale";
export * from "./useUserTheme";
```
- [ ] **Step 4: Run hook tests and verify they pass**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
```
Expected: PASS for all `useLocalStorage` and `useDebouncedEffect` tests.
- [ ] **Step 5: Commit shared hooks**
```bash
git add web/src/hooks/useDebouncedEffect.ts web/src/hooks/useLocalStorage.ts web/src/hooks/index.ts web/tests/hooks.test.tsx
git commit -m "feat: add local frontend hooks"
```
---
### Task 3: Replace Simple react-use Helpers With React Hooks
**Files:**
- Modify: `web/src/components/TagTree.tsx`
- Modify: `web/src/components/MobileHeader.tsx`
- Modify: `web/src/layouts/RootLayout.tsx`
- [ ] **Step 1: Replace `useToggle` in `TagTree`**
In `web/src/components/TagTree.tsx`, change the imports:
```ts
import { ChevronRightIcon, HashIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
```
Replace the toggle state in `TagItemContainer` with:
```ts
const [showSubTags, setShowSubTags] = useState(false);
useEffect(() => {
setShowSubTags(expandSubTags);
}, [expandSubTags]);
```
Replace `handleToggleBtnClick` with:
```ts
const handleToggleBtnClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setShowSubTags((current) => !current);
}, []);
```
- [ ] **Step 2: Replace `useWindowScroll` in `MobileHeader`**
In `web/src/components/MobileHeader.tsx`, replace the first import with React hooks:
```ts
import { useEffect, useState } from "react";
import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
import NavigationDrawer from "./NavigationDrawer";
```
Inside `MobileHeader`, replace `const { y: offsetTop } = useWindowScroll();` with:
```ts
const [offsetTop, setOffsetTop] = useState(() => {
if (typeof window === "undefined") return 0;
return window.scrollY;
});
useEffect(() => {
const handleScroll = () => {
setOffsetTop(window.scrollY);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
```
- [ ] **Step 3: Replace `usePrevious` in `RootLayout`**
In `web/src/layouts/RootLayout.tsx`, change the React import and remove the `react-use` import:
```ts
import { useEffect, useRef } from "react";
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
```
Replace:
```ts
const prevPathname = usePrevious(pathname);
```
with:
```ts
const prevPathnameRef = useRef<string | undefined>(undefined);
```
Replace the route filter clearing effect with:
```ts
useEffect(() => {
const prevPathname = prevPathnameRef.current;
// When the route changes and there is no filter in the search params, remove all filters.
if (prevPathname !== undefined && prevPathname !== pathname && !searchParams.has("filter")) {
removeFilter(() => true);
}
prevPathnameRef.current = pathname;
}, [pathname, searchParams, removeFilter]);
```
- [ ] **Step 4: Run TypeScript/lint check**
Run:
```bash
cd web
pnpm lint
```
Expected: PASS.
- [ ] **Step 5: Commit simple helper replacements**
```bash
git add web/src/components/TagTree.tsx web/src/components/MobileHeader.tsx web/src/layouts/RootLayout.tsx
git commit -m "refactor: replace simple react-use helpers"
```
---
### Task 4: Replace Debounce And LocalStorage Call Sites
**Files:**
- Modify: `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- Modify: `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
- Modify: `web/src/components/MemoExplorer/TagsSection.tsx`
- Test: `web/tests/hooks.test.tsx`
- [ ] **Step 1: Update `InsertMenu` debounce import and call**
In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`, remove:
```ts
import { useDebounce } from "react-use";
```
Add:
```ts
import { useDebouncedEffect } from "@/hooks";
```
Replace:
```ts
useDebounce(
() => {
setDebouncedPosition(locationState.position);
},
1000,
[locationState.position],
);
```
with:
```ts
useDebouncedEffect(
() => {
setDebouncedPosition(locationState.position);
},
1000,
[locationState.position],
);
```
- [ ] **Step 2: Update `useLinkMemo` debounce import and call**
In `web/src/components/MemoEditor/hooks/useLinkMemo.ts`, remove:
```ts
import useDebounce from "react-use/lib/useDebounce";
```
Add:
```ts
import { useDebouncedEffect } from "@/hooks";
```
Replace:
```ts
useDebounce(
```
with:
```ts
useDebouncedEffect(
```
Keep the existing async callback body, `300` delay, and `[isOpen, searchText]` dependency list unchanged.
- [ ] **Step 3: Update `TagsSection` localStorage import**
In `web/src/components/MemoExplorer/TagsSection.tsx`, replace:
```ts
import useLocalStorage from "react-use/lib/useLocalStorage";
```
with:
```ts
import { useLocalStorage } from "@/hooks";
```
Keep both call sites unchanged:
```ts
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
```
- [ ] **Step 4: Verify no source imports from `react-use` remain**
Run:
```bash
rg -n 'react-use' web/src web/package.json
```
Expected: only `web/package.json` still reports `react-use` before the dependency removal task.
- [ ] **Step 5: Run targeted hook tests and lint**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
pnpm lint
```
Expected: both commands PASS.
- [ ] **Step 6: Commit call-site replacements**
```bash
git add web/src/components/MemoEditor/Toolbar/InsertMenu.tsx web/src/components/MemoEditor/hooks/useLinkMemo.ts web/src/components/MemoExplorer/TagsSection.tsx web/tests/hooks.test.tsx
git commit -m "refactor: use local debounce and storage hooks"
```
---
### Task 5: Remove react-use Dependency And Regenerate Lockfile
**Files:**
- Modify: `web/package.json`
- Modify: `web/pnpm-lock.yaml`
- [ ] **Step 1: Remove `react-use` from `web/package.json`**
In `web/package.json`, remove this dependency line:
```json
"react-use": "^17.6.0",
```
Do not add `js-cookie` as a direct dependency.
- [ ] **Step 2: Regenerate the lockfile**
Run:
```bash
cd web
pnpm install --lockfile-only
```
Expected: command succeeds and updates `web/pnpm-lock.yaml`.
- [ ] **Step 3: Verify dependency graph removal**
Run:
```bash
cd web
pnpm why react-use js-cookie
```
Expected: output does not list installed versions for `react-use` or `js-cookie`.
- [ ] **Step 4: Verify repository text references**
Run:
```bash
rg -n '"react-use"|react-use/lib|react-use@17\.6\.0|^\s+react-use:|js-cookie' web/package.json web/pnpm-lock.yaml web/src
```
Expected: no matches.
- [ ] **Step 5: Commit dependency removal**
```bash
git add web/package.json web/pnpm-lock.yaml
git commit -m "chore: remove react-use dependency"
```
---
### Task 6: Final Verification
**Files:**
- Verify: all files changed by Tasks 1-5
- [ ] **Step 1: Run frontend tests for the new hook coverage**
Run:
```bash
cd web
pnpm test tests/hooks.test.tsx
```
Expected: PASS.
- [ ] **Step 2: Run frontend lint**
Run:
```bash
cd web
pnpm lint
```
Expected: PASS.
- [ ] **Step 3: Run frontend build**
Run:
```bash
cd web
pnpm build
```
Expected: PASS.
- [ ] **Step 4: Confirm no removed dependency remains**
Run:
```bash
cd web
pnpm why react-use js-cookie
rg -n '"react-use"|react-use/lib|react-use@17\.6\.0|^\s+react-use:|js-cookie' src package.json pnpm-lock.yaml
```
Expected: no `react-use` or `js-cookie` dependency remains. The `rg` command returns no matches.
- [ ] **Step 5: Inspect final diff**
Run:
```bash
git diff --stat HEAD~5..HEAD
git status --short
```
Expected: diff contains only hook tests, local hooks, six call-site rewrites, and dependency files. Working tree is clean after all task commits.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,887 @@
# CEL Filter Surface Expansion — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users write three more CEL constructs in the `filter` field — scalar `startsWith()`/`endsWith()` (case-insensitive), `matches(regex)`, and `all()` over tags (non-empty) — each compiled to SQL across SQLite/MySQL/Postgres.
**Architecture:** The `internal/filter` engine parses CEL with `cel-go`, walks the AST into a dialect-agnostic IR (`ir.go`), and renders dialect SQL (`render.go`). cel-go never evaluates — every feature must become a SQL `WHERE` fragment. We add IR nodes + parser recognition + per-dialect rendering, and register a Go-backed `REGEXP` function for SQLite (which has no built-in one).
**Tech Stack:** Go, `github.com/google/cel-go v0.28.0`, `modernc.org/sqlite` (pure-Go), `github.com/stretchr/testify/require`.
**Spec:** `docs/superpowers/specs/2026-06-15-cel-filter-surface-expansion-design.md`
---
## File structure
| File | Change | Responsibility |
|------|--------|----------------|
| `internal/filter/ir.go` | Modify | Replace `ContainsCondition` with `TextMatchCondition`; add `RegexCondition`; add `ComprehensionAll` kind |
| `internal/filter/parser.go` | Modify | Recognize top-level `contains`/`startsWith`/`endsWith`/`matches`; accept `all()` comprehension |
| `internal/filter/render.go` | Modify | Render text-match (LIKE), regex, and `all()` per-element subqueries; shared `foldedLike`/`likePattern`/`escapeLikeLiteral` helpers |
| `internal/filter/schema.go` | Modify | Add `cel.ValidateRegexLiterals()` validator; enable text matching on attachment `mime_type` |
| `internal/filter/engine_test.go` | Modify | Compile-level accept/reject unit tests |
| `store/db/sqlite/functions.go` | Modify | Register a Go-backed `regexp(pattern, value)` scalar function with a compiled-pattern cache |
| `store/db/sqlite/sqlite.go` | Modify | Call `ensureRegexpRegistered()` in `NewDB` |
| `store/test/memo_filter_test.go` | Modify | Behavioral tests for the new memo filters |
| `store/test/attachment_filter_test.go` | Modify | Behavioral tests for `filename`/`mime_type` |
| `internal/filter/README.md` | Modify | Document new syntax + regex cross-dialect caveat |
**Key design choices locked in:**
- The existing `Field.SupportsContains` flag is **reused** as the gate for *all* text-matching ops (`contains`/`startsWith`/`endsWith`/`matches`) — no rename, lower risk. We just enable it on `mime_type`.
- New scalar `startsWith`/`endsWith`/`contains` are **case-insensitive** (reuse the existing `memos_unicode_lower` / `ILIKE` machinery). `matches()` and `==` are case-sensitive.
- `all()` over a memo with zero tags does **not** match (non-empty guard).
- LIKE patterns escape `%` `_` `\`; only SQLite needs an explicit `ESCAPE '\'` clause (Postgres/MySQL default the escape char to backslash, and patterns are passed as bound parameters so no SQL-literal backslash hazard).
**Suggested task order** lands the two cheap features (text-match refactor, scalar prefix/suffix, regex) before the heavy `all()` work, giving a natural stop point. `all()` (Task 5) is the largest piece and could be deferred to a follow-up if needed.
---
### Task 1: Refactor `ContainsCondition` → `TextMatchCondition` (+ LIKE escaping)
Foundation refactor. No new user-facing behavior except that LIKE metacharacters in `contains()` values are now treated literally. Existing tests must stay green.
**Files:**
- Modify: `internal/filter/ir.go` (replace `ContainsCondition`)
- Modify: `internal/filter/parser.go` (`buildContainsCondition` → shared builder)
- Modify: `internal/filter/render.go` (`renderContainsCondition``renderTextMatch` + helpers)
- [ ] **Step 1: Add a failing escaping test** in `internal/filter/engine_test.go`
```go
func TestCompileContainsEscapesLikeWildcards(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `content.contains("50%_off")`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
// The % and _ in the value must be escaped so they are matched literally,
// and SQLite needs an explicit ESCAPE clause.
require.Contains(t, stmt.SQL, `ESCAPE '\'`)
require.Equal(t, []any{`%50\%\_off%`}, stmt.Args)
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `go test ./internal/filter/ -run TestCompileContainsEscapesLikeWildcards -v`
Expected: FAIL (current renderer emits `%50%_off%` with no `ESCAPE`).
- [ ] **Step 3: Replace `ContainsCondition` in `internal/filter/ir.go`**
Delete the `ContainsCondition` struct + its `isCondition()` (lines ~76-82) and add:
```go
// TextMatchMode enumerates LIKE-based string match modes.
type TextMatchMode string
const (
TextMatchContains TextMatchMode = "contains"
TextMatchPrefix TextMatchMode = "prefix"
TextMatchSuffix TextMatchMode = "suffix"
)
// TextMatchCondition models a case-insensitive LIKE match on a scalar string field
// (content.contains/startsWith/endsWith).
type TextMatchCondition struct {
Field string
Mode TextMatchMode
Value string
}
func (*TextMatchCondition) isCondition() {}
```
- [ ] **Step 4: Update the parser in `internal/filter/parser.go`**
In `buildCallCondition`, replace the `case "contains":` line with:
```go
case "contains":
return buildTextMatchCondition(call, schema, TextMatchContains)
```
Delete `buildContainsCondition` (lines ~196-227) and add:
```go
func buildTextMatchCondition(call *exprv1.Expr_Call, schema Schema, mode TextMatchMode) (Condition, error) {
if call.Target == nil {
return nil, errors.New("text match requires a target")
}
targetName, err := getIdentName(call.Target)
if err != nil {
return nil, err
}
field, ok := schema.Field(targetName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", targetName)
}
if !field.SupportsContains {
return nil, errors.Errorf("identifier %q does not support text matching", targetName)
}
if len(call.Args) != 1 {
return nil, errors.New("text match expects exactly one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "text match only supports literal arguments")
}
str, ok := value.(string)
if !ok {
return nil, errors.New("text match argument must be a string")
}
return &TextMatchCondition{Field: targetName, Mode: mode, Value: str}, nil
}
```
- [ ] **Step 5: Update the renderer in `internal/filter/render.go`**
In `renderCondition`, replace `case *ContainsCondition:` / `return r.renderContainsCondition(c)` with:
```go
case *TextMatchCondition:
return r.renderTextMatch(c)
```
Delete `renderContainsCondition` (lines ~449-469) and add:
```go
func (r *renderer) renderTextMatch(cond *TextMatchCondition) (renderResult, error) {
field, ok := r.schema.Field(cond.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
}
column := field.columnExpr(r.dialect)
pattern := likePattern(cond.Mode, cond.Value)
return renderResult{sql: r.foldedLike(column, pattern)}, nil
}
// foldedLike renders a case-insensitive LIKE comparison of colExpr against a
// (already metacharacter-escaped) pattern, using each dialect's case-folding.
func (r *renderer) foldedLike(colExpr, pattern string) string {
switch r.dialect {
case DialectSQLite:
// memos_unicode_lower gives Unicode-aware folding; ESCAPE '\' is required
// because SQLite has no default LIKE escape character.
return fmt.Sprintf(`memos_unicode_lower(%s) LIKE memos_unicode_lower(%s) ESCAPE '\'`, colExpr, r.addArg(pattern))
case DialectPostgres:
// ILIKE is case-insensitive; backslash is the default escape character.
return fmt.Sprintf("%s ILIKE %s", colExpr, r.addArg(pattern))
default: // MySQL: default collation is case-insensitive; backslash is the default escape.
return fmt.Sprintf("%s LIKE %s", colExpr, r.addArg(pattern))
}
}
// likePattern escapes LIKE metacharacters in value and wraps it for the mode.
func likePattern(mode TextMatchMode, value string) string {
escaped := escapeLikeLiteral(value)
switch mode {
case TextMatchPrefix:
return escaped + "%"
case TextMatchSuffix:
return "%" + escaped
default:
return "%" + escaped + "%"
}
}
// escapeLikeLiteral escapes the LIKE metacharacters \, %, and _ so user input
// is matched literally. Backslash is the escape character on all three dialects.
func escapeLikeLiteral(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
```
- [ ] **Step 6: Run the new test + existing suites to verify green**
Run: `go test ./internal/filter/ -v`
Expected: PASS (including `TestCompileContainsEscapesLikeWildcards`).
Run: `go test ./store/test/ -run TestMemoFilterContent -v`
Expected: PASS (existing `contains` behavioral tests, including special-characters/unicode, still pass).
- [ ] **Step 7: Commit**
```bash
git add internal/filter/ir.go internal/filter/parser.go internal/filter/render.go internal/filter/engine_test.go
git commit -m "refactor(filter): unify string matching into TextMatchCondition with LIKE escaping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Scalar `startsWith()` / `endsWith()`
Wire the new prefix/suffix modes through the parser and enable text matching on attachment `mime_type`. Rendering already exists from Task 1.
**Files:**
- Modify: `internal/filter/parser.go` (add `startsWith`/`endsWith` cases)
- Modify: `internal/filter/schema.go` (enable `SupportsContains` on `mime_type`)
- Modify: `store/test/memo_filter_test.go`, `store/test/attachment_filter_test.go` (behavioral tests)
- Modify: `internal/filter/engine_test.go` (reject on unsupported field)
- [ ] **Step 1: Add failing behavioral tests** in `store/test/memo_filter_test.go`
```go
func TestMemoFilterContentStartsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-todo", tc.User.ID).Content("TODO: buy milk"))
tc.CreateMemo(NewMemoBuilder("memo-done", tc.User.ID).Content("Done with milk"))
// Prefix match, case-insensitive (consistent with contains()).
memos := tc.ListWithFilter(`content.startsWith("todo")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-todo", memos[0].UID)
memos = tc.ListWithFilter(`content.startsWith("nope")`)
require.Len(t, memos, 0)
}
func TestMemoFilterContentEndsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-md", tc.User.ID).Content("notes.md"))
tc.CreateMemo(NewMemoBuilder("memo-txt", tc.User.ID).Content("notes.txt"))
memos := tc.ListWithFilter(`content.endsWith(".md")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-md", memos[0].UID)
}
```
And in `store/test/attachment_filter_test.go`:
```go
func TestAttachmentFilterFilenameStartsWith(t *testing.T) {
t.Parallel()
tc := NewAttachmentFilterTestContextWithUser(t)
defer tc.Close()
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("invoice-2026.pdf").MimeType("application/pdf"))
tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.png").MimeType("image/png"))
got := tc.ListWithFilter(`filename.startsWith("invoice")`)
require.Len(t, got, 1)
require.Equal(t, "invoice-2026.pdf", got[0].Filename)
// mime_type prefix matching (newly enabled).
got = tc.ListWithFilter(`mime_type.startsWith("image/")`)
require.Len(t, got, 1)
require.Equal(t, "photo.png", got[0].Filename)
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `go test ./store/test/ -run 'TestMemoFilterContentStartsWith|TestMemoFilterContentEndsWith|TestAttachmentFilterFilenameStartsWith' -v`
Expected: FAIL — `startsWith` hits `buildCallCondition`'s default branch ("unsupported call expression"), and `mime_type` is not yet text-matchable.
- [ ] **Step 3: Add parser cases** in `internal/filter/parser.go` `buildCallCondition`
Immediately after the `case "contains":` line, add:
```go
case "startsWith":
return buildTextMatchCondition(call, schema, TextMatchPrefix)
case "endsWith":
return buildTextMatchCondition(call, schema, TextMatchSuffix)
```
- [ ] **Step 4: Enable text matching on `mime_type`** in `internal/filter/schema.go`
In `NewAttachmentSchema`, add `SupportsContains: true` to the `mime_type` field entry:
```go
"mime_type": {
Name: "mime_type",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "attachment", Name: "type"},
SupportsContains: true,
Expressions: map[DialectName]string{},
},
```
- [ ] **Step 5: Add a compile-reject unit test** in `internal/filter/engine_test.go`
```go
func TestCompileRejectsStartsWithOnUnsupportedField(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `visibility.startsWith("P")`)
require.Error(t, err)
require.Contains(t, err.Error(), "does not support text matching")
}
```
- [ ] **Step 6: Run tests to verify green**
Run: `go test ./internal/filter/ ./store/test/ -run 'StartsWith|EndsWith|TextMatch' -v`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add internal/filter/parser.go internal/filter/schema.go internal/filter/engine_test.go store/test/memo_filter_test.go store/test/attachment_filter_test.go
git commit -m "feat(filter): support startsWith()/endsWith() on scalar string fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Register a SQLite `REGEXP` function
`modernc.org/sqlite` has no built-in `REGEXP`. SQLite desugars `X REGEXP Y` to `regexp(Y, X)`, so register a 2-arg `regexp(pattern, value)` scalar function backed by Go's `regexp`, mirroring `ensureUnicodeLowerRegistered`.
**Files:**
- Modify: `store/db/sqlite/functions.go`
- Modify: `store/db/sqlite/sqlite.go`
- Test: `store/db/sqlite/functions_test.go` (create)
- [ ] **Step 1: Write a failing test** — create `store/db/sqlite/functions_test.go`
```go
package sqlite
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRegexpFunctionMatches(t *testing.T) {
require.NoError(t, ensureRegexpRegistered())
re, err := compileRegexp(`^v\d+$`)
require.NoError(t, err)
require.True(t, re.MatchString("v12"))
require.False(t, re.MatchString("version"))
// Caching returns the same compiled instance.
re2, err := compileRegexp(`^v\d+$`)
require.NoError(t, err)
require.Same(t, re, re2)
_, err = compileRegexp(`(`)
require.Error(t, err)
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `go test ./store/db/sqlite/ -run TestRegexpFunctionMatches -v`
Expected: FAIL — `ensureRegexpRegistered`/`compileRegexp` undefined.
- [ ] **Step 3: Implement in `store/db/sqlite/functions.go`**
Add `"errors"` and `"regexp"` to the imports, then append:
```go
var (
registerRegexpOnce sync.Once
registerRegexpErr error
// regexpCache memoizes compiled patterns; keys are pattern strings.
regexpCache sync.Map
)
// ensureRegexpRegistered registers a Go-backed `regexp(pattern, value)` scalar
// function so SQLite's `value REGEXP pattern` operator works (modernc.org/sqlite
// has no built-in implementation). Patterns use Go's RE2 syntax. Registered once
// globally; safe to call multiple times.
func ensureRegexpRegistered() error {
registerRegexpOnce.Do(func() {
registerRegexpErr = msqlite.RegisterScalarFunction("regexp", 2, func(_ *msqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
if len(args) != 2 || args[0] == nil || args[1] == nil {
return int64(0), nil
}
pattern, ok := args[0].(string)
if !ok {
return nil, errors.New("regexp pattern must be a string")
}
var value string
switch v := args[1].(type) {
case string:
value = v
case []byte:
value = string(v)
default:
return int64(0), nil
}
re, err := compileRegexp(pattern)
if err != nil {
return nil, err
}
if re.MatchString(value) {
return int64(1), nil
}
return int64(0), nil
})
})
return registerRegexpErr
}
// compileRegexp compiles and caches a RE2 pattern.
func compileRegexp(pattern string) (*regexp.Regexp, error) {
if cached, ok := regexpCache.Load(pattern); ok {
return cached.(*regexp.Regexp), nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
regexpCache.Store(pattern, re)
return re, nil
}
```
- [ ] **Step 4: Wire into `NewDB`** in `store/db/sqlite/sqlite.go`
Right after the `ensureUnicodeLowerRegistered()` block, add:
```go
if err := ensureRegexpRegistered(); err != nil {
return nil, errors.Wrap(err, "failed to register sqlite regexp function")
}
```
- [ ] **Step 5: Run to verify green**
Run: `go test ./store/db/sqlite/ -run TestRegexpFunctionMatches -v`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add store/db/sqlite/functions.go store/db/sqlite/sqlite.go store/db/sqlite/functions_test.go
git commit -m "feat(sqlite): register Go-backed REGEXP function
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: `matches(regex)` on string fields
Add the IR node, parser recognition, per-dialect rendering, and the compile-time regex validator.
**Files:**
- Modify: `internal/filter/ir.go` (add `RegexCondition`)
- Modify: `internal/filter/parser.go` (`matches` case + builder)
- Modify: `internal/filter/render.go` (`renderRegex`)
- Modify: `internal/filter/schema.go` (add `cel.ValidateRegexLiterals()` to both schemas)
- Modify: `internal/filter/engine_test.go`, `store/test/memo_filter_test.go`
- [ ] **Step 1: Add failing tests** — compile-level in `internal/filter/engine_test.go`:
```go
func TestCompileRejectsMalformedRegex(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `content.matches("(")`)
require.Error(t, err)
}
func TestCompileMatchesRendersRegexOperator(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `content.matches("v[0-9]+")`, RenderOptions{Dialect: DialectPostgres})
require.NoError(t, err)
require.Contains(t, stmt.SQL, "~")
require.Equal(t, []any{"v[0-9]+"}, stmt.Args)
}
```
And behavioral in `store/test/memo_filter_test.go` (runs against SQLite by default, exercising the registered `REGEXP` function):
```go
func TestMemoFilterContentMatches(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-v1", tc.User.ID).Content("release v12 shipped"))
tc.CreateMemo(NewMemoBuilder("memo-plain", tc.User.ID).Content("no version here"))
memos := tc.ListWithFilter(`content.matches("v[0-9]+")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-v1", memos[0].UID)
memos = tc.ListWithFilter(`content.matches("^xyz")`)
require.Len(t, memos, 0)
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `go test ./internal/filter/ -run 'Malformed|MatchesRenders' -v && go test ./store/test/ -run TestMemoFilterContentMatches -v`
Expected: FAIL — `matches` is unhandled and no regex validator is configured.
- [ ] **Step 3: Add the IR node** in `internal/filter/ir.go`
```go
// RegexCondition models field.matches("pattern") on a string field.
type RegexCondition struct {
Field string
Pattern string
}
func (*RegexCondition) isCondition() {}
```
- [ ] **Step 4: Add parser support** in `internal/filter/parser.go`
In `buildCallCondition`, after the `case "endsWith":` block, add:
```go
case "matches":
return buildMatchesCondition(call, schema)
```
Then add the builder:
```go
func buildMatchesCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error) {
if call.Target == nil {
return nil, errors.New("matches requires a target")
}
targetName, err := getIdentName(call.Target)
if err != nil {
return nil, err
}
field, ok := schema.Field(targetName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", targetName)
}
if !field.SupportsContains {
return nil, errors.Errorf("identifier %q does not support matches()", targetName)
}
if len(call.Args) != 1 {
return nil, errors.New("matches expects exactly one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "matches only supports literal arguments")
}
pattern, ok := value.(string)
if !ok {
return nil, errors.New("matches argument must be a string")
}
return &RegexCondition{Field: targetName, Pattern: pattern}, nil
}
```
- [ ] **Step 5: Add the renderer** in `internal/filter/render.go`
In `renderCondition`, after the `case *TextMatchCondition:` arm, add:
```go
case *RegexCondition:
return r.renderRegex(c)
```
Then add:
```go
func (r *renderer) renderRegex(cond *RegexCondition) (renderResult, error) {
field, ok := r.schema.Field(cond.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
}
column := field.columnExpr(r.dialect)
switch r.dialect {
case DialectPostgres:
// POSIX regex match operator.
return renderResult{sql: fmt.Sprintf("%s ~ %s", column, r.addArg(cond.Pattern))}, nil
case DialectMySQL, DialectSQLite:
// MySQL has a native REGEXP operator; SQLite uses the registered regexp() function.
return renderResult{sql: fmt.Sprintf("%s REGEXP %s", column, r.addArg(cond.Pattern))}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
```
- [ ] **Step 6: Add the regex validator** in `internal/filter/schema.go`
Add the `cel` import line already present. In **both** `NewSchema` and `NewAttachmentSchema`, append the validator to the `envOptions` slice (e.g. after `nowFunction`):
```go
cel.ASTValidators(cel.ValidateRegexLiterals()),
```
- [ ] **Step 7: Run tests to verify green**
Run: `go test ./internal/filter/ -run 'Malformed|MatchesRenders' -v && go test ./store/test/ -run TestMemoFilterContentMatches -v`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add internal/filter/ir.go internal/filter/parser.go internal/filter/render.go internal/filter/schema.go internal/filter/engine_test.go store/test/memo_filter_test.go
git commit -m "feat(filter): support matches() regex on string fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: `all()` comprehension on tags (non-empty)
The heaviest task. `exists()` matches against the *serialized* JSON array and cannot express "every element matches", so `all()` needs real per-element iteration via `json_each` / `jsonb_array_elements_text` / `JSON_TABLE`, plus a non-empty guard.
**Files:**
- Modify: `internal/filter/ir.go` (add `ComprehensionAll`)
- Modify: `internal/filter/parser.go` (accept `all()` in `detectComprehensionKind`)
- Modify: `internal/filter/render.go` (`renderTagAll` + element predicate SQL; branch in `renderListComprehension`)
- Modify: `store/test/memo_filter_test.go`
- [ ] **Step 1: Add failing behavioral tests** in `store/test/memo_filter_test.go`
```go
func TestMemoFilterTagsAll(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-all-work", tc.User.ID).Content("all work").Tags("work/a", "work/b"))
tc.CreateMemo(NewMemoBuilder("memo-mixed", tc.User.ID).Content("mixed").Tags("work/a", "home"))
tc.CreateMemo(NewMemoBuilder("memo-untagged", tc.User.ID).Content("untagged"))
// Every tag starts with "work/": only the all-work memo qualifies.
memos := tc.ListWithFilter(`tags.all(t, t.startsWith("work/"))`)
require.Len(t, memos, 1)
require.Equal(t, "memo-all-work", memos[0].UID)
// Untagged memos must NOT match (non-empty guard, decision B).
require.NotContains(t, uids(memos), "memo-untagged")
}
func TestMemoFilterTagsAllEquals(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-only-x", tc.User.ID).Content("only x").Tags("x", "x"))
tc.CreateMemo(NewMemoBuilder("memo-x-and-y", tc.User.ID).Content("x and y").Tags("x", "y"))
memos := tc.ListWithFilter(`tags.all(t, t == "x")`)
require.Len(t, memos, 1)
require.Equal(t, "memo-only-x", memos[0].UID)
}
```
Add this helper near the top of `store/test/memo_filter_test.go` (after the imports) if not already present:
```go
func uids(memos []*store.Memo) []string {
out := make([]string, 0, len(memos))
for _, m := range memos {
out = append(out, m.UID)
}
return out
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `go test ./store/test/ -run 'TestMemoFilterTagsAll' -v`
Expected: FAIL — `detectComprehensionKind` returns "all() comprehension is not supported".
- [ ] **Step 3: Add the IR kind** in `internal/filter/ir.go`
In the `ComprehensionKind` const block, add `ComprehensionAll`:
```go
const (
ComprehensionExists ComprehensionKind = "exists"
ComprehensionAll ComprehensionKind = "all"
)
```
- [ ] **Step 4: Accept `all()` in the parser** in `internal/filter/parser.go`
In `detectComprehensionKind`, replace the `all()` rejection block:
```go
// all() starts with true and uses AND (&&) - not supported
if accuInit.GetBoolValue() {
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
return "", errors.New("all() comprehension is not supported; use exists() instead")
}
}
```
with:
```go
// all() starts with true and uses AND (&&) in the loop step.
if accuInit.GetBoolValue() {
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
return ComprehensionAll, nil
}
}
```
- [ ] **Step 5: Branch and render in `internal/filter/render.go`**
At the top of `renderListComprehension`, right after the `field.Kind != FieldKindJSONList` guard, add:
```go
if cond.Kind == ComprehensionAll {
return r.renderTagAll(field, cond.Predicate)
}
```
Then add the new render path + element-predicate helper:
```go
// renderTagAll renders tags.all(t, <pred>): the array is non-empty AND no element
// fails the predicate. Element predicates use plain CEL semantics (case-insensitive
// for startsWith/endsWith/contains, case-sensitive for ==), evaluated per element.
func (r *renderer) renderTagAll(field Field, pred PredicateExpr) (renderResult, error) {
arrayExpr := jsonArrayExpr(r.dialect, field)
elemCond, err := r.elementPredicateSQL(pred)
if err != nil {
return renderResult{}, err
}
switch r.dialect {
case DialectSQLite:
nonEmpty := fmt.Sprintf("%s IS NOT NULL AND %s != '[]'", arrayExpr, arrayExpr)
sub := fmt.Sprintf("NOT EXISTS (SELECT 1 FROM json_each(%s) WHERE NOT (%s))", arrayExpr, elemCond)
return renderResult{sql: fmt.Sprintf("(%s AND %s)", nonEmpty, sub)}, nil
case DialectMySQL:
nonEmpty := fmt.Sprintf("%s IS NOT NULL AND JSON_LENGTH(%s) > 0", arrayExpr, arrayExpr)
sub := fmt.Sprintf("NOT EXISTS (SELECT 1 FROM JSON_TABLE(%s, '$[*]' COLUMNS (value VARCHAR(512) PATH '$')) AS elem WHERE NOT (%s))", arrayExpr, elemCond)
return renderResult{sql: fmt.Sprintf("(%s AND %s)", nonEmpty, sub)}, nil
case DialectPostgres:
nonEmpty := fmt.Sprintf("%s IS NOT NULL AND jsonb_array_length(%s) > 0", arrayExpr, arrayExpr)
sub := fmt.Sprintf("NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(%s) AS elem(value) WHERE NOT (%s))", arrayExpr, elemCond)
return renderResult{sql: fmt.Sprintf("(%s AND %s)", nonEmpty, sub)}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
// elementPredicateSQL builds the per-element SQL condition for an all() predicate.
// The iterated element is exposed as the unqualified column `value` on all dialects
// (json_each.value / JSON_TABLE column / elem(value)).
func (r *renderer) elementPredicateSQL(pred PredicateExpr) (string, error) {
switch p := pred.(type) {
case *EqualsPredicate:
return fmt.Sprintf("value = %s", r.addArg(p.Value)), nil
case *StartsWithPredicate:
return r.foldedLike("value", likePattern(TextMatchPrefix, p.Prefix)), nil
case *EndsWithPredicate:
return r.foldedLike("value", likePattern(TextMatchSuffix, p.Suffix)), nil
case *ContainsPredicate:
return r.foldedLike("value", likePattern(TextMatchContains, p.Substring)), nil
default:
return "", errors.Errorf("unsupported predicate %T in all()", pred)
}
}
```
> Note: `foldedLike`, `likePattern`, and `escapeLikeLiteral` were added in Task 1; reuse them as-is.
- [ ] **Step 6: Run tests to verify green**
Run: `go test ./store/test/ -run 'TestMemoFilterTagsAll' -v`
Expected: PASS.
Run: `go test ./store/test/ -run 'TestMemoFilterTagsExists' -v`
Expected: PASS (exists() rendering untouched).
- [ ] **Step 7: Commit**
```bash
git add internal/filter/ir.go internal/filter/parser.go internal/filter/render.go store/test/memo_filter_test.go
git commit -m "feat(filter): support all() comprehension over tags
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Docs + full verification
**Files:**
- Modify: `internal/filter/README.md`
- [ ] **Step 1: Document the new syntax** — append to the "SQL Generation Notes" section of `internal/filter/README.md`:
```markdown
- **String Matching** — `content.contains(x)`, `content.startsWith(x)`, and
`content.endsWith(x)` render as case-insensitive `LIKE`/`ILIKE` with LIKE
metacharacters (`%`, `_`, `\`) escaped. Available on scalar string fields whose
schema sets `SupportsContains` (memo `content`; attachment `filename`,
`mime_type`).
- **Regex** — `field.matches("pattern")` renders to `~` (Postgres) or `REGEXP`
(MySQL/SQLite). SQLite uses a Go-backed `regexp` function registered in
`store/db/sqlite/functions.go`. Patterns are validated at compile time against
Go's RE2 via `cel.ValidateRegexLiterals()`. **Caveat:** regex *syntax* differs
per engine (Go RE2 on SQLite, POSIX ERE on Postgres, ICU on MySQL 8.0+), so
engine-specific patterns may not be portable.
- **Tag `all()`** — `tags.all(t, <pred>)` matches only non-empty tag sets where
every element satisfies the predicate, via per-element iteration
(`json_each` / `jsonb_array_elements_text` / `JSON_TABLE`).
```
- [ ] **Step 2: Run the full engine + store suite (SQLite)**
Run: `go test ./internal/filter/... ./store/...`
Expected: PASS.
- [ ] **Step 3: Vet and lint**
Run: `go vet ./internal/filter/... ./store/db/sqlite/...`
Expected: no output.
Run: `golangci-lint run internal/filter/... store/db/sqlite/...` (if available; skip if the binary is absent).
Expected: no findings.
- [ ] **Step 4: Cross-dialect verification (if Docker/CI DSNs available)**
Run MySQL and Postgres suites to confirm the `all()` subqueries and regex operators render correctly:
```bash
DRIVER=mysql go test ./store/test/ -run 'TagsAll|Matches|StartsWith|EndsWith'
DRIVER=postgres go test ./store/test/ -run 'TagsAll|Matches|StartsWith|EndsWith'
```
Expected: PASS. (These require the project's standard test DB setup; if unavailable locally, rely on CI which runs all three drivers.)
- [ ] **Step 5: Commit**
```bash
git add internal/filter/README.md
git commit -m "docs(filter): document string matching, regex, and tag all() support
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Self-review notes
- **Spec coverage:** ① scalar `startsWith`/`endsWith` → Task 2; ② `all()` non-empty → Task 5; ④ `matches()` + SQLite REGEXP fn + `ValidateRegexLiterals` → Tasks 3-4; the LIKE-escaping fix → Task 1; docs/caveat → Task 6. `lowerAscii`/`upperAscii` correctly omitted (dropped in spec). Hardening/native-AST migration correctly deferred to the follow-up spec.
- **Type consistency:** `TextMatchCondition`/`TextMatchMode`/`likePattern`/`foldedLike`/`escapeLikeLiteral` (Task 1) are reused by Tasks 2 and 5; `RegexCondition`/`renderRegex` (Task 4) and `ensureRegexpRegistered`/`compileRegexp` (Task 3) names match across their call sites; `ComprehensionAll` (Task 5) matches its parser and render references.
- **Element reference:** the unqualified `value` column is produced by `json_each` (SQLite), the `JSON_TABLE(... COLUMNS (value ...))` (MySQL), and `elem(value)` (Postgres), so `elementPredicateSQL` is dialect-agnostic.
```
@@ -0,0 +1,124 @@
# ActivityCalendar: Honor `timeBasis` (Create vs Update)
## Problem
The ActivityCalendar in `web/src/components/ActivityCalendar/` always aggregates by memo creation time, regardless of how the surrounding memo list is sorted.
The application already supports a global "time basis" toggle (`web/src/contexts/ViewContext.tsx:3`):
```ts
export type MemoTimeBasis = "create_time" | "update_time";
```
The toggle is persisted in `localStorage` and drives memo list ordering across the app. When a user switches the list to `update_time`, the heatmap below it continues to show creation counts — the two views literally disagree about what "today" means.
This is the user-visible bug we are fixing.
## Non-goals
The following were considered and explicitly excluded:
- **Tracking every individual edit event.** This would require resurrecting the `activity` table that was deliberately dropped in migration `0.27/03__drop_activity.sql`. The cost (write-path instrumentation, storage growth, privacy review) is not justified by this UI bug.
- **Tracking archive / restore / delete events.** Housekeeping actions, not contributions; would also leak private behavior on public Profile/Explore pages.
- **Adding comments or reactions to the heatmap count.** A reasonable separate feature, but a different decision (event-type expansion). Out of scope for this spec — one ticket, one problem.
- **Renaming `ActivityCalendar` to `ContributionCalendar`.** Out of scope.
## Design
### Semantics
The heatmap aggregates one timestamp per memo:
- When `timeBasis === "create_time"`: use `memo.created_ts` (current behavior).
- When `timeBasis === "update_time"`: use `memo.updated_ts`.
Each memo contributes exactly one cell of color, on the day of its chosen timestamp. This matches the list view's semantics exactly: in `update_time` mode, a memo edited on 5/1 and again on 5/2 appears once at 5/2 in the list, and the heatmap will show +1 on 5/2 and nothing on 5/1. The "lossiness" is identical to the lossiness already accepted by the list view — so by definition, the two are consistent.
### Backend
`UserStats` (proto/api/v1/user_service.proto) gains one field:
```proto
// The latest update timestamps of the user's memos.
repeated google.protobuf.Timestamp memo_updated_timestamps = 8;
```
The implementation mirrors `memo_created_timestamps` in `server/router/api/v1/user_service_stats.go`: in the same loop that appends `memo.CreatedTs` (line 115), also append `memo.UpdatedTs` to a parallel slice. The same `FindMemo` filters apply automatically: `RowStatus: NORMAL` (archived excluded), `ExcludeComments: true`, and the viewer-based visibility filter. Both `GetUserStats` and `ListAllUserStats` paths must be updated symmetrically.
No DB migration. No new tables. No new write paths.
### Frontend
`web/src/hooks/useFilteredMemoStats.ts` reads `useView().timeBasis` and switches its data source:
- `create_time``userStats.memoCreatedTimestamps` (today's behavior, untouched)
- `update_time``userStats.memoUpdatedTimestamps`
The `explore` context branch (which derives stats from the in-memory memo list rather than `userStats`) applies the same switch using `memo.createTime` vs `memo.updateTime` from the cached memos.
The `MonthCalendar` / `YearCalendar` components themselves require no changes — they receive an opaque `Record<date, count>` and render it. The change is confined to the data-source layer.
### Tooltip / labeling
A small but necessary clarification for the user: the cell tooltip should reflect which basis is active, e.g.
- `create_time` mode: "3 memos on May 2"
- `update_time` mode: "3 memos updated on May 2"
This belongs in `ActivityCalendar/utils.ts:getTooltipText`, which already takes a `t` translator. Add a `timeBasis` argument and pick the right i18n key.
## Components
| Unit | Responsibility | Depends on |
|---|---|---|
| `UserStats` proto | Carry both timestamp arrays | — |
| `GetUserStats` server impl | Populate both arrays from `memo` table | store |
| `useFilteredMemoStats` | Pick the correct array based on `timeBasis`; aggregate by day | `useView`, `useUserStats`, `useMemos` |
| `getTooltipText` | Render basis-aware tooltip | i18n |
| `MonthCalendar` / `YearCalendar` | Unchanged — render `Record<date, count>` | — |
## Data flow
```
ViewContext.timeBasis ──┐
useFilteredMemoStats ── pick array ── countBy(day) ── Record<date,count> ── MonthCalendar
userStats ───────────┤ (memo_created_timestamps OR memo_updated_timestamps)
memos cache ─────────┘ (createTime OR updateTime — explore context only)
```
## Error handling
No new failure modes.
`protobuf-es` generates `repeated` fields as non-optional `T[]`, so an older server that doesn't populate the new field deserializes it as `[]` (never `undefined`). Naïvely treating empty as "no data" would be wrong, because a user with zero memos also gets `[]`. Detection uses **length divergence**: since `memo.updated_ts` is initialized to `created_ts` at row creation, the two arrays are the same length whenever there are any memos. So:
- `created.length === 0 && updated.length === 0` — user has no memos, render empty.
- `created.length > 0 && updated.length === created.length` — new server, normal path.
- `created.length > 0 && updated.length === 0` — old server, fall back to `memoCreatedTimestamps` regardless of `timeBasis`, with a one-line `console.warn`.
## Testing
- Unit test `useFilteredMemoStats`: given a fixed `userStats`, switching `timeBasis` returns aggregations matching the expected source array.
- Unit test the new `getTooltipText` branch.
- Manual verification: in dev, toggle the global time basis and confirm:
- Heatmap recomputes
- A memo edited yesterday but created last week shows up "yesterday" in update mode and "last week" in create mode
- Tooltip text reflects the basis
## Migration / compatibility
- Proto field is additive (tag 8 is unused; tag 2 is `reserved`).
- Old clients ignore the new field.
- New clients tolerate old servers via the fallback above.
- No DB migration.
- No data backfill — `updated_ts` already exists on every memo row.
## Out-of-scope follow-ups (not part of this work)
These came up during brainstorming and are tracked here only so they aren't lost:
1. Adding comment / reaction event types to the heatmap count.
2. A "Memo History / Versions" feature (per-edit snapshots, diffs, optional commit messages). If pursued, the heatmap would become a downstream consumer of that history, and the field added here may be revisited.
@@ -0,0 +1,165 @@
# Create memo on selected calendar date — design
**Date:** 2026-05-02
**Scope:** Frontend-only.
## Problem
Clicking a date in the activity calendar filters the memo list to that date but does nothing for the inline editor. To create a memo dated for the selected day, the user must (1) create with today's timestamp, then (2) open the timestamp popover on the saved memo and edit `createTime`. This is a two-step retro-fill that defeats the calendar selection. Empty dates are also not clickable today, so the user cannot start the first memo for an empty day from the calendar at all.
## Goal
When a user picks a calendar date and immediately writes in the home editor, the resulting memo is created on that date — single-step.
## Non-goals
- Backend changes. The API already accepts custom `createTime` and `updateTime`.
- Changes to the comment/reply editor or the edit-mode editor.
- Changes outside the home page's editor render site (Explore/Archived/Profile pages have no editor).
- Reworking the timestamp popover UI itself.
- Empty-state copy changes on non-Home pages when an empty date is selected.
## User-visible behavior
1. **Empty calendar dates are clickable.** Clicking a date with zero memos sets the `displayTime` filter the same way a populated date does. Tooltip and selection ring still work.
2. **When the home editor renders with an active `displayTime` filter:**
- The `TimestampPopover` (already used in edit mode) appears in create mode, pre-populated with the selected date.
- The draft's `createTime` is set to **selected local date + current local hh:mm:ss** (e.g., picking May 1 at 14:32 → `2025-05-01 14:32`).
- The draft's `updateTime` is set to the same value, to avoid the saved memo immediately reading "updated today" relative to a back-dated `createTime`.
- The user can adjust either field via the popover before saving.
3. **When no `displayTime` filter is active**, the editor is identical to today: no popover in create mode, no override, server stamps with "now".
4. **Live derivation.** If the filter changes while a draft is in progress, the editor's prefilled timestamps re-sync to the new date. The popover stays visible so the change is observable. (Manual popover edits before the next filter change are overwritten — chosen tradeoff.)
5. **Future dates are allowed** (e.g., May 15 when today is May 2). Backend already accepts future timestamps.
6. **Other contexts** (Explore/Archived/Profile) gain empty-date clickability for navigation consistency, but have no editor and so no prefill behavior.
## Architecture
Four touch points, all in `web/src/`:
| File | Change |
|------|--------|
| `components/ActivityCalendar/CalendarCell.tsx` | Drop `day.count > 0` gate so empty in-month cells are clickable. |
| `components/MemoEditor/index.tsx` | Accept `defaultCreateTime?: Date` prop; render `TimestampPopover` in create mode when set; sync state on prop change. |
| `components/MemoEditor/utils/deriveDefaultCreateTime.ts` (new) | Pure helper: `(filters, now?) => Date \| undefined` derived from any `displayTime` filter. |
| `components/PagedMemoList/PagedMemoList.tsx` | At the home-editor render site (line 155), read `MemoFilterContext`, compute `defaultCreateTime`, pass as prop. |
### Data flow
```
CalendarCell click
→ useDateFilterNavigation
→ URL ?filter=displayTime:YYYY-MM-DD
→ MemoFilterContext re-renders
→ PagedMemoList recomputes defaultCreateTime via deriveDefaultCreateTimeFromFilters(filters)
→ <MemoEditor defaultCreateTime={...}> re-renders
→ editor reducer syncs state.timestamps (create + update) and renders TimestampPopover
→ save → memoService.ts:111 sends createTime/updateTime to API
```
## Component contracts
### `MemoEditor` — new prop
```ts
interface MemoEditorProps {
// ...existing props
/**
* When set in create mode (no `memo` prop), seeds the draft's
* createTime/updateTime and reveals the TimestampPopover so the
* user can adjust. Tracked live: changes after mount re-sync state.
* Ignored in edit mode (when `memo` is set).
*/
defaultCreateTime?: Date;
}
```
Internal behavior:
- On `INIT_MEMO` for create mode, if `defaultCreateTime` is set, payload `timestamps` is `{ createTime: defaultCreateTime, updateTime: defaultCreateTime }`.
- A `useEffect` keyed on `[defaultCreateTime?.getTime(), memo]` dispatches `SET_TIMESTAMPS` whenever the prop changes in create mode.
- Popover render condition becomes `memoName || (!memo && state.timestamps.createTime)`.
### `deriveDefaultCreateTimeFromFilters` — pure helper
```ts
// web/src/components/MemoEditor/utils/deriveDefaultCreateTime.ts
export function deriveDefaultCreateTimeFromFilters(
filters: MemoFilter[],
now: Date = new Date(),
): Date | undefined {
const dateFilter = filters.find((f) => f.factor === "displayTime");
if (!dateFilter) return undefined;
const [y, m, d] = dateFilter.value.split("-").map(Number);
if (!y || !m || !d) return undefined;
return new Date(y, m - 1, d, now.getHours(), now.getMinutes(), now.getSeconds());
}
```
Notes:
- Defensive parse — returns `undefined` for malformed values rather than throwing.
- `now` is injectable for deterministic tests.
- Multiple `displayTime` filters are not produced by current UI; `find` ignores extras safely.
### `PagedMemoList.tsx` — call-site change
```tsx
const { filters } = useMemoFilterContext();
const defaultCreateTime = useMemo(
() => deriveDefaultCreateTimeFromFilters(filters),
[filters],
);
// ...
{showMemoEditor ? (
<MemoEditor
className="mb-2"
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
) : null}
```
`useMemo` keyed on `filters` keeps the reference stable when the filter doesn't change, avoiding unnecessary editor re-syncs. `now` is captured once per filter change — matches "the local time when you picked the date".
### `CalendarCell.tsx` — empty-cell clickability
- `handleClick`: drop the `day.count > 0` check; just call `onClick(day.date)` if `onClick` is provided.
- `isInteractive`: `Boolean(onClick)`.
- `tabIndex` / `aria-disabled` / hover-cursor classes follow the new `isInteractive`.
- `shouldShowTooltip`: drop the `day.count > 0` gate; tooltip text already conveys the count.
- Out-of-month cells (existing early return) stay unclickable.
- The `selected` ring already works on count=0 cells. Visual contrast on the lowest-intensity background may need a small ring-weight bump in light theme; eyeball during implementation.
## Edge cases
- **No filter / filter cleared:** `defaultCreateTime` becomes `undefined`; editor falls back to current behavior.
- **User edits draft, then re-picks date:** live-derived; editor's `createTime` updates, popover reflects new value.
- **User manually edits via popover, then changes filter:** prop sync overwrites manual edit. Acceptable per design choice; popover keeps the change observable.
- **Draft cache (`cacheKey="home-memo-editor"`):** caches `content`, not `timestamps`. Reload restores text but `createTime` is freshly derived from current filter — consistent.
- **Future dates:** allowed. No clamp.
- **DST / timezone:** date arithmetic uses local time (`new Date(y, m-1, d, h, mi, s)`), matching `useDateFilterNavigation`'s local-date convention. Server receives an absolute `Timestamp`.
- **Comment editor (`MemoCommentSection`):** doesn't pass `defaultCreateTime` → no behavior change.
- **Edit mode (`memo` prop set):** prop is ignored; existing edit-mode popover is unchanged.
- **Empty-date click on Explore/Archived/Profile:** filters to empty date → "no memos" empty state. Acceptable.
## Testing
- **Unit (Vitest)** for `deriveDefaultCreateTimeFromFilters`:
- no `displayTime` filter → `undefined`
- valid `displayTime:2025-05-01` + injected `now=14:32:10``2025-05-01 14:32:10` local
- malformed value (`"not-a-date"`, `"2025-13-40"`) → `undefined`
- extra non-`displayTime` filters present → still works
- **Component (React Testing Library) for `CalendarCell`:** count=0 in-month cell is clickable, has correct `tabIndex`/`aria-disabled`, fires `onClick` with date.
- **Component for `MemoEditor`:** with `defaultCreateTime` prop, popover renders in create mode and `state.timestamps.createTime` matches; without prop, no popover; changing the prop re-syncs state.
- **Manual smoke (per CLAUDE.md UI-changes rule):** `pnpm dev`, click a non-today date (with and without existing memos), type a memo, save, confirm it appears under that date. Clear the filter chip; confirm a new memo posts to today.
## Risks
- The `useEffect` re-sync overwriting an in-progress popover edit is a *chosen* behavior. If users later complain, a "manual override sticky" flag is the natural follow-up. Not pre-built.
- Selection-ring contrast on the lowest-intensity background may need a small visual tweak; flagged for implementation.
## Out of scope (explicit)
- Sticky manual-override semantics for the popover.
- New empty-state copy on Explore/Archived/Profile when filtering to a date with zero memos.
- Any backend/API change.
- Any change to the comment editor, edit mode, or non-Home editor sites.
@@ -0,0 +1,511 @@
# STT and Audio-LLM Split — Design Spec
**Date:** 2026-05-02
**Status:** Draft, pending user review
## 1. Goal
Refactor `internal/ai/` to split **speech-to-text (STT)** and **audio-multimodal-LLM (Audio-LLM)** into two separate Go interfaces, aligning with mainstream OSS conventions (Vercel AI SDK, LiteLLM, the Go AI ecosystem). Update the public API handler to dispatch to the right interface based on provider type. Make two small comment improvements to `proto/store/instance_setting.proto` and the generated bindings; **no proto field changes**.
## 2. Non-Goals
The following are intentionally **out of scope** for this design:
- **`enabled` boolean field on `TranscriptionConfig`** (improvement #1 from the brainstorming) — keep using `provider_id == ""` as the disabled signal.
- **Direction C (audio → structured note pipeline)** — auto-summarization / tag extraction. Independent future feature.
- **Multi-provider STT with default-model selector** (Dify-style) — Memos has a single transcription config; that stays.
- **Per-model credential overrides**, **load balancing**, **capability YAML schemas** — Dify-style enterprise complexity.
- **Streaming transcription, retry policy, OpenAI Translations endpoint** — YAGNI.
- **`gpt-4o-audio-preview` user-facing support** — the `audiollm/openai` package will be implementable after this refactor, but UI support is a follow-up.
- **TTS** (text-to-speech) — different concern, not affected.
## 3. Background
The current `internal/ai/` has one `Transcriber` interface with two implementations: `openAITranscriber` (calls `/audio/transcriptions`, a real STT endpoint) and `geminiTranscriber` (calls `generateContent`, a multimodal-LLM endpoint dressed up to act like STT). This conflation has caused real symptoms:
- `TranscribeResponse.Language` and `Duration` are silently empty for Gemini (Gemini multimodal doesn't return them).
- The `Prompt` field has different semantics across providers — Whisper treats it as a soft hint that may be ignored; Gemini treats it as a literal instruction.
- Gemini's multimodal failure modes (safety filter, token-truncation, refusals) are flattened to a single "did not include text" error.
- Gemini-specific code (WebM transcoding, `maxGeminiInlineAudioSize`, `genai` SDK) lives in the same package as the OpenAI Whisper integration.
The brainstorming session (this conversation, 2026-05-02) ran two rounds of OSS research to validate the corrective direction.
## 4. Research Findings Summary
Detailed findings in conversation history; abridged here for design accountability.
### 4.1 SDK-Layer Research
| Source | Key Decision |
|---|---|
| **Vercel AI SDK** (`vercel/ai`) | `TranscriptionModelV3` is implemented **only** by providers with a dedicated STT endpoint (OpenAI Whisper/gpt-4o-transcribe, Deepgram, ElevenLabs, AssemblyAI). **Google provider deliberately does not implement it** — Gemini audio rides through `generateText` with `FilePart`. Two completely separate code paths. No "source" discriminator. Provider id is `vendor.modality` (`openai.transcription`); model is a free string. |
| **LiteLLM** (`BerriAI/litellm`) | `litellm.transcription()` only routes to providers with `/audio/transcriptions`-style endpoints. **Gemini is absent** from the transcription router (`litellm/llms/gemini/` has no `audio_transcription/` subdirectory). Multimodal audio rides through `litellm.completion()` with `{"type":"input_audio"}` content parts. Response is `text + usage`, no provider discriminator. |
| **Go AI SDKs** (`cloudwego/eino`, `tmc/langchaingo`, `sashabaranov/go-openai`) | One package per provider; provider identity = import path; **no provider enum**. `Model` is opaque string. OpenAI-compatible endpoints handled via `BaseURL` config field, never via separate package. go-openai's `audio.go` is structurally separate from `chat.go`. |
**Convergent finding:** All three ecosystems split STT and multimodal-audio into separate interfaces. None expose a "this came from a multimodal LLM" discriminator. None encode wire-format into the provider type enum.
### 4.2 Application-Layer Research
| Source | STT-Storage Design |
|---|---|
| **Open WebUI** | STT is a **flat singleton config block** (`audio.stt.*` namespace), completely separate from chat providers (`openai.*` namespace). `STT_ENGINE` enum dispatches; per-engine credentials side-by-side in one config. |
| **LobeChat** | STT is a **separate global user setting** (`UserTTSConfig`). But credentials silently piggyback on the `openai` chat provider's `keyVaults` — author has marked the helper `@deprecated`. |
| **Dify** | `ProviderEntity.supported_model_types` declares capabilities; STT is the `SPEECH2TEXT` enum value. STT info lives in a **separate "system model" config row** (`tenant_default_models(model_type='speech2text', provider_name, model_name)`) that **references** an existing provider. |
**Convergent finding:** Zero apps add STT-specific fields onto the AI provider entity. All three keep providers capability-agnostic and put STT config in a separate place.
### 4.3 Proto Schema Assessment
The current `proto/store/instance_setting.proto` `InstanceAISetting` + `TranscriptionConfig` is **already aligned with the mainstream pattern**:
-`AIProviderConfig` carries no STT-specific field (capability-agnostic)
-`TranscriptionConfig` is a separate pointer (`provider_id` references a provider)
-`AIProviderType` is vendor-level (`OPENAI`, `GEMINI`) — no wire-format suffix
-`model` is a free string
- ✅ Comments already document Whisper vs Gemini prompt semantics (though could be clearer)
The proto schema requires **no field changes**. Only two comment improvements (§7 below).
## 5. Current State (Files Touched)
```
internal/ai/
ai.go # ProviderType, ProviderConfig, errors
client.go # NewTranscriber factory, transcriberOptions, normalizeEndpoint, requireAPIKey
transcription.go # Transcriber interface, TranscribeRequest, TranscribeResponse
openai.go # openAITranscriber → /audio/transcriptions
openai_test.go
gemini.go # geminiTranscriber → generateContent (multimodal)
gemini_test.go
models.go # DefaultOpenAITranscriptionModel, DefaultGeminiTranscriptionModel
resolver.go # FindProvider
errors.go # ErrProviderNotFound, ErrCapabilityUnsupported
audio/
webm.go # IsWebMContentType, WebMOpusToWAV (used by Gemini path)
webm_test.go
server/router/api/v1/
ai_service.go # Transcribe handler (lines 42123)
proto/store/
instance_setting.proto # InstanceAISetting, AIProviderConfig, AIProviderType, TranscriptionConfig
web/src/components/Settings/
AISection.tsx # Provider list UI, TranscriptionForm
```
The handler at `server/router/api/v1/ai_service.go:42` is the **single integration point** between proto config and the `internal/ai/` SDK. It already discards Language/Duration (returns `{Text}` only), so the response narrowing is already in place.
## 6. Target Design
### 6.1 Package Structure
```
internal/ai/
ai.go # ProviderType (unchanged: OPENAI, GEMINI), ProviderConfig (unchanged)
resolver.go # FindProvider (unchanged)
errors.go # add ErrSTTNotSupported, ErrAudioLLMNotSupported
audio/
webm.go # unchanged — moves with audiollm/gemini consumer
webm_test.go
stt/
stt.go # Transcriber interface, Request, Response, Segment
factory.go # NewTranscriber(cfg ai.ProviderConfig, opts...) (Transcriber, error)
options.go # TranscriberOption, WithHTTPClient
openai/
openai.go # openAITranscriber → POST /audio/transcriptions
openai_test.go
audiollm/
audiollm.go # Model interface, Request, Response, FinishReason
factory.go # NewModel(cfg ai.ProviderConfig, opts...) (Model, error)
options.go # ModelOption, WithHTTPClient
gemini/
gemini.go # geminiModel → POST :generateContent (multimodal audio)
gemini_test.go
# openai/ — NOT created in this refactor; reserved for future gpt-4o-audio support
```
**Rationale (Go-ecosystem convention, per §4.1):** one package per provider; provider identity is import path; capability is implied by which umbrella package (`stt` vs `audiollm`) you import from. The runtime dispatch (factory) is the only place that translates `ProviderConfig.Type` enum → concrete implementation.
### 6.2 Interfaces
#### `internal/ai/stt/stt.go`
```go
package stt
import (
"context"
"io"
)
// Transcriber transcribes audio into text using a provider's dedicated STT endpoint
// (e.g. OpenAI /audio/transcriptions). Implementations are deterministic STT —
// they are NOT for multimodal LLMs that happen to accept audio input. For
// multimodal audio understanding, see internal/ai/audiollm.
type Transcriber interface {
Transcribe(ctx context.Context, req Request) (*Response, error)
}
type Request struct {
Audio io.Reader
Size int64
Filename string
ContentType string // IANA media type, e.g. "audio/wav"
Model string // provider-specific model id (e.g. "whisper-1", "gpt-4o-transcribe")
Prompt string // soft spelling/vocabulary hint (Whisper "prompt" parameter)
Language string // ISO 639-1, optional
}
type Response struct {
Text string
Language string // empty if provider did not return it (best-effort)
Segments []Segment // empty unless provider returned timestamps
}
type Segment struct {
Text string
Start float64
End float64
Speaker string // empty unless using a diarization-capable model (e.g. gpt-4o-transcribe-diarize)
}
```
#### `internal/ai/audiollm/audiollm.go`
```go
package audiollm
import (
"context"
"io"
)
// Model invokes a multimodal LLM with audio input. Implementations call
// chat-completions or generate-content style APIs that happen to accept audio.
// They are NOT deterministic STT — outputs may be refused, truncated, or
// rephrased per the LLM's behavior. For pure transcription, prefer
// internal/ai/stt where available.
type Model interface {
GenerateFromAudio(ctx context.Context, req Request) (*Response, error)
}
type Request struct {
Audio io.Reader
Size int64
ContentType string
Model string
Instructions string // literal instruction the model is expected to follow
Temperature *float32 // optional; nil leaves provider default
}
type Response struct {
Text string
FinishReason FinishReason
}
type FinishReason string
const (
FinishStop FinishReason = "stop" // model finished normally
FinishLength FinishReason = "length" // truncated by max-tokens
FinishSafety FinishReason = "safety" // safety filter blocked output
FinishOther FinishReason = "other" // anything else (incl. unknown)
)
```
#### Factory dispatch
```go
// internal/ai/stt/factory.go
package stt
import (
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/stt/openai"
)
func NewTranscriber(cfg ai.ProviderConfig, opts ...TranscriberOption) (Transcriber, error) {
switch cfg.Type {
case ai.ProviderOpenAI:
return openai.New(cfg, applyOptions(opts...))
case ai.ProviderGemini:
return nil, errors.Wrapf(ai.ErrSTTNotSupported,
"Gemini does not provide a dedicated STT endpoint; use audiollm.NewModel instead")
default:
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
}
}
```
```go
// internal/ai/audiollm/factory.go
package audiollm
import (
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/audiollm/gemini"
)
func NewModel(cfg ai.ProviderConfig, opts ...ModelOption) (Model, error) {
switch cfg.Type {
case ai.ProviderGemini:
return gemini.New(cfg, applyOptions(opts...))
case ai.ProviderOpenAI:
// NOTE: gpt-4o-audio-preview support belongs here but is out of scope;
// see §2 (Non-Goals).
return nil, errors.Wrapf(ai.ErrAudioLLMNotSupported,
"OpenAI multimodal audio (gpt-4o-audio) is not yet implemented in this codebase")
default:
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
}
}
```
### 6.3 Backend Handler Dispatch
The handler at `server/router/api/v1/ai_service.go:Transcribe` dispatches based on `provider.Type`:
```go
func (s *APIV1Service) Transcribe(ctx context.Context, request *v1pb.TranscribeRequest) (*v1pb.TranscribeResponse, error) {
// ... existing config loading, provider resolution, audio reading ...
switch provider.Type {
case ai.ProviderOpenAI:
text, err := s.transcribeViaSTT(ctx, provider, transcriptionCfg, audio, contentType)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to transcribe: %v", err)
}
return &v1pb.TranscribeResponse{Text: text}, nil
case ai.ProviderGemini:
text, err := s.transcribeViaAudioLLM(ctx, provider, transcriptionCfg, audio, contentType)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to transcribe: %v", err)
}
return &v1pb.TranscribeResponse{Text: text}, nil
default:
return nil, status.Errorf(codes.FailedPrecondition,
"provider type %q is not supported for transcription", provider.Type)
}
}
func (s *APIV1Service) transcribeViaSTT(ctx context.Context, provider ai.ProviderConfig,
cfg *storepb.TranscriptionConfig,
audio io.Reader, contentType string) (string, error) {
t, err := stt.NewTranscriber(provider)
if err != nil { return "", err }
resp, err := t.Transcribe(ctx, stt.Request{
Audio: audio,
Filename: "audio",
ContentType: contentType,
Model: resolveModel(provider, cfg.Model),
Prompt: cfg.Prompt, // Whisper: soft hint, may be ignored
Language: cfg.Language,
})
if err != nil { return "", err }
return resp.Text, nil
}
func (s *APIV1Service) transcribeViaAudioLLM(ctx context.Context, provider ai.ProviderConfig,
cfg *storepb.TranscriptionConfig,
audio io.Reader, contentType string) (string, error) {
m, err := audiollm.NewModel(provider)
if err != nil { return "", err }
resp, err := m.GenerateFromAudio(ctx, audiollm.Request{
Audio: audio,
ContentType: contentType,
Model: resolveModel(provider, cfg.Model),
Instructions: buildTranscriptionInstructions(cfg.Prompt, cfg.Language),
})
if err != nil { return "", err }
if resp.FinishReason != audiollm.FinishStop {
return "", errors.Errorf("transcription incomplete (finish reason: %s)", resp.FinishReason)
}
return resp.Text, nil
}
```
`buildTranscriptionInstructions` lives next to the handler and centralizes the literal instruction sent to multimodal LLMs:
```go
func buildTranscriptionInstructions(prompt, language string) string {
parts := []string{
"Transcribe the audio accurately. Return only the transcript text. " +
"Do not summarize, explain, or add content that is not spoken.",
}
if language != "" {
parts = append(parts, fmt.Sprintf("The input language is %s.", language))
}
if prompt != "" {
parts = append(parts, "Context and spelling hints:\n"+prompt)
}
return strings.Join(parts, "\n\n")
}
```
`resolveModel` returns `cfg.Model` if non-empty, else the per-provider default from `ai/models.go` (unchanged from today).
### 6.4 Implementation Notes Per Package
#### `internal/ai/stt/openai/openai.go`
- Identical wire behavior to current `internal/ai/openai.go::openAITranscriber.Transcribe`.
- Uses `github.com/openai/openai-go/v3` SDK (already a dep).
- Defaults `endpoint` to `https://api.openai.com/v1`. Trims trailing slash. Validates URL.
- Honors `cfg.Endpoint` to support OpenAI-compatible providers (Groq Whisper, faster-whisper self-hosted, Azure Whisper deployments). The user simply adds another `AIProviderConfig` row with `Type=OPENAI` and a different `Endpoint`.
- Supports any model the underlying endpoint accepts: `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `gpt-4o-transcribe-diarize`, etc. The model string is opaque.
- Returns `Response.Language` and `Response.Segments` populated when the API returns them; otherwise empty.
#### `internal/ai/audiollm/gemini/gemini.go`
- Identical wire behavior to current `internal/ai/gemini.go::geminiTranscriber.Transcribe`, EXCEPT:
- Reads `Instructions` from the caller (not hardcoded inside the package).
- Maps `genai.FinishReason` to `audiollm.FinishReason` (`STOP→FinishStop`, `MAX_TOKENS→FinishLength`, `SAFETY→FinishSafety`, anything else → `FinishOther`).
- Returns `Response{Text, FinishReason}` instead of swallowing the finish reason into a generic error.
- Continues to use `internal/ai/audio.WebMOpusToWAV` for WebM transcoding (Gemini doesn't accept WebM).
- Continues to enforce `maxGeminiInlineAudioSize` (14 MiB) — File API support is out of scope.
- Uses `google.golang.org/genai` SDK (already a dep).
#### `internal/ai/errors.go`
Add:
```go
var ErrSTTNotSupported = errors.New("provider does not support speech-to-text capability")
var ErrAudioLLMNotSupported = errors.New("provider does not support multimodal audio capability")
```
Keep existing `ErrProviderNotFound` and `ErrCapabilityUnsupported`.
### 6.5 Proto Schema Changes
**Two comment-only updates. No field additions, no field renames, no breaking changes.**
#### Improvement #2 — `TranscriptionConfig.model` comment
Replace lines 179181 of `proto/store/instance_setting.proto`:
```proto
// model is the provider-specific model identifier.
// Empty string falls back to the engine default
// (whisper-1 for OPENAI providers, gemini-2.5-flash for GEMINI providers).
string model = 2;
```
with:
```proto
// model is the provider-specific model identifier.
// Empty string falls back to the engine default.
// OPENAI examples:
// - whisper-1 (legacy, lower cost)
// - gpt-4o-transcribe, gpt-4o-mini-transcribe (higher quality)
// - gpt-4o-transcribe-diarize (includes speaker labels)
// GEMINI examples:
// - gemini-2.5-flash (default, multimodal call)
// - gemini-2.5-pro
string model = 2;
```
**Rationale:** OpenAI's `/audio/transcriptions` endpoint now supports the `gpt-4o-transcribe` family in addition to `whisper-1`. The current comment is misleading — it implies Whisper is the only OpenAI option.
#### Improvement #3 — `TranscriptionConfig.prompt` comment
Replace lines 188191:
```proto
// prompt is a default spelling/vocabulary hint passed to the provider.
// Used as the OpenAI Whisper "prompt" parameter and folded into the Gemini
// generation prompt as a "Context and spelling hints" block.
string prompt = 4;
```
with:
```proto
// prompt is a default spelling/vocabulary hint passed to the provider.
// Used as the OpenAI Whisper "prompt" parameter (a soft hint that the model
// may ignore) and folded into the Gemini generation prompt as a "Context and
// spelling hints" block (which the LLM will treat more literally).
string prompt = 4;
```
**Rationale:** Same field, two semantically different behaviors. Surfacing this in the schema documentation (which propagates to generated Go and TypeScript via JSDoc) makes the cross-provider variability explicit for any caller reading the bindings cold.
After editing the proto, regenerate via `cd proto && buf format -w && buf generate`. The two regenerated files are:
- `proto/gen/store/instance_setting.pb.go`
- `web/src/types/proto/store/instance_setting_pb.ts`
#### Why NOT add an `enabled` field (improvement #1)
Out of scope per §2. Doing it would add a new field that the frontend, backend, and migration logic all need to handle, for the sole benefit of letting users "disable but keep the config." The current `provider_id == ""` semantics work; the cost of the change exceeds the benefit at this moment.
### 6.6 Frontend Impact
Minimal. `web/src/components/Settings/AISection.tsx` already:
- Switches the model placeholder per provider (`placeholderForProvider` at line 371, using `setting.ai.transcription-model-placeholder-gemini` / `-openai`).
- Disables the form when `providerId == ""`.
- Validates that the referenced provider exists.
Recommended adjustments (in scope):
1. **Update i18n model placeholder strings** in `web/src/locales/en.json` to reflect the new model examples:
- `setting.ai.transcription-model-placeholder-openai`: include `gpt-4o-transcribe` family alongside `whisper-1`.
- `setting.ai.transcription-model-placeholder-gemini`: confirm `gemini-2.5-flash` is the listed example.
2. **Update the prompt help text** (`setting.ai.transcription-prompt-help`) to note the cross-provider semantic difference, mirroring the new proto comment in user-facing language.
No structural component changes. No new fields. No state-shape changes.
### 6.7 What Stays Identical
- Database storage (`InstanceSetting` rows, `AISetting` blob) — proto field tags unchanged.
- API surface (`TranscribeRequest`, `TranscribeResponse` messages) — unchanged.
- gRPC/Connect endpoint paths — unchanged.
- Frontend state shape (`LocalTranscription`) — unchanged.
- All existing tests semantically unchanged (will be ported to new package paths).
## 7. Migration Path
The refactor is internal to the Go server. End-to-end behavior is preserved. Migration is staged so each stage is independently buildable, testable, and revertable.
| Stage | What | Compiles? | Tests pass? |
|---|---|---|---|
| A | Add `internal/ai/stt/` and `internal/ai/audiollm/` with new interfaces and (empty) factories. Add new errors. | ✅ | ✅ (no callers yet) |
| B | Implement `internal/ai/stt/openai/` — port behavior from current `openai.go::openAITranscriber`. Port tests to `stt/openai/openai_test.go`. | ✅ | ✅ |
| C | Implement `internal/ai/audiollm/gemini/` — port behavior from current `gemini.go::geminiTranscriber`, but: lift instructions out into the caller, return `FinishReason` instead of swallowing it. Port tests. | ✅ | ✅ |
| D | Refactor `server/router/api/v1/ai_service.go::Transcribe` to dispatch via the new factories. Add `transcribeViaSTT` and `transcribeViaAudioLLM`. Add `buildTranscriptionInstructions`. | ✅ | ✅ |
| E | Delete old files: `internal/ai/transcription.go`, `client.go`, `openai.go`, `openai_test.go`, `gemini.go`, `gemini_test.go`. | ✅ | ✅ |
| F | Update proto comments (#2 and #3), run `buf format -w && buf generate`. | ✅ | ✅ |
| G | Update `web/src/locales/en.json` strings for model placeholders and prompt help. | ✅ | ✅ |
Each stage is one commit. Reverting any single stage leaves the system in a working state.
## 8. Anti-Patterns Avoided (and Why)
| Anti-pattern | Where it would have come from | Why we're avoiding it |
|---|---|---|
| `ProviderType` enum with wire-format suffix (`OPENAI_TRANSCRIPTIONS`, `OPENAI_CHAT_AUDIO`) | Earlier brainstorming draft | Vercel/LiteLLM/Go ecosystem all use vendor-level identity; capability is implied by which interface you call. |
| `Response.Source` enum (`NativeSTT`, `MultimodalLLM`) | Earlier brainstorming draft | None of the three SDKs surveyed has this. It re-introduces the "pretend STT" smell at a different layer. |
| Adapter wrapping `audiollm.Model` as `stt.Transcriber` | Earlier brainstorming draft | Adapter would re-create the conflation we're trying to remove. Application-layer dispatch is honest. |
| Adding `transcription_*` fields to `AIProviderConfig` | Naive instinct | Three of three OSS apps surveyed (Open WebUI, LobeChat, Dify) do **not** do this. Pollutes the provider entity; repeats with every new capability. |
| Silently reusing chat provider credentials for STT (LobeChat's deprecated pattern) | LobeChat-style shortcut | LobeChat's own author marked the helper `@deprecated`. Memos's existing `provider_id` reference is more flexible (user can configure a different OpenAI-compatible endpoint, e.g. Groq, just for STT). |
| Per-model credential overrides, capability YAML, load balancing | Dify | Enterprise complexity that doesn't fit Memos's scope. |
| Auto-fallback from STT failure to multimodal-LLM transcription | Plausible "smart" idea | LiteLLM doesn't do this; failure modes and cost differ enough that fallback would surprise users. Explicit dispatch by provider type is what LiteLLM ships. |
## 9. Open Decisions
All resolved during brainstorming. None remain open. For the record:
1. **Direction A (split STT/Audio-LLM into separate interfaces) over Direction B (capability-flag system) over Direction C (audio-to-structured-note pipeline).** Resolved: A. Rationale: most honest abstraction, matches mainstream SDKs, leaves the door open to C as a future addition without rework.
2. **Provider type naming: vendor-level (`openai`/`gemini`) over wire-format-encoded.** Resolved: vendor-level. Rationale: matches Vercel/LiteLLM/Go convention; new OpenAI transcription model snapshots require zero schema or code changes.
3. **`TranscriptionConfig.Duration` field decision.** Not present in current proto; not added. Audio duration belongs to resource metadata (computed from the file at upload time), not to the transcription response.
4. **Multimodal failure-mode surface.** Resolved: expose `FinishReason` from `audiollm.Model` to the application layer; the Transcribe handler converts non-`Stop` reasons into informative errors.
## 10. Implementation Plan Pointer
Once this spec is approved, the implementation plan will be created at `docs/superpowers/plans/2026-05-02-stt-audiollm-split.md` covering Stages AG from §7 above as discrete, bite-sized tasks with TDD steps and per-stage commits.
@@ -0,0 +1,155 @@
# Transcription (STT) settings — design
**Date:** 2026-05-02
**Scope:** Backend + frontend. Schema-additive (no migration required).
## Problem
Memos has one AI feature today: audio transcription (speech-to-text). The current design has three concrete problems:
1. **Model is hard-coded per provider type.** `internal/ai/models.go` pins OpenAI to `gpt-4o-transcribe` and Gemini to `gemini-2.5-flash`. Users who want `whisper-1` (cheaper, often more accurate for non-English) or third-party Whisper-compatible endpoints (Groq's `whisper-large-v3-turbo`, self-hosted whisper.cpp / Speaches via OpenAI-compatible URL) cannot configure them at all.
2. **No explicit transcription configuration.** `InstanceAISetting.providers` is a generic credentials list. The frontend (`MemoEditor/index.tsx:65`) implicitly picks "the first provider with an API key whose type is in TRANSCRIPTION_PROVIDER_TYPES." Users cannot:
- Choose which provider runs transcription when they have multiple.
- Set a default language (Whisper API supports it but it is never sent).
- Set a `prompt` hint to bias spelling of proper nouns / jargon (a documented Whisper feature, surfaced by every other STT product).
3. **Gemini fails for browser-recorded audio.** `internal/ai/gemini.go:23` does not list `audio/webm` in `geminiSupportedContentTypes`, but `MediaRecorder` in browsers defaults to `audio/webm`. So selecting a Gemini provider for in-editor recording produces a content-type error every time.
## Goal
Let the operator configure transcription explicitly: which provider, which model, default language, and a spelling-hint prompt. Make the OpenAI provider work as a universal "OpenAI-compatible" engine so Groq / self-hosted Whisper / Speaches are reachable through endpoint override.
## Non-goals
- Adding STT engines beyond OpenAI and Gemini (Azure, Deepgram, AWS Transcribe — out of scope; the schema admits them later via `AIProviderType` enum).
- Other AI features (summarization, embeddings, tag suggestion). The schema is shaped so they fit later, but none are designed here.
- Per-call provider override at recording time. Research across all surveyed products (OpenWebUI, LibreChat, Whisper Memos, Superwhisper, etc.) confirms STT engine is a global preference, not an action-time choice. We follow the same pattern.
- Server-side audio transcoding (e.g., webm → wav for Gemini). See "Gemini webm" below for the chosen mitigation.
- Multi-user or per-user override of admin defaults. Memos' STT setting is instance-scoped, like every other instance setting.
## Naming
Field and message names follow cross-platform STT conventions, not Memos-internal shorthand:
| Concept | Chosen name | Rationale |
|---|---|---|
| Config message | `TranscriptionConfig` | AssemblyAI uses this exact identifier; matches OpenAI's `CreateTranscription*` verb family and Memos' existing `Transcribe` RPC. The `STT` acronym is not used as a type name in any major STT API. |
| Provider reference | `provider_id` (string) | Plain protobuf convention for a string-ID reference (`field_id`, `user_id` style). `engine` was rejected as an OpenWebUI-only term; typed message refs are not needed since providers are addressed by string ID. |
| Model | `model` | Unanimous across OpenAI, Google v2, Deepgram, OpenWebUI, LibreChat. Not `model_id`. |
| Default language | `language` | Bare `language` is the modern convention (OpenAI, Whisper family, Deepgram, Wyoming). `language_code` is the older Google/AWS form; we accept ISO 639-1 short codes the same way OpenAI does. |
| Spelling hint | `prompt` | OpenAI's public API field name and AssemblyAI's. Whisper's internal name is `initial_prompt`, but `prompt` is what users of `audio.transcriptions.create` recognize. |
A note on the message name collision: `proto/api/v1/ai_service.proto` already declares a `TranscriptionConfig` for **per-call** prompt/language overrides. The new store-level `TranscriptionConfig` lives in package `memos.store`, so the two compile cleanly. Memos already uses parallel `api.v1.X` / `store.X` message pairs (e.g. `User`, `Memo`); this matches that pattern.
## Architecture
### Schema (additive)
`proto/store/instance_setting.proto`:
```proto
message InstanceAISetting {
repeated AIProviderConfig providers = 1; // unchanged — credential pool
TranscriptionConfig transcription = 2; // NEW — feature config
}
message TranscriptionConfig {
// References an entry in providers[].id. Empty string = transcription disabled.
string provider_id = 1;
// Free text. Empty string = engine default (whisper-1 for OPENAI, gemini-2.5-flash for GEMINI).
string model = 2;
// ISO 639-1 short code. Empty string = auto-detect.
string language = 3;
// Up to ~200 tokens. Used as the OpenAI Whisper `prompt` parameter and as
// a "Context and spelling hints:" block in the Gemini prompt.
string prompt = 4;
}
```
`proto/api/v1/ai_service.proto`:
- `TranscribeRequest.provider_id` becomes optional. When omitted, the server resolves the provider from `InstanceAISetting.transcription.provider_id`.
- `TranscribeRequest.config` (per-call `TranscriptionConfig` with `prompt` / `language`) is kept for advanced overrides but its fields, when empty, fall back to the persisted defaults from `InstanceAISetting.transcription`.
### Backend changes
1. **`internal/ai/models.go`** — `DefaultTranscriptionModel` already exists; reuse it as the fallback when `TranscriptionConfig.model` is empty. No new code, just used from a new call site.
2. **`server/router/api/v1/ai_service.go`**:
- Read `InstanceAISetting.transcription` at the start of `Transcribe`.
- Resolve `provider_id` from request → fall back to `transcription.provider_id`. If both empty, return `FailedPrecondition` with a clear "transcription not configured" message.
- Resolve `model` similarly: request override → `transcription.model` → engine default via `DefaultTranscriptionModel`.
- Merge `language` and `prompt`: per-call overrides win; otherwise fall through to persisted defaults.
3. **`internal/ai/gemini.go`** — out of scope to fix the webm content-type list here. See mitigation below.
### Frontend changes
`web/src/components/Settings/AISection.tsx` is restructured into two settings groups inside the existing `SettingSection`:
1. **AI Integrations** (renamed from "Providers" — current behavior): list of credential entries (id, title, type, endpoint, api key). No functional changes; the rename communicates that this section is just credentials.
2. **Transcription** (new): three-segment form
- **Provider** — Select dropdown listing entries from group 1 by `title`. First option is "None — transcription disabled". Disabled with a hint "Add an AI integration first ↑" when group 1 is empty.
- **Model** — text input. Placeholder updates dynamically based on the selected provider's type (`whisper-1` for OPENAI, `gemini-2.5-flash` for GEMINI). Help text below: "Free text. Use the provider's model identifier — e.g., whisper-1, gpt-4o-transcribe, whisper-large-v3-turbo."
- **Default language** — text input, ISO 639-1 placeholder, empty = auto.
- **Prompt hints** — textarea, ~200 token soft limit, help text "Improves spelling of proper nouns and jargon. Whisper limit is ~224 tokens."
`web/src/components/MemoEditor/index.tsx:65` changes:
- Replace the "first provider with apiKey in TRANSCRIPTION_PROVIDER_TYPES" lookup with this enable rule: transcribe button shows iff `aiSetting.transcription.providerId` is non-empty AND the referenced provider exists in `aiSetting.providers` AND that provider has `apiKeySet === true`.
- The editor no longer needs to know the provider object itself for the call — see service change below.
`web/src/components/MemoEditor/services/transcriptionService.ts` is simplified: it stops accepting a `provider` argument and simply omits `provider_id` from the request. The server resolves the provider, model, language, and prompt from `InstanceAISetting.transcription`. (No override path is exposed at the editor layer; advanced callers can still pass `provider_id` directly via the proto if needed in the future.)
### How "OpenAI-compatible" backends work
To use Groq, Speaches, or self-hosted whisper.cpp:
1. In **AI Integrations**, add a provider with type `OPENAI`, set `endpoint` to e.g. `https://api.groq.com/openai/v1` or `http://speaches:8000/v1`, set the API key, give it a recognizable title ("Groq", "Self-hosted Whisper").
2. In **Transcription**, select that provider and set `model` to the backend's model identifier (`whisper-large-v3-turbo`, `Systran/faster-distil-whisper-large-v3`, etc.).
This is the universal escape hatch confirmed across OpenWebUI, LibreChat, and Whisper Obsidian plugin: don't enumerate every backend — let the OpenAI engine be a transport, not a brand.
## Gemini webm mitigation
The Gemini `audio/webm` failure is a real user-blocking bug but separate from the settings redesign. Three options were considered:
- **(a) Server-side transcode** with ffmpeg. Adds a heavy runtime dep; rejected as YAGNI.
- **(b) Switch MediaRecorder format** when STT engine is Gemini. Browser support for `audio/mp4` and `audio/wav` in `MediaRecorder` is patchy across Firefox / Safari / Chrome; rejected as fragile.
- **(c) Inline hint + accept the limitation.** Selected. The Transcription section shows a small warning under the model field when the chosen provider type is `GEMINI`: "Gemini does not accept browser-recorded `audio/webm`. For in-editor recording, use an OpenAI-compatible provider."
Server-side transcoding can be revisited later as a self-contained change if Gemini demand grows.
## Validation
Server validation (`server/router/api/v1/ai_service.go`):
- `transcription.provider_id`, when set, must reference an existing entry in `providers[]`. On `UpdateInstanceSetting` for the AI key, reject with `InvalidArgument` if it doesn't.
- `transcription.model` length cap: 256 chars (covers `Systran/faster-distil-whisper-large-v3`-style names with margin).
- `transcription.language` length cap: 32 chars (existing constant `maxTranscriptionLanguageLength`).
- `transcription.prompt` length cap: 4096 chars (existing constant `maxTranscriptionPromptLength`).
Frontend validation in `AISection.tsx`:
- "Save" disabled if `transcription.providerId` is set but the referenced provider was just deleted from the integrations list (in the same unsaved edit).
- Inline warning shown (but Save still allowed) if the referenced provider exists but has `apiKeySet === false` — surfacing the broken state so the operator can fix it without blocking unrelated edits to other settings.
## Backwards compatibility
The schema change is purely additive. Existing instances with `providers` configured but no `transcription` field default to `provider_id = ""`, which means transcription is disabled until the operator visits the new Transcription section and selects a provider.
This is a small UX regression for instances that were relying on the implicit "first provider wins" behavior — they now must make a one-click selection. Acceptable trade-off because:
- It makes the choice explicit (the implicit pick was the source of confusion when users had multiple providers).
- A one-time migration that auto-fills `transcription.provider_id` with the first STT-capable provider is feasible but adds complexity for a one-line user action. Skip the migration; document the change in the release notes.
## Testing
- `internal/ai/transcription_test.go` (existing) covers the transcribe RPC. Add cases for: empty `provider_id` falls back to setting; empty `model` falls back to `DefaultTranscriptionModel`; per-call overrides win over settings.
- `server/router/api/v1/test/ai_service_test.go` (existing) covers the API service. Add cases for the validation rules above (unknown provider_id, oversized model/language/prompt).
- Frontend: manual verification via the dev server (`pnpm dev` in `web/`) — load Settings, add a provider, configure transcription, verify the home editor's record button enables/disables based on `provider_id`. No new component tests required (existing AISection has none).
## Out of scope, explicitly
- Multiple transcription configurations / per-tag or per-user routing.
- Per-call provider override exposed in the editor UI.
- Test-transcription button in settings (worth doing later; deferred to keep this scope tight).
- Glossary / vocabulary list as a separate field — folded into `prompt` for now (Joplin/Superwhisper split this; we can add later if users ask).
- TTS settings. Memos has none today and none planned.
@@ -0,0 +1,113 @@
# First Screen Lazy Heavy Dependencies Design
## Context
The auth and signup pages currently fetch JavaScript and CSS assets for features that are not used on the first screen, including Mermaid, KaTeX, Leaflet, and React Leaflet. The current routing already uses lazy route components, so the remaining problem is eager imports from shared app entry points and feature modules.
The goal is to reduce first screen load time, especially for `/auth` and `/auth/signup`, without changing memo rendering, map behavior, or authenticated workflows.
## Goals
- Prevent Mermaid and Leaflet vendor chunks from loading on auth/signup before they are needed.
- Prevent Leaflet and KaTeX CSS from loading globally at app startup.
- Preserve current behavior when users view Mermaid diagrams, math content, memo location previews, profile maps, or location pickers.
- Keep fallbacks small and consistent with existing async rendering patterns.
- Verify the production build and confirm auth/signup network requests no longer include Mermaid or Leaflet chunks during initial render.
## Non-Goals
- Rewriting the markdown rendering pipeline.
- Removing support for Mermaid, KaTeX, Leaflet, or React Leaflet.
- Optimizing every authenticated route in this change.
- Changing server behavior, route guards, or authentication flow.
## Recommended Approach
Use feature-level lazy loading for heavy optional features. Keep the app shell and auth routes free of diagram, math styling, and map dependencies. Load those dependencies from the feature boundary where the user actually needs them.
This approach has the best balance of impact and risk because it removes known eager imports while preserving existing route structure and feature internals.
## Architecture
### App Entry
`web/src/main.tsx` should no longer import:
- `leaflet/dist/leaflet.css`
- `katex/dist/katex.min.css`
These styles are feature-specific and should be loaded from the map and markdown rendering paths.
### Mermaid
`web/src/components/MemoContent/MermaidBlock.tsx` should replace the static `import mermaid from "mermaid"` with an async `import("mermaid")` inside the render effect.
The component should keep its current behavior:
- initialize Mermaid with the resolved app theme;
- render when code content or theme changes;
- show the existing error fallback when rendering fails.
The Mermaid chunk should only be requested when a memo actually renders a Mermaid code block.
### KaTeX
KaTeX CSS should load from the memo markdown rendering path instead of the app entry. Since `rehype-katex` is only useful when memo markdown is rendered, loading the stylesheet near `MemoMarkdownRenderer` keeps auth/signup free of KaTeX CSS while preserving math output styling.
This change does not need content-level math detection. Loading KaTeX CSS with memo markdown is simpler and still removes it from the first auth/signup screen.
### Leaflet Maps
Leaflet-dependent UI should be moved behind lazy component boundaries:
- `UserMemoMap` should be lazy-loaded by the user profile route or by a small wrapper component.
- `LocationPicker` should be lazy-loaded where location UI is opened or displayed.
The underlying map implementations can continue using Leaflet, React Leaflet, marker clustering, and their current helpers. The key boundary is that parent components must not statically import the map implementation if that parent can be pulled into non-map first-screen chunks.
Leaflet CSS and marker cluster CSS should load inside the lazy map implementation path, not from `main.tsx`.
### Type Imports
Any imports from `leaflet` that are used only as TypeScript types should use `import type`. Runtime construction such as `new LatLng(...)` should be avoided in parent components that are meant to stay Leaflet-free; pass plain latitude/longitude data into lazy map wrappers and construct Leaflet objects inside the lazy implementation.
## Data Flow
Auth/signup initial render:
1. App entry initializes theme, locale, providers, auth, and instance data.
2. Router loads only the auth/signup route component and shared app shell dependencies.
3. Mermaid, Leaflet, React Leaflet, marker cluster, and feature CSS are not requested.
Memo markdown render:
1. Memo content renders with the existing markdown renderer.
2. KaTeX CSS loads with the markdown rendering path.
3. If a code block language is `mermaid`, `MermaidBlock` dynamically imports Mermaid and renders the diagram.
Map render:
1. A map feature mounts through a lazy boundary.
2. The lazy implementation imports Leaflet, React Leaflet, and required map CSS.
3. Existing map interactions and display behavior continue inside the loaded implementation.
## Error Handling
- Mermaid import or render failures should use the existing Mermaid error UI with the original code content visible.
- Lazy map boundaries should use minimal fallbacks sized like the eventual map container to avoid layout shift.
- Chunk load failures should continue to use the existing router chunk reload behavior where applicable.
## Testing
- Run `pnpm build` in `web`.
- Run `pnpm lint` in `web`.
- Inspect production build output to ensure Mermaid and Leaflet remain split chunks.
- Use a production preview or equivalent browser check for `/auth/signup` and confirm initial network requests do not include Mermaid or Leaflet JavaScript chunks.
- Smoke test memo content with Mermaid and math.
- Smoke test location picker, location popover, and user profile map.
## Risks
- Loading CSS from lazy paths can cause a small style delay the first time a map or math content appears. Use map-sized fallbacks and keep CSS imports in the feature implementation to minimize visible shifts.
- Moving Leaflet runtime types out of parent components may require small prop shape changes.
- Dynamic Mermaid import needs effect cancellation to avoid setting state after unmount.
@@ -0,0 +1,282 @@
# Placeholder Component Design
## Context
The web frontend currently renders empty states with a single `Empty.tsx` component that displays a lucide `BirdIcon`. It is used in exactly one place (`web/src/pages/Inboxes.tsx`) and offers no support for other state varieties — loading, no-search-results, or 404 pages — each of which is currently handled inconsistently or not at all.
The goal is to replace `Empty.tsx` with a single reusable `<Placeholder>` component that renders a hand-curated ASCII bird illustration plus a short muted message, supports four distinct variants, and ships with the architectural seam needed to grow a randomized pool of ASCII pieces per variant in future work.
The visual register is "cozy and minimal": muted colors, monospace throughout, gentle motion that respects `prefers-reduced-motion`. The ASCII art itself is drawn from Joan Stark's (jgs) classic ASCII bird collection (https://github.com/oldcompcz/jgs), preserving the `jgs` signature and date as a visible credit beneath each piece.
## Goals
- Provide a single `<Placeholder variant="…">` component covering empty, loading, no-results, and 404 states.
- Render real, recognizable ASCII bird art (not freehand sketches), preserving Joan Stark's attribution.
- Apply subtle CSS-only animation (bob for perched birds, flutter for in-flight birds, fade-in on the message).
- Respect `prefers-reduced-motion` — no animation when the user opts out.
- Provide a pool-shaped data file so future PRs can drop in additional ASCII pieces per variant without component changes.
- Replace the existing `Empty.tsx` in `Inboxes.tsx` as the proof of integration.
## Non-Goals
- Adding additional ASCII pieces beyond the four initial picks (one per variant). The pool architecture supports growth, but seeding more pieces is a follow-up.
- Wiring the component into search results, the 404 route, or Suspense fallbacks. Each is a candidate; each is a separate PR.
- Translating the default messages. The file structure leaves a seam for `i18next`, but the initial PR ships plain strings.
- Adding a JS animation library (Framer Motion, react-spring, etc.). Three keyframe animations do not justify a new dependency.
- Visual regression testing infrastructure.
## Recommended Approach
Build a single `<Placeholder>` component with a `variant` prop that selects a randomized ASCII piece from a co-located pool data file. Animation is CSS-only, with motion presets keyed off each piece's `motion` field. Accessibility uses `aria-hidden` on the decorative ASCII and a semantic `<p>` for the message.
This approach has the best balance of present-day simplicity and future extensibility: the initial pool contains one piece per variant, so the component is deterministic today, but the picker function and pool shape impose no constraints on how many pieces are added later.
## Architecture
### Public Component
**Path:** `web/src/components/Placeholder/index.tsx`
```tsx
import { useMemo } from "react";
import clsx from "clsx";
import { pickPiece, MotionStyle, PlaceholderVariant } from "./ascii-pool";
import { DEFAULT_MESSAGES } from "./messages";
import "./Placeholder.css";
interface PlaceholderProps {
variant: PlaceholderVariant;
message?: string;
children?: React.ReactNode;
className?: string;
}
const MOTION_CLASS: Record<MotionStyle, string> = {
bob: "placeholder-motion-bob",
flutter: "placeholder-motion-flutter",
none: "",
};
export function Placeholder({ variant, message, children, className }: PlaceholderProps) {
const piece = useMemo(() => pickPiece(variant), [variant]);
const resolvedMessage = message ?? DEFAULT_MESSAGES[variant];
const isLoading = variant === "loading";
return (
<div
role={isLoading ? "status" : undefined}
aria-live={isLoading ? "polite" : undefined}
className={clsx("flex flex-col items-center justify-center max-w-md mx-auto px-4 py-8", className)}
>
<pre
aria-hidden="true"
className={clsx(
"font-mono text-xs sm:text-sm leading-tight text-muted-foreground whitespace-pre",
MOTION_CLASS[piece.motion],
)}
>
{piece.ascii}
</pre>
<p className="mt-3 font-mono text-sm text-muted-foreground placeholder-fade-in">
{resolvedMessage}
</p>
<p className="mt-1 font-mono text-[10px] text-muted-foreground/60">{piece.credit}</p>
{children && <div className="mt-4">{children}</div>}
</div>
);
}
```
The `useMemo` keyed on `variant` ensures the piece is stable for the mount but re-rolls if the variant prop changes (rare in practice). Re-mounting re-rolls naturally.
### ASCII Pool
**Path:** `web/src/components/Placeholder/ascii-pool.ts`
```ts
export type PlaceholderVariant = "empty" | "loading" | "noResults" | "notFound";
export type MotionStyle = "bob" | "flutter" | "none";
export interface AsciiPiece {
id: string;
variant: PlaceholderVariant;
ascii: string;
credit: string;
motion: MotionStyle;
}
export const ASCII_POOL: AsciiPiece[] = [
{
id: "jgs-crested-parrot",
variant: "empty",
credit: "jgs · 4/97",
motion: "bob",
ascii: ` .---.
/ 6_6
\\_ (__\\
// \\\\
(( ))
=====""===""=====
|||
|`,
},
{
id: "jgs-hummingbird-sm",
variant: "loading",
credit: "jgs · 7/98",
motion: "flutter",
ascii: ` , _
{ \\/\`o;====-
.----'-/\`-/
\`'-..-| /
/\\/\\
\`--\``,
},
{
id: "jgs-wide-eyed-owl",
variant: "noResults",
credit: "jgs · 2/01",
motion: "bob",
ascii: ` __ __
\\ \`-'"'-\` /
/ \\_ _/ \\
| d\\_/b |
.'\\ V /'.
/ '-...-' \\
| / \\ |
\\/\\ /\\/
==(||)---(||)==`,
},
{
id: "jgs-bird-flown-away",
variant: "notFound",
credit: "jgs · 7/96",
motion: "flutter",
ascii: ` ___
_,-' ______
.' .-' ____7
/ / ___7
_| / ___7
>(')\\ | ___7
\\\\/ \\_______
' _======>
\`'----\\\\\``,
},
];
export function pickPiece(variant: PlaceholderVariant): AsciiPiece {
const matches = ASCII_POOL.filter(p => p.variant === variant);
return matches[Math.floor(Math.random() * matches.length)];
}
```
> Each `ascii` value is a template literal; backslashes and backticks are escaped per JS rules. The art shown here is verbatim from the brainstorm sign-off; implementation must preserve every space.
The `ascii` field of each entry holds the verbatim ASCII string. The four initial pieces are the ones validated during brainstorming:
- **empty** — Joan Stark's "early-bird" parrot with crest, perched on a branch
- **loading** — Joan Stark's compact hummingbird (mid-flight, fluttering)
- **noResults** — Joan Stark's wide-eyed two-feathered owl
- **notFound** — Joan Stark's flying-away bird with motion trails
Each piece's `ascii` is committed as a template literal preserving exact whitespace; the file is the source of truth and is small enough (~510 lines of art per piece) to keep inline rather than splitting into per-piece files.
### Default Messages
**Path:** `web/src/components/Placeholder/messages.ts`
```ts
export const DEFAULT_MESSAGES: Record<PlaceholderVariant, string> = {
empty: "No memos yet",
loading: "Loading…",
noResults: "Nothing matches that search",
notFound: "This page flew the coop",
};
```
Plain strings for the initial PR. A future i18n pass swaps these for `t("placeholder.empty")` etc. without touching the component.
### Animation
CSS keyframes live in a small co-located stylesheet (`Placeholder.css`) imported by `index.tsx`, scoped via a `.placeholder-…` class prefix to avoid leakage. The codebase uses Tailwind v4 plus plain CSS imports elsewhere; this matches the existing pattern.
```css
@media (prefers-reduced-motion: no-preference) {
.placeholder-motion-bob { animation: placeholder-bob 3.4s ease-in-out infinite; }
.placeholder-motion-flutter { animation: placeholder-flutter 0.7s ease-in-out infinite; }
.placeholder-fade-in { animation: placeholder-fade 1s ease-out 0.3s both; opacity: 0; }
}
@keyframes placeholder-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes placeholder-flutter {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(2px, -1px); }
}
@keyframes placeholder-fade {
to { opacity: 1; }
}
```
The reduced-motion guard wraps all three rules. When the user prefers reduced motion, the bird is static and the message appears without fading.
### Accessibility
- ASCII `<pre>` carries `aria-hidden="true"`; screen readers do not announce it character-by-character.
- The message is a semantic `<p>`. It is the accessible name of the placeholder for assistive tech.
- For `variant="loading"` only, the wrapper has `role="status"` and `aria-live="polite"` so the loading message is announced when the placeholder appears.
- The credit line (`jgs · 4/97`) is visible-but-small below the message. It is not aria-hidden because it is intentionally part of the visual presentation.
- The component itself is not focusable. Anything passed as `children` (e.g. a "Go home" button on 404) participates normally in tab order.
- Color contrast relies on the existing `text-muted-foreground` token, which already meets WCAG AA in the project's theme.
### File Layout
```
web/src/components/Placeholder/
index.tsx # the <Placeholder> component (public export)
Placeholder.css # keyframes + .placeholder-motion-* classes
ascii-pool.ts # AsciiPiece type, ASCII_POOL array, pickPiece()
messages.ts # DEFAULT_MESSAGES map (i18n-ready seam)
CREDITS.md # Joan Stark attribution + link to oldcompcz/jgs
```
### Integration
- **Delete:** `web/src/components/Empty.tsx`.
- **Modify:** `web/src/pages/Inboxes.tsx` — replace the `<Empty />` import and usage with `<Placeholder variant="empty" />`.
Other potential call sites (search results page, router 404 catch-all, Suspense fallbacks) are explicitly out of scope for this PR. They are noted in the PR description as follow-up opportunities.
### Credits
**Path:** `web/src/components/Placeholder/CREDITS.md`
A short Markdown file pointing to https://github.com/oldcompcz/jgs and acknowledging Joan Stark's work. This survives even if the per-piece `credit` field is ever cleaned up by a refactor.
## Testing
A small Vitest suite covers:
- Each variant renders without throwing.
- The chosen `<pre>` content contains text from the matching pool entry.
- `aria-hidden="true"` on the `<pre>`.
- `role="status"` only present when `variant="loading"`.
- A custom `message` prop overrides the default.
- The credit text is present in the DOM.
No visual regression testing is added.
## Open Questions
None. All design decisions were validated during the brainstorming session — animation register (cozy), bird selections (jgs collection), naming (`Placeholder`, no "bird" in the name), text treatment (plain muted monospace), and integration scope (replace `Empty.tsx`, do not wire other sites yet).
## References
- Joan Stark's ASCII Art Gallery (jgs collection): https://github.com/oldcompcz/jgs
- Existing component being replaced: `web/src/components/Empty.tsx`
- Existing call site: `web/src/pages/Inboxes.tsx`
- Visual brainstorm artifacts: `.superpowers/brainstorm/1991-1778593581/content/` (mascot-approach, animation-vibe, bird-shapes-v2, jgs-bird-set, text-treatment-c)
@@ -0,0 +1,82 @@
# Remove react-use Design
## Context
The frontend has a direct dependency on `react-use@17.6.0`. Current source usage is limited to six imports:
- `usePrevious` in `web/src/layouts/RootLayout.tsx`
- `useLocalStorage` in `web/src/components/MemoExplorer/TagsSection.tsx`
- `useWindowScroll` in `web/src/components/MobileHeader.tsx`
- `useToggle` in `web/src/components/TagTree.tsx`
- `useDebounce` in `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- `useDebounce` in `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
`react-use` pulls in `js-cookie@2.2.1` transitively. A direct `js-cookie@3.0.7` dependency does not remove that vulnerable transitive copy, so the better remediation is to remove `react-use` rather than adding another copy of `js-cookie`.
## Goals
- Remove `react-use` from `web/package.json` and `web/pnpm-lock.yaml`.
- Remove all source imports from `react-use` and `react-use/lib/*`.
- Prefer built-in React hooks directly where the replacement is simple.
- Add local hooks only where reuse or browser-side-effect cleanup makes the code clearer and safer.
- Confirm `react-use` and `js-cookie` are no longer present in the frontend dependency graph.
## Non-Goals
- Do not introduce another general-purpose React hooks library.
- Do not refactor unrelated frontend state management.
- Do not change user-visible behavior of tag preferences, tag tree expansion, scroll shadow behavior, route filter clearing, memo link search, or reverse geocoding debounce.
## Approach
Use native React hooks for one-off, simple behavior:
- Replace `useToggle` in `TagTree` with `useState(false)` plus local callbacks.
- Replace `usePrevious` in `RootLayout` with local `useRef` and `useEffect` while preserving the existing previous-path comparison.
- Replace `useWindowScroll` in `MobileHeader` with local `useState` and `useEffect` for the scroll listener.
Create focused local hooks where repeated behavior or cleanup consistency matters:
- Add `useDebouncedEffect` under `web/src/hooks/` for the two debounce call sites. It schedules a callback after the configured delay and clears the timeout when dependencies change or the component unmounts.
- Add a typed `useLocalStorage` under `web/src/hooks/` for tag display settings. It reads the stored value on initialization, falls back to the provided default, writes updates to `localStorage`, and tolerates unavailable or malformed storage by using the default.
Update imports to use `@/hooks/...` for local hooks. Keep hook APIs small and shaped around current usage rather than cloning the full `react-use` API.
## Data Flow And Behavior
Tag view settings continue to persist in `localStorage` under the same keys:
- `tag-view-as-tree`
- `tag-tree-auto-expand`
Debounced effects continue to defer:
- reverse-geocoding position updates in the memo editor insert menu by 1000 ms
- memo link search requests by 300 ms
The route filter clearing logic continues to run only when navigation changes route and the URL has no `filter` parameter.
The mobile header continues to show its shadow when the window has scrolled below the top.
## Error Handling
The local storage hook catches read and write failures. On read failure or malformed JSON, it returns the default value. On write failure, it keeps React state updated so the current UI interaction still works, while avoiding a thrown render or event-handler error.
Debounced effects do not swallow errors inside the user callback. Existing call sites already handle their own asynchronous errors where needed.
## Testing And Verification
Run frontend validation after implementation:
- `pnpm install --lockfile-only` from `web/` to regenerate `pnpm-lock.yaml`
- `pnpm why react-use js-cookie` from `web/` and confirm neither dependency remains
- `pnpm lint` from `web/`
- `pnpm build` from `web/`
Manual checks should cover:
- toggling tag tree mode and auto-expand persists across reload
- opening tag tree nodes still works
- mobile header shadow appears after scrolling
- memo link search still debounces and updates results
- location reverse geocoding still waits for position changes before lookup
@@ -0,0 +1,241 @@
# OpenAPI-Driven MCP Support Design
## Context
Memos previously had an MCP server, but it was removed in commit `2a4638b3` and should not be used as the baseline for this work. GitHub issue #6022 reported that some old MCP tools returned a bare JSON array in `result.structuredContent`, which strict MCP clients reject because `structuredContent` must be an object.
The new MCP support should start from zero and use the generated OpenAPI document at `proto/gen/openapi.yaml` as the source of truth. The OpenAPI file already describes the public REST gateway operations generated from protobuf definitions, including operation IDs, descriptions, parameters, request schemas, and response schemas.
## Goals
- Add MCP support back through a new implementation that is mechanically tied to `proto/gen/openapi.yaml`.
- Expose a standard MCP Streamable HTTP endpoint at the `/mcp` path.
- Register tools only; do not add MCP resources or prompts in the first version.
- Expose a curated memo-focused toolset derived from OpenAPI operations, not every API endpoint.
- Execute MCP tool calls through the existing API contract instead of duplicating store or service logic.
- Return object-shaped `structuredContent` for every tool result.
- Keep authentication and authorization behavior aligned with the existing API.
## Non-Goals
- Reviving or adapting the removed MCP package design.
- Adding custom MCP route aliases, readonly endpoints, or toolset filtering headers.
- Exposing every operation in `proto/gen/openapi.yaml` as an MCP tool.
- Adding MCP tools for admin settings, users, identity providers, webhooks, personal access tokens, authentication, share-link management, AI transcription, or bulk deletion.
- Adding `list_tags` or `search_memos` unless matching proto/API operations are added and OpenAPI is regenerated.
- Hand-editing generated OpenAPI or generated protobuf outputs.
## Recommended Approach
Build a new `server/router/mcp` package that parses `proto/gen/openapi.yaml` at startup, selects a curated allowlist of operation IDs, and converts those selected OpenAPI operations into MCP tools. Tool calls map MCP arguments into the selected operation's path parameters, query parameters, and JSON body, then execute the corresponding `/api/v1/...` HTTP request through the existing Echo/gRPC-Gateway route in-process.
This approach keeps OpenAPI as the authoritative contract while avoiding the usability and safety problems of exposing a large API-mirrored tool surface.
## Architecture
### MCP Service
Create a new MCP service package under `server/router/mcp`. `server.NewServer` creates the existing `APIV1Service`, registers the normal file, RSS, and API routes, then registers a new MCP service against the same Echo server.
The service exposes one MCP endpoint path:
```text
/mcp
```
The implementation must support standard Streamable HTTP client messages on `POST /mcp`. It may also support `GET /mcp` and `DELETE /mcp` if the chosen MCP transport implementation requires those methods for standards-compliant streaming or session cleanup.
The first version advertises only the MCP tools capability. It does not advertise prompts or resources.
### OpenAPI Operation Registry
At startup, the MCP service loads `proto/gen/openapi.yaml` and builds an operation registry keyed by `operationId`. Each parsed operation stores:
- operation ID
- HTTP method
- OpenAPI route template
- description
- path parameters
- query parameters
- JSON request body schema
- HTTP 200 JSON response schema
The parser should fail fast during service construction if any curated operation ID is missing or cannot be converted into a valid MCP tool schema.
### Tool Names
Tool names are derived from OpenAPI `operationId` values and normalized for MCP clients. The exact naming convention should be deterministic and tested. A practical convention is lower snake case without the `Service` suffix in the subject:
```text
MemoService_ListMemos -> memo_list_memos
AttachmentService_GetAttachment -> attachment_get_attachment
```
The OpenAPI `operationId` remains stored in tool metadata so tests and future diagnostics can prove which OpenAPI operation produced each MCP tool.
### Tool Schemas
Each MCP tool input schema is an object built from:
- OpenAPI path parameters
- OpenAPI query parameters
- JSON request body fields, when present
Required path parameters stay required. Required request bodies stay required. Optional query parameters stay optional. The schema should preserve OpenAPI descriptions and primitive types where possible.
Each MCP tool output schema is the OpenAPI HTTP 200 JSON response schema. For empty 200 responses with no JSON schema, the MCP output schema is:
```json
{ "type": "object", "properties": { "ok": { "type": "boolean" } } }
```
## Tool Scope
The first version exposes these curated OpenAPI operations:
- `MemoService_ListMemos`
- `MemoService_CreateMemo`
- `MemoService_GetMemo`
- `MemoService_UpdateMemo`
- `MemoService_DeleteMemo`
- `MemoService_ListMemoComments`
- `MemoService_CreateMemoComment`
- `MemoService_ListMemoAttachments`
- `MemoService_SetMemoAttachments`
- `MemoService_ListMemoReactions`
- `MemoService_UpsertMemoReaction`
- `MemoService_DeleteMemoReaction`
- `MemoService_ListMemoRelations`
- `MemoService_SetMemoRelations`
- `AttachmentService_ListAttachments`
- `AttachmentService_GetAttachment`
- `AttachmentService_DeleteAttachment`
Excluded in the first version:
- auth sign-in, sign-out, and refresh
- user management
- personal access token management
- identity provider management
- webhooks
- instance settings
- share-link management
- AI transcription
- bulk delete operations
- any operation not present in generated OpenAPI
## Data Flow
### Startup
1. `server.NewServer` creates the existing `APIV1Service`.
2. The new `MCPService` loads `proto/gen/openapi.yaml`.
3. The OpenAPI parser builds the operation registry.
4. The curated operation allowlist selects supported operations.
5. Each selected operation is registered as an MCP tool with input schema, output schema, description, annotations, and operation metadata.
### Tool Call
1. The client sends a standard MCP `tools/call` request to `/mcp`.
2. The MCP server validates the tool name and arguments against the OpenAPI-derived input schema.
3. The adapter substitutes path parameters into the OpenAPI route template.
4. The adapter encodes query parameters into the query string.
5. The adapter marshals request body arguments as JSON when the operation has a request body.
6. The adapter forwards the caller's `Authorization` header and executes the matching `/api/v1/...` request through the existing Echo handler in-process.
7. The API response JSON is decoded into `map[string]any`.
8. The MCP result returns a compact JSON text fallback plus object-shaped `structuredContent`.
## Result Shape
Every MCP result must use object-shaped `structuredContent`.
Rules:
- If the API response is a JSON object, return it unchanged.
- If the API response is empty, return `{ "ok": true }`.
- If an unexpected raw JSON array appears, wrap it as `{ "result": [...] }`.
- If an unexpected scalar appears, wrap it as `{ "result": value }`.
This directly addresses issue #6022 by preventing collection tools from returning bare arrays.
## Authentication And Origin Safety
The MCP endpoint accepts the same bearer credentials as the API:
```text
Authorization: Bearer <PAT-or-access-token>
```
The MCP adapter forwards the bearer header to the in-process API request. Public API operations can work without authentication when the API allows them. Mutating operations require authentication because the API already enforces that behavior.
For browser-origin safety, `/mcp` rejects cross-origin browser requests unless the `Origin` header is same-origin or matches the configured instance URL. Requests without an `Origin` header are allowed because desktop MCP clients commonly omit it.
## Errors
The MCP server should convert failures into MCP tool errors:
- Invalid MCP arguments: concise validation message.
- API `401` or `403`: preserve the API message where available.
- API `404`: report that the resource was not found.
- Other API errors: include the HTTP status code and decoded API message.
- Internal OpenAPI or adapter errors: log server-side details and return a concise tool error.
Adapter errors should not bypass MCP result formatting unless the underlying MCP framework requires protocol-level errors for invalid protocol messages.
## Tool Annotations
Tool annotations are derived from HTTP methods:
- `GET`: read-only, non-destructive, idempotent.
- `POST`: mutating unless the operation is explicitly known to be read-only.
- `PATCH`: mutating, non-idempotent by default.
- `DELETE`: destructive and idempotent by default.
These annotations are hints for clients and do not replace API authorization.
## Testing
### OpenAPI Parsing Tests
Tests should verify:
- every curated operation ID exists in `proto/gen/openapi.yaml`
- every selected tool has an object input schema
- every selected tool has an object output schema
- selected operations do not include admin, auth, webhook, identity provider, personal access token, instance setting, share-link, AI transcription, or bulk-delete operations
- tool names are deterministic and unique
### MCP Protocol Tests
Using an Echo test server and JSON-RPC requests, tests should verify:
- `initialize` succeeds
- `tools/list` returns only curated OpenAPI-derived tools
- tool definitions include input and output schemas
- no prompts or resources capabilities are advertised
- collection tool calls return object-shaped `structuredContent`, never a bare array
### Adapter Tests
Representative adapter tests should cover:
- `GET /api/v1/memos?pageSize=...`
- `POST /api/v1/memos`
- `GET /api/v1/memos/{memo}`
- `PATCH /api/v1/memos/{memo}`
- `DELETE /api/v1/memos/{memo}`
- `GET /api/v1/memos/{memo}/comments`
- `POST /api/v1/memos/{memo}/reactions`
Before finishing implementation, run:
```bash
go test ./server/router/mcp/... ./server/router/api/v1/... ./server/...
```
## Implementation Notes
- Do not hand-edit `proto/gen/openapi.yaml`.
- If a needed MCP tool is not represented by OpenAPI, add or adjust the proto/API surface first, run `cd proto && buf generate`, then derive the MCP tool from the regenerated OpenAPI.
- Prefer existing API auth and gateway behavior over duplicating authorization logic in the MCP adapter.
- Keep the first version intentionally small. Additional OpenAPI-derived tools can be added by extending the curated allowlist and tests.
@@ -0,0 +1,168 @@
# Markdown WYSIWYG Editor — Design
**Date:** 2026-06-11
**Status:** Approved pending user review
## Goal
Replace the plain `<textarea>` memo editor with a modern WYSIWYG editor that renders
markdown syntax live as the user types (Linear / Claude.ai composer style) while keeping
markdown — not HTML — as the only storage and API format. A toggle lets users drop back
to the raw textarea editor at any time.
## Background
The current editor (`web/src/components/MemoEditor/Editor/index.tsx`) is a textarea with
hand-rolled features layered on top: tag suggestions (`#`), slash commands (`/`),
Ctrl+B/I markdown shortcuts, list auto-continuation, URL-paste-to-link, IME composition
plumbing, auto-grow height, and a toolbar that inserts raw markdown strings through an
imperative, character-offset-based `EditorRefActions` API.
Live markdown editing is among the most-requested memos features
(usememos/memos#2216, #3495, #766, #4259).
### Prior art (researched 2026-06)
- **Linear** builds its editor directly on **ProseMirror** (+ y-prosemirror for collab).
- **ChatGPT's composer** is raw **ProseMirror**.
- **Claude.ai's composer** is **Tiptap** (headless framework on ProseMirror).
All three references are ProseMirror-family WYSIWYG editors with markdown input rules.
### Library options considered
| Option | Verdict |
|---|---|
| **Tiptap v3 + `@tiptap/markdown`** | **Chosen.** Claude.ai's stack. Best ecosystem; StarterKit covers the core set with input rules; Suggestion utility replaces hand-rolled tag/slash popups; official bidirectional markdown (early release — gated by a spike). First-class React bindings; best-in-class IME handling via ProseMirror. ~100 KB gzipped. |
| Milkdown | Markdown-first ProseMirror + remark; strongest fidelity by design, but thin UX building blocks, small community, low bus factor. |
| Raw ProseMirror + prosemirror-markdown | Linear's literal approach; maximum control, most engineering effort for the least product difference. |
| Lexical | Non-ProseMirror engine; markdown is a conversion target, historically weaker IME. |
| CodeMirror 6 live-preview (Obsidian style) | Perfect byte fidelity but a different UX than the named references; largely DIY. |
## Decisions (user-confirmed)
1. **Raw mode stays**: a per-editor toggle switches between WYSIWYG and the current
textarea editor. (Revised from an earlier "full replacement" decision.)
2. **Fidelity contract**: lossless for supported syntax — semantic round-trip for
everything the editor models; style normalization (e.g. `*``-` bullets) is
acceptable. Unknown/exotic syntax is preserved verbatim, never dropped.
3. **v1 scope — Linear-style core set** rendered live: bold/italic/strike/inline-code,
headings, ordered/unordered/task lists, links, blockquotes, code blocks, and memos
`#tags`. Tables, `$…$`/`$$…$$` math, and inline HTML remain literal text in the
editor (they still render richly in the memo view after save). Mermaid lives in
` ```mermaid ` fences, so it is just a code block while editing.
## Architecture
### Editor abstraction
`EditorContent` hosts one of two implementations behind a shared **`EditorController`**
interface:
- `focus()`, `getMarkdown()`, `setMarkdown()`, `insertMarkdown()`, `isEmpty()`
- Formatting **intents**: `toggleBold()`, `toggleItalic()`, `toggleTaskList()`, etc.
The **Tiptap editor** implements intents as ProseMirror commands. The **textarea editor**
implements them exactly as today (wrap selection in `**`). The toolbar dispatches intents
and never knows which editor is mounted. The textarea's existing string-surgery API
(`EditorRefActions`) becomes internal to the textarea implementation rather than the
public contract.
### Mode toggle
- A small icon button in the editor toolbar switches modes mid-edit.
- Switching is a markdown string handoff: WYSIWYG → raw serializes (the same path as
save, so no new fidelity risk); raw → WYSIWYG parses.
- The preference persists in localStorage (per device). No backend/proto changes; can
graduate to a server-side user setting later.
### Tiptap editor composition
Built on `@tiptap/react` `useEditor`. Extensions:
- **StarterKit** (configured): headings, bold/italic/strike/code, lists, blockquote,
code block — all with live input rules (`**bold**` converts as you type).
- **TaskList / TaskItem**, **Link** (includes URL-paste-over-selection → link),
**Placeholder** (i18n'd), **`@tiptap/markdown`**.
- **`Tag`** (custom): inline node for `#tag`, rendered as a styled token, serialized
back to `#tag` verbatim. `#` triggers a Suggestion-plugin popup backed by the
existing tag store.
- **`SlashCommand`** (custom): Suggestion-plugin popup on `/`, replacing
`SlashCommands.tsx` in WYSIWYG mode.
- **`PreservedBlock`** (custom): the fidelity workhorse — unmodeled constructs
(tables, math, inline HTML, unrecognized syntax) are captured at parse time into
nodes carrying their raw source, displayed as literal text (subtle mono styling),
and re-emitted byte-for-byte on serialize.
Hand-rolled textarea features that become native or config in WYSIWYG mode:
Ctrl+B/I keymaps (StarterKit), list auto-continuation (ProseMirror lists),
auto-grow height (contenteditable), IME composition (ProseMirror — better CJK
behavior than the textarea plumbing).
### Data flow
Unchanged at the boundaries. Memo markdown → `setMarkdown()` on load. On update,
serialized markdown (debounced) flows into the existing state reducer as
`state.content`, so auto-save, the localStorage draft cache (still a plain markdown
string), tag extraction, and the save path are untouched. The backend only ever sees
markdown. File paste/drag wires Tiptap `handlePaste`/`handleDrop` into the existing
`uploadService`.
## Fidelity contract
1. **Supported constructs** round-trip semantically; list-marker style may normalize;
content never changes meaning.
2. **Unmodeled constructs** round-trip byte-for-byte via `PreservedBlock`.
3. **A round-trip corpus test enforces this permanently** (see Testing). It is written
first, as a spike, and is the go/no-go gate on `@tiptap/markdown` (early release).
If the gate fails and custom Marked tokenizers cannot fix it, the fallback is
swapping only the parse/serialize layer to remark (already a memos dependency)
while keeping the Tiptap editor — editor and serialization are deliberately
decoupled for this reason.
## Error handling
- **Load guard (tripwire):** on opening an existing memo, parse → re-serialize →
compare semantically. If the round-trip would lose content, log it and show a
notice — "this memo contains syntax the editor can't safely edit" — with a
one-click switch to raw mode for that memo. Expected never to fire.
- **Draft safety:** the localStorage draft cache stays a markdown string on the same
debounce as today; drafts are interchangeable between both editor modes.
- **Save path:** reuses already-serialized `state.content`; save never triggers a
fresh parse, so it gains no new failure mode.
## Testing
- **Round-trip corpus test (the spike, written first):** fixture markdown files
(GFM, tables, math, mermaid, inline HTML, nested lists, CJK, emoji) → parse →
serialize → assert semantic equality for supported syntax, byte equality for
preserved blocks. Runs in the existing vitest/jsdom setup.
- **Extension unit tests:** `Tag` (parse/serialize/suggestion trigger),
`PreservedBlock` (verbatim round-trip), link-paste.
- **Component tests** (@testing-library): toolbar intents produce expected markdown
in both modes; slash/tag popups open and insert correctly; mode toggle hands
content across without loss.
- **Manual QA:** CJK IME composition, mobile Safari/Chrome soft keyboards, focus
mode, paste-image upload.
## Rollout
Feature branch, PR series:
1. **Spike:** corpus test + `@tiptap/markdown` verdict (throwaway if the gate fails).
2. **Core editor:** Tiptap behind `EditorController`, StarterKit set, markdown in/out,
wired into the state layer.
3. **Memos features:** `Tag` + suggestions, `SlashCommand`, `PreservedBlock`,
file paste/drop, toolbar intent conversion.
4. **Toggle + refactor:** mode toggle UI and persistence; textarea editor refactored
to implement `EditorController` (nothing deleted — it powers raw mode).
Dependency cost: ~100 KB gzipped (`@tiptap/core`, `@tiptap/react`, extensions,
`@tiptap/markdown` + MarkedJS). WYSIWYG is the default mode for all users.
## Out of scope (v1)
- Interactive table editing, live KaTeX/mermaid rendering while editing.
- Collaborative editing (Yjs).
- Server-side persistence of the mode preference.
- Mobile apps (web/PWA only — native apps are separate codebases).
@@ -0,0 +1,257 @@
# Design: Expand the CEL filter surface
- **Date:** 2026-06-15
- **Status:** Approved (design); ready for implementation planning
- **Area:** `internal/filter` (memo & attachment filter engine)
- **Follow-up spec:** CEL engine hardening + native-AST migration (separate, sequenced after this)
## Summary
memos lets API clients pass a CEL expression in the `filter` field of list
requests. The `internal/filter` engine uses `cel-go` purely as a **parse +
type-check frontend**, then walks the AST and translates it into a SQL `WHERE`
fragment for the active dialect (SQLite / MySQL / Postgres). cel-go never
evaluates anything.
This spec adds three new CEL constructs that users can write, each with a SQL
translation across all three dialects:
1. `startsWith()` / `endsWith()` on scalar string fields (case-insensitive).
2. `all()` comprehension on tag lists (matches only non-empty tag sets).
3. `matches(regex)` on string fields.
## Goals
- Expose the three constructs above through the existing
parse → IR → render pipeline.
- Keep parity across SQLite, MySQL, and Postgres, with golden tests for each.
- Preserve the engine's invariant: only schema-declared fields and explicitly
supported operations are accepted; everything else is rejected with a clear
error.
## Non-goals
- **Value-producing CEL features with no SQL form** are explicitly out of scope:
optional types (`?.`, `optional.of`), `map()` / `filter()` transforms, the
math extension, string-manipulation extensions (`replace`, `split`,
`substring`, `format`), and two-variable comprehensions. There is nothing to
push into a `WHERE` clause for these.
- **`lowerAscii()` / `upperAscii()`** — dropped. `contains()` is already
case-insensitive on all dialects, and the new `startsWith`/`endsWith` are
case-insensitive too (see decisions), so explicit case-folding adds little.
Revisit only if users ask.
- **Parser hardening and the native-AST proto migration** are a separate
follow-up spec. The one exception that rides along here is
`cel.ValidateRegexLiterals()`, which feature ③ requires for safety.
## Background: how the engine works today
Pipeline (see `internal/filter/README.md`):
1. **Parse**`env.Compile(filter)` parses and type-checks against the
memo/attachment environment declared in `schema.go`; the AST is converted via
`cel.AstToParsedExpr()`.
2. **Normalize**`parser.go` walks the CEL `Expr` and builds a
dialect-agnostic IR (`ir.go`): logical ops, comparisons, `IN`, `contains()`,
and `exists()` comprehensions over tag lists.
3. **Render**`render.go` walks the IR and emits dialect-specific SQL plus
placeholder args.
Two existing facts that shaped this design:
- **`contains()` is already case-insensitive** on all three dialects
(`render.go` `renderContainsCondition`): SQLite uses the custom
`memos_unicode_lower` function, Postgres uses `ILIKE`, MySQL relies on its
default case-insensitive collation.
- **Custom SQLite scalar functions are already registered**
(`store/db/sqlite/functions.go`, `ensureUnicodeLowerRegistered` via
`modernc.org/sqlite`'s `RegisterScalarFunction`, invoked from
`store/db/sqlite/sqlite.go`). The new `REGEXP` function follows this exact
pattern.
cel-go version: `v0.28.0` (latest is `v0.28.1`, a patch with nothing relevant to
memos). No version bump is required for this work.
## Resolved decisions
| # | Decision | Choice |
|---|----------|--------|
| A | Case-sensitivity of new scalar `startsWith`/`endsWith` | **Case-insensitive**, consistent with existing `contains()`. `==` stays case-sensitive (exact match). |
| B | `all()` over a memo with zero tags | **Require non-empty**: an untagged memo does NOT match an `all()` filter. (Diverges from strict CEL vacuous-truth, but matches search-box intuition.) |
| C | Keep `lowerAscii()` / `upperAscii()`? | **Drop** from this spec. |
## Detailed design
### ① `startsWith()` / `endsWith()` on scalar string fields
**Surface.** Allow `field.startsWith("x")` and `field.endsWith("x")` as
top-level boolean calls for scalar string fields. Today these functions are only
recognized *inside* tag comprehensions (`parser.go` `extractPredicate`).
Applicable fields: memo `content`; attachment `filename`, `mime_type`.
`creator` is intentionally **excluded**: it is an identity field with `==`/`!=`
semantics whose column is wrapped as `'users/' || username`, so prefix/suffix
matching there would match against the `users/` prefix and surprise users.
**Schema.** Generalize the per-field text-matching capability. Today `Field` has
`SupportsContains bool`. Replace/extend with a capability that also covers
prefix/suffix matching (e.g. a `SupportsTextMatch bool`, or reuse
`SupportsContains` to gate all three LIKE-based ops). Fields that already set
`SupportsContains: true` gain prefix/suffix support.
**Parser.** In `buildCallCondition`, recognize `startsWith` / `endsWith` calls
whose target is a scalar string field and whose single argument is a string
literal. Reject non-literal arguments and fields without the capability.
**IR.** Generalize `ContainsCondition` into a single node:
```go
type TextMatchMode string
const (
TextMatchContains TextMatchMode = "contains"
TextMatchPrefix TextMatchMode = "prefix"
TextMatchSuffix TextMatchMode = "suffix"
)
type TextMatchCondition struct {
Field string
Mode TextMatchMode
Value string
}
```
`contains()` migrates to `TextMatchCondition{Mode: TextMatchContains}`.
**Render.** Build a `LIKE` pattern from the (escaped) literal:
- prefix → `value%`
- suffix → `%value`
- contains → `%value%`
Reuse the existing case-insensitive rendering already used by `contains()`:
SQLite `memos_unicode_lower(col) LIKE memos_unicode_lower(?)`, Postgres
`col ILIKE $n`, MySQL `col LIKE ?`.
**LIKE-escaping fix.** The current `contains()` renderer interpolates the raw
value into the pattern without escaping `%`, `_`, or `\`. This means a search
for `50%` behaves as a wildcard. The new shared path will escape these
metacharacters (and emit `ESCAPE '\'` where required by the dialect). This
closes a small latent wildcard-injection inconsistency and applies uniformly to
contains/prefix/suffix.
### ② `all()` comprehension on tag lists
**Surface.** Allow `tags.all(t, <pred>)` where `<pred>` is one of the predicates
already supported for `exists()`: `t == "x"`, `t.startsWith("x")`,
`t.endsWith("x")`, `t.contains("x")`.
**Parser.** `detectComprehensionKind` currently accepts only `exists()` and
explicitly rejects `all()`. Add a `ComprehensionAll` kind (accumulator inits to
`true`, loop step uses `_&&_`). Reuse the existing predicate extraction.
**IR.** Add `ComprehensionAll` to the `ComprehensionKind` enum; the existing
`ListComprehensionCondition` already carries `Kind`.
**Render — proper per-element semantics.** The existing `exists()`
implementation matches the *serialized* JSON array text with `LIKE`, which works
for "at least one element matches a substring" but **cannot** express "every
element matches." `all()` therefore needs real per-element iteration. Decision B
(require non-empty) means: array is non-empty **AND** no element fails the
predicate.
- **SQLite:**
```sql
(<array> IS NOT NULL AND <array> != '[]'
AND NOT EXISTS (SELECT 1 FROM json_each(<array>)
WHERE NOT (<predicate on json_each.value>)))
```
- **Postgres:**
```sql
(<array> IS NOT NULL AND jsonb_array_length(<array>) > 0
AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(<array>) AS e(value)
WHERE NOT (<predicate on e.value>)))
```
- **MySQL:**
```sql
(<array> IS NOT NULL AND JSON_LENGTH(<array>) > 0
AND NOT EXISTS (SELECT 1 FROM JSON_TABLE(<array>, '$[*]'
COLUMNS (value VARCHAR(512) PATH '$')) AS j
WHERE NOT (<predicate on j.value>)))
```
The per-element predicate reuses LIKE/`=` against the element `value`
(case-insensitive for `startsWith`/`endsWith`/`contains`, consistent with ①).
Hierarchical-tag prefix behavior should match the existing `exists()` rendering
(a prefix matches the exact tag or a `tag/...` child).
> Note: this introduces correlated subqueries against the same `memo.payload`
> column the outer query already reads; confirm the generated SQL composes with
> the surrounding `WHERE` and placeholder offsets in `helpers.AppendConditions`.
### ④ `matches(regex)` on string fields
**Surface.** Allow `field.matches("pattern")` for the same free-text fields as ①
(`content`, `filename`, `mime_type`; `creator` excluded), literal pattern only.
**Env / validation.** Add `cel.ValidateRegexLiterals()` to the env options in
`schema.go` so malformed patterns fail at compile time with a clear message
(validated against Go's RE2).
**Parser / IR.** Recognize `matches` calls; add:
```go
type RegexCondition struct {
Field string
Pattern string
}
```
Reject non-literal patterns and fields without text-match capability.
**Render.**
- **Postgres:** `col ~ $n`
- **MySQL:** `col REGEXP ?`
- **SQLite:** `col REGEXP ?`. SQLite desugars `X REGEXP Y` to the function call
`regexp(Y, X)`, so register a 2-arg scalar function named `regexp(pattern,
value)` returning 1/0, backed by Go's `regexp` package, following the
`ensureUnicodeLowerRegistered` pattern in `store/db/sqlite/functions.go`.
Compile patterns lazily with a small cache (or rely on RE2 compile per call;
decide during implementation based on measured cost).
**Documented caveats** (engine differences are inherent, not bugs):
- Regex *syntax* differs per engine: SQLite uses Go RE2; Postgres uses POSIX
ERE; MySQL 8.0+ uses ICU. Portable patterns work everywhere; engine-specific
constructs may not. Document this in `internal/filter/README.md`.
- ReDoS risk is low: RE2 (SQLite path) is linear-time; Postgres/MySQL POSIX
engines do not catastrophically backtrack. `ValidateRegexLiterals()` rejects
patterns that don't compile under RE2 as a first-line guard.
## Testing strategy
For each feature, add golden tests in
`store/db/{sqlite,mysql,postgres}/memo_filter_test.go` (and the attachment
filter tests where applicable):
- **Happy path:** assert the exact SQL fragment and args per dialect.
- **Error paths:** non-literal argument, unsupported field, malformed regex,
unsupported predicate inside `all()`.
- **`all()` empty-set:** confirm an untagged memo does not match (decision B).
- **LIKE escaping:** confirm `%`, `_`, `\` in `contains`/`startsWith`/`endsWith`
values are treated literally.
Run `go test ./...` (engine unit tests plus all three dialect suites). The
`contains()``TextMatchCondition` refactor must keep existing golden outputs
unchanged except for the intentional escaping fix.
## Rollout / sequencing
This is the first of two specs. The second (already agreed) covers engine
hardening: tightened parser limits (`ParserExpressionSizeLimit`,
`ParserRecursionLimit`, `ParserErrorRecoveryLimit`), the
`ValidateComprehensionNestingLimit` / `ValidateHomogeneousAggregateLiterals`
validators, and migrating `parser.go` off the deprecated
`genproto/.../expr/v1alpha1` proto to the native `common/ast` API. Building this
surface-expansion spec first is acceptable; the hardening migration is a pure
refactor that the golden tests written here will help protect.