Compare commits

...

1209 Commits

Author SHA1 Message Date
johnnyjoygh 8d12287f30 test(email): bound SMTP listener wait 2026-07-25 01:21:56 +08:00
johnnyjoygh 1d978098c4 test(email): make SMTP failure test deterministic 2026-07-25 01:11:20 +08:00
Chad Harp 03e34bd0da fix(mcp): accept canonical resource names in path params and keep error results schema-valid (#6108) 2026-07-24 00:15:13 +08:00
boojack 41dd3d1740 chore(main): release 0.30.0-rc.2 (#6096) 2026-07-20 23:35:59 +08:00
boojack cc20327b8a chore(access-token): default expiration to never 2026-07-20 20:03:05 +08:00
boojack b7d5d09f8a fix(api): update UID compatibility
Use the original UID format consistently across API, username, and store validation so UUID-based callers continue to work. Regenerate API documentation and add regression coverage for UUID memo IDs.
2026-07-20 19:41:55 +08:00
boojack d149a96566 chore(release): enable prerelease 2026-07-20 08:54:41 +08:00
johnnyjoygh 249b331596 perf(web): reduce memo feed startup and rendering work 2026-07-20 00:15:26 +08:00
johnnyjoygh 7c78320999 chore: cleanup docs 2026-07-19 23:51:11 +08:00
johnnyjoygh 06ecef33bc chore(web): update about page and access token settings 2026-07-19 22:23:51 +08:00
johnnyjoygh f94697496b docs: polish README and add web clipper 2026-07-18 23:43:21 +08:00
johnnyjoygh 6c1055f483 perf(web): deduplicate explore data requests
Share canonical user and memo query caches across creator, reaction, comment, mention, and relation lookups. Reuse list data for relation snippets and cover overlapping queries with regression tests.
2026-07-18 23:25:59 +08:00
johnnyjoygh 88c6ee8ebc perf(web): reduce demo bandwidth usage
- Cache fingerprinted frontend assets for one month.
- Load media, rich renderers, dialogs, and editors only when needed.
- Simplify seeded demo content and cover deferred-loading behavior.
2026-07-18 22:06:14 +08:00
johnnyjoygh 0bfeb91d50 fix(api): show clean RPC error messages 2026-07-18 11:20:15 +08:00
johnnyjoygh 84776cc106 fix(api): align resource IDs with AIP conventions
Validate new user-provided IDs using the AIP-122 format while retaining legacy UID compatibility. Correct resource annotations and canonical names returned by user stats.
2026-07-18 11:12:28 +08:00
johnnyjoygh 715306ea66 chore: enrich access tokens setting page
Settings drops the all-in-one bordered card for a de-carded layout in
the property-rail design language: a sticky table-of-contents rail at
md+ (Settings wordmark, uppercase group labels, quiet anchor rows) and
a horizontally swipeable chip strip below md, replacing the mobile
section dropdown. Nav items are real anchors with aria-current, and
switching sections scrolls back to the top.

Access Tokens becomes a first-class section with an explainer panel:
what a PAT is and a copyable curl example (real instance origin,
memos_pat_ prefix) beside token-safety guidelines in a two-column band,
with a Learn more docs link and the tokens table beneath. Successful
PAT authentication now records the token's lastUsedAt asynchronously
inside resolveBearer, with a clone-before-mutate cache guard and
monotonic writes in the store, surfaced in a Last used column.

Also localizes the create dialog's 90 Days label, lets the My Account
row wrap instead of clipping on narrow screens, and drops the dead
select-section key from all locales.
2026-07-18 10:41:53 +08:00
Dedy F. Setyawan 2d01420c23 fix(web): align radio button group indicator (#6097)
Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
2026-07-18 01:47:45 +08:00
boojack 469c995cc0 chore: dump demo data
Login on the demo instance is SSO-only, so visitors always arrive as a
fresh user and consume seed content through Explore. Rebuild the seed
around that funnel: four personas (steven the maintainer, alice, ben,
zoe), a public feed mixing short captures with long-form anchors, and
protected memos that visibly appear after sign-in to teach the
visibility model.

Every headline feature is now demonstrated: nested tags, memo
references, comments with mentions, reactions, locations, a database-
stored image attachment, and a rendering test memo covering math,
mermaid, code, tables, and task lists.
2026-07-17 00:14:01 +08:00
boojack d4b5a1695b feat(web): redesign memo detail sidebar as property rail
Rework MemoDetailSidebar into a Linear-style rail: a quiet icon action
cluster (pin, copy link, share image, share links) followed by
label/value property rows for visibility, created/edited times, author,
and location, then hairline-separated sections for tags, attachments,
relations, and the outline. Attachments and reference relations were
previously not surfaced in the sidebar at all.

- Visibility is editable in place, reusing VisibilitySelector with a
  new compact size variant that matches the 13px row grammar
- Pin toggle and visibility changes go through useUpdateMemo
- MemoOutline gains active-section tracking (scroll-spy), depth
  indentation, and tick-rail styling; drops the animated underline
  and native title tooltips
- Widen the detail rail from w-56 to w-60
2026-07-16 23:59:31 +08:00
boojack e497895bf3 feat(web): restore scroll position across navigation 2026-07-16 22:31:00 +08:00
boojack 773e56e537 fix(web): position tooltip arrows outside content
Closes #6095
2026-07-15 22:29:38 +08:00
boojack 49516395da chore(release): disable prerelease 2026-07-15 21:53:27 +08:00
boojack 57d579a2a2 chore(main): release 0.30.0-rc.1 (#6015) 2026-07-15 00:19:47 +08:00
boojack 3350abe815 chore(release): enable release candidate versioning 2026-07-14 21:56:28 +08:00
boojack df1967df91 fix(editor): restore CodeMirror default spacing
Remove the zero-padding line override that replaced CodeMirror's built-in horizontal spacing and caused the editor content regression reported in #6093.

Disable completion icons through CodeMirror configuration, preserve placeholder and dark-theme contrast, and remove redundant editor style overrides.

Fixes #6093
2026-07-14 21:35:55 +08:00
boojack 8d9849e13f chore: redesign about page as identity hero
Rebuild /about around an identity hero: instance branding with custom
profile fallbacks, a demo badge, and version/commit chips linked to the
GitHub release and commit. Dev builds show a plain version chip and hide
the unknown-commit chip.

Project links become an annotated directory (homepage, docs, API docs,
GitHub repo), followed by an MIT license note. The bird sprite gallery
moves out of the page, and the version/commit block leaves the settings
sidebar now that /about owns build info.
2026-07-14 20:54:29 +08:00
boojack 1e91cfba9d fix(auth): stabilize SSO option loading 2026-07-14 20:06:54 +08:00
boojack 9c3bd44a1b chore(web): migrate UI primitives to Base UI 2026-07-14 09:06:40 +08:00
boojack 0038295bbc feat(config): provision settings from secret files
- Load IdPs and supported instance-setting groups as runtime overlays from /etc/secrets.
- Reject API mutations of deployment-managed resources and serialize authentication safety checks across database drivers.
- Preserve upgrade compatibility, demo SSO policy, stable IdP ordering, and driver-specific transaction retries.
2026-07-13 22:34:24 +08:00
boojack 564da949cb chore(auth): redesign sign-in and sign-up pages with panel layout
Rebuild the auth pages around a shared AuthPageLayout card with a
three-tier guidance hierarchy: the form (or provider buttons) on top,
a centered switch sentence below it, and a quiet explore band anchored
to the card foot on public instances.

- add AuthPageLayout (brand row, chip, empty state, link prompt,
  explore band gated by instance URL with hideExplore opt-out)
- extract CredentialFields shared by sign-in and sign-up forms
- extract IdentityProviderButtons; fetch the provider list via a
  cached TanStack Query hook with an enabled flag
- cover every instance config: SSO-only, password-only, registrations
  closed, private instance, nothing enabled, first-run setup, admin
- promote /auth/signup, /auth/admin, /auth/callback to ROUTES and add
  appendSearchParams for param-preserving auth links
- move the explore link out of AuthFooter (selectors only now)
- refresh en and zh-Hans auth strings; drop dead keys
2026-07-13 19:40:31 +08:00
boojack a9fcd459f6 chore(demo): harden SSO-only demo experience 2026-07-13 00:31:15 +08:00
boojack ad6d009767 feat(auth): bootstrap identity providers from secret files
- scan /etc/secrets for memos-idp-*.json after migrations and demo seeding
- reconcile protobuf JSON identity providers by stable UID
- validate all discovered providers before applying updates
2026-07-13 00:08:15 +08:00
boojack 6c17e87cf6 fix(auth): support OAuth client auth auto-detection
- support providers requiring client_secret_basic while preserving POST fallback
- stop logging user-info claims and mapped profile data
- cover both client authentication styles with PKCE
2026-07-12 23:34:33 +08:00
boojack 4bc3928029 fix(user): implement ListUsers pagination
Rework ListUsers to match the ListMemos pagination contract: opaque
PageToken, normalizePageSize, DB-level limit+1 look-ahead, and a
next_page_token. Adds Offset to store.FindUser with an OFFSET clause in
all three dialects, and an `id DESC` ORDER BY tiebreaker so offset pages
stay stable when created_ts ties.

Also align pagination across list endpoints:
- Bump DefaultPageSize 10 -> 50 to match the documented default; use
  normalizePageSize in ListAttachments.
- Remove the never-implemented total_size field from all six list
  responses (ListUsers, ListAttachments, ListMemoComments,
  ListMemoReactions, ListUserSettings, ListPersonalAccessTokens) and
  regenerate.
- useListUsers now pages through next_page_token so the admin members
  view still loads every user past the default page size.
2026-07-12 20:51:04 +08:00
Yuhang Wang 375e2bc60e fix(i18n): complete missing Simplified Chinese translations (#6084) 2026-07-12 19:55:37 +08:00
boojack 3fe145083f chore: reorganize backend and frontend modules
- remove the unused internal cron package
- split API service implementations by responsibility
- clarify frontend shared-module ownership
2026-07-12 17:59:12 +08:00
TowyTowy c9b356b46a fix(memo): populate parent relation in comment webhook payload (#6083)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:23:58 +08:00
boojack 9203a22ed1 chore(editor): update focus and save workflows
- preserve grid layout while the editor is in focus mode\n- extract save orchestration into a dedicated lifecycle hook\n- simplify code formatting controls in the toolbar
2026-07-12 17:23:36 +08:00
boojack 42ad4105c1 fix(editor): align CodeMirror markdown event handling
- intercept file paste and drop before CodeMirror inserts file text
- reconfigure localized placeholders without remounting the editor
- add regression coverage and document the CodeMirror review
2026-07-10 18:59:33 +08:00
boojack a9ac008a68 fix(editor): let CodeMirror own caret layout
Keep caret and placeholder behavior on CodeMirror's standard extensions, and move height and scrolling to the editor elements so empty space stays interactive across browsers.\n\n- disable unused multi-cursor selection\n- upgrade CodeMirror state and view patches
2026-07-10 08:51:16 +08:00
boojack 0150040056 chore(web): upgrade typescript to 7.0
TS 7.0 is the native compiler and stays CLI-compatible, so the tsc-based lint is unchanged. Verified pnpm lint and pnpm build pass.
2026-07-09 21:38:32 +08:00
boojack 730c24592f fix(web): stabilize multi-column memo layout 2026-07-09 20:56:35 +08:00
boojack 5329e60407 fix(web): fill the editor host so the caret shows in empty space
The CodeMirror editor only grew to its content height, so in focus mode
(where the host is tall) the empty area below the text was bare host
markup outside the editor. Clicking there could not focus the editor or
place the caret, so it appeared to vanish in "empty" space. Give
.cm-editor height:100% — it collapses to auto in normal (auto-height)
mode and only fills when the host has a definite height.

Fixes #6076
2026-07-08 23:37:47 +08:00
boojack 83552c35be fix(ci): stamp canary image with semantic version
The canary workflow passed VERSION=canary as a build-arg, so the demo
instance reported "canary" as its version. Derive the version from the
latest git tag instead (e.g. 0.29.1) so the app reports a real semver.
The `canary` image tag is unchanged.
2026-07-08 21:58:00 +08:00
boojack a72ae804c0 fix(web): keep the multi-column feed balanced
Newly-created memos were pinned to column one and never released, and cards
that grew after placement (late images/comments) never rebalanced, so the
columns drifted badly out of balance.

- Leave the priority (just-created) memo's column assignment transient, so a
  superseded memo rebalances instead of piling up in column one forever.
- Self-heal: re-pack from a clean slate when a late height change leaves the
  columns lopsided, adopting it only when it meaningfully shrinks the spread.
- Animate only the adopted rebalance; resizes, column-count changes, growth
  reflows and first paint stay instant so widths and positions stay in lockstep.
2026-07-08 21:51:02 +08:00
boojack dc41480ece chore(web): replace memo list skeleton with a simple delayed spinner
The card-shaped skeleton never matched the loaded memos (heights vary
with markdown, images, and tags), making the swap feel jarring. Use the
same centered spinner the grid layout already used, still gated behind
the 250ms delay so fast loads render no indicator at all.

Ref https://github.com/orgs/usememos/discussions/6066
2026-07-07 23:49:43 +08:00
boojack 41ff22b0cc feat(filter): fold now.getXxx() accessors for dynamic date-part filters
Timestamp accessors previously compiled only on schema fields, so a saved
shortcut like created_ts.getMonth() == now.getMonth() && created_ts.getDate()
== now.getDate() ("on this day") failed with unknown identifier "now" and the
only workaround froze literal month/day values into the filter.

Accessors on `now` now fold to literal date parts of the frozen per-compile
clock (UTC, CEL bases: 0-based month/day-of-week), so such filters re-resolve
on every query. Comparisons are also normalized for the renderer: a folded
literal on the left swaps operands with the operator mirrored, and
literal-vs-literal comparisons fold to a constant condition.
2026-07-07 22:53:21 +08:00
johnnyjoygh e3c231fcac feat(web): add code block and strikethrough to formatting toolbar
Reorganize the toolbar into inline marks (bold, italic, strikethrough,
inline code) and blocks (lists, fenced code block), with the Type glyph
for the paragraph/heading picker. Marks derive from the shared MARKS
table; code block toggling reuses the same selection-probe lookup so
toggle-off always agrees with the button's active state. A fresh empty
strikethrough pair (~~~~) parses as a tilde code fence, so empty-pair
removal and code-block detection recognize doubled mark tokens.
2026-07-07 22:08:33 +08:00
johnnyjoygh 475b04f765 chore(proto): regenerate with updated protoc plugins 2026-07-07 21:18:06 +08:00
johnnyjoygh 620db61f7c chore(web): unify compact truncation into a single ClampedSection
Compact cards previously had two truncation systems taped together: a
text-only clamp inside MemoContent (144px, no re-measure on image load,
fade tinted to the wrong surface) and a whole-tile cap in ColumnGrid
(hard clip mid-image, no affordance), later joined by a content-shape
rule choosing an owner per card — which could still stack two Show-more
buttons and silently broke footnote navigation in clamped cards.

Now there is exactly one mechanism: ClampedSection measures its content
and folds anything taller than 420px to a 360px preview with a fade and
a Show more/less toggle. MemoBody wraps the whole body in it (reactions
stay outside, never hidden); ColumnGrid is pure packing again and never
clips; MemoContent is a stateless renderer whose `compact` prop only
informs footnote-link behavior; MemoPreview uses a static CSS bound in
place of its previously inert toggle.

Deltas: text-only compact previews grow from ~6 rows to the same 360px
image cards get, and pinned memos clamp like any card (pinned means
ordering and a badge, not size).
2026-07-07 21:01:19 +08:00
weifanglab 9fae524221 refactor: use slices.Contains to simplify code (#6069)
Signed-off-by: weifanglab <weifanglab@outlook.com>
2026-07-07 09:25:38 +08:00
boojack 14a1c9a53b chore(web): icon layout switcher with radio semantics
Replace the layout Select with a lean 28px segmented control: Rows3 /
Columns2 / Columns3 / Infinity icons on a quiet muted track, only the
active option filled. Each option carries a tooltip with a label and an
honest description of the ceiling semantics ("up to N columns when the
width allows").

The control is a proper radiogroup — roving tabindex, arrow-key
movement, aria-checked — restoring the single-choice semantics the
Select used to provide. Options derive from ViewContext's canonical
value list, and one exhaustive record maps each value to its icon and
wording, so a future column option fails to compile until both exist.

The Compact mode row now stays visible in multi-column layouts, shown
as on and locked (multi-column always renders compact tiles); the
stored preference is untouched and resurfaces at a single column.
2026-07-07 09:24:48 +08:00
boojack 177d65a90e feat(web): add max-columns memo feed layout
Replace the single-column-only feed with a max-columns model: one
Columns setting (1 / 2 / 3 / ∞) where 1 is the reading list and
anything wider packs into a Google-Keep-style column grid. There is
deliberately no separate list/grid mode — the setting is a ceiling,
and widths that only fit one column fall back to the flow list.

- ColumnGrid: absolute-positioned packing that only translates cards,
  so appends and reorders never remount them; sticky column assignment
  keeps existing cards in place when new memos arrive; tiles are capped
  at 360px with a fade; columns clamp to 420px and center.
- Column one is the action column: the composer and active filters
  stack as its first tile, and a just-created memo is pinned directly
  beneath them.
- Multi-column always renders compact cards (policy centralized in
  PagedMemoList and threaded through renderer(memo, { compact })).
- Setting persists in ViewContext localStorage; the settings menu
  derives its options from the context's canonical value list.
2026-07-06 23:55:36 +08:00
boojack d1cef7a9ab feat(auth): add private instance mode derived from instance_url
Run the instance in private mode when instance_url is not configured: the API rejects anonymous requests except the auth-bootstrap set (sign-in, token refresh, instance profile/settings, SSO providers, share-link access) plus first-run user creation, and the web UI redirects anonymous visitors to /auth instead of /explore. Setting instance_url keeps the current public behavior. Access tokens and personal access tokens are never gated.

Enforcement lives in a shared Authorizer used by both the Connect interceptor and the gRPC-gateway middleware; the file server applies the same rule to public-memo attachments and avatars. Also merges the duplicated Authenticate/AuthenticateToUser token dispatch behind resolveBearer, dedups the AuthContext unauthenticated state, extracts the redirect decision into a pure shouldGatePrivateInstance helper, and prints the access mode at startup.
2026-07-05 22:48:00 +08:00
boojack 1794d0dc51 chore(editor): flatten insert menu
- Insert menu: replace the nested "More" submenu with a flat dropdown —
  insert actions plus inline Focus Mode and Formatting-toolbar toggles.
- Formatting toolbar: remove the link button and its window.prompt flow;
  the link command stays in the shared catalog for future surfaces.
2026-07-05 20:43:02 +08:00
Archit Goyal b787bfa75f feat(web): add untagged shortcut filter example (#6065) 2026-07-05 19:31:58 +08:00
boojack 3a55edd917 fix(scripts): prevent entrypoint restart loop when MEMOS_UID=0
The root-drop guard only checked `id -u = 0`, so when the target user was
also root (MEMOS_UID=0, common under rootless Docker with userns-remap)
su-exec re-execed back into root, re-entered the block, and looped forever
without ever reaching the memos binary — the container hung with no logs.

Add a MEMOS_ENTRYPOINT_SWITCHED marker (preserved across the su-exec
re-exec) so the privilege drop runs at most once, and log the resolved
UID:GID on startup so this path is never silent again.

Closes #6061
2026-07-03 22:02:53 +08:00
boojack 16b47b3be4 chore: tweak readme
Removed HTML sponsor section and updated sponsors list.

Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-07-03 09:21:44 +08:00
boojack c349c1549e fix(editor): make formatting commands toggle and convert correctly
Bold (and italic/code) now remove an empty just-inserted delimiter pair instead of nesting more asterisks, and strip real marks whichever way the selection was made; clicking Link inside a link unwraps it instead of inserting [](); the three list modes convert between each other (bullet on a task line no longer leaves '[ ] ...' behind), toggle off any ordered number, respect indentation, and apply across multi-line selections; list/heading edits keep the cursor in place (task insert on an empty line lands after the marker).

Structurally, highlight and toggle now share one line-mode detector so they cannot disagree; the heading regex is shared with headingDecorations, so indented headings render styled; one MARKS table drives all inline-mark behavior; run("link") is total (URL doubles as label on empty selection, now inserted inline rather than as its own block); the unused getSelectedText contract method and the dead markdown-list-detection.ts util are removed.

Known divergence, not addressed here: markdown-task-actions.ts TASK_LINE_REGEXP accepts ordered task items ('1. [ ]') which the editor's TASK_LINE does not; the viewer toggles such checkboxes but the editor toolbar won't detect them.
2026-07-02 20:41:46 +08:00
boojack ba609c5cb8 chore(editor): toggleable formatting toolbar
Add a localStorage-backed insert-menu toggle (default off) that surfaces the formatting toolbar outside focus mode, and redesign it as a lean inline row: ghost icon buttons grouped by thin dividers, a level-reflecting heading glyph (pilcrow / H1-H3), and the active command as the only filled control.

Also keep the editor focused when using the toolbar: command buttons preventDefault on mousedown, and the dropdown menus return focus to the editor on close.
2026-07-02 09:19:34 +08:00
grandpig 76aee4e177 refactor: use the built-in max/min to simplify the code (#6060)
Signed-off-by: grandpig <grandpig@outlook.com>
2026-07-02 08:35:47 +08:00
boojack 0e1d821fb8 feat(mcp): expose create_attachment tool
Add AttachmentService_CreateAttachment to the curated MCP allowlist so
agents can upload files (inline base64 content) alongside memos, closing
the gap where the MCP server could list/get/delete attachments but not
create them.

Closes #6057
2026-07-01 22:15:58 +08:00
blackflytech 0820fc2d6c refactor: use slices.Backward to simplify the code (#6058)
Signed-off-by: blackflytech <blackflytech@outlook.com>
2026-07-01 18:38:24 +08:00
boojack 10200606db feat(markdown): render and navigate GFM footnotes
Footnote definitions previously vanished on display. Two underlying issues:

- rehype-sanitize re-clobbered `id` attributes with a second `user-content-`
  prefix while leaving hrefs untouched, so footnote refs/backrefs pointed at
  ids that didn't exist. Disable clobbering (ids are already namespaced by
  remark-rehype) so anchors match their targets.
- Footnote anchors went through the external Link (target="_blank"), opening a
  blank tab instead of navigating. Route in-page `#` anchors through a new
  AnchorLink: scroll within the memo when shown in full, else navigate to the
  memo detail page with the hash, where MemoDetail scrolls it into view.

Also style the footnotes section GitHub-style: thin separator, smaller muted
text, and un-underlined ref/backref links.
2026-06-29 22:46:41 +08:00
boojack 3b601b8416 fix(location): truncate long address in memo location chip
The location chip used the kit Button (shrink-0 + whitespace-nowrap),
which grew unbounded so its inner truncate span never fired and long
addresses overflowed the memo card. Replace with a raw button that
caps width (max-w-full min-w-0) and truncates the address text,
matching the existing LocationDisplayEditor pattern.
2026-06-29 20:27:52 +08:00
johnnyjoygh 5a73d7d3e5 refactor: rebuild the editor on CodeMirror as decorated source
Rebuild the memo editor as a single CodeMirror 6 "decorated source"
editor. The document is the raw markdown, stored verbatim and styled in
place (markers stay visible), so the editor never serializes a tree back
to markdown — removing the round-trip fidelity bug class (inline images,
setext headings, ordered-list indentation, HTML entities) that the old
editor needed per-case patches for.

- MemoEditor/Editor: CodeMirror 6 + lang-markdown (GFM). Tokens, heading
  lines, #tag/@mention, and the autocomplete popover are styled in plain
  CSS (Editor/editor.css) with theme tokens, not a CSS-in-JS theme.
- Tab/Shift-Tab nest/outdent list items (marker-aware, ordered items
  renumbered so nesting is CommonMark-valid); Escape blurs; #tag
  autocomplete sourced from useTagCounts.
- Focus-mode toolbar reimplemented as markdown-text ops; active state read
  from the Lezer tree via the backend-agnostic formatting/commands catalog.
- Remove the old serialize-back-to-markdown editor (its Editor dir,
  PlainEditor, the editor-mode system) and its now-unused dependencies
  (marked, textarea-caret, and the rich-text editor packages).
- Consolidate toolbar components under Toolbar/. Read-only MemoContent
  rendering is unchanged.
2026-06-28 22:59:14 +08:00
Santhosh Thottingal 281e0dc17c fix(tags): include combining marks in tag character class (#6051) 2026-06-28 10:41:41 +08:00
boojack 047175dbed chore(mcp): improve tool discoverability, add orientation tools and evals
Make the OpenAPI-driven MCP surface more usable by agents, following the
mcp-builder guidance.

- Enrich proto descriptions (single source of truth, flows to OpenAPI + MCP
  tool descriptions): document the memo `filter` CEL grammar with fields and
  examples (replacing the dangling "Refer to Shortcut.filter"), clarify the
  created_ts/updated_ts vs create_time/update_time naming, the visibility
  enum, the declarative replace semantics of Set* ops, and steer tag filters
  to `"x" in tags` (not the unsupported `tag == "x"`).
- Mark SetMemoAttachments / SetMemoRelations idempotent via a per-operation
  override the HTTP-method heuristic can't express.
- Curate two read-only orientation tools: shortcut_list_shortcuts (surfaces
  reusable CEL filters) and auth_get_current_user (the single allowed
  auth/identity op, for resolving the current user); guard test updated to
  keep the rest of the auth/user surface excluded.
- Add a task-level evaluation suite (server/router/mcp/evals) with 10
  verified questions, pinned to the deterministic demo seed.
2026-06-27 10:32:42 +08:00
boojack daa71d0456 feat(editor): recognize and style @mentions in the rich editor
Mirror the #tag mark so the TipTap editor styles @username while typing
and on load, matching the read-only view. A shared mention grammar
(utils/mention-grammar.ts) feeds both the editor tokenizer and the
read-only remark renderer so they can't drift.

Styling only -- no autocomplete dropdown (ListUsers is admin-only and
there is no user-search RPC). Bare emails still autolink to mailto: and
are never treated as mentions: the tokenizer's start() skips email-glued
@s so marked's GFM autolinker keeps seeing the whole address.
2026-06-26 09:28:41 +08:00
boojack eb826455b6 chore(webhook): reveal-later signing secret flow
Generate webhook signing secrets server-side and let users reveal them on
demand, replacing the create-dialog secret controls that surfaced internal
mask state (Status / Generate & Copy / Clear / Pending) to users.

- Add owner-gated GetUserWebhookSigningSecret RPC — the only path that
  returns the secret; list/create/update responses still omit it.
- Generate the secret server-side on create (webhook.GenerateSigningSecret),
  so validity no longer depends on the client.
- Rename UserWebhook.has_signing_secret -> signing_secret_set for parity
  with the existing api_key_set field.
- Create dialog drops the secret section to a one-line note; the generated
  secret is shown once right after create and revealable from Edit later.
2026-06-26 09:03:24 +08:00
Woookash c6d65bcf3f chore: i18n polish translations (#6050) 2026-06-26 00:36:37 +08:00
Yiges.M.x. c703b05dab feat: add webhook edit UI and signing secret status indicator (#6027) 2026-06-25 22:26:20 +08:00
boojack 9e84f61029 fix(web): delay loading skeleton to avoid flash on fast loads
Render the memo list skeleton only after loading exceeds a short delay
(SKELETON_LOADING_DELAY_MS). Fast/self-hosted loads now finish before the
threshold and never flash the skeleton, while slow loads still show it.

Close #6047
2026-06-25 22:02:39 +08:00
boojack e3e4ae1051 feat(web): add link preview view setting
Add a persisted "Link preview" toggle (default on) to the memo
display-settings popover, mirroring the compact-mode setting. When turned
off, link-preview cards are hidden across all memo views and the metadata
request is skipped (the gate sits at the paragraph renderer and reads the
preference from ViewContext). Resolves the request in #6045.
2026-06-25 20:45:51 +08:00
greymoth a5c10a7221 i18n(ja): complete Japanese translations — fill 24 missing keys (#6048) 2026-06-25 18:11:23 +08:00
boojack 5f7e038aa7 fix(editor): support backslash-escaping literal #tags
Memos layers #tag syntax on top of CommonMark, but there was no way to
write a literal "#NAS" — it always became a clickable tag (issue #6035).

Handle escapes lexically, the way TipTap/prosemirror-markdown do, with no
"escaped tag" node in the document model:

- Parse: marked's built-in backslash escape already turns "\#NAS" into
  plain text; the read-only renderer (remark-tag) now honors the same
  escape by lexing from the original source slice and falling back to the
  prior value-based parse when it can't faithfully reconstruct the text.
- Serialize: a tag-aware Markdown extension (tagMarkdown.ts) backslash-
  escapes a "#" that would re-parse into a tag, skipping links and code
  where tags never form.
- The editor's #tag lexing moves to the canonical @tiptap/markdown
  markdownTokenizer; tag-in-link skipping moves to a tree pass so the
  editor and renderer agree.

The capped tag-run grammar is consolidated into utils/tag-grammar.ts
(TAG_RUN), shared by the tokenizer, the serialize-escape, and the
renderer so they can't drift. README documents why the markdown manager
is worked around in three places.
2026-06-23 23:00:54 +08:00
boojack 20c19ef82d feat(storage): add insecure_skip_tls_verify option for S3
Adds an opt-in toggle to skip TLS certificate verification when connecting
to the S3 endpoint, for self-hosted S3-compatible backends (e.g. rustfs,
MinIO) that use self-signed certificates. Exposed in both the store/API
protos and the storage settings UI, mirroring the existing use_path_style
toggle. When enabled, the AWS client uses an HTTP transport with
InsecureSkipVerify; default behavior is unchanged.

This governs backend-initiated S3 calls (uploads, deletes, thumbnails, and
image/document streaming). Video/audio playback redirects the browser to a
presigned URL, so that path still requires the browser to trust the cert.

Closes #6039
2026-06-23 00:04:54 +08:00
boojack 1e3ec38fa1 refactor(editor): modularize formatting and simplify editor state
Behavior-preserving restructure of the MemoEditor subsystem for simplicity
and extensibility:

- Add a data-driven command catalog (editorCommands.ts) as the single source
  for formatting verbs; the toolbar, active-state hook, and WYSIWYG handle all
  derive from it, so adding a verb is a one-file change.
- Add a createSuggestionExtension factory; TagSuggestion consumes it, so new
  `/` or `@` triggers reuse the shared popup in ~10 lines.
- Collapse FormattingController into a single EditorController with an optional
  `formatting` capability; PlainEditor is an honest textarea fallback (no faked
  formatting); replace stringly-typed isActive with typed getActiveFormats.
- Move audio-recorder state out of the reducer into useAudioRecorder, keeping a
  single recorderBusy flag in the store.
- Convert the editor context to an external store (useSyncExternalStore) with
  per-slice subscriptions, so typing no longer re-renders the toolbar, insert
  menu, or metadata.
- Extract the shared #tag lexing grammar (tag-grammar.ts) used by both the
  editor tokenizer and the remark renderer.
- Remove dead reducer actions/cases and fix a preview blob-URL leak on the
  upload path.

Add tests for the command catalog, suggestion factory, and autosave.
2026-06-22 23:51:59 +08:00
boojack 26f4b73cb9 feat(filter): standard CEL now variable, time accessors, set ops
Replace the custom now() function with an idiomatic `now` timestamp variable (host-injected, frozen once per compile) and retype created_ts/updated_ts/create_time to CEL timestamp. Filters now use standard timestamp/duration arithmetic, e.g. `created_ts >= now - duration("24h")` and `timestamp("2025-01-01T00:00:00Z")`.

Add standard CEL surface that compiles to SQL across SQLite/MySQL/Postgres: timestamp accessors (getFullYear/getMonth/getDate/getDayOfWeek/..., with 0-based month and weekday normalized), ext.Sets() (sets.contains/intersects/equivalent over tags), tags.exists_one(), size() on string fields, and division/modulo folding. A frozen clock is injectable for deterministic tests.

BREAKING CHANGE: now() is removed (use the `now` variable) and time fields are timestamps, so bare-epoch comparisons need timestamp(<epoch>). Existing saved shortcuts using the old syntax must be updated.
2026-06-22 22:42:44 +08:00
johnnyjoygh cafa56f1a8 chore: make compact mode an opt-in view setting
Memo list views hard-coded compact rendering, truncating long memos by
default. Make full content the default and add a persisted 'Compact mode'
toggle (default off) in the display-settings popover, backed by the
localStorage ViewContext. Wire Home/Explore/Archived/UserProfile to read
it; add the compact-mode label across all locales.
2026-06-21 22:55:56 +08:00
johnnyjoygh 8fa2ff4423 fix(mcp): allow reverse-proxied instances to serve /mcp
The go-sdk Streamable HTTP handler enables DNS-rebinding protection that
rejects any request whose Host header is non-loopback while the server is
bound to a loopback address. memos is commonly run bound to loopback behind a
reverse proxy (e.g. the public demo), so every /mcp request was rejected with
"403 Forbidden: invalid Host header" before authentication ran.

Disable the SDK's localhost protection and rely on memos' own Origin/Host
allowlist (isAllowedMCPOrigin) for CSRF / DNS-rebinding protection. Add a
regression test covering the proxied shape and confirming disallowed origins
are still rejected.
2026-06-21 22:20:00 +08:00
johnnyjoygh 96cb65320b fix(instance): add needs_setup so admin-less instances aren't treated as fresh
The frontend keyed first-run setup off a null InstanceProfile.admin, but a
null admin only means "no admin-role user exists" — which also happens on a
populated instance that has lost all its admins. Such an instance was wrongly
redirected to signup, where the new account is created as a normal user (the
first-user promotion only triggers when there are zero users), leaving the
instance permanently admin-less.

Add an explicit InstanceProfile.needs_setup derived from user count == 0, and
switch the signup redirect and host tip to use it. admin stays for display only.
2026-06-21 22:14:15 +08:00
johnnyjoygh 6eb17864df feat: surface newly created memo above pinned list 2026-06-21 14:11:46 +08:00
johnnyjoygh deddf71d7b feat(editor): add focus-mode formatting toolbar
Add a rich-text formatting toolbar as the focus-mode header when the
WYSIWYG editor is active: heading dropdown, bold/italic/code, lists, and
link, with a priority+overflow responsive layout and live active-state
highlighting. The toolbar is a self-contained component driven through a
new FormattingController surface routed via EditorContent.

Remove the now-redundant slash-command feature (/todo, /code, /link,
/table) and its "Type / for commands" hint, since the toolbar covers
those actions; the shared suggestion renderer stays for #tag.
2026-06-21 11:57:45 +08:00
johnnyjoygh f727ad217c fix(comments): list all memo comments via pagination
Memo detail only showed the first 10 comments: the frontend requested
pageSize=0, which the backend normalizes to DefaultPageSize (10), and the
returned nextPageToken was never followed.

Add useInfiniteMemoComments (mirrors useInfiniteMemos) so the detail page
paginates through every comment, with a "Load more" control in
MemoCommentSection. Page size defaults to DEFAULT_LIST_MEMOS_PAGE_SIZE to
match the memo list convention.
2026-06-21 09:55:37 +08:00
johnnyjoygh f5a60263b2 chore(ui): align frontend on shadcn kit
Design system:
- add semantic --success/--warning OKLCH tokens across themes; replace
  hardcoded green/amber feedback colors and their manual dark: overrides
- drop the unused @emotion dependency

Kit usage:
- migrate raw <button>/<input> to ui-kit components (actions) or
  <div>/<span> (non-action surfaces); keep genuinely custom looks as
  raw HTML with their own styles
- make kit usage prop-only (no className overrides): add Button
  size="icon-sm", Badge "warning" variant + "pill" shape
- extract a shared Tabs primitive (segmented/underline) and migrate
  Inboxes + UserProfile onto it
- tokenize z-index tiers as z-overlay/z-dropdown/z-tooltip
- export variant types (ButtonVariant/Size, BadgeVariant/Shape, TabsVariant)
- document the kit and its policy in components/ui/README.md
2026-06-21 09:17:04 +08:00
boojack 3b07f78fd8 chore: tweak demo data
- Trim sponsor memo to CodeRabbit + SSD Nodes, concise single-tier layout
- Add a demo personal access token (Bearer memos_pat_demo) for the admin user
- Stagger memo created_ts relative to seed time so the demo timeline always
  looks recent; lead the pinned section with the Welcome memo
- Unpin the Scratchpad promo and drop the fixed "June" movie-marathon label
2026-06-19 13:38:02 +08:00
boojack f0e4a5624f feat(filter): expand CEL filter surface with startsWith/endsWith, matches(), and all()
Let users write three more CEL constructs in the filter field, each compiled to
SQL across SQLite/MySQL/Postgres:

- Scalar startsWith()/endsWith() on content/filename/mime_type (case-insensitive)
- matches() regex: PG ~, MySQL/SQLite REGEXP (Go-backed SQLite fn), validated at
  compile time via cel.ValidateRegexLiterals()
- all() comprehension over tags via per-element subqueries, non-empty required

Also: contains() now escapes LIKE metacharacters (%, _, \); cross-dialect render
tests plus behavioral tests; cel-go bumped to v0.28.1; new operators surfaced in
the frontend shortcut guide.
2026-06-15 23:22:28 +08:00
boojack 817561df8f fix(editor): collapse lingering select-all selection after delete
Ctrl+A creates a whole-document AllSelection; deleting it (Backspace, Delete, or Cut) maps the AllSelection onto the now-empty paragraph instead of collapsing to a caret, so the view paints a "selected" empty block. A ProseMirror appendTransaction now collapses a leftover AllSelection to a caret after any doc-changing edit.
2026-06-15 09:33:16 +08:00
ManJieqi 46685b1906 chore: update Chinese translations in zh-Hans.json (#6033)
Signed-off-by: ManJieqi <40858189+manjieqi@users.noreply.github.com>
2026-06-14 23:23:18 +08:00
boojack 385fa22056 fix(cors): open API to any origin for token auth, keep cookies same-origin
Reflect any Origin so token-authenticated clients (Access Token V2 / PAT)
can call the API cross-origin, but emit Access-Control-Allow-Credentials
only for trusted origins (same host / configured InstanceURL). This keeps
the SameSite=Lax refresh cookie unreadable by untrusted (incl. same-site
subdomain) origins. Origin: null is not reflected.

Note for operators: cross-origin token access is now open by default; if
you front memos with a caching proxy, ensure it honors `Vary: Origin`.
2026-06-14 23:20:34 +08:00
boojack 00225db922 refactor(web): share markdown element styles between viewer and editor
Extract the Tailwind classes for common markdown elements (paragraph,
blockquote, lists, inline code, link, hr, headings) into a single
markdownStyles.ts consumed by both the read-only MemoContent components
and the WYSIWYG editor, replacing the duplicated per-element strings and
the .memo-wysiwyg CSS block. Heading classes are precomputed per level so
the hot renderHTML path is a lookup, not a cn() merge.

Also require at least one character after `#` before opening the tag
suggestion menu so a bare `#` (or `# ` heading) no longer conflicts with
markdown headings.
2026-06-13 22:17:00 +08:00
boojack 2c0efeba7c refactor(web): rename editor dirs and simplify raw mode to a plain textarea 2026-06-13 01:08:38 +08:00
boojack 797f1ff15d feat(web): markdown WYSIWYG editor with raw-mode toggle (#6030)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:51 +08:00
boojack 8080bd10e1 docs: add README for mcp 2026-06-09 23:54:58 +08:00
boojack f497f009ce fix(webhook): fail loud on malformed signing secret and add tests
Follow-up to #6013. The signing path silently fell back to using the raw
secret string as the HMAC key when a whsec_-prefixed secret had invalid
base64, producing signatures no receiver could verify with no server-side
signal.

- Extract resolveSigningKey helper that errors on invalid whsec_ base64
- Post returns that error (logged by the async dispatcher); ValidateSigningSecret
  rejects it at write time so a bad secret is never stored
- Fix stale comment referencing a nonexistent Authorization header
- Add Go tests: key derivation, secret validation, end-to-end signature
  round-trip, and the invariant that the secret never leaks into API responses
2026-06-09 22:58:10 +08:00
Yiges.M.x. 063a44498d feat: add optional webhook signing secret (Standard Webhooks HMAC-SHA256) (#6013) 2026-06-09 22:45:01 +08:00
boojack 1052c04d33 fix(web): improve mobile control spacing 2026-06-09 21:04:47 +08:00
boojack 418398587c feat(i18n): add searchable locale picker 2026-06-09 09:33:17 +08:00
boojack 777d227eb9 feat: add OpenAPI-driven MCP support (#6026) 2026-06-09 09:16:50 +08:00
boojack a47d04954e feat(i18n): expand European locale coverage 2026-06-09 09:07:28 +08:00
boojack 6870e863de chore(github): improve issue templates with structured triage fields
Add area dropdowns, reproduction requirements, regression info, and
compatibility sections to reduce back-and-forth on bug reports and
feature requests.
2026-06-08 22:21:33 +08:00
boojack ecbe2ab797 fix(memo): preserve expanded todo list state 2026-06-08 19:11:29 +08:00
ay5399 8f1377324f fix(editor): wire Ctrl+B and Ctrl+I markdown shortcuts to textarea (#6016)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2026-06-08 00:24:05 +08:00
boojack 9eabb554d5 feat(settings): move tag metadata to user settings (#6017) 2026-06-07 23:58:00 +08:00
boojack a50ce09e81 fix(markdown): ignore tags inside links 2026-06-06 00:01:28 +08:00
boojack 5e71c0a737 docs: rewrite agent repository guide 2026-06-05 09:17:22 +08:00
boojack 2a4638b332 chore: remove MCP server 2026-06-05 08:38:57 +08:00
boojack 5f194da7d3 chore(main): release 0.29.1 (#5985) 2026-06-05 08:06:30 +08:00
boojack bb76949fc0 chore(server): centralize CORS policy 2026-06-04 22:37:41 +08:00
boojack d69f1aab27 chore: tweak demo data 2026-06-03 00:21:45 +08:00
boojack 0e2a9a9c0c fix(web): render video attachment posters on mobile 2026-06-02 23:08:16 +08:00
goingforstudying-ctrl e8d32e87d1 fix: support <meta name=description> in link previews (#6000)
Co-authored-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
2026-06-02 23:02:21 +08:00
boojack 0d31e3c2fb chore(media): add zoom controls to preview dialog 2026-06-02 22:46:07 +08:00
boojack fadc974364 docs: consolidate agent guidance 2026-05-31 18:53:37 +08:00
boojack 53abb8020e refactor(frontend): remove react-use dependency 2026-05-31 18:32:17 +08:00
boojack 7c3bff4e98 fix(markdown): keep task item content in one grid column 2026-05-27 23:57:23 +08:00
boojack f22c4bd5c2 chore(main): release 0.29.0 (#5909) 2026-05-27 20:48:29 +08:00
boojack e0bb3a2e68 fix(editor): wrap selected text when pasting URL 2026-05-27 20:36:27 +08:00
boojack 648b3bd812 feat(memo): add task list quick actions (#5983) 2026-05-27 09:13:55 +08:00
boojack e564c1a993 chore: update about page 2026-05-26 21:14:59 +08:00
boojack e53b7d96e7 fix: delete user cleanup (#5981) 2026-05-25 22:10:29 +08:00
boojack d1208a68e9 chore: update sponsors 2026-05-25 20:39:25 +09:00
boojack 3c3382a3c6 fix: avoid update event on memo create attachments (#5961) 2026-05-16 21:18:44 +08:00
boojack 858f7419b4 chore(seed): tweak seed data 2026-05-15 09:04:28 +08:00
boojack 63d6431d3e chore: update images in README for dark mode
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-05-15 08:33:19 +08:00
Santosh Yadav ddcaf5f25c chore: fix coderabbit logo for light and dark theme (#5954) 2026-05-15 07:10:27 +08:00
boojack ab2b08a21e chore(seed): refresh demo memos and sponsor showcase 2026-05-15 00:23:11 +08:00
boojack 727480d49d chore: revamp sponsors section in README
Updated the featured sponsors section to include a table layout and added new sponsor information.

Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-05-14 23:56:57 +08:00
boojack 439ddaebf5 refactor(placeholder): extract tile sprite strip 2026-05-14 20:38:00 +08:00
boojack 411ba7b34c feat(about): add about page with bird sprites 2026-05-14 20:30:37 +08:00
boojack 638e4f398e feat(placeholder): add woodpecker tilemap 2026-05-14 09:35:11 +08:00
boojack cf55f11072 feat(frontend): add pixel bird tilemaps 2026-05-14 00:03:21 +08:00
boojack ca2bc4eb84 chore: render placeholder ascii pieces as components 2026-05-13 09:06:23 +08:00
boojack aa5cb455e9 refactor(web): use / as the home route instead of /home
Home now lives at `/` directly. Unauthenticated visitors are sent to
`/explore` by the landing gate; old `/home` URLs redirect to `/` for
bookmark compatibility.
2026-05-12 23:17:46 +08:00
boojack 8c16ffa1f1 feat: add <Placeholder> component with ASCII bird states (#5949)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 22:48:37 +08:00
Mayank Saini f1e2a06b46 feat: add configurable --log-level flag (#5934)
Signed-off-by: boojack <stevenlgtm@gmail.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2026-05-12 21:10:05 +08:00
boojack a6024eebf1 perf: lazy load heavy first-screen dependencies (#5947) 2026-05-12 20:11:39 +08:00
boojack 7f1f53ffc4 fix(s3presign): preserve motion media payload 2026-05-09 22:51:56 +08:00
boojack 511c04bca2 chore: fix linter 2026-05-09 19:08:21 +08:00
boojack f3f059b2f7 chore: add batch get settings API 2026-05-09 09:30:28 +08:00
boojack 21303e879d fix(sse): stream initial response and refresh tokens 2026-05-09 09:15:50 +08:00
boojack 88ac3ec31e feat(stats): support filtered all-user stats
- Add state and filter inputs to ListAllUserStats and reuse it for explore/archive sidebar stats.
- Reduce duplicate home initialization requests by sharing stats/settings data paths.
- Include memo paragraph regression coverage from the current working tree.
2026-05-09 09:11:04 +08:00
boojack c49e75f91f chore: avoid copying memo protobuf locks 2026-05-09 08:32:00 +08:00
boojack 078488ca81 fix(httpgetter): prevent DNS rebinding in link metadata fetch 2026-05-08 23:24:16 +08:00
boojack 4a1e401bd9 fix(memo): enforce parent visibility for comments 2026-05-08 23:22:56 +08:00
boojack 1df6479443 feat: add dedicated shortcuts page (#5942) 2026-05-08 23:06:12 +08:00
boojack 02096836c3 test: stabilize backend tests in CI
- Avoid requiring built frontend assets in cache header tests.
- Skip Testcontainers-backed store tests when Docker is unavailable.
2026-05-08 22:24:54 +08:00
boojack b6f42cbe6b chore: upgrade frontend to React 19 (#5940) 2026-05-08 22:10:48 +08:00
boojack 084f40bc9e fix(frontend): correct static cache headers 2026-05-08 21:25:05 +08:00
boojack 4f0556e23f chore: update sponsors
Added sponsorship information for InstaPods.

Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-05-08 19:48:30 +08:00
hchengting bcbcb03894 chore: update zh-Hant translation (#5930) 2026-05-04 20:18:00 +08:00
boojack 5ccba98adc refactor: split STT and Audio-LLM into separate interfaces (#5928) 2026-05-03 00:21:58 +08:00
boojack 238f27dea1 feat(transcription): explicit STT settings with provider, model, prompt (#5926) 2026-05-02 19:35:18 +08:00
boojack ef55013418 feat(memo): create memos on the selected calendar date (#5925) 2026-05-02 11:53:50 +08:00
Steven d349fe4409 chore(theme): rebalance dark palette for readability 2026-05-02 09:11:46 +08:00
Steven 8daef1dc89 feat(activity-calendar): aggregate by ViewContext.timeBasis
Fixes the inconsistency where switching the memo list to update_time
left the activity heatmap aggregating by created_time. The heatmap
now follows the same time basis as the list it sits next to.

Backend
- UserStats gains memo_updated_timestamps (additive proto field, tag 8).
- GetUserStats and ListAllUserStats populate it alongside the existing
  memo_created_timestamps. No DB migration; memo.updated_ts already
  exists on every row.

Frontend
- useFilteredMemoStats reads timeBasis from ViewContext and selects
  the matching timestamp source.
- StatisticsView and MonthNavigator forward timeBasis through to
  MonthCalendar / YearCalendar so tooltip text matches the basis
  ("X memos in DATE" vs "X memos updated on DATE").
- Falls back to memoCreatedTimestamps when an old server returns an
  empty memoUpdatedTimestamps array (detected by length divergence,
  since protobuf-es deserializes missing repeated fields as []).

Tests
- Backend: TestGetUserStats_MemoUpdatedTimestamps verifies the field
  is populated and reflects post-creation updates.
- Frontend: filtered-memo-stats covers create/update source switching
  and the old-server fallback path; activity-calendar-tooltip covers
  basis-aware label selection.

Spec and implementation plan committed under docs/superpowers/.
2026-05-02 00:26:53 +08:00
Steven ea0625da45 feat(stats): admin instance resource statistics 2026-05-01 23:15:56 +08:00
Steven cd4f28ae10 feat(notification): add smtp email settings
- Add admin notification email settings UI and test-email RPC
- Dispatch privacy-first comment and mention emails through server notification layer
- Keep SMTP secrets write-only and require passwords when SMTP identity changes
2026-05-01 18:48:21 +08:00
Steven 35bf761b8c fix(security): enforce attachment ownership on memo updates 2026-05-01 18:23:53 +08:00
Tokenicrat 词元 603781f792 fix(frontend): use correct url path for memos in sitemap.xml (#5921) 2026-05-01 16:08:31 +08:00
Steven a0b6417689 chore: bump pnpm to 11.0.1 and align list test assertions 2026-04-30 20:05:39 +08:00
boojack e008b1a23c fix(markdown): align list items with checkboxes 2026-04-30 08:49:49 +08:00
boojack 1df3fe7955 fix(user): omit internal settings from list responses (#5917) 2026-04-30 08:49:03 +08:00
boojack 0bc56694b0 feat: render link metadata cards 2026-04-29 23:23:52 +08:00
boojack 9c5c604944 feat: add link metadata endpoints 2026-04-29 22:38:08 +08:00
memoclaw c7242324a1 fix(fileserver): preserve HDR image metadata in thumbnails 2026-04-29 21:32:10 +08:00
memoclaw 267f90a3ff chore: refactor Settings UI structure (#5912)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-04-29 09:30:58 +08:00
boojack 14480bfc46 chore(settings): improve storage tags and memo panels 2026-04-28 23:28:01 +08:00
boojack ee65e90a39 chore: upgrade pnpm to 11 (#5911) 2026-04-28 22:16:22 +08:00
Chyna 328396a97f fix(web): sync avatar changes immediately after profile update (#5903)
Signed-off-by: Chyna <68607983+chyna-gvng@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-28 21:57:58 +08:00
Mostafa Attia 9353848603 chore: increased muted text contrast in dark theme (#5902) 2026-04-28 20:03:24 +08:00
boojack 616487fa37 chore: update backend and frontend dependencies (#5900)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-04-28 08:25:26 +08:00
boojack 18e729fe00 chore(memo): simplify markdown task list rendering 2026-04-27 23:22:29 +08:00
boojack 9e310bf93c chore(memo): clarify displayed update time 2026-04-27 21:50:41 +08:00
boojack 9bf648ac91 chore(main): release 0.28.0 (#5866) 2026-04-27 08:56:00 +08:00
boojack 94ce1e5347 chore(settings): show build commit in version info 2026-04-27 08:51:11 +08:00
boojack e2c60845ea fix(markdown): split mixed task and bullet lists 2026-04-26 23:25:29 +08:00
boojack c268551a16 feat(memos): choose created or updated time for memos (#5894) 2026-04-26 11:50:26 +08:00
boojack 3949a252db style(web): refine scrollbar styling 2026-04-26 00:24:45 +08:00
boojack 34c90dd5e1 chore: remove duplicate tags from share image preview 2026-04-25 22:22:49 +08:00
boojack 0fb83a745d fix(auth): harden authorization and username validation (#5890) 2026-04-25 21:24:16 +08:00
boojack ee1799851e feat: redesign account and SSO management (#5886) 2026-04-24 09:08:58 +08:00
boojack 30c0611a82 fix: fix legacy username auth flows (#5885) 2026-04-23 22:35:38 +08:00
boojack d688914b28 feat(auth): add SSO user identity linkage (#5883) 2026-04-23 08:51:45 +08:00
boojack 50638040f6 fix: reduce list memo query overhead (#5880) 2026-04-22 09:31:48 +08:00
boojack ebc0e10f07 docs: refine SSO identity linkage design (#5879) 2026-04-22 09:09:15 +08:00
boojack b5863d76be fix(web): preserve task checkbox state (#5867) 2026-04-20 23:27:00 +08:00
wndnjs0 d98f665919 fix: disable modal prop on DropdownMenu to prevent scroll disappearing (#5861) 2026-04-20 21:38:28 +08:00
boojack 765a8c6e28 chore(main): release 0.27.1 (#5855) 2026-04-19 13:17:09 +08:00
George Wu bbded584ce fix: user resource names can be uuidv4 from idp sub claim (#5856) 2026-04-19 13:05:08 +08:00
boojack f8a304bae3 fix(release): inject build version into artifacts 2026-04-19 12:02:32 +08:00
boojack cfe2cf4cf5 chore(main): release 0.27.1 (#5854) 2026-04-19 10:55:16 +08:00
boojack 01be01f4b7 fix: mixed-case user resource names (#5853) 2026-04-19 10:44:25 +08:00
boojack 1fd6a2a385 chore(main): release 0.27.0 (#5843)
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-04-19 04:02:50 +08:00
boojack 583c3d24f4 feat(mcp): harden tool exposure and side effects (#5850) 2026-04-18 22:28:35 +08:00
wndnjs0 0fc1dab28b fix: add unix socket file permission setting (755 -> 660) (#5849) 2026-04-18 13:21:03 +08:00
boojack 88cb58ab64 refactor(web/routing): guard-based auth flow, migrate tests to Vitest (#5848) 2026-04-18 01:06:54 +08:00
boojack 587f5b1b6c chore(github): simplify issue templates 2026-04-17 23:54:17 +08:00
boojack ff6389a5ef fix(api): appease image size lint 2026-04-16 23:21:07 +08:00
boojack c45663761d fix(api): reduce memory pressure in backend paths 2026-04-16 23:08:48 +08:00
boojack 8479e1d5a3 test: close SSE response body explicitly 2026-04-16 22:57:31 +08:00
boojack a5ddd5adaf fix(server): close SSE clients during shutdown
Close long-lived SSE streams before HTTP shutdown so graceful shutdown is not held until the deadline. Also wait for background runners before closing the store to make shutdown ordering explicit.
2026-04-16 22:49:10 +08:00
boojack a7fd1dacc9 refactor(ai): use official provider SDKs (#5845) 2026-04-16 22:27:57 +08:00
boojack f394e9469a chore: add release-please automation (#5842) 2026-04-15 23:04:10 +08:00
Mattia Borda 5be530dbac chore: update Italian translation for 'cancel' key (#5841)
Signed-off-by: Mattia Borda <mattiagiovanni.borda@icloud.com>
2026-04-15 21:01:57 +08:00
boojack 411d8335e7 chore: set native color scheme for dark themes (#5840) 2026-04-15 20:55:41 +08:00
boojack a3a5b52d60 chore: tweak location picker 2026-04-15 20:07:36 +08:00
boojack ab5332901f fix: hide transcribe button without AI provider 2026-04-14 21:47:59 +08:00
boojack 35504cc8bd feat(web): add demo mode banner (#5836) 2026-04-14 09:08:17 +08:00
boojack 101704c8ea feat(ai): add BYOK audio transcription (#5832) 2026-04-13 22:09:24 +08:00
boojack 0ad0fec8d4 feat(ai): add Anthropic provider option 2026-04-12 21:42:17 +08:00
memoclaw d87539a1e1 feat: add Gemini transcription provider (#5830)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-04-12 21:12:03 +08:00
memoclaw 83ed32f119 feat(ai): add instance AI providers and transcription (#5829)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-04-12 19:23:34 +08:00
boojack 40fd700fb8 fix(fileserver): render SVG attachment previews 2026-04-12 10:41:38 +08:00
boojack efeb28c872 fix: remove duplicate Japanese locale keys 2026-04-12 10:34:20 +08:00
Takuro Onoue 0f7c7eb75e i18n: refine and normalize Japanese locale strings (#5826)
Signed-off-by: Takuro Onoue <kusanaginoturugi@gmail.com>
2026-04-11 22:15:26 +08:00
boojack 2523d966f4 chore(web): improve navigation accessibility 2026-04-10 22:27:29 +08:00
boojack fee7fcd660 fix(frontend): restore sitemap and robots routes 2026-04-10 22:23:00 +08:00
boojack 8cdcd7b284 refactor(attachments): extract visual gallery layout and tile style tokens 2026-04-10 08:55:20 +08:00
boojack 9ca71229a6 fix: preserve draft content when tab is suspended or editor remounts
Gate auto-save on initialization to prevent empty content from overwriting
cached drafts on remount. Flush drafts synchronously on visibilitychange/
pagehide so Chromium tab suspension cannot drop pending debounced saves.
Restore cached draft over saved memo content when they differ (inline editing).
2026-04-10 08:48:50 +08:00
boojack 938c405b0f refactor: move embedded content styles from global CSS to Tailwind 2026-04-08 23:34:49 +08:00
boojack 124708f164 chore: refactor attachment media layout and insert menu organization 2026-04-08 23:28:49 +08:00
boojack 7e21b728b3 fix: harden memo content iframe and HTML sanitization 2026-04-08 21:10:35 +08:00
boojack 2d682ae176 refactor(attachments): compact audio item layout and playback logic
Made-with: Cursor
2026-04-07 21:47:06 +08:00
boojack 5b78023fd5 Polish share-as-image UI and sidebar sharing actions
Made-with: Cursor
2026-04-07 21:07:40 +08:00
boojack e51985a29f feat(memo-editor): add compact live waveform recorder panel (#5817) 2026-04-07 20:40:34 +08:00
boojack 61c963841b chore(web): polish dark theme and calendar UI
- simplify ActivityCalendar state handling and shared max-count utilities
- remove calendar ring styling and darken the default dark theme primary colors
- tighten the audio recorder panel layout and action sizing
2026-04-06 22:44:11 +08:00
memoclaw 24fc8ab8ca feat(mentions): add memo mention parsing, notifications, and rendering (#5811)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-04-06 22:16:53 +08:00
boojack 38fc22b754 feat(memo): add image sharing in detail view
Keep the unpublished image-sharing flow scoped to memo detail pages.

- add a dedicated share-image preview and export pipeline
- measure the rendered memo card so preview and exported image stay aligned
- move the entry point into the detail sidebar and drawer only
2026-04-06 18:30:01 +08:00
boojack 2cbc70762b refactor(attachments): simplify the attachment library
- split attachment page states and primitives into focused components
- unify card and list item presentation across media, audio, documents, and unused uploads
- move attachment paging and cleanup flows onto shared query and view-model hooks
2026-04-06 17:39:58 +08:00
boojack 7ac9989d43 fix(map): align dark mode map styling 2026-04-06 17:38:56 +08:00
boojack 15d8ec10d5 chore: update locale translations for new i18n entries 2026-04-06 16:11:21 +08:00
boojack 067d7ff0ce chore: refactor memo editor audio recording flow 2026-04-06 15:46:38 +08:00
boojack c3e7e2c316 fix: normalize attachment MIME types before validation 2026-04-06 14:05:24 +08:00
boojack aafcc21ae6 fix: improve image preview dialog and live photo trigger 2026-04-06 13:41:27 +08:00
boojack 6b0487dcd8 fix: unify live photo previews around LIVE badge playback 2026-04-06 11:56:58 +08:00
boojack 065e817470 fix: backend tests action 2026-04-06 11:19:31 +08:00
boojack 10a955fd62 refactor: move plugin packages under internal 2026-04-06 11:10:32 +08:00
boojack 4b4e719470 feat(attachments): add Live Photo and Motion Photo support (#5810) 2026-04-06 10:47:01 +08:00
boojack 894b3eb045 fix(map): refine Leaflet controls and memo map styling 2026-04-06 08:45:54 +08:00
boojack 25feef3aad fix(api): tolerate missing related users in memo conversions (#5809) 2026-04-06 08:23:18 +08:00
boojack 87d411bc70 docs(agents): align contributor guidance with current CI workflows (#5808) 2026-04-06 08:02:21 +08:00
memoclaw f403f8c03c refactor: simplify memo metadata components 2026-04-02 22:30:21 +08:00
memoclaw 0e4d2d25ca refactor: simplify audio attachment playback component 2026-04-02 21:56:01 +08:00
Serhat 9676e72533 i18n: complete Turkish (tr) translations to 100% coverage (#5802)
Signed-off-by: Serhat <49079271+onwp@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-01 19:25:18 +08:00
memoclaw c0d5854f67 feat(editor): add voice note recording to the memo composer (#5801)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-04-01 09:36:33 +08:00
memoclaw a0d83e1a9e fix(web): refine attachment media layout 2026-04-01 08:40:36 +08:00
memoclaw cdbe40a390 chore: update security.md 2026-04-01 08:39:49 +08:00
memoclaw 63a17d897d refactor: split audio attachment view into reusable components 2026-03-31 23:03:38 +08:00
memoclaw 1921b57662 fix(tags): allow blur-only tag metadata (#5800)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-31 21:38:55 +08:00
boojack 201c8a8ea9 chore: add rc release handling 2026-03-31 08:34:36 +08:00
boojack 0e89407ee9 fix(filter): enforce CEL syntax semantics
Reject non-standard truthy numeric expressions in filters and document the parser as a supported subset of standard CEL syntax.

- remove legacy filter rewrites
- support standard equality in tag exists predicates
- add regression coverage for accepted and rejected expressions
2026-03-31 08:10:49 +08:00
boojack d3f6e8ee31 chore: harden MCP access control and origin validation 2026-03-31 00:12:28 +08:00
boojack 7c708ee27e chore: add migration upgrade coverage (#5796) 2026-03-30 23:51:57 +08:00
memoclaw e520b637fd fix: prevent stale comment drafts from being restored 2026-03-30 22:37:07 +08:00
memoclaw acbc914dea fix(webhooks): trigger memo updates for attachment and relation changes (#5795)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-30 20:01:16 +08:00
memoclaw 9610ed8fc8 fix(lint): correct goimports struct literal alignment after removing write-only credential fields (#5794)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-29 20:31:09 +08:00
memoclaw 9d3a74bccc fix(api): make credentials write-only and restrict sensitive settings to admins
Security fixes for credential leakage across three resources:

- NOTIFICATION setting: restrict GetInstanceSetting to admin-only
  (was publicly accessible, exposing SMTP credentials)
- SMTP password: never return SmtpPassword in API responses (write-only)
- S3 secret: never return AccessKeySecret in API responses (write-only)
- OAuth2 ClientSecret: never return in API responses for any role
  (was previously returned to admins); remove redactIdentityProviderResponse
  in favor of omitting the field at the conversion layer
- Preserve-on-empty: when updating settings with an empty credential
  field, preserve the existing stored value instead of overwriting
  (applies to SmtpPassword, AccessKeySecret, and ClientSecret)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 07:34:00 +08:00
memoclaw c53677fcba fix(api): improve SSE hub design and fix double-broadcast on comments
- Fix duplicate SSE event on comment creation: CreateMemoComment now
  suppresses the redundant memo.created broadcast from the inner
  CreateMemo call, emitting only memo.comment.created
- Extract reaction event-building IIFEs into buildMemoReactionSSEEvent
  helper, removing duplicated inline DB-fetch logic
- Promote resolveSSEAudienceCreatorID from method to free function
  (resolveSSECreatorID) since it never used the receiver
- Add userID to SSE connect/disconnect log lines for traceability
- Change canReceive default from permissive (return true) to
  deny-with-warning for unknown visibility types
- Add comprehensive tests covering all new helpers, visibility edge
  cases, slow-client drop behavior, and the double-broadcast fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 07:33:40 +08:00
boojack d720efb6e6 chore: tweak featured sponsors
Removed the old sponsors table and replaced it with individual sponsor links and descriptions.

Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-03-29 02:17:29 +08:00
memoclaw 4add9b04ad fix: prevent local attachment uploads from overwriting files 2026-03-26 21:46:51 +08:00
memoclaw a24d420922 fix(api): restrict user email exposure to self and admins (#5784)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-25 22:02:08 +08:00
memoclaw acddef1f3d fix(api): switch user resource names to usernames (#5779)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-25 09:11:17 +08:00
boojack 2327f4e3a6 chore: revamp featured sponsors in README
Updated featured sponsors section with new layout and content.

Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-03-25 00:02:26 +08:00
boojack 60a7d49874 chore: add InstaPods promotion to README
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-03-24 23:46:37 +08:00
memoclaw 4818bf3559 refactor(store): remove synthetic system bot user lookup (#5778)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-24 22:16:30 +08:00
memoclaw 4f6730a12e chore: show compact attachment count instead of thumbnails in comment previews
When truncate mode is active (comment list), display an inline file icon
with attachment count instead of rendering full image thumbnails.
2026-03-24 19:30:39 +08:00
memoclaw 6a03917f6e chore: upgrade TypeScript to 6.0.2 2026-03-24 08:45:56 +08:00
memoclaw bb7f4978e5 refactor(web): consolidate SharedMemo into MemoDetail (#5773)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-24 08:40:49 +08:00
memoclaw 6b30579903 feat: add outline navigation to memo detail sidebar (#5771)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-24 00:20:40 +08:00
memoclaw 2aaca27bd5 refactor(web): improve MemoDetail and sidebar maintainability (#5769)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-23 22:51:24 +08:00
memoclaw e176b28c80 feat(memo-preview): support comment metadata in previews (#5768)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-23 21:51:30 +08:00
memoclaw 22519b57a0 fix(web): prevent MemoContent prop leaks 2026-03-23 20:34:14 +08:00
memoclaw 45b21530d9 feat: add blur_content attribute to tag metadata settings (#5767)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-23 19:42:27 +08:00
memoclaw 9e04049632 feat: treat tag setting keys as anchored regex patterns (#5759)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-22 08:07:45 +08:00
memoclaw 9ded59a1aa refactor(web): improve Settings page maintainability and consistency (#5757)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-21 15:05:48 +08:00
memoclaw d5de325ff4 refactor(web): simplify main layout sidebar structure (#5756)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-21 12:54:18 +08:00
memoclaw 4177898054 refactor(web): consolidate memo metadata components into MemoMetadata (#5755)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-21 09:04:15 +08:00
memoclaw ac077ac3d3 refactor(web): improve MemoView and MemoEditor maintainability (#5754)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-21 07:20:18 +08:00
memoclaw 7b4f3a9fa5 refactor(memo): simplify MemoDetail error handling, drop memo.failed-to-load i18n key
The failed-to-load key was only used for non-ConnectError exceptions, which
are unreachable in practice since the Connect RPC client always wraps errors
as ConnectError. Use (error as Error).message as a plain fallback instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 20:19:44 +08:00
memoclaw 5f6f624635 i18n: add missing auth.protected-memo-notice and memo.failed-to-load fallbacks to all locales
30 locale files were missing the two keys added in the auth redirect PR.
Added English fallback strings so all locales render properly until
community translations are contributed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 19:41:12 +08:00
xun zhao 7601708ae4 Redirect unauthenticated protected memo access to sign in (#5738) 2026-03-20 19:40:19 +08:00
fiatcode 551ee1d81f fix(auth): recover session via refresh cookie when localStorage is empty (#5748) 2026-03-20 19:21:11 +08:00
memoclaw be00abe852 fix: sync html lang attribute with active locale (#5753)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 19:17:30 +08:00
google-labs-jules[bot] ff32869d2f chore: improve access to default fields in cron parser (#5750)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 18:50:07 +08:00
google-labs-jules[bot] e0cc247823 chore: optimize multi-user RSS feed generation by fixing N+1 query (#5749)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-20 18:09:24 +08:00
google-labs-jules[bot] 23a7e99a21 chore: optimize unreact network requests (#5747)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2026-03-20 18:08:28 +08:00
memoclaw a7cabb7ce6 refactor(web): remove masonry memo layout (#5746)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-20 09:01:43 +08:00
memoclaw 05810e7882 fix(memo-editor): scope Cmd+Enter save to the active editor (#5745)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-20 08:43:01 +08:00
memoclaw e0334cf0a8 refactor: restructure i18n locale keys for better maintainability (#5744)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 05:56:56 +08:00
memoclaw 6f1f3d81c5 chore: remove disallow_public_visibility setting (#5743)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 00:00:51 +08:00
memoclaw 3f3133d6e2 feat(memo): add share links for private memos (#5742)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-19 23:47:22 +08:00
memoclaw e164517773 test: remove raw SQL migration backfill test setup 2026-03-19 19:54:39 +08:00
memoclaw f759b416af refactor(inbox): store memo comment payloads without activity records (#5741)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-19 19:33:25 +08:00
memoclaw a249d06e2e feat(instance): add notification transport setting (#5737)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
2026-03-19 09:18:28 +08:00
boojack 65d14fbb63 feat(instance): add canonical tag metadata setting (#5736) 2026-03-19 08:54:43 +08:00
boojack 330291d4d9 feat(user): add per-user tag metadata settings (#5735) 2026-03-18 23:15:14 +08:00
boojack 04f239a2fc fix(api): remove public activity service (#5734) 2026-03-18 22:42:57 +08:00
Nix 89c6902895 feat(i18n): update sse connect label in Chinese (#5732) 2026-03-18 18:53:09 +08:00
Johnny 0ba4c0f397 feat(cli): add version subcommand (#5731) 2026-03-18 08:41:34 +08:00
johnnyjoygh f90d9a49a7 fix: improve installer compatibility and docs 2026-03-17 20:46:18 +08:00
johnnyjoygh 1ed542c21d feat: add GitHub release installer and release workflow 2026-03-17 20:16:16 +08:00
memoclaw 12e2205cb6 chore(backend): update Go toolchain and dependencies (#5730) 2026-03-16 21:07:52 +08:00
memoclaw 6f5f0d94e8 chore(web): update frontend dependencies and Node runtime (#5729) 2026-03-16 20:37:16 +08:00
memoclaw 1e688b2a5d feat: extract title from first H1 heading into memo property (#5726)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:05:03 +08:00
memoclaw b8e9ee2b26 feat(mcp): enhance MCP server with full capabilities and new tools (#5720)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:15:52 +08:00
boojack d0b0652a7c chore: tweak README (#5717)
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-03-12 23:16:59 +08:00
memoclaw f4154d090b fix: restrict archived memo access to creator only (#5707)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:15:08 +08:00
memoclaw 8f43e8075b fix: correct typos in comments, error messages, and identifiers (#5704)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:40:17 +08:00
memoclaw bdd3554b89 fix: handle chunk load errors after redeployment with auto-reload (#5703)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:45:16 +08:00
memoclaw e5b9392fcd chore: use issue types instead of labels for bug/feature templates
Replace `labels: [bug]` and `labels: [enhancement]` with `type: Bug`
and `type: Feature` to leverage GitHub's native issue types already
configured on this repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:49:27 +08:00
memoclaw 1bfd121d7d chore: improve GitHub issue templates (#5700)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:25:10 +08:00
memoclaw 2ccb98a6cb fix: render audio attachments as inline players (#5699)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:57:48 +08:00
memoclaw 4503679155 enhance: improve link memo dialog with rich previews (#5697)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:54:13 +08:00
memoclaw e70149af5f enhance: polish PWA manifest and meta tags (#5695)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:02:13 +08:00
memoclaw cd5816c428 feat: add --allow-private-webhooks flag to bypass SSRF protection (#5694)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:46:03 +08:00
memoclaw 851e090ff9 chore: align branding with brand guidelines (#5693)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:32:36 +08:00
memoclaw 1e82714a52 perf: batch load memo relations when listing memos (#5692)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:19:19 +08:00
memoclaw 3d4f793f97 fix: include plain URLs and tags in memo snippet generation (#5688)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:03:45 +08:00
memoclaw 92d937b1aa feat: replace auto-increment ID with UID for identity provider resource names (#5687)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:01:22 +08:00
memoclaw f0c4489468 chore: move storage setting migration to v0.27.0 (#5686)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:44:36 +08:00
Peter Etelej 3010f10eaf fix: access token refresh on web app (#5681) 2026-03-05 19:29:21 +08:00
memoclaw 334dfef714 docs: streamline AGENTS.md and simplify CLAUDE.md (#5685)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:28:09 +08:00
boojack 5a17614a6e chore: tweak CODEOWNERS
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-03-05 19:14:16 +08:00
memoclaw b44d9c4629 docs: add CLAUDE.md for Claude Code quick reference (#5683)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:42:48 +08:00
longhe.li 0729779e04 fix: toggle focus mode do not reset editor height (#5504)
Signed-off-by: longhe.li <lilonghe@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-04 22:13:58 +08:00
thefatcode 2c3f9e3bfb fix: clear content search filter when selecting shortcut (#5499) 2026-03-04 22:12:41 +08:00
Ashour Badine 3ea6ea3108 fix: tag parsing truncates emojis with variation selectors (#5496) 2026-03-04 22:10:40 +08:00
Steven ce441644af fix: ensure comment divs span full width in MemoDetail 2026-03-04 19:13:58 +08:00
Biplav Barua def123232a feat: implement ListActivities pagination (#5468) 2026-03-04 19:08:47 +08:00
Steven c36934f348 chore: add featured sponsor section for Warp 2026-03-04 08:51:29 +08:00
Steven 0cf8805184 refactor(sse): move status indicator to avatar badge
Replace the standalone SSE dot above UserMenu with a small badge
overlaid on the bottom-right of the user avatar. Only visible when
status is connecting (yellow) or disconnected (red) — invisible in the
normal connected state, removing constant visual noise.
2026-03-03 23:25:01 +08:00
milvasic ea0892a8b2 feat: add live refresh via Server-Sent Events (SSE) with visual indicator (#5638)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: milvasic <milvasic@users.noreply.github.com>
2026-03-03 22:56:12 +08:00
Steven a69e405c95 refactor: remove dead code and deduplicate comment amount logic 2026-03-03 22:30:25 +08:00
Steven 3a5d3c8ff9 feat: show inline comment preview in list view
Add a comment preview section below memo cards in list view, displaying
up to 3 comment snippets with a "View all" link. Removes the old comment
count icon from the memo header in favor of this richer inline display.
Comment preview is hidden in memo detail view.
2026-03-03 21:40:56 +08:00
Steven 3e4c052f44 chore: unify table style 2026-03-03 21:07:28 +08:00
Steven 4321887a02 chore: fix linter 2026-03-03 08:50:57 +08:00
Lincoln Nogueira d0764fb0be i18n: update all locale translations (#5679) 2026-03-03 08:49:08 +08:00
Steven 98859eb5e5 fix(routing): redirect unauthenticated users to /explore when public visibility is allowed 2026-03-03 08:48:54 +08:00
Steven 737acbba2f fix(markdown): support height/width attributes on img elements 2026-03-02 22:45:36 +08:00
boojack ec3ab350d9 chore: tweak sponsor assets
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-03-02 22:31:20 +08:00
Steven dfab67f035 chore(seed): polish demo data with realistic multi-user content 2026-03-02 21:50:47 +08:00
Steven 6b37fcc01b fix: improve KaTeX and Mermaid error handling and overflow 2026-03-02 21:11:07 +08:00
Steven 7f753bf6d7 refactor(theme): consolidate theme system and polish dark mode colors
- Reduce themes from 6 to 4: system, light, dark, paper
  - Remove midnight (too similar to dark) and whitewall (too similar to light)
  - Drop all unused tokens: chart-*, sidebar-primary, sidebar-border,
    sidebar-ring, shadow-2xs, tracking-normal, spacing
  - Remove redundant @theme inline blocks from dynamic theme files
    (Tailwind only processes them at compile time, not at runtime)
  - Move shared tokens (fonts, radius) to default.css only

- Polish dark mode color palette
  - Consistent cool-slate hue (h265) across all surfaces
  - Proper surface layering: sidebar(0.07) → bg(0.09) → card(0.13) →
    popover(0.17) → secondary(0.19) → muted(0.21) → accent(0.22)
  - Foreground softened to 0.82 (from 0.9) — comfortable off-white
  - Accent more chromatic than muted (0.012 vs 0.008) — hover feels interactive
  - Popover elevated above card (0.17 vs 0.13) — floating elements visible
  - Destructive more vivid (0.62/c0.20) — clearly red on dark surfaces
  - Input border (0.25) more distinct than layout border (0.21)
  - Sidebar foreground (0.68) dimmer than content foreground (0.82)

- Fix inline code background invisible in dark mode (#5674)
  - Muted was same lightness as card — increased to 0.21 for clear contrast
2026-03-02 09:30:57 +08:00
Johnny 803d488a5f feat(mcp): refactor MCP server to standard protocol structure
- Replace PAT-only auth with optional auth supporting both PAT and JWT
  via auth.Authenticator.Authenticate(); unauthenticated requests see
  only public memos, matching REST API visibility semantics
- Inline auth middleware into mcp.go following fileserver pattern;
  remove auth_middleware.go
- Introduce memoJSON response type that correctly serialises store.Memo
  (including Payload.Tags and Payload.Property) without proto marshalling
- Add tools: list_memo_comments, create_memo_comment, list_tags
- Extend list_memos with state (NORMAL/ARCHIVED), order_by_pinned, and
  page parameters
- Extend update_memo with pinned and state parameters
- Extract #tags from content on create/update via regex to pre-populate
  Payload.Tags without requiring a full markdown service rebuild
- Add MCP Resources: memo://memos/{uid} template returns memo as
  Markdown with YAML frontmatter, allowing clients to read memos by URI
- Add MCP Prompts: capture (save a thought) and review (search + summarise)
2026-03-01 23:10:23 +08:00
Johnny 16576be111 chore: polish MemoDetailSidebar for consistent structure and styles 2026-03-01 21:22:11 +08:00
Johnny 9628d3de21 fix: detect legacy installations with empty schema version 2026-03-01 20:59:39 +08:00
Johnny 7c1defba01 feat(webhook): dispatch webhook on memo comment creation 2026-03-01 20:11:23 +08:00
Johnny 53a8a436ba chore: fix frontend linter 2026-03-01 19:51:52 +08:00
Johnny 78efa6802e feat(store): change default storage type to local filesystem
Add migration scripts for existing instances without a storage setting
to explicitly preserve DATABASE as their storage type, ensuring backward
compatibility. Change the default for new installations to LOCAL to
improve out-of-the-box performance.
2026-03-01 19:42:01 +08:00
Johnny 026ea92f7b fix(editor): show newly linked memos when editing a memo with attachments 2026-03-01 19:16:57 +08:00
Johnny 252b3eff90 test(store): add tests for CreateMemo with custom timestamps 2026-03-01 19:08:27 +08:00
Mudkip 09d73e8b6e fix(mysql): handle CreateMemo custom timestamps with FROM_UNIXTIME (#5673) 2026-03-01 19:01:54 +08:00
mostapko 104d2ec0a6 feat(ui): allow navigating between images with arrows in preview dialog (#5669) 2026-03-01 11:30:47 +08:00
Steven 664b8c5629 fix(ui): unify metadata component styles across MemoView and MemoEditor
Align header padding, text color, background tokens, and item row styles
across CodeBlock, AttachmentList, RelationList, and LocationDisplay so all
metadata panels follow the same visual pattern.
2026-02-26 23:18:11 +08:00
Steven 2be4a52e1a ci: extend stale workflow to cover PRs and improve clarity
- Add PR stale/close handling alongside issues
- Reduce close grace period from 7 to 3 days
- Add inline comments explaining the two-phase stale behavior
- Rename workflow and job names for clarity
2026-02-26 22:44:47 +08:00
Kevin Zhang 54d6d8c644 chore: update documentation link in README (#5663)
Signed-off-by: Kevin Zhang <ztm0929@icloud.com>
2026-02-26 18:18:06 +08:00
milvasic 6b0736b293 fix(ui): show comment editor above the comment list (#5662) 2026-02-26 08:50:27 +08:00
Steven f7ac6a0191 refactor: auth token refresh flow and simplify user hooks 2026-02-25 22:08:18 +08:00
Steven 333c9df233 fix(web): refresh memo detail cache after editor save 2026-02-25 20:03:09 +08:00
Steven bbdc998646 fix(web): use BroadcastChannel to sync token refreshes across tabs
When multiple tabs are open and a token expires, each tab independently
attempts a refresh. With server-side token rotation this causes all but
the first tab to fail, logging the user out.

Add a BroadcastChannel (memos_token_sync) so that when any tab
successfully refreshes, it broadcasts the new token to all other tabs.
Receiving tabs adopt the token in-memory immediately, skipping their own
refresh request and avoiding conflicts with token rotation.

Falls back gracefully when BroadcastChannel is unavailable (e.g. some
privacy modes).
2026-02-24 23:31:59 +08:00
Steven 26d10212c6 refactor: consolidate duplicated auth logic into auth package
Add ApplyToContext and AuthenticateToUser helpers to the auth package,
then remove the duplicated auth code spread across the MCP middleware,
file server, Connect interceptor, and gRPC-Gateway middleware.

- auth.ApplyToContext: single place to set claims/user into context after Authenticate()
- auth.AuthenticateToUser: resolves any credential (bearer token or refresh cookie) to a *store.User
- MCP middleware: replaced manual PAT DB lookup + expiry check with Authenticator.AuthenticateByPAT
- File server: replaced authenticateByBearerToken/authenticateByRefreshToken with AuthenticateToUser
- Connect interceptor + Gateway middleware: replaced duplicated context-setting block with ApplyToContext
- MCPService now accepts secret to construct its own Authenticator
2026-02-24 23:08:16 +08:00
Steven 47d9414702 feat: add MCP server with PAT authentication
Embeds a Model Context Protocol (MCP) server into the Memos HTTP
process, exposing memo operations as MCP tools at POST/GET /mcp using
Streamable HTTP transport.

Authentication is PAT-only — requests without a valid personal access
token receive HTTP 401. Six tools are exposed: list_memos, get_memo,
create_memo, update_memo, delete_memo, and search_memos, all scoped to the authenticated user.
2026-02-24 22:54:51 +08:00
Steven 71263736b0 chore: fix codeowners 2026-02-23 20:10:18 +08:00
Mudkip ff3e4c5cfe fix: avoid truncating memo batch attachments (#5654) 2026-02-23 20:09:39 +08:00
Steven 9ecd7b876b fix(web): fix spurious logout on page reload with expired access token
Two bugs caused users to be redirected to /auth too frequently:

1. Race condition in Promise.all([initInstance(), initAuth()]):
   initInstance() makes a gRPC request whose auth interceptor calls
   getAccessToken() synchronously. When the access token was expired,
   getAccessToken() eagerly deleted it from localStorage as a "cleanup"
   side-effect. By the time initAuth() ran and checked hasStoredToken(),
   localStorage was already empty, so it skipped the getCurrentUser()
   call and the token refresh cycle entirely — logging the user out even
   when the refresh-token cookie was still valid. Fix: remove the
   localStorage deletion from getAccessToken(); clearAccessToken()
   (called on confirmed auth failure and logout) handles proper cleanup.

2. React Query retry: 1 caused a second refresh+redirect attempt after
   auth failures. The auth interceptor already handles token refresh and
   request retry internally. If it still throws Unauthenticated, the
   redirect is already in flight — a React Query retry only fires another
   failed refresh and a redundant redirectOnAuthFailure() call. Fix: use
   a shouldRetry function that skips retries for Unauthenticated errors
   while keeping the existing once-retry behaviour for other errors.
2026-02-23 14:08:59 +08:00
Steven 03c30b8ccb fix(web): fix explore page showing private tags and improve stats hook
The explore page sidebar was showing tags from the current user's private
memos because the default ListMemos query applies a server-side OR filter
(creator_id == X || visibility in [...]), mixing private content in.

Fix by using a visibility-scoped ListMemos request in the explore context
so private memos are always excluded via the AND'd server auth filter.

Also consolidate two always-firing useMemos calls into one context-aware
query, unify activity stats computation with countBy across all branches,
and extract a toDateString helper to remove duplicated formatting logic.
2026-02-23 13:15:01 +08:00
Steven 1cea9b0a78 fix(web): make MonthNavigator month label reactive to language changes
Use useTranslation() hook instead of the static i18n import so that
the month label re-computes when the language changes.
2026-02-23 11:04:04 +08:00
Steven 704503e556 fix(store): allow memo/attachment deletion when local file is missing
Fixes two bugs reported in #5603:

1. store/attachment.go: ignore os.ErrNotExist when removing a local
   attachment file so that a missing file on disk (broken state from
   failed uploads) no longer blocks deletion of the DB record, allowing
   memos referencing corrupt attachments to be deleted normally.

2. memo_attachment_service.go: add nil guard on GetAttachment result
   before dereferencing it in SetMemoAttachments, preventing a nil
   pointer panic when an attachment UID no longer exists in the DB.
2026-02-23 10:26:40 +08:00
Steven 17fc8383df fix(web): improve MemoEditor layout and timestamp popover styling 2026-02-23 10:20:00 +08:00
Steven 150371d211 fix(webhook): remediate SSRF vulnerability in webhook dispatcher
- Add plugin/webhook/validate.go as single source of truth for SSRF
  protection: reserved CIDR list parsed once at init(), isReservedIP(),
  and exported ValidateURL() used at registration/update time
- Replace unguarded http.Client in webhook.go with safeClient whose
  Transport uses a custom DialContext that re-resolves hostnames at
  dial time, defeating DNS rebinding attacks
- Call webhook.ValidateURL() in CreateUserWebhook and both
  UpdateUserWebhook paths to reject non-http/https schemes and
  reserved/private IP targets before persisting
- Strip internal service response body from non-2xx error log messages
  to prevent data leakage via application logs
2026-02-23 10:14:24 +08:00
Steven f43965de00 chore: bump version 2026-02-22 18:06:58 +08:00
Steven c3061002f3 fix(web): persist auth token in localStorage for cross-tab sessions
Switch from sessionStorage to localStorage so the auth token survives
across tabs and browser restarts, matching standard platform behavior.
Also guard the signup redirect in App.tsx behind profileLoaded to avoid
a false redirect when the instance profile fetch fails.
2026-02-22 13:55:46 +08:00
Steven 4aaebc507e fix(web): skip GetCurrentUser on init when no token is stored
When no token exists in sessionStorage, AuthContext.initialize() was
still calling GetCurrentUser, triggering the auth interceptor to attempt
RefreshToken and retry — producing a burst of 5+ auth API calls in under
a second that reverse proxies with rate limiting (e.g. CrowdSec) flag as
brute force.

Add hasStoredToken() to auth-state and bail out of initialize() early
when there is definitively no session to restore. The refresh flow for
expired tokens is preserved since hasStoredToken() checks for presence
regardless of expiry.

Fixes #5647
2026-02-22 13:40:34 +08:00
Steven b8bca6bacf fix(web): scope task list item index to memo content container
The closest() selector was targeting a CSS class that never existed on
the container, causing fallback to document.body and collecting task
items across all visible memos. This caused index collisions when
multiple memos with todo lists were on the page.

Adds data-memo-content attribute to the container and updates the
selector accordingly.

Fixes #5635
2026-02-22 13:20:29 +08:00
Steven 2ab476ef2e fix(web): memoize useNavigateTo to prevent infinite view transition loop
Wrapping the returned function in useCallback prevents a new reference
on every render, which was causing an infinite startViewTransition loop
on the initial signup page (fresh install with no admin).

Fixes #5626
2026-02-22 13:16:59 +08:00
Steven 6eaab52e48 fix(web): handle delete memo errors and only run post-delete actions on success 2026-02-22 13:00:36 +08:00
milvasic 1d9d4cd797 fix: invalidate comments query on comment reaction and delete (#5641) 2026-02-22 12:58:56 +08:00
Kaki021 122ac94273 fix(web): ensure default memo visibility is correctly applied (#5623) 2026-02-14 16:58:58 +08:00
Steven 861b58cbf3 refactor(web): extract NsfwOverlay into standalone component with flexbox centering 2026-02-12 23:19:54 +08:00
Steven aeb1e5fe40 perf(web): eliminate redundant fetch when opening inline memo editor 2026-02-12 23:02:25 +08:00
Steven 69485eecb3 chore: tweak readme 2026-02-12 22:35:58 +08:00
Johnny 6402618c26 feat(web): replace EditableTimestamp with inline editor timestamp popover 2026-02-11 23:45:53 +08:00
Johnny 566fdccae6 fix(web): allow calendar year picker to go back to 1970 2026-02-11 22:58:25 +08:00
Johnny 177a74e82b fix: allow ampersand in tags to support compound tags 2026-02-11 22:55:45 +08:00
boojack f7d370dba1 chore: tweak readme
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-02-10 23:04:35 +08:00
Steven 47e2ace690 fix(web): redirect to auth page instead of explore on session expiry
Fixes #5617
2026-02-10 21:15:44 +08:00
Steven 27063d4850 chore(ci): auto-upload binaries to GitHub Release on publish 2026-02-10 20:51:05 +08:00
Steven 71e8a06463 chore: upgrade Echo v4 to v5.0.3 2026-02-10 09:15:27 +08:00
boojack cdadead6c9 chore: add SSD Nodes sponsorship
Signed-off-by: boojack <stevenlgtm@gmail.com>
2026-02-10 08:27:18 +08:00
Steven 2b19d8a969 fix(tests): update PAT tests to accept both custom and SQL errors
The PostgreSQL implementation returns 'PAT not found' when no rows match,
while SQLite/MySQL return 'sql: no rows in result set' from QueryRowContext.

Both behaviors are correct - the key fix is that PostgreSQL no longer
throws JSONB errors on missing/malformed data.

Changes:
- Update test assertions to accept either error type
- Fix comment punctuation for godot linter
- Maintain backward compatibility across all database drivers
2026-02-09 21:09:19 +08:00
Steven d9e8387d63 fix(postgres): handle missing PAT data gracefully and add comprehensive tests
Fixes #5612, #5611

Root cause: PostgreSQL's jsonb_array_elements() throws errors when the
'tokens' key is missing or malformed, while SQLite/MySQL return NULL
gracefully. This caused:
- 502 errors when creating admin after v0.25.3 → v0.26.0 upgrade
- Settings not persisting and users unable to stay logged in

Changes to store/db/postgres/user_setting.go:
- Remove strict JSONB operations from GetUserByPATHash query
- Fetch all PERSONAL_ACCESS_TOKENS rows and filter in Go
- Skip malformed/invalid JSON rows with continue (error recovery)
- Match SQLite/MySQL's forgiving error handling

New integration tests (store/test/user_setting_test.go):
- TestUserSettingGetUserByPATHashNoTokensKey
- TestUserSettingGetUserByPATHashEmptyTokensArray
- TestUserSettingGetUserByPATHashWithOtherUsers

New PostgreSQL-specific tests (store/db/postgres/user_setting_test.go):
- TestGetUserByPATHashWithMissingData (comprehensive edge cases)
- TestGetUserByPATHashPerformance (100+ users)
- TestUpsertUserSetting (basic upsert)

Test coverage:
 Missing PERSONAL_ACCESS_TOKENS key
 Empty/malformed JSON data
 Multiple users with mixed valid/invalid data
 Performance with 100+ users
 Error recovery without crashes

Benefits:
- No database migration required (TEXT column works fine)
- Backward compatible with v0.25.3 upgrades
- Handles missing/corrupt data gracefully
- Consistent behavior across all database drivers
2026-02-09 21:02:09 +08:00
MuLingQwQ b5108b4f97 fix(web): calendar navigation should use current page path (#5605) 2026-02-09 20:40:12 +08:00
Johnny b623162d37 chore: fix static check linter warnings 2026-02-08 21:37:02 +08:00
Johnny ba615172b0 chore: bump version 2026-02-08 21:28:11 +08:00
Johnny 984d9b461b fix: gracefully handle deleted memos in activity service to prevent inbox crashes 2026-02-08 21:26:18 +08:00
Johnny a69056a1c2 fix: handle underscores in environment variables correctly 2026-02-08 20:38:43 +08:00
Johnny f827296d6b chore: fix broken links 2026-02-08 19:46:03 +08:00
Johnny d9dc5be200 fix: replace echo.NewHTTPError with status.Errorf 2026-02-08 19:23:34 +08:00
Johnny c4176b4ef1 fix: videos attachment 2026-02-07 16:03:52 +08:00
Steven 81ef53b398 fix: prevent 401 errors on window focus when token expires
Fixes #5589

When the page returns from background to foreground after the JWT
token expires (~15 min), React Query's automatic refetch-on-focus
triggers multiple API calls simultaneously. These all fail with 401
Unauthorized, leaving the user with empty content.

Solution:
- Add useTokenRefreshOnFocus hook that listens to visibilitychange
- Proactively refresh token BEFORE React Query refetches
- Uses 2-minute buffer to catch expiring tokens early
- Graceful error handling - logs error but doesn't block

Changes:
- Created web/src/hooks/useTokenRefreshOnFocus.ts
- Updated isTokenExpired() to accept optional buffer parameter
- Exported refreshAccessToken() for use by the hook
- Integrated hook into AppInitializer (only when user authenticated)
2026-02-05 22:14:48 +08:00
Steven 86f780d1a4 chore: tweak security report email 2026-02-05 19:43:19 +08:00
Steven cf65f0867b refactor: remove hide-scrollbar utility
Removed the hide-scrollbar CSS class and all its usages throughout the
codebase. Hiding scrollbars can hurt UX by making it unclear when
content is scrollable.

Changes:
- Removed hide-scrollbar CSS definition from index.css
- Removed hide-scrollbar class from Navigation component (2 instances)
- Removed hide-scrollbar class from MemoDetailSidebar (2 instances)
- Removed hide-scrollbar class from TagsSection
- Removed hide-scrollbar class from ShortcutsSection

Components now use standard browser scrollbar behavior, which provides
better visual feedback to users about scrollable content. Modern
browsers already handle scrollbar appearance elegantly.
2026-02-04 20:24:54 +08:00
Steven 74b63b278c perf: disable tooltips in year calendar to fix lag
Fixed issue #5579 where the calendar selection dialog was very laggy.

The root cause was rendering ~365 individual Tooltip components when
opening the year calendar view (one per day with activity). This created
a huge number of DOM nodes and event listeners that caused significant
performance issues.

Changes:
- Added disableTooltips prop to MonthCalendar and CalendarCell components
- Disabled tooltips in YearCalendar's small month views
- Removed unnecessary TooltipProvider wrapper in YearCalendar
- Tooltips remain enabled in the default month calendar view

Performance improvements:
- Eliminates ~365 tooltip instances when dialog opens
- Reduces initial render time significantly
- Makes dialog interactions smooth and responsive

Users can still click on dates to drill down for details if needed.
2026-02-04 20:14:55 +08:00
Steven e7605d90da fix: shortcut edit button opens create dialog instead of edit dialog
Fixed issue #5576 where clicking the edit button on a shortcut would
incorrectly open a create dialog instead of an edit dialog.

The root cause was an incorrect useEffect that was watching the shortcut
state itself instead of the initialShortcut prop. When the dialog was
opened for editing, the state wasn't properly reinitializing with the
existing shortcut data.

Changed the useEffect to:
- Watch initialShortcut as the dependency
- Reinitialize the shortcut state when initialShortcut changes
- Properly distinguishes between create (initialShortcut undefined) and
  edit (initialShortcut has data) modes
2026-02-04 20:03:53 +08:00
memory_clear f9bf903eea chore: update access token deletion message description for zh-Hans (#5573)
Signed-off-by: memory_clear <83893503+MemoryClear@users.noreply.github.com>
2026-02-04 19:54:51 +08:00
Steven 6bb383a4a5 fix: prevent CTRL+Enter save while editor is loading content (#5581)
- Add validation check for loading state before allowing save
- Prevents false "Content, attachment, or file required" error
- Occurs when user presses CTRL+Enter immediately after opening edit mode
- Editor state may still be loading when keyboard shortcut fires

Closes #5581
2026-02-03 23:55:07 +08:00
Steven b4fea8c647 fix: nested task list display and checkbox interaction (#5575)
- Fix nested task lists not showing proper indentation
- Use simple CSS cascade with [&_ul.contains-task-list]:ml-6
- Fix checkbox clicks toggling wrong tasks in nested lists
- Search from memo root container for global task indexing
- Remove complex selectors in favor of standard approach
- Match behavior of GitHub, Notion, and other platforms

Closes #5575
2026-02-03 23:45:45 +08:00
Steven cf0a285ef1 fix(auth): make PKCE optional for OAuth sign-in (#5570)
Fixes issue where OAuth sign-in fails with 'Cannot read properties of
undefined (reading 'digest')' when accessing Memos over HTTP.

The crypto.subtle API is only available in secure contexts (HTTPS or
localhost), but PKCE (RFC 7636) is optional per OAuth 2.0 standards.

Changes:
- Make PKCE generation optional with graceful fallback
- Use PKCE when crypto.subtle available (HTTPS/localhost)
- Fall back to standard OAuth flow when unavailable (HTTP)
- Log warning to console when PKCE unavailable
- Only include code_challenge in auth URL when PKCE enabled

The backend already supports optional PKCE (empty codeVerifier), so no
backend changes needed. This fix aligns frontend behavior with backend.

Benefits:
- OAuth sign-in works on HTTP deployments (reverse proxy scenarios)
- Enhanced security (PKCE) still used when HTTPS available
- Backward compatible with OAuth providers that don't support PKCE

Fixes #5570
2026-02-02 23:28:21 +08:00
Steven 7465fbb4cc refactor: improve GitHub Actions workflows structure and maintainability
- Add build-binaries workflow for multi-platform binary releases
- Rename workflows for conciseness:
  - demo-render-deploy.yml → demo-deploy.yml
  - build-and-push-canary-image.yml → build-canary-image.yml
  - build-and-push-stable-image.yml → build-stable-image.yml
- Centralize version config with env variables (GO_VERSION, NODE_VERSION, PNPM_VERSION)
- Standardize step names across all workflows
- Add concurrency controls to prevent redundant runs
- Update Node.js (20→22) and pnpm (9→10) versions to match build-binaries
- Improve job names with descriptive labels
- Add consistent comments and formatting
- Set artifact retention to 60 days for binary builds
2026-02-02 23:15:15 +08:00
Steven 4033f64b9a feat: add build binaries workflow for multi-platform releases
- Add workflow_dispatch trigger for manual binary builds
- Build for linux (amd64, arm64, arm/v7), darwin (amd64, arm64), windows (amd64)
- Package as tar.gz (Unix) and zip (Windows)
- Generate build summary with artifact sizes
2026-02-02 22:54:30 +08:00
Steven 29412f4690 refactor: simplify NSFW implementation by inlining logic
- Remove unused showNsfwContent prop (was only used in MemoDetail to pre-reveal NSFW, which defeats the purpose)
- Inline useNsfwContent hook logic directly into MemoView.tsx (only 3 lines, no reusability benefit)
- Delete web/src/components/MemoView/hooks/useNsfwContent.ts
- NSFW content now consistently starts hidden across all pages
- Maintains same user experience: click to reveal, no toggle back

This removes unnecessary indirection and prop threading while preserving functionality.
2026-02-02 22:06:04 +08:00
Steven 6db58fae11 fix: prevent private memos from disappearing during token refresh (#5565)
Root cause: enabled={isInitialized && !!user} prevented displaying cached
data when user auth state transitioned during token refresh.

Changes:
- Remove !!user check from Home page enabled condition
- Add clearAccessToken() in redirectOnAuthFailure for clean logout

Fixes #5565
2026-02-02 21:45:24 +08:00
Steven 8770b186e4 fix: add Unicode case-insensitive search for SQLite (#5559)
Add custom memos_unicode_lower() SQLite function to enable proper
case-insensitive text search for non-English languages (Cyrillic,
Greek, CJK, etc.).

Previously, SQLite's LOWER() only worked for ASCII characters due to
modernc.org/sqlite lacking ICU extension. This caused searches for
non-English text to be case-sensitive (e.g., searching 'блины' wouldn't
find 'Блины').

Changes:
- Add store/db/sqlite/functions.go with Unicode case folding function
- Register custom function using golang.org/x/text/cases.Fold()
- Update filter renderer to use custom function for SQLite dialect
- Add test for Unicode case-insensitive search
- Make golang.org/x/text a direct dependency

Fixes #5559
2026-02-02 21:10:07 +08:00
Damian Jankowski c14843fa62 chore: view memo URL updated (#5569) 2026-02-02 20:39:33 +08:00
Steven 2db57b139a fix: handle pasted files in memo editor
Fixes #5568
2026-02-02 20:36:08 +08:00
Steven 0dbc35a2ba fix: restore access token creation flow
Fixes #5563
2026-02-02 20:31:22 +08:00
Steven e1c8101d29 fix: preserve tag case when extracting markdown tags
Fixes #5566
2026-02-02 20:07:06 +08:00
Steven 34c341c88c chore: fix update user request name in member dialog
Fixed #5564
2026-02-02 19:52:46 +08:00
Steven b8029c70ef chore: fix OAuth callback double-run state error 2026-02-02 09:07:55 +08:00
Johnny d14cfa1c4f fix: auto-fix permission issues when upgrading from 0.25.3 to 0.26.0
Fixes #5551

The Docker image now runs as non-root (UID 10001) for security, but this
breaks upgrades from 0.25.3 where data files were owned by root.

Changes:
- Dockerfile: Keep USER as root, install su-exec
- entrypoint.sh: Fix ownership of /var/opt/memos, then drop to non-root
- Supports custom MEMOS_UID/MEMOS_GID env vars for flexibility

This allows seamless upgrades without manual chown on the host.
2026-02-01 08:37:06 +08:00
Johnny 1696c6c414 fix: add nil check for currentUser in DeleteUser
Defense-in-depth fix: Add missing nil check before accessing
currentUser.ID and currentUser.Role in DeleteUser function.

While the auth interceptor should block unauthenticated requests,
this check prevents potential nil pointer panic if fetchCurrentUser
returns (nil, nil).
2026-01-31 23:08:09 +08:00
Johnny c7b48b800f fix: add access control checks for attachments, comments, and reactions
Security fixes for multiple authorization bypass vulnerabilities:

- GetAttachment: Add visibility check via checkAttachmentAccess helper
- UpdateAttachment: Add ownership check (creator or admin only)
- Fileserver: Require creator/admin auth for unlinked attachments
- ListMemoAttachments: Add memo visibility check
- CreateMemoComment: Add memo visibility check for target memo
- ListMemoReactions: Add memo visibility check
- UpsertMemoReaction: Add memo visibility check

All checks follow the existing pattern used in GetMemo for consistency.
2026-01-31 23:02:30 +08:00
Johnny 86fab0cf4c fix(fileserver): use streaming for video/audio to prevent memory exhaustion
- Add serveMediaStream() to stream video/audio without loading into memory
- Use http.ServeFile for local files (zero-copy, handles range requests)
- Redirect to S3 presigned URLs for S3-stored media files
- Refactor for better maintainability:
  - Extract constants and pre-compile lookup maps
  - Consolidate duplicated S3 client creation logic
  - Split authentication into focused helper methods
  - Group code by responsibility with section comments
  - Add setSecurityHeaders() and setMediaHeaders() helpers
2026-01-31 22:01:28 +08:00
Ganesh M 27de96d440 fix(ui): math render (#5549) 2026-01-31 21:46:19 +08:00
Johnny 8cd9c591d4 chore: deprecate remove completed tasks action
- Remove menu item and dialog from MemoActionMenu
- Remove removeCompletedTasks() and hasCompletedTasks() utilities
- Remove translation keys from all 34 locale files
- Feature was not aligned with standard note-taking UX patterns
2026-01-31 21:03:05 +08:00
Johnny 5396c126b8 chore: extract task list class names to constants
- Add TASK_LIST_CLASS and TASK_LIST_ITEM_CLASS constants
- Replace hardcoded 'contains-task-list' and 'task-list-item' strings
- Improve maintainability and prevent typos
2026-01-31 20:53:55 +08:00
Johnny 97ba15450f chore: prevent unnecessary API calls when timestamp unchanged in MemoDetailSidebar
- Add same value check before updating createTime/updateTime
- Skip request if new timestamp equals current timestamp
- Simplify callback handlers and improve code readability
- Use .some() instead of .filter().length for cleaner code
2026-01-31 15:12:27 +08:00
Johnny f7a81296fb style: enhance ActivityCalendar components with improved styling and layout adjustments 2026-01-30 00:13:58 +08:00
Johnny fcb9e377c1 chore: streamline memo editor insert menu 2026-01-29 23:34:40 +08:00
Steven b32cba35c6 fix: add nil check for AnyResponse in WrapUnary method to prevent caching issues 2026-01-29 21:32:54 +08:00
Steven b0558824c4 feat: update instance profile to use admin user instead of initialized flag
- Changed InstanceProfile to include admin user field
- Updated GetInstanceProfile method to retrieve admin user
- Modified related tests to reflect changes in admin user retrieval
- Removed owner cache logic and tests, introducing new admin cache tests
2026-01-28 23:27:53 +08:00
Johnny 81022123a1 chore: simplify page loading logic 2026-01-27 23:37:32 +08:00
cui c5d9770fd1 typo: lenght to length (#5539) 2026-01-27 22:21:55 +08:00
Johnny 6731eccded feat: add EditableTimestamp component for inline date editing in MemoDetailSidebar 2026-01-26 23:23:14 +08:00
Johnny a7b0d71f6e feat: implement EXIF metadata stripping for image uploads 2026-01-26 22:18:44 +08:00
Brent Bilis 2f7c8dcea7 fix(ui): correct calendar header month parsing (#5532)
Co-authored-by: Local Admin <root@localhost>
2026-01-26 22:01:11 +08:00
Johnny e1888153f8 chore: polish dark styles 2026-01-26 21:59:36 +08:00
Johnny 98eaff1277 style: adjust spacing and background colors for improved UI consistency across components 2026-01-26 21:49:26 +08:00
Johnny 72c7965c8f chore: enable binary format for transport 2026-01-26 21:16:06 +08:00
Salman Chishti a8dbc1fd5e chore: upgrade GitHub Actions for Node 24 compatibility (#5527)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-01-26 09:02:57 +08:00
itzmk21 f22b3dad25 chore(i18n): update British English spelling (#5529) 2026-01-26 08:48:43 +08:00
Salman Chishti 1e82723e88 chore: upgrade GitHub Actions to latest versions (#5528)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-01-26 08:48:07 +08:00
XIN_____ 45945a1df7 chore: update Chinese translation (#5519) 2026-01-23 23:32:58 +08:00
Pádraic Slattery 7fbf3bed85 chore: update outdated GitHub Actions versions (#5522) 2026-01-23 23:22:14 +08:00
Johnny ad327a4a8d fix: adjust compact mode styling for MemoContent component 2026-01-23 09:11:33 +08:00
Johnny 7154ce0228 feat: implement markdown components for enhanced rendering 2026-01-23 09:04:42 +08:00
Steven c0d6224155 chore: enable compact mode for list view 2026-01-22 22:52:14 +08:00
Steven 6c9ea31de0 chore: add translation for saving status in EditorToolbar 2026-01-22 22:31:19 +08:00
Steven 501e8f1eae chore: implement read-write lock for owner cache 2026-01-22 22:11:47 +08:00
Steven edcddf3c95 chore: fix tests 2026-01-22 21:59:23 +08:00
Steven fd29a98c90 chore: fix linter 2026-01-22 21:03:05 +08:00
Steven ba099b72ed feat: update InstanceProfile to include initialization status
- Removed the owner field from InstanceProfile as it is no longer needed.
- Added an initialized field to InstanceProfile to indicate if the instance has completed first-time setup.
- Updated GetInstanceProfile method to set initialized based on the existence of an admin user.
- Modified tests to reflect changes in InstanceProfile and ensure correct behavior regarding instance initialization.
- Adjusted frontend logic to redirect users based on the initialized status instead of the owner field.
2026-01-22 20:59:40 +08:00
Steven c240b70591 feat: add enabled option to useInfiniteMemos and PagedMemoList for conditional fetching 2026-01-22 20:21:16 +08:00
Steven 956ae0ebc5 fix: prevent browser caching of API responses in MetadataInterceptor 2026-01-21 08:12:23 +08:00
Steven 9cc970a3ea chore: fix data directory handling 2026-01-21 08:02:25 +08:00
Steven 4180613fc0 fix: update demo mode handling 2026-01-21 07:36:30 +08:00
Johnny 324f795965 fix: improve default data directory handling 2026-01-20 23:55:46 +08:00
Johnny d3ed069ddb refactor: remove environment variable binding for instance URL 2026-01-20 23:45:59 +08:00
Johnny 0f3c9a467d refactor: migrate HOST roles to ADMIN
- Updated the isSuperUser function to only check for ADMIN role.
- Added SQL migration scripts for MySQL, PostgreSQL, and SQLite to change user roles from HOST to ADMIN.
- Created a new SQLite migration to alter the user table structure and ensure data integrity during the migration process.
2026-01-20 23:38:30 +08:00
Johnny 47ebb04dc3 refactor: remove mode flag and introduce explicit demo flag 2026-01-20 22:58:33 +08:00
Johnny 05f31e457e fix: add mmap size setting to database connection to prevent OOM errors 2026-01-20 21:53:31 +08:00
Johnny 552318209b fix: resolve flaky migration tests and add stable upgrade test (#5514) 2026-01-20 19:25:00 +08:00
Steven 00f21b86e2 chore: remove redundant tests 2026-01-20 09:12:36 +08:00
Steven d8b5bd61ab chore: tweak sponsor assets 2026-01-20 08:58:25 +08:00
Johnny 7089db06c2 test: enhance memo filter tests with COALESCE for JSON extraction and add migration data persistence tests 2026-01-19 23:09:17 +08:00
Johnny af2a2588bf chore(test): add edge case tests for user settings shortcuts and JSON fields 2026-01-19 22:50:14 +08:00
Johnny dc7ec8a8ad feat: allow setting custom timestamps when creating memos and comments
Allow API users to set custom create_time, update_time, and display_time
when creating memos and comments. This enables importing historical data
with accurate timestamps.

Changes:
- Update proto definitions: change create_time and update_time from
  OUTPUT_ONLY to OPTIONAL to allow setting on creation
- Modify CreateMemo service to handle custom timestamps from request
- Update database drivers (SQLite, MySQL, PostgreSQL) to support
  inserting custom timestamps when provided
- Add comprehensive test coverage for custom timestamp functionality
- Maintain backward compatibility: auto-generated timestamps still
  work when custom values are not provided
- Fix golangci-lint issues in plugin/filter (godot and revive)

Fixes #5483
2026-01-17 12:56:03 +08:00
Johnny cbf46a2988 feat(filter): add CEL list comprehension support for tag filtering
Add support for CEL exists() comprehension with startsWith, endsWith, and
contains predicates to enable powerful tag filtering patterns.

Features:
- tags.exists(t, t.startsWith("prefix")) - Match tags by prefix
- tags.exists(t, t.endsWith("suffix")) - Match tags by suffix
- tags.exists(t, t.contains("substring")) - Match tags by substring
- Negation: !tags.exists(...) to exclude matching tags
- Works with all operators (AND, OR, NOT) and other filters

Implementation:
- Added ListComprehensionCondition IR type for comprehension expressions
- Parser detects exists() macro and extracts predicates
- Renderer generates optimized SQL for SQLite, MySQL, PostgreSQL
- Proper NULL/empty array handling across all database dialects
- Helper functions reduce code duplication

Design decisions:
- Only exists() supported (all() rejected at parse time with clear error)
- Only simple predicates (matches() excluded to avoid regex complexity)
- Fail-fast validation with helpful error messages

Tests:
- Comprehensive test suite covering all predicates and edge cases
- Tests for NULL/empty arrays, combined filters, negation
- Real-world use case test for Issue #5480 (archive workflow)
- All tests pass on SQLite, MySQL, PostgreSQL

Closes #5480
2026-01-17 12:36:39 +08:00
zz4zz f600fffe93 fix: use stable memos image tag (#5482) 2026-01-14 23:56:27 +08:00
Johnny 4bd0f29ee5 fix: resolve linter warning and cache race condition
- Suppress revive redundant-test-main-exit warning with //nolint
- Fix data race in cache.Clear() by using Delete() instead of map replacement
- Both changes maintain correct behavior while fixing CI failures
2026-01-14 23:50:37 +08:00
Johnny db57f4456a fix: resolve data races in store tests with atomic operations
Fix all data races detected by -race flag in parallel store tests.

Problems fixed:
1. TestMain not propagating exit code
   - Was: m.Run(); return
   - Now: os.Exit(m.Run())

2. Data races on global DSN variables
   - mysqlBaseDSN and postgresBaseDSN written in sync.Once
   - Read outside Once without synchronization
   - Race detector: write/read without happens-before relationship

3. Data races on container pointers
   - mysqlContainer, postgresContainer, testDockerNetwork
   - Same pattern: write in Once, read in cleanup

Solution: Use atomic operations
- atomic.Value for DSN strings (Store/Load)
- atomic.Pointer for container pointers (Store/Load)
- Provides proper memory synchronization
- Race-free reads and writes

Why sync.Once alone wasn't enough:
- sync.Once guarantees function runs once
- Does NOT provide memory barrier for variables written inside
- Reads outside Once have no synchronization with writes
- Race detector correctly flags this as violation

Technical details:
- atomic.Value.Store() provides release semantics
- atomic.Value.Load() provides acquire semantics
- Guarantees happens-before relationship per Go memory model
- All 159 parallel tests can safely access globals

Impact:
- Tests now pass with -race flag
- No performance degradation (atomic ops are fast)
- Maintains parallel execution benefits (8-10x speedup)
- Proper Go memory model compliance

Related: Issues #2, #3 from race analysis
2026-01-14 23:37:12 +08:00
Johnny e082adf7b6 fix: set DRIVER=sqlite in CI to prevent TestMain from spawning child processes
Problem:
- store/test/TestMain checks if DRIVER env var is set
- If not set, it runs tests for all 3 drivers (sqlite, mysql, postgres)
  by spawning child 'go test' processes
- This conflicts with t.Parallel() in individual tests
- CI workflow didn't set DRIVER, triggering multi-driver execution

Solution:
- Set DRIVER=sqlite in GitHub Actions workflow
- TestMain will run tests once with SQLite driver
- Tests run in parallel with t.Parallel() as intended
- Avoids spawning child processes and race conditions

Why SQLite:
- Fastest test execution (no container startup)
- Sufficient for CI validation
- MySQL/Postgres can be tested locally when needed

This fixes the 'table already exists' errors and test flakiness
in CI while maintaining parallel execution benefits.
2026-01-14 22:50:30 +08:00
Johnny 411e8fc5b0 perf: enable parallel execution for all store tests
Add t.Parallel() to all 159 test functions in store/test/

Benefits:
- Tests run in parallel (8-16 concurrent on typical CI)
- Each test already has isolated database (safe to parallelize)
- No shared state between tests
- Go test runner handles synchronization

Expected performance:
- SQLite tests: 4-6min → 30-45sec (87% faster)
- MySQL tests: 6-8min → 45-60sec (88% faster)
- Better CPU utilization (10-15% → 80-95%)

Why it's safe:
- NewTestingStore() creates isolated DB per test
- No global state mutations
- Each test uses t.TempDir() for file isolation
- Container-based tests use unique database names

Impact on CI workflow:
- Backend tests workflow: 4-6min → 1-2min total
- Store test group: 3-4min → 20-30sec
- 8-10x speedup from parallelization alone
2026-01-14 22:44:19 +08:00
Johnny 07b837b6f6 perf: optimize backend tests with parallel execution
Major improvements:
- Split tests into 4 parallel groups (store, server, plugin, other)
  * 3-4x faster test execution (4-6min → 1-2min)

- Enable golangci-lint cache (was disabled)
  * Saves 20-30s on lint checks

- Remove unnecessary flags
  * check-latest: API call overhead
  * --verbose: unnecessary output
  * skip-cache: disabled caching

- Add race detector (-race)
  * Better concurrency bug detection
  * Only 10-20% overhead

- Add coverage tracking
  * Per-group coverage upload to Codecov
  * Better visibility into test quality

- Add concurrency control
  * Cancel outdated PR runs
  * Saves runner minutes

- Clean up output processing
  * Remove test.log and complex awk/sed parsing
  * GitHub Actions shows output by default

Performance impact:
- Setup: 30s → 10s (2-3x faster)
- Tests: Sequential → 4 parallel jobs (3-4x faster)
- Total: 4-6min → 1-2min (~70% faster)
2026-01-14 22:34:46 +08:00
Johnny d7c5641246 fix: correct manifest merge step in workflows
- Re-run docker/metadata-action in merge job to generate proper JSON
- Use steps.meta.outputs.json instead of needs.prepare.outputs.tags
- Remove unused prepare job outputs (tags, labels)
- Simplify workflow dependency chain

This fixes the jq parse error when creating multi-arch manifests.
2026-01-14 22:28:26 +08:00
Johnny d1d2d86900 perf: optimize CI/CD workflows and Docker builds
- Implement parallel matrix builds for multi-platform images (~50% faster)
- Add pnpm store caching for 20-40s savings on cache hits
- Use --frozen-lockfile for faster, deterministic installs
- Optimize Dockerfile: CGO_ENABLED=0, -trimpath, static linking
- Reduce Docker layers and improve caching strategy
- Enhance .dockerignore to exclude unnecessary files (~1MB saved)

Build time improvements:
- Canary: 12-18min → 6-10min
- Stable: 17-27min → 8-12min
2026-01-14 22:12:28 +08:00
Johnny 253e79c111 style: remove unnecessary font-weight classes for cleaner UI 2026-01-13 23:19:43 +08:00
Steven 73c301072b refactor: simplify editor scroll logic
- Extract `scrollToCaret` helper to deduplicate scroll logic
- Unify precise scroll behavior for both manual triggers and auto-scroll
2026-01-13 21:23:23 +08:00
Steven 4e34ef22bf fix: improve editor auto-scroll and Safari IME handling (#5469)
- Use `textarea-caret` for precise cursor position calculation instead of line approximation
- Update `scrollToCursor` to scroll to the actual cursor position
- Fix Safari double-enter issue with IME in list completion
2026-01-13 21:19:31 +08:00
Steven 61dbca8dc2 fix: prevent browser cache from serving stale memo data (#5470)
This fixes a critical data loss issue where users editing the same memo
on multiple devices would overwrite each other's changes due to aggressive
browser caching, particularly in Chromium-based browsers and PWAs.

Changes:
- Backend: Add Cache-Control headers to all API responses to prevent
  browser HTTP caching
- Frontend: Force fresh fetch from server when opening memo editor by
  invalidating React Query cache
- Frontend: Reduce memo query staleTime from 60s to 10s for better
  collaborative editing support

Fixes #5470
2026-01-13 20:55:21 +08:00
Johnny c45a59549a fix: replace os.Exit with panic for clearer error handling 2026-01-12 23:36:48 +08:00
Johnny 69b62cccdb test: optimize store tests performance by reusing docker image and reducing build context 2026-01-12 23:30:56 +08:00
Johnny f58533003b fix: clean up memo_relation and attachments when deleting memo
Fixes #5472

Move cleanup logic to store.DeleteMemo to ensure data consistency:
- Delete memo_relation records where memo is source (MemoID) or target (RelatedMemoID)
- Delete attachments linked to the memo (including S3/local files)

This prevents stale COMMENT records in memo_relation after deleting
a memo that has comments.
2026-01-12 23:18:04 +08:00
Johnny 6899c2f66a chore: fix golangci-lint 2026-01-12 22:32:38 +08:00
Johnny 31f634b71a chore: add more tests 2026-01-12 22:28:42 +08:00
Johnny 8f9ff5634c chore: remove redundant icon 2026-01-12 09:11:08 +08:00
Johnny 9a3451b9d1 fix(editor): filter RelationList to only show referencing memos
- Filter out COMMENT type relations, only show REFERENCE type
- When editing a memo, only show relations where current memo is the source
- Pass memoName through EditorMetadata to RelationList for filtering
2026-01-11 22:41:20 +08:00
Johnny 7053edae27 fix: allow guests to view public memo comments
Add ListMemoComments to public endpoints whitelist so unauthenticated
users can see public comments. The service layer already filters
comments by visibility (only PUBLIC for guests).

Fixes #5471
2026-01-11 22:35:12 +08:00
Faizaan pochi da2dd80e2f fix: return Unauthenticated instead of PermissionDenied on token expiration (#5454) 2026-01-08 23:21:33 +08:00
Jongho Hong 07eac279d0 chore(i18n): add missing Korean translations (#5456)
Signed-off-by: Jongho Hong <myodan@pm.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 23:13:21 +08:00
Johnny 1e8505bfeb chore: fix tests 2026-01-08 23:06:28 +08:00
Johnny af6a0726d8 chore: drop redundant indexes in sqlite migration 2026-01-08 22:53:46 +08:00
Johnny 1985205dc2 chore: remove memo_organizer table 2026-01-08 22:28:13 +08:00
Johnny f4cf2e9559 test: improve migration tests stability and maintainability
- Fix linting issues and address testcontainers deprecation in store/test/containers.go
- Extract MemosStartupWaitStrategy for consistent container health checks
- Refactor migrator_test.go to use a consolidated testMigration helper, reducing duplication
- Add store/test/Dockerfile for optimized local test image builds
2026-01-07 23:04:12 +08:00
Om vataliya 013ea52519 fix: apply theme and locale changes immediately on login screen (#5440) (#5442) 2026-01-07 21:58:47 +08:00
Faizaan pochi 7c3fcc297d fix: allow public memo API access without authentication (#5451) 2026-01-07 20:52:04 +08:00
Johnny 14fb38f375 fix: attachment table name 2026-01-07 20:44:21 +08:00
Johnny c29c1d3e1f fix: seed data 2026-01-07 20:41:16 +08:00
Johnny cc9a214be8 revert: revert system_setting to instance_setting rename changes
Reverts only the system_setting → instance_setting rename related changes from commit d326c71.
Keeps the resource → attachment rename changes intact.

- Reverts table name back to system_setting in all database drivers (MySQL, PostgreSQL, SQLite)
- Removes migration files for the system_setting rename
- Reverts LATEST.sql files to use system_setting table
2026-01-07 20:38:02 +08:00
Johnny e75862de31 chore: fix tests 2026-01-07 09:17:34 +08:00
Johnny 79f1edc9ba chore: bump version 2026-01-07 09:08:20 +08:00
Johnny 9ccb658768 fix: sign up redirect 2026-01-07 08:50:10 +08:00
Johnny 64b487d4af chore: fix seed data 2026-01-07 08:34:42 +08:00
Johnny d326c71078 refactor(db): rename tables for clarity - resource→attachment, system_setting→instance_setting 2026-01-06 23:36:42 +08:00
Johnny 01669685b4 fix(ci): remove invalid revive rule 'use-waitgroup-go' from golangci-lint config 2026-01-06 21:12:40 +08:00
Johnny 1f755f74a4 fix(web): make login screen theme selector reactive
This fixes an issue where the theme selector on the login screen would not update its display value after selection because the component was not re-rendering. Added local state to track the current theme. Validated that this pattern is unique to the unauthenticated context.
2026-01-06 20:51:45 +08:00
Steven fb4f3e70dc fix: resolve golangci-lint issues 2026-01-06 20:41:24 +08:00
Steven e1941e7843 fix: attachment type checks 2026-01-06 20:37:04 +08:00
Faizaan pochi 874a4a7142 fix: KaTeX math rendering with underscores (#5438) 2026-01-06 20:16:01 +08:00
Steven 62646853eb chore: tweak comments display 2026-01-06 19:08:03 +08:00
Steven ed66e0fec2 chore: fix linter 2026-01-06 09:08:27 +08:00
Steven 5a8e1845e2 fix: reaction button in mobile view 2026-01-06 09:06:52 +08:00
Johnny d68ca84832 fix: delete unused attachments by using filter 2026-01-05 22:43:57 +08:00
Johnny 4b110d0d38 fix: cursor position after slash commands 2026-01-05 22:24:13 +08:00
Steven 15646a8989 chore: fix linter 2026-01-05 21:57:07 +08:00
Steven e268a1fe9c chore: tweak memo content renderer 2026-01-05 21:50:49 +08:00
Steven ac37738148 fix: default visibility setting 2026-01-05 21:46:35 +08:00
lif 86013d6c2a fix(scripts): add file existence check in file_env (#5424)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-04 19:18:48 +08:00
lif cbbb585b2e fix(frontend): ensure attachments are properly linked when creating memos (#5428)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:17:14 +08:00
Sheep-realms b7b658358e chore: update simplified chinese localization (#5422) 2026-01-04 19:10:24 +08:00
Roman Teitge 8cf99f5200 Update german translations (#5419)
Signed-off-by: Roman Teitge <83205200+rteitge@users.noreply.github.com>
2026-01-04 19:10:08 +08:00
Steven 06d1c60aaf chore: tweak domain format 2026-01-03 20:43:10 +08:00
Johnny d2b421213f chore: optimize RelationList and unify RelationCard usage 2026-01-03 14:27:12 +08:00
Johnny 77e9376e03 chore: improve metadata section UI consistency and maintainability 2026-01-03 13:54:47 +08:00
Johnny 5612fb8f41 feat: add HDR image and video support
- Add HDR detection utilities for wide color gamut formats (HEIC, HEIF, WebP)
- Apply colorSpace attribute to image/video elements for HDR-capable files
- Update frontend components (AttachmentCard, PreviewImageDialog, AttachmentList)
- Expand backend thumbnail generation to support HEIC, HEIF, WebP formats
- Add Color-Gamut response headers to advertise wide gamut support
- Extend avatar MIME type validation for HDR formats

Supported formats:
- Images: HEIC, HEIF, WebP, PNG, JPEG
- Videos: MP4, QuickTime, Matroska, WebM (VP9 Profile 2)

Browser support:
- Safari 14.1+, Chrome 118+, Edge 118+
- Gracefully degrades to sRGB on unsupported browsers
2026-01-03 13:16:56 +08:00
Johnny e761ef8684 chore: move memo-metadata components to MemoView and MemoEditor
- Remove shared memo-metadata folder
- Move metadata display components (AttachmentList, LocationDisplay, RelationList) to MemoView/components/metadata
- Move attachment types and utilities (LocalFile, AttachmentItem, toAttachmentItems) to MemoEditor/types/attachment
- Simplify AttachmentList and AttachmentCard to work directly with Attachment proto
- Update all imports across MemoEditor and MemoView components
- Better separation of concerns: MemoView handles display, MemoEditor handles local files + attachments
2026-01-03 13:07:53 +08:00
Johnny a6e8ba7fb2 refactor: consolidate MemoEditor components (#5409) 2026-01-03 12:49:13 +08:00
Johnny a630b70ba9 chore: add .worktrees to gitignore 2026-01-03 12:44:37 +08:00
Guillaume e85a3da899 chore: add missing French locale translations (#5405)
Co-authored-by: Guillaume Reichenbach <guillaume.reichenbach@reflet-digital.com>
2026-01-03 07:46:24 +08:00
Lynchi 7b35a2672f chore: unify the Chinese localization of ellipsis (#5404)
Signed-off-by: Lynchi <77619755+Linqi1234@users.noreply.github.com>
2026-01-03 07:45:07 +08:00
Johnny d2acebcc53 fix: auth checks in reaction selector 2026-01-02 23:36:41 +08:00
Johnny ef8e3cfb99 chore: remove unused keyboard shortcuts 2026-01-02 09:57:09 +08:00
Johnny 02f39c2a59 refactor: replace generic LeafletMap with dedicated LocationPicker 2026-01-02 09:36:30 +08:00
Johnny f66c750075 chore: simplify attachment file writing 2025-12-31 21:54:37 +08:00
Johnny bd02de9895 chore: add store tests (#5397) 2025-12-31 21:26:35 +08:00
Johnny 12f32acd09 chore: remove dnd-kit dependencies 2025-12-31 09:32:23 +08:00
Johnny be1b758d30 refactor: simplify memo-metadata components 2025-12-31 09:27:37 +08:00
Johnny d7284fe867 refactor: nest reaction resource names under memos 2025-12-30 23:29:54 +08:00
Johnny c2aea5a4b7 feat: implement refresh token rotation with sliding window sessions in the auth service 2025-12-30 23:00:15 +08:00
Johnny d55af9b527 refactor: standardize loading indicators by using Spinner for route fallbacks and specializing Skeleton for memo lists 2025-12-30 21:54:22 +08:00
Johnny 61e94e8b08 refactor: replace i18n language change with loadLocale 2025-12-30 21:07:56 +08:00
Johnny 0735c11d75 feat: implement memo map in user profile 2025-12-30 20:41:44 +08:00
Johnny f416eb00b0 refactor(web): simplify Skeleton component structure 2025-12-30 08:58:01 +08:00
Johnny d7478b8003 perf: optimize memory usage for statistics and image processing 2025-12-30 00:06:23 +08:00
Johnny b826e90276 refactor: polish ActivityCalendar components with modern design 2025-12-29 23:40:35 +08:00
Johnny 5d677828a6 refactor: remove NSFW content settings from instance configuration 2025-12-29 20:13:54 +08:00
Johnny 96a91ebff0 chore: add week days to calendar 2025-12-29 08:59:08 +08:00
Johnny ea3371badb chore: add ActivityCalendar components 2025-12-28 19:59:36 +08:00
Johnny 78aa41336a feat: implement attachment filtering functionality 2025-12-28 18:47:59 +08:00
Johnny 955ff0cad6 refactor: optimize user fetching in MemoCommentMessage and MemoReactionListView components 2025-12-28 17:50:43 +08:00
Johnny 115d1bacd7 refactor: replace MemoSkeleton with a new Skeleton component for improved loading states 2025-12-28 17:31:21 +08:00
Johnny 792d58b74d refactor: consolidate and update type definitions across MemoEditor components 2025-12-28 13:17:02 +08:00
Johnny 40585607f4 refactor: streamline MemoView component and related hooks, removing unused code and integrating user data 2025-12-28 12:59:55 +08:00
Johnny 64ae13839a fix: add missing @types/hast dependency 2025-12-28 12:49:41 +08:00
Johnny 85f4fc7a75 refactor: remove MemoContentContext and integrate MemoViewContext
- Deleted MemoContentContext and its associated types.
- Updated Tag and TaskListItem components to use MemoViewContext instead.
- Refactored MemoContent component to eliminate context provider and directly use derived values.
- Simplified MemoViewContext to only include essential data.
- Enhanced error handling in various components by introducing a centralized error handling utility.
- Improved type safety across components and hooks by refining TypeScript definitions.
- Updated remark plugins to enhance tag parsing and preserve node types.
2025-12-28 12:46:12 +08:00
Johnny ab650ac86d chore: remove unused dependency react-simple-pull-to-refresh from lockfile 2025-12-27 10:26:27 +08:00
Johnny d711d724bb chore: clean up unused imports and parameters across components 2025-12-27 10:18:38 +08:00
Johnny 0ad75b8f08 refactor: replace useResponsiveWidth with useMediaQuery across components 2025-12-26 23:44:52 +08:00
boojack d21610cce1 chore: add @johnnyjoygh as a code owner
Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-12-26 23:04:16 +08:00
Steven 12fdf930a6 fix: invalidate React Query cache after memo reaction changes 2025-12-26 00:13:44 +08:00
Steven 9f5a7bb889 docs: create AGENTS.md for AI agents and remove outdated CLAUDE.md
- Remove outdated CLAUDE.md with obsolete architecture references
- Create comprehensive AGENTS.md covering:
  - Backend: Go, gRPC, Connect RPC, store layer, migrations
  - Frontend: React, React Query, Context API, state management
  - Development commands, workflows, and testing patterns
  - Code conventions, configuration, CI/CD, and security notes
2025-12-25 23:48:16 +08:00
Steven 6523891982 fix: react hooks order violation in MemoDetail when creating comments
Replace dynamic hook mapping with useMemoComments hook to fetch all comments
via listMemoComments API, ensuring consistent hook order across renders and
fixing page load failure after comment creation.
2025-12-25 23:40:42 +08:00
Steven c4dfb85400 fix: ensure dispatch is used for actions in useMemoInit hook 2025-12-25 23:17:34 +08:00
Steven 28e4ade009 fix: register Catalan and Galician locales in i18n configuration
The translation files ca.json and gl.json existed but weren't registered in the locales array, preventing users from selecting these languages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 08:52:51 +08:00
Steven d4e08ae2bd refactor(react-query): optimize config, add error boundary, and remove JSDoc
This commit combines multiple improvements to the React Query migration:

Performance Optimization:
- Increase default staleTime from 10s to 30s for better performance
- Reduces unnecessary network requests while maintaining data freshness

Error Handling:
- Add ErrorBoundary component with user-friendly error UI
- Integrated at app root level for comprehensive coverage
- Provides error details and reload option

Documentation:
- Add docs/auth-architecture.md explaining AuthContext design decisions
- Document why AuthContext is preferred over React Query for current user

Code Cleanup:
- Remove all JSDoc comments from hooks and components
- Keep essential inline comments for clarity
- Simplifies code readability

Files modified:
- src/lib/query-client.ts - Optimized staleTime
- src/main.tsx - Added ErrorBoundary wrapper
- src/components/ErrorBoundary.tsx - New component
- src/hooks/useMemoQueries.ts - Removed JSDoc
- src/hooks/useUserQueries.ts - Removed JSDoc
- src/components/PagedMemoList/PagedMemoList.tsx - Removed JSDoc
- docs/auth-architecture.md - New documentation

All changes verified with TypeScript compilation and production build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 08:47:36 +08:00
ghose eed935ce44 feat: contribution New Locale: Galician (gl, gl_ES) (#5376)
Signed-off-by: ghose <704948+xmgz@users.noreply.github.com>
2025-12-25 08:38:24 +08:00
dwojtas 9821d6ace4 chore: add missing keys to Polish translation, based on the en.json file (#5380) 2025-12-25 08:37:14 +08:00
Steven b2e2b6426c perf(react-query): fix context re-renders and improve type safety
Optimizes React Query migration with performance and consistency improvements:

Performance:
- Memoize AuthContext and InstanceContext provider values to prevent unnecessary re-renders
- Convert InstanceContext getter functions to useMemo hooks
- Fix refetchSettings to avoid state dependency that caused frequent recreations

Type Safety:
- Replace 'any' types in useAttachmentQueries with proper protobuf types
- Add Attachment and ListAttachmentsRequest type imports

Query Key Consistency:
- Replace hardcoded ["users", "stats"] with userKeys.stats() factory function
- Ensures consistent cache key management across mutations

Developer Experience:
- Rename unused useCurrentUser to useCurrentUserQuery to avoid confusion
- Add documentation explaining AuthContext-based vs React Query current user hooks
- Update internal references in useNotifications and useTagCounts

All changes verified with TypeScript compilation and build tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 08:29:34 +08:00
Johnny f87f728b0f feat: react query migration (#5379) 2025-12-24 22:59:18 +08:00
Johnny 4109fe3245 chore(MemoEditor): enhance focus mode handling and improve editor layout 2025-12-23 20:11:38 +08:00
Johnny 595daaa163 style: organize imports in MemoView hooks 2025-12-23 19:33:31 +08:00
Johnny 0f9322ea11 fix(MemoEditor): dispatch toggleFocusMode action properly
- Wrap actions.toggleFocusMode() with dispatch() call
- Create toggleFocusMode helper function for callbacks
- Fix focus mode not toggling due to action not being dispatched
2025-12-23 19:30:38 +08:00
Johnny 201a0466e9 fix(MemoEditor): restore focus mode functionality
- Add useFocusMode hook to lock body scroll in focus mode
- Add 'f' key keyboard shortcut to toggle focus mode
- Add FocusModeExitButton inside editor
- Import and wire up all focus mode components and hooks
- Update useKeyboard to handle focus mode toggle
2025-12-23 19:20:21 +08:00
Johnny ca84241b8a fix(MemoEditor): restore focus mode styles
- Import and use FOCUS_MODE_STYLES from constants
- Apply proper fixed positioning, shadow, and spacing in focus mode
- Add transition animation for smooth mode switching
- Replace empty 'focus-mode' class with actual Tailwind utilities
2025-12-23 19:18:24 +08:00
Johnny f2bc115a1d fix(MemoEditor): restore wrapper styles removed by refactoring
- Add back Tailwind utility classes for proper layout
- Restore padding, border, background, and flex styles
- Fix missing styles on memo-editor-wrapper element
2025-12-23 19:17:00 +08:00
Johnny 735dd1fe4b style(MemoView): restore comments and formatting
- Add back descriptive comments removed by editor refactoring commit
- Restore original timestamp calculation format in MemoHeader
- Improve code readability with section comments
2025-12-23 19:05:29 +08:00
Johnny e61d594ded refactor(MemoView): improve maintainability and code organization
Complete refactoring of MemoView components for better code quality:

- Split useMemoViewState into individual hook files (useMemoActions,
  useKeyboardShortcuts, useNsfwContent, useImagePreview, useMemoCreator)
  for single responsibility principle
- Consolidate types closer to usage - move hook-specific types to
  respective files, keep only component props in shared types.ts
- Optimize context with separate static/dynamic memoization to reduce
  unnecessary re-renders
- Simplify barrel exports to only expose public API (MemoView component
  and MemoViewProps type)
- Add comprehensive JSDoc documentation to all public APIs with usage
  examples

Benefits:
- Better maintainability: each hook file has one clear purpose
- Improved performance: context optimization prevents unnecessary re-renders
- Enhanced developer experience: clear documentation and encapsulation
- Cleaner architecture: public API is minimal, internal details hidden

All automated checks pass (TypeScript compilation, linter, production build).
2025-12-23 19:01:57 +08:00
Johnny 1b11e8c841 refactor(editor): complete state machine and services migration
BREAKING CHANGE: MemoEditor internal architecture completely refactored

## Summary

Refactored MemoEditor from hooks-based state management to a three-layer
architecture (Presentation → State → Services) using useReducer pattern.

## Changes

### Architecture
- **State Layer** (5 new files): types, actions, reducer, context, barrel export
- **Service Layer** (6 new files): error, validation, upload, cache, memo services + barrel
- **Component Layer** (3 new files): EditorToolbar, EditorContent, EditorMetadata
- **Simplified Hooks** (3 new files): useMemoInit, useAutoSave, useKeyboard

### Code Reduction
- Main component: ~380 lines → ~140 lines (-63%)
- Hooks removed: 5 old hooks (useMemoEditorState, useMemoSave, etc.)
- Total lines removed: 508 lines of old code
- Utility hooks preserved: 8 hooks still in use (useLocation, useDragAndDrop, etc.)

### Improvements
-  Predictable state transitions with useReducer
-  Testable business logic in pure service functions
-  Cleaner component code (presentation only)
-  Better separation of concerns
-  Type-safe actions with discriminated unions
-  Centralized error handling

## Statistics
- Files changed: 26
- Commits created: 25 (squashed into 1)
- New files: 17
- Removed files: 5
- TypeScript errors: 0
- Lint errors: 0

## Testing
Manual testing required for:
- Editor functionality (create, edit, save)
- Drag and drop
- Focus mode
- Keyboard shortcuts (Cmd/Ctrl + Enter)
- Auto-save to localStorage
2025-12-23 08:38:02 +08:00
Johnny 8a7c976758 refactor: streamline tag sorting and update coordinate handling in MemoEditor components 2025-12-22 22:54:09 +08:00
Johnny d537591005 feat: add slash commands tooltip to InsertMenu 2025-12-22 22:42:23 +08:00
Steven f9dd7ad853 style: improve code block styling and formatting
- Adjust CodeBlock styling for better layout and spacing
- Reorganize copy button positioning with absolute positioning
- Simplify button styling and reduce icon sizes
- Wrap MermaidBlock in pre tag for consistent structure
- Format useDropdownMenuSubHoverDelay hook call for readability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-22 21:27:50 +08:00
Steven dfc0d376d1 refactor: extract submenu hover delay logic into reusable hook
- Create useDropdownMenuSubHoverDelay hook in dropdown-menu component
- Encapsulates hover delay behavior for preventing accidental submenu closure
- Eliminates code duplication at component usage sites
- Simplifies InsertMenu by removing 45 lines of timeout/state management code
- Hook provides handleTriggerEnter/Leave and handleContentEnter/Leave handlers
- Configurable closeDelay parameter (default 150ms)

This makes the hover behavior pattern reusable across any dropdown menu submenus.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-22 21:04:00 +08:00
Steven 332d32bd35 fix: render dates correctly in memo detail sidebar and link dialog
Convert protobuf Timestamp objects to Date before formatting
to prevent "[object Object]" from appearing in the UI.

Fixes #5368
2025-12-22 20:24:04 +08:00
Steven be7ef74698 fix: eliminate duplicate API requests by deduplicating user fetch calls
- Add request deduplication to getOrFetchUser using RequestDeduplicator
- Consolidates multiple simultaneous calls for same user into single API request
- Prevents duplicate 401 errors and wasted network traffic
- Matches pattern already used by fetchUsers and fetchUserStats
- Remove backwards compatibility aliases (getOrFetchUserByName, getOrFetchUserByUsername)
- Update all call sites to use canonical getOrFetchUser method

Fixes issue where PagedMemoList, useMemoViewState, MainLayout, and UserProfile
were making duplicate user fetch requests when loading user data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-22 20:12:44 +08:00
Johnny 5828f34aae feat: implement scheduler plugin
- Added `scheduler` package with core functionalities for job scheduling.
- Implemented job registration, execution, and graceful shutdown mechanisms.
- Introduced middleware support for logging, recovery from panics, and timeout handling.
- Developed cron expression parser to support both 5-field and 6-field formats.
- Created comprehensive integration and unit tests for scheduler, job definitions, middleware, and parser functionalities.
- Enhanced logging capabilities to track job execution and errors.
2025-12-20 15:43:25 +08:00
Johnny b55a0314f8 feat: add Email Plugin with SMTP functionality
- Implemented the Email Plugin for self-hosted Memos instances, providing SMTP email sending capabilities.
- Created configuration structure for SMTP settings with validation.
- Developed message structure for email content with validation and formatting.
- Added synchronous and asynchronous email sending methods.
- Implemented error handling and logging for email sending processes.
- Included tests for client, configuration, and message functionalities to ensure reliability.
- Updated documentation to reflect new features and usage instructions.
2025-12-20 14:23:15 +08:00
Johnny 319a7cac94 chore: fix remark tag parse 2025-12-20 13:50:32 +08:00
Johnny 6b0f90f355 chore: prevent sensitive data caching 2025-12-20 12:33:16 +08:00
Johnny eaef04a0a0 chore: fix linter 2025-12-20 11:55:00 +08:00
Steven d236ef1611 feat(web): add glassmorphism map controls with Google Maps integration
- Add custom zoom controls with modern glassmorphism styling
- Add "Open in Google Maps" button for location markers
- Refactor to React component architecture with proper Leaflet integration
- Create reusable GlassButton component for maintainability
- Use React 18 createRoot for portal rendering
- Replace imperative DOM manipulation with declarative React patterns
- Add coordinate display to location metadata

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 00:18:01 +08:00
Steven d0c3908168 refactor: remove deprecated Sessions and AccessTokens settings
- Remove ListSessions and RevokeSession RPC endpoints
- Remove Session message and SessionsSetting from UserSetting
- Remove ACCESS_TOKENS key and AccessTokensSetting
- Update references to use RefreshTokensUserSetting with its own ClientInfo
- Remove UserSessionsSection frontend component
- Clean up user store to remove session and access token settings
- Regenerate protobuf files

The system now uses:
- REFRESH_TOKENS for session management with sliding expiration
- PERSONAL_ACCESS_TOKENS for long-lived API tokens
2025-12-19 08:35:57 +08:00
Steven a6c32908a0 refactor(auth): remove legacy session cookie authentication
- Remove SessionCookieName and SessionSlidingDuration constants
- Remove ExtractSessionCookieFromHeader() function
- Remove SessionIDContextKey and GetSessionID() function
- Remove sessionID parameter from SetUserInContext()
- Remove SessionID field from AuthResult struct
- Remove session cookie extraction from middleware
- Update documentation to reflect JWT + PAT only auth

Session cookies were never being set since migration to refresh token
authentication. This change removes ~50 lines of dead code and clarifies
that the system uses JWT access tokens, refresh tokens, and PATs only.
2025-12-19 00:09:08 +08:00
Steven b0aeb06f85 refactor(web): improve auth flow and eliminate route duplication
- Extract route paths to router/routes.ts as single source of truth
- Refactor connect.ts auth interceptor with better structure and error handling
  - Add TokenRefreshManager class to prevent race conditions
  - Implement smart redirect logic for public/private routes
  - Support unauthenticated access to explore and user profile pages
  - Add proper error handling for missing access tokens
  - Extract magic strings to named constants
- Maintain backward compatibility by aliasing Routes to ROUTES

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 22:14:30 +08:00
Johnny 50606a850e fix(auth): resolve token refresh and persistence issues
- Fix cookie expiration timezone to use GMT (RFC 6265 compliance)
- Use Connect RPC client for token refresh instead of fetch
- Fix error code checking (numeric Code.Unauthenticated instead of string)
- Prevent infinite redirect loop when already on /auth page
- Fix protobuf Timestamp conversion using timestampDate helper
- Store access token in sessionStorage to avoid unnecessary refreshes on page reload
- Add refresh token cookie fallback for attachment authentication
- Improve error handling with proper type checking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 20:56:54 +08:00
Johnny 7932f6d0d0 refactor: user auth improvements (#5360) 2025-12-18 18:15:51 +08:00
Johnny 2c2ef53737 chore: fix frontend linter 2025-12-17 09:04:05 +08:00
Johnny ea14280cb3 feat: enhance attachment handling with MIME type validation 2025-12-17 08:58:43 +08:00
Johnny 642271a831 feat: add iframe support for embedded videos in markdown content 2025-12-17 08:52:59 +08:00
Johnny 310590b278 chore: fix golang checks 2025-12-16 22:53:36 +08:00
Johnny 40e129b8af refactor(auth): streamline session authentication and cookie handling 2025-12-16 22:23:59 +08:00
Steven 87b8c2b2d2 refactor(web): rename grpcweb.ts to connect.ts and enable binary format
- Rename grpcweb.ts to connect.ts to reflect ConnectRPC usage
- Enable binary protobuf format for improved performance
- Update all imports across 26 files from @/grpcweb to @/connect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 20:15:16 +08:00
Steven 6926764b91 fix: allow unauthenticated CreateUser for first user registration
Add CreateUser to PublicMethods ACL whitelist to fix "authentication required"
error during first-time setup. The CreateUser method already has proper security
logic that automatically assigns HOST role to the first user and enforces
DisallowUserRegistration setting for subsequent users.

Fixes #5352

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-15 23:18:30 +08:00
Steven b1a52f20ed fix(web): add clipboard fallback for CodeBlock copy button in non-secure contexts
The CodeBlock component was refactored in v0.25.3 to use navigator.clipboard.writeText(),
which requires HTTPS or localhost. This caused the copy button to fail silently for users
accessing Memos over HTTP.

This fix adds a fallback to the copy-to-clipboard library (already used by all other
copy operations in the codebase) when the native clipboard API is unavailable or fails,
ensuring the copy button works reliably in all deployment scenarios.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-15 22:51:34 +08:00
Steven a2ddf05933 chore: fix linter 2025-12-15 19:58:58 +08:00
Steven 1b3318f886 refactor(web): improve ActivityCalendar maintainability and add Calendar page
- Extract shared utilities and constants to eliminate code duplication
- Create dedicated Calendar page with year view and month grid
- Add date filter navigation with bidirectional URL sync
- Fix useTodayDate memoization bug causing stale date references
- Standardize naming conventions (get vs generate functions)
- Add comprehensive type exports and proper store encapsulation
- Implement size variants for compact calendar display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-15 19:47:46 +08:00
Johnny d14e66daf5 fix: openapi generation (#5349) 2025-12-15 10:55:04 +08:00
Davide Truffa 8659f69da0 chore(locales): update Italian translation (#5346) 2025-12-15 10:05:32 +08:00
Johnny 09afa579e4 chore: implement session sliding expiration and JWT authentication
- Added UpdateSessionLastAccessed method to update session access time.
- Enhanced Authenticate method to support both session cookie and JWT token authentication.
- Introduced AuthResult struct to encapsulate authentication results.
- Added SetUserInContext function to simplify context management for authenticated users.

refactor(auth): streamline gRPC and HTTP authentication

- Removed gRPC authentication interceptor and replaced it with a unified approach using GatewayAuthMiddleware for HTTP requests.
- Updated Connect interceptors to utilize the new authentication logic.
- Consolidated public and admin-only method checks into service layer for better maintainability.

chore(api): clean up unused code and improve documentation

- Removed deprecated logger interceptor and unused gRPC server code.
- Updated ACL configuration documentation for clarity on public and admin-only methods.
- Enhanced metadata handling in Connect RPC to ensure consistent header access.

fix(server): simplify server startup and shutdown process

- Eliminated cmux dependency for handling HTTP and gRPC traffic.
- Streamlined server initialization and shutdown logic for better performance and readability.
2025-12-15 10:04:11 +08:00
Steven 65a19df4be fix(backend): correct generic type parameter in withHeaderCarrier helper
Problem:
The withHeaderCarrier generic function had a type mismatch that caused compilation
errors in CI. The function used `T proto.Message` constraint, but Connect's Response
type expects the non-pointer message type while protobuf methods return pointers.

Error from CI:
  type T of resp does not match *T (cannot infer T)

This occurred because:
- Connect methods expect: *connect.Response[v1pb.CreateSessionResponse]
- Service methods return: (*v1pb.CreateSessionResponse, error)
- Old signature: fn func(context.Context) (T, error) with T proto.Message
- This caused T to be inferred as *v1pb.CreateSessionResponse
- Leading to return type: *connect.Response[*v1pb.CreateSessionResponse] (wrong!)

Solution:
Changed generic signature to explicitly handle the pointer/non-pointer distinction:
- New signature: fn func(context.Context) (*T, error) with T any
- T is now the non-pointer type (e.g., v1pb.CreateSessionResponse)
- fn returns *T (e.g., *v1pb.CreateSessionResponse)
- Return type is correctly: *connect.Response[T] (e.g., *connect.Response[v1pb.CreateSessionResponse])

Also removed unused "google.golang.org/protobuf/proto" import and improved documentation
to clarify the T vs *T distinction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 08:06:13 +08:00
Steven 06a0f8d618 fix(web): convert visibility enum values to string names in filter expressions
Problem:
The Explore page filter was sending visibility filter as:
  visibility in ["3", "2"]
when it should send:
  visibility in ["PUBLIC", "PROTECTED"]

The backend CEL filter parser expects string enum names, not numeric values.
This caused the Explore page to return no memos even when public memos existed.

Solution:
- Added getVisibilityName() helper to convert Visibility enum values to string names
- Updated useMemoFilters to use getVisibilityName() when building visibility filter
- Follows same pattern as existing getInstanceSettingKeyName() and getUserSettingKeyName()
- Added validation to warn on invalid enum values

Files changed:
- web/src/store/common.ts: Add getVisibilityName() helper with validation
- web/src/hooks/useMemoFilters.ts: Use getVisibilityName() in visibility filter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 08:03:25 +08:00
Steven 3d893a7394 fix(backend): implement protocol-agnostic header setting for dual gRPC/Connect-RPC support
Problem:
The codebase supports both native gRPC and Connect-RPC protocols, but auth
service was using grpc.SetHeader() which only works for native gRPC. This
caused "failed to set grpc header" errors when using Connect-RPC clients
(browsers using nice-grpc-web).

Solution:
- Created HeaderCarrier pattern for protocol-agnostic header setting
- HeaderCarrier stores headers in context for Connect-RPC requests
- Falls back to grpc.SetHeader for native gRPC requests
- Updated auth service to use SetResponseHeader() instead of grpc.SetHeader()
- Refactored Connect wrappers to use withHeaderCarrier() helper to eliminate
  code duplication

Additional fixes:
- Allow public methods when gRPC metadata is missing in ACL interceptor
- Properly handle ParseSessionCookieValue errors instead of ignoring them
- Fix buildSessionCookie to gracefully handle missing metadata

Files changed:
- server/router/api/v1/header_carrier.go: New protocol-agnostic header carrier
- server/router/api/v1/auth_service.go: Use SetResponseHeader, handle missing metadata
- server/router/api/v1/connect_services.go: Use withHeaderCarrier helper
- server/router/api/v1/acl.go: Allow public methods without metadata
- server/router/api/v1/connect_interceptors.go: Handle ParseSessionCookieValue errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-12 07:46:28 +08:00
Steven 8a7e00886d fix(web): convert enum values to string names in API resource paths
Frontend was incorrectly using numeric enum values (e.g., 1, 2, 3) instead
of string names (e.g., "GENERAL", "STORAGE") when constructing API resource
paths. This caused the backend to fail with "unsupported instance setting
key: INSTANCE_SETTING_KEY_UNSPECIFIED" errors during initialization.

Changes:
- Add helper functions in store/common.ts to convert enum values to names
  - getInstanceSettingKeyName() and buildInstanceSettingName()
  - getUserSettingKeyName() and buildUserSettingName()
- Update instance store to use string enum names in API calls
- Update user store to use string enum names in API calls
- Update all components to use new helper functions for setting names

Fixes enum string conversion for:
- InstanceSetting_Key (6 locations)
- UserSetting_Key (2 locations)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 20:08:11 +08:00
Johnny edd3ced9bf refactor: migrate to connect-rpc (#5338) 2025-12-11 19:49:07 +08:00
Steven 8af8b9d238 fix(web): use AST parsing for task detection to handle code blocks correctly
Fixes #5319. Checkboxes inside code blocks were incorrectly counted when
toggling tasks, causing the wrong checkbox to be checked. Replaced regex-based
task detection with mdast AST parsing which properly ignores code block content.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 08:26:16 +08:00
Steven 3dc740c752 refactor(web): improve locale/theme preference initialization
- Extract preference logic into dedicated hooks (useUserLocale, useUserTheme)
- Add applyLocaleEarly() for consistent early application
- Remove applyUserPreferences() from user store (now redundant)
- Simplify App.tsx by moving effects to custom hooks
- Make locale/theme handling consistent and reactive
- Clean up manual preference calls from sign-in flows

Fixes locale not overriding localStorage on user login.
Improves maintainability with better separation of concerns.
2025-12-11 07:59:52 +08:00
spaghetti-coder 7479205e21 fix(ui): change focus search bar shortcut overlapping with url shortcut (#5336) 2025-12-11 07:56:20 +08:00
XIN_____ baf33af980 chore: add 'all' translation to zh-Hans and zh-Hant (#5333) 2025-12-11 07:53:20 +08:00
Shivam Kumar 41358ed3ba fix(theme): improve text contrast in default dark mode (#5323)
Signed-off-by: boojack <stevenlgtm@gmail.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2025-12-11 07:53:02 +08:00
xiaolinny 6beb3fcde0 chore: fix some typos in comments (#5332)
Signed-off-by: xiaolinny <xiaolincode@outlook.com>
2025-12-11 07:50:16 +08:00
Steven 48ce4ccc26 fix(web): disable setext header syntax (#5314)
Add custom remark plugin to prevent setext headers (headers using === or --- underlines) from being recognized by the markdown parser. The plugin disables the setextUnderline construct at the micromark parser level.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 09:07:54 +08:00
Steven 1cf047707b refactor: migrate binary file serving from gRPC to dedicated HTTP fileserver
Migrates attachment and avatar binary serving from gRPC endpoints to a new dedicated HTTP fileserver package, fixing Safari video playback issues and improving architectural separation.

Key changes:
- Created server/router/fileserver package for all binary file serving
- Removed GetAttachmentBinary and GetUserAvatar gRPC endpoints from proto
- Implemented native HTTP handlers with full range request support
- Added authentication support (session cookies + JWT) to fileserver
- New avatar endpoint supports lookup by user ID or username
- Eliminated duplicate auth constants (imports from api/v1)

HTTP endpoints:
- Attachments: /file/attachments/:uid/:filename (unchanged URL)
- Avatars: /file/users/:identifier/avatar (new URL format)

This fixes Safari video/audio playback by using http.ServeContent() which properly handles HTTP 206 Partial Content responses and range request headers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 08:53:52 +08:00
spaghetti-coder 9ea27ee61f fix(ui): fix todo command does nothing (#5329) 2025-12-09 08:20:57 +08:00
spaghetti-coder 618db89f4f fix(ui): remove unsupported highlight command (#5328) 2025-12-09 07:57:28 +08:00
XIN_____ 2a876436e0 chore: add 'auto-expand' translation in zh-Hans.json (#5326) 2025-12-08 21:03:57 +08:00
Steven 4668c4714b refactor(web): improve MemoContent security and maintainability
Security improvements:
- Add rehype-sanitize for XSS protection in markdown content
- Remove DOMPurify and deprecated __html code block feature
- Extract sanitize schema to constants with comprehensive documentation

Maintainability improvements:
- Extract SANITIZE_SCHEMA to constants.ts for better organization
- Create utils.ts with shared code extraction utilities
- Refactor CodeBlock and MermaidBlock to use shared utilities
- Rename PreProps to CodeBlockProps for clarity
- Reduce code duplication across components

Dependency cleanup:
- Remove explicit katex dependency (now transitive via rehype-katex)
- Remove @matejmazur/react-katex (unused)
- Remove dompurify (replaced by rehype-sanitize)
- Update vite config to remove katex-vendor chunk

Changes: 7 files changed, 84 insertions(+), 100 deletions(-)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 22:45:22 +08:00
Steven d9f8bc80f0 feat(web): add LaTeX math rendering support to MemoContent
Integrates remark-math and rehype-katex plugins to enable LaTeX mathematical expressions in memos. Users can now write inline math ($...$) and display math ($$...$$) using standard LaTeX syntax.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 22:08:45 +08:00
Steven 81da20c905 refactor: simplify theme/locale to user preferences and improve initialization
Remove theme and locale from instance settings to eliminate duplication and
simplify the codebase. These are user-specific preferences and should only
exist in user settings, not instance-wide settings.

Backend changes:
- Remove theme from InstanceGeneralSetting proto
- Remove locale from InstanceCustomProfile proto
- Update instance service converters to remove theme/locale handling
- Simplify RSS feed to use static locale

Frontend changes:
- Remove theme/locale from instanceStore state
- Create unified initialization flow with clear fallback priority:
  * Theme: user setting → localStorage → system preference
  * Locale: user setting → browser language
- Add applyUserPreferences() to centralize theme/locale application
- Simplify App.tsx by removing redundant state synchronization
- Update all components to use new helper functions:
  * getThemeWithFallback() for theme resolution
  * getLocaleWithFallback() for locale resolution
- Remove theme/locale selectors from instance profile dialog

Theme utilities refactor:
- Organize code into clear sections with JSDoc comments
- Extract localStorage operations into getStoredTheme/setStoredTheme helpers
- Split DOM manipulation into focused functions
- Improve type safety with Theme and ResolvedTheme types
- Reduce code duplication and improve maintainability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 09:08:46 +08:00
Steven 8154a411a9 fix(web): allow only one active tag filter at a time
Previously, clicking multiple tags would add them all as active filters. Now clicking a new tag automatically clears any existing tag filters before applying the new one, ensuring only one tag can be filtered at a time. Clicking an already-active tag still deselects it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 08:40:43 +08:00
Johnny a863154224 docs: update Memos Codebase Guide for clarity and structure 2025-12-02 08:19:20 +08:00
Steven 0610257562 refactor(store): remove deprecated migration_history table and backward compatibility code
Complete removal of migration_history system in favor of instance_setting based schema versioning.

Changes:
- Remove migration_history table creation from all LATEST.sql files
- Delete all migration_history model and implementation files (~300 lines)
- Remove FindMigrationHistoryList and UpsertMigrationHistory from Driver interface
- Replace complex backward compatibility functions with simple version check
- Update health check to use instance_setting instead of migration_history
- Simplify checkMinimumUpgradeVersion to detect pre-v0.22 installations

Breaking change:
Users on versions < v0.22.0 (May 2024) must upgrade to v0.25.x first before upgrading to this version.
Clear error message with upgrade instructions will be shown for old installations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 22:54:30 +08:00
Steven fae5eac31b fix(web): fix infinite loop in MemoEditor and improve React/MobX integration
- Wrap all setter functions in useMemoEditorState with useCallback to ensure stable references
  This prevents infinite loops when setters are used in useEffect dependencies (fixes "Maximum update depth exceeded" error)
- Extract MobX observable values in useMemoFilters and useMemoSorting before using them in useMemo dependencies
  This prevents React from tracking MobX observables directly, improving reliability
- Add comprehensive documentation explaining the design decisions for future maintainability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 08:54:40 +08:00
Steven d1492007ab fix(store): filter inbox notifications by message type at database level
Add MessageType filter to FindInbox to exclude legacy VERSION_UPDATE
notifications from inbox queries. This resolves the issue where users
saw notification counts but no items displayed, as VERSION_UPDATE
entries cannot be rendered in the new UserNotification API.

Changes:
- Add MessageType field to FindInbox struct for database-level filtering
- Implement JSON extraction filters in SQLite, MySQL, and PostgreSQL drivers
- Update ListUserNotifications to filter MEMO_COMMENT type at store level

This approach improves performance by filtering at the database rather
than in application code, reducing unnecessary data transfer for users
with many legacy inbox entries.

Fixes #5278

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 08:43:05 +08:00
Steven 17e116b977 chore: fix linter 2025-12-01 08:27:23 +08:00
Johnny 545323d12c refactor(rss): comprehensive RSS service improvements
Major performance and standards compliance improvements to RSS feed generation:

Performance optimizations:
- Fix N+1 query problem by batch loading attachments (101 queries → 2-3)
- Add in-memory caching with 1-hour TTL and LRU eviction
- Implement ETag-based conditional requests (304 Not Modified)
- Add database-level pagination with LIMIT clause
- Clean up expired cache entries to prevent memory leaks

RSS 2.0 compliance:
- Add item titles extracted from memo content
- Include both description and content:encoded fields
- Add author information (name and email)
- Set proper Last-Modified headers
- Use specific application/rss+xml content type

Code quality:
- Fix potential index out of bounds panic in title generation
- Improve markdown heading stripping with regex (handles # to ######)
- Add proper HTTP caching headers (Cache-Control, ETag, Last-Modified)
- Thread-safe cache implementation with RWMutex
- Better error handling and edge case coverage

The RSS backend now follows industry best practices with optimal
performance, full standards compliance, and production-ready reliability.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 00:28:23 +08:00
Johnny 1a9bd32cf1 feat(auth): add PKCE support and enhance OAuth security
Implements critical OAuth 2.0 security improvements to protect against authorization code interception attacks and improve provider compatibility:

- Add PKCE (RFC 7636) support with SHA-256 code challenge/verifier
- Fix access token extraction to use standard field instead of Extra()
- Add OAuth error parameter handling (access_denied, invalid_scope, etc.)
- Maintain backward compatibility for non-PKCE flows

This brings the OAuth implementation up to modern security standards as recommended by Auth0, Okta, and the OAuth 2.0 Security Best Current Practice (RFC 8252).

Backend changes:
- Add code_verifier parameter to ExchangeToken with PKCE support
- Use token.AccessToken for better provider compatibility
- Update proto definition with optional code_verifier field

Frontend changes:
- Generate cryptographically secure PKCE parameters
- Include code_challenge in authorization requests
- Handle and display OAuth provider errors gracefully
- Pass code_verifier during token exchange

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 00:04:26 +08:00
Johnny a6a8997f4c chore: tweak comments 2025-11-30 13:16:02 +08:00
Johnny 07072b75a7 chore: reorganize reaction components 2025-11-30 12:48:21 +08:00
Johnny 6dcf7cc74c chore: enhance MemoView component structure 2025-11-30 12:41:24 +08:00
Johnny 7aa8262ef2 chore: streamline MemoEditor components and remove unused code 2025-11-30 12:30:00 +08:00
Huang Youchuan 26cb357685 fix: update user session retrieval to use user.ID instead of userID (#5294)
Co-authored-by: huangyouchuan <huangyouchuan@letu.com>
2025-11-30 11:45:16 +08:00
Johnny ee9d9603ee chore: remove goreleaser 2025-11-30 11:42:46 +08:00
Johnny 2516cdf2b4 refactor: clean up MemoView and MemoEditor component architecture
This commit refactors MemoView and MemoEditor components for better
maintainability, introducing React Context, custom hooks, and improved
folder structure.

MemoView improvements:
- Introduce MemoViewContext to eliminate prop drilling
- Reduce MemoHeader props from 18 to 8
- Reduce MemoBody props from 9 to 4
- Extract custom hooks: useMemoViewDerivedState, useMemoEditor,
  useMemoHandlers for better separation of concerns
- Fix React hooks ordering bug in edit mode

MemoEditor improvements:
- Extract state management into useMemoEditorState hook
- Extract keyboard handling into useMemoEditorKeyboard hook
- Extract event handlers into useMemoEditorHandlers hook
- Extract initialization logic into useMemoEditorInit hook
- Reduce main component from 461 to 317 lines (31% reduction)

Folder structure cleanup:
- Move SortableItem to memo-metadata (correct location)
- Move ErrorBoundary to components folder
- Flatten Toolbar/InsertMenu structure (remove unnecessary nesting)
- Consolidate hooks in main hooks folder
- Consolidate types in main types folder

Benefits:
- Better separation of concerns
- Improved testability
- Easier maintenance
- Cleaner code organization
- No functionality changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:15:20 +08:00
Johnny bb7e0cdb79 refactor: remove enable link preview setting
- Remove enable_link_preview field from proto definitions
- Remove setting UI from MemoRelatedSettings component
- Remove translations from all 33 locale files
- Regenerate proto files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 10:34:22 +08:00
Johnny 5fb6f8eccf refactor: remove legacy disable markdown shortcuts setting
- Remove disable_markdown_shortcuts field from proto definitions
- Remove setting UI from MemoRelatedSettings component
- Enable markdown shortcuts permanently in MemoEditor
- Remove translations from all 32 locale files
- Fix TypeScript error in useMemoSave hook by using typed translation function

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 10:30:55 +08:00
Johnny 2b7b70ebfe chore: fix linter 2025-11-30 10:15:20 +08:00
Johnny 1ef11f7470 refactor: implement MemoView component with subcomponents and hooks
- Added MemoView component to display a single memo card with full functionality including creator info, memo content, attachments, reactions, and comments.
- Created MemoBody and MemoHeader subcomponents to separate concerns and improve maintainability.
- Introduced custom hooks for managing memo actions, keyboard shortcuts, NSFW content visibility, and image preview.
- Implemented reaction handling with new ReactionSelector and ReactionView components.
- Added TypeScript types for better type safety and clarity.
- Established constants for memo card styling and keyboard shortcuts.
- Removed legacy ReactionSelector and ReactionView components from the previous structure.
2025-11-29 23:21:35 +08:00
Johnny 50199fe998 feat: add LocationDialog and related hooks for location management in MemoEditor
- Implemented LocationDialog component for selecting and entering location coordinates.
- Created useLocation hook to manage location state and updates.
- Added LocationState type for managing location data.
- Introduced useLinkMemo hook for linking memos with search functionality.
- Added VisibilitySelector component for selecting memo visibility.
- Refactored MemoEditor to integrate new hooks and components for improved functionality.
- Removed obsolete handlers and streamlined memo save logic with useMemoSave hook.
- Enhanced focus mode functionality with dedicated components for overlay and exit button.
2025-11-28 09:21:53 +08:00
Chriss c1765fc246 feat: add midnight theme (#5288) 2025-11-27 21:21:57 +08:00
Steven 07a030ddfd fix(postgres): update tag filtering SQL to ensure proper type casting for LIKE comparisons 2025-11-26 23:04:07 +08:00
Steven 50f49fc00d chore: update demo data 2025-11-26 21:58:56 +08:00
Steven 05f7c9606b fix: add HTML sanitization and dynamic theme loading 2025-11-26 20:34:52 +08:00
Steven 363bc9f455 fix(web): add missing tag styles and unify primary color across themes
Tags were missing CSS styles, appearing as plain text. Added text-primary
styling to Tag component and updated default theme primary color to match
other themes (blue hue 250, chroma 0.08) for consistency.

Fixes #5282

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 20:25:28 +08:00
Steven 5a16f8009f fix(markdown): render single newlines as line breaks
Add remark-breaks plugin to render single newlines as <br> tags,
matching GitHub Flavored Markdown behavior.

Fixes https://github.com/usememos/memos/issues/5277

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 20:13:57 +08:00
김강현 ef033d21de chore(i18n): add missing Korean translations (#5285) 2025-11-26 20:02:57 +08:00
김강현 b7c9f0e6ff chore(i18n): improve Korean locale translations for copy and delete actions (#5280) 2025-11-26 12:34:54 +08:00
Steven 424f11f227 fix(store): fix PostgreSQL tag filtering type inference error
Resolves issue where tag filtering in PostgreSQL databases failed with "operator does not exist: jsonb ~~ unknown" error. The hierarchical tag filtering feature introduced in commit 5e47f25b generated SQL with implicit type placeholders that PostgreSQL couldn't infer.

The fix explicitly casts the LIKE comparison placeholder to text (::text) in the PostgreSQL dialect, ensuring proper type resolution for the query parameter.

Fixes #5275

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 07:40:39 +08:00
Steven 68c17469a3 fix(markdown): fix UTF-8 truncation for CJK characters in snippet generation
The truncateAtWord function was slicing strings by byte position instead of
character position. When truncating text with multi-byte UTF-8 characters
(like CJK), this could cut in the middle of a character, creating invalid
UTF-8 and causing gRPC marshaling errors.

Fixed by converting to runes before truncation to ensure we always cut at
proper character boundaries. Added test cases for CJK characters.

Fixes #5276

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 07:34:28 +08:00
Steven e17cd163c6 chore: bump version 2025-11-25 22:17:45 +08:00
Steven eb541d04cc fix(web): resolve tag sidebar filtering and reactivity issues
This fixes multiple issues with the tag sidebar and activity calendar:

1. Tag disappearing bug: When filtering by a tag, the sidebar now shows all tags instead of only the selected tag
2. Activity calendar filtering: Calendar now shows full activity history instead of filtered results
3. Auto-update on memo changes: Sidebar tags and calendar now update automatically when creating/editing memos without requiring manual page refresh

Technical changes:
- Modified useFilteredMemoStats to fetch unfiltered UserStats from backend API for Home/Profile pages
- Fixed key mismatch bug in userStore where stats were stored with inconsistent keys
- Added statsStateId update in fetchUserStats to trigger reactivity
- Updated MainLayout to pass appropriate userName based on page context
- Archived/Explore pages continue to compute from cached memos (correct behavior)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 22:17:01 +08:00
Steven 8ec4c9ab63 fix(web): update time locale when language is changed
Pass i18n.language to time display components to ensure locale updates
when the user switches languages in UserMenu. Changes:

- MemoView: Pass i18n.language to toLocaleString() and <relative-time> lang attribute
- MonthNavigator: Wrap with observer to make component reactive to i18n.language changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 21:37:04 +08:00
Steven ef6456a4f5 refactor(web): restructure MemoEditor with custom hooks and improved error handling
Extract reusable logic into custom hooks (useLocalFileManager, useDragAndDrop, useDebounce, useAbortController), add ErrorBoundary for resilience, and centralize constants. Fix cursor positioning bugs, useEffect dependency issues, and add geocoding request cancellation. Improve performance with debounced localStorage writes and ref-based flags.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 21:29:54 +08:00
Johnny 1832b59190 fix(css): standardize color variable values in default-dark theme 2025-11-25 09:02:57 +08:00
Steven 5e95a77f50 chore: update color variables in default-dark theme for improved contrast 2025-11-24 21:59:24 +08:00
Steven b5de847107 chore(web): add external link icon for memo attachments 2025-11-24 21:56:14 +08:00
Steven 424e599980 refactor(web): optimize memo statistics fetching by using cached data from memo store 2025-11-24 21:37:12 +08:00
Steven 72f93c5379 feat(web): enhance file upload handling with local file support and preview 2025-11-24 21:31:18 +08:00
Steven 1d582e0f39 chore: upgrade dependencies 2025-11-24 20:18:52 +08:00
Richard Szegh 455eef9fa3 feat(web): add ability to delete unused attachments (#5272) 2025-11-24 11:55:53 +08:00
Johnny 60d977c0bf fix: add period to comment for golangci-lint compliance 2025-11-23 23:47:17 +08:00
Johnny b78d4c2568 refactor(markdown): use Unicode categories for tag validation
Replace custom character whitelist with Unicode standards-based validation:

- Use unicode.IsLetter/IsNumber/IsSymbol instead of hardcoded lists
- Remove manual UTF-8 byte checking for CJK punctuation
- Add proper rune-based length limiting (MAX_TAG_LENGTH = 100)
- Improve international character support (CJK, Arabic, Cyrillic, etc.)
- Add emoji support via unicode.IsSymbol

Benefits:
- Cleaner, more maintainable code (~50 lines removed)
- Standards-based approach following Unicode categories
- Better UTF-8 safety with utf8.DecodeRune
- Consistent validation between Go backend and TypeScript frontend

All existing tests pass with improved Unicode handling.
2025-11-23 23:45:10 +08:00
Steven d69435c97c chore(docs): update security policy to prevent public vulnerability disclosure
- Direct security reports to email only instead of public GitHub issues
- Set clear expectations: no CVEs during beta (v0.x) phase
- Add security best practices for self-hosted deployments
- Plan formal vulnerability disclosure program for v1.0+

Addresses #5255
2025-11-21 09:13:40 +08:00
gitkeniwo a533ba02dc fix: add load more button and pagination to attachments page (#5258) 2025-11-21 09:04:41 +08:00
Steven edfbd6b073 fix(web): refresh sidebar tags when creating/updating memos
The sidebar tag list wasn't updating when users created new memos with tags
or modified existing memo tags. This was because useFilteredMemoStats hook
only refetched when filter/state/orderBy changed.

Now the hook observes memoStore.state.stateId, which changes whenever memos
are created, updated, or deleted. This triggers automatic refetch and the
sidebar updates immediately with the latest tag counts.

Fixes tag refresh issue in sidebar
2025-11-19 22:30:00 +08:00
Steven cabd0d61c6 fix(web): resolve Leaflet DOM cleanup error causing app crashes
- Add MapCleanup component to properly remove Leaflet map instances on unmount
- Fix LocationMarker initialization with useRef to prevent re-initialization
- Remove problematic key prop in LocationDialog that caused unnecessary remounts
- Fix goimports formatting in tag parser

Fixes #5260
2025-11-19 22:21:01 +08:00
Steven 3989100a27 fix(parser): handle additional Unicode punctuation in tag parsing 2025-11-19 22:19:56 +08:00
Steven 64e9d82d67 fix(parser): support Unicode characters in tags
Fixes #5264

Chinese, Japanese, Korean, and other Unicode characters are now
properly recognized in hashtags, following the standard hashtag
parsing conventions used by Twitter, Instagram, and GitHub.

Changes:
- Updated tag parser to allow Unicode letters and digits
- Tags stop at whitespace and punctuation (both ASCII and CJK)
- Allow dash, underscore, forward slash in tags
- Added comprehensive tests for CJK characters and emoji

Examples:
- #测试 → recognized as tag '测试'
- #日本語 → recognized as tag '日本語'
- #한국어 → recognized as tag '한국어'
- #测试。→ recognized as tag '测试' (stops at punctuation)
- #work/测试/项目 → hierarchical tag with Unicode
2025-11-19 22:06:11 +08:00
Neo 4de8712cb0 fix: keyboard shortcuts (#5250) 2025-11-17 08:55:57 +08:00
Johnny 357118804e feat(web): add Focus Mode UI entry in Insert Menu
Add discoverable UI entry point for Focus Mode via Insert Menu submenu:

UI Changes:
- Add "View" submenu to Insert Menu (+ button dropdown)
- Nested menu with Focus Mode option (ChatGPT-style pattern)
- Display keyboard shortcut hint (⌘⇧F) next to menu item
- Uses DropdownMenuSub components from Radix UI

User Access Methods:
1. Keyboard: Cmd/Ctrl+Shift+F (primary, power users)
2. Mouse: + menu → View → Focus Mode (discoverable)
3. Mobile: Touch-friendly menu access

Benefits:
- Improves discoverability for new users
- Doesn't clutter main editor UI
- Educates users about keyboard shortcut
- Extensible for future view options (typewriter, reading mode, etc.)
- Follows familiar UI patterns (ChatGPT, Notion)

Files Modified:
- web/src/components/MemoEditor/ActionButton/InsertMenu.tsx
  * Add DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent
  * Add View submenu with Focus Mode entry
  * Add onToggleFocusMode prop
- web/src/components/MemoEditor/index.tsx
  * Pass toggleFocusMode to InsertMenu component
- web/src/locales/en.json
  * Add "editor.view" translation key
2025-11-17 08:55:10 +08:00
Johnny c8162ff3cc feat(web): add Focus Mode for distraction-free writing
Add keyboard-activated Focus Mode to provide an immersive writing experience:

Features:
- Toggle with Cmd/Ctrl+Shift+F (matches GitHub, Google Docs)
- Exit with Escape, toggle shortcut, button click, or backdrop click
- Expands editor to ~80-90% of viewport with centered layout
- Semi-transparent backdrop with blur effect
- Maintains all editor functionality (attachments, shortcuts, etc.)
- Smooth 300ms transitions

Responsive Design:
- Mobile (< 640px): 8px margins, 50vh min-height
- Tablet (640-768px): 16px margins
- Desktop (> 768px): 32px margins, 60vh min-height, 1024px max-width

Implementation:
- Centralized constants for easy maintenance (FOCUS_MODE_STYLES)
- Extracted keyboard shortcuts and heights to named constants
- JSDoc documentation for all new functions and interfaces
- TypeScript type safety with 'as const'
- Explicit positioning (top/left/right/bottom) to avoid width overflow

Files Modified:
- web/src/components/MemoEditor/index.tsx - Main Focus Mode logic
- web/src/components/MemoEditor/Editor/index.tsx - Height adjustments
- web/src/locales/en.json - Translation keys

Design follows industry standards (GitHub Focus Mode, Notion, Obsidian)
and maintains code quality with single source of truth pattern.
2025-11-16 23:15:36 +08:00
Steven 156908c77f chore(web): migrate from ESLint+Prettier to Biome
- Install @biomejs/biome@2.3.5 as unified linter and formatter
- Remove ESLint, Prettier and all related plugins (221 packages removed)
- Migrate linting rules from ESLint to Biome configuration
- Migrate formatting rules from Prettier to Biome configuration
- Exclude auto-generated proto files from linting (src/types/proto/**)
- Exclude CSS files from Biome (Tailwind syntax not yet supported)
- Update package.json scripts:
  - lint: tsc + biome check
  - lint:fix: biome check --write
  - format: biome format --write
- Auto-fix import organization across 60+ files
- Fix duplicate key in Russian locale (ru.json)
- Update CLAUDE.md documentation to reflect Biome usage

Benefits:
- 10-100x faster linting performance
- Simplified toolchain with single configuration file
- 221 fewer npm dependencies
- Unified linting, formatting, and import organization
2025-11-14 23:58:07 +08:00
Johnny 64111369d3 fix(web): refactor task list styles to follow GitHub standard (#5253)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 23:37:00 +08:00
Steven d1b2c0308b chore: add demo user access token for testing purposes 2025-11-11 08:31:05 +08:00
Steven dc398cf6a7 chore(web): unify metadata badge styling and fix event handling
- Remove MetadataBadge component and inline styles consistently
- Add pointer/mouse event handlers to prevent drag interference
- Fix LocationDisplay mode handling and popover interaction
- Clean up RelationList empty state logic
2025-11-10 21:53:56 +08:00
boojack 659c63165b chore: add Vercel OSS Program badge
Added Vercel OSS Program badge to README.

Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-11-10 20:18:41 +08:00
Johnny 9a100d55be chore(server): remove profiler code and endpoints (#5244)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-10 20:17:21 +08:00
Simon bb3d808e0e feat: allow closing image preview via backdrop click (#5243) 2025-11-10 19:12:45 +08:00
Johnny 6fc0ef7cc2 chore(web): remove redundant and minimal README files
Removed 3 markdown files that provided no useful documentation:
- web/README.md: Single line "The frontend of Memos" (redundant)
- web/src/components/kit/README.md: Single line "Base components" (minimal)
- web/MARKDOWN_STYLE_GUIDE.md: Outdated styling guide (no longer applicable)

Kept comprehensive documentation files:
- web/src/components/MasonryView/README.md (implementation details)
- web/src/themes/COLOR_GUIDE.md (design system guide)
- web/src/components/ConfirmDialog/README.md (component API docs)
- web/src/store/README.md (architecture patterns)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 14:21:10 +08:00
Johnny fa3e0fc7f9 fix(web): make MermaidBlock reactive to system theme changes and improve code quality
- Add system theme listener to detect OS theme changes in real-time
- Refactor to eliminate duplicate theme preference extraction
- Simplify getMermaidTheme function from switch statement to ternary
- Move render guard outside async function for better readability
- Update comments to be more concise and focused

The component now properly re-renders Mermaid diagrams when the OS theme changes while using "system" theme preference.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 14:19:11 +08:00
Johnny fb736c20d3 feat(web): add Mermaid diagram support in markdown (#5242)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-09 14:13:10 +08:00
Johnny 5925e3cfc1 chore(web): simplify command system and improve editor UX (#5242)
Streamlines the command suggestion interface and fixes list auto-completion behavior:

- Remove command descriptions for cleaner suggestion popup UI
- Replace PaperclipIcon with FileIcon for semantic accuracy
- Fix list auto-completion to avoid extra newline when exiting list mode
- Add explanatory comments for cursor offset positions
- Improve dependency array in useListAutoCompletion hook

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 13:09:50 +08:00
Johnny fc43f86571 chore(web): unify Location/Attachments/Relations components (#5241)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-09 07:23:31 +08:00
Johnny a2ccf6b201 feat(web): improve list auto-completion and tag insertion UX (#5240)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-09 01:26:29 +08:00
Johnny e8b0273473 chore(web): improve CommandSuggestions and TagSuggestions components (#5239)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-09 01:05:24 +08:00
Johnny bd21338fdb fix(web): markdown list auto-completion creates new line correctly (#5238)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 10:47:10 +08:00
Johnny c54fcf7aa7 refactor(web): redesign Settings components (#5237)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 10:32:55 +08:00
Steven 805bb4e741 chore(github): streamline issue templates with validation and better structure
Improve bug report and feature request templates to reduce duplicate submissions and gather better information:

Bug Report Template:
- Add pre-submission checklist requiring users to search existing issues and test on latest version/demo
- Add dropdown for issue location (stable, dev, demo site, older version)
- Restructure fields with clearer labels and better placeholders
- Add "Expected Behavior" section for clarity
- Combine screenshots and context with helpful prompts

Feature Request Template:
- Add pre-submission checklist to confirm issue search
- Expand feature type categories (API/Backend, Integrations/Plugins, Security/Privacy, Performance)
- Add "Problem or Use Case" field to understand the underlying need
- Add "Alternatives Considered" section
- Improve placeholders with specific examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 10:28:51 +08:00
boojack 906412013f refactor(api): remove test_auth.go and inline test helpers (#5235)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 09:53:29 +08:00
boojack 13fea64d15 fix(api): implement custom memo ID support in CreateMemo (#5234)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 09:43:10 +08:00
Steven ef9eee19d6 fix: implement tag suggestions functionality
Backend changes:
- Fix ListAllUserStats to calculate and return tag statistics
- Previously only returned name and timestamps, missing TagCount
- Now properly aggregates tags, pinned memos, and memo type stats

Frontend changes:
- Initialize user stats on app startup to populate tag data
- Show all tags when typing just '#' (fix empty Fuse.js search)
- Auto-refresh stats after creating/updating/deleting memos
- Fix Checkbox component ref warning with forwardRef

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 09:15:38 +08:00
boojack 7d4d1e8517 feat(web): standardize theme system with auto sync option (#5231)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 00:41:21 +08:00
Steven 8f29db2f49 fix(server): prevent memory exhaustion in thumbnail generation
Address high memory usage when opening resource tab (fixes #5183) by implementing:

1. Concurrency control: Limit thumbnail generation to 3 concurrent operations using semaphore to prevent memory exhaustion when many images are requested simultaneously

2. S3 optimization: Skip server-side thumbnail generation for S3-stored images by default. S3 images now use presigned URLs directly, avoiding:
   - Downloading large images from S3 into server memory
   - Decoding and resizing images on the server
   - High memory consumption during batch requests

3. Memory management improvements:
   - Explicitly clear blob and decoded image from memory after use
   - Restructure thumbnail cache check to avoid unnecessary semaphore acquisition
   - Double-check pattern to prevent duplicate generation while waiting

This restores the original S3 behavior before commit e4f6345 while maintaining thumbnail support for local/database storage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 09:04:21 +08:00
Steven b7215f46a6 refactor(web): use Radix Checkbox and remove memoTypeStats
- Replace native input with Radix UI Checkbox in TaskListItem for better accessibility and consistent styling
- Remove memoTypeStats tracking and display (link count, todo count, code count)
- Remove StatCard component and related type definitions
- Simplify statistics to only track activity calendar data and tags

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 08:36:15 +08:00
Steven 8f136ffa75 fix(api): correct user registration logic and first-user detection
- Changed first-user detection to check for any users instead of only HOST users
- Moved registration setting check before role assignment to properly block unauthorized registrations
- Fixed role assignment logic to ensure unauthenticated users always get USER role
- Updated test cases to create host user first when not testing first-user scenario

This ensures the first user is always created as HOST and registration settings are properly enforced for subsequent user creation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 23:41:47 +08:00
boojack 21d31e3609 fix(security): implement security review recommendations (#5228)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 23:32:27 +08:00
Elliott bb8fa90496 feat(i18n): add missing translations for zh-Hans (closes #5209) (#5229) 2025-11-06 22:32:03 +08:00
Steven 32d47abef2 fix(api): use correct instance setting method in user registration
Replace non-existent GetWorkspaceGeneralSetting with GetInstanceGeneralSetting
to properly check if user registration is allowed. This fixes a compilation
error that was preventing tests from running.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 20:00:35 +08:00
Florian Dewald 769dcd0cf9 fix(security): add missing authorization checks to various services (#5217) 2025-11-06 19:42:44 +08:00
boojack df93120f60 chore: add Claude Code GitHub workflow (#5227) 2025-11-06 19:40:01 +08:00
boojack 6cff7972d5 fix(web): fix tag syntax rendering on first line (#5226)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 19:29:16 +08:00
Steven 8f0658e90d feat(web): enhance inbox notifications and user profile layouts
- Polish inbox notification items with improved visual hierarchy
  - Add original memo snippet with left border indicator
  - Redesign comment preview with gradient background and primary accent
  - Increase spacing and improve typography with consistent sizing
  - Add ring borders to avatars and refined icon badges
  - Enhance loading and error states with better skeleton designs
  - Improve hover states and transitions throughout

- Redesign user profile header layout
  - Create full-width centered header with avatar and user info
  - Add horizontal layout for profile actions
  - Improve responsive design with proper flex wrapping
  - Allow memo list to use full width for masonry layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 08:41:30 +08:00
Steven 71d0dbaf41 chore: fix linter 2025-11-05 23:59:24 +08:00
Steven 89b0b81bdc fix(web): add required headers for Nominatim reverse geocoding API
Nominatim's usage policy requires a User-Agent header to identify the application. Added User-Agent and Accept headers to the reverse geocoding fetch request, and improved error handling to check HTTP response status.

Fixes #5222

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 23:47:44 +08:00
Steven 4c1d1c70d1 refactor: rename workspace to instance throughout codebase
Remove work-related terminology by renaming "workspace" to "instance"
across the entire application. This change better reflects that Memos
is a self-hosted tool suitable for personal and non-work use cases.

Breaking Changes:
- API endpoints: /api/v1/workspace/* → /api/v1/instance/*
- gRPC service: WorkspaceService → InstanceService
- Proto types: WorkspaceSetting → InstanceSetting
- Frontend translation keys: workspace-section → instance-section

Backend Changes:
- Renamed proto definitions and regenerated code
- Updated all store layer methods and database drivers
- Renamed service implementations and API handlers
- Updated cache from workspaceSettingCache to instanceSettingCache

Frontend Changes:
- Renamed service client: workspaceServiceClient → instanceServiceClient
- Updated all React components and state management
- Refactored stores: workspace.ts → instance.ts
- Updated all 32 locale translation files

All tests pass and both backend and frontend build successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 23:35:35 +08:00
boojack d98ee36178 chore: standardize and improve API structure (#5224)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 22:36:51 +08:00
Florian Dewald 5f57f48673 fix(security): validate attachment filenames (#5218) 2025-11-05 08:48:55 +08:00
Steven 1d7efb1580 refactor(web): unify memo stats/filters with context-aware MainLayout
Create unified architecture for memo statistics, filters, and sorting
across all pages (Home, Explore, Archived, Profile) with proper
visibility filtering and consistent data flow.

Key changes:
- Rename HomeLayout → MainLayout to reflect broader usage
- Create useFilteredMemoStats hook for unified stats computation
- Create useMemoFilters/useMemoSorting hooks to eliminate duplication
- Refactor all pages to use unified hooks (~147 lines removed)
- Move Explore route under MainLayout (was sibling before)
- Fix masonry column calculation threshold (1024px → 688px+)

Architecture improvements:
- MainLayout computes filter/stats per route context
- Stats/tags based on same filter as memo list (consistency)
- Proper visibility filtering (PUBLIC/PROTECTED) on Explore
- MemoExplorer/StatisticsView accept stats as required props
- Eliminated optional fallbacks and redundant data fetching

Benefits:
- Single source of truth for stats computation
- Stats remain static (don't change with user filters)
- Reduced code duplication across 4 pages
- Better maintainability and type safety
- Proper security (no private memo leakage on Explore)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 08:46:52 +08:00
Steven d30ff2898f fix(web): correct task checkbox toggling in multi-section memos
Fixed a bug where clicking checkboxes in task lists would toggle the wrong
checkbox when a memo contained multiple sections with separate task lists.

The issue was that TaskListItem was counting tasks only within the immediate
parent list (ul/ol), but the toggleTaskAtIndex function counts all tasks
globally across the entire memo. This caused index misalignment.

Changes:
- Add containerRef to MemoContentContext for proper task scoping
- Pass memoContentContainerRef through context in MemoContent component
- Update TaskListItem to count all tasks within the container scope

This ensures task indices are calculated consistently with the markdown
manipulation logic, fixing checkbox toggling in complex multi-section memos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 09:25:15 +08:00
Claude 9b72963e08 feat: replace HomeSidebar with MemoExplorer and MemoExplorerDrawer components 2025-11-02 23:31:29 +08:00
Claude dc9470f71c feat: implement OAuth state management with CSRF protection and cleanup functionality 2025-11-02 23:23:44 +08:00
Claude fb01b49ecf feat: add VisuallyHidden component and enhance LocationDialog accessibility 2025-11-02 23:02:08 +08:00
Claude 638b22a20d chore: implement InsertMenu with file upload and memo linking functionality 2025-11-02 22:53:07 +08:00
Steven 93964827ad refactor: redesign memo editor action bar UI
- Replace multiple action buttons with unified InsertMenu dropdown
- Consolidate upload, link memo, and location into single + button
- Redesign VisibilitySelector with text-based dropdown UI
- Unify badge styling for location, attachments, and links
  - Consistent height (h-7), padding, gaps, and border styles
  - Secondary foreground text color with hover states
  - Max width with truncation for long content
- Add image thumbnails in attachment badges
- Simplify button hierarchy with ghost variant for save/cancel
- Remove obsolete components (TagSelector, MarkdownMenu, etc.)
- Extract LocationView to separate component for better organization

Fixes #5196
2025-10-31 21:29:58 +08:00
Steven 1ced0bcdbd chore: fix linter 2025-10-31 08:36:12 +08:00
Steven bc1550e926 refactor(api): migrate inbox functionality to user notifications
- Remove standalone InboxService and move functionality to UserService
- Rename inbox to user notifications for better API consistency
- Add ListUserNotifications, UpdateUserNotification, DeleteUserNotification methods
- Update frontend components to use new notification endpoints
- Update store layer to support new notification model

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 08:33:09 +08:00
Steven e915e3a46b refactor(web): refactor MemoFilters component and add comprehensive filter support
- Refactored MemoFilters.tsx for better maintainability:
  * Centralized filter configuration with FILTER_CONFIGS object
  * Added TypeScript interfaces for type safety
  * Removed separate FactorIcon component
  * Extracted handleRemoveFilter function
  * Improved imports organization

- Polished MemoFilters UI styles:
  * Changed to modern pill/badge design with rounded-full
  * Enhanced spacing and color schemes
  * Added smooth transitions and hover effects
  * Improved interactive remove button with destructive color hints
  * Better text readability with font-medium

- Added comprehensive filter support to all pages:
  * Explore page: Added full filter support (was missing)
  * Archived page: Enhanced from basic to full filter support
  * UserProfile page: Enhanced from basic to full filter support
  * All pages now support: content search, tag search, pinned, hasLink, hasTaskList, hasCode, and displayTime filters

- Consistency improvements:
  * All pages using PagedMemoList now have identical filter logic
  * Respects workspace settings for display time (created/updated)
  * Unified filter behavior across Home, Explore, Archived, and UserProfile pages
2025-10-30 22:52:58 +08:00
Steven 1e954070b9 refactor(web): improve memo component skeleton and loading states 2025-10-30 22:25:19 +08:00
Steven 5e47f25bf5 feat(store): add hierarchical tag filtering support
Tag filters now support hierarchical matching where searching for a tag (e.g., "book") will match both the exact tag and any tags with that prefix (e.g., "book/fiction", "book/non-fiction"). This applies across all database backends (SQLite, MySQL, PostgreSQL) with corresponding test updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 00:21:53 +08:00
boojack a2bfba6928 chore: update readme
Added LambdaTest to sponsorship section

Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-10-30 00:16:40 +08:00
Steven f65633e8a9 chore: fix linter issues in backend and frontend
- Remove extra blank line in memo_service.go (goimports)
- Remove invalid fields from CreateMemoRequest call (validateOnly, requestId)
- Clean up unnecessary comments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 23:41:20 +08:00
Steven 243ecf14b0 refactor(api): remove DeleteMemoTag and RenameMemoTag endpoints
BREAKING CHANGE: Removed DeleteMemoTag and RenameMemoTag API endpoints
for better API consistency. Tags should now be managed by updating memo
content directly via UpdateMemo endpoint.

Backend changes:
- Remove RenameMemoTag and DeleteMemoTag RPC methods from proto
- Remove backend implementations in memo_service.go
- Regenerate protocol buffers (Go, TypeScript, OpenAPI)

Frontend changes:
- Remove RenameTagDialog component
- Simplify TagsSection to remove rename/delete functionality
- Improve tag styling with active state highlighting
- Add smooth transitions and better hover interactions
- Polish TagTree component for consistency
- Tags now only support click-to-filter (no inline editing)

Style improvements:
- Active tags highlighted with primary color and font-medium
- Consistent hover states across flat and tree views
- Better spacing and visual hierarchy
- Improved empty state styling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 23:32:47 +08:00
Steven d794c0bf8b feat(web): improve loading performance with skeleton screens and parallel fetches
- Add MemoSkeleton component for smooth initial page load experience
- Integrate skeleton loader into PagedMemoList during initial fetch
- Parallelize user settings and shortcuts API calls (~50% faster session init)
- Batch-fetch memo creators in parallel to eliminate individual loading spinners
- Pass showCreator prop to Explore page for proper skeleton rendering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 23:53:35 +08:00
Steven 2371bbb1b7 feat(web): add quick language and theme switchers to user menu
- Add language and theme selector submenus to UserMenu component for quick access
- Refactor shared utilities: extract THEME_OPTIONS constant and getLocaleDisplayName() function
- Update LocaleSelect and ThemeSelect to use shared utilities, eliminating code duplication
- Make UserMenu reactive with MobX observer for real-time setting updates
- Fix language switching reactivity by immediately updating workspaceStore.state.locale
- Add scrollable menu support for language selector (max-h-[90vh])
- Apply same instant locale update to PreferencesSection for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 22:32:25 +08:00
Steven d693142dd4 feat(web): enhance code blocks with copy button and fix link navigation
Add custom code block renderer with language display and copy functionality. Links now open in new windows, and clicking image links no longer triggers both link navigation and image preview.

- Add CodeBlock component with copy-to-clipboard button and language label
- Configure all markdown links to open in new windows with target="_blank"
- Fix image link behavior to prevent duplicate actions when clicked

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 22:06:07 +08:00
Steven b00df8a9d1 fix(web): remove error notifications for location geocoding failures
Remove disruptive error toasts when reverse geocoding or geolocation fails.
Instead, silently fall back to using coordinates as the location placeholder.
This improves UX for users in regions where OpenStreetMap is restricted
(e.g., China) or when CSP blocks external API calls.

Fixes #5198, #5197

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 21:28:29 +08:00
Steven 5f7c758f38 fix(web): make layout and direction settings reactive in UI
Fixed issue #5194 where changing the layout (List/Masonry) or sort direction
didn't update the UI until page refresh. The root cause was that ViewState
fields weren't marked as MobX observables, so the UI didn't react to changes
even though values were being persisted to localStorage.

Solution: Added constructor to ViewState that marks orderByTimeAsc and layout
fields as observable, following the same pattern used in other stores like
MemoFilterState.

Fixes #5194

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 07:58:15 +08:00
Steven 91a7e927a5 fix(server): reduce static asset cache to prevent stale files after redeploy
Changed Cache-Control max-age from 7 days to 1 hour with immutable directive.
This prevents users from experiencing blank pages or JS errors when accessing
frequently redeployed instances (e.g., demo environments) where old cached
assets may reference files that no longer exist after redeployment.

Since Vite generates content-hashed filenames, the immutable directive prevents
unnecessary revalidation while the shorter cache duration ensures fresh assets
are served within an hour of redeployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 07:55:02 +08:00
Steven 2197178dd7 chore: update README for clarity and consistency 2025-10-28 07:46:45 +08:00
boojack d516f9aab2 chore: tweak readme
Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-10-27 23:41:24 +08:00
Steven f635d85bcf chore: fix reactions seed data
- Add comprehensive inline documentation for auth services
- Document session-based and token-based authentication flows
- Clarify authentication priority and validation logic
- Add detailed comments for JWT token structure and claims
- Fix reactions seed data to use memo UIDs instead of numeric IDs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 20:27:27 +08:00
Claude f241b590a2 chore: update demo data 2025-10-27 09:20:26 +08:00
Claude 596b894ca0 chore: remove unused syntax
- Removed the wikilink extension from markdown services in test and API service.
- Deleted the DefaultLink and WikiLink components, simplifying link handling.
- Updated ConditionalComponent to remove wikilink checks.
- Adjusted MemoContent to exclude wikilink handling in markdown rendering.
- Refined markdown styles for compact rendering, enhancing readability.
- Added a Markdown Styling Guide to document the new compact styling approach.
2025-10-27 08:31:57 +08:00
Claude 7eec424274 chore: remove references handling from markdown extraction 2025-10-27 00:12:24 +08:00
Claude 6cb96ef65e chore: add missing punctuation in comments 2025-10-26 11:58:34 +08:00
Claude 739fd2cde6 refactor: update markdown parser
- Removed the `nodes` field from the `Memo` interface in `memo_service.ts`.
- Updated the `createBaseMemo` function and the `Memo` message functions to reflect the removal of `nodes`.
- Cleaned up the serialization and deserialization logic accordingly.

chore: remove code-inspector-plugin from Vite configuration

- Deleted the `codeInspectorPlugin` from the Vite configuration in `vite.config.mts`.
- Simplified the plugins array to include only `react` and `tailwindcss`.
2025-10-26 11:28:40 +08:00
Steven bfad0708e2 fix(web): make memoFilterStore reactive by marking fields as observable
Fixes the root cause of non-reactive filtering. The MemoFilterState class
was not marking its fields as observable in MobX, so changes to the filters
array were not being tracked.

Added makeObservable configuration to mark:
- filters and shortcut as observable
- addFilter, removeFilter, removeFiltersByFactor, clearAllFilters, setShortcut as actions

This ensures that when tags are clicked and filters are added/removed,
MobX observer components will re-render and fetch new data.

Related to #5189

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 07:01:27 +08:00
Steven e0b1153269 fix(web): resolve MobX observable reactivity issue in filter computation
Fixes filtering functionality that was broken due to improper use of
useMemo with MobX observables. The issue occurred because useMemo's
dependency array uses reference equality, but MobX observable arrays
are mutated in place (reference doesn't change when items are added/removed).

Changes:
- Remove useMemo from filter computation in Home, UserProfile, and Archived pages
- Calculate filters directly in render since components are already MobX observers
- Fix typo: memoFitler -> memoFilter in Archived.tsx

This ensures filters are recalculated whenever memoFilterStore.filters changes,
making tag clicks and other filter interactions work correctly.

Fixes #5189

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 06:59:13 +08:00
Steven 46ce0bc62e fix(store): correct PostgreSQL placeholder generation in IN clauses
Fixes a regression introduced in v0.25.2 where PostgreSQL IN clause
placeholders were not properly incremented, causing all parameters to
use the same placeholder index (e.g., $1, $1, $1 instead of $1, $2, $3).

This bug affected:
- ListReactions (ContentIDList) - caused "failed to list reactions" errors
- ListAttachments (MemoIDList)
- ListMemos (IDList and UIDList)

The fix combines placeholder generation and argument appending into a
single loop to ensure proper incrementing.

Fixes #5188

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 06:47:06 +08:00
Steven 0c0d2a6294 chore: bump version 2025-10-24 08:15:32 +08:00
Claude 455665a16b docs: enhance README with improved feature descriptions and formatting 2025-10-23 22:25:54 +08:00
boojack f2f2a5159c chore: tweak README to enhance sponsorship and clarity
Updated README to include sponsorship details and enhance clarity on features and benefits.

Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-10-23 21:49:15 +08:00
Florian Dewald e4f6345342 feat: generate thumbnails for images stored in S3 and generate thumbnails with a maximum size (#5179) 2025-10-23 21:29:22 +08:00
Claude 16425ed650 feat(web): improve ReactionSelector UX with hover visibility
- Add hover-based visibility for reaction selector in memo cards
- Show reaction selector only on card hover or when popover is open
- Add onOpenChange callback to ReactionSelector for state management
- Reorder action buttons for better visual hierarchy
- Simplify conditional rendering of comment link
2025-10-23 21:20:15 +08:00
Claude 16e0049490 chore: improve layout and spacing in LocationSelector 2025-10-23 20:21:00 +08:00
Claude 7079790d4f feat: add copy content functionality to MemoActionMenu 2025-10-23 20:05:31 +08:00
Claude 686d31b357 feat: enhance attachment store with MobX observables and actions 2025-10-23 19:55:44 +08:00
Claude e35f16306e feat: add id as final tie-breaker in ListMemos 2025-10-23 19:44:41 +08:00
Steven c177871ab4 chore: remove outdated dependabot configuration and agents documentation 2025-10-23 19:16:21 +08:00
Steven f5624fa682 refactor: standardize MobX store architecture with base classes and utilities
Refactored all stores to follow consistent patterns and best practices while keeping MobX:

New Infrastructure:
- Created base-store.ts with StandardState base class and factory functions
- Added store-utils.ts with RequestDeduplicator, StoreError, and OptimisticUpdate helpers
- Added config.ts for MobX configuration and strict mode
- Created comprehensive README.md with architecture guide and examples

Server State Stores (API data):
- attachment.ts: Added request deduplication, error handling, computed properties, delete/clear methods
- workspace.ts: Added Theme type validation, computed memoization, improved initialization
- memo.ts: Enhanced with optimistic updates, request deduplication, structured errors
- user.ts: Fixed temporal coupling, added computed memoization, request deduplication

Client State Stores (UI state):
- view.ts: Added helper methods (toggleSortOrder, setLayout, resetToDefaults), input validation
- memoFilter.ts: Added utility methods (hasFilter, clearAllFilters, removeFiltersByFactor)

Improvements:
- Request deduplication prevents duplicate API calls (all server stores)
- Computed property memoization improves performance
- Structured error handling with error codes
- Optimistic updates for better UX (memo updates)
- Comprehensive JSDoc documentation
- Type-safe APIs with proper exports
- Clear separation between server and client state

All stores now follow consistent patterns for better maintainability and easier onboarding.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 19:13:42 +08:00
dependabot[bot] cce52585c4 chore: bump actions/setup-node from 4 to 5 (#5134)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-22 22:41:45 +08:00
Steven c09ea5a8ab chore: remove basic filter support from ListAttachments method 2025-10-22 22:41:24 +08:00
Steven 2e31bee1de fix: add missing period to comment per golangci-lint 2025-10-22 22:32:24 +08:00
Steven 9121ddbad9 feat(api): support username lookup in GetUser endpoint
- Update GetUser to accept both numeric IDs and username strings (users/{id} or users/{username})
- Implement CEL filter parsing for username-based lookups
- Update proto documentation to reflect dual lookup capability
- Simplify frontend user store to use GetUser instead of ListUsers filter
- Update ListUsers filter documentation to show current capabilities
2025-10-22 21:48:34 +08:00
Steven 4d4325eba5 chore: update component styles for consistency 2025-10-22 20:21:58 +08:00
Steven b4341dd123 fix(web): make reaction selector responsive with scrolling
- Replace fixed width (max-w-56) with responsive viewport-based width
- Use 90vw on mobile and max-w-md on desktop for better adaptability
- Add vertical scrolling with max-h-64 overflow-y-auto
- Increase grid columns to 8 on medium+ screens for better space utilization
- Ensures all reactions are accessible regardless of quantity

Fixes #5117

Co-authored-by: GitHub Copilot <noreply@github.com>
2025-10-22 19:31:58 +08:00
dependabot[bot] 07b523e4b2 chore: bump actions/setup-go from 5 to 6 (#5135)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-22 19:23:39 +08:00
dependabot[bot] 7a58250195 chore: bump actions/stale from 9.1.0 to 10.0.0 (#5136)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-22 19:23:25 +08:00
Simon 290cdf5d4f chore: use input type datetime-local instead of text (#5174) 2025-10-22 09:04:37 +08:00
Steven 2d4361d5fe chore: update memo content formatting and enhance link styling 2025-10-22 08:50:09 +08:00
Steven d8f7a4e739 chore: tweak demo data 2025-10-21 23:41:46 +08:00
Steven 81e852e3b5 chore: update gomark dependency 2025-10-21 23:09:27 +08:00
Steven b19e736f10 chore: tweak demo data 2025-10-20 23:58:49 +08:00
Steven b4ea7d843f feat: enhance memo sorting functionality to support multiple fields 2025-10-20 23:41:58 +08:00
Steven 95de5cc700 refactor: update migration history methods 2025-10-20 23:05:50 +08:00
Johnny bc7decf642 refactor: remove unused constants 2025-10-16 20:40:46 +08:00
Copilot b685ffacdf refactor: memo filter
- Updated memo and reaction filtering logic to use a unified engine for compiling filter expressions into SQL statements.
- Removed redundant filter parsing and conversion code from ListMemoRelations, ListReactions, and ListAttachments methods.
- Introduced IDList and UIDList fields in FindMemo and FindReaction structs to support filtering by multiple IDs.
- Removed old filter test files for reactions and attachments, as the filtering logic has been centralized.
- Updated tests for memo filtering to reflect the new SQL statement compilation approach.
- Ensured that unsupported user filters return an error in ListUsers method.
2025-10-16 09:22:52 +08:00
Steven 228cc6105d chore: update StatCard component for improved icon and count rendering in StatisticsView 2025-10-14 23:15:16 +08:00
Steven 56758f107c chore: refactor ActivityCalendar to use a calendar matrix and improve cell rendering 2025-10-14 21:12:43 +08:00
Steven 5011eb5d70 feat: enhance LocationSelector with improved geolocation handling 2025-10-14 20:23:14 +08:00
Ben Mitchinson 12c4aeeccc feat: lat/long input fields (#5152)
Signed-off-by: Ben Mitchinson <mitchinson.dev@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-14 20:07:01 +08:00
Steven 435cc7b177 feat: implement masonry layout with responsive columns and memo height tracking 2025-10-14 19:46:09 +08:00
Copilot 3245613a88 chore: cleanup components naming 2025-10-12 18:58:37 +08:00
asttool 54e3c13435 refactor: use WaitGroup.Go to simplify code (#5162)
Signed-off-by: asttool <asttool@outlook.com>
2025-10-10 22:28:35 +08:00
Huang Cheng Ting d7e751997d chore: update zh-Hant translation (#5159) 2025-10-09 21:30:55 +08:00
Nic Luckie 20233c7051 feat(web): add accessible ConfirmDialog and migrate confirmations; and Markdown-safe descriptions (#5111)
Signed-off-by: Nic Luckie <nicolasluckie@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-09 00:40:08 +08:00
Johnny 4056a1bada chore: update sponsors 2025-10-08 20:42:15 +08:00
Johnny efe6013c36 fix: add user authentication checks 2025-10-08 20:30:05 +08:00
Claude f6e025d583 feat: implement theme management with system preference detection and early application 2025-10-08 09:51:49 +08:00
dependabot[bot] 3514180721 chore: bump golang.org/x/crypto from 0.41.0 to 0.42.0 (#5141)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:30:37 +08:00
dependabot[bot] 184e975664 chore: bump mobx-react-lite from 4.1.0 to 4.1.1 in /web (#5137)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:28:40 +08:00
dependabot[bot] 1db86bcd30 chore: bump typescript-eslint from 8.44.0 to 8.45.0 in /web (#5138)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:28:31 +08:00
dependabot[bot] 0f19713fce chore: bump github.com/aws/aws-sdk-go-v2/config from 1.31.6 to 1.31.12 (#5139)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:28:25 +08:00
dependabot[bot] ca62b8cd0c chore: bump google.golang.org/protobuf from 1.36.8 to 1.36.9 (#5140)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:28:17 +08:00
dependabot[bot] cca33af8fd chore: bump google.golang.org/grpc from 1.75.0 to 1.75.1 (#5142)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:28:06 +08:00
dependabot[bot] 763a0d0dea chore: bump golang.org/x/mod from 0.27.0 to 0.28.0 (#5143)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:27:58 +08:00
dependabot[bot] 498facdfbe chore: bump lucide-react from 0.486.0 to 0.544.0 in /web (#5144)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:27:48 +08:00
dependabot[bot] 698b08ae8d chore: bump tw-animate-css from 1.3.8 to 1.4.0 in /web (#5145)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 09:27:39 +08:00
dependabot[bot] e3890ca9be chore: bump typescript from 5.9.2 to 5.9.3 in /web (#5146)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 00:14:11 +08:00
boojack bcfcb1b554 docs: add sponsor section for Warp in README (#5127) 2025-10-01 05:25:42 +08:00
Claude c3a961229e fix: update cell opacity colors to use primary theme instead of destructive 2025-09-29 15:01:46 +08:00
github-actions[bot] 074c580214 chore: bump gomark
- Bump protoc-gen-go version from v1.36.8 to v1.36.9 in multiple generated files.
- Enhance OpenAPI descriptions for various endpoints to improve clarity and readability.
- Fix typo in the description list handling in the markdown service.
- Update HTMLElement component to handle children and self-closing tags correctly.
- Modify types in the markdown service to include children and self-closing properties for HTML elements.
2025-09-28 16:15:55 +08:00
Steven 8d8cc83fd8 chore: tweak assets link 2025-09-26 20:47:54 +08:00
Steven 7ab57f8ed2 chore: upgrade frontend dependencies 2025-09-17 21:51:58 +08:00
Steven 6d1485d168 fix: reset state on dialog close and improve dialog open handling 2025-09-17 21:51:46 +08:00
Steven 5ad2038b1a feat: update gomark dependency and refactor markdown parsing logic 2025-09-17 21:09:30 +08:00
Steven b7f792cbf7 chore: tweak development instructions 2025-09-17 08:48:27 +08:00
Steven f44b0850f4 feat: enhance CodeBlock component with theme-aware syntax highlighting 2025-09-16 19:08:28 +08:00
Steven c3d4f8e9d1 feat: implement user-specific SQL converter for filtering in user service 2025-09-10 21:05:26 +08:00
Steven 383553d3c8 feat: add DOMPurify for sanitizing HTML content in CodeBlock component 2025-09-10 20:52:51 +08:00
Giacomo Cerquone f4e23727bb fix: avoid hiding reaction selector and keep it always shown for mobile (#5079) 2025-09-09 11:35:21 +08:00
Giacomo Cerquone 60123de318 fix: update useEffect dependency to location.key so drawer is closed also when re-navigating to current route (#5077) 2025-09-08 16:26:18 +08:00
dependabot[bot] 3be1b3a1e3 chore: bump @radix-ui/react-checkbox from 1.3.2 to 1.3.3 in /web (#5062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 22:57:34 +08:00
dependabot[bot] fb8a997248 chore: bump @radix-ui/react-dropdown-menu from 2.1.15 to 2.1.16 in /web (#5063)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 22:57:11 +08:00
dependabot[bot] b2a259d0ff chore: bump react-router-dom from 7.7.1 to 7.8.2 in /web (#5061)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 21:01:44 +08:00
dependabot[bot] b8fe953833 chore: bump @radix-ui/react-select from 2.2.5 to 2.2.6 in /web (#5065)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 21:01:34 +08:00
Steven 5a1af7b240 fix: adjust badge height 2025-09-02 23:18:11 +08:00
dependabot[bot] 0c516c83bd chore: bump nice-grpc-web from 3.3.7 to 3.3.8 in /web (#5056)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 22:56:34 +08:00
dependabot[bot] 9c67bbbbba chore: bump tw-animate-css from 1.3.6 to 1.3.7 in /web (#5053)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 22:56:23 +08:00
dependabot[bot] 74a44ac3e2 chore: bump actions/checkout from 4 to 5 (#5052)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 22:56:08 +08:00
dependabot[bot] fdbcd20ad1 chore: bump react-hot-toast from 2.5.2 to 2.6.0 in /web (#5051)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 22:55:57 +08:00
dependabot[bot] 56fd9fd7d5 chore: bump @bufbuild/protobuf from 2.6.2 to 2.7.0 in /web (#5057)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 22:21:47 +08:00
dependabot[bot] 9c456c3b72 chore: bump i18next from 25.3.2 to 25.4.2 in /web (#5058)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 22:21:34 +08:00
dependabot[bot] 1954eeb37b chore: bump github.com/spf13/cobra from 0.0.3 to 1.10.1 (#5059)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 22:21:21 +08:00
Johnny ebff15149b fix: use Fprint instead of Fprintf for development mode message 2025-08-31 20:38:07 +08:00
Johnny 3a7538f787 chore: fix linter 2025-08-31 20:29:40 +08:00
Johnny a373673087 chore: go mod tidy 2025-08-31 20:24:18 +08:00
Johnny 7cc2df9254 chore: fix linter 2025-08-31 20:22:32 +08:00
Johnny 3d1c197764 chore: upgrade golangci-lint version to v2.4.0 2025-08-31 19:50:55 +08:00
Johnny 9557150a87 chore: upgrade go version to 1.25 2025-08-31 19:45:41 +08:00
Johnny e427344e82 chore: upgrade dependencies 2025-08-31 13:14:10 +08:00
Johnny 12ab7e9dde chore: tweak docs 2025-08-27 21:35:04 +08:00
Johnny 3ba3f610c9 chore: upgrade typescript-eslint 2025-08-27 19:54:17 +08:00
Johnny 0b4252d1a3 chore: fix linter 2025-08-25 19:52:53 +08:00
Johnny cf29684882 chore: tweak links 2025-08-25 19:51:01 +08:00
Johnny 01c6aca788 chore: update readme with star history section 2025-08-24 22:43:49 +08:00
Xiang Jaywhen 016efe50a2 fix: update the link of Installation Guide (#5024)
Signed-off-by: Xiang Jaywhen <jaywhenx@gmail.com>
2025-08-24 20:24:46 +08:00
varsnotwars e2454a8ae4 chore: add test for ListMemos (#5015) 2025-08-19 21:57:38 +08:00
boojack c5a61ba89e chore: tweak README.md
Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-08-18 22:13:58 +08:00
Steven 0d658088dc chore: bump version to 0.25.1 2025-08-18 20:21:30 +08:00
Steven e07e9f2e9f fix: theme selector 2025-08-18 20:08:53 +08:00
varsnotwars fbcdf0fba7 fix update theme not saving (#5012) 2025-08-18 01:39:48 +00:00
Johnny 01d3f2f86c chore: tweak dark theme 2025-08-17 21:11:54 +08:00
Johnny e93f3cbb8b refactor: unify theme and apperance 2025-08-17 11:27:59 +08:00
varsnotwars 4eb5b67baf feat: attachments by id (#5008) 2025-08-15 22:02:29 +08:00
Amazingca a3add85c95 fix: Update SSO login flow to encode redirect_uri (#5006)
Signed-off-by: Amazingca <autumn@amazingca.dev>
2025-08-15 08:45:19 +08:00
varsnotwars a9508b2546 chore: simplify convert reaction (#5001) 2025-08-14 00:06:23 +08:00
Sara Vieira 51d643c780 chore: default type to text/plain (#4993)
Signed-off-by: Sara Vieira <hey@iamsaravieira.com>
Co-authored-by: boojack <stevenlgtm@gmail.com>
2025-08-13 20:09:02 +08:00
Liu Chao ba7dfb7d98 fix: OpenAPI Parameter Conflict in DeleteMemoTag Endpoint (#4985)
Signed-off-by: ChaoLiu <chaoliu719@gmail.com>
2025-08-13 07:14:18 +08:00
Huang Cheng Ting 5ba81d629c fix: prevent default focus behavior on open and close events in sheet (#4998) 2025-08-13 07:13:45 +08:00
Johnny 76ca258f23 chore: simplify update user settings 2025-08-11 23:19:56 +08:00
Huang Cheng Ting d86756f104 feat: add an option to auto expand subtags in tree mode (#4994) 2025-08-11 23:05:36 +08:00
varsnotwars 3fd305dce7 fix: preferences being overwritten (#4990) 2025-08-11 22:57:52 +08:00
Neo c76ffb0fe4 chore: adds a check to only query reactions when there are actual memos (#4984) 2025-08-10 14:25:35 +08:00
Jason Shawn D' Souza 1dd25634fd fix: Midi files show up as playable (#4991) 2025-08-10 14:23:41 +08:00
Neo 6b507ff600 fix: pinned shortcut comparison operators (#4987) 2025-08-10 14:22:54 +08:00
varsnotwars 9f8921d3b9 fix: preference not being saved (#4980)
Signed-off-by: varsnotwars <varsnotwars@protonmail.com>
Co-authored-by: Johnny <yourselfhosted@gmail.com>
2025-08-08 22:50:46 +08:00
Sara Vieira 1f1add1a48 fix: add mime type check for file types (#4981) 2025-08-08 20:39:16 +08:00
Tobias Waslowski 15c146cfc5 feat(editor): create text-based autocompleting commands (#4971) 2025-08-08 18:55:59 +08:00
varsnotwars f4bdfa28a0 feat: filter/method for reactions by content_id (#4969) 2025-08-08 00:00:51 +08:00
Brandon Sprague 931ddb7c1c chore: add a new LOG_STACKTRACES option (#4973) 2025-08-07 23:56:21 +08:00
Johnny 8576001c83 fix: nl.json
Signed-off-by: Johnny <yourselfhosted@gmail.com>
2025-08-07 14:43:35 +08:00
Stephan Paternotte 1043fc8fba chore: update nl.json (#4974)
Signed-off-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2025-08-07 14:40:53 +08:00
Colin Holzman 8319516d1a fix: boolean filters (#4966) 2025-08-06 21:06:15 +08:00
johnnyjoy 66b4f583a5 fix: linter 2025-08-06 20:22:20 +08:00
aarohee-he 0f2390a0b7 feat: add tooltips to editor buttons (#4934)
Co-authored-by: Anthony Cooper <antcoop096@gmail.com>
Co-authored-by: aarohee-he <aarohee-he@users.noreply.github.com>
Co-authored-by: Mishti Gala <mishtigala19@gmail.com>
2025-08-06 10:15:06 +08:00
boojack 13957472eb chore: merge visibility selector into save button
Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-08-06 00:03:59 +08:00
johnnyjoy a5e441b622 chore: tweak dark mdoe styles 2025-08-05 21:07:59 +08:00
Liu Chao 3427ae75ba fix: resolve gRPC Gateway connection issue when server address is empty (#4968)
Signed-off-by: ChaoLiu <chaoliu719@gmail.com>
2025-08-05 19:42:20 +08:00
johnnyjoy 506b477d50 fix: get user by username 2025-08-04 19:56:12 +08:00
varsnotwars fa2fa8a5d7 refactor: remove call to db for parent memo name (#4947) 2025-08-04 19:39:52 +08:00
Huang Cheng Ting 896a3dccf2 chore: update zh-Hant translation (#4962) 2025-08-04 19:38:58 +08:00
johnnyjoy 34fb3f1514 chore: fix linter 2025-08-03 19:55:04 +08:00
Colin Holzman aae7ec8d1f fix: calendar filter (#4942) 2025-08-03 19:11:09 +08:00
Maximilian Krauß 4d6042e35f fix(tags): ensure JSON array elements are properly formatted in SQL queries (#4944) 2025-08-03 19:10:59 +08:00
XIN_____ 9c60165ba1 chore: update zh-Hans.json (#4940) 2025-08-03 19:10:49 +08:00
Andrea Marchetta 71464779dd fix: change itemCount into an Int64 (#4945) 2025-08-03 19:07:17 +08:00
dependabot[bot] 07e270564a chore: bump golang.org/x/net from 0.40.0 to 0.42.0 (#4948)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 19:05:49 +08:00
dependabot[bot] 94b58f0691 chore: bump google.golang.org/grpc from 1.72.2 to 1.74.2 (#4949)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 19:05:40 +08:00
dependabot[bot] 5a7693c90a chore: bump github.com/google/cel-go from 0.25.0 to 0.26.0 (#4951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 19:05:30 +08:00
dependabot[bot] 11685c1f4d chore: bump github.com/aws/aws-sdk-go-v2/feature/s3/manager from 1.17.82 to 1.18.2 (#4952)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 19:05:22 +08:00
dependabot[bot] a5711e893a chore: bump code-inspector-plugin from 0.18.3 to 1.0.4 in /web (#4954)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 19:05:17 +08:00
johnnyjoy b2a2151eaf chore: bump frontend dependencies 2025-08-03 18:17:21 +08:00
dependabot[bot] 9b06275f4c chore: bump vite from 7.0.1 to 7.0.6 in /web (#4957)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 12:46:05 +08:00
Colin Holzman 671f4bf88b fix: shortcuts list (#4936) 2025-07-31 19:52:13 +08:00
XIN_____ 79c7fcc8b6 chore: update zh-Hans.json (#4933) 2025-07-31 19:50:29 +08:00
Colin Holzman 6005d0f3f7 chore: use MEMOS_INSTANCE_URL for "Copy Link" (#4930) 2025-07-30 18:57:39 +08:00
Steven 1fcafd807e chore: enable memo comments by default 2025-07-29 22:31:18 +08:00
Johnny 5074268f4f fix: action styles in memo editor 2025-07-29 22:10:31 +08:00
Maximilian Krauß e724a7ef72 fix: disable profiler in production mode (#4925) 2025-07-29 19:22:41 +08:00
johnnyjoy 9971cbeed4 chore: fix linter 2025-07-29 19:20:59 +08:00
Johnny 8f51791da8 chore: update user setting definition 2025-07-29 09:21:51 +08:00
Johnny 3b8bfe72ea fix: linter 2025-07-27 23:35:01 +08:00
johnnyjoy c5d497a1f0 chore: update user settings 2025-07-27 23:12:42 +08:00
johnnyjoy 3f56ce47d2 chore: update definition of workspace setting 2025-07-27 15:50:09 +08:00
Anthony Cooper 53d3978507 chore: add hyperlink for 'releases page' (#4914) 2025-07-24 12:51:02 +08:00
Steven c5d2d15278 chore: fix eslint 2025-07-23 23:41:59 +08:00
Steven f78b277462 chore: simplify add memo relation 2025-07-23 23:34:30 +08:00
johnnyjoy 9b5b7b1ea2 Merge branch 'main' of https://github.com/usememos/memos 2025-07-23 22:22:15 +08:00
johnnyjoy c5767bf9d6 fix: eslint 2025-07-23 22:22:12 +08:00
Maximilian Krauß 2c7eb23343 feat(inbox): handles errors while fetching and adds possibility to delete items (#4908) 2025-07-23 14:21:26 +00:00
johnnyjoy db63b1949a chore: fix creator id format 2025-07-23 22:18:47 +08:00
johnnyjoy 9ea2f9f686 fix: linter 2025-07-23 22:13:48 +08:00
johnnyjoy ed23cbc011 refactor: memo filter 2025-07-23 22:10:16 +08:00
Steven 1a75d19a89 fix: memo filter for sqlite 2025-07-22 23:39:52 +08:00
Steven ac386c218d fix: workspace setting keys 2025-07-22 22:59:54 +08:00
Steven c133f5528a fix: workspace setting keys in frontend 2025-07-22 22:45:47 +08:00
johnnyjoy 6e45e9f8b6 refactor: deprecate old filter 2025-07-22 21:25:57 +08:00
johnnyjoy 7098721617 chore: fix linter 2025-07-22 21:15:23 +08:00
johnnyjoy e24f92b719 fix: tests 2025-07-22 21:13:24 +08:00
johnnyjoy b55904a428 feat: support more filter factors 2025-07-22 19:18:08 +08:00
Johnny 1a3fc4d874 fix: media width 2025-07-21 23:55:28 +08:00
johnnyjoy 6d9770b9c8 chore: move filter to filters 2025-07-21 21:52:00 +08:00
johnnyjoy 7481fe10bf chore: remove order by pinned 2025-07-21 21:45:10 +08:00
Johnny 71181b1640 chore: update openapi version 2025-07-20 12:02:16 +08:00
Huang Cheng Ting f675324994 chore: update zh-Hant translation (#4895) 2025-07-18 19:27:03 +08:00
Steven d7ced7b738 fix: code block style in dark mode 2025-07-18 19:25:29 +08:00
Johnny 74769b3332 fix: user state 2025-07-17 22:36:28 +08:00
Johnny e3735d2de3 fix: storage setting 2025-07-17 22:02:40 +08:00
Johnny f09897e4b6 fix: dialog state 2025-07-17 21:58:00 +08:00
Huang Cheng Ting 6119a4b965 chore: update zh-Hant translation (#4889) 2025-07-17 21:24:29 +08:00
Maximilian Krauß c4baba8527 fix(syntax-highlighting): adds missing CSS imports to CodeBlock.tsx (#4890) 2025-07-17 21:23:42 +08:00
Anthony Cooper 1f88e3e2d2 chore: Docker hyperlink in README (#4887) 2025-07-17 12:12:48 +08:00
Johnny 40130350c5 fix: args offset for postgres 2025-07-17 00:05:07 +08:00
Steven 834f92f67c chore: fix linter 2025-07-16 22:24:50 +08:00
Steven 0398df1d21 chore: update migrator comments 2025-07-16 21:59:37 +08:00
Jovane Castro a8db132110 chore(i18n): update hindi translation (#4858) 2025-07-16 21:46:18 +08:00
XIN_____ 84eacebfbb chore: update zh text i18n (#4844) 2025-07-16 21:46:09 +08:00
Jovane Castro 72b5186a56 chore(i18n): update croatian translation (#4859) 2025-07-16 21:45:58 +08:00
Steven 54e6ca37a2 chore: tweak props definition 2025-07-15 23:51:28 +08:00
Steven 8df0dea304 chore: remove fixed theme color 2025-07-15 23:26:07 +08:00
Jovane Castro 7683b26658 chore(i18n): update farsi translation (#4855) 2025-07-15 23:21:16 +08:00
Jovane Castro 4066af989c chore(i18n): update spanish translation (#4854) 2025-07-15 23:20:52 +08:00
Jovane Castro 8ef13468c7 chore(i18n): update hungarian translation (#4860) 2025-07-15 23:20:15 +08:00
Jovane Castro 2d4b5a1044 chore(i18n): update german translation (#4853) 2025-07-15 19:25:33 +08:00
Jovane Castro a21112427b chore(i18n): update french translation (#4856) 2025-07-15 19:25:21 +08:00
Jovane Castro 5a53f7178c chore(i18n): update italian translation (#4861) 2025-07-15 19:25:10 +08:00
Jovane Castro 74d35f7e33 chore(i18n): update japanese translation (#4862) 2025-07-15 19:24:59 +08:00
Jovane Castro 163038d5e0 chore(i18n): update georgian translation (#4863) 2025-07-15 19:24:49 +08:00
Jovane Castro af60f81f96 chore(i18n): update korean translation (#4864) 2025-07-15 18:15:25 +08:00
Jovane Castro c79b114972 chore(i18n): update marathi translation (#4865) 2025-07-15 18:14:41 +08:00
Jovane Castro a95104a0d4 chore(i18n): update norwegian translation (#4866) 2025-07-15 18:14:30 +08:00
Jovane Castro ff38347272 chore(i18n): update dutch translation (#4867) 2025-07-15 18:14:19 +08:00
Jovane Castro 4659b932d0 chore(i18n): update polish translation (#4868) 2025-07-15 18:14:09 +08:00
Jovane Castro d9e13baa37 chore(i18n): update portuguese-Brazil translation (#4869) 2025-07-15 18:13:58 +08:00
Jovane Castro 4551a0c52d chore(i18n): update portuguese translation (#4870) 2025-07-15 18:13:40 +08:00
Jovane Castro bd1f675051 chore(i18n): update slovenian translation (#4871) 2025-07-15 18:13:19 +08:00
Jovane Castro b4046f339e chore(i18n): update swedish translation (#4872) 2025-07-15 18:12:56 +08:00
Jovane Castro d9d63bf174 chore(i18n): update thai translation (#4873) 2025-07-14 23:26:17 +08:00
Jovane Castro d2dcf26104 chore(i18n): update turkish translation (#4874) 2025-07-14 23:26:06 +08:00
Johnny 1c408ff5a9 chore: remove unused main file 2025-07-14 23:18:24 +08:00
Jovane Castro 78387bec83 chore(i18n): update ukrainian translation (#4875) 2025-07-14 23:14:06 +08:00
Dedy F. Setyawan def61a8877 fix(i18n): Improve Indonesian translation (#4849) 2025-07-12 21:29:51 +08:00
Anthony Cooper 02641ef10a chore: hyperlinks in README Prerequisites (#4848) 2025-07-12 21:29:34 +08:00
unuunn 08883b58a7 updated Russian translation (#4840) 2025-07-10 07:38:56 +08:00
unuunn 9eafb9bc26 feat: highlight pinned memos (#4843) 2025-07-10 07:38:33 +08:00
unuunn 452f47c4b9 set HasCode only for CodeBlock (#4842) 2025-07-10 07:37:33 +08:00
Steven 29b683d5db fix: dialog styles 2025-07-09 21:48:02 +08:00
Steven e8862e3ca9 chore: update claude.md 2025-07-09 21:13:54 +08:00
Amar Singh Rathour b779dd2e54 fix: correct avatar URL generation to resolve 404 errors (#4839) 2025-07-09 21:05:52 +08:00
Dedy F. Setyawan e803d7bc0f feat(i18n): update Indonesian (id) translations (#4838) 2025-07-09 18:08:36 +08:00
unuunn 8732575ea4 fix: dayjs - ISO 8601 compatible DATE_TIME_FORMAT (#4829) 2025-07-09 00:16:10 +08:00
XIN_____ 0b1b471ee4 chore: update zh-Hant.json (#4833) 2025-07-08 23:03:35 +08:00
Steven 3ac4a03229 chore: update i18n 2025-07-08 20:33:44 +08:00
Steven c1708df7a2 chore: use select/dropdown instead of popover 2025-07-08 20:25:56 +08:00
Jovane Castro d32924ec48 chore: update i18n for Arabic locale (#4825) 2025-07-08 19:57:50 +08:00
Jovane Castro 1e582baf1c chore: update i18n for Catalan locale (#4826) 2025-07-08 19:57:14 +08:00
Jovane Castro eeb18e5deb chore: update i18n for Czech locale (#4827) 2025-07-08 19:57:08 +08:00
Kevin b074aefc34 chore: enhance site.webmanifest in firefox (#4828)
Signed-off-by: Kevin <16837578+KevinStaude@users.noreply.github.com>
2025-07-08 19:56:46 +08:00
Steven 533591af2b chore: theme in user setting 2025-07-08 19:50:04 +08:00
Johnny f907619752 chore: move frontend store 2025-07-07 22:44:08 +08:00
Johnny 12fdf5292d chore: tweak form style 2025-07-07 22:33:49 +08:00
johnnyjoy 239671eb87 chore: tweak tag style 2025-07-07 22:20:17 +08:00
Johnny e55ed8a9c7 chore: new theme whitewall 2025-07-07 20:13:55 +08:00
Johnny 1b3083b216 feat: themes (#4824) 2025-07-07 20:13:22 +08:00
Johnny 3426a791b6 chore: update tag styles 2025-07-07 08:58:09 +08:00
Johnny fb13d2e63d chore: tweak colors 2025-07-07 08:46:53 +08:00
Johnny d8d88c2505 fix: creator view 2025-07-06 22:51:28 +08:00
Johnny f2e27c16a1 chore: tweak colors 2025-07-06 22:26:54 +08:00
Johnny 240d89fbca feat: migrate dialogs 2025-07-06 22:01:55 +08:00
Jovane Castro f70138535c chore: update i18n for pt-BR text (#4819) 2025-07-06 13:59:50 +08:00
Johnny 35928ce5ba chore: unify colors 2025-07-06 13:55:08 +08:00
Jovane Castro 2e474d37fd chore: update i18n for pt-BR locale (#4817) 2025-07-05 19:32:57 +08:00
dependabot[bot] d60aee7823 chore: bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.80.0 to 1.82.0 (#4806)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 19:32:25 +08:00
Johnny 41fdc7a484 chore: fix frontend linter 2025-07-05 11:44:01 +08:00
Johnny a3de61a141 chore: fix dark mode 2025-07-05 11:40:49 +08:00
XIN_____ cf16795a98 chore: update zh text (#4797) 2025-07-05 11:28:30 +08:00
dependabot[bot] 3c512939a8 chore: bump github.com/go-sql-driver/mysql from 1.9.2 to 1.9.3 (#4807)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 11:27:57 +08:00
Johnny db4b1f5fde chore: tweak styles 2025-07-05 11:26:59 +08:00
Johnny 91be2f68d1 feat: variant colors (#4816) 2025-07-04 23:03:13 +08:00
dependabot[bot] b164c15976 chore: bump @types/node from 22.15.34 to 24.0.8 in /web (#4802)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 22:55:35 +08:00
dependabot[bot] f8fa0985fb chore: bump tailwind-merge from 2.6.0 to 3.3.1 in /web (#4801)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 22:39:25 +08:00
dependabot[bot] 1e09bf9a4f chore: bump vite from 6.3.5 to 7.0.0 in /web (#4805)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 22:39:10 +08:00
dependabot[bot] 17cd19588d chore: bump github.com/aws/aws-sdk-go-v2/feature/s3/manager from 1.17.77 to 1.17.82 (#4808)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 22:34:19 +08:00
dependabot[bot] 49004f230f chore: bump golang.org/x/mod from 0.24.0 to 0.25.0 (#4809)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 22:34:11 +08:00
Steven fda19c2d73 chore: update 2025-07-03 22:33:31 +08:00
Steven 046c6c83e1 chore: fix linter formats 2025-07-03 22:28:45 +08:00
Copilot e3a2dadb4b fix: tag count regression - remove double increment causing off-by-one error (#4800)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: boojack <24653555+boojack@users.noreply.github.com>
2025-07-02 20:17:19 +08:00
Steven 0df3171b64 chore: remove command 2025-07-01 20:14:42 +08:00
boojack 0cabdbe020 chore: rename Popover.tsx to popover.tsx
Signed-off-by: boojack <stevenlgtm@gmail.com>
2025-07-01 19:43:46 +08:00
Steven 49766ed920 chore: update frontend instructions 2025-07-01 19:42:25 +08:00
Johnny 493832aeb4 refactor: unify components 2025-07-01 19:29:48 +08:00
Steven 50a41a39a6 chore: tweak instructions 2025-06-30 19:49:18 +08:00
XIN_____ 98d1238674 chore: update i18n text for zh-hans (#4790) 2025-06-28 16:06:32 +08:00
Steven c18d6927ba chore: update user session table 2025-06-27 00:00:06 +08:00
johnnyjoy 1fffc41f79 feat: sliding expiration for user sessions 2025-06-26 23:03:28 +08:00
XIN_____ e93d695aa8 chore: update i18n for zh text (#4785)
* Update zh-Hans.json

* Update zh-Hant.json

Co-authored-by: @kiss-fire <iamtaoxin@gmail.com>

---------

Co-authored-by: @kiss-fire <iamtaoxin@gmail.com>
2025-06-26 08:44:32 +08:00
Johnny 3e24f8cf62 fix: seed data 2025-06-25 10:07:24 +08:00
Steven 42d1650c6d chore: tweak auth service 2025-06-24 23:31:37 +08:00
Steven 6e1b01cb68 chore: bump version 2025-06-24 23:06:57 +08:00
XIN_____ 3a7995c733 chore: update i18n text for zh-hans and zh-hant (#4784)
chore: update i18n text for zh-hant
2025-06-24 21:59:03 +08:00
johnnyjoy 976bd332fe chore: fix linter 2025-06-24 21:55:27 +08:00
johnnyjoy d6a75bba4c refactor: webhook service 2025-06-24 21:28:21 +08:00
jinjingroad 03399a6007 refactor: use the built-in max/min to simplify the code (#4781)
Signed-off-by: jinjingroad <jinjingroad@sina.com>
2025-06-24 07:12:02 +08:00
Johnny e6e460493c refactor: general user setting 2025-06-23 23:23:57 +08:00
Johnny 9b15936873 refactor: clean unused fields 2025-06-23 22:55:14 +08:00
Johnny 778a5eb184 refactor: memo filter 2025-06-23 22:38:44 +08:00
Johnny 52a5ca2ef4 chore: update get attachment binary 2025-06-23 21:20:59 +08:00
Steven 697e54758d fix: frontend linter 2025-06-23 21:20:37 +08:00
Steven 83febf9928 chore: clean resource definition 2025-06-23 21:08:25 +08:00
Steven 5821bb7a65 chore: update auth service 2025-06-23 20:31:18 +08:00
Steven 59eae4465a chore: update user session section 2025-06-23 20:12:29 +08:00
Johnny b8a37c7229 chore: fix linter 2025-06-23 09:27:06 +08:00
XIN_____ bbd984a754 chore: update i18n for zh (#4780)
* feat: add i18n text for zh

* feat: add i18n text for zh-hant
2025-06-23 09:14:49 +08:00
Johnny 4e3a4e36f6 feat: implement user sessions 2025-06-23 09:13:51 +08:00
Johnny 6e4d1d9100 fix: auth context 2025-06-22 22:58:00 +08:00
johnnyjoy 45df653f37 fix: navigation overflow style 2025-06-22 18:49:12 +08:00
Johnny a2bfa82a88 fix: linter 2025-06-22 14:13:27 +08:00
Johnny 77b7fc4441 feat: implement user session 2025-06-22 12:46:13 +08:00
Johnny 741fe35c2a refactor: rename session methods in auth service 2025-06-21 00:00:58 +08:00
Johnny cb96d4e76d fix: embedded attachment 2025-06-18 23:10:41 +08:00
Steven df52bf5e63 fix: tag count 2025-06-18 22:49:03 +08:00
Steven 35df62a96c chore: remove duplicated prefix from enums 2025-06-18 22:40:54 +08:00
XIN_____ d27639f147 chore: update i18n zh-Hans and zh-Hant (#4778)
* Update zh-Hans.json

* Update zh-Hant.json

Co-authored-by: @kiss-fire <iamtaoxin@gmail.com>

---------

Co-authored-by: @kiss-fire <iamtaoxin@gmail.com>
2025-06-18 20:46:37 +08:00
Steven d71fd2f84a refactor: auth service 2025-06-18 20:25:46 +08:00
Steven 9972a77d9e refactor: memo service 2025-06-18 19:58:38 +08:00
Steven 91c2a4cef9 refactor: inbox service 2025-06-18 19:06:09 +08:00
Steven a4920d464b refactor: attachment service part2 2025-06-18 00:09:19 +08:00
Steven bb5809cae4 refactor: attachment service 2025-06-17 22:15:19 +08:00
Steven 174b1a0361 chore: fix linter 2025-06-17 21:04:28 +08:00
Steven 760c164328 chore: add server tests 2025-06-17 20:56:10 +08:00
Steven f6e5da4499 refator: activity service 2025-06-17 09:00:03 +08:00
Steven 4a20b529df chore: add CLAUDE.md 2025-06-17 08:51:43 +08:00
Steven 8e8e246ab2 chore: add server tests 2025-06-17 00:29:42 +08:00
Steven dac059a7f7 refactor: identity provider service 2025-06-16 23:56:28 +08:00
Steven 8d8e9b3b30 refactor: shortcut service 2025-06-16 23:10:50 +08:00
Steven 1ea4cc453c refactor: workspace setting service 2025-06-16 22:51:26 +08:00
Steven da906c665c chore: tweak workspace service 2025-06-16 22:20:10 +08:00
Steven 3b0c87591f refactor: webhook service 2025-06-16 22:10:43 +08:00
Steven c9ab03e1a0 refactor: user service 2025-06-16 21:49:38 +08:00
Steven 330282d8b2 chore: tweak styles 2025-06-16 20:48:08 +08:00
Johnny f467264e7d chore: remove memopayload runonce 2025-06-16 20:24:57 +08:00
Johnny 38b6c3afb9 chore: fix linter 2025-06-15 10:27:24 +08:00
Johnny 3fd29f6493 refactor: schema migrator 2025-06-15 10:23:18 +08:00
XIN_____ d386b83b7b chore: update i18n text for zh (#4771)
* feat: add i18n text

* feat: add i18n text for zh
2025-06-12 23:09:04 +08:00
Steven c9c07a599c chore: unify action names 2025-06-11 23:39:29 +08:00
Steven efc0dc9aea chore: update trim in Autocomplete 2025-06-11 23:21:48 +08:00
Johnny acb71dfb69 chore: tweak button styles 2025-06-10 22:51:42 +08:00
KidLoveToPlay df8d1d4f3d fix: trim queryText and split by regex (#4766) 2025-06-09 22:13:39 +08:00
Johnny f2a01d9ccb chore: use popover instead of dropdown/menu 2025-06-08 23:21:23 +08:00
Johnny 522da2a114 fix: border color 2025-06-08 00:39:51 +08:00
Johnny f5c64849d2 chore: upgrade tailwindcss to v4 2025-06-07 10:15:12 +08:00
Ezmana a50253d311 chore: add missing lines and French translations (#4761)
Adding missing lines and translations
2025-06-06 19:18:48 +08:00
Steven add099b89d chore: update rollup options 2025-06-05 23:09:15 +08:00
Steven 786c6f672e chore: remove prevent default for enter keydown 2025-06-05 23:08:55 +08:00
boojack cc36422e43 chore: tweak README.md 2025-06-05 09:43:41 +08:00
Steven fe71b01f2d chore: polish readme 2025-06-04 23:10:31 +08:00
johnnyjoy c84dcddbf6 chore: always show visibility label 2025-06-04 22:05:51 +08:00
johnnyjoy 8d9396247b fix: external newline 2025-06-04 21:28:58 +08:00
Maya Doshi efb8e7ab0c fix: broken resource urls in RSS feed (#4753)
fix: resource url generation in rss feed
2025-06-04 09:06:34 +08:00
johnnyjoy 94d1e20570 chore: update date time format 2025-06-03 21:22:14 +08:00
Steven 6d319900d6 fix: date format 2025-06-03 09:24:38 +08:00
Johnny b942643785 chore: tweak seed data 2025-06-02 20:06:45 +08:00
Lincoln Nogueira 44e710ce4a fix: downgrade modernc.org/libc to v1.65.7 (#4745)
`modernc.org/libc` version on go.mod must match whatever is on `modernc.org/sqlite`'s [go.mod](https://gitlab.com/cznic/sqlite/-/blob/v1.37.1/go.mod#L9) file to prevent sqlite-related issues.

See:
  - <https://pkg.go.dev/modernc.org/sqlite#hdr-Fragile_modernc_org_libc_dependency>
  - <https://gitlab.com/cznic/sqlite/-/issues/177>
2025-06-02 18:19:57 +08:00
Johnny d20362e722 chore: tweak stat card 2025-06-02 10:20:11 +08:00
Johnny 1594c1ce3e chore: bump version 2025-06-02 09:39:57 +08:00
Johnny 3343dc73f7 chore: update navigator 2025-06-02 09:39:50 +08:00
Johnny 663e56747f chore: replace switch kit 2025-06-02 01:01:29 +08:00
Johnny ff0f275431 chore: fix golangci-lint version 2025-06-02 00:34:45 +08:00
dependabot[bot] 136c1d7aa6 chore: bump golangci/golangci-lint-action from 7 to 8 (#4744)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 7 to 8.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v7...v8)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 00:32:53 +08:00
dependabot[bot] 7c9215b513 chore: bump i18next from 24.2.3 to 25.2.1 in /web (#4742)
Bumps [i18next](https://github.com/i18next/i18next) from 24.2.3 to 25.2.1.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v24.2.3...v25.2.1)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 00:32:27 +08:00
Johnny d4425a6aa1 chore: upgrade dependencies 2025-06-02 00:27:35 +08:00
Johnny e6fe088ac0 chore: fix missing observer 2025-06-01 09:22:33 +08:00
Johnny fd99e10962 chore: tweak sidebar 2025-06-01 09:13:44 +08:00
Johnny 2595e32f83 chore: simplify date editor 2025-05-31 11:14:47 +08:00
Johnny 673026ffa1 chore: update statistics view 2025-05-31 09:43:24 +08:00
Johnny 376b25a69c chore: fix visibility selector style (#4733) 2025-05-31 09:15:28 +08:00
Steven f1b365f928 refactor: clean packages 2025-05-29 21:44:43 +08:00
Johnny 2bde296217 chore: add demo deployment to Render action 2025-05-29 08:38:01 +08:00
Johnny ea4e7a1606 refactor: memo editor (#4730) 2025-05-29 07:46:40 +08:00
Maximilian Krauß 77d3853f73 fix: inherits memo visibility for default comment visibility (#4728) 2025-05-28 22:18:41 +08:00
Steven a317f9e653 fix: linter 2025-05-28 21:21:27 +08:00
Steven de3e55c2e6 feat: support now() time functions 2025-05-28 21:18:49 +08:00
Steven f5ecb66fb8 feat: implement height-based masonry view 2025-05-28 08:27:15 +08:00
Steven 8520e30721 fix: handle type assertion safely 2025-05-27 23:44:39 +08:00
Steven ef6f80d925 chore: fix linter 2025-05-27 23:22:32 +08:00
Steven 81ae42518e chore: fix linter 2025-05-27 22:49:56 +08:00
Maximilian Krauß eca91d5c0c feat(rss): use server title and description for RSS feed, if configured (#4717) 2025-05-27 22:10:23 +08:00
Steven ad2c5f0d05 refactor: store cache 2025-05-27 22:06:41 +08:00
Steven c23aebd648 refactor: memo filter store 2025-05-27 21:14:18 +08:00
Steven f12d7ae8bc chore: add asynchronous webhook dispatch 2025-05-27 20:01:04 +08:00
Jonathan Fok kan 7c05a9b997 fix(editor): prevent race condition and ensure correct list continuation on Enter (#4716)
* fix(editor): Prevent race condition and ensure correct list continuation on Enter

_

* fix: always insert newline after preventing default Enter key behavior
2025-05-27 19:57:33 +08:00
Johnny d76f988bb5 fix: edit for comment 2025-05-27 08:38:14 +08:00
Steven 785c250f3c refactor: migrate memo store 2025-05-27 08:26:13 +08:00
Steven dcd68bc5f4 refactor: migrate resource store to v2 2025-05-27 07:55:00 +08:00
Steven 46900f9807 refactor: move store prefixes to common module 2025-05-27 07:49:46 +08:00
Steven 46be6b0ff6 fix: missing service handler 2025-05-26 23:22:10 +08:00
Steven b89d8f5342 feat: implement hasTaskList filter 2025-05-26 22:37:59 +08:00
Steven cbf5687dd9 chore: remove unused action 2025-05-26 21:50:36 +08:00
eya46 4924b23884 fix: state mismatch in Storage and System pages (#4719)
* fix: sync storage setting state

* fix: sync customProfile state
2025-05-26 21:50:10 +08:00
Steven 64b27d5a4e refactor: shortcut service 2025-05-26 21:45:14 +08:00
dabeeduu 6964c1df05 fix: make the save button disabled after a successful update (#4721)
* fix: make the save button disabled after a successful update

* fix: fixing formatting error

---------

Co-authored-by: didi <didi@localhost.localdomain>
2025-05-26 14:50:13 +08:00
Brian c6e6b7b93b feat: add infinite scrolling for memos (#4715)
* feat(WIP): add prop-driven infinite scroll

* feat(WIP): add global toggle for infinite scrolling under settings

* feat: integrate newly added scroll-mode selection state with backend for persists across refreshes

* fix: default to infinite scrolling over load more button & remove redundant setting option

* fix: rectify linting issues

* Update web/src/components/PagedMemoList/PagedMemoList.tsx

---------

Co-authored-by: Johnny <yourselfhosted@gmail.com>
2025-05-25 23:12:47 +08:00
Steven 46d5307d7f fix: prevent XSS for specific content types 2025-05-22 00:05:33 +08:00
Dimitris Zervas c2528c57f0 feat: automatically add a new table row in the editor when pressing enter (#4706)
Automatically add a new table row in the editor when pressing enter
2025-05-21 20:11:53 +08:00
Steven 72babbb393 fix: dark mode of highlight.js 2025-05-20 23:58:22 +08:00
johnnyjoy f9c0621c15 refactor: root layout 2025-05-19 22:15:11 +08:00
johnnyjoy ee99afd7a4 chore: enable location by default 2025-05-19 21:25:51 +08:00
Johnny d4ebf293f3 chore: fix format 2025-05-19 10:03:05 +08:00
Jake Howard 0440888571 chore: hide "or" when password auth disabled (#4699)
This makes the UI cleaner, instead only showing the "Sign in with ..." buttons.
2025-05-19 09:59:52 +08:00
Steven 606924b4c9 chore: unify form styles 2025-05-15 22:15:01 +08:00
johnnyjoy f9e07a2245 feat: support update user's role 2025-05-15 19:36:15 +08:00
Simon ead2d70082 fix: remove duplicated “Enable” in settings label (#4692)
remove duplicated “Enable” in settings label
2025-05-15 15:08:50 +08:00
Steven 1f79ee575b fix: create user with avatar url for sqlite 2025-05-14 23:45:02 +08:00
johnnyjoy 21935abe3f chore: update canary build action 2025-05-14 22:24:54 +08:00
johnnyjoy ca79990679 refactor: merge sign in requests 2025-05-14 22:13:52 +08:00
johnnyjoy a0f68895ab chore: add more logs for oauth2 2025-05-14 20:38:14 +08:00
johnnyjoy 733f16816b chore: bump mui 2025-05-13 23:28:15 +08:00
johnnyjoy 43d176f272 fix: idp config convert 2025-05-13 20:40:54 +08:00
johnnyjoy 966a108d52 chore: check signin with sso 2025-05-13 20:36:00 +08:00
Steven fa1ad7dc72 chore: update list padding 2025-05-12 22:39:25 +08:00
啥也不会的帆 f6054b6138 fix: preview image window proportion error (#4689) 2025-05-12 21:32:01 +08:00
Johnny 87798801fc fix: list styles 2025-05-12 09:09:09 +08:00
johnnyjoy 446447a20c chore: update stable build action 2025-05-11 18:04:15 +08:00
Pavel Stržínek 070b392942 chore: Czech locale update (#4685)
* cs locale updated

* cs locale update
2025-05-11 17:45:32 +08:00
Johnny 9c7899781f chore: update alt text of deepwiki badge 2025-05-09 14:58:58 +08:00
johnnyjoy cd5abd9e60 chore: update nsfw description 2025-05-08 21:18:46 +08:00
johnnyjoy ccfa9983e9 fix: user avatar url 2025-05-08 20:28:00 +08:00
mousemin 30f37f6a05 feat: enhance site.webmanifest in chrome (#4676)
Update site.webmanifest
2025-05-07 22:47:55 +08:00
Sergey Gorbunov e23ade1f8b feat: support listening on a UNIX socket (#4654) 2025-05-07 22:12:05 +08:00
johnnyjoy 2a92baf52c fix: filter args of postgres 2025-05-06 21:19:13 +08:00
Johnny 1328e0b861 chore: remove issue-translator 2025-05-03 16:16:01 +08:00
dependabot[bot] c3273f8f97 chore: bump @vitejs/plugin-legacy from 6.0.2 to 6.1.1 in /web (#4661)
Bumps [@vitejs/plugin-legacy](https://github.com/vitejs/vite/tree/HEAD/packages/plugin-legacy) from 6.0.2 to 6.1.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/plugin-legacy/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.1.1/packages/plugin-legacy)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-legacy"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 16:15:21 +08:00
dependabot[bot] 24650140b2 chore: bump @types/node from 22.13.10 to 22.15.3 in /web (#4660)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.13.10 to 22.15.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 16:10:17 +08:00
dependabot[bot] f6b5b26cda chore: bump eslint from 9.23.0 to 9.25.1 in /web (#4663)
Bumps [eslint](https://github.com/eslint/eslint) from 9.23.0 to 9.25.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.23.0...v9.25.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.25.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 16:10:08 +08:00
dependabot[bot] 0ab7082c7c chore: bump mobx from 6.13.6 to 6.13.7 in /web (#4662)
Bumps [mobx](https://github.com/mobxjs/mobx) from 6.13.6 to 6.13.7.
- [Release notes](https://github.com/mobxjs/mobx/releases)
- [Commits](https://github.com/mobxjs/mobx/compare/mobx@6.13.6...mobx@6.13.7)

---
updated-dependencies:
- dependency-name: mobx
  dependency-version: 6.13.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 14:27:06 +08:00
dependabot[bot] 8550176cfa chore: bump golang.org/x/crypto from 0.35.0 to 0.37.0 (#4665)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.35.0 to 0.37.0.
- [Commits](https://github.com/golang/crypto/compare/v0.35.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 14:26:28 +08:00
dependabot[bot] fdcef4d3a7 chore: bump golang.org/x/net from 0.35.0 to 0.39.0 (#4666)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.39.0.
- [Commits](https://github.com/golang/net/compare/v0.35.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 14:26:19 +08:00
dependabot[bot] a266c4c6a5 chore: bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 (#4667)
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 14:17:07 +08:00
dependabot[bot] 0c692c48af chore: bump modernc.org/sqlite from 1.36.0 to 1.37.0 (#4668)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.36.0 to 1.37.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.36.0...v1.37.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 14:16:59 +08:00
dependabot[bot] b5f68576ff chore: bump golang.org/x/mod from 0.23.0 to 0.24.0 (#4669)
Bumps [golang.org/x/mod](https://github.com/golang/mod) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/mod/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 14:16:51 +08:00
boojack 75f5c4c0b2 chore: add deepwiki badge 2025-04-28 13:51:41 +08:00
João Tiago a46301fa77 feat: support child tags in NSFW blur feature (#4640)
* fix: add support for child nsfw tags

* fix: correctly detect child tags of custom NSFW tags

* Update web/src/components/MemoView.tsx

---------

Co-authored-by: Johnny <yourselfhosted@gmail.com>
2025-04-26 16:23:15 +08:00
johnnyjoy 411a91122b fix: linter error 2025-04-24 10:11:35 +08:00
johnnyjoy eb33a604b2 feat: support mapping avatar url from oauth2 2025-04-24 10:07:24 +08:00
Steven e41916408a chore: update dockerfile 2025-04-21 22:26:18 +08:00
Johnny 0f965c6fe0 chore: add action cache 2025-04-21 21:39:09 +08:00
Johnny 33907539cd chore: merge release action 2025-04-21 21:26:46 +08:00
johnnyjoy dc5f37ebba chore: remove unused demo dockfile 2025-04-21 21:16:38 +08:00
Johnny 7e1368028b chore: add dockerfile.demo 2025-04-21 09:02:38 +08:00
Johnny fdc0553cfb chore: move frontend build to action 2025-04-19 23:10:32 +08:00
Simon bb892be5b3 feat: improve visual feedback in MemoEditor for drag/drop file uploads (#4634)
* improve drag/drop file upload UI

* fix eslint errors

* use tailwind for cursor styles

* fix eslint issues
2025-04-18 23:49:59 +08:00
Johnny d38f4699c2 chore: try build arm/v7 2025-04-17 23:28:33 +08:00
Steven 1a121e350b chore: update gitignore 2025-04-17 23:11:36 +08:00
RichardYe 76ed1b5cc5 feat: set locale based on browser language when user is not logged in (#4635)
* feat: set locale based on browser language when user is not logged in

* docs: comments for locale fallback logic
2025-04-17 20:02:41 +08:00
Steven f6e4e504fe chore: remove armv7 2025-04-16 23:05:27 +08:00
Steven aa272670ce chore: upgrade buf 2025-04-16 23:00:11 +08:00
Steven 7475a30fe9 chore: update docker platforms 2025-04-16 22:50:31 +08:00
Steven e2f5c61e30 fix: docker username in actions 2025-04-16 22:39:58 +08:00
Steven 02f8e0da41 chore: update actions 2025-04-16 22:39:15 +08:00
RichardYe ebccef6033 fix: shutdown gRPC server gracefully (#4626)
fix: add graceful shutdown for gRPC server
2025-04-15 22:58:25 +00:00
Steven 789a9cc5a2 fix: padding left of list 2025-04-14 23:00:36 +08:00
Steven a6be658f42 fix: list memo comments 2025-04-14 22:51:11 +08:00
Johnny b94682dc4f chore: fix linter 2025-04-12 22:16:32 +08:00
Johnny 18b9b9d18f chore: implement memo filter in list memo relations 2025-04-12 22:13:18 +08:00
Johnny 08f9b18ced fix: list memo relations 2025-04-12 22:02:13 +08:00
Johnny 2f6dc2b51f fix: padding of list 2025-04-10 23:06:14 +08:00
Johnny 7632c52c0c chore: bump version 2025-04-10 22:52:03 +08:00
Johnny 97b23f46e6 chore: remove resource title 2025-04-09 22:58:53 +08:00
倒霉狐狸 ebf13c4dff chore: caching resource files accelerates second requests (#4614)
* cache assets files

* Apply suggestions from code review

* Update server/router/frontend/frontend.go

* Update server/router/frontend/frontend.go

---------

Co-authored-by: Johnny <yourselfhosted@gmail.com>
2025-04-09 22:22:03 +08:00
Johnny 3cc0e255ce fix: memo filter for postgres 2025-04-08 23:46:08 +08:00
Johnny f5d5ebea6f chore: hide actions for comment 2025-04-06 22:58:43 +08:00
Johnny aa8cf44c41 chore: fix entrypoint.sh 2025-04-06 22:44:19 +08:00
Johnny 9dde9f332f chore: move dockerfile 2025-04-06 22:40:26 +08:00
dependabot[bot] 052d5d41fe chore: bump github.com/grpc-ecosystem/grpc-gateway/v2 from 2.26.1 to 2.26.3 (#4586)
chore: bump github.com/grpc-ecosystem/grpc-gateway/v2

Bumps [github.com/grpc-ecosystem/grpc-gateway/v2](https://github.com/grpc-ecosystem/grpc-gateway) from 2.26.1 to 2.26.3.
- [Release notes](https://github.com/grpc-ecosystem/grpc-gateway/releases)
- [Changelog](https://github.com/grpc-ecosystem/grpc-gateway/blob/main/.goreleaser.yml)
- [Commits](https://github.com/grpc-ecosystem/grpc-gateway/compare/v2.26.1...v2.26.3)

---
updated-dependencies:
- dependency-name: github.com/grpc-ecosystem/grpc-gateway/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 23:08:48 +08:00
dependabot[bot] bec0acc66a chore: bump golang.org/x/oauth2 from 0.27.0 to 0.28.0 (#4587)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.27.0 to 0.28.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.27.0...v0.28.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 22:56:28 +08:00
Steven 3d610a78f2 chore: update golangci config 2025-04-02 22:53:17 +08:00
Steven ec17c4b015 chore: update linter ci 2025-04-02 22:51:22 +08:00
Steven 2cf2126d64 fix: golangci config 2025-04-02 22:47:34 +08:00
Steven 2aca8180d1 chore: update golangci config 2025-04-02 22:16:05 +08:00
Steven 0430de8713 chore: fix golangci version 2025-04-02 22:10:19 +08:00
dependabot[bot] 76737b5542 chore: bump golangci/golangci-lint-action from 6 to 7 (#4584)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 6 to 7.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:17:02 +08:00
dependabot[bot] 39f85e9d68 chore: bump github.com/aws/aws-sdk-go-v2/feature/s3/manager from 1.17.64 to 1.17.70 (#4585)
chore: bump github.com/aws/aws-sdk-go-v2/feature/s3/manager

Bumps [github.com/aws/aws-sdk-go-v2/feature/s3/manager](https://github.com/aws/aws-sdk-go-v2) from 1.17.64 to 1.17.70.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/credentials/v1.17.64...feature/s3/manager/v1.17.70)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/feature/s3/manager
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:16:49 +08:00
dependabot[bot] ebc2cc2970 chore: bump google.golang.org/grpc from 1.70.0 to 1.71.1 (#4588)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.70.0 to 1.71.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.70.0...v1.71.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:16:27 +08:00
dependabot[bot] d1fb602988 chore: bump google.golang.org/protobuf from 1.36.5 to 1.36.6 (#4589)
Bumps google.golang.org/protobuf from 1.36.5 to 1.36.6.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:16:11 +08:00
dependabot[bot] c43762a9b6 chore: bump lucide-react from 0.453.0 to 0.486.0 in /web (#4590)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.453.0 to 0.486.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.486.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:15:58 +08:00
dependabot[bot] 28074c1404 chore: bump nice-grpc-web from 3.3.6 to 3.3.7 in /web (#4591)
Bumps [nice-grpc-web](https://github.com/deeplay-io/nice-grpc) from 3.3.6 to 3.3.7.
- [Release notes](https://github.com/deeplay-io/nice-grpc/releases)
- [Commits](https://github.com/deeplay-io/nice-grpc/compare/nice-grpc-web@3.3.6...nice-grpc-web@3.3.7)

---
updated-dependencies:
- dependency-name: nice-grpc-web
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:15:46 +08:00
dependabot[bot] 2458bc98f2 chore: bump i18next from 24.2.2 to 24.2.3 in /web (#4592)
Bumps [i18next](https://github.com/i18next/i18next) from 24.2.2 to 24.2.3.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v24.2.2...v24.2.3)

---
updated-dependencies:
- dependency-name: i18next
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:15:03 +08:00
dependabot[bot] d98520acba chore: bump @trivago/prettier-plugin-sort-imports from 4.3.0 to 5.2.2 in /web (#4593)
chore: bump @trivago/prettier-plugin-sort-imports in /web

Bumps [@trivago/prettier-plugin-sort-imports](https://github.com/trivago/prettier-plugin-sort-imports) from 4.3.0 to 5.2.2.
- [Release notes](https://github.com/trivago/prettier-plugin-sort-imports/releases)
- [Changelog](https://github.com/trivago/prettier-plugin-sort-imports/blob/main/CHANGELOG.md)
- [Commits](https://github.com/trivago/prettier-plugin-sort-imports/compare/v4.3.0...v5.2.2)

---
updated-dependencies:
- dependency-name: "@trivago/prettier-plugin-sort-imports"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 18:14:46 +08:00
dependabot[bot] 3dac75f48c chore: bump react-datepicker from 7.6.0 to 8.2.1 in /web (#4594)
Bumps [react-datepicker](https://github.com/Hacker0x01/react-datepicker) from 7.6.0 to 8.2.1.
- [Release notes](https://github.com/Hacker0x01/react-datepicker/releases)
- [Commits](https://github.com/Hacker0x01/react-datepicker/compare/v7.6.0...v8.2.1)

---
updated-dependencies:
- dependency-name: react-datepicker
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 09:44:20 +08:00
Steven 4f39e00868 chore: fix lint 2025-04-01 20:06:54 +08:00
Johnny b770042a8a refactor: migrate eslint 2025-04-01 00:04:43 +08:00
cmuangs d649d326ef fix: remove errors.Wrap(nil) (#4576)
* Fix errors.Wrap(nil)

* fix resource_test failure where resource does not exist
2025-03-31 12:56:51 +08:00
Shawn 88b38ff2c0 fix: aws-chunked encoding is not supported with the specified x-amz-content-sha256 value for some s3 providers (#4575)
Co-authored-by: Shawn L. <shawn@sola.sh>
2025-03-31 08:49:48 +08:00
Johnny 09c50a8419 fix: codeblock highlight 2025-03-30 23:49:51 +08:00
johnnyjoy 8e6d9b4943 chore: buf generate 2025-03-30 13:16:42 +08:00
Anubhav Singh a63d9b471b feat: update ItalicNode to use repeated children instead of content (#4566)
* feat: update ItalicNode to use repeated children instead of content

* chore: update gomark dependency to latest version

---------

Co-authored-by: Anubhav Singh <anubhav@mail.techindika.com>
2025-03-28 16:15:03 +08:00
olavlan 8bdd1ec009 feat: add norwegian bokmål (nb) translation (#4561)
* add locale file for norwegian (nb)

* correct spelling
2025-03-27 13:14:12 +08:00
Anubhav Singh 194a49367c fix: incorrect checkbox state after removing checked items #4549 (#4552)
Co-authored-by: Anubhav Singh <anubhav@mail.techindika.com>
2025-03-25 21:59:30 +08:00
1156 changed files with 159168 additions and 53532 deletions
+12
View File
@@ -1 +1,13 @@
web/node_modules
web/dist
.git
.github
build/
tmp/
memos
*.md
.gitignore
.golangci.yaml
.dockerignore
docs/
.DS_Store
+146 -23
View File
@@ -1,39 +1,162 @@
name: Bug Report
description: If something isn't working as expected
labels: [bug]
description: Report reproducible behavior that is broken in the current version
type: Bug
body:
- type: markdown
attributes:
value: |
Before submitting a bug report, please check if the issue is already present in the issues. If it is, please add a reaction to the issue. If it isn't, please fill out the form below.
- type: textarea
Thanks for reporting a bug. Please help maintainers reproduce it on current code.
Before submitting:
- Search [existing issues](https://github.com/usememos/memos/issues) to avoid duplicates.
- Upgrade and retest with the [latest release](https://github.com/usememos/memos/releases/latest), current `main`, or the [demo site](https://demo.usememos.com/).
- Use [Discussions](https://github.com/usememos/memos/discussions) for questions, setup help, or configuration troubleshooting.
Maintainers normally fix bugs in current code and future releases, not older released versions.
- type: checkboxes
id: pre-check
attributes:
label: Describe the bug
description: |
Briefly describe the problem you are having in a few paragraphs.
label: Pre-submission Checklist
options:
- label: I have searched existing issues and confirmed this has not already been reported
required: true
- label: I have reproduced this bug on the latest Memos release, current `main`, or the demo site, or this report is specifically about upgrading to the latest release
required: true
- label: This is a bug report, not a question or configuration request
required: true
- type: dropdown
id: area
attributes:
label: Affected Area
description: Choose the main area where the bug appears.
multiple: true
options:
- Web UI
- API
- Authentication / SSO
- Database / migration
- Resources / attachments
- Search / filters
- Markdown / editor
- Deployment / startup
- Other
validations:
required: true
- type: textarea
- type: dropdown
id: reproduced-on
attributes:
label: Steps to reproduce
description: |
Provide the steps to reproduce the issue.
placeholder: |
1. Go to '...'
2. Click on '....'
3. See error
label: Reproduced On
description: Where did you confirm the issue still happens?
options:
- Latest release
- Current main branch
- Demo site
- Upgrade to latest release
- I cannot test the latest version
validations:
required: true
- type: input
id: version
attributes:
label: |
The version of Memos you're using
description: |
Provide the version of Memos you're using. Please use the following format: `v0.22.0` instead of `stable` or `latest`.
label: Memos Version Tested
description: Report the exact version where you reproduced the bug. Use the version shown in **Settings > System**, the container image tag, the release binary version, or `memos --version`. Please do not write `latest` only.
placeholder: "v0.29.x, ghcr.io/usememos/memos:v0.29.x, main@abcdef, or demo site"
validations:
required: true
- type: textarea
- type: input
id: previous-version
attributes:
label: Screenshots or additional context
description: |
If applicable, add screenshots to help explain your problem. And add any other context about the problem here. Such as the device you're using, etc.
label: Previous Version
description: If this happened after an upgrade, enter the version you upgraded from. Leave blank if not applicable.
placeholder: "v0.28.x, unknown, or not applicable"
- type: textarea
id: environment
attributes:
label: Environment Details
description: Include the deployment, database, client, and network details that apply.
placeholder: |
- Deployment: Docker / binary / source / demo site
- Database: SQLite / PostgreSQL / MySQL
- Browser: Firefox 126 on macOS 15
- Server OS: Ubuntu 24.04
- Reverse proxy: nginx / Caddy / Traefik / none
- Related settings: SSO / SMTP / storage / webhooks / none
validations:
required: true
- type: dropdown
id: frequency
attributes:
label: Reproduction Frequency
description: How reliably can you reproduce this?
options:
- Always
- Often
- Sometimes
- Once
- Unknown
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Provide minimal, numbered steps that start from a clean page/session when possible.
placeholder: |
1. Open ...
2. Click ...
3. Enter ...
4. Wait for ...
5. See ...
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: Current Behavior
description: What happened? Include exact error messages, status codes, or visible UI state if available.
placeholder: The application ...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: What did you expect to happen instead?
placeholder: I expected...
validations:
required: true
- type: textarea
id: regression
attributes:
label: Regression Information
description: If this worked before, describe when it last worked and what changed before it broke.
placeholder: |
This last worked in ...
It started after ...
- type: textarea
id: logs
attributes:
label: Logs
description: Paste relevant server logs, browser console output, or network errors. Remove secrets before submitting.
render: shell
placeholder: |
Paste logs here.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Attach screenshots, screen recordings, sample data, exports, or links to related issues.
placeholder: Drag and drop files here or add any extra context.
+7
View File
@@ -1 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Support
url: https://github.com/usememos/memos/discussions
about: Ask questions or get help in GitHub Discussions — please don't open issues for questions
- name: Documentation
url: https://www.usememos.com/docs
about: Check the documentation before opening an issue
+96 -23
View File
@@ -1,36 +1,109 @@
name: Feature Request
description: If you have a suggestion for a new feature
labels: [enhancement]
description: Propose a product improvement with a clear use case
type: Feature
body:
- type: markdown
attributes:
value: |
Before submitting a feature request, please check if the issue is already present in the issues. If it is, please add a reaction to the issue. If it isn't, please fill out the form below.
- type: textarea
Thanks for suggesting an improvement. Please describe the problem and workflow first so maintainers can evaluate the request.
Before submitting:
- Search [existing feature requests](https://github.com/usememos/memos/issues?q=label%3Aenhancement) to avoid duplicates.
- Use [Discussions](https://github.com/usememos/memos/discussions) for broad ideas, setup help, or questions.
- Keep one feature request focused on one user problem.
- type: checkboxes
id: pre-check
attributes:
label: Describe the solution you'd like
description: |
A clear and concise description of what you want to happen.
placeholder: |
It would be great if [...]
validations:
required: true
- type: dropdown
attributes:
label: Type of feature
description: What type of feature is this?
label: Pre-submission Checklist
options:
- User Interface (UI)
- User Experience (UX)
- label: I have searched existing issues and confirmed this has not already been requested
required: true
- label: This is a feature request, not a bug report or support question
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Choose the main area this request affects.
multiple: true
options:
- Web UI
- Mobile / responsive experience
- API
- Documentation
- Integrations
- Authentication / SSO
- Administration
- Memo editor
- Search / filters
- Tags / organization
- Resources / attachments
- Import / export
- Deployment / operations
- Other
default: 0
validations:
required: true
- type: textarea
id: problem
attributes:
label: Additional context
description: |
What are you trying to do? Why is this important to you?
label: Problem or Use Case
description: What problem does this solve? Describe the user need, not just the proposed UI or API.
placeholder: |
I often need to ...
Today, this is difficult because ...
validations:
required: true
- type: textarea
id: users
attributes:
label: Who Would Use This?
description: Describe the user, role, team, or workflow that would benefit.
placeholder: |
This helps ...
They currently ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the behavior you would like Memos to support. Include UI, API, or configuration details if relevant.
placeholder: |
Memos should ...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Existing workarounds, related tools, or simpler alternatives you considered.
placeholder: |
I tried ...
It does not work well because ...
- type: textarea
id: compatibility
attributes:
label: Compatibility and Migration
description: Note any expected impact on existing users, APIs, storage, permissions, or deployments.
placeholder: |
This should not affect existing memos because ...
Existing API clients may need ...
- type: textarea
id: examples
attributes:
label: Examples
description: Link to screenshots, mockups, API sketches, related apps, or existing issues that show the desired behavior.
placeholder: Drag and drop files here or add links/examples.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any extra context that does not fit above.
placeholder: Any other details.
-20
View File
@@ -1,20 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
commit-message:
prefix: "chore"
directory: "/"
schedule:
interval: "monthly"
- package-ecosystem: npm
commit-message:
prefix: "chore"
directory: "/web"
schedule:
interval: "monthly"
- package-ecosystem: "gomod"
commit-message:
prefix: "chore"
directory: "/"
schedule:
interval: "monthly"
-13
View File
@@ -1,13 +0,0 @@
enabled: true
# Only check PR title
titleOnly: true
types:
- feat
- fix
- refactor
- chore
- docs
- style
- test
+70 -26
View File
@@ -1,47 +1,91 @@
name: Backend Test
name: Backend Tests
on:
push:
branches: [main]
pull_request:
branches:
- main
branches: [main]
paths:
- "go.mod"
- "go.sum"
- "**.go"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: "1.26.2"
jobs:
go-static-checks:
static-checks:
name: Static Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: 1.24
check-latest: true
go-version: ${{ env.GO_VERSION }}
cache: true
cache-dependency-path: go.sum
- name: Verify go.mod is tidy
run: |
go mod tidy -go=1.24
go mod tidy -go=${{ env.GO_VERSION }}
git diff --exit-code
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.5
args: --verbose --timeout=3m
skip-cache: true
go-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
go-version: 1.24
check-latest: true
version: v2.11.3
args: --timeout=3m
tests:
name: Tests (${{ matrix.test-group }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
test-group: [store, server, internal, other]
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Run all tests
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
- name: Pretty print tests running time
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'
cache-dependency-path: go.sum
- name: Run tests
run: |
case "${{ matrix.test-group }}" in
store)
# Run store tests for all drivers (sqlite, mysql, postgres)
go test -v -coverprofile=coverage.out -covermode=atomic ./store/...
;;
server)
go test -v -race -coverprofile=coverage.out -covermode=atomic ./server/...
;;
internal)
go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/...
;;
other)
go test -v -race -coverprofile=coverage.out -covermode=atomic \
./cmd/... ./internal/... ./proto/...
;;
esac
env:
DRIVER: ${{ matrix.test-group == 'store' && '' || 'sqlite' }}
- name: Upload coverage
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: codecov/codecov-action@v5
with:
files: ./coverage.out
flags: ${{ matrix.test-group }}
fail_ci_if_error: false
@@ -1,60 +0,0 @@
name: Build and Push Canary Image
on:
push:
branches: [main]
jobs:
build-and-push-canary-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: stevenlgtm
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
install: true
version: v0.9.1
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
neosmemo/memos
ghcr.io/usememos/memos
flavor: |
latest=false
tags: |
type=raw,value=canary
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v6
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -1,72 +0,0 @@
name: Build and Push Release Image by Branch
on:
push:
branches:
# Run on pushing branches like 'release/1.0.0'.
- "release/*"
jobs:
build-and-push-release-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Extract build args
# Extract version from tag name
# Example: tag name `release/1.0.0` sets up env.VERSION=1.0.0
run: |
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: stevenlgtm
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
install: true
version: v0.9.1
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
neosmemo/memos
ghcr.io/usememos/memos
tags: |
type=semver,pattern={{version}},value=${{ env.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }}
type=raw,value=stable
flavor: |
latest=false
labels: |
org.opencontainers.image.version=${{ env.VERSION }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v6
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -1,71 +0,0 @@
name: Build and Push Release Image by Tag
on:
push:
tags:
# Match stable versions, such as 'v1.0.0'.
- "v*.*.*"
jobs:
build-and-push-stable-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Extract build args
# Extract version number and check if it's an rc version
run: |
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: stevenlgtm
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
install: true
version: v0.9.1
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
neosmemo/memos
ghcr.io/usememos/memos
tags: |
type=semver,pattern={{version}},value=${{ env.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.VERSION }}
type=raw,value=stable
flavor: |
latest=false
labels: |
org.opencontainers.image.version=${{ env.VERSION }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v6
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-45
View File
@@ -1,45 +0,0 @@
name: Build Artifacts
on:
push:
tags:
- "*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: 1.24
check-latest: true
cache: true
- uses: pnpm/action-setup@v4.1.0
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml"
- run: pnpm install
working-directory: web
- run: pnpm release
working-directory: web
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
# either 'goreleaser' (default) or 'goreleaser-pro'
distribution: goreleaser
# 'latest', 'nightly', or a semver
version: latest
args: release --clean --skip=validate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+180
View File
@@ -0,0 +1,180 @@
name: Build Canary Image
on:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.repository }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4.2.0
with:
version: 11.0.1
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml"
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('web/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-store-
- run: pnpm install --frozen-lockfile
working-directory: web
- name: Run frontend build
run: pnpm release
working-directory: web
- name: Upload frontend artifacts
uses: actions/upload-artifact@v6
with:
name: frontend-dist
path: server/router/frontend/dist
retention-days: 1
build-push:
needs: build-frontend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Resolve version from latest tag
id: version
run: |
version="$(git describe --tags --abbrev=0 2>/dev/null || echo dev)"
echo "version=${version#v}" >> "$GITHUB_OUTPUT"
- name: Download frontend artifacts
uses: actions/download-artifact@v7
with:
name: frontend-dist
path: server/router/frontend/dist
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./scripts/Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
COMMIT=${{ github.sha }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
outputs: type=image,name=neosmemo/memos,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v6
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: build-push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v7
with:
pattern: digests-*
merge-multiple: true
path: /tmp/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
neosmemo/memos
ghcr.io/usememos/memos
flavor: |
latest=false
tags: |
type=raw,value=canary
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'neosmemo/memos@sha256:%s ' *)
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
- name: Inspect images
run: |
docker buildx imagetools inspect neosmemo/memos:canary
docker buildx imagetools inspect ghcr.io/usememos/memos:canary
+17
View File
@@ -0,0 +1,17 @@
name: Demo Deploy
on:
workflow_dispatch:
jobs:
deploy-demo:
runs-on: ubuntu-latest
steps:
- name: Trigger Render Deploy
run: |
curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK }}" \
-H "Content-Type: application/json" \
-d '{"trigger": "github_action"}'
- name: Deployment Status
run: echo "Demo deployment triggered successfully on Render"
+60 -34
View File
@@ -1,50 +1,76 @@
name: Frontend Test
name: Frontend Tests
on:
push:
branches: [main]
pull_request:
branches:
- main
branches: [main]
paths:
- "web/**"
jobs:
static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4.1.0
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml"
- run: pnpm install
working-directory: web
- name: Run eslint check
run: pnpm lint
working-directory: web
- name: Run type checks
run: pnpm type-check
working-directory: web
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
frontend-build:
env:
NODE_VERSION: "24"
PNPM_VERSION: "11.0.1"
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4.1.0
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
with:
version: 9
- uses: actions/setup-node@v4
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: "web/pnpm-lock.yaml"
- run: pnpm install
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run lint
working-directory: web
run: pnpm lint
- name: Run unit tests
working-directory: web
run: pnpm test
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Build frontend
working-directory: web
- name: Run frontend build
run: pnpm build
working-directory: web
-18
View File
@@ -1,18 +0,0 @@
name: Issue Translator
on:
issue_comment:
types: [created]
issues:
types: [opened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: usthe/issues-translate-action@v2.7
with:
IS_MODIFY_TITLE: false
# not require, default false, . Decide whether to modify the issue title
# if true, the robot account @Issues-translate-bot must have modification permissions, invite @Issues-translate-bot to your project or use your custom bot.
CUSTOM_BOT_NOTE: Issue is not in English. It has been translated automatically.
# not require. Customize the translation robot prefix message.
+17 -10
View File
@@ -1,33 +1,40 @@
name: Protobuf Linter
name: Proto Linter
on:
push:
branches: [main]
pull_request:
branches:
- main
branches: [main]
paths:
- "proto/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-protos:
lint:
name: Lint Protos
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup buf
uses: bufbuild/buf-setup-action@v1
with:
github_token: ${{ github.token }}
- name: buf lint
- name: Run buf lint
uses: bufbuild/buf-lint-action@v1
with:
input: "proto"
- name: buf format
input: proto
- name: Check buf format
run: |
if [[ $(buf format -d) ]]; then
echo "Run 'buf format -d'"
echo "❌ Proto files are not formatted. Run 'buf format -w' to fix."
exit 1
fi
+37
View File
@@ -0,0 +1,37 @@
name: Release Please
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- name: Check release token
env:
RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
if [ -z "$RELEASE_PLEASE_TOKEN" ]; then
echo "RELEASE_PLEASE_TOKEN must be set to a fine-grained PAT so release-please tags can trigger release.yml." >&2
exit 1
fi
- name: Run release-please
uses: googleapis/release-please-action@v4
with:
# Use a fine-grained PAT so generated tags trigger release.yml.
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+384
View File
@@ -0,0 +1,384 @@
name: Release
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
GO_VERSION: "1.26.2"
NODE_VERSION: "24"
PNPM_VERSION: "11.0.1"
ARTIFACT_RETENTION_DAYS: 60
ARTIFACT_PREFIX: memos
jobs:
prepare:
name: Extract Version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
major_minor: ${{ steps.version.outputs.major_minor }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
steps:
- name: Extract version
id: version
env:
REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "tag=" >> "$GITHUB_OUTPUT"
echo "version=manual-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
echo "major_minor=" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ ! "$REF_NAME" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)(-rc\.[0-9]+)?$ ]]; then
echo "Unsupported release tag format: $REF_NAME" >&2
exit 1
fi
version="${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
major_minor="${BASH_REMATCH[1]%.*}"
is_prerelease=false
if [ -n "${BASH_REMATCH[2]}" ]; then
is_prerelease=true
fi
echo "tag=${REF_NAME}" >> "$GITHUB_OUTPUT"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "major_minor=${major_minor}" >> "$GITHUB_OUTPUT"
echo "is_prerelease=${is_prerelease}" >> "$GITHUB_OUTPUT"
build-frontend:
name: Build Frontend
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT"
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('web/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-pnpm-store-
- name: Install dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Build frontend release assets
working-directory: web
run: pnpm release
- name: Upload frontend artifacts
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: server/router/frontend/dist
retention-days: 1
build-binaries:
name: Build ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }}
needs: [prepare, build-frontend]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: arm
goarm: "7"
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: windows
goarch: amd64
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Download frontend artifacts
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: server/router/frontend/dist
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
run: |
output_name="memos"
if [ "$GOOS" = "windows" ]; then
output_name="memos.exe"
fi
mkdir -p build
go build \
-trimpath \
-ldflags="-s -w -X github.com/usememos/memos/internal/version.Version=${{ needs.prepare.outputs.version }} -X github.com/usememos/memos/internal/version.Commit=${{ github.sha }} -extldflags '-static'" \
-tags netgo,osusergo \
-o "build/${output_name}" \
./cmd/memos
- name: Package binary
env:
VERSION: ${{ needs.prepare.outputs.version }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
run: |
cd build
package_name="${ARTIFACT_PREFIX}_${VERSION}_${GOOS}_${GOARCH}"
if [ -n "$GOARM" ]; then
package_name="${package_name}v${GOARM}"
fi
if [ "$GOOS" = "windows" ]; then
artifact_name="${package_name}.zip"
zip -q "${artifact_name}" memos.exe
else
artifact_name="${package_name}.tar.gz"
tar czf "${artifact_name}" memos
fi
echo "artifact_name=${artifact_name}" >> "$GITHUB_ENV"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.artifact_name }}
path: build/${{ env.artifact_name }}
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
checksums:
name: Generate Checksums
needs: [prepare, build-binaries]
runs-on: ubuntu-latest
steps:
- name: Download binary artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: ${{ env.ARTIFACT_PREFIX }}_*
merge-multiple: true
- name: Generate checksums
working-directory: artifacts
run: sha256sum * > checksums.txt
- name: Upload checksum artifact
uses: actions/upload-artifact@v4
with:
name: checksums
path: artifacts/checksums.txt
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
release:
name: Publish GitHub Release
needs: [prepare, build-binaries, checksums]
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download binary artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: ${{ env.ARTIFACT_PREFIX }}_*
merge-multiple: true
- name: Download checksum artifact
uses: actions/download-artifact@v4
with:
name: checksums
path: artifacts
- name: Publish release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }}
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
files: artifacts/*
build-push:
name: Build Image ${{ matrix.platform }}
needs: [prepare, build-frontend]
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm/v7
- linux/arm64
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Download frontend artifacts
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: server/router/frontend/dist
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./scripts/Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
COMMIT=${{ github.sha }}
cache-from: type=gha,scope=release-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=release-${{ matrix.platform }}
outputs: type=image,name=neosmemo/memos,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-images:
name: Publish Release Image Tags
needs: [prepare, build-push]
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
pattern: digests-*
merge-multiple: true
path: /tmp/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
version="${{ needs.prepare.outputs.version }}"
if [ "${{ needs.prepare.outputs.is_prerelease }}" = "true" ]; then
docker buildx imagetools create \
-t "neosmemo/memos:${version}" \
-t "ghcr.io/usememos/memos:${version}" \
$(printf 'neosmemo/memos@sha256:%s ' *)
exit 0
fi
docker buildx imagetools create \
-t "neosmemo/memos:${version}" \
-t "neosmemo/memos:${{ needs.prepare.outputs.major_minor }}" \
-t "neosmemo/memos:stable" \
-t "ghcr.io/usememos/memos:${version}" \
-t "ghcr.io/usememos/memos:${{ needs.prepare.outputs.major_minor }}" \
-t "ghcr.io/usememos/memos:stable" \
$(printf 'neosmemo/memos@sha256:%s ' *)
- name: Inspect images
run: |
docker buildx imagetools inspect neosmemo/memos:${{ needs.prepare.outputs.version }}
if [ "${{ needs.prepare.outputs.is_prerelease }}" = "true" ]; then
exit 0
fi
docker buildx imagetools inspect neosmemo/memos:stable
+13 -6
View File
@@ -1,17 +1,24 @@
name: Close Stale Issues
name: Close Stale
on:
schedule:
- cron: "0 */8 * * *"
- cron: "0 */8 * * *" # Every 8 hours
jobs:
stale:
close-stale:
name: Close Stale Issues and PRs
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9.1.0
- name: Mark and close stale issues and PRs
uses: actions/stale@v10.1.1
with:
# Issues: mark stale after 14 days of inactivity, close after 3 more days
days-before-issue-stale: 14
days-before-issue-close: 7
days-before-issue-close: 3
# Pull requests: mark stale after 14 days of inactivity, close after 3 more days
days-before-pr-stale: 14
days-before-pr-close: 3
+14 -2
View File
@@ -4,8 +4,10 @@ tmp
# Frontend asset
web/dist
# build folder
build
# Build artifacts
build/
bin/
memos
.DS_Store
@@ -19,3 +21,13 @@ dist
# VSCode settings
.vscode
# Git worktrees
.worktrees/
# Local pnpm store (project-scoped, created when --config.store-dir is set
# without an existing store; contains a symlink back to the workspace).
.pnpm-store/
# Frontend test coverage output (Vitest + @vitest/coverage-v8).
web/coverage/
+86 -76
View File
@@ -1,9 +1,7 @@
run:
timeout: 10m
version: "2"
linters:
enable:
- errcheck
- goimports
- revive
- govet
- staticcheck
@@ -16,76 +14,88 @@ linters:
- forbidigo
- mirror
- bodyclose
disable:
- errcheck
settings:
exhaustive:
explicit-exhaustive-switch: false
staticcheck:
checks:
- all
- -ST1000
- -ST1003
- -ST1021
- -QF1003
revive:
# Default to run all linters so that new rules in the future could automatically be added to the static check.
enable-all-rules: true
rules:
# The following rules are too strict and make coding harder. We do not enable them for now.
- name: file-header
disabled: true
- name: line-length-limit
disabled: true
- name: function-length
disabled: true
- name: max-public-structs
disabled: true
- name: function-result-limit
disabled: true
- name: banned-characters
disabled: true
- name: argument-limit
disabled: true
- name: cognitive-complexity
disabled: true
- name: cyclomatic
disabled: true
- name: confusing-results
disabled: true
- name: add-constant
disabled: true
- name: flag-parameter
disabled: true
- name: nested-structs
disabled: true
- name: import-shadowing
disabled: true
- name: early-return
disabled: true
- name: use-any
disabled: true
- name: exported
disabled: true
- name: unhandled-error
disabled: true
- name: if-return
disabled: true
- name: max-control-nesting
disabled: true
- name: redefines-builtin-id
disabled: true
- name: package-comments
disabled: true
gocritic:
disabled-checks:
- ifElseChain
govet:
settings:
printf: # The name of the analyzer, run `go tool vet help` to see the list of all analyzers
funcs: # Run `go tool vet help printf` to see the full configuration of `printf`.
- common.Errorf
enable-all: true
disable:
- fieldalignment
- shadow
forbidigo:
forbid:
- pattern: 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
- pattern: 'ioutil\.ReadDir(# Please use os\.ReadDir)?'
issues:
exclude:
- Rollback
- fmt.Printf
linters-settings:
goimports:
# Put imports beginning with prefix after 3rd-party packages.
local-prefixes: github.com/usememos/memos
revive:
# Default to run all linters so that new rules in the future could automatically be added to the static check.
enable-all-rules: true
rules:
# The following rules are too strict and make coding harder. We do not enable them for now.
- name: file-header
disabled: true
- name: line-length-limit
disabled: true
- name: function-length
disabled: true
- name: max-public-structs
disabled: true
- name: function-result-limit
disabled: true
- name: banned-characters
disabled: true
- name: argument-limit
disabled: true
- name: cognitive-complexity
disabled: true
- name: cyclomatic
disabled: true
- name: confusing-results
disabled: true
- name: add-constant
disabled: true
- name: flag-parameter
disabled: true
- name: nested-structs
disabled: true
- name: import-shadowing
disabled: true
- name: early-return
disabled: true
- name: use-any
disabled: true
- name: exported
disabled: true
- name: unhandled-error
disabled: true
- name: if-return
disabled: true
- name: max-control-nesting
disabled: true
- name: redefines-builtin-id
disabled: true
gocritic:
disabled-checks:
- ifElseChain
govet:
settings:
printf: # The name of the analyzer, run `go tool vet help` to see the list of all analyzers
funcs: # Run `go tool vet help printf` to see the full configuration of `printf`.
- common.Errorf
enable-all: true
disable:
- fieldalignment
- shadow
forbidigo:
forbid:
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
- 'ioutil\.ReadDir(# Please use os\.ReadDir)?'
formatters:
enable:
- goimports
settings:
goimports:
local-prefixes:
- github.com/usememos/memos
-36
View File
@@ -1,36 +0,0 @@
version: 1
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
builds:
- main: ./bin/memos
binary: memos
goos:
- linux
- darwin
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
checksum:
disable: true
release:
draft: true
replace_existing_draft: true
make_latest: true
mode: replace
skip_upload: false
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.30.0-rc.2"
}
+125
View File
@@ -0,0 +1,125 @@
# AGENTS.md
Repository instructions for AI coding agents. Keep this file short, concrete, and tied to commands that actually work in this
repo. If a fact here conflicts with source files or CI config, trust the source file and update this guide.
## Project Snapshot
Memos is a self-hosted note-taking app.
- Backend: Go 1.26.2, Echo v5, Connect RPC, gRPC-Gateway, Protocol Buffers.
- Frontend: React 19, TypeScript 6, Vite 8, Tailwind CSS v4, React Query v5.
- Storage: SQLite, MySQL, PostgreSQL.
- Generated API outputs: `proto/gen/` for Go/OpenAPI, `web/src/types/proto/` for TypeScript.
## Working Rules
- Read relevant code before editing; prefer local patterns over new abstractions.
- Keep diffs scoped. Do not do repo-wide cleanup, dependency churn, or generated-file rewrites unless the task requires it.
- Do not hand-edit generated proto outputs. Change `.proto` files, then run `buf generate`.
- Add migrations for all database drivers when schema changes, and update each driver's `LATEST.sql`.
- Add public API endpoints to `server/router/api/v1/acl_config.go`.
- Ask before adding heavy dependencies, changing auth/token behavior, or altering Docker/release workflows.
## Commands
Run from the repository root unless a command starts with `cd`.
```bash
# Backend
go run ./cmd/memos --port 8081 # Start backend dev server
go test ./... # Run all Go tests
go test -v ./store/... # Store tests, including DB drivers via TestContainers
go test -v -race ./server/... # Server tests with race detector
go test -v -race ./internal/... # Internal package tests with race detector
go test -v -run TestFoo ./pkg/... # Run matching Go tests
go mod tidy -go=1.26.2 # Match CI tidy check
golangci-lint run # Go lint, config: .golangci.yaml
golangci-lint run --fix # Auto-fix lint, including goimports
# Frontend
cd web && pnpm install # Install dependencies
cd web && pnpm dev # Dev server on :3001, proxying API to :8081
cd web && pnpm lint # Type check + Biome lint
cd web && pnpm test # Vitest unit tests
cd web && pnpm build # Production build
cd web && pnpm release # Build SPA into server/router/frontend/dist
# Protocol Buffers
cd proto && buf generate # Regenerate Go + TypeScript + OpenAPI
cd proto && buf lint # Lint proto files
cd proto && buf format -w # Format proto files
```
## Code Map
| Path | Purpose |
| --- | --- |
| `cmd/memos/main.go` | Cobra/Viper CLI setup and server startup |
| `server/server.go` | Echo HTTP server and background runner wiring |
| `server/auth/` | JWT access tokens, refresh tokens, PAT handling |
| `server/router/api/v1/` | Connect/gRPC-Gateway services, ACL config, SSE hub |
| `server/router/frontend/` | Static SPA serving |
| `server/router/fileserver/` | Native HTTP file serving, thumbnails, range requests |
| `server/runner/` | Background memo processing and S3 presign refresh |
| `store/` | Store facade, cache, migrations, driver interface |
| `store/db/{sqlite,mysql,postgres}/` | Database-specific drivers and SQL |
| `proto/api/v1/` | Public API service definitions |
| `proto/store/` | Internal storage proto messages |
| `internal/` | App-private packages: scheduler, cron, email, CEL filter, markdown, idp, S3 |
| `web/src/connect.ts` | Connect RPC clients, auth interceptor, access-token refresh |
| `web/src/auth-state.ts` | Token storage and BroadcastChannel cross-tab sync |
| `web/src/hooks/` | React Query hooks for server state |
| `web/src/contexts/` | React context for client/UI state |
| `web/src/components/` | Radix/Tailwind UI components and feature components |
| `web/src/themes/` | CSS themes using OKLch color tokens |
## Change Routing
| Change | Update | Verify |
| --- | --- | --- |
| Go service or router behavior | Service code under `server/`, tests near package | `go test -v -race ./server/...` |
| Store or migration behavior | `store/`, all three DB driver migrations, `LATEST.sql` | `go test -v ./store/...` |
| Internal package logic | Relevant `internal/` package tests | `go test -v -race ./internal/...` |
| Frontend behavior | Components/hooks/contexts under `web/src/` | `cd web && pnpm lint && pnpm test` |
| Frontend production output | Vite config or release-sensitive UI | `cd web && pnpm build` or `pnpm release` |
| Proto API | `.proto` source plus generated outputs | `cd proto && buf generate && buf lint` |
| Public unauthenticated route | `server/router/api/v1/acl_config.go` | Targeted server test or manual route check |
## Go Conventions
- Wrap errors with `errors.Wrap(err, "context")` from `github.com/pkg/errors`; do not use `fmt.Errorf`.
- Return service errors with `status.Errorf(codes.X, "message")`.
- Keep imports grouped as stdlib, third-party, then `github.com/usememos/memos`; goimports is run by golangci-lint.
- Add doc comments for exported identifiers; godot enforces exported comment punctuation.
- Avoid package-level mutable state unless the surrounding package already uses that pattern.
## Frontend Conventions
- Use `@/` for absolute imports.
- Follow Biome formatting: 2-space indent, double quotes, semicolons, 140-character line width.
- Put server data in React Query hooks under `web/src/hooks/`; keep UI-only state in contexts or component state.
- Use Tailwind CSS v4 utilities, `cn()` for class merging, and CVA for variants.
- Reuse Radix primitives and existing components before adding new UI primitives.
- Keep generated proto TypeScript under `web/src/types/proto/` out of manual edits and Biome rewrites.
## Database And Proto Rules
- Schema changes require SQLite, MySQL, and PostgreSQL migrations plus `LATEST.sql` updates.
- Fresh-install SQL and incremental migrations must stay equivalent.
- Proto field changes must preserve compatibility unless the task explicitly allows a breaking API change.
- Regenerate after proto edits and include both Go/OpenAPI and TypeScript generated outputs.
## Verification Policy
- Run the narrowest relevant checks while iterating.
- Before finishing, run the checks that match the changed surface from "Change Routing".
- For docs-only changes, `git diff --check` is sufficient unless the docs include runnable examples that should be tested.
- If a required check cannot run locally, report the reason and the exact command that remains.
## CI Reference
- Backend CI: Go 1.26.2, `go mod tidy -go=1.26.2`, golangci-lint v2.11.3, test groups `store`, `server`, `internal`, `other`.
- Frontend CI: Node 24, pnpm 11.0.1, `pnpm lint`, `pnpm test`, `pnpm build`.
- Proto CI: `buf lint` and `buf format` check.
- Docker: `scripts/Dockerfile`, Alpine 3.21 runtime, non-root user, port 5230, multi-arch amd64/arm64/arm/v7.
+230
View File
@@ -0,0 +1,230 @@
# Changelog
## [0.30.0-rc.2](https://github.com/usememos/memos/compare/v0.30.0-rc.1...v0.30.0-rc.2) (2026-07-20)
### Highlights
* **Memo detail and navigation:** Redesigned the memo detail sidebar as a property rail with quick actions, editable visibility, memo metadata, attachments, relations, and a scroll-aware outline. Feed scroll position is now restored when navigating back from another page. ([d4b5a16](https://github.com/usememos/memos/commit/d4b5a1695b9acf4da3ef72f600789a4a0f90e4d5)) ([e497895](https://github.com/usememos/memos/commit/e497895bf3b72cdae92e5bab6cd5b691c8eedc15))
* **Settings and access tokens:** Refreshed settings navigation and expanded the access-token page with practical guidance, a copyable API example, safety notes, and last-used timestamps. The About page and project documentation now surface the Memos Web Clipper. ([715306e](https://github.com/usememos/memos/commit/715306ea66306a91765d3f7c3ede8bc815d2def5)) ([06ecef3](https://github.com/usememos/memos/commit/06ecef33bc7e6bafc263b2dee71ffd290ab3dc42)) ([f946974](https://github.com/usememos/memos/commit/f94697496b60f5fe590c2ffa19e3f0c93b749af4))
* **Faster feeds and Explore:** Reduced initial work and bandwidth by caching fingerprinted assets, deferring media and rich renderers until needed, rendering feed content near the viewport, and sharing user and memo lookups across creators, reactions, comments, mentions, and relations. ([88c6ee8](https://github.com/usememos/memos/commit/88c6ee8ebcc341aa1d2e2ae38638a518f4745de1)) ([6c1055f](https://github.com/usememos/memos/commit/6c1055f483c92c2a95193bea07110b6775359651)) ([249b331](https://github.com/usememos/memos/commit/249b331596fd1c9ceceb4e72da8e422499be683f))
* **Demo experience:** Rebuilt the demo data around representative public and protected memos that showcase nested tags, references, comments, mentions, reactions, locations, attachments, and rich Markdown. ([469c995](https://github.com/usememos/memos/commit/469c995cc04b5e7de259156d28c58b948e85d111))
### Fixes and polish
* **API compatibility and errors:** Aligned new resource IDs and annotations with AIP conventions while preserving legacy UUID-based identifiers, corrected canonical user-stat resource names, and removed internal RPC prefixes from user-facing error messages. ([84776cc](https://github.com/usememos/memos/commit/84776cc106745278371e5a8c8d43abca6d38e8b5)) ([b7d5d09](https://github.com/usememos/memos/commit/b7d5d09f8a7445076811d6f53b98d3445ce3afac)) ([0bfeb91](https://github.com/usememos/memos/commit/0bfeb91d50fdd4bafd15c13bb02b3e2810d4b2e4))
* **Interface:** Corrected radio-group indicator alignment and positioned tooltip arrows outside their content panels. ([#6097](https://github.com/usememos/memos/issues/6097)) ([2d01420](https://github.com/usememos/memos/commit/2d01420c23372d5708904ec78e03485edb44d49b)) ([#6095](https://github.com/usememos/memos/issues/6095)) ([773e56e](https://github.com/usememos/memos/commit/773e56e537657508af4e357f9c8845af4d4f15e8))
## [0.30.0-rc.1](https://github.com/usememos/memos/compare/v0.29.1...v0.30.0-rc.1) (2026-07-14)
### ⚠ BREAKING CHANGES
* **Access control:** Instances without `--instance-url` (or `MEMOS_INSTANCE_URL`) now run in private mode. Anonymous visitors are sent to sign-in and anonymous API access is limited to setup, authentication, and shared-memo routes. Set the instance URL to retain the previous public behavior. ([d1cef7a](https://github.com/usememos/memos/commit/d1cef7a9ab23e8c93f76b2f32661c720c733536d))
* **Filters:** `now()` has been replaced by the `now` timestamp variable, and time fields now use CEL timestamps. Update saved shortcuts to use expressions such as `created_ts >= now - duration("24h")` or `timestamp(<epoch>)` instead of comparing time fields with bare epoch values. ([26f4b73](https://github.com/usememos/memos/commit/26f4b73cb9a996f9232daad2b5daa34360742697))
* **MCP:** The MCP server is now a stateless, tools-only endpoint generated from the OpenAPI schema. The previous prompts, resources, tool-filtering headers and route aliases, and unprefixed tool names have been removed. Clients that used them must switch to `/mcp` and the new service-prefixed tool names. ([#6026](https://github.com/usememos/memos/issues/6026))
### Highlights
* **Markdown editor:** Rebuilt the editor as a single CodeMirror 6 decorated-source editor. Markdown is preserved verbatim while headings, formatting, tags, and mentions are styled in place. The editor also adds tag completion, list indentation, and a toggleable formatting toolbar with headings, lists, task lists, code blocks, bold, italic, strikethrough, and inline code. ([5a73d7d](https://github.com/usememos/memos/commit/5a73d7d3e56f853a965207ac709e8f5e84918959)) ([e3c231f](https://github.com/usememos/memos/commit/e3c231fcac5691f9590a5d685bdebec23b73be35))
* **MCP:** Replaced the hand-written MCP implementation with a curated OpenAPI-driven tool surface that reuses the public API's authentication and authorization. The final toolset includes memo, comment, relation, reaction, shortcut, identity, and attachment operations, including attachment uploads. ([777d227](https://github.com/usememos/memos/commit/777d227eb992c4feccd5d78c781cf8e9094e38de)) ([0e1d821](https://github.com/usememos/memos/commit/0e1d821fb84310feff40b90d899403d45bced596))
* **Deployment configuration:** Identity providers and supported instance settings can be supplied as validated JSON files under `/etc/secrets`. File-backed settings act as runtime overrides and cannot be changed through the UI or API until the files are removed. ([0038295](https://github.com/usememos/memos/commit/0038295bbc772b38425b6c7f9ca814e4d1e44260))
* **Memo feeds:** Added persisted controls for one, two, three, or automatically fitted columns; compact mode; and link previews. Multi-column feeds stay balanced as cards resize, while newly created memos remain easy to find above pinned content. ([177d65a](https://github.com/usememos/memos/commit/177d65a90e321fa975a7cf19bd3c075143af5c10)) ([e3e4ae1](https://github.com/usememos/memos/commit/e3e4ae10512f514f71729779b5096d0d591c8cf4))
* **Webhooks:** Added Standard Webhooks HMAC-SHA256 signing secrets, webhook editing, and a signing-status indicator. Secrets are generated server-side, shown after creation, and can be revealed later from the edit dialog; malformed secrets now fail validation. ([#6013](https://github.com/usememos/memos/issues/6013)) ([#6027](https://github.com/usememos/memos/issues/6027))
* **Filters:** Expanded CEL shortcuts with string matching, regular expressions, collection predicates, timestamp accessors, set operations, and an untagged-memos example. ([f0e4a56](https://github.com/usememos/memos/commit/f0e4a5624f4371e08fb0c41510891c9f8cce6ba0)) ([b787bfa](https://github.com/usememos/memos/commit/b787bfa75f74d8ee5ec271bd308c2e5a0389ed35))
* **Tag settings:** Tag colors and content-blur rules are now per-user. Existing instance tag settings are copied to current users during migration. ([#6017](https://github.com/usememos/memos/issues/6017))
* **Internationalization:** Added a searchable locale picker, expanded European locale coverage, and completed missing Japanese and Simplified Chinese translations. ([4183985](https://github.com/usememos/memos/commit/418398587cef90151745ba0dbc51cef4762045ca)) ([a47d049](https://github.com/usememos/memos/commit/a47d04954e75c54491138601c72f1c0cbe140f3c))
* **Interface:** Refreshed the sign-in, sign-up, and About pages and aligned common controls across the app. Compact rendering is now opt-in in single-column feeds, so full memo content is shown by default. ([564da94](https://github.com/usememos/memos/commit/564da949cb87491ca8e493d2f7aedcd2ef9545a1)) ([cafa56f](https://github.com/usememos/memos/commit/cafa56f1a8c9091062a4b220284e8ca697b050e8))
* **Markdown and storage:** Added GFM footnote rendering and navigation, plus an `insecure_skip_tls_verify` option for S3-compatible storage with self-signed certificates. ([1020060](https://github.com/usememos/memos/commit/10200606db24e3d70fb8efefee99c7b0a369ddea)) ([#6039](https://github.com/usememos/memos/issues/6039))
### Fixes and polish
* **Authentication and setup:** Stabilized SSO option loading, added OAuth client-auth auto-detection, and distinguished fresh instances from populated instances that no longer have an admin. ([6c17e87](https://github.com/usememos/memos/commit/6c17e87cf61cd9c62ea4ef4e25d4adb5603ffc88)) ([96cb653](https://github.com/usememos/memos/commit/96cb65320b714ef8d7993ddb7c9182350ee9da4c))
* **Editor:** Improved formatting toggles and keyboard shortcuts, file paste and drop handling, focus-mode layout, caret behavior in empty space, and CodeMirror spacing. ([c349c15](https://github.com/usememos/memos/commit/c349c1549e49950e3171b7de28faba5d89249cfa)) ([#6076](https://github.com/usememos/memos/issues/6076)) ([#6093](https://github.com/usememos/memos/issues/6093))
* **Memos and comments:** Preserved expanded task-list state, loaded every comment page, and included the parent memo relation in comment webhook payloads. ([ecbe2ab](https://github.com/usememos/memos/commit/ecbe2ab7977fcd3521aeae1226e816a9dc2a6a40)) ([#6083](https://github.com/usememos/memos/issues/6083))
* **Tags:** Tags inside links are no longer parsed as memo tags, literal tags can be escaped with a backslash, and tag names support Unicode combining marks. ([a50ce09](https://github.com/usememos/memos/commit/a50ce09e8159836aed1a97fa717488283378200f)) ([#6051](https://github.com/usememos/memos/issues/6051))
* **API and security:** Cross-origin API requests now work with bearer tokens while cookie authentication remains same-origin, and `ListUsers` now returns consistent paginated results. ([385fa22](https://github.com/usememos/memos/commit/385fa22056c51a42a0eb99fd08a8876009c2c52d)) ([4bc3928](https://github.com/usememos/memos/commit/4bc39280290b0f2612ff52285689854b6cf1344b))
* **Interface:** Replaced mismatched memo skeletons with a delayed spinner, improved mobile control spacing, and truncated long location labels without hiding the full address. ([#6047](https://github.com/usememos/memos/issues/6047)) ([3b601b8](https://github.com/usememos/memos/commit/3b601b841670ece4a2832341a99ccc74124a745e))
* **Container:** Prevented the entrypoint from restarting indefinitely when `MEMOS_UID=0`. ([#6061](https://github.com/usememos/memos/issues/6061))
## [0.29.1](https://github.com/usememos/memos/compare/v0.29.0...v0.29.1) (2026-06-04)
### Bug Fixes
* **markdown:** keep task item content in one grid column ([7c3bff4](https://github.com/usememos/memos/commit/7c3bff4e98223fb99a5d49f093e3cd57dd140ba4))
* support &lt;meta name=description&gt; in link previews ([#6000](https://github.com/usememos/memos/issues/6000)) ([e8d32e8](https://github.com/usememos/memos/commit/e8d32e87d1d6e4927250ad5794ba5965e0260153))
* **web:** render video attachment posters on mobile ([0e2a9a9](https://github.com/usememos/memos/commit/0e2a9a9c0ce0e2da63535210c553b3ae2f79b239))
## [0.29.0](https://github.com/usememos/memos/compare/v0.28.0...v0.29.0) (2026-05-27)
### Features
* **about:** add about page with bird sprites ([411ba7b](https://github.com/usememos/memos/commit/411ba7b34c0c23ad217a7897835097887a28a036))
* **activity-calendar:** aggregate by ViewContext.timeBasis ([8daef1d](https://github.com/usememos/memos/commit/8daef1dc89544512fff78fd64d8216b2babc2e42))
* add &lt;Placeholder&gt; component with ASCII bird states ([#5949](https://github.com/usememos/memos/issues/5949)) ([8c16ffa](https://github.com/usememos/memos/commit/8c16ffa1f1410f9413cb57ebe251b4b604f9aeff))
* add configurable `--log-level` flag ([#5934](https://github.com/usememos/memos/issues/5934)) ([f1e2a06](https://github.com/usememos/memos/commit/f1e2a06b46c7f45ba6562c308d2703fdfb4067b0))
* add dedicated shortcuts page ([#5942](https://github.com/usememos/memos/issues/5942)) ([1df6479](https://github.com/usememos/memos/commit/1df6479443c334732b862d60a0f76e61f00ebb89))
* add link metadata endpoints ([9c5c604](https://github.com/usememos/memos/commit/9c5c604944428d3f682f35c9d05a87c1d4c3152f))
* **frontend:** add pixel bird tilemaps ([cf55f11](https://github.com/usememos/memos/commit/cf55f1107273c928e7c2d6bc26df5f3efea26458))
* **memo:** add task list quick actions ([#5983](https://github.com/usememos/memos/issues/5983)) ([648b3bd](https://github.com/usememos/memos/commit/648b3bd812e5bf2b1abb9f8b3529efefe4734e7c))
* **memo:** create memos on the selected calendar date ([#5925](https://github.com/usememos/memos/issues/5925)) ([ef55013](https://github.com/usememos/memos/commit/ef55013418d68e2d6a24a6d02a0365c1ffff03d7))
* **notification:** add smtp email settings ([cd4f28a](https://github.com/usememos/memos/commit/cd4f28ae1058a125e245ea0a6eae16aea71b8f9d))
* **placeholder:** add woodpecker tilemap ([638e4f3](https://github.com/usememos/memos/commit/638e4f398e90c556f70af150a79538312c8fc760))
* render link metadata cards ([0bc5669](https://github.com/usememos/memos/commit/0bc56694b0ca347ab1eb083f62997a22007b763d))
* **stats:** admin instance resource statistics ([ea0625d](https://github.com/usememos/memos/commit/ea0625da45a419e08158ab4102051f3fe5e59b87))
* **stats:** support filtered all-user stats ([88ac3ec](https://github.com/usememos/memos/commit/88ac3ec31ee3e808db82663c04a31cf730d1221e))
* **transcription:** explicit STT settings with provider, model, prompt ([#5926](https://github.com/usememos/memos/issues/5926)) ([238f27d](https://github.com/usememos/memos/commit/238f27dea149492a78a8994470b0aac55fece78d))
### Bug Fixes
* avoid update event on memo create attachments ([#5961](https://github.com/usememos/memos/issues/5961)) ([3c3382a](https://github.com/usememos/memos/commit/3c3382a3c652bed3b58058931ff3cbf1d875b9a8))
* delete user cleanup ([#5981](https://github.com/usememos/memos/issues/5981)) ([e53b7d9](https://github.com/usememos/memos/commit/e53b7d96e70965529ba3b04ea720ea6966f77b60))
* **editor:** wrap selected text when pasting URL ([e0bb3a2](https://github.com/usememos/memos/commit/e0bb3a2e684e932309df2f9fb0ff774ce8e6b692))
* **fileserver:** preserve HDR image metadata in thumbnails ([c724232](https://github.com/usememos/memos/commit/c7242324a18962e453e7f2a0309a7c07b358bdbe))
* **frontend:** correct static cache headers ([084f40b](https://github.com/usememos/memos/commit/084f40bc9e5922ff2d41c08dce837b96696437aa))
* **frontend:** use correct url path for memos in sitemap.xml ([#5921](https://github.com/usememos/memos/issues/5921)) ([603781f](https://github.com/usememos/memos/commit/603781f792b2603fbda93146988a64a93c92d760))
* **httpgetter:** prevent DNS rebinding in link metadata fetch ([078488c](https://github.com/usememos/memos/commit/078488ca818626fbcab69bdfb4b93b58dca6b6eb))
* **markdown:** align list items with checkboxes ([e008b1a](https://github.com/usememos/memos/commit/e008b1a23c77945eff87707eb15578d4d6d74e4c))
* **memo:** enforce parent visibility for comments ([4a1e401](https://github.com/usememos/memos/commit/4a1e401bd99c7850ac48e9693b25c9ebeecfb042))
* **s3presign:** preserve motion media payload ([7f1f53f](https://github.com/usememos/memos/commit/7f1f53ffc417962f6930d1e6e3783eacd911003d))
* **security:** enforce attachment ownership on memo updates ([35bf761](https://github.com/usememos/memos/commit/35bf761b8c2c3f155bdc06e9b373b6076116a725))
* **sse:** stream initial response and refresh tokens ([21303e8](https://github.com/usememos/memos/commit/21303e879d2c7a857306206d2cbdae83f61983e6))
* **user:** omit internal settings from list responses ([#5917](https://github.com/usememos/memos/issues/5917)) ([1df3fe7](https://github.com/usememos/memos/commit/1df3fe79559ccf94b6c71e9ffb58e870ed43820d))
* **web:** sync avatar changes immediately after profile update ([#5903](https://github.com/usememos/memos/issues/5903)) ([328396a](https://github.com/usememos/memos/commit/328396a97f2219592032e8418c383e0ab7edaca6))
### Performance Improvements
* lazy load heavy first-screen dependencies ([#5947](https://github.com/usememos/memos/issues/5947)) ([a6024ee](https://github.com/usememos/memos/commit/a6024eebf129c4b8fb65ee0cba09c6657274bfde))
## [0.28.0](https://github.com/usememos/memos/compare/v0.27.1...v0.28.0) (2026-04-27)
### Features
* **auth:** add SSO user identity linkage ([#5883](https://github.com/usememos/memos/issues/5883)) ([d688914](https://github.com/usememos/memos/commit/d688914b2864791eeadbf21c882608632875f17c))
* **memos:** choose created or updated time for memos ([#5894](https://github.com/usememos/memos/issues/5894)) ([c268551](https://github.com/usememos/memos/commit/c268551a16929a2cbea6891951feff91926bba59))
* redesign account and SSO management ([#5886](https://github.com/usememos/memos/issues/5886)) ([ee17998](https://github.com/usememos/memos/commit/ee1799851e88674a6920c7a56d93428fcf95e662))
### Bug Fixes
* **auth:** harden authorization and username validation ([#5890](https://github.com/usememos/memos/issues/5890)) ([0fb83a7](https://github.com/usememos/memos/commit/0fb83a745dd5057ade45a3caad2c444af2239113))
* disable modal prop on DropdownMenu to prevent scroll disappearing ([#5861](https://github.com/usememos/memos/issues/5861)) ([d98f665](https://github.com/usememos/memos/commit/d98f6659190b8d1a8252e64549d9120d85e05d33))
* fix legacy username auth flows ([#5885](https://github.com/usememos/memos/issues/5885)) ([30c0611](https://github.com/usememos/memos/commit/30c0611a82f9254952a74650095105254f2940e4))
* **markdown:** split mixed task and bullet lists ([e2c6084](https://github.com/usememos/memos/commit/e2c60845eaff9a78b8d8eb3ccc9a067ef5690976))
* reduce list memo query overhead ([#5880](https://github.com/usememos/memos/issues/5880)) ([5063804](https://github.com/usememos/memos/commit/50638040f618b02b0c6d010e1d41554c75067517))
* **web:** preserve task checkbox state ([#5867](https://github.com/usememos/memos/issues/5867)) ([b5863d7](https://github.com/usememos/memos/commit/b5863d76be3cfbf3e0f8237d8e762122b5a0a679))
## [0.27.1](https://github.com/usememos/memos/compare/v0.27.0...v0.27.1) (2026-04-19)
### Bug Fixes
* mixed-case user resource names ([#5853](https://github.com/usememos/memos/issues/5853)) ([01be01f](https://github.com/usememos/memos/commit/01be01f4b7676af41bdd1758b1e9b096aa922546))
* **release:** inject build version into artifacts ([f8a304b](https://github.com/usememos/memos/commit/f8a304bae33086320b39095d631288156eec4249))
* user resource names can be uuidv4 from idp sub claim ([#5856](https://github.com/usememos/memos/issues/5856)) ([bbded58](https://github.com/usememos/memos/commit/bbded584ce85a856d863485768e08b53adec7244))
## [0.27.0](https://github.com/usememos/memos/compare/v0.26.2...v0.27.0) (2026-04-18)
### Features
* add --allow-private-webhooks flag to bypass SSRF protection ([#5694](https://github.com/usememos/memos/issues/5694)) ([cd5816c](https://github.com/usememos/memos/commit/cd5816c428931f56561f7d48b649a33858812539))
* add blur_content attribute to tag metadata settings ([#5767](https://github.com/usememos/memos/issues/5767)) ([45b2153](https://github.com/usememos/memos/commit/45b21530d9ebbc730e77bcbcf407f4c295b52e93))
* add Gemini transcription provider ([#5830](https://github.com/usememos/memos/issues/5830)) ([d87539a](https://github.com/usememos/memos/commit/d87539a1e1261590ab355220e33fa0ae00bd9431))
* add GitHub release installer and release workflow ([1ed542c](https://github.com/usememos/memos/commit/1ed542c21db421e9aa0cc47417a92c6bd103e257))
* add live refresh via Server-Sent Events (SSE) with visual indicator ([#5638](https://github.com/usememos/memos/issues/5638)) ([ea0892a](https://github.com/usememos/memos/commit/ea0892a8b26d8806a093e3a942562dfc22794f68))
* add MCP server with PAT authentication ([47d9414](https://github.com/usememos/memos/commit/47d9414702dc18966af385352b960bfe451511b7))
* add outline navigation to memo detail sidebar ([#5771](https://github.com/usememos/memos/issues/5771)) ([6b30579](https://github.com/usememos/memos/commit/6b3057990396f9cb6f21706c2e99dddebc35ffd5))
* **ai:** add BYOK audio transcription ([#5832](https://github.com/usememos/memos/issues/5832)) ([101704c](https://github.com/usememos/memos/commit/101704c8eac17c7f34508d8db6c53bc972061cdb))
* **ai:** add instance AI providers and transcription ([#5829](https://github.com/usememos/memos/issues/5829)) ([83ed32f](https://github.com/usememos/memos/commit/83ed32f1195841d2d6c057c3e00086a3147879a2))
* **attachments:** add Live Photo and Motion Photo support ([#5810](https://github.com/usememos/memos/issues/5810)) ([4b4e719](https://github.com/usememos/memos/commit/4b4e719470184e49cd62084b1aa53c9a777a9fec))
* **cli:** add version subcommand ([#5731](https://github.com/usememos/memos/issues/5731)) ([0ba4c0f](https://github.com/usememos/memos/commit/0ba4c0f397c532a5a314f35ddeb3ce3633e84d42))
* **editor:** add voice note recording to the memo composer ([#5801](https://github.com/usememos/memos/issues/5801)) ([c0d5854](https://github.com/usememos/memos/commit/c0d5854f678f357cf4054d448e0311f2ee90f8ac))
* extract title from first H1 heading into memo property ([#5726](https://github.com/usememos/memos/issues/5726)) ([1e688b2](https://github.com/usememos/memos/commit/1e688b2a5d78c2972f4b0078a7b033dae5d1cbf3))
* **i18n:** update sse connect label in Chinese ([#5732](https://github.com/usememos/memos/issues/5732)) ([89c6902](https://github.com/usememos/memos/commit/89c69028953fa33dbb6e0e257d764636c0fc2d09))
* **instance:** add canonical tag metadata setting ([#5736](https://github.com/usememos/memos/issues/5736)) ([65d14fb](https://github.com/usememos/memos/commit/65d14fbb63053fc1fbf1f63881c81adbc9d01671))
* **instance:** add notification transport setting ([#5737](https://github.com/usememos/memos/issues/5737)) ([a249d06](https://github.com/usememos/memos/commit/a249d06e2e0f33e0e3fea34471a891352dacc466))
* **mcp:** enhance MCP server with full capabilities and new tools ([#5720](https://github.com/usememos/memos/issues/5720)) ([b8e9ee2](https://github.com/usememos/memos/commit/b8e9ee2b26a59e1b19aab6db3ca39656fc18785a))
* **mcp:** harden tool exposure and side effects ([#5850](https://github.com/usememos/memos/issues/5850)) ([583c3d2](https://github.com/usememos/memos/commit/583c3d24f4d785faa5e034c1b88ed90eda119baa))
* **mcp:** refactor MCP server to standard protocol structure ([803d488](https://github.com/usememos/memos/commit/803d488a5f8f55477cd3ad4cc4cf0fac98901dd3))
* **memo-editor:** add compact live waveform recorder panel ([#5817](https://github.com/usememos/memos/issues/5817)) ([e51985a](https://github.com/usememos/memos/commit/e51985a29ffeeecd318b2fb793358ed45bb0eff4))
* **memo-preview:** support comment metadata in previews ([#5768](https://github.com/usememos/memos/issues/5768)) ([e176b28](https://github.com/usememos/memos/commit/e176b28c801cc3bb3718208a96a7d64c36620d0f))
* **memo:** add image sharing in detail view ([38fc22b](https://github.com/usememos/memos/commit/38fc22b7541b8a9ddcd848cf77054fcf844eb87f))
* **memo:** add share links for private memos ([#5742](https://github.com/usememos/memos/issues/5742)) ([3f3133d](https://github.com/usememos/memos/commit/3f3133d6e2f404061e147f9dd2424680dc0303a3))
* **mentions:** add memo mention parsing, notifications, and rendering ([#5811](https://github.com/usememos/memos/issues/5811)) ([24fc8ab](https://github.com/usememos/memos/commit/24fc8ab8ca68d092e7b12a5d51c48ca9420f72b5))
* replace auto-increment ID with UID for identity provider resource names ([#5687](https://github.com/usememos/memos/issues/5687)) ([92d937b](https://github.com/usememos/memos/commit/92d937b1aa87365152edc005a3b736e6bf9b9e45))
* show inline comment preview in list view ([3a5d3c8](https://github.com/usememos/memos/commit/3a5d3c8ff92ae5c24559dc4574300d6475820a87))
* **store:** change default storage type to local filesystem ([78efa68](https://github.com/usememos/memos/commit/78efa6802e2fd96c981e86c5b658fb4b5c05091f))
* treat tag setting keys as anchored regex patterns ([#5759](https://github.com/usememos/memos/issues/5759)) ([9e04049](https://github.com/usememos/memos/commit/9e04049632e63f1cf53535773f170c1aa5af7168))
* **ui:** allow navigating between images with arrows in preview dialog ([#5669](https://github.com/usememos/memos/issues/5669)) ([104d2ec](https://github.com/usememos/memos/commit/104d2ec0a6973e983c46724165fdc0916515ad16))
* **user:** add per-user tag metadata settings ([#5735](https://github.com/usememos/memos/issues/5735)) ([330291d](https://github.com/usememos/memos/commit/330291d4d9fa95649e940001898929b4f6f0367e))
* **web:** add demo mode banner ([#5836](https://github.com/usememos/memos/issues/5836)) ([35504cc](https://github.com/usememos/memos/commit/35504cc8bd6f9921291fb565633d549388a3f948))
* **webhook:** dispatch webhook on memo comment creation ([7c1defb](https://github.com/usememos/memos/commit/7c1defba01fbc91cc81e1a0841cecb6738056db7))
### Bug Fixes
* access token refresh on web app ([#5681](https://github.com/usememos/memos/issues/5681)) ([3010f10](https://github.com/usememos/memos/commit/3010f10eafb49af5aadd71a94b3ef7a6ec71f617))
* add unix socket file permission setting (755 -&gt; 660) ([#5849](https://github.com/usememos/memos/issues/5849)) ([0fc1dab](https://github.com/usememos/memos/commit/0fc1dab28b33f7fdbe9a21cd3bd3affe75dd7f19))
* **api:** appease image size lint ([ff6389a](https://github.com/usememos/memos/commit/ff6389a5ef73772fcb5c132bf1cb169fb4744c00))
* **api:** improve SSE hub design and fix double-broadcast on comments ([c53677f](https://github.com/usememos/memos/commit/c53677fcba202a8eed35c8ecdfaacea5152b8691))
* **api:** make credentials write-only and restrict sensitive settings to admins ([9d3a74b](https://github.com/usememos/memos/commit/9d3a74bcccf934aa2fa95ee240b56cc3b3a25776))
* **api:** reduce memory pressure in backend paths ([c456637](https://github.com/usememos/memos/commit/c45663761d148e94d3e1b30f4810922e7c2571ab))
* **api:** remove public activity service ([#5734](https://github.com/usememos/memos/issues/5734)) ([04f239a](https://github.com/usememos/memos/commit/04f239a2fc74f7090c779a6a7b8a08238fd31b88))
* **api:** restrict user email exposure to self and admins ([#5784](https://github.com/usememos/memos/issues/5784)) ([a24d420](https://github.com/usememos/memos/commit/a24d4209222814e4a8bedfc0392e91462bc855ad))
* **api:** switch user resource names to usernames ([#5779](https://github.com/usememos/memos/issues/5779)) ([acddef1](https://github.com/usememos/memos/commit/acddef1f3dcca44806bcbb85e9dc5cae7daa5285))
* **api:** tolerate missing related users in memo conversions ([#5809](https://github.com/usememos/memos/issues/5809)) ([25feef3](https://github.com/usememos/memos/commit/25feef3aadd34bfd474d7e3b685815a6509bc4c6))
* **auth:** recover session via refresh cookie when localStorage is empty ([#5748](https://github.com/usememos/memos/issues/5748)) ([551ee1d](https://github.com/usememos/memos/commit/551ee1d81f398abdcaf73a0c2b782c8379b0e3f2))
* backend tests action ([065e817](https://github.com/usememos/memos/commit/065e817470b1d3b6b00222525791b85d825c1492))
* clear content search filter when selecting shortcut ([#5499](https://github.com/usememos/memos/issues/5499)) ([2c3f9e3](https://github.com/usememos/memos/commit/2c3f9e3bfbe09e5d0028d1525c1df83c03f547c9))
* correct typos in comments, error messages, and identifiers ([#5704](https://github.com/usememos/memos/issues/5704)) ([8f43e80](https://github.com/usememos/memos/commit/8f43e8075b62706f99da4005ab04b165b35215f6))
* detect legacy installations with empty schema version ([9628d3d](https://github.com/usememos/memos/commit/9628d3de21289e887ffeb6cc4b6710913a0de44e))
* **editor:** show newly linked memos when editing a memo with attachments ([026ea92](https://github.com/usememos/memos/commit/026ea92f7b4005c30ca84340c22018740659ab51))
* ensure comment divs span full width in MemoDetail ([ce44164](https://github.com/usememos/memos/commit/ce441644af34ffff156f1793c40011c2b82e8d5f))
* **fileserver:** render SVG attachment previews ([40fd700](https://github.com/usememos/memos/commit/40fd700fb8be27a8d7a300a2636cf4f366572176))
* **filter:** enforce CEL syntax semantics ([0e89407](https://github.com/usememos/memos/commit/0e89407ee91deda87e0df7464a89d26d1e9a88b3))
* **frontend:** restore sitemap and robots routes ([fee7fcd](https://github.com/usememos/memos/commit/fee7fcd6608b9d07da1f146c33c9bb8f898f708f))
* handle chunk load errors after redeployment with auto-reload ([#5703](https://github.com/usememos/memos/issues/5703)) ([bdd3554](https://github.com/usememos/memos/commit/bdd3554b897246bac5c1e6fd801495c24b6a656e))
* harden memo content iframe and HTML sanitization ([7e21b72](https://github.com/usememos/memos/commit/7e21b728b346e80023c93e8986130f83e70584f9))
* hide transcribe button without AI provider ([ab53329](https://github.com/usememos/memos/commit/ab5332901fd6626f0a2ae6b99671af9663f68078))
* improve image preview dialog and live photo trigger ([aafcc21](https://github.com/usememos/memos/commit/aafcc21ae6f96a313fb372365dd1af8a1a69d1ba))
* improve installer compatibility and docs ([f90d9a4](https://github.com/usememos/memos/commit/f90d9a49a78d0764a6c5cf8f6720e69ea1164b25))
* improve KaTeX and Mermaid error handling and overflow ([6b37fcc](https://github.com/usememos/memos/commit/6b37fcc01b5dcded6a2ec254fbaf895f7812d072))
* include plain URLs and tags in memo snippet generation ([#5688](https://github.com/usememos/memos/issues/5688)) ([3d4f793](https://github.com/usememos/memos/commit/3d4f793f97b567bda823a60b7fe10e09e14499ce))
* **lint:** correct goimports struct literal alignment after removing write-only credential fields ([#5794](https://github.com/usememos/memos/issues/5794)) ([9610ed8](https://github.com/usememos/memos/commit/9610ed8fc809b3e7aa4b93e4909634957fa94cf7))
* **map:** align dark mode map styling ([7ac9989](https://github.com/usememos/memos/commit/7ac9989d43f3967b466f340da3841290d86b20f1))
* **map:** refine Leaflet controls and memo map styling ([894b3eb](https://github.com/usememos/memos/commit/894b3eb045c7d2c2acce847c34a85295c909dcf7))
* **markdown:** support height/width attributes on img elements ([737acbb](https://github.com/usememos/memos/commit/737acbba2f5e3dd911da3a77d2d5d1cde1bf8ba1))
* **memo-editor:** scope Cmd+Enter save to the active editor ([#5745](https://github.com/usememos/memos/issues/5745)) ([05810e7](https://github.com/usememos/memos/commit/05810e7882cb03d448d9d8fa84bc6ca54eafbed0))
* **mysql:** handle CreateMemo custom timestamps with FROM_UNIXTIME ([#5673](https://github.com/usememos/memos/issues/5673)) ([09d73e8](https://github.com/usememos/memos/commit/09d73e8b6e16c43a61767817909a472916c83daa))
* normalize attachment MIME types before validation ([c3e7e2c](https://github.com/usememos/memos/commit/c3e7e2c316d01bf857931c38303efc87307f2a19))
* preserve draft content when tab is suspended or editor remounts ([9ca7122](https://github.com/usememos/memos/commit/9ca71229a6c0dfad44b76ed06fced46c5bef49c7))
* prevent local attachment uploads from overwriting files ([4add9b0](https://github.com/usememos/memos/commit/4add9b04ad5d0b7608c985281f5975dc39f9a572))
* prevent stale comment drafts from being restored ([e520b63](https://github.com/usememos/memos/commit/e520b637fd8d0f6331674003abe72f9dfbae8231))
* remove duplicate Japanese locale keys ([efeb28c](https://github.com/usememos/memos/commit/efeb28c872a2bd6edd28fa3b68bd0b15af923d50))
* render audio attachments as inline players ([#5699](https://github.com/usememos/memos/issues/5699)) ([2ccb98a](https://github.com/usememos/memos/commit/2ccb98a6cbc40544e81eeeca2a2cbbaf8348f45e))
* restrict archived memo access to creator only ([#5707](https://github.com/usememos/memos/issues/5707)) ([f4154d0](https://github.com/usememos/memos/commit/f4154d090be3896c8e0dd83440f24953bbeb308c))
* **routing:** redirect unauthenticated users to /explore when public visibility is allowed ([98859eb](https://github.com/usememos/memos/commit/98859eb5e5065a7c12ada983b7c38c5af5035bb6))
* **server:** close SSE clients during shutdown ([a5ddd5a](https://github.com/usememos/memos/commit/a5ddd5adafef282f00b23e00e145119d4316f24f))
* sync html lang attribute with active locale ([#5753](https://github.com/usememos/memos/issues/5753)) ([be00abe](https://github.com/usememos/memos/commit/be00abe852479820b7c574e15e276d70961ab7bd))
* tag parsing truncates emojis with variation selectors ([#5496](https://github.com/usememos/memos/issues/5496)) ([3ea6ea3](https://github.com/usememos/memos/commit/3ea6ea3108f58dc34f39fa6f21d761bd2e97732b))
* **tags:** allow blur-only tag metadata ([#5800](https://github.com/usememos/memos/issues/5800)) ([1921b57](https://github.com/usememos/memos/commit/1921b57662c2129d179930f71e5c42caf9070a19))
* toggle focus mode do not reset editor height ([#5504](https://github.com/usememos/memos/issues/5504)) ([0729779](https://github.com/usememos/memos/commit/0729779e0427c68bdbc5b6b711a5683f66ba9bbc))
* **ui:** show comment editor above the comment list ([#5662](https://github.com/usememos/memos/issues/5662)) ([6b0736b](https://github.com/usememos/memos/commit/6b0736b29325ff7698b7395dc761f2ca41e6521c))
* **ui:** unify metadata component styles across MemoView and MemoEditor ([664b8c5](https://github.com/usememos/memos/commit/664b8c56290026ba2beb7b75db24c684824b8670))
* unify live photo previews around LIVE badge playback ([6b0487d](https://github.com/usememos/memos/commit/6b0487dcd857ba72189ca0624cb4985961f08f25))
* **webhooks:** trigger memo updates for attachment and relation changes ([#5795](https://github.com/usememos/memos/issues/5795)) ([acbc914](https://github.com/usememos/memos/commit/acbc914dea8d4e533028b03929193a4af1ac32b4))
* **web:** prevent MemoContent prop leaks ([22519b5](https://github.com/usememos/memos/commit/22519b57a0a971ab927a03015c994c63b8c3f1da))
* **web:** refine attachment media layout ([a0d83e1](https://github.com/usememos/memos/commit/a0d83e1a9e9d0ba50a0103e594adb011728faf8c))
* **web:** refresh memo detail cache after editor save ([333c9df](https://github.com/usememos/memos/commit/333c9df233240a843a9e62d17c6ff464690636f8))
* **web:** use BroadcastChannel to sync token refreshes across tabs ([bbdc998](https://github.com/usememos/memos/commit/bbdc998646e8093223a448378a2c29690f0d8612))
### Performance Improvements
* batch load memo relations when listing memos ([#5692](https://github.com/usememos/memos/issues/5692)) ([1e82714](https://github.com/usememos/memos/commit/1e82714a52c72455a6ba02605cc641d7ad4fbf1a))
+1 -1
View File
@@ -1,2 +1,2 @@
# These owners will be the default owners for everything in the repo.
* @boojack
* @usememos/moderators
-41
View File
@@ -1,41 +0,0 @@
# Build frontend dist.
FROM node:20-alpine AS frontend
WORKDIR /frontend-build
COPY . .
WORKDIR /frontend-build/web
RUN npm install -g pnpm
RUN pnpm i --frozen-lockfile
RUN pnpm build
# Build backend exec file.
FROM golang:1.24-alpine AS backend
WORKDIR /backend-build
COPY . .
COPY --from=frontend /frontend-build/web/dist /backend-build/server/router/frontend/dist
RUN go build -o memos ./bin/memos/main.go
# Make workspace with above generated files.
FROM alpine:latest AS monolithic
WORKDIR /usr/local/memos
RUN apk add --no-cache tzdata
ENV TZ="UTC"
COPY --from=backend /backend-build/memos /usr/local/memos/
COPY entrypoint.sh /usr/local/memos/
EXPOSE 5230
# Directory to store the data, which can be referenced as the mounting point.
RUN mkdir -p /var/opt/memos
VOLUME /var/opt/memos
ENV MEMOS_MODE="prod"
ENV MEMOS_PORT="5230"
ENTRYPOINT ["./entrypoint.sh", "./memos"]
+59 -36
View File
@@ -1,58 +1,81 @@
# Memos - Open Source, Self-hosted, Your Notes, Your Way
> ✨ Featured Sponsor: [CodeRabbit](https://coderabbit.link/usememos) — Cut code review time & bugs in half, instantly.
<img align="right" height="96px" src="https://www.usememos.com/logo-rounded.png" alt="Memos" />
# Memos
An open-source, self-hosted note-taking solution designed for seamless deployment and multi-platform access. Experience effortless plain text writing with pain-free, complemented by robust Markdown syntax support for enhanced formatting.
<img align="right" height="96px" src="https://raw.githubusercontent.com/usememos/.github/refs/heads/main/assets/logo-rounded.png" alt="Memos" />
<a href="https://www.usememos.com">Home Page</a> •
<a href="https://www.usememos.com/blog">Blogs</a> •
<a href="https://www.usememos.com/docs">Docs</a> •
<a href="https://demo.usememos.com/">Live Demo</a>
Memos is an open-source, self-hosted note-taking app built for quick capture. It is Markdown-native, lightweight, and keeps your data under your control.
<p>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg"/></a>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker image size" src="https://img.shields.io/docker/image-size/neosmemo/memos?sort=semver"/></a>
<a href="https://discord.gg/tfPJa4UmAv"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
</p>
[![Home](https://img.shields.io/badge/🏠-usememos.com-blue?style=flat-square)](https://usememos.com)
[![Live Demo](https://img.shields.io/badge/✨-Try%20Demo-orange?style=flat-square)](https://demo.usememos.com/)
[![Docs](https://img.shields.io/badge/📚-Documentation-green?style=flat-square)](https://usememos.com/docs)
[![Discord](https://img.shields.io/badge/💬-Discord-5865f2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/tfPJa4UmAv)
[![Docker Pulls](https://img.shields.io/docker/pulls/neosmemo/memos?style=flat-square&logo=docker)](https://hub.docker.com/r/neosmemo/memos)
![demo](https://www.usememos.com/demo.png)
<img src="https://raw.githubusercontent.com/usememos/.github/refs/heads/main/assets/demo.png" alt="Memos Demo Screenshot" height="512" />
## Features
- **Privacy First** 🏡: Your data, your control. All runtime data is securely stored in your local database.
- **Create at Speed** ✍️: Write and save content as plain text for quick access, with Markdown support for fast formatting and easy sharing.
- **Lightweight but Powerful** ⚡: Built with Go and React.js, our app combines a compact architecture with powerful performance.
- **Customizable** 🧩: Personalize your experience by customizing the server name, icon, description, theme, and execution scripts.
- **Open Source** 🦦: Fully open source, with all code available on GitHub for transparency and collaboration.
- **Free to Use** 💸: Enjoy all features at no cost, no hidden fees, no subscriptions.
- **Capture quickly** — A timeline-first interface keeps note-taking simple: open, write, and move on.
- **Own your data** — Self-host Memos on your infrastructure with no telemetry.
- **Deploy anywhere** — Run a single Go binary or Docker container with SQLite, MySQL, or PostgreSQL.
- **Integrate freely** — Build on the REST and gRPC APIs or adapt the MIT-licensed source to your needs.
## Deploy with Docker in seconds
## Quick Start
Want to explore Memos first? Open the [live demo](https://demo.usememos.com/).
### Docker (Recommended)
```bash
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:stable
docker run -d \
--name memos \
-p 5230:5230 \
-v ~/.memos:/var/opt/memos \
neosmemo/memos:stable
```
> [!NOTE]
> This command is only applicable for Unix/Linux systems. For Windows, please refer to the detailed [documentation](https://www.usememos.com/docs/install/container-install#docker-on-windows).
>
> The `~/.memos/` directory will be used as the data directory on your local machine, while `/var/opt/memos` is the directory of the volume in Docker and should not be modified.
Open `http://localhost:5230` and start writing.
Learn more about [other installation methods](https://www.usememos.com/docs/install).
### Native Binary
> [!WARNING]
> Memos is still under active development, so you may encounter bugs or breaking changes as we improve.
```bash
curl -fsSL https://raw.githubusercontent.com/usememos/memos/main/scripts/install.sh | sh
```
## Contribution
### Other Installation Methods
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. We greatly appreciate any contributions you make. Thank you for being a part of our community! 🥰
- **Docker Compose** — Recommended for production deployments.
- **Kubernetes** — Helm charts and manifests are available.
- **Build from source** — Best for development and customization.
Guide to [contribution](https://www.usememos.com/docs/contribution/development).
See the [deployment guide](https://usememos.com/docs/deploy) for detailed instructions.
## Star history
## Web Clipper
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)
Save pages, selected text, and images directly to your Memos instance with the official [Memos Web Clipper](https://github.com/usememos/web-clipper). The extension is available for [Chrome](https://chromewebstore.google.com/detail/memos-web-clipper/nebaoebnljalfegiidibihhkebeiklbl) and [Firefox](https://addons.mozilla.org/en-US/firefox/addon/memos-web-clipper/), and lets you review each clip, choose its visibility, and customize its Markdown format before saving.
## Other Projects
## Contributing
- [**Slash**](https://github.com/yourselfhosted/slash): An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.
- [**Gomark**](https://github.com/usememos/gomark): A markdown parser written in Go for Memos. And its [WebAssembly version](https://github.com/usememos/gomark-wasm) is also available.
Contributions of all kinds are welcome, including bug reports, feature suggestions, pull requests, documentation, and translations.
- [Report bugs](https://github.com/usememos/memos/issues/new?template=bug_report.md)
- [Suggest features](https://github.com/usememos/memos/issues/new?template=feature_request.md)
- [Submit pull requests](https://github.com/usememos/memos/pulls)
- [Improve documentation](https://github.com/usememos/dotcom)
- [Help with translations](https://github.com/usememos/memos/tree/main/web/src/locales)
## Sponsors
- [**CodeRabbit** — Cut code review time and bugs in half](https://coderabbit.link/usememos)
- [**SSD Nodes** — Affordable VPS hosting for self-hosters](https://ssdnodes.com/?utm_source=memos&utm_medium=sponsor)
Love Memos? [Sponsor us on GitHub](https://github.com/sponsors/usememos) to help keep the project growing!
## License
Memos is open-source software licensed under the [MIT License](LICENSE). See our [Privacy Policy](https://usememos.com/privacy) for details on data handling.
---
**[Website](https://usememos.com)** • **[Documentation](https://usememos.com/docs)** • **[Demo](https://demo.usememos.com/)** • **[Discord](https://discord.gg/tfPJa4UmAv)** • **[X/Twitter](https://x.com/usememos)**
+37 -3
View File
@@ -1,7 +1,41 @@
# Security Policy
## Reporting a bug
## Supported Versions
Report security bugs via GitHub [issues](https://github.com/usememos/memos/issues).
Memos is currently a `0.x` project. Security fixes are only provided for the latest release. Older releases are not supported for security updates, and fixes are not backported.
For more information, please contact [usememos@gmail.com](usememos@gmail.com).
If you run Memos in production, keep your instance updated to the latest release.
## Reporting a Vulnerability
Please report security issues privately by email: `dev@usememos.com`
Do not open public GitHub issues, discussions, or pull requests for suspected vulnerabilities.
Please include:
- A clear description of the issue
- Steps to reproduce
- Affected version or commit
- Deployment details that matter to reproduction
- Your assessment of impact
We will review reports as time permits and fix valid issues in regular releases.
## Disclosure and CVEs
Memos is self-hosted software and is still in the `0.x` stage. At this stage, we do not run a formal disclosure program, publish separate security advisories for every issue, or request CVE IDs.
Security fixes may be shipped directly in normal releases or noted briefly in release notes and changelogs.
## Self-Hosted Deployment Notes
The security posture of a Memos instance depends heavily on how it is deployed and operated. In particular:
- Keep Memos updated
- Put it behind a properly configured reverse proxy when exposed to the internet
- Require authentication for any non-public deployment
- Use TLS in production
- Limit access to trusted users and administrators
Reports that depend entirely on intentionally unsafe deployment choices, unsupported local patches, or administrator actions may be treated as deployment issues rather than product vulnerabilities.
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"io"
"log/slog"
"strings"
"github.com/pkg/errors"
)
func parseSlogLevel(s string) (slog.Level, error) {
switch strings.ToLower(s) {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, errors.Errorf("unknown log level %q: must be debug, info, warn, or error", s)
}
}
func newLogger(level slog.Level, w io.Writer) *slog.Logger {
return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{Level: level}))
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
)
func TestParseSlogLevel(t *testing.T) {
tests := []struct {
input string
wantLevel slog.Level
wantErr bool
}{
{"debug", slog.LevelDebug, false},
{"info", slog.LevelInfo, false},
{"warn", slog.LevelWarn, false},
{"error", slog.LevelError, false},
{"DEBUG", slog.LevelDebug, false},
{"INFO", slog.LevelInfo, false},
{"WARN", slog.LevelWarn, false},
{"ERROR", slog.LevelError, false},
{"invalid", slog.LevelInfo, true},
{"", slog.LevelInfo, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseSlogLevel(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseSlogLevel(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
if got != tt.wantLevel {
t.Errorf("parseSlogLevel(%q) = %v, want %v", tt.input, got, tt.wantLevel)
}
})
}
}
func TestNewLoggerLevelFiltering(t *testing.T) {
tests := []struct {
level slog.Level
logAt slog.Level
msg string
shouldAppear bool
}{
// debug passes all
{slog.LevelDebug, slog.LevelDebug, "debug-msg", true},
{slog.LevelDebug, slog.LevelInfo, "info-msg", true},
{slog.LevelDebug, slog.LevelWarn, "warn-msg", true},
{slog.LevelDebug, slog.LevelError, "error-msg", true},
// info suppresses debug
{slog.LevelInfo, slog.LevelDebug, "debug-suppressed", false},
{slog.LevelInfo, slog.LevelInfo, "info-visible", true},
{slog.LevelInfo, slog.LevelWarn, "warn-visible", true},
// warn suppresses debug+info
{slog.LevelWarn, slog.LevelDebug, "debug-suppressed", false},
{slog.LevelWarn, slog.LevelInfo, "info-suppressed", false},
{slog.LevelWarn, slog.LevelWarn, "warn-visible", true},
{slog.LevelWarn, slog.LevelError, "error-visible", true},
// error suppresses everything below
{slog.LevelError, slog.LevelDebug, "debug-suppressed", false},
{slog.LevelError, slog.LevelInfo, "info-suppressed", false},
{slog.LevelError, slog.LevelWarn, "warn-suppressed", false},
{slog.LevelError, slog.LevelError, "error-visible", true},
}
for _, tt := range tests {
var buf bytes.Buffer
logger := newLogger(tt.level, &buf)
logger.Log(context.TODO(), tt.logAt, tt.msg)
appeared := strings.Contains(buf.String(), tt.msg)
if appeared != tt.shouldAppear {
t.Errorf("level=%s logAt=%s msg=%q: appeared=%v want=%v",
tt.level, tt.logAt, tt.msg, appeared, tt.shouldAppear)
}
}
}
func TestNewLoggerOutputFormat(t *testing.T) {
var buf bytes.Buffer
logger := newLogger(slog.LevelDebug, &buf)
logger.Info("hello-world", "key", "value")
out := buf.String()
if !strings.Contains(out, "hello-world") {
t.Errorf("expected message in output, got: %s", out)
}
if !strings.Contains(out, "key=value") {
t.Errorf("expected key=value attr in output, got: %s", out)
}
if !strings.Contains(out, "INFO") {
t.Errorf("expected level in output, got: %s", out)
}
}
func TestNewLoggerDoesNotMutateGlobalDefault(t *testing.T) {
original := slog.Default()
var buf bytes.Buffer
_ = newLogger(slog.LevelError, &buf)
if slog.Default() != original {
t.Error("newLogger must not change slog.Default()")
}
}
+89 -48
View File
@@ -7,28 +7,27 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/version"
"github.com/usememos/memos/internal/webhook"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
"github.com/usememos/memos/server/version"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
)
const (
greetingBanner = `
`
)
func initSlogDefault() {
level, err := parseSlogLevel(viper.GetString("log-level"))
if err != nil {
slog.Warn("invalid log-level value, defaulting to info", "error", err)
}
slog.SetDefault(newLogger(level, os.Stderr))
}
var (
rootCmd = &cobra.Command{
@@ -36,17 +35,22 @@ var (
Short: `An open source, lightweight note-taking service. Easily capture and share your great thoughts.`,
Run: func(_ *cobra.Command, _ []string) {
instanceProfile := &profile.Profile{
Mode: viper.GetString("mode"),
Demo: viper.GetBool("demo"),
Addr: viper.GetString("addr"),
Port: viper.GetInt("port"),
UNIXSock: viper.GetString("unix-sock"),
Data: viper.GetString("data"),
Driver: viper.GetString("driver"),
DSN: viper.GetString("dsn"),
InstanceURL: viper.GetString("instance-url"),
Version: version.GetCurrentVersion(viper.GetString("mode")),
}
instanceProfile.Version = version.GetCurrentVersion()
instanceProfile.Commit = version.Commit
webhook.AllowPrivateIPs = viper.GetBool("allow-private-webhooks")
if err := instanceProfile.Validate(); err != nil {
panic(err)
slog.Error("failed to validate profile", "error", err)
return
}
ctx, cancel := context.WithCancel(context.Background())
@@ -63,6 +67,11 @@ var (
slog.Error("failed to migrate", "error", err)
return
}
if err := storeInstance.LoadDeploymentConfiguration(ctx); err != nil {
cancel()
slog.Error("failed to load deployment configuration", "error", err)
return
}
s, err := server.NewServer(ctx, instanceProfile, storeInstance)
if err != nil {
@@ -81,6 +90,7 @@ var (
if err != http.ErrServerClosed {
slog.Error("failed to start server", "error", err)
cancel()
return
}
}
@@ -96,22 +106,34 @@ var (
<-ctx.Done()
},
}
versionCmd = &cobra.Command{
Use: "version",
Short: "Print the current Memos version",
Run: func(_ *cobra.Command, _ []string) {
fmt.Println(version.GetCurrentVersion())
},
}
)
func init() {
viper.SetDefault("mode", "dev")
cobra.OnInitialize(initSlogDefault)
viper.SetDefault("demo", false)
viper.SetDefault("driver", "sqlite")
viper.SetDefault("port", 8081)
rootCmd.PersistentFlags().String("mode", "dev", `mode of server, can be "prod" or "dev" or "demo"`)
rootCmd.PersistentFlags().Bool("demo", false, "enable demo mode")
rootCmd.PersistentFlags().String("addr", "", "address of server")
rootCmd.PersistentFlags().Int("port", 8081, "port of server")
rootCmd.PersistentFlags().String("unix-sock", "", "path to the unix socket, overrides --addr and --port")
rootCmd.PersistentFlags().String("data", "", "data directory")
rootCmd.PersistentFlags().String("driver", "sqlite", "database driver")
rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)")
rootCmd.PersistentFlags().String("instance-url", "", "the url of your memos instance")
rootCmd.PersistentFlags().Bool("allow-private-webhooks", false, "allow webhook URLs to resolve to private/reserved IP addresses")
rootCmd.PersistentFlags().String("log-level", "info", "log verbosity level (debug, info, warn, error)")
if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil {
if err := viper.BindPFlag("demo", rootCmd.PersistentFlags().Lookup("demo")); err != nil {
panic(err)
}
if err := viper.BindPFlag("addr", rootCmd.PersistentFlags().Lookup("addr")); err != nil {
@@ -120,6 +142,9 @@ func init() {
if err := viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")); err != nil {
panic(err)
}
if err := viper.BindPFlag("unix-sock", rootCmd.PersistentFlags().Lookup("unix-sock")); err != nil {
panic(err)
}
if err := viper.BindPFlag("data", rootCmd.PersistentFlags().Lookup("data")); err != nil {
panic(err)
}
@@ -132,46 +157,62 @@ func init() {
if err := viper.BindPFlag("instance-url", rootCmd.PersistentFlags().Lookup("instance-url")); err != nil {
panic(err)
}
viper.SetEnvPrefix("memos")
viper.AutomaticEnv()
if err := viper.BindEnv("instance-url", "MEMOS_INSTANCE_URL"); err != nil {
if err := viper.BindPFlag("allow-private-webhooks", rootCmd.PersistentFlags().Lookup("allow-private-webhooks")); err != nil {
panic(err)
}
if err := viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")); err != nil {
panic(err)
}
viper.SetEnvPrefix("memos")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
rootCmd.AddCommand(versionCmd)
}
func printGreetings(profile *profile.Profile) {
if profile.IsDev() {
println("Development mode is enabled")
println("DSN: ", profile.DSN)
}
fmt.Printf(`---
Server profile
version: %s
data: %s
addr: %s
port: %d
mode: %s
driver: %s
---
`, profile.Version, profile.Data, profile.Addr, profile.Port, profile.Mode, profile.Driver)
fmt.Printf("Memos %s started successfully!\n", profile.Version)
print(greetingBanner)
if len(profile.Addr) == 0 {
fmt.Printf("Version %s has been started on port %d\n", profile.Version, profile.Port)
} else {
fmt.Printf("Version %s has been started on address '%s' and port %d\n", profile.Version, profile.Addr, profile.Port)
if profile.Demo {
fmt.Fprint(os.Stderr, "Development mode is enabled\n")
if profile.DSN != "" {
fmt.Fprintf(os.Stderr, "Database: %s\n", profile.DSN)
}
}
fmt.Printf(`---
See more in:
👉Website: %s
👉GitHub: %s
---
`, "https://usememos.com", "https://github.com/usememos/memos")
// Server information
fmt.Printf("Data directory: %s\n", profile.Data)
fmt.Printf("Database driver: %s\n", profile.Driver)
// Connection information
if len(profile.UNIXSock) == 0 {
if len(profile.Addr) == 0 {
fmt.Printf("Server running on port %d\n", profile.Port)
fmt.Printf("Access your memos at: http://localhost:%d\n", profile.Port)
} else {
fmt.Printf("Server running on %s:%d\n", profile.Addr, profile.Port)
fmt.Printf("Access your memos at: http://%s:%d\n", profile.Addr, profile.Port)
}
} else {
fmt.Printf("Server running on unix socket: %s\n", profile.UNIXSock)
}
// Access mode is derived from instance_url: set = public, unset = private.
accessMode := "private"
if profile.AllowAnonymous() {
accessMode = "public"
}
fmt.Printf("Access mode: %s\n", accessMode)
fmt.Println()
fmt.Printf("Documentation: %s\n", "https://usememos.com")
fmt.Printf("Source code: %s\n", "https://github.com/usememos/memos")
fmt.Println("\nHappy note-taking!")
}
func main() {
if err := rootCmd.Execute(); err != nil {
panic(err)
os.Exit(1)
}
}
+497
View File
@@ -0,0 +1,497 @@
# Configuration Provisioning
Status: Implemented
## Summary
Memos should follow Mastodon's deployment-configuration model: configuration supplied by the deployment is loaded directly into each server process and
remains authoritative for that process lifetime. It is not imported into the database and is not tracked as database-owned application state.
The first supported file-backed configuration resources are:
- OAuth2 identity providers.
- Instance settings for general policy, storage, memo behavior, notifications, and AI providers.
Memos scans `/etc/secrets` after database migration and demo seeding, validates every matching file, builds one immutable configuration snapshot, and
publishes that snapshot before HTTP or background services start. Applying a changed file requires a process restart.
Every resource file contains exactly one existing `memos.store` protobuf message encoded as protobuf JSON. No resource envelope, state file, ownership
table, or second persistent copy of a secret is introduced. The process necessarily holds decoded secrets in its private runtime snapshot.
## Design model
Mastodon reads external authentication and other deployment configuration from environment variables or a dotenv file during process initialization. It
does not copy that configuration into an administrator-editable database resource or maintain Terraform-style ownership state.
Memos should use the same lifecycle while adapting the input format to its existing generated store messages:
- Mounted JSON files replace a large collection of environment variables.
- File-backed resources exist in the effective runtime configuration.
- Stored resources continue to exist in the database but are shadowed when a file declares the same stable key.
- UI and API mutations cannot change an actively file-backed resource.
- Removing a file and restarting removes the runtime override; it does not delete or modify the stored resource.
This is deployment configuration, not resource reconciliation. Terms such as adoption, import, unmanage, drift, prune, and Terraform state do not apply.
## Goals
- Accept secrets through mounted files without committing them to seed SQL or command-line arguments.
- Keep each file equal to one generated store protobuf message.
- Load and validate the complete file set before exposing any of it.
- Make deployment configuration authoritative for the lifetime of the process.
- Preserve database-backed UI configuration for keys not supplied by files.
- Prevent API writes from appearing to change an effective file-backed resource.
- Keep secret values out of logs, API responses, caches that expose values, and additional persistence.
- Preserve the administrator password sign-in path when password sign-in is disabled for regular users.
## Non-goals
- Persist file contents or file ownership metadata in the database.
- Add a `provisioning_resource` table or provisioning columns to existing tables.
- Reconcile database state to match a desired resource graph.
- Delete database resources when files disappear.
- Support multiple configuration sources with precedence rules in the first version.
- Write UI changes back into mounted files.
- Watch files or reload configuration without restarting in the first version.
- Support partial field ownership within an instance-setting group.
## Terminology
**Stored configuration**
: Configuration stored in the existing `idp` and `system_setting` database tables.
**Deployment configuration**
: Configuration decoded from matching files during process startup.
**Effective configuration**
: The configuration used by APIs, authentication, and background services. Deployment configuration shadows stored configuration with the same stable key.
**Stable key**
: The identity-provider UID or instance-setting key used to merge deployment and stored configuration.
## File discovery
Memos scans direct children of `/etc/secrets`. The directory may contain unrelated platform secrets; only supported filename patterns are read. Memos does
not recurse into subdirectories and does not create, modify, or delete anything in the directory.
| Filename pattern | Protobuf message | Stable key |
| --- | --- | --- |
| `memos-idp-<label>.json` | `memos.store.IdentityProvider` | `uid` |
| `memos-instance-setting-<label>.json` | `memos.store.InstanceSetting` | `key` |
`<label>` uses lowercase kebab case and must match `[a-z0-9]+(?:-[a-z0-9]+)*`. Matching is case-sensitive and the extension is lowercase `.json`.
For upgrade compatibility, identity-provider filenames accepted by the original bootstrap (`memos-idp-*.json`) continue to load when the label is not
lowercase kebab case, but startup logs a deprecation warning. New files should always use the canonical convention.
Recommended labels mirror the resource key for operator readability:
| Resource | Canonical filename |
| --- | --- |
| Identity provider with UID `primary-sso` | `memos-idp-primary-sso.json` |
| `GENERAL` | `memos-instance-setting-general.json` |
| `STORAGE` | `memos-instance-setting-storage.json` |
| `MEMO_RELATED` | `memos-instance-setting-memo-related.json` |
| `NOTIFICATION` | `memos-instance-setting-notification.json` |
| `AI` | `memos-instance-setting-ai.json` |
The filename label remains descriptive rather than authoritative. The `uid` or `key` inside the message is the resource identity, so renaming a file does
not change account links or effective resource identity. Files are read in lexical order only to produce deterministic diagnostics; ordering has no
configuration semantics.
Each matching file:
- Must be a valid protobuf JSON representation of the expected message.
- Must not contain unknown fields.
- Must not exceed 1 MiB.
- Must contain exactly one resource.
- May contain plaintext secrets because the containing directory is treated as sensitive.
- May be a regular file or a platform-managed symlink that resolves to a regular file, as used by Kubernetes Secret volumes.
A missing directory or a readable directory without matching files is a normal no-op. An unreadable directory or matching file is a startup error. Startup
logs include matched counts by resource type so a misspelled filename is visible without logging file contents. A direct child beginning with `memos-` but
not matching a supported pattern produces a warning; unrelated filenames are silently ignored.
### SSO-only deployments
An SSO-only deployment mounts both an identity-provider file and `memos-instance-setting-general.json` with `disallowPasswordAuth` enabled. These resources
are validated and published together during startup. The public demo seed does not contain authentication policy, so both files must be mounted to enable
SSO-only behavior. Keep `disallowUserRegistration` disabled when first-time SSO users should be created automatically.
## Identity-provider files
An identity-provider file contains exactly one `memos.store.IdentityProvider`. The database-generated `id` must be omitted. `uid` is required and is the
stable key.
Example `/etc/secrets/memos-idp-primary-sso.json`:
```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": ["openid", "profile", "email"],
"fieldMapping": {
"identifier": "sub",
"displayName": "name",
"email": "email",
"avatarUrl": "picture"
}
}
}
}
```
Initial validation supports only OAuth2 providers and requires:
- A valid, nonempty UID and display name.
- Client ID and client secret.
- Authorization, token, and user-info URLs.
- At least one scope, with no empty scope entries.
- A field-mapping object with a nonempty identifier field.
Duplicate UIDs across files are rejected.
User identity links already use the provider UID as their stable provider value. A file-backed provider therefore does not need a database-generated IdP ID
to preserve account links or complete SSO sign-in.
## Instance-setting files
An instance-setting file contains exactly one `memos.store.InstanceSetting`. `key` is required and is the stable key. The populated `oneof` must match the
key.
Example `/etc/secrets/memos-instance-setting-general.json`:
```json
{
"key": "GENERAL",
"generalSetting": {
"disallowUserRegistration": false,
"disallowPasswordAuth": true,
"additionalScript": "",
"additionalStyle": "",
"weekStartDayOffset": 1,
"disallowChangeUsername": false,
"disallowChangeNickname": false,
"customProfile": {
"title": "Company Memos",
"description": "Internal notes",
"logoUrl": "https://example.com/logo.png"
}
}
}
```
Example `/etc/secrets/memos-instance-setting-notification.json`:
```json
{
"key": "NOTIFICATION",
"notificationSetting": {
"email": {
"enabled": true,
"smtpHost": "smtp.example.com",
"smtpPort": 587,
"smtpUsername": "memos",
"smtpPassword": "smtp-secret",
"fromEmail": "memos@example.com",
"fromName": "Memos",
"replyTo": "support@example.com",
"useTls": true,
"useSsl": false
}
}
}
```
Supported keys:
| Key | Deployment use |
| --- | --- |
| `GENERAL` | Registration, authentication, branding, scripts, styles, and user-profile policy |
| `STORAGE` | Attachment storage type, limits, paths, and S3 credentials |
| `MEMO_RELATED` | Memo limits, editing behavior, and reactions |
| `NOTIFICATION` | SMTP transport and credentials |
| `AI` | AI providers, API keys, and transcription defaults |
Rejected keys:
- `BASIC` contains the instance secret key and database schema version. Replacing the secret key invalidates sessions, while replacing the schema version
interferes with database migration state.
- `TAGS` is retained for backward compatibility; active tag metadata is stored per user.
Only one file may declare a given setting key.
### Complete-group replacement
An instance-setting group is the smallest deployment-configured unit. A file replaces the complete effective group. A scalar omitted from protobuf JSON is
stored in the decoded message as its protobuf default; omission does not preserve a field from the database value.
Some existing setting getters apply application defaults after decoding zero values. For example, STORAGE defaults to local storage, a 30 MiB upload limit,
and `assets/{timestamp}_{uuid}_{filename}` when the corresponding decoded fields are unspecified. The effective behavior is therefore the decoded file plus
the same read-time defaults used for database-backed configuration.
Empty secret fields in a file mean empty values; they never mean "preserve the database secret." Credential-preservation behavior used by UI updates does
not apply to deployment configuration.
### AI normalization
AI deployment configuration uses deterministic, self-contained normalization rather than the UI update path:
- Every provider requires an explicit stable `id`; the loader never generates one.
- Every provider requires a title, a supported provider type, and an API key.
- An empty OpenAI endpoint becomes `https://api.openai.com/v1`.
- An empty Gemini endpoint becomes `https://generativelanguage.googleapis.com/v1beta`.
- Duplicate provider IDs are rejected.
- A transcription provider ID must reference a provider in the same effective AI setting.
- Model, language, and prompt use the same length limits as API-managed settings.
- No provider, API key, or transcription value is copied from the shadowed database setting.
## Configuration format compatibility
Using store protobuf JSON makes the selected messages a supported deployment-configuration interface even though the messages remain internal to the
application. For every provisionable message, Memos must preserve:
- Existing protobuf JSON field names.
- Existing enum names and meanings.
- Stable resource-key and `oneof` mappings.
- Previously valid omissions for fields that have defaults.
New optional fields and enum values may be added. A provisionable field may be deprecated, but its existing JSON spelling must continue to decode for the
supported upgrade window. Field names must not be reused with a different meaning. New validation should not invalidate an existing safe configuration
without an upgrade note and a documented replacement.
Unknown fields remain startup errors because this catches misspellings and configuration written for a newer, incompatible Memos version. Compatibility
tests should keep representative JSON fixtures from earlier releases and decode them with the current loader.
## Runtime configuration snapshot
The loader builds an immutable snapshot containing maps keyed by provider UID and setting key. It does not mutate the database while loading. The `Store`
owns this snapshot so all existing consumers resolve configuration through one boundary.
Startup follows this sequence:
```text
Initialize or migrate database
-> apply demo seed when enabled
-> read all matching deployment-configuration files
-> decode and validate every resource
-> validate affected cross-resource invariants
-> publish one immutable runtime snapshot
-> construct HTTP and background services
-> accept requests
```
If any matching file is invalid, no snapshot is published and startup fails. Atomicity comes from publishing the snapshot only after complete validation;
no cross-database transaction abstraction is required because deployment configuration performs no database writes.
The snapshot is loaded once. Files changed after startup have no effect until the process restarts.
### Immutability and copy semantics
Generated protobuf messages are mutable pointers, so immutability must be enforced rather than assumed:
- Canonical snapshot messages remain private to the `Store`.
- Effective getters return deep clones, using `proto.Clone`, rather than canonical pointers.
- Read-time defaults and redaction are applied only to clones.
- Canonical snapshot messages are never inserted into the existing instance-setting TTL cache.
- Callers cannot obtain a mutable map or message owned by the snapshot.
This prevents one request, background runner, defaulting helper, or redaction path from changing configuration observed by another goroutine.
### Effective and stored access
The store facade has an explicit separation between effective reads and stored-resource access:
- Normal list/get operations used by authentication, APIs, and background services return effective configuration.
- Internal raw list/get operations read only the database and are used by migration, snapshot planning, and permitted mutation paths.
- Mutation services check the snapshot source before loading a raw database row.
- A file-backed IdP has no database ID and must never be passed to a driver update or delete operation.
- The loader reads stored configuration through raw access before publishing the snapshot, avoiding recursive effective resolution.
## Effective configuration resolution
### Identity providers
List and get operations return the union of stored and file-backed providers by UID:
- A file-backed provider shadows a stored provider with the same UID.
- Stored providers with other UIDs remain available.
- Authentication resolves the same effective provider collection.
- Removing the file and restarting reveals any stored provider that had been shadowed; it does not restore values from the file.
- Stored providers retain their database insertion order, and a file-backed provider that shadows one occupies the same position. Providers that exist only
in deployment configuration are appended in UID order, keeping existing API and sign-in ordering stable while remaining deterministic.
Operators migrating an existing stored provider to a file should keep the same UID so existing user-identity links continue to work. They should remove or
update the shadowed stored provider before later removing the file if they do not want the old database configuration to reappear.
### Instance settings
Every effective instance-setting getter checks the runtime snapshot before its database cache:
- A file-backed group completely shadows the `system_setting` row with the same key.
- Other setting groups continue to use stored values and existing application defaults.
- The runtime snapshot must never be overwritten by a cached database value.
- Removing a file and restarting returns the group to its stored database value.
The demo seed writes `MEMO_RELATED` but does not write `GENERAL`. Loading deployment configuration after seeding supplies the complete effective General
settings without embedding deployment authentication policy in demo data.
## Validation and authentication safety
All file-local validation runs before snapshot publication. Relationships between file-backed resources are validated against the resulting effective
configuration when the desired files affect that relationship.
At minimum, validation rejects:
- A file-backed `GENERAL` setting that disables password authentication for regular users when the resulting effective configuration has no identity
provider.
- An instance-setting key whose populated `oneof` does not match the key.
- S3 storage without the required endpoint, bucket, region, or credentials.
- Enabled email delivery without the required SMTP host, port, or sender.
- Duplicate AI provider IDs.
- Transcription referencing an AI provider ID absent from the effective AI setting.
- Duplicate stable keys across files.
An unrelated file must not turn an existing database condition into a new startup failure. For example, a STORAGE-only file does not fail startup merely
because the database already disables regular-user password sign-in while containing no IdP; Memos logs that existing condition as a warning. A file that
configures GENERAL or an IdP evaluates the authentication invariant against the resulting effective state.
The administrator password path remains available regardless of `disallowPasswordAuth`. Runtime mutations reject transitions from a safe authentication
state to one where password sign-in is disabled for regular users without an effective IdP. An unrelated edit may preserve an already-existing legacy
violation so an upgrade does not make the complete `GENERAL` group uneditable; the administrator can resolve that state by enabling password sign-in or
configuring an IdP. Deleting the last effective IdP from a previously safe state remains rejected.
The validation and database mutation must be one serializable store operation. In particular, updating `GENERAL` and deleting an IdP cannot use separate
check-then-write calls, because concurrent requests could each validate an old safe state and together produce an unsafe state. The narrow runtime mutation
operation:
1. Starts a serializable database transaction.
2. Reads the stored `GENERAL` setting and stored IdPs inside that transaction.
3. Combines them with the immutable file snapshot and the proposed mutation.
4. Validates the resulting effective authentication state.
5. Applies the stored-resource mutation and commits.
6. Retries serialization conflicts a bounded number of times.
This transaction is required for runtime authentication-policy safety, not for loading deployment files. It adds no table or schema migration. Each
database driver must provide equivalent transaction semantics for this narrow operation.
## API behavior
The API operates on effective resources for reads and stored resources for permitted writes.
Mutation behavior:
- Creating a stored IdP with a UID reserved by a file-backed provider returns `codes.FailedPrecondition`.
- Updating or deleting a file-backed IdP returns `codes.FailedPrecondition`.
- Updating a file-backed instance-setting group returns `codes.FailedPrecondition`.
- The instance-setting guard runs before validation or future field-mask application, so every masked update to a file-backed group is rejected.
- Mutations of unshadowed stored configuration continue normally, subject to authentication safety invariants.
- API responses continue to redact client secrets, SMTP passwords, S3 secrets, and AI API keys.
No API operation writes to the mounted files. Test operations that do not change stored configuration, such as testing the effective SMTP configuration,
remain available.
## Frontend behavior
The frontend does not receive configuration-source metadata. It presents the normal mutation controls and reports the API's `FailedPrecondition` error when
an administrator attempts to create, update, or delete a deployment-managed resource. The API remains the sole authority for mutation enforcement.
## Security
- Treat `/etc/secrets` and every matching file as sensitive plaintext.
- Recommend owner-only or application-group-readable filesystem permissions.
- Never log file contents, decoded messages, before/after values, or secret fields.
- Redact secrets from validation errors and startup summaries.
- Do not persist deployment secrets in `idp`, `system_setting`, a state file, or ownership metadata.
- Fail startup on an invalid or unreadable matching file rather than publishing partial configuration.
- Keep the immutable snapshot process-local and expose only redacted API representations.
## Multiple server replicas
Every replica independently loads deployment configuration at startup, as Mastodon processes independently load environment configuration. All replicas in
one deployment must mount identical files.
A rolling deployment can temporarily run old and new configuration generations at the same time. Memos does not attempt distributed reconciliation or
cache invalidation for this process-local configuration. Deployments changing authentication or storage configuration should use a rollout strategy that
does not route traffic to replicas with different file generations, and readiness must be reported only after the new snapshot validates successfully.
Because file-backed settings bypass the database setting cache, a replica cannot replace a deployment value with a stale cached database value.
## Database and migration impact
This design requires no database schema changes and no migrations:
- File-backed IdPs are not inserted into `idp`.
- File-backed settings are not inserted into `system_setting`.
- Existing user-identity links remain database-backed and continue to reference provider UIDs.
- Existing stored configuration remains untouched beneath runtime overrides.
### Transition from the database-writing bootstrap
Versions with the original `memos-idp-*.json` bootstrap copied file-backed IdPs, including client secrets, into the `idp` table during migration. The new
loader cannot reliably distinguish those rows from providers created through the UI, so it must not delete or scrub them automatically.
When a file shadows a stored provider with the same UID, startup logs a secret-free warning that a stored copy remains. Operators who previously used the
database-writing bootstrap should clean up explicitly:
1. Back up the database and retain administrator password access.
2. Temporarily remove the IdP file and restart Memos so the stored provider is no longer shadowed.
3. Delete or update the stored provider through the administrator UI or API, or perform equivalent offline database maintenance.
4. Restore the file and restart Memos.
Until that cleanup is complete, the old stored provider and secret remain in the database and can reappear if the file is removed. The no-persistence
guarantee applies to the new loader; it does not claim to erase secrets written by earlier versions.
## Implementation
The implementation:
1. Replaces the database-writing IdP bootstrap with a typed deployment-configuration loader.
2. Decodes and validates `memos-instance-setting-*.json` resources.
3. Loads configuration after migration and demo seeding but before service construction.
4. Publishes an immutable provider/settings snapshot owned by the store facade, with clone-on-read semantics.
5. Resolves file-backed values before database values and caches for IdP authentication and instance settings.
6. Uses explicit raw database reads for snapshot planning and permitted mutation paths.
7. Validates affected startup state and uses a narrow serializable transaction for runtime authentication mutations.
8. Enforces deployment ownership through API mutation guards and returns `codes.FailedPrecondition` for rejected writes.
## Test strategy
The implementation requires tests for:
- Every canonical filename pattern, legacy identity-provider filename compatibility, and supported message type.
- Unknown fields, oversized files, unreadable files, invalid protobuf JSON, and invalid enum values.
- Duplicate provider UIDs and setting keys.
- Rejection of `BASIC`, `TAGS`, and key/`oneof` mismatches.
- Complete validation before snapshot publication.
- Store-proto JSON compatibility fixtures from earlier releases.
- Effective merging and shadowing by provider UID and setting key.
- Stable stored ordering and deterministic placement of deployment-only identity providers.
- Removal of a file taking effect after constructing a new process snapshot without deleting stored configuration.
- Existing user-identity links working with a file-backed provider of the same UID.
- File-backed settings bypassing database cache entries.
- Clone-on-read behavior and race tests proving snapshot messages cannot be mutated by callers.
- Authentication safety for startup and runtime IdP deletion.
- Concurrent `GENERAL` updates and IdP deletion preserving the runtime authentication invariant across database drivers.
- An unrelated deployment file not failing because of untouched pre-existing authentication state.
- Storage read-time defaults, SMTP validation, and deterministic AI normalization without database secret preservation.
- Missing-directory behavior and matched-file count logging.
- Mutation guards, including requests carrying field masks.
- A stored provider shadowed by a file producing a secret-free legacy-bootstrap warning.
- Secret redaction in errors, logs, and API responses.
## Research references
- [Mastodon environment configuration](https://docs.joinmastodon.org/admin/config/)
- [Mastodon OmniAuth initialization](https://github.com/mastodon/mastodon/blob/main/config/initializers/3_omniauth.rb)
- [GitLab OpenID Connect configuration](https://docs.gitlab.com/administration/auth/oidc/)
- [Keycloak startup import](https://www.keycloak.org/server/importExport)
- [Grafana provisioning](https://grafana.com/docs/grafana/latest/administration/provisioning/)
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env sh
file_env() {
var="$1"
fileVar="${var}_FILE"
val_var="$(printenv "$var")"
val_fileVar="$(printenv "$fileVar")"
if [ -n "$val_var" ] && [ -n "$val_fileVar" ]; then
echo "error: both $var and $fileVar are set (but are exclusive)" >&2
exit 1
fi
if [ -n "$val_var" ]; then
val="$val_var"
elif [ -n "$val_fileVar" ]; then
val="$(cat "$val_fileVar")"
fi
export "$var"="$val"
unset "$fileVar"
}
file_env "MEMOS_DSN"
exec "$@"
+129 -77
View File
@@ -1,102 +1,154 @@
module github.com/usememos/memos
go 1.24
go 1.26.2
require (
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/config v1.29.8
github.com/aws/aws-sdk-go-v2/credentials v1.17.61
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0
github.com/go-sql-driver/mysql v1.9.0
github.com/google/cel-go v0.24.1
connectrpc.com/connect v1.19.2
github.com/at-wat/ebml-go v0.18.0
github.com/aws/aws-sdk-go-v2 v1.41.6
github.com/aws/aws-sdk-go-v2/config v1.32.16
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0
github.com/go-sql-driver/mysql v1.9.3
github.com/google/cel-go v0.28.1
github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1
github.com/improbable-eng/grpc-web v0.15.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.13.3
github.com/lib/pq v1.10.9
github.com/labstack/echo/v5 v5.1.0
github.com/lib/pq v1.12.3
github.com/lithammer/shortuuid/v4 v4.2.0
github.com/moby/moby/api v1.54.2
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/openai/openai-go/v3 v3.32.0
github.com/pion/opus v0.0.0-20260430223319-81a9c5dc5013
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.10.0
github.com/usememos/gomark v0.0.0-20250227070400-97a73c2adb29
golang.org/x/crypto v0.35.0
golang.org/x/mod v0.23.0
golang.org/x/net v0.35.0
golang.org/x/oauth2 v0.27.0
google.golang.org/genproto/googleapis/api v0.0.0-20250227231956-55c901821b1e
google.golang.org/grpc v1.70.0
modernc.org/sqlite v1.36.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.50.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
google.golang.org/genai v1.54.0
google.golang.org/genproto v0.0.0-20260427160629-7cedc36a6bc4
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4
google.golang.org/grpc v1.80.0
modernc.org/sqlite v1.50.0
)
require (
cel.dev/expr v0.21.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/desertbit/timer v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 // indirect
golang.org/x/image v0.24.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.61.13 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/image v0.39.0 // indirect
google.golang.org/api v0.276.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
nhooyr.io/websocket v1.8.17 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/soheilhy/cmux v0.1.5
github.com/spf13/pflag v1.0.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.10.0 // indirect
google.golang.org/protobuf v1.36.5
gopkg.in/yaml.v3 v3.0.1 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
+310 -662
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
package ai
// ProviderType identifies an AI provider implementation.
type ProviderType string
const (
// ProviderOpenAI is OpenAI's hosted API.
ProviderOpenAI ProviderType = "OPENAI"
// ProviderGemini is Google's Gemini API.
ProviderGemini ProviderType = "GEMINI"
)
// ProviderConfig configures a callable AI provider connection.
type ProviderConfig struct {
ID string
Title string
Type ProviderType
Endpoint string
APIKey string
}
+158
View File
@@ -0,0 +1,158 @@
// Package audio provides audio container/codec helpers for AI providers.
//
// The motivating use case is Gemini transcription: Gemini's audio inputs
// require WAV/MP3/AIFF/AAC/OGG/FLAC, but browser MediaRecorder defaults to
// WebM/Opus. This package converts WebM/Opus into 16-bit PCM WAV using
// pure-Go decoders — no ffmpeg or other system dependency.
package audio
import (
"bytes"
"encoding/binary"
"io"
"strings"
"github.com/at-wat/ebml-go"
"github.com/at-wat/ebml-go/webm"
"github.com/pion/opus"
"github.com/pkg/errors"
)
const (
opusOutputSampleRate = 48000
// maxOpusPacketSamples is Opus's spec maximum: 120 ms at 48 kHz.
maxOpusPacketSamples = 5760
// opusCodecID is the WebM TrackEntry CodecID for an Opus audio track.
opusCodecID = "A_OPUS"
// opusHeadMinLength is the minimum size of the OpusHead identification
// header stored in TrackEntry.CodecPrivate.
opusHeadMinLength = 19
)
// WebMOpusToWAV decodes a WebM/Opus file into 16-bit PCM WAV bytes.
//
// The output is mono or stereo at 48 kHz (Opus's native decode rate),
// regardless of the original encoder's hint. Pre-skip samples declared in
// the OpusHead are discarded to avoid the encoder's startup padding.
//
// The function reads the entire WebM document into memory; callers should
// enforce their own size limits before invoking it.
func WebMOpusToWAV(input []byte) ([]byte, error) {
var doc struct {
Header webm.EBMLHeader `ebml:"EBML"`
Segment webm.Segment `ebml:"Segment"`
}
if err := ebml.Unmarshal(bytes.NewReader(input), &doc); err != nil && !errors.Is(err, io.EOF) {
return nil, errors.Wrap(err, "parse webm")
}
track := findOpusTrack(doc.Segment.Tracks.TrackEntry)
if track == nil {
return nil, errors.New("webm has no Opus audio track")
}
if len(track.CodecPrivate) < opusHeadMinLength {
return nil, errors.Errorf("invalid OpusHead: expected at least %d bytes, got %d", opusHeadMinLength, len(track.CodecPrivate))
}
channels := int(track.Audio.Channels)
if channels < 1 || channels > 2 {
return nil, errors.Errorf("unsupported Opus channel count: %d", channels)
}
preSkip := int(binary.LittleEndian.Uint16(track.CodecPrivate[10:12]))
decoder := opus.NewDecoder()
if err := decoder.Init(opusOutputSampleRate, channels); err != nil {
return nil, errors.Wrap(err, "init opus decoder")
}
pcm := make([]int16, 0, 1<<16)
frame := make([]int16, maxOpusPacketSamples*channels)
decodeBlock := func(block ebml.Block) error {
if block.TrackNumber != track.TrackNumber {
return nil
}
for _, packet := range block.Data {
if len(packet) == 0 {
continue
}
n, err := decoder.DecodeToInt16(packet, frame)
if err != nil {
return errors.Wrap(err, "decode opus packet")
}
pcm = append(pcm, frame[:n*channels]...)
}
return nil
}
for _, cluster := range doc.Segment.Cluster {
for _, sb := range cluster.SimpleBlock {
if err := decodeBlock(sb); err != nil {
return nil, err
}
}
for _, bg := range cluster.BlockGroup {
if err := decodeBlock(bg.Block); err != nil {
return nil, err
}
}
}
skip := min(preSkip*channels, len(pcm))
pcm = pcm[skip:]
return encodeWAV(pcm, opusOutputSampleRate, channels), nil
}
// IsWebMContentType reports whether the MIME type is WebM audio.
// Both "audio/webm" and "audio/webm; codecs=opus" return true.
func IsWebMContentType(contentType string) bool {
contentType = strings.TrimSpace(contentType)
if contentType == "" {
return false
}
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = contentType[:i]
}
return strings.EqualFold(strings.TrimSpace(contentType), "audio/webm")
}
func findOpusTrack(entries []webm.TrackEntry) *webm.TrackEntry {
for i := range entries {
entry := &entries[i]
if entry.CodecID == opusCodecID && entry.Audio != nil {
return entry
}
}
return nil
}
// encodeWAV writes a standard RIFF/WAVE container around 16-bit PCM samples.
// Reference layout: http://soundfile.sapp.org/doc/WaveFormat/
func encodeWAV(samples []int16, sampleRate, channels int) []byte {
const bitsPerSample = 16
const bytesPerSample = bitsPerSample / 8
blockAlign := channels * bytesPerSample
byteRate := sampleRate * blockAlign
dataSize := len(samples) * bytesPerSample
buf := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
buf.WriteString("RIFF")
_ = binary.Write(buf, binary.LittleEndian, uint32(36+dataSize))
buf.WriteString("WAVE")
buf.WriteString("fmt ")
_ = binary.Write(buf, binary.LittleEndian, uint32(16))
_ = binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM
_ = binary.Write(buf, binary.LittleEndian, uint16(channels))
_ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate))
_ = binary.Write(buf, binary.LittleEndian, uint32(byteRate))
_ = binary.Write(buf, binary.LittleEndian, uint16(blockAlign))
_ = binary.Write(buf, binary.LittleEndian, uint16(bitsPerSample))
buf.WriteString("data")
_ = binary.Write(buf, binary.LittleEndian, uint32(dataSize))
_ = binary.Write(buf, binary.LittleEndian, samples)
return buf.Bytes()
}
+48
View File
@@ -0,0 +1,48 @@
package audio
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestIsWebMContentType(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"audio/webm", true},
{"audio/webm;codecs=opus", true},
{"audio/webm; codecs=opus", true},
{"AUDIO/WEBM", true},
{" audio/webm ", true},
{"audio/wav", false},
{"audio/mp4", false},
{"video/webm", false},
{"", false},
{"webm", false},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
require.Equal(t, tc.want, IsWebMContentType(tc.in))
})
}
}
func TestWebMOpusToWAV_RejectsInvalidInput(t *testing.T) {
t.Run("empty", func(t *testing.T) {
_, err := WebMOpusToWAV(nil)
require.Error(t, err)
})
t.Run("not webm", func(t *testing.T) {
_, err := WebMOpusToWAV([]byte("hello world this is not webm"))
require.Error(t, err)
})
t.Run("truncated webm header bytes", func(t *testing.T) {
// Valid EBML magic but no Segment.
_, err := WebMOpusToWAV([]byte{0x1A, 0x45, 0xDF, 0xA3})
require.Error(t, err)
})
}
+41
View File
@@ -0,0 +1,41 @@
// Package audiollm defines the multimodal-audio capability for AI providers.
// Implementations call chat-completions or generate-content style APIs that
// accept audio as input. For deterministic transcription, prefer internal/ai/stt
// where a dedicated STT endpoint exists.
package audiollm
import (
"context"
"io"
)
// Model invokes a multimodal LLM with audio input.
type Model interface {
GenerateFromAudio(ctx context.Context, req Request) (*Response, error)
}
// Request is the input to a multimodal-audio call.
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 the provider default in place
}
// Response is the output of a multimodal-audio call.
type Response struct {
Text string
FinishReason FinishReason
}
// FinishReason describes why the model stopped generating.
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, including unknown
)
+202
View File
@@ -0,0 +1,202 @@
// Package gemini implements audiollm.Model against the Gemini generateContent
// endpoint. Used by Memos transcription when the user picks a Gemini provider:
// the handler issues a transcription instruction via audiollm.Request.Instructions.
package gemini
import (
"context"
"io"
"mime"
"net/url"
"strings"
"github.com/pkg/errors"
"google.golang.org/genai"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/audio"
"github.com/usememos/memos/internal/ai/audiollm"
)
const (
defaultEndpoint = "https://generativelanguage.googleapis.com/v1beta"
defaultAPIVersion = "v1beta"
maxInlineSize = 14 * 1024 * 1024
providerName = "Gemini"
)
var supportedContentTypes = map[string]string{
"audio/wav": "audio/wav",
"audio/x-wav": "audio/wav",
"audio/mp3": "audio/mp3",
"audio/mpeg": "audio/mp3",
"audio/aiff": "audio/aiff",
"audio/aac": "audio/aac",
"audio/ogg": "audio/ogg",
"audio/flac": "audio/flac",
"audio/x-flac": "audio/flac",
}
// Model implements audiollm.Model for Gemini generateContent.
type Model struct {
client *genai.Client
}
// New constructs a Model from a provider config.
func New(cfg ai.ProviderConfig, options audiollm.Options) (*Model, error) {
endpoint, err := normalizeEndpoint(cfg.Endpoint)
if err != nil {
return nil, err
}
if cfg.APIKey == "" {
return nil, errors.Errorf("%s API key is required", providerName)
}
baseURL, apiVersion, err := splitEndpoint(endpoint)
if err != nil {
return nil, err
}
httpOptions := genai.HTTPOptions{BaseURL: baseURL, APIVersion: apiVersion}
if options.HTTPClient != nil && options.HTTPClient.Timeout > 0 {
timeout := options.HTTPClient.Timeout
httpOptions.Timeout = &timeout
}
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
APIKey: cfg.APIKey,
Backend: genai.BackendGeminiAPI,
HTTPClient: options.HTTPClient,
HTTPOptions: httpOptions,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create Gemini client")
}
return &Model{client: client}, nil
}
// GenerateFromAudio calls Gemini generateContent with the audio attached.
func (m *Model) GenerateFromAudio(ctx context.Context, req audiollm.Request) (*audiollm.Response, error) {
if strings.TrimSpace(req.Model) == "" {
return nil, errors.New("model is required")
}
if req.Audio == nil {
return nil, errors.New("audio is required")
}
if strings.TrimSpace(req.Instructions) == "" {
return nil, errors.New("instructions are required")
}
audioBytes, err := io.ReadAll(req.Audio)
if err != nil {
return nil, errors.Wrap(err, "failed to read audio")
}
if len(audioBytes) == 0 {
return nil, errors.New("audio is required")
}
contentType := req.ContentType
if audio.IsWebMContentType(contentType) {
wav, err := audio.WebMOpusToWAV(audioBytes)
if err != nil {
return nil, errors.Wrap(err, "failed to transcode webm audio for Gemini")
}
audioBytes = wav
contentType = "audio/wav"
}
if len(audioBytes) > maxInlineSize {
return nil, errors.Errorf("audio is too large for Gemini inline request; maximum size is %d bytes", maxInlineSize)
}
contentType, err = normalizeContentType(contentType)
if err != nil {
return nil, err
}
cfg := &genai.GenerateContentConfig{}
if req.Temperature != nil {
t := *req.Temperature
cfg.Temperature = &t
}
resp, err := m.client.Models.GenerateContent(ctx, normalizeModelName(req.Model), []*genai.Content{
genai.NewContentFromParts([]*genai.Part{
genai.NewPartFromBytes(audioBytes, contentType),
genai.NewPartFromText(req.Instructions),
}, genai.RoleUser),
}, cfg)
if err != nil {
return nil, errors.Wrap(err, "failed to send Gemini request")
}
return &audiollm.Response{
Text: strings.TrimSpace(resp.Text()),
FinishReason: mapFinishReason(resp),
}, nil
}
func mapFinishReason(resp *genai.GenerateContentResponse) audiollm.FinishReason {
if resp == nil || len(resp.Candidates) == 0 {
return audiollm.FinishOther
}
switch resp.Candidates[0].FinishReason {
case genai.FinishReasonStop:
return audiollm.FinishStop
case genai.FinishReasonMaxTokens:
return audiollm.FinishLength
case genai.FinishReasonSafety,
genai.FinishReasonRecitation,
genai.FinishReasonProhibitedContent,
genai.FinishReasonSPII,
genai.FinishReasonBlocklist,
genai.FinishReasonImageSafety,
genai.FinishReasonImageProhibitedContent,
genai.FinishReasonImageRecitation:
return audiollm.FinishSafety
default:
return audiollm.FinishOther
}
}
func normalizeEndpoint(endpoint string) (string, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
endpoint = defaultEndpoint
}
if _, err := url.ParseRequestURI(endpoint); err != nil {
return "", errors.Wrapf(err, "invalid %s endpoint", providerName)
}
return strings.TrimRight(endpoint, "/"), nil
}
func splitEndpoint(endpoint string) (string, string, error) {
parsed, err := url.Parse(endpoint)
if err != nil {
return "", "", errors.Wrap(err, "invalid Gemini endpoint")
}
path := strings.TrimRight(parsed.Path, "/")
apiVersion := defaultAPIVersion
for _, supported := range []string{"v1alpha", "v1beta", "v1"} {
if path == "/"+supported || strings.HasSuffix(path, "/"+supported) {
apiVersion = supported
parsed.Path = strings.TrimSuffix(path, "/"+supported)
break
}
}
return strings.TrimRight(parsed.String(), "/"), apiVersion, nil
}
func normalizeContentType(contentType string) (string, error) {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
if err != nil {
return "", errors.Wrap(err, "invalid audio content type")
}
mediaType = strings.ToLower(mediaType)
normalized, ok := supportedContentTypes[mediaType]
if !ok {
return "", errors.Errorf("audio content type %q is not supported by Gemini", mediaType)
}
return normalized, nil
}
func normalizeModelName(model string) string {
return strings.TrimPrefix(strings.TrimSpace(model), "models/")
}
+106
View File
@@ -0,0 +1,106 @@
package gemini_test
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/audiollm"
audiollmgemini "github.com/usememos/memos/internal/ai/audiollm/gemini"
)
func TestGenerateFromAudio(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/v1beta/models/gemini-2.5-flash:generateContent", r.URL.Path)
require.Equal(t, "test-key", r.Header.Get("x-goog-api-key"))
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
var request struct {
Contents []struct {
Parts []struct {
Text string `json:"text"`
InlineData *struct {
MIMEType string `json:"mimeType"`
Data string `json:"data"`
} `json:"inlineData"`
} `json:"parts"`
} `json:"contents"`
GenerationConfig map[string]json.Number `json:"generationConfig"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Len(t, request.Contents, 1)
require.Len(t, request.Contents[0].Parts, 2)
require.NotNil(t, request.Contents[0].Parts[0].InlineData)
require.Equal(t, "audio/mp3", request.Contents[0].Parts[0].InlineData.MIMEType)
audio, err := base64.StdEncoding.DecodeString(request.Contents[0].Parts[0].InlineData.Data)
require.NoError(t, err)
require.Equal(t, "audio bytes", string(audio))
require.Equal(t, "transcribe please", request.Contents[0].Parts[1].Text)
require.Equal(t, json.Number("0"), request.GenerationConfig["temperature"])
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{
{
"finishReason": "STOP",
"content": map[string]any{
"parts": []map[string]string{{"text": "hello from gemini"}},
},
},
},
}))
}))
defer server.Close()
model, err := audiollmgemini.New(ai.ProviderConfig{
Type: ai.ProviderGemini,
Endpoint: server.URL + "/v1beta",
APIKey: "test-key",
}, audiollm.ApplyOptions(nil))
require.NoError(t, err)
temp := float32(0)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := model.GenerateFromAudio(ctx, audiollm.Request{
Model: "models/gemini-2.5-flash",
ContentType: "audio/mpeg",
Audio: strings.NewReader("audio bytes"),
Instructions: "transcribe please",
Temperature: &temp,
})
require.NoError(t, err)
require.Equal(t, "hello from gemini", resp.Text)
require.Equal(t, audiollm.FinishStop, resp.FinishReason)
}
func TestGenerateFromAudioRejectsUnsupportedContentType(t *testing.T) {
t.Parallel()
model, err := audiollmgemini.New(ai.ProviderConfig{
Type: ai.ProviderGemini,
Endpoint: "https://example.com/v1beta",
APIKey: "test-key",
}, audiollm.ApplyOptions(nil))
require.NoError(t, err)
_, err = model.GenerateFromAudio(context.Background(), audiollm.Request{
Model: "gemini-2.5-flash",
ContentType: "video/mp4",
Audio: strings.NewReader("video bytes"),
Instructions: "transcribe please",
})
require.Error(t, err)
require.Contains(t, err.Error(), "not supported by Gemini")
}
+34
View File
@@ -0,0 +1,34 @@
package audiollm
import (
"net/http"
"time"
)
const defaultHTTPTimeout = 2 * time.Minute
// Options is the resolved option set passed to provider implementations.
type Options struct {
HTTPClient *http.Client
}
// ModelOption customizes a Model.
type ModelOption func(*Options)
// WithHTTPClient overrides the HTTP client used by the model.
func WithHTTPClient(client *http.Client) ModelOption {
return func(o *Options) {
if client != nil {
o.HTTPClient = client
}
}
}
// ApplyOptions resolves a ModelOption slice into Options with defaults.
func ApplyOptions(opts []ModelOption) Options {
resolved := Options{HTTPClient: &http.Client{Timeout: defaultHTTPTimeout}}
for _, apply := range opts {
apply(&resolved)
}
return resolved
}
+17
View File
@@ -0,0 +1,17 @@
package ai
import "github.com/pkg/errors"
var (
// ErrProviderNotFound indicates that a requested provider ID does not exist.
ErrProviderNotFound = errors.New("AI provider not found")
// ErrCapabilityUnsupported indicates that the provider does not support the requested capability.
ErrCapabilityUnsupported = errors.New("AI provider capability unsupported")
// ErrSTTNotSupported indicates that the provider does not have a dedicated
// speech-to-text endpoint. Use the audiollm package for multimodal audio
// understanding when this is returned.
ErrSTTNotSupported = errors.New("provider does not support speech-to-text capability")
// ErrAudioLLMNotSupported indicates that the provider does not have a
// multimodal-audio LLM available in this codebase.
ErrAudioLLMNotSupported = errors.New("provider does not support multimodal audio capability")
)
+22
View File
@@ -0,0 +1,22 @@
package ai
import "github.com/pkg/errors"
const (
// DefaultOpenAITranscriptionModel is the built-in OpenAI transcription model.
DefaultOpenAITranscriptionModel = "whisper-1"
// DefaultGeminiTranscriptionModel is the built-in Gemini transcription model.
DefaultGeminiTranscriptionModel = "gemini-2.5-flash"
)
// DefaultTranscriptionModel returns the built-in transcription model for a provider.
func DefaultTranscriptionModel(providerType ProviderType) (string, error) {
switch providerType {
case ProviderOpenAI:
return DefaultOpenAITranscriptionModel, nil
case ProviderGemini:
return DefaultGeminiTranscriptionModel, nil
default:
return "", errors.Wrapf(ErrCapabilityUnsupported, "provider type %q", providerType)
}
}
+16
View File
@@ -0,0 +1,16 @@
package ai
import "github.com/pkg/errors"
// FindProvider returns the provider with the given ID.
func FindProvider(providers []ProviderConfig, providerID string) (*ProviderConfig, error) {
if providerID == "" {
return nil, errors.Wrap(ErrProviderNotFound, "provider ID is required")
}
for _, provider := range providers {
if provider.ID == providerID {
return &provider, nil
}
}
return nil, errors.Wrapf(ErrProviderNotFound, "provider ID %q", providerID)
}
+116
View File
@@ -0,0 +1,116 @@
// Package openai implements stt.Transcriber against the OpenAI
// /audio/transcriptions endpoint (and any compatible third-party endpoint
// such as Groq Whisper, faster-whisper self-hosted, or Azure Whisper).
package openai
import (
"context"
"mime"
"net/url"
"strings"
openaisdk "github.com/openai/openai-go/v3"
openaioption "github.com/openai/openai-go/v3/option"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/stt"
)
const defaultEndpoint = "https://api.openai.com/v1"
// Transcriber implements stt.Transcriber for OpenAI-compatible STT endpoints.
type Transcriber struct {
client openaisdk.Client
}
// New constructs a Transcriber from a provider config.
func New(cfg ai.ProviderConfig, options stt.Options) (*Transcriber, error) {
endpoint, err := normalizeEndpoint(cfg.Endpoint)
if err != nil {
return nil, err
}
if cfg.APIKey == "" {
return nil, errors.New("OpenAI API key is required")
}
return &Transcriber{
client: openaisdk.NewClient(
openaioption.WithAPIKey(cfg.APIKey),
openaioption.WithBaseURL(endpoint),
openaioption.WithHTTPClient(options.HTTPClient),
),
}, nil
}
// Transcribe sends the audio to /audio/transcriptions.
func (t *Transcriber) Transcribe(ctx context.Context, req stt.Request) (*stt.Response, error) {
if strings.TrimSpace(req.Model) == "" {
return nil, errors.New("model is required")
}
if req.Audio == nil {
return nil, errors.New("audio is required")
}
filename, contentType, err := normalizeAudioMetadata(req)
if err != nil {
return nil, err
}
params := openaisdk.AudioTranscriptionNewParams{
File: openaisdk.File(req.Audio, filename, contentType),
Model: openaisdk.AudioModel(req.Model),
ResponseFormat: openaisdk.AudioResponseFormatJSON,
}
if req.Prompt != "" {
params.Prompt = openaisdk.String(req.Prompt)
}
if req.Language != "" {
params.Language = openaisdk.String(req.Language)
}
resp, err := t.client.Audio.Transcriptions.New(ctx, params)
if err != nil {
return nil, errors.Wrap(err, "failed to send OpenAI transcription request")
}
return &stt.Response{
Text: resp.Text,
Language: resp.Language,
}, nil
}
func normalizeEndpoint(endpoint string) (string, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
endpoint = defaultEndpoint
}
if _, err := url.ParseRequestURI(endpoint); err != nil {
return "", errors.Wrap(err, "invalid OpenAI endpoint")
}
return strings.TrimRight(endpoint, "/"), nil
}
func normalizeAudioMetadata(req stt.Request) (string, string, error) {
filename := strings.TrimSpace(req.Filename)
if filename == "" {
filename = "audio"
}
contentType := strings.TrimSpace(req.ContentType)
if contentType == "" {
contentType = "application/octet-stream"
} else {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return "", "", errors.Wrap(err, "invalid audio content type")
}
contentType = mediaType
}
return sanitizeFilename(filename), contentType, nil
}
func sanitizeFilename(filename string) string {
filename = strings.NewReplacer("\r", "_", "\n", "_").Replace(filename)
if strings.TrimSpace(filename) == "" {
return "audio"
}
return filename
}
+68
View File
@@ -0,0 +1,68 @@
package openai_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/stt"
sttopenai "github.com/usememos/memos/internal/ai/stt/openai"
)
func TestTranscribe(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/audio/transcriptions", r.URL.Path)
require.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
require.NoError(t, r.ParseMultipartForm(10<<20))
require.Equal(t, "gpt-4o-transcribe", r.FormValue("model"))
require.Equal(t, "json", r.FormValue("response_format"))
require.Equal(t, "domain words", r.FormValue("prompt"))
require.Equal(t, "en", r.FormValue("language"))
file, header, err := r.FormFile("file")
require.NoError(t, err)
defer file.Close()
require.Equal(t, "voice.wav", header.Filename)
require.Equal(t, "audio/wav", header.Header.Get("Content-Type"))
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"text": "hello world",
"language": "en",
"duration": 1.5,
}))
}))
defer server.Close()
transcriber, err := sttopenai.New(ai.ProviderConfig{
Type: ai.ProviderOpenAI,
Endpoint: server.URL,
APIKey: "test-key",
}, stt.ApplyOptions(nil))
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
response, err := transcriber.Transcribe(ctx, stt.Request{
Model: "gpt-4o-transcribe",
Filename: "voice.wav",
ContentType: "audio/wav",
Audio: strings.NewReader("RIFF"),
Prompt: "domain words",
Language: "en",
})
require.NoError(t, err)
require.Equal(t, "hello world", response.Text)
require.Equal(t, "en", response.Language)
// Note: Duration intentionally omitted from stt.Response — not exposed in the new contract.
}
+34
View File
@@ -0,0 +1,34 @@
package stt
import (
"net/http"
"time"
)
const defaultHTTPTimeout = 2 * time.Minute
// Options is the resolved option set passed to provider implementations.
type Options struct {
HTTPClient *http.Client
}
// TranscriberOption customizes a Transcriber.
type TranscriberOption func(*Options)
// WithHTTPClient overrides the HTTP client used by the transcriber.
func WithHTTPClient(client *http.Client) TranscriberOption {
return func(o *Options) {
if client != nil {
o.HTTPClient = client
}
}
}
// ApplyOptions resolves a TranscriberOption slice into Options with defaults.
func ApplyOptions(opts []TranscriberOption) Options {
resolved := Options{HTTPClient: &http.Client{Timeout: defaultHTTPTimeout}}
for _, apply := range opts {
apply(&resolved)
}
return resolved
}
+41
View File
@@ -0,0 +1,41 @@
// Package stt defines the speech-to-text capability for AI providers.
// Implementations call dedicated STT endpoints (e.g. OpenAI /audio/transcriptions)
// and return deterministic transcription output. For multimodal LLMs that
// happen to accept audio input, see internal/ai/audiollm.
package stt
import (
"context"
"io"
)
// Transcriber transcribes audio to text using a provider's dedicated STT endpoint.
type Transcriber interface {
Transcribe(ctx context.Context, req Request) (*Response, error)
}
// Request is the input to a transcription call.
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
}
// Response is the output of a transcription call.
type Response struct {
Text string
Language string // empty if provider did not return it
Segments []Segment // empty unless provider returned timestamps
}
// Segment is a timestamped portion of the transcript.
type Segment struct {
Text string
Start float64
End float64
Speaker string // empty unless using a diarization-capable model
}
+7
View File
@@ -0,0 +1,7 @@
package base
import "regexp"
var (
UIDMatcher = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$`)
)
@@ -1,4 +1,4 @@
package util
package base
import (
"testing"
@@ -22,6 +22,9 @@ func TestUIDMatcher(t *testing.T) {
{"a1234567890123456789012345678901", true},
{"abc123", true},
{"abc123-", false},
{"123e4567-e89b-12d3-a456-426614174000", true}, // UUID v4 from IDP
{"a12345678901234567890123456789012345", true}, // 36 characters.
{"a123456789012345678901234567890123456", false}, // 37 characters.
}
for _, test := range tests {
+507
View File
@@ -0,0 +1,507 @@
# Email Plugin
SMTP email sending functionality for self-hosted Memos instances.
## Overview
This plugin provides a simple, reliable email sending interface following industry-standard SMTP protocols. It's designed for self-hosted environments where instance administrators configure their own email service, similar to platforms like GitHub, GitLab, and Discourse.
## Features
- Standard SMTP protocol support
- TLS/STARTTLS and SSL/TLS encryption
- HTML and plain text emails
- Multiple recipients (To, Cc, Bcc)
- Synchronous and asynchronous sending
- Detailed error reporting with context
- Works with all major email providers
- Reply-To header support
- RFC 5322 compliant message formatting
## Quick Start
### 1. Configure SMTP Settings
```go
import "github.com/usememos/memos/internal/email"
config := &email.Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 587,
SMTPUsername: "your-email@gmail.com",
SMTPPassword: "your-app-password",
FromEmail: "noreply@yourdomain.com",
FromName: "Memos",
UseTLS: true,
}
```
### 2. Create and Send Email
```go
message := &email.Message{
To: []string{"user@example.com"},
Subject: "Welcome to Memos!",
Body: "Thanks for signing up.",
IsHTML: false,
}
// Synchronous send (waits for result)
err := email.Send(config, message)
if err != nil {
log.Printf("Failed to send email: %v", err)
}
// Asynchronous send (returns immediately)
email.SendAsync(config, message)
```
## Provider Configuration
### Gmail
Requires an [App Password](https://support.google.com/accounts/answer/185833) (2FA must be enabled):
```go
config := &email.Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 587,
SMTPUsername: "your-email@gmail.com",
SMTPPassword: "your-16-char-app-password",
FromEmail: "your-email@gmail.com",
FromName: "Memos",
UseTLS: true,
}
```
**Alternative (SSL):**
```go
config := &email.Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 465,
SMTPUsername: "your-email@gmail.com",
SMTPPassword: "your-16-char-app-password",
FromEmail: "your-email@gmail.com",
FromName: "Memos",
UseSSL: true,
}
```
### SendGrid
```go
config := &email.Config{
SMTPHost: "smtp.sendgrid.net",
SMTPPort: 587,
SMTPUsername: "apikey",
SMTPPassword: "your-sendgrid-api-key",
FromEmail: "noreply@yourdomain.com",
FromName: "Memos",
UseTLS: true,
}
```
### AWS SES
```go
config := &email.Config{
SMTPHost: "email-smtp.us-east-1.amazonaws.com",
SMTPPort: 587,
SMTPUsername: "your-smtp-username",
SMTPPassword: "your-smtp-password",
FromEmail: "verified@yourdomain.com",
FromName: "Memos",
UseTLS: true,
}
```
**Note:** Replace `us-east-1` with your AWS region. Email must be verified in SES.
### Mailgun
```go
config := &email.Config{
SMTPHost: "smtp.mailgun.org",
SMTPPort: 587,
SMTPUsername: "postmaster@yourdomain.com",
SMTPPassword: "your-mailgun-smtp-password",
FromEmail: "noreply@yourdomain.com",
FromName: "Memos",
UseTLS: true,
}
```
### Self-Hosted SMTP (Postfix, Exim, etc.)
```go
config := &email.Config{
SMTPHost: "mail.yourdomain.com",
SMTPPort: 587,
SMTPUsername: "username",
SMTPPassword: "password",
FromEmail: "noreply@yourdomain.com",
FromName: "Memos",
UseTLS: true,
}
```
## HTML Emails
```go
message := &email.Message{
To: []string{"user@example.com"},
Subject: "Welcome to Memos!",
Body: `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body style="font-family: Arial, sans-serif;">
<h1 style="color: #333;">Welcome to Memos!</h1>
<p>We're excited to have you on board.</p>
<a href="https://yourdomain.com" style="background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Get Started</a>
</body>
</html>
`,
IsHTML: true,
}
email.Send(config, message)
```
## Multiple Recipients
```go
message := &email.Message{
To: []string{"user1@example.com", "user2@example.com"},
Cc: []string{"manager@example.com"},
Bcc: []string{"admin@example.com"},
Subject: "Team Update",
Body: "Important team announcement...",
ReplyTo: "support@yourdomain.com",
}
email.Send(config, message)
```
## Testing
### Run Tests
```bash
# All tests
go test ./internal/email/... -v
# With coverage
go test ./internal/email/... -v -cover
# With race detector
go test ./internal/email/... -race
```
### Manual Testing
Create a simple test program:
```go
package main
import (
"log"
"github.com/usememos/memos/internal/email"
)
func main() {
config := &email.Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 587,
SMTPUsername: "your-email@gmail.com",
SMTPPassword: "your-app-password",
FromEmail: "your-email@gmail.com",
FromName: "Test",
UseTLS: true,
}
message := &email.Message{
To: []string{"recipient@example.com"},
Subject: "Test Email",
Body: "This is a test email from Memos email plugin.",
}
if err := email.Send(config, message); err != nil {
log.Fatalf("Failed to send email: %v", err)
}
log.Println("Email sent successfully!")
}
```
## Security Best Practices
### 1. Use TLS/SSL Encryption
Always enable encryption in production:
```go
// STARTTLS (port 587) - Recommended
config.UseTLS = true
// SSL/TLS (port 465)
config.UseSSL = true
```
### 2. Secure Credential Storage
Never hardcode credentials. Use environment variables:
```go
import "os"
config := &email.Config{
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: 587,
SMTPUsername: os.Getenv("SMTP_USERNAME"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
FromEmail: os.Getenv("SMTP_FROM_EMAIL"),
UseTLS: true,
}
```
### 3. Use App-Specific Passwords
For Gmail and similar services, use app passwords instead of your main account password.
### 4. Validate and Sanitize Input
Always validate email addresses and sanitize content:
```go
// Validate before sending
if err := message.Validate(); err != nil {
return err
}
```
### 5. Implement Rate Limiting
Prevent abuse by limiting email sending:
```go
// Example using golang.org/x/time/rate
limiter := rate.NewLimiter(rate.Every(time.Second), 10) // 10 emails per second
if !limiter.Allow() {
return errors.New("rate limit exceeded")
}
```
### 6. Monitor and Log
Log email sending activity for security monitoring:
```go
if err := email.Send(config, message); err != nil {
slog.Error("Email send failed",
slog.String("recipient", message.To[0]),
slog.Any("error", err))
}
```
## Common Ports
| Port | Protocol | Security | Use Case |
|------|----------|----------|----------|
| **587** | SMTP + STARTTLS | Encrypted | **Recommended** for most providers |
| **465** | SMTP over SSL/TLS | Encrypted | Alternative secure option |
| **25** | SMTP | Unencrypted | Legacy, often blocked by ISPs |
| **2525** | SMTP + STARTTLS | Encrypted | Alternative when 587 is blocked |
**Port 587 (STARTTLS)** is the recommended standard for modern SMTP:
```go
config := &email.Config{
SMTPPort: 587,
UseTLS: true,
}
```
**Port 465 (SSL/TLS)** is the alternative:
```go
config := &email.Config{
SMTPPort: 465,
UseSSL: true,
}
```
## Error Handling
The package provides detailed, contextual errors:
```go
err := email.Send(config, message)
if err != nil {
// Error messages include context:
switch {
case strings.Contains(err.Error(), "invalid email configuration"):
// Configuration error (missing host, invalid port, etc.)
log.Printf("Configuration error: %v", err)
case strings.Contains(err.Error(), "invalid email message"):
// Message validation error (missing recipients, subject, body)
log.Printf("Message error: %v", err)
case strings.Contains(err.Error(), "authentication failed"):
// SMTP authentication failed (wrong credentials)
log.Printf("Auth error: %v", err)
case strings.Contains(err.Error(), "failed to connect"):
// Network/connection error
log.Printf("Connection error: %v", err)
case strings.Contains(err.Error(), "recipient rejected"):
// SMTP server rejected recipient
log.Printf("Recipient error: %v", err)
default:
log.Printf("Unknown error: %v", err)
}
}
```
### Common Error Messages
```
❌ "invalid email configuration: SMTP host is required"
→ Fix: Set config.SMTPHost
❌ "invalid email configuration: SMTP port must be between 1 and 65535"
→ Fix: Set valid config.SMTPPort (usually 587 or 465)
❌ "invalid email configuration: from email is required"
→ Fix: Set config.FromEmail
❌ "invalid email message: at least one recipient is required"
→ Fix: Set message.To with at least one email address
❌ "invalid email message: subject is required"
→ Fix: Set message.Subject
❌ "invalid email message: body is required"
→ Fix: Set message.Body
❌ "SMTP authentication failed"
→ Fix: Check credentials (username/password)
❌ "failed to connect to SMTP server"
→ Fix: Verify host/port, check firewall, ensure TLS/SSL settings match server
```
### Async Error Handling
For async sending, errors are logged automatically:
```go
email.SendAsync(config, message)
// Errors logged as:
// [WARN] Failed to send email asynchronously recipients=user@example.com error=...
```
## Dependencies
### Required
- **Go 1.25+**
- Standard library: `net/smtp`, `crypto/tls`
- `github.com/pkg/errors` - Error wrapping with context
### No External SMTP Libraries
This plugin uses Go's standard `net/smtp` library for maximum compatibility and minimal dependencies.
## API Reference
### Types
#### `Config`
```go
type Config struct {
SMTPHost string // SMTP server hostname
SMTPPort int // SMTP server port
SMTPUsername string // SMTP auth username
SMTPPassword string // SMTP auth password
FromEmail string // From email address
FromName string // From display name (optional)
UseTLS bool // Enable STARTTLS (port 587)
UseSSL bool // Enable SSL/TLS (port 465)
}
```
#### `Message`
```go
type Message struct {
To []string // Recipients
Cc []string // CC recipients (optional)
Bcc []string // BCC recipients (optional)
Subject string // Email subject
Body string // Email body (plain text or HTML)
IsHTML bool // true for HTML, false for plain text
ReplyTo string // Reply-To address (optional)
}
```
### Functions
#### `Send(config *Config, message *Message) error`
Sends an email synchronously. Blocks until email is sent or error occurs.
#### `SendAsync(config *Config, message *Message)`
Sends an email asynchronously in a goroutine. Returns immediately. Errors are logged.
#### `NewClient(config *Config) *Client`
Creates a new SMTP client for advanced usage.
#### `Client.Send(message *Message) error`
Sends email using the client's configuration.
## Architecture
```
internal/email/
├── config.go # SMTP configuration types
├── message.go # Email message types and formatting
├── client.go # SMTP client implementation
├── email.go # High-level Send/SendAsync API
├── doc.go # Package documentation
└── *_test.go # Unit tests
```
## License
Part of the Memos project. See main repository for license details.
## Contributing
This package follows the Memos contribution guidelines. Please ensure:
1. All code is tested (TDD approach)
2. Tests pass: `go test ./internal/email/... -v`
3. Code is formatted: `go fmt ./internal/email/...`
4. No linting errors: `golangci-lint run ./internal/email/...`
## Support
For issues and questions:
- Memos GitHub Issues: https://github.com/usememos/memos/issues
- Memos Documentation: https://usememos.com/docs
## Roadmap
Future enhancements may include:
- Email template system
- Attachment support
- Inline image embedding
- Email queuing system
- Delivery status tracking
- Bounce handling
+173
View File
@@ -0,0 +1,173 @@
package email
import (
"crypto/tls"
"net"
"net/smtp"
"time"
"github.com/pkg/errors"
)
const smtpOperationTimeout = 15 * time.Second
// Client represents an SMTP email client.
type Client struct {
config *Config
}
// NewClient creates a new email client with the given configuration.
func NewClient(config *Config) *Client {
return &Client{
config: config,
}
}
// validateConfig validates the client configuration.
func (c *Client) validateConfig() error {
if c.config == nil {
return errors.New("email configuration is required")
}
return c.config.Validate()
}
// createAuth creates an SMTP auth mechanism if credentials are provided.
func (c *Client) createAuth() smtp.Auth {
if c.config.SMTPUsername == "" && c.config.SMTPPassword == "" {
return nil
}
return smtp.PlainAuth("", c.config.SMTPUsername, c.config.SMTPPassword, c.config.SMTPHost)
}
// createTLSConfig creates a TLS configuration for secure connections.
func (c *Client) createTLSConfig() *tls.Config {
return &tls.Config{
ServerName: c.config.SMTPHost,
MinVersion: tls.VersionTLS12,
}
}
// Send sends an email message via SMTP.
func (c *Client) Send(message *Message) error {
// Validate configuration
if err := c.validateConfig(); err != nil {
return errors.Wrap(err, "invalid email configuration")
}
// Validate message
if message == nil {
return errors.New("message is required")
}
if err := message.Validate(); err != nil {
return errors.Wrap(err, "invalid email message")
}
// Format the message
body := message.Format(c.config.FromEmail, c.config.FromName)
// Get all recipients
recipients := message.GetAllRecipients()
// Create auth
auth := c.createAuth()
// Send based on encryption type
if c.config.UseSSL {
return c.sendWithSSL(auth, recipients, body)
}
return c.sendWithTLS(auth, recipients, body)
}
// sendWithTLS sends email using STARTTLS (port 587).
func (c *Client) sendWithTLS(auth smtp.Auth, recipients []string, body string) error {
serverAddr := c.config.GetServerAddress()
dialer := &net.Dialer{Timeout: smtpOperationTimeout}
conn, err := dialer.Dial("tcp", serverAddr)
if err != nil {
return errors.Wrapf(err, "failed to connect to SMTP server: %s", serverAddr)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(smtpOperationTimeout)); err != nil {
return errors.Wrap(err, "failed to set SMTP connection deadline")
}
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return errors.Wrap(err, "failed to create SMTP client")
}
defer client.Quit()
if c.config.UseTLS {
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("SMTP server does not support STARTTLS")
}
if err := client.StartTLS(c.createTLSConfig()); err != nil {
return errors.Wrap(err, "failed to start SMTP STARTTLS")
}
}
return c.sendWithClient(client, auth, recipients, body)
}
// sendWithSSL sends email using SSL/TLS (port 465).
func (c *Client) sendWithSSL(auth smtp.Auth, recipients []string, body string) error {
serverAddr := c.config.GetServerAddress()
// Create TLS connection
tlsConfig := c.createTLSConfig()
dialer := &net.Dialer{Timeout: smtpOperationTimeout}
conn, err := tls.DialWithDialer(dialer, "tcp", serverAddr, tlsConfig)
if err != nil {
return errors.Wrapf(err, "failed to connect to SMTP server with SSL: %s", serverAddr)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(smtpOperationTimeout)); err != nil {
return errors.Wrap(err, "failed to set SMTP connection deadline")
}
// Create SMTP client
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return errors.Wrap(err, "failed to create SMTP client")
}
defer client.Quit()
return c.sendWithClient(client, auth, recipients, body)
}
func (c *Client) sendWithClient(client *smtp.Client, auth smtp.Auth, recipients []string, body string) error {
if auth != nil {
if err := client.Auth(auth); err != nil {
return errors.Wrap(err, "SMTP authentication failed")
}
}
// Set sender
if err := client.Mail(c.config.FromEmail); err != nil {
return errors.Wrap(err, "failed to set sender")
}
// Set recipients
for _, recipient := range recipients {
if err := client.Rcpt(recipient); err != nil {
return errors.Wrapf(err, "failed to set recipient: %s", recipient)
}
}
// Send message body
writer, err := client.Data()
if err != nil {
return errors.Wrap(err, "failed to send DATA command")
}
if _, err := writer.Write([]byte(body)); err != nil {
return errors.Wrap(err, "failed to write message body")
}
if err := writer.Close(); err != nil {
return errors.Wrap(err, "failed to close message writer")
}
return nil
}
+121
View File
@@ -0,0 +1,121 @@
package email
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewClient(t *testing.T) {
config := &Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
SMTPUsername: "user@example.com",
SMTPPassword: "password",
FromEmail: "noreply@example.com",
FromName: "Test App",
UseTLS: true,
}
client := NewClient(config)
assert.NotNil(t, client)
assert.Equal(t, config, client.config)
}
func TestClientValidateConfig(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
}{
{
name: "valid config",
config: &Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
FromEmail: "test@example.com",
},
wantErr: false,
},
{
name: "nil config",
config: nil,
wantErr: true,
},
{
name: "invalid config",
config: &Config{
SMTPHost: "",
SMTPPort: 587,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(tt.config)
err := client.validateConfig()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestClientSendValidation(t *testing.T) {
config := &Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
FromEmail: "test@example.com",
}
client := NewClient(config)
tests := []struct {
name string
message *Message
wantErr bool
}{
{
name: "valid message",
message: &Message{
To: []string{"recipient@example.com"},
Subject: "Test",
Body: "Test body",
},
wantErr: false, // Will fail on actual send, but passes validation
},
{
name: "nil message",
message: nil,
wantErr: true,
},
{
name: "invalid message",
message: &Message{
To: []string{},
Subject: "Test",
Body: "Test",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := client.Send(tt.message)
// We expect validation errors for invalid messages
// For valid messages, we'll get connection errors (which is expected in tests)
if tt.wantErr {
assert.Error(t, err)
// Should fail validation before attempting connection
assert.NotContains(t, err.Error(), "dial")
}
// Note: We don't assert NoError for valid messages because
// we don't have a real SMTP server in tests
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package email
import (
"fmt"
"github.com/pkg/errors"
)
// Config represents the SMTP configuration for email sending.
// These settings should be provided by the self-hosted instance administrator.
type Config struct {
// SMTPHost is the SMTP server hostname (e.g., "smtp.gmail.com")
SMTPHost string
// SMTPPort is the SMTP server port (common: 587 for TLS, 465 for SSL, 25 for unencrypted)
SMTPPort int
// SMTPUsername is the SMTP authentication username (usually the email address)
SMTPUsername string
// SMTPPassword is the SMTP authentication password or app-specific password
SMTPPassword string
// FromEmail is the email address that will appear in the "From" field
FromEmail string
// FromName is the display name that will appear in the "From" field
FromName string
// UseTLS enables STARTTLS encryption (recommended for port 587)
UseTLS bool
// UseSSL enables SSL/TLS encryption (for port 465)
UseSSL bool
}
// Validate checks if the configuration is valid.
func (c *Config) Validate() error {
if c.SMTPHost == "" {
return errors.New("SMTP host is required")
}
if c.SMTPPort <= 0 || c.SMTPPort > 65535 {
return errors.New("SMTP port must be between 1 and 65535")
}
if c.FromEmail == "" {
return errors.New("from email is required")
}
return nil
}
// GetServerAddress returns the SMTP server address in the format "host:port".
func (c *Config) GetServerAddress() string {
return fmt.Sprintf("%s:%d", c.SMTPHost, c.SMTPPort)
}
+80
View File
@@ -0,0 +1,80 @@
package email
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigValidation(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
}{
{
name: "valid config",
config: &Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 587,
SMTPUsername: "user@example.com",
SMTPPassword: "password",
FromEmail: "noreply@example.com",
FromName: "Memos",
},
wantErr: false,
},
{
name: "missing host",
config: &Config{
SMTPPort: 587,
SMTPUsername: "user@example.com",
SMTPPassword: "password",
FromEmail: "noreply@example.com",
},
wantErr: true,
},
{
name: "invalid port",
config: &Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 0,
SMTPUsername: "user@example.com",
SMTPPassword: "password",
FromEmail: "noreply@example.com",
},
wantErr: true,
},
{
name: "missing from email",
config: &Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 587,
SMTPUsername: "user@example.com",
SMTPPassword: "password",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestConfigGetServerAddress(t *testing.T) {
config := &Config{
SMTPHost: "smtp.gmail.com",
SMTPPort: 587,
}
expected := "smtp.gmail.com:587"
assert.Equal(t, expected, config.GetServerAddress())
}
+98
View File
@@ -0,0 +1,98 @@
// Package email provides SMTP email sending functionality for self-hosted Memos instances.
//
// This package is designed for self-hosted environments where instance administrators
// configure their own SMTP servers. It follows industry-standard patterns used by
// platforms like GitHub, GitLab, and Discourse.
//
// # Configuration
//
// The package requires SMTP server configuration provided by the instance administrator:
//
// config := &email.Config{
// SMTPHost: "smtp.gmail.com",
// SMTPPort: 587,
// SMTPUsername: "your-email@gmail.com",
// SMTPPassword: "your-app-password",
// FromEmail: "noreply@yourdomain.com",
// FromName: "Memos Notifications",
// UseTLS: true,
// }
//
// # Common SMTP Settings
//
// Gmail (requires App Password):
// - Host: smtp.gmail.com
// - Port: 587 (TLS) or 465 (SSL)
// - Username: your-email@gmail.com
// - UseTLS: true (for port 587) or UseSSL: true (for port 465)
//
// SendGrid:
// - Host: smtp.sendgrid.net
// - Port: 587
// - Username: apikey
// - Password: your-sendgrid-api-key
// - UseTLS: true
//
// AWS SES:
// - Host: email-smtp.[region].amazonaws.com
// - Port: 587
// - Username: your-smtp-username
// - Password: your-smtp-password
// - UseTLS: true
//
// Mailgun:
// - Host: smtp.mailgun.org
// - Port: 587
// - Username: your-mailgun-smtp-username
// - Password: your-mailgun-smtp-password
// - UseTLS: true
//
// # Sending Email
//
// Synchronous (waits for completion):
//
// message := &email.Message{
// To: []string{"user@example.com"},
// Subject: "Welcome to Memos",
// Body: "Thank you for joining!",
// IsHTML: false,
// }
//
// err := email.Send(config, message)
// if err != nil {
// // Handle error
// }
//
// Asynchronous (returns immediately):
//
// email.SendAsync(config, message)
// // Errors are logged but not returned
//
// # HTML Email
//
// message := &email.Message{
// To: []string{"user@example.com"},
// Subject: "Welcome!",
// Body: "<html><body><h1>Welcome to Memos!</h1></body></html>",
// IsHTML: true,
// }
//
// # Security Considerations
//
// - Always use TLS (port 587) or SSL (port 465) for production
// - Store SMTP credentials securely (environment variables or secrets management)
// - Use app-specific passwords for services like Gmail
// - Validate and sanitize email content to prevent injection attacks
// - Rate limit email sending to prevent abuse
//
// # Error Handling
//
// The package returns descriptive errors for common issues:
// - Configuration validation errors (missing host, invalid port, etc.)
// - Message validation errors (missing recipients, subject, or body)
// - Connection errors (cannot reach SMTP server)
// - Authentication errors (invalid credentials)
// - SMTP protocol errors (recipient rejected, etc.)
//
// All errors are wrapped with context using github.com/pkg/errors for better debugging.
package email
+61
View File
@@ -0,0 +1,61 @@
package email
import (
"log/slog"
"github.com/pkg/errors"
)
type asyncEmailRequest struct {
config *Config
message *Message
}
var asyncEmailQueue = make(chan asyncEmailRequest, 128)
func init() {
for range 2 {
go func() {
for request := range asyncEmailQueue {
if err := Send(request.config, request.message); err != nil {
recipients := ""
if request.message != nil && len(request.message.To) > 0 {
recipients = request.message.To[0]
if len(request.message.To) > 1 {
recipients += " and others"
}
}
slog.Warn("Failed to send email asynchronously",
slog.String("recipients", recipients),
slog.Any("error", err))
}
}
}()
}
}
// Send sends an email synchronously.
// Returns an error if the email fails to send.
func Send(config *Config, message *Message) error {
if config == nil {
return errors.New("email configuration is required")
}
if message == nil {
return errors.New("email message is required")
}
client := NewClient(config)
return client.Send(message)
}
// SendAsync sends an email asynchronously.
// It enqueues the message for bounded asynchronous sending and does not wait for the response.
// Any errors are logged but not returned.
func SendAsync(config *Config, message *Message) {
select {
case asyncEmailQueue <- asyncEmailRequest{config: config, message: message}:
default:
slog.Warn("Dropped email because the async queue is full")
}
}
+146
View File
@@ -0,0 +1,146 @@
package email
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
)
func TestSend(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
connectionClosed := make(chan struct{})
go func() {
defer close(connectionClosed)
conn, err := listener.Accept()
if err == nil {
_ = conn.Close()
}
}()
config := &Config{
SMTPHost: "127.0.0.1",
SMTPPort: listener.Addr().(*net.TCPAddr).Port,
FromEmail: "test@example.com",
}
message := &Message{
To: []string{"recipient@example.com"},
Subject: "Test",
Body: "Test body",
}
// The local server closes before sending an SMTP greeting, proving Send made
// it through validation and attempted to create an SMTP client.
err = Send(config, message)
select {
case <-connectionClosed:
case <-time.After(time.Second):
t.Fatal("SMTP listener did not accept a connection")
}
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to create SMTP client")
}
func TestSendValidation(t *testing.T) {
tests := []struct {
name string
config *Config
message *Message
wantErr bool
errMsg string
}{
{
name: "nil config",
config: nil,
message: &Message{To: []string{"test@example.com"}, Subject: "Test", Body: "Test"},
wantErr: true,
errMsg: "configuration is required",
},
{
name: "nil message",
config: &Config{SMTPHost: "smtp.example.com", SMTPPort: 587, FromEmail: "from@example.com"},
message: nil,
wantErr: true,
errMsg: "message is required",
},
{
name: "invalid config",
config: &Config{
SMTPHost: "",
SMTPPort: 587,
},
message: &Message{To: []string{"test@example.com"}, Subject: "Test", Body: "Test"},
wantErr: true,
errMsg: "invalid email configuration",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Send(tt.config, tt.message)
if tt.wantErr {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
}
})
}
}
func TestSendAsync(t *testing.T) {
config := &Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
FromEmail: "test@example.com",
}
message := &Message{
To: []string{"recipient@example.com"},
Subject: "Test Async",
Body: "Test async body",
}
// SendAsync should not block
start := time.Now()
SendAsync(config, message)
duration := time.Since(start)
// Should return almost immediately (< 100ms)
assert.Less(t, duration, 100*time.Millisecond)
// Give goroutine time to start
time.Sleep(50 * time.Millisecond)
}
func TestSendAsyncConcurrent(t *testing.T) {
config := &Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
FromEmail: "test@example.com",
}
g := errgroup.Group{}
count := 5
for i := 0; i < count; i++ {
g.Go(func() error {
message := &Message{
To: []string{"recipient@example.com"},
Subject: "Concurrent Test",
Body: "Test body",
}
SendAsync(config, message)
return nil
})
}
if err := g.Wait(); err != nil {
t.Fatalf("SendAsync calls failed: %v", err)
}
}
+110
View File
@@ -0,0 +1,110 @@
package email
import (
"errors"
"fmt"
"strings"
"time"
)
// Message represents an email message to be sent.
type Message struct {
To []string // Required: recipient email addresses
Cc []string // Optional: carbon copy recipients
Bcc []string // Optional: blind carbon copy recipients
Subject string // Required: email subject
Body string // Required: email body content
IsHTML bool // Whether the body is HTML (default: false for plain text)
ReplyTo string // Optional: reply-to address
}
// Validate checks that the message has all required fields.
func (m *Message) Validate() error {
if len(m.To) == 0 {
return errors.New("at least one recipient is required")
}
if m.Subject == "" {
return errors.New("subject is required")
}
if m.Body == "" {
return errors.New("body is required")
}
return nil
}
// Format creates an RFC 5322 formatted email message.
func (m *Message) Format(fromEmail, fromName string) string {
var sb strings.Builder
fromEmail = sanitizeEmailHeaderValue(fromEmail)
fromName = sanitizeEmailHeaderValue(fromName)
to := sanitizeEmailHeaderValues(m.To)
cc := sanitizeEmailHeaderValues(m.Cc)
replyTo := sanitizeEmailHeaderValue(m.ReplyTo)
subject := sanitizeEmailHeaderValue(m.Subject)
// From header
if fromName != "" {
fmt.Fprintf(&sb, "From: %s <%s>\r\n", fromName, fromEmail)
} else {
fmt.Fprintf(&sb, "From: %s\r\n", fromEmail)
}
// To header
fmt.Fprintf(&sb, "To: %s\r\n", strings.Join(to, ", "))
// Cc header (optional)
if len(cc) > 0 {
fmt.Fprintf(&sb, "Cc: %s\r\n", strings.Join(cc, ", "))
}
// Reply-To header (optional)
if replyTo != "" {
fmt.Fprintf(&sb, "Reply-To: %s\r\n", replyTo)
}
// Subject header
fmt.Fprintf(&sb, "Subject: %s\r\n", subject)
// Date header (RFC 5322 format)
fmt.Fprintf(&sb, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
// MIME headers
sb.WriteString("MIME-Version: 1.0\r\n")
// Content-Type header
if m.IsHTML {
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n")
} else {
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
}
// Empty line separating headers from body
sb.WriteString("\r\n")
// Body
sb.WriteString(m.Body)
return sb.String()
}
func sanitizeEmailHeaderValue(value string) string {
value = strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
return strings.Join(strings.Fields(value), " ")
}
func sanitizeEmailHeaderValues(values []string) []string {
sanitized := make([]string, 0, len(values))
for _, value := range values {
sanitized = append(sanitized, sanitizeEmailHeaderValue(value))
}
return sanitized
}
// GetAllRecipients returns all recipients (To, Cc, Bcc) as a single slice.
func (m *Message) GetAllRecipients() []string {
var recipients []string
recipients = append(recipients, m.To...)
recipients = append(recipients, m.Cc...)
recipients = append(recipients, m.Bcc...)
return recipients
}
+204
View File
@@ -0,0 +1,204 @@
package email
import (
"strings"
"testing"
)
func TestMessageValidation(t *testing.T) {
tests := []struct {
name string
msg Message
wantErr bool
}{
{
name: "valid message",
msg: Message{
To: []string{"user@example.com"},
Subject: "Test Subject",
Body: "Test Body",
},
wantErr: false,
},
{
name: "no recipients",
msg: Message{
To: []string{},
Subject: "Test Subject",
Body: "Test Body",
},
wantErr: true,
},
{
name: "no subject",
msg: Message{
To: []string{"user@example.com"},
Subject: "",
Body: "Test Body",
},
wantErr: true,
},
{
name: "no body",
msg: Message{
To: []string{"user@example.com"},
Subject: "Test Subject",
Body: "",
},
wantErr: true,
},
{
name: "multiple recipients",
msg: Message{
To: []string{"user1@example.com", "user2@example.com"},
Cc: []string{"cc@example.com"},
Subject: "Test Subject",
Body: "Test Body",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.msg.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestMessageFormatPlainText(t *testing.T) {
msg := Message{
To: []string{"user@example.com"},
Subject: "Test Subject",
Body: "Test Body",
IsHTML: false,
}
formatted := msg.Format("sender@example.com", "Sender Name")
// Check required headers
if !strings.Contains(formatted, "From: Sender Name <sender@example.com>") {
t.Error("Missing or incorrect From header")
}
if !strings.Contains(formatted, "To: user@example.com") {
t.Error("Missing or incorrect To header")
}
if !strings.Contains(formatted, "Subject: Test Subject") {
t.Error("Missing or incorrect Subject header")
}
if !strings.Contains(formatted, "Content-Type: text/plain; charset=utf-8") {
t.Error("Missing or incorrect Content-Type header for plain text")
}
if !strings.Contains(formatted, "Test Body") {
t.Error("Missing message body")
}
}
func TestMessageFormatHTML(t *testing.T) {
msg := Message{
To: []string{"user@example.com"},
Subject: "Test Subject",
Body: "<html><body>Test Body</body></html>",
IsHTML: true,
}
formatted := msg.Format("sender@example.com", "Sender Name")
// Check HTML content-type
if !strings.Contains(formatted, "Content-Type: text/html; charset=utf-8") {
t.Error("Missing or incorrect Content-Type header for HTML")
}
if !strings.Contains(formatted, "<html><body>Test Body</body></html>") {
t.Error("Missing HTML body")
}
}
func TestMessageFormatMultipleRecipients(t *testing.T) {
msg := Message{
To: []string{"user1@example.com", "user2@example.com"},
Cc: []string{"cc1@example.com", "cc2@example.com"},
Bcc: []string{"bcc@example.com"},
Subject: "Test Subject",
Body: "Test Body",
ReplyTo: "reply@example.com",
}
formatted := msg.Format("sender@example.com", "Sender Name")
// Check To header formatting
if !strings.Contains(formatted, "To: user1@example.com, user2@example.com") {
t.Error("Missing or incorrect To header with multiple recipients")
}
// Check Cc header formatting
if !strings.Contains(formatted, "Cc: cc1@example.com, cc2@example.com") {
t.Error("Missing or incorrect Cc header")
}
// Bcc should NOT appear in the formatted message
if strings.Contains(formatted, "Bcc:") {
t.Error("Bcc header should not appear in formatted message")
}
// Check Reply-To header
if !strings.Contains(formatted, "Reply-To: reply@example.com") {
t.Error("Missing or incorrect Reply-To header")
}
}
func TestMessageFormatSanitizesHeaderValues(t *testing.T) {
msg := Message{
To: []string{"user@example.com\r\nX-Injected-To: bad"},
Cc: []string{"cc@example.com\r\nX-Injected-Cc: bad"},
Subject: "Test\r\nX-Injected-Subject: bad",
Body: "Test Body",
ReplyTo: "reply@example.com\r\nX-Injected-Reply-To: bad",
}
formatted := msg.Format("sender@example.com\r\nX-Injected-From: bad", "Sender\r\nX-Injected-Name: bad")
headers := strings.SplitN(formatted, "\r\n\r\n", 2)[0]
if strings.Contains(headers, "\r\nX-Injected") {
t.Fatalf("header value injection was not sanitized:\n%s", headers)
}
if !strings.Contains(headers, "Subject: Test X-Injected-Subject: bad") {
t.Error("subject header was not normalized")
}
if !strings.Contains(headers, "From: Sender X-Injected-Name: bad <sender@example.com X-Injected-From: bad>") {
t.Error("from header was not normalized")
}
}
func TestGetAllRecipients(t *testing.T) {
msg := Message{
To: []string{"user1@example.com", "user2@example.com"},
Cc: []string{"cc@example.com"},
Bcc: []string{"bcc@example.com"},
}
recipients := msg.GetAllRecipients()
// Should have all 4 recipients
if len(recipients) != 4 {
t.Errorf("GetAllRecipients() returned %d recipients, want 4", len(recipients))
}
// Check all recipients are present
expectedRecipients := map[string]bool{
"user1@example.com": true,
"user2@example.com": true,
"cc@example.com": true,
"bcc@example.com": true,
}
for _, recipient := range recipients {
if !expectedRecipients[recipient] {
t.Errorf("Unexpected recipient: %s", recipient)
}
delete(expectedRecipients, recipient)
}
if len(expectedRecipients) > 0 {
t.Error("Not all expected recipients were returned")
}
}
+50
View File
@@ -0,0 +1,50 @@
# Maintaining the Memo Filter Engine
The engine is memo-specific; any future field or behavior changes must stay
consistent with the memo schema and store implementations. Use this guide when
extending or debugging the package.
## Adding a New Memo Field
1. **Update the schema**
- Add the field entry in `schema.go`.
- Define the backing column (`Column`), JSON path (if applicable), type, and
allowed operators.
- Include the CEL variable in `EnvOptions`.
2. **Adjust parser or renderer (if needed)**
- For non-scalar fields (JSON booleans, lists), add handling in
`parser.go` or extend the renderer helpers.
- Keep validation in the parser (e.g., reject unsupported operators).
3. **Write a golden test**
- Extend the dialect-specific memo filter tests under
`store/db/{sqlite,mysql,postgres}/memo_filter_test.go` with a case that
exercises the new field.
4. **Run `go test ./...`** to ensure the SQL output matches expectations across
all dialects.
## Supporting Dialect Nuances
- Centralize differences inside `render.go`. If a new dialect-specific behavior
emerges (e.g., JSON operators), add the logic there rather than leaking it
into store code.
- Use the renderer helpers (`jsonExtractExpr`, `jsonArrayExpr`, etc.) rather than
sprinkling ad-hoc SQL strings.
- When placeholders change, adjust `addArg` so that argument numbering stays in
sync with store queries.
## Debugging Tips
- **Parser errors** Most originate in `buildCondition` or schema validation.
Enable logging around `parser.go` when diagnosing unknown identifier/operator
messages.
- **Renderer output** Temporary printf/log statements in `renderCondition` help
identify which IR node produced unexpected SQL.
- **Store integration** Ensure drivers call `filter.DefaultEngine()` exactly once
per process; the singleton caches the parsed CEL environment.
## Testing Checklist
- `go test ./store/...` ensures all dialect tests consume the engine correctly.
- Add targeted unit tests whenever new IR nodes or renderer paths are introduced.
- When changing boolean or JSON handling, verify all three dialect test suites
(SQLite, MySQL, Postgres) to avoid regression.
+105
View File
@@ -0,0 +1,105 @@
# Memo Filter Engine
This package houses the memo-only filter engine that turns standard CEL syntax
into SQL fragments for the subset of expressions supported by the memo schema.
The engine follows a three phase pipeline inspired by systems
such as Calcite or Prisma:
1. **Parsing** CEL expressions are parsed with `cel-go` and validated against
the memo-specific environment declared in `schema.go`. Only fields that
exist in the schema can surface in the filter, and non-standard legacy
coercions are rejected.
2. **Normalization** the raw CEL AST is converted into an intermediate
representation (IR) defined in `ir.go`. The IR is a dialect-agnostic tree of
conditions (logical operators, comparisons, list membership, etc.). This
step enforces schema rules (e.g. operator compatibility, type checks).
3. **Rendering** the renderer in `render.go` walks the IR and produces a SQL
fragment plus placeholder arguments tailored to a target dialect
(`sqlite`, `mysql`, or `postgres`). Dialect differences such as JSON access,
boolean semantics, placeholders, and `LIKE` vs `ILIKE` are encapsulated in
renderer helpers.
The entry point is `filter.DefaultEngine()` from `engine.go`. It lazily constructs
an `Engine` configured with the memo schema and exposes:
```go
engine, _ := filter.DefaultEngine()
stmt, _ := engine.CompileToStatement(ctx, `has_task_list && visibility == "PUBLIC"`, filter.RenderOptions{
Dialect: filter.DialectPostgres,
})
// stmt.SQL -> "((memo.payload->'property'->>'hasTaskList')::boolean IS TRUE AND memo.visibility = $1)"
// stmt.Args -> ["PUBLIC"]
```
## Core Files
| File | Responsibility |
| ------------- | ------------------------------------------------------------------------------- |
| `schema.go` | Declares memo fields, their types, backing columns, CEL environment options |
| `ir.go` | IR node definitions used across the pipeline |
| `parser.go` | Converts CEL `Expr` into IR while applying schema validation |
| `render.go` | Translates IR into SQL, handling dialect-specific behavior |
| `engine.go` | Glue between the phases; exposes `Compile`, `CompileToStatement`, and `DefaultEngine` |
| `helpers.go` | Convenience helpers for store integration (appending conditions) |
## SQL Generation Notes
- **Placeholders** — `?` is used for SQLite/MySQL, `$n` for Postgres. The renderer
tracks offsets to compose queries with pre-existing arguments.
- **JSON Fields** — Memo metadata lives in `memo.payload`. The renderer handles
`JSON_EXTRACT`/`json_extract`/`->`/`->>` variations and boolean coercion.
- **Time Fields** — `created_ts`, `updated_ts`, and attachment `create_time` are
CEL `timestamp` values. Express instants with the `now` variable,
`duration("…")` (e.g. `created_ts >= now - duration("24h")`), or
`timestamp("2006-01-02T15:04:05Z")` / `timestamp(<epoch-seconds>)`. These fold
to epoch seconds at compile time — `now` is frozen once per compile (injectable
for tests via the engine clock) — so the backing columns stay unchanged.
- **Tag Operations** — `tag in [...]` and `"tag" in tags` become JSON array
predicates. SQLite uses `LIKE` patterns, MySQL uses `JSON_CONTAINS`, and
Postgres uses `@>`.
- **Boolean Flags** — Fields such as `has_task_list` render as `IS TRUE` equality
checks, or comparisons against `CAST('true' AS JSON)` depending on the dialect.
- **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()` / `exists_one()`** — `tags.all(t, <pred>)` matches only non-empty
tag sets where every element satisfies the predicate; `tags.exists_one(t,
<pred>)` matches when exactly one element does (`COUNT(...) = 1`). Both iterate
per-element (`json_each` / `jsonb_array_elements_text` / `JSON_TABLE`).
- **Timestamp Accessors** — `created_ts.getFullYear()`, `getMonth()`, `getDate()`,
`getDayOfMonth()`, `getDayOfWeek()`, `getDayOfYear()`, `getHours()`,
`getMinutes()`, `getSeconds()` render to date-part extraction (`strftime` /
`EXTRACT` / `YEAR`/`MONTH`/…). Results are normalized to CEL's base (0-based
month, 0-based day-of-week with 0 = Sunday). The same accessors on `now` fold
to literal date parts of the frozen evaluation time (UTC), so saved filters
like `created_ts.getMonth() == now.getMonth() && created_ts.getDate() ==
now.getDate()` ("on this day") re-resolve on every compile. Extraction is UTC
on SQLite/Postgres (epoch columns); on MySQL the `TIMESTAMP` column is read in
the session time zone. A timezone argument is not supported.
- **Set Operations** — `ext.Sets()`: `sets.contains(tags, [...])`,
`sets.intersects(tags, [...])`, and `sets.equivalent(tags, [...])` desugar to
exact-membership checks (AND / OR of `"v" in tags`); `equivalent` adds a
`size(tags)` length check (relies on tags being a set).
- **`size()`** — `size(tags)` renders to JSON array length; `size(content)` (and
other string fields) render to `LENGTH` / `CHAR_LENGTH` (MySQL) for code-point
counts.
- **Arithmetic** — `+`, `-`, `*`, `/`, `%` constant-fold on literal/`now`/`duration`
operands (division and modulo guard against a zero divisor).
## Typical Integration
1. Fetch the engine with `filter.DefaultEngine()`.
2. Call `CompileToStatement` using the appropriate dialect enum.
3. Append the emitted SQL fragment/args to the existing `WHERE` clause.
4. Execute the resulting query through the store driver.
The `helpers.AppendConditions` helper encapsulates steps 23 when a driver needs
to process an array of filters.
+124
View File
@@ -0,0 +1,124 @@
package filter
import (
"context"
"strings"
"sync"
"time"
"github.com/google/cel-go/cel"
"github.com/pkg/errors"
)
// Engine parses CEL filters into a dialect-agnostic condition tree.
type Engine struct {
schema Schema
env *cel.Env
// nowFunc resolves the value of the `now` variable. It is frozen once per
// Compile so a single filter sees a single instant, and is overridable in
// tests for deterministic folding.
nowFunc func() time.Time
}
// NewEngine builds a new Engine for the provided schema.
func NewEngine(schema Schema) (*Engine, error) {
env, err := cel.NewEnv(schema.EnvOptions...)
if err != nil {
return nil, errors.Wrap(err, "failed to create CEL environment")
}
return &Engine{
schema: schema,
env: env,
nowFunc: time.Now,
}, nil
}
// Program stores a compiled filter condition.
type Program struct {
schema Schema
condition Condition
}
// ConditionTree exposes the underlying condition tree.
func (p *Program) ConditionTree() Condition {
return p.condition
}
// Compile parses the filter string into an executable program.
func (e *Engine) Compile(_ context.Context, filter string) (*Program, error) {
if strings.TrimSpace(filter) == "" {
return nil, errors.New("filter expression is empty")
}
ast, issues := e.env.Compile(filter)
if issues != nil && issues.Err() != nil {
return nil, errors.Wrap(issues.Err(), "failed to compile filter")
}
parsed, err := cel.AstToParsedExpr(ast)
if err != nil {
return nil, errors.Wrap(err, "failed to convert AST")
}
cond, err := buildCondition(parsed.GetExpr(), parseContext{schema: e.schema, now: e.nowFunc()})
if err != nil {
return nil, err
}
return &Program{
schema: e.schema,
condition: cond,
}, nil
}
// CompileToStatement compiles and renders the filter in a single step.
func (e *Engine) CompileToStatement(ctx context.Context, filter string, opts RenderOptions) (Statement, error) {
program, err := e.Compile(ctx, filter)
if err != nil {
return Statement{}, err
}
return program.Render(opts)
}
// RenderOptions configure SQL rendering.
type RenderOptions struct {
Dialect DialectName
PlaceholderOffset int
DisableNullChecks bool
}
// Statement contains the rendered SQL fragment and its args.
type Statement struct {
SQL string
Args []any
}
// Render converts the program into a dialect-specific SQL fragment.
func (p *Program) Render(opts RenderOptions) (Statement, error) {
renderer := newRenderer(p.schema, opts)
return renderer.Render(p.condition)
}
var (
defaultOnce sync.Once
defaultInst *Engine
defaultErr error
defaultAttachmentOnce sync.Once
defaultAttachmentInst *Engine
defaultAttachmentErr error
)
// DefaultEngine returns the process-wide memo filter engine.
func DefaultEngine() (*Engine, error) {
defaultOnce.Do(func() {
defaultInst, defaultErr = NewEngine(NewSchema())
})
return defaultInst, defaultErr
}
// DefaultAttachmentEngine returns the process-wide attachment filter engine.
func DefaultAttachmentEngine() (*Engine, error) {
defaultAttachmentOnce.Do(func() {
defaultAttachmentInst, defaultAttachmentErr = NewEngine(NewAttachmentSchema())
})
return defaultAttachmentInst, defaultAttachmentErr
}
+197
View File
@@ -0,0 +1,197 @@
package filter
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestCompileAcceptsStandardTagEqualityPredicate(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `tags.exists(t, t == "1231")`)
require.NoError(t, err)
}
func TestCompileRejectsLegacyNumericLogicalOperand(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `pinned && 1`)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to compile filter")
}
func TestCompileRejectsNonBooleanTopLevelConstant(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `1`)
require.EqualError(t, err, "filter must evaluate to a boolean value")
}
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)
}
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")
}
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)
}
// =============================================================================
// Cross-dialect rendering tests (no DB required; complements the SQLite-only
// behavioral tests in store/test by asserting MySQL/Postgres SQL generation).
// =============================================================================
func TestRenderStartsWithPerDialect(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
cases := []struct {
dialect DialectName
fragments []string
}{
{DialectSQLite, []string{"memos_unicode_lower(", "`memo`.`content`", `ESCAPE '\'`}},
{DialectPostgres, []string{"memo.content ILIKE $1"}},
{DialectMySQL, []string{"`memo`.`content` LIKE ?"}},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), `content.startsWith("TODO")`, RenderOptions{Dialect: tc.dialect})
require.NoError(t, err, tc.dialect)
for _, frag := range tc.fragments {
require.Contains(t, stmt.SQL, frag, "dialect %s", tc.dialect)
}
require.Equal(t, []any{"TODO%"}, stmt.Args, "dialect %s", tc.dialect)
}
}
func TestRenderEndsWithPerDialect(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
for _, dialect := range []DialectName{DialectSQLite, DialectPostgres, DialectMySQL} {
stmt, err := engine.CompileToStatement(context.Background(), `content.endsWith(".md")`, RenderOptions{Dialect: dialect})
require.NoError(t, err, dialect)
require.Equal(t, []any{"%.md"}, stmt.Args, "dialect %s", dialect)
}
}
func TestRenderMatchesPerDialect(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
cases := []struct {
dialect DialectName
fragment string
}{
{DialectSQLite, "`memo`.`content` REGEXP ?"},
{DialectMySQL, "`memo`.`content` REGEXP ?"},
{DialectPostgres, "memo.content ~ $1"},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), `content.matches("v[0-9]+")`, RenderOptions{Dialect: tc.dialect})
require.NoError(t, err, tc.dialect)
require.Contains(t, stmt.SQL, tc.fragment, "dialect %s", tc.dialect)
require.Equal(t, []any{"v[0-9]+"}, stmt.Args, "dialect %s", tc.dialect)
}
}
func TestRenderTagsAllPerDialect(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
cases := []struct {
dialect DialectName
fragments []string
}{
{DialectSQLite, []string{"NOT EXISTS", "json_each(", "!= '[]'", "memos_unicode_lower(value)"}},
{DialectPostgres, []string{"NOT EXISTS", "jsonb_array_elements_text(", "jsonb_array_length(", "value ILIKE"}},
{DialectMySQL, []string{"NOT EXISTS", "JSON_TABLE(", "JSON_LENGTH(", "value LIKE"}},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), `tags.all(t, t.startsWith("work/"))`, RenderOptions{Dialect: tc.dialect})
require.NoError(t, err, tc.dialect)
for _, frag := range tc.fragments {
require.Contains(t, stmt.SQL, frag, "dialect %s", tc.dialect)
}
require.Equal(t, []any{"work/%"}, stmt.Args, "dialect %s", tc.dialect)
}
}
func TestRenderTextMatchEscaping(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
// Both % and _ in the value must be escaped so they match literally.
stmt, err := engine.CompileToStatement(context.Background(), `content.contains("a%b_c")`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{`%a\%b\_c%`}, stmt.Args)
}
func TestRenderAllRejectsUnsupportedPredicate(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
// size() is not a valid per-element predicate inside all().
_, err = engine.CompileToStatement(context.Background(), `tags.all(t, size(t) > 2)`, RenderOptions{Dialect: DialectSQLite})
require.Error(t, err)
}
+377
View File
@@ -0,0 +1,377 @@
package filter
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// Arithmetic folding: division and modulo
// ---------------------------------------------------------------------------
func TestCompileDivisionFolds(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `creator_id == 100 / 10`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(10)}, stmt.Args)
}
func TestCompileModuloFolds(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `creator_id == 17 % 5`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(2)}, stmt.Args)
}
func TestCompileDivisionByZeroErrors(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `creator_id == 10 / 0`)
require.Error(t, err)
}
// ---------------------------------------------------------------------------
// size() on scalar string fields -> SQL length
// ---------------------------------------------------------------------------
func TestCompileSizeOnContentRendersLengthPerDialect(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
cases := []struct {
dialect DialectName
fragment string
}{
{DialectSQLite, "LENGTH("},
{DialectPostgres, "LENGTH("},
{DialectMySQL, "CHAR_LENGTH("},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), `size(content) > 5`, RenderOptions{Dialect: tc.dialect})
require.NoError(t, err, tc.dialect)
require.Contains(t, stmt.SQL, tc.fragment, "dialect %s", tc.dialect)
require.Equal(t, []any{int64(5)}, stmt.Args, "dialect %s", tc.dialect)
}
}
// ---------------------------------------------------------------------------
// Timestamp accessor methods (getFullYear, getMonth, ...)
// ---------------------------------------------------------------------------
func TestCompileTimestampAccessorsPerDialect(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
cases := []struct {
name string
filter string
dialect DialectName
fragments []string
arg int64
}{
// getFullYear == 2024
{"sqlite year", `created_ts.getFullYear() == 2024`, DialectSQLite, []string{"strftime('%Y'", "'unixepoch'"}, 2024},
{"pg year", `created_ts.getFullYear() == 2024`, DialectPostgres, []string{"EXTRACT(YEAR FROM to_timestamp(", "AT TIME ZONE 'UTC'"}, 2024},
{"mysql year", `created_ts.getFullYear() == 2024`, DialectMySQL, []string{"YEAR(`memo`.`created_ts`)"}, 2024},
// getMonth is 0-based -> SQL must subtract 1
{"sqlite month", `created_ts.getMonth() == 5`, DialectSQLite, []string{"strftime('%m'", "- 1)"}, 5},
{"pg month", `created_ts.getMonth() == 5`, DialectPostgres, []string{"EXTRACT(MONTH FROM", "- 1)"}, 5},
{"mysql month", `created_ts.getMonth() == 5`, DialectMySQL, []string{"MONTH(`memo`.`created_ts`)", "- 1)"}, 5},
// getDayOfWeek 0=Sunday -> MySQL DAYOFWEEK is 1-based and must subtract 1
{"mysql dow", `created_ts.getDayOfWeek() == 0`, DialectMySQL, []string{"DAYOFWEEK(`memo`.`created_ts`)", "- 1)"}, 0},
{"sqlite dow", `created_ts.getDayOfWeek() == 0`, DialectSQLite, []string{"strftime('%w'"}, 0},
// getDate is 1-based -> no offset
{"sqlite date", `created_ts.getDate() == 22`, DialectSQLite, []string{"strftime('%d'"}, 22},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), tc.filter, RenderOptions{Dialect: tc.dialect})
require.NoError(t, err, tc.name)
for _, frag := range tc.fragments {
require.Contains(t, stmt.SQL, frag, tc.name)
}
require.Equal(t, []any{tc.arg}, stmt.Args, tc.name)
}
}
func TestCompileNowAccessorsFoldToInjectedClock(t *testing.T) {
t.Parallel()
// 2026-07-07T10:30:45Z, a Tuesday (year day 188).
engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
cases := []struct {
name string
filter string
args []any
}{
{"on this day", `created_ts.getMonth() == now.getMonth() && created_ts.getDate() == now.getDate()`, []any{int64(6), int64(7)}},
{"year", `created_ts.getFullYear() < now.getFullYear()`, []any{int64(2026)}},
{"day of month", `created_ts.getDayOfMonth() == now.getDayOfMonth()`, []any{int64(6)}},
{"day of week", `created_ts.getDayOfWeek() == now.getDayOfWeek()`, []any{int64(2)}},
{"day of year", `created_ts.getDayOfYear() == now.getDayOfYear()`, []any{int64(187)}},
{"clock parts", `created_ts.getHours() == now.getHours() || created_ts.getMinutes() == now.getMinutes() || created_ts.getSeconds() == now.getSeconds()`, []any{int64(10), int64(30), int64(45)}},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), tc.filter, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err, tc.name)
require.Equal(t, tc.args, stmt.Args, tc.name)
}
}
func TestCompileNowAccessorRejectsTimezoneArg(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `created_ts.getMonth() == now.getMonth("America/New_York")`)
require.Error(t, err)
}
func TestCompileOnThisDayPerDialect(t *testing.T) {
t.Parallel()
const onThisDay = `created_ts.getMonth() == now.getMonth() && created_ts.getDate() == now.getDate()`
engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
cases := []struct {
dialect DialectName
fragments []string
}{
{DialectSQLite, []string{"strftime('%m'", "strftime('%d'", "'unixepoch'"}},
{DialectPostgres, []string{"EXTRACT(MONTH FROM", "EXTRACT(DAY FROM"}},
{DialectMySQL, []string{"MONTH(`memo`.`created_ts`)", "DAYOFMONTH(`memo`.`created_ts`)"}},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), onThisDay, RenderOptions{Dialect: tc.dialect})
require.NoError(t, err, tc.dialect)
for _, frag := range tc.fragments {
require.Contains(t, stmt.SQL, frag, "dialect %s", tc.dialect)
}
require.Equal(t, []any{int64(6), int64(7)}, stmt.Args, "dialect %s", tc.dialect)
}
}
func TestCompileNowAccessorsAtBoundaryDates(t *testing.T) {
t.Parallel()
// Every accessor folded at calendar edges: year rollover, leap day, Sunday.
allAccessors := `created_ts.getFullYear() == now.getFullYear() ` +
`&& created_ts.getMonth() == now.getMonth() ` +
`&& created_ts.getDate() == now.getDate() ` +
`&& created_ts.getDayOfMonth() == now.getDayOfMonth() ` +
`&& created_ts.getDayOfWeek() == now.getDayOfWeek() ` +
`&& created_ts.getDayOfYear() == now.getDayOfYear() ` +
`&& created_ts.getHours() == now.getHours() ` +
`&& created_ts.getMinutes() == now.getMinutes() ` +
`&& created_ts.getSeconds() == now.getSeconds()`
cases := []struct {
name string
clock time.Time
// year, month, date, dayOfMonth, dayOfWeek, dayOfYear, hours, minutes, seconds
args []any
}{
{
"new year's eve",
time.Date(2026, time.December, 31, 23, 59, 59, 0, time.UTC),
[]any{int64(2026), int64(11), int64(31), int64(30), int64(4), int64(364), int64(23), int64(59), int64(59)},
},
{
"new year's day",
time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC),
[]any{int64(2027), int64(0), int64(1), int64(0), int64(5), int64(0), int64(0), int64(0), int64(0)},
},
{
"leap day",
time.Date(2028, time.February, 29, 12, 0, 0, 0, time.UTC),
[]any{int64(2028), int64(1), int64(29), int64(28), int64(2), int64(59), int64(12), int64(0), int64(0)},
},
{
"sunday",
time.Date(2026, time.July, 5, 8, 15, 30, 0, time.UTC),
[]any{int64(2026), int64(6), int64(5), int64(4), int64(0), int64(185), int64(8), int64(15), int64(30)},
},
}
for _, tc := range cases {
engine := memoEngineAt(t, tc.clock.Unix())
stmt, err := engine.CompileToStatement(context.Background(), allAccessors, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err, tc.name)
require.Equal(t, tc.args, stmt.Args, tc.name)
}
}
func TestCompileNowAccessorFoldsInUTC(t *testing.T) {
t.Parallel()
// A clock in UTC+8 at 05:00 on July 8 is still July 7, 21:00 in UTC;
// folding must not leak the clock's zone.
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
engine.nowFunc = func() time.Time {
return time.Date(2026, time.July, 8, 5, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
}
stmt, err := engine.CompileToStatement(
context.Background(),
`created_ts.getDate() == now.getDate() && created_ts.getHours() == now.getHours()`,
RenderOptions{Dialect: DialectSQLite},
)
require.NoError(t, err)
require.Equal(t, []any{int64(7), int64(21)}, stmt.Args)
}
func TestCompileNowAccessorOnLeftSide(t *testing.T) {
t.Parallel()
engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
stmt, err := engine.CompileToStatement(context.Background(), `now.getMonth() == created_ts.getMonth()`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Contains(t, stmt.SQL, "strftime('%m'")
require.Equal(t, []any{int64(6)}, stmt.Args)
}
func TestCompileNowAccessorMirrorsOrderingOperator(t *testing.T) {
t.Parallel()
// Swapping the literal to the right must flip < to > to keep the meaning.
engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
stmt, err := engine.CompileToStatement(context.Background(), `now.getHours() < created_ts.getHours()`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Contains(t, stmt.SQL, "> ?")
require.Equal(t, []any{int64(10)}, stmt.Args)
}
func TestCompileNowAccessorAgainstLiteralFoldsToConstant(t *testing.T) {
t.Parallel()
// Both sides fold to literals; the comparison folds to a constant condition.
engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
// True → trivial filter (empty SQL, matches everything).
stmt, err := engine.CompileToStatement(context.Background(), `now.getFullYear() >= 2026`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Empty(t, stmt.SQL)
require.Empty(t, stmt.Args)
// False → unsatisfiable filter.
stmt, err = engine.CompileToStatement(context.Background(), `now.getFullYear() < 2026`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, "1 = 0", stmt.SQL)
require.Empty(t, stmt.Args)
}
func TestCompileAttachmentNowAccessors(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewAttachmentSchema())
require.NoError(t, err)
engine.nowFunc = fixedClock(time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
stmt, err := engine.CompileToStatement(
context.Background(),
`create_time.getMonth() == now.getMonth() && create_time.getDate() == now.getDate()`,
RenderOptions{Dialect: DialectSQLite},
)
require.NoError(t, err)
require.Contains(t, stmt.SQL, "`attachment`.`created_ts`")
require.Equal(t, []any{int64(6), int64(7)}, stmt.Args)
}
func TestCompileTimestampAccessorRejectsTimezoneArg(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
_, err = engine.Compile(context.Background(), `created_ts.getHours("America/New_York") == 9`)
require.Error(t, err)
}
func TestCompileTimestampAccessorRejectsNonTimestampField(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
// content is a string, not a timestamp.
_, err = engine.Compile(context.Background(), `content.getFullYear() == 2024`)
require.Error(t, err)
}
// ---------------------------------------------------------------------------
// ext.Sets(): sets.contains / sets.intersects / sets.equivalent over tags
// ---------------------------------------------------------------------------
func TestCompileSetsContainsRendersAndOfMemberships(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `sets.contains(tags, ["a", "b"])`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Contains(t, stmt.SQL, " AND ")
require.Len(t, stmt.Args, 2)
}
func TestCompileSetsIntersectsRendersOrOfMemberships(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `sets.intersects(tags, ["a", "b"])`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Contains(t, stmt.SQL, " OR ")
require.Len(t, stmt.Args, 2)
}
func TestCompileSetsEquivalentAddsLengthCheck(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
stmt, err := engine.CompileToStatement(context.Background(), `sets.equivalent(tags, ["a", "b"])`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Contains(t, stmt.SQL, "JSON_ARRAY_LENGTH")
require.Contains(t, stmt.Args, int64(2))
}
// ---------------------------------------------------------------------------
// exists_one() comprehension on tags
// ---------------------------------------------------------------------------
func TestCompileExistsOnePerDialect(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
cases := []struct {
dialect DialectName
fragments []string
}{
{DialectSQLite, []string{"COUNT(", "json_each(", ") = 1"}},
{DialectPostgres, []string{"COUNT(", "jsonb_array_elements_text(", ") = 1"}},
{DialectMySQL, []string{"COUNT(", "JSON_TABLE(", ") = 1"}},
}
for _, tc := range cases {
stmt, err := engine.CompileToStatement(context.Background(), `tags.exists_one(t, t == "urgent")`, RenderOptions{Dialect: tc.dialect})
require.NoError(t, err, tc.dialect)
for _, frag := range tc.fragments {
require.Contains(t, stmt.SQL, frag, "dialect %s", tc.dialect)
}
require.Equal(t, []any{"urgent"}, stmt.Args, "dialect %s", tc.dialect)
}
}
+25
View File
@@ -0,0 +1,25 @@
package filter
import (
"context"
"fmt"
)
// AppendConditions compiles the provided filters and appends the resulting SQL fragments and args.
func AppendConditions(ctx context.Context, engine *Engine, filters []string, dialect DialectName, where *[]string, args *[]any) error {
for _, filterStr := range filters {
stmt, err := engine.CompileToStatement(ctx, filterStr, RenderOptions{
Dialect: dialect,
PlaceholderOffset: len(*args),
})
if err != nil {
return err
}
if stmt.SQL == "" {
continue
}
*where = append(*where, fmt.Sprintf("(%s)", stmt.SQL))
*args = append(*args, stmt.Args...)
}
return nil
}
+196
View File
@@ -0,0 +1,196 @@
package filter
// Condition represents a boolean expression derived from the CEL filter.
type Condition interface {
isCondition()
}
// LogicalOperator enumerates the supported logical operators.
type LogicalOperator string
const (
LogicalAnd LogicalOperator = "AND"
LogicalOr LogicalOperator = "OR"
)
// LogicalCondition composes two conditions with a logical operator.
type LogicalCondition struct {
Operator LogicalOperator
Left Condition
Right Condition
}
func (*LogicalCondition) isCondition() {}
// NotCondition negates a child condition.
type NotCondition struct {
Expr Condition
}
func (*NotCondition) isCondition() {}
// FieldPredicateCondition asserts that a field evaluates to true.
type FieldPredicateCondition struct {
Field string
}
func (*FieldPredicateCondition) isCondition() {}
// ComparisonOperator lists supported comparison operators.
type ComparisonOperator string
const (
CompareEq ComparisonOperator = "="
CompareNeq ComparisonOperator = "!="
CompareLt ComparisonOperator = "<"
CompareLte ComparisonOperator = "<="
CompareGt ComparisonOperator = ">"
CompareGte ComparisonOperator = ">="
)
// ComparisonCondition represents a binary comparison.
type ComparisonCondition struct {
Left ValueExpr
Operator ComparisonOperator
Right ValueExpr
}
func (*ComparisonCondition) isCondition() {}
// InCondition represents an IN predicate with literal list values.
type InCondition struct {
Left ValueExpr
Values []ValueExpr
}
func (*InCondition) isCondition() {}
// ElementInCondition represents the CEL syntax `"value" in field`.
type ElementInCondition struct {
Element ValueExpr
Field string
}
func (*ElementInCondition) isCondition() {}
// 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() {}
// RegexCondition models field.matches("pattern") on a string field.
type RegexCondition struct {
Field string
Pattern string
}
func (*RegexCondition) isCondition() {}
// ConstantCondition captures a literal boolean outcome.
type ConstantCondition struct {
Value bool
}
func (*ConstantCondition) isCondition() {}
// ValueExpr models arithmetic or scalar expressions whose result feeds a comparison.
type ValueExpr interface {
isValueExpr()
}
// FieldRef references a named schema field.
type FieldRef struct {
Name string
}
func (*FieldRef) isValueExpr() {}
// LiteralValue holds a literal scalar.
type LiteralValue struct {
Value interface{}
}
func (*LiteralValue) isValueExpr() {}
// FunctionValue captures simple function calls like size(tags).
type FunctionValue struct {
Name string
Args []ValueExpr
}
func (*FunctionValue) isValueExpr() {}
// FieldAccessorValue captures a CEL timestamp accessor on a field, such as
// created_ts.getMonth(). It renders to a dialect-specific date-part extraction.
type FieldAccessorValue struct {
Field string
Accessor string // e.g. "getFullYear", "getMonth"
}
func (*FieldAccessorValue) isValueExpr() {}
// ListComprehensionCondition represents CEL macros like exists(), all(), filter().
type ListComprehensionCondition struct {
Kind ComprehensionKind
Field string // The list field to iterate over (e.g., "tags")
IterVar string // The iteration variable name (e.g., "t")
Predicate PredicateExpr // The predicate to evaluate for each element
}
func (*ListComprehensionCondition) isCondition() {}
// ComprehensionKind enumerates the types of list comprehensions.
type ComprehensionKind string
const (
ComprehensionExists ComprehensionKind = "exists"
ComprehensionAll ComprehensionKind = "all"
ComprehensionExistsOne ComprehensionKind = "exists_one"
)
// PredicateExpr represents predicates used in comprehensions.
type PredicateExpr interface {
isPredicateExpr()
}
// StartsWithPredicate represents t.startsWith("prefix").
type StartsWithPredicate struct {
Prefix string
}
func (*StartsWithPredicate) isPredicateExpr() {}
// EndsWithPredicate represents t.endsWith("suffix").
type EndsWithPredicate struct {
Suffix string
}
func (*EndsWithPredicate) isPredicateExpr() {}
// ContainsPredicate represents t.contains("substring").
type ContainsPredicate struct {
Substring string
}
func (*ContainsPredicate) isPredicateExpr() {}
// EqualsPredicate represents t == "value".
type EqualsPredicate struct {
Value string
}
func (*EqualsPredicate) isPredicateExpr() {}
File diff suppressed because it is too large Load Diff
+980
View File
@@ -0,0 +1,980 @@
package filter
import (
"fmt"
"strings"
"github.com/pkg/errors"
)
type renderer struct {
schema Schema
dialect DialectName
placeholderOffset int
placeholderCounter int
args []any
}
type renderResult struct {
sql string
trivial bool
unsatisfiable bool
}
func newRenderer(schema Schema, opts RenderOptions) *renderer {
return &renderer{
schema: schema,
dialect: opts.Dialect,
placeholderOffset: opts.PlaceholderOffset,
}
}
func (r *renderer) Render(cond Condition) (Statement, error) {
result, err := r.renderCondition(cond)
if err != nil {
return Statement{}, err
}
args := r.args
if args == nil {
args = []any{}
}
switch {
case result.unsatisfiable:
return Statement{
SQL: "1 = 0",
Args: args,
}, nil
case result.trivial:
return Statement{
SQL: "",
Args: args,
}, nil
default:
return Statement{
SQL: result.sql,
Args: args,
}, nil
}
}
func (r *renderer) renderCondition(cond Condition) (renderResult, error) {
switch c := cond.(type) {
case *LogicalCondition:
return r.renderLogicalCondition(c)
case *NotCondition:
return r.renderNotCondition(c)
case *FieldPredicateCondition:
return r.renderFieldPredicate(c)
case *ComparisonCondition:
return r.renderComparison(c)
case *InCondition:
return r.renderInCondition(c)
case *ElementInCondition:
return r.renderElementInCondition(c)
case *TextMatchCondition:
return r.renderTextMatch(c)
case *RegexCondition:
return r.renderRegex(c)
case *ListComprehensionCondition:
return r.renderListComprehension(c)
case *ConstantCondition:
if c.Value {
return renderResult{trivial: true}, nil
}
return renderResult{sql: "1 = 0", unsatisfiable: true}, nil
default:
return renderResult{}, errors.Errorf("unsupported condition type %T", c)
}
}
func (r *renderer) renderLogicalCondition(cond *LogicalCondition) (renderResult, error) {
left, err := r.renderCondition(cond.Left)
if err != nil {
return renderResult{}, err
}
right, err := r.renderCondition(cond.Right)
if err != nil {
return renderResult{}, err
}
switch cond.Operator {
case LogicalAnd:
return combineAnd(left, right), nil
case LogicalOr:
return combineOr(left, right), nil
default:
return renderResult{}, errors.Errorf("unsupported logical operator %s", cond.Operator)
}
}
func (r *renderer) renderNotCondition(cond *NotCondition) (renderResult, error) {
child, err := r.renderCondition(cond.Expr)
if err != nil {
return renderResult{}, err
}
if child.trivial {
return renderResult{sql: "1 = 0", unsatisfiable: true}, nil
}
if child.unsatisfiable {
return renderResult{trivial: true}, nil
}
return renderResult{
sql: fmt.Sprintf("NOT (%s)", child.sql),
}, nil
}
func (r *renderer) renderFieldPredicate(cond *FieldPredicateCondition) (renderResult, error) {
field, ok := r.schema.Field(cond.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
}
switch field.Kind {
case FieldKindBoolColumn:
column := qualifyColumn(r.dialect, field.Column)
return renderResult{
sql: fmt.Sprintf("%s IS TRUE", column),
}, nil
case FieldKindJSONBool:
sql, err := r.jsonBoolPredicate(field)
if err != nil {
return renderResult{}, err
}
return renderResult{sql: sql}, nil
default:
return renderResult{}, errors.Errorf("field %q cannot be used as a predicate", cond.Field)
}
}
func (r *renderer) renderComparison(cond *ComparisonCondition) (renderResult, error) {
switch left := cond.Left.(type) {
case *FieldRef:
field, ok := r.schema.Field(left.Name)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", left.Name)
}
switch field.Kind {
case FieldKindBoolColumn:
return r.renderBoolColumnComparison(field, cond.Operator, cond.Right)
case FieldKindJSONBool:
return r.renderJSONBoolComparison(field, cond.Operator, cond.Right)
case FieldKindScalar:
return r.renderScalarComparison(field, cond.Operator, cond.Right)
default:
return renderResult{}, errors.Errorf("field %q does not support comparison", field.Name)
}
case *FunctionValue:
return r.renderFunctionComparison(left, cond.Operator, cond.Right)
case *FieldAccessorValue:
return r.renderAccessorComparison(left, cond.Operator, cond.Right)
default:
return renderResult{}, errors.New("comparison must start with a field reference or supported function")
}
}
// accessorSpec maps a CEL timestamp accessor to per-dialect SQL date-part tokens
// and the offset to subtract so the result matches CEL's base (e.g. CEL months
// are 0-based but every dialect reports 1-based, so off=1). off is indexed
// [sqlite, postgres, mysql].
type accessorSpec struct {
sqlite string // strftime format specifier
pg string // EXTRACT field
mysql string // function name
off [3]int
}
var accessorSpecs = map[string]accessorSpec{
"getFullYear": {"%Y", "YEAR", "YEAR", [3]int{0, 0, 0}},
"getMonth": {"%m", "MONTH", "MONTH", [3]int{1, 1, 1}},
"getDate": {"%d", "DAY", "DAYOFMONTH", [3]int{0, 0, 0}},
"getDayOfMonth": {"%d", "DAY", "DAYOFMONTH", [3]int{1, 1, 1}},
"getDayOfWeek": {"%w", "DOW", "DAYOFWEEK", [3]int{0, 0, 1}},
"getDayOfYear": {"%j", "DOY", "DAYOFYEAR", [3]int{1, 1, 1}},
"getHours": {"%H", "HOUR", "HOUR", [3]int{0, 0, 0}},
"getMinutes": {"%M", "MINUTE", "MINUTE", [3]int{0, 0, 0}},
"getSeconds": {"%S", "SECOND", "SECOND", [3]int{0, 0, 0}},
}
func (r *renderer) renderAccessorComparison(acc *FieldAccessorValue, op ComparisonOperator, right ValueExpr) (renderResult, error) {
field, ok := r.schema.Field(acc.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", acc.Field)
}
value, err := expectNumericLiteral(right)
if err != nil {
return renderResult{}, err
}
expr, err := r.timestampAccessorExpr(field, acc.Accessor)
if err != nil {
return renderResult{}, err
}
placeholder := r.addArg(value)
return renderResult{
sql: fmt.Sprintf("%s %s %s", expr, sqlOperator(op), placeholder),
}, nil
}
// timestampAccessorExpr builds a dialect-specific integer expression for a CEL
// timestamp accessor. Extraction is UTC on SQLite/Postgres (epoch columns); on
// MySQL the TIMESTAMP column is read in the server session time zone.
func (r *renderer) timestampAccessorExpr(field Field, accessor string) (string, error) {
spec, ok := accessorSpecs[accessor]
if !ok {
return "", errors.Errorf("unsupported timestamp accessor %q", accessor)
}
col := qualifyColumn(r.dialect, field.Column)
var base string
var off int
switch r.dialect {
case DialectSQLite:
base = fmt.Sprintf("CAST(strftime('%s', %s, 'unixepoch') AS INTEGER)", spec.sqlite, col)
off = spec.off[0]
case DialectPostgres:
base = fmt.Sprintf("CAST(EXTRACT(%s FROM to_timestamp(%s) AT TIME ZONE 'UTC') AS INTEGER)", spec.pg, col)
off = spec.off[1]
case DialectMySQL:
base = fmt.Sprintf("%s(%s)", spec.mysql, col)
off = spec.off[2]
default:
return "", errors.Errorf("unsupported dialect %q", r.dialect)
}
if off != 0 {
base = fmt.Sprintf("(%s - %d)", base, off)
}
return base, nil
}
func (r *renderer) renderFunctionComparison(fn *FunctionValue, op ComparisonOperator, right ValueExpr) (renderResult, error) {
if fn.Name != "size" {
return renderResult{}, errors.Errorf("unsupported function %s in comparison", fn.Name)
}
if len(fn.Args) != 1 {
return renderResult{}, errors.New("size() expects one argument")
}
fieldArg, ok := fn.Args[0].(*FieldRef)
if !ok {
return renderResult{}, errors.New("size() argument must be a field")
}
field, ok := r.schema.Field(fieldArg.Name)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", fieldArg.Name)
}
if field.Kind == FieldKindVirtualAlias {
field, ok = r.schema.ResolveAlias(fieldArg.Name)
if !ok {
return renderResult{}, errors.Errorf("invalid alias %q", fieldArg.Name)
}
}
value, err := expectNumericLiteral(right)
if err != nil {
return renderResult{}, err
}
var expr string
switch {
case field.Kind == FieldKindJSONList:
expr = jsonArrayLengthExpr(r.dialect, field)
case field.Kind == FieldKindScalar && field.Type == FieldTypeString:
expr = stringLengthExpr(r.dialect, field.columnExpr(r.dialect))
default:
return renderResult{}, errors.Errorf("size() does not support field %q", field.Name)
}
placeholder := r.addArg(value)
return renderResult{
sql: fmt.Sprintf("%s %s %s", expr, sqlOperator(op), placeholder),
}, nil
}
// stringLengthExpr returns the character-count expression for a string column.
// MySQL's LENGTH counts bytes, so CHAR_LENGTH is used to count characters and
// match CEL's size() code-point semantics; SQLite/Postgres LENGTH already counts
// characters.
func stringLengthExpr(d DialectName, colExpr string) string {
if d == DialectMySQL {
return fmt.Sprintf("CHAR_LENGTH(%s)", colExpr)
}
return fmt.Sprintf("LENGTH(%s)", colExpr)
}
func (r *renderer) renderScalarComparison(field Field, op ComparisonOperator, right ValueExpr) (renderResult, error) {
lit, err := expectLiteral(right)
if err != nil {
return renderResult{}, err
}
columnExpr := field.columnExpr(r.dialect)
if lit == nil {
switch op {
case CompareEq:
return renderResult{sql: fmt.Sprintf("%s IS NULL", columnExpr)}, nil
case CompareNeq:
return renderResult{sql: fmt.Sprintf("%s IS NOT NULL", columnExpr)}, nil
default:
return renderResult{}, errors.Errorf("operator %s not supported for null comparison", op)
}
}
placeholder := ""
switch field.Type {
case FieldTypeString:
value, ok := lit.(string)
if !ok {
return renderResult{}, errors.Errorf("field %q expects string value", field.Name)
}
placeholder = r.addArg(value)
case FieldTypeInt, FieldTypeTimestamp:
num, err := toInt64(lit)
if err != nil {
return renderResult{}, errors.Wrapf(err, "field %q expects integer value", field.Name)
}
placeholder = r.addArg(num)
default:
return renderResult{}, errors.Errorf("unsupported data type %q for field %s", field.Type, field.Name)
}
return renderResult{
sql: fmt.Sprintf("%s %s %s", columnExpr, sqlOperator(op), placeholder),
}, nil
}
func (r *renderer) renderBoolColumnComparison(field Field, op ComparisonOperator, right ValueExpr) (renderResult, error) {
value, err := expectBool(right)
if err != nil {
return renderResult{}, err
}
placeholder := r.addBoolArg(value)
column := qualifyColumn(r.dialect, field.Column)
return renderResult{
sql: fmt.Sprintf("%s %s %s", column, sqlOperator(op), placeholder),
}, nil
}
func (r *renderer) renderJSONBoolComparison(field Field, op ComparisonOperator, right ValueExpr) (renderResult, error) {
value, err := expectBool(right)
if err != nil {
return renderResult{}, err
}
jsonExpr := jsonExtractExpr(r.dialect, field)
switch r.dialect {
case DialectSQLite:
switch op {
case CompareEq:
if field.Name == "has_task_list" {
target := "0"
if value {
target = "1"
}
return renderResult{sql: fmt.Sprintf("%s = %s", jsonExpr, target)}, nil
}
if value {
return renderResult{sql: fmt.Sprintf("%s IS TRUE", jsonExpr)}, nil
}
return renderResult{sql: fmt.Sprintf("NOT(%s IS TRUE)", jsonExpr)}, nil
case CompareNeq:
if field.Name == "has_task_list" {
target := "0"
if value {
target = "1"
}
return renderResult{sql: fmt.Sprintf("%s != %s", jsonExpr, target)}, nil
}
if value {
return renderResult{sql: fmt.Sprintf("NOT(%s IS TRUE)", jsonExpr)}, nil
}
return renderResult{sql: fmt.Sprintf("%s IS TRUE", jsonExpr)}, nil
default:
return renderResult{}, errors.Errorf("operator %s not supported for boolean JSON field", op)
}
case DialectMySQL:
boolStr := "false"
if value {
boolStr = "true"
}
return renderResult{
sql: fmt.Sprintf("%s %s CAST('%s' AS JSON)", jsonExpr, sqlOperator(op), boolStr),
}, nil
case DialectPostgres:
placeholder := r.addArg(value)
return renderResult{
sql: fmt.Sprintf("(%s)::boolean %s %s", jsonExpr, sqlOperator(op), placeholder),
}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
func (r *renderer) renderInCondition(cond *InCondition) (renderResult, error) {
fieldRef, ok := cond.Left.(*FieldRef)
if !ok {
return renderResult{}, errors.New("IN operator requires a field on the left-hand side")
}
if fieldRef.Name == "tag" {
return r.renderTagInList(cond.Values)
}
field, ok := r.schema.Field(fieldRef.Name)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", fieldRef.Name)
}
if field.Kind != FieldKindScalar {
return renderResult{}, errors.Errorf("field %q does not support IN()", fieldRef.Name)
}
return r.renderScalarInCondition(field, cond.Values)
}
func (r *renderer) renderTagInList(values []ValueExpr) (renderResult, error) {
field, ok := r.schema.ResolveAlias("tag")
if !ok {
return renderResult{}, errors.New("tag attribute is not configured")
}
conditions := make([]string, 0, len(values))
for _, v := range values {
lit, err := expectLiteral(v)
if err != nil {
return renderResult{}, err
}
str, ok := lit.(string)
if !ok {
return renderResult{}, errors.New("tags must be compared with string literals")
}
switch r.dialect {
case DialectSQLite:
// Support hierarchical tags: match exact tag OR tags with this prefix (e.g., "book" matches "book" and "book/something")
exactMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s"%%`, str)))
prefixMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str)))
expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
conditions = append(conditions, expr)
case DialectMySQL:
// Support hierarchical tags: match exact tag OR tags with this prefix
exactMatch := fmt.Sprintf("JSON_CONTAINS(%s, %s)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
prefixMatch := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str)))
expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
conditions = append(conditions, expr)
case DialectPostgres:
// Support hierarchical tags: match exact tag OR tags with this prefix
exactMatch := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
prefixMatch := fmt.Sprintf("(%s)::text LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s/%%`, str)))
expr := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
conditions = append(conditions, expr)
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
if len(conditions) == 1 {
return renderResult{sql: conditions[0]}, nil
}
return renderResult{
sql: fmt.Sprintf("(%s)", strings.Join(conditions, " OR ")),
}, nil
}
func (r *renderer) renderElementInCondition(cond *ElementInCondition) (renderResult, error) {
field, ok := r.schema.Field(cond.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
}
if field.Kind != FieldKindJSONList {
return renderResult{}, errors.Errorf("field %q is not a tag list", cond.Field)
}
lit, err := expectLiteral(cond.Element)
if err != nil {
return renderResult{}, err
}
str, ok := lit.(string)
if !ok {
return renderResult{}, errors.New("tags membership requires string literal")
}
switch r.dialect {
case DialectSQLite:
sql := fmt.Sprintf("%s LIKE %s", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`%%"%s"%%`, str)))
return renderResult{sql: sql}, nil
case DialectMySQL:
sql := fmt.Sprintf("JSON_CONTAINS(%s, %s)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
return renderResult{sql: sql}, nil
case DialectPostgres:
sql := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", jsonArrayExpr(r.dialect, field), r.addArg(fmt.Sprintf(`"%s"`, str)))
return renderResult{sql: sql}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
func (r *renderer) renderScalarInCondition(field Field, values []ValueExpr) (renderResult, error) {
placeholders := make([]string, 0, len(values))
for _, v := range values {
lit, err := expectLiteral(v)
if err != nil {
return renderResult{}, err
}
switch field.Type {
case FieldTypeString:
str, ok := lit.(string)
if !ok {
return renderResult{}, errors.Errorf("field %q expects string values", field.Name)
}
placeholders = append(placeholders, r.addArg(str))
case FieldTypeInt:
num, err := toInt64(lit)
if err != nil {
return renderResult{}, err
}
placeholders = append(placeholders, r.addArg(num))
default:
return renderResult{}, errors.Errorf("field %q does not support IN() comparisons", field.Name)
}
}
column := field.columnExpr(r.dialect)
return renderResult{
sql: fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ",")),
}, nil
}
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
}
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)
}
}
// 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)
}
func (r *renderer) renderListComprehension(cond *ListComprehensionCondition) (renderResult, error) {
field, ok := r.schema.Field(cond.Field)
if !ok {
return renderResult{}, errors.Errorf("unknown field %q", cond.Field)
}
if field.Kind != FieldKindJSONList {
return renderResult{}, errors.Errorf("field %q is not a JSON list", cond.Field)
}
if cond.Kind == ComprehensionAll {
return r.renderTagAll(field, cond.Predicate)
}
if cond.Kind == ComprehensionExistsOne {
return r.renderTagExistsOne(field, cond.Predicate)
}
// Render based on predicate type
switch pred := cond.Predicate.(type) {
case *EqualsPredicate:
return r.renderTagEquals(field, pred.Value, cond.Kind)
case *StartsWithPredicate:
return r.renderTagStartsWith(field, pred.Prefix, cond.Kind)
case *EndsWithPredicate:
return r.renderTagEndsWith(field, pred.Suffix, cond.Kind)
case *ContainsPredicate:
return r.renderTagContains(field, pred.Substring, cond.Kind)
default:
return renderResult{}, errors.Errorf("unsupported predicate type %T in comprehension", pred)
}
}
// 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)
}
}
// renderTagExistsOne renders tags.exists_one(t, <pred>): exactly one element
// satisfies the predicate, via a COUNT(...) = 1 subquery. A null or empty array
// yields COUNT 0, which is correctly not equal to 1.
func (r *renderer) renderTagExistsOne(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:
return renderResult{sql: fmt.Sprintf("(SELECT COUNT(*) FROM json_each(%s) WHERE %s) = 1", arrayExpr, elemCond)}, nil
case DialectMySQL:
return renderResult{sql: fmt.Sprintf("(SELECT COUNT(*) FROM JSON_TABLE(%s, '$[*]' COLUMNS (value VARCHAR(512) PATH '$')) AS elem WHERE %s) = 1", arrayExpr, elemCond)}, nil
case DialectPostgres:
return renderResult{sql: fmt.Sprintf("(SELECT COUNT(*) FROM jsonb_array_elements_text(%s) AS elem(value) WHERE %s) = 1", arrayExpr, elemCond)}, 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)
}
}
// renderTagEquals generates SQL for tags.exists(t, t == "value").
func (r *renderer) renderTagEquals(field Field, value string, _ ComprehensionKind) (renderResult, error) {
arrayExpr := jsonArrayExpr(r.dialect, field)
switch r.dialect {
case DialectSQLite, DialectMySQL:
exactMatch := r.buildJSONArrayLike(arrayExpr, fmt.Sprintf(`%%"%s"%%`, value))
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, exactMatch)}, nil
case DialectPostgres:
exactMatch := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", arrayExpr, r.addArg(fmt.Sprintf(`"%s"`, value)))
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, exactMatch)}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
// renderTagStartsWith generates SQL for tags.exists(t, t.startsWith("prefix")).
func (r *renderer) renderTagStartsWith(field Field, prefix string, _ ComprehensionKind) (renderResult, error) {
arrayExpr := jsonArrayExpr(r.dialect, field)
switch r.dialect {
case DialectSQLite, DialectMySQL:
// Match exact tag or tags with this prefix (hierarchical support)
exactMatch := r.buildJSONArrayLike(arrayExpr, fmt.Sprintf(`%%"%s"%%`, prefix))
prefixMatch := r.buildJSONArrayLike(arrayExpr, fmt.Sprintf(`%%"%s%%`, prefix))
condition := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, condition)}, nil
case DialectPostgres:
// Use PostgreSQL's powerful JSON operators
exactMatch := fmt.Sprintf("%s @> jsonb_build_array(%s::json)", arrayExpr, r.addArg(fmt.Sprintf(`"%s"`, prefix)))
prefixMatch := fmt.Sprintf("(%s)::text LIKE %s", arrayExpr, r.addArg(fmt.Sprintf(`%%"%s%%`, prefix)))
condition := fmt.Sprintf("(%s OR %s)", exactMatch, prefixMatch)
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, condition)}, nil
default:
return renderResult{}, errors.Errorf("unsupported dialect %s", r.dialect)
}
}
// renderTagEndsWith generates SQL for tags.exists(t, t.endsWith("suffix")).
func (r *renderer) renderTagEndsWith(field Field, suffix string, _ ComprehensionKind) (renderResult, error) {
arrayExpr := jsonArrayExpr(r.dialect, field)
pattern := fmt.Sprintf(`%%%s"%%`, suffix)
likeExpr := r.buildJSONArrayLike(arrayExpr, pattern)
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, likeExpr)}, nil
}
// renderTagContains generates SQL for tags.exists(t, t.contains("substring")).
func (r *renderer) renderTagContains(field Field, substring string, _ ComprehensionKind) (renderResult, error) {
arrayExpr := jsonArrayExpr(r.dialect, field)
pattern := fmt.Sprintf(`%%%s%%`, substring)
likeExpr := r.buildJSONArrayLike(arrayExpr, pattern)
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, likeExpr)}, nil
}
// buildJSONArrayLike builds a LIKE expression for matching within a JSON array.
// Returns the LIKE clause without NULL/empty checks.
func (r *renderer) buildJSONArrayLike(arrayExpr, pattern string) string {
switch r.dialect {
case DialectSQLite, DialectMySQL:
return fmt.Sprintf("%s LIKE %s", arrayExpr, r.addArg(pattern))
case DialectPostgres:
return fmt.Sprintf("(%s)::text LIKE %s", arrayExpr, r.addArg(pattern))
default:
return ""
}
}
// wrapWithNullCheck wraps a condition with NULL and empty array checks.
// This ensures we don't match against NULL or empty JSON arrays.
func (r *renderer) wrapWithNullCheck(arrayExpr, condition string) string {
var nullCheck string
switch r.dialect {
case DialectSQLite:
nullCheck = fmt.Sprintf("%s IS NOT NULL AND %s != '[]'", arrayExpr, arrayExpr)
case DialectMySQL:
nullCheck = fmt.Sprintf("%s IS NOT NULL AND JSON_LENGTH(%s) > 0", arrayExpr, arrayExpr)
case DialectPostgres:
nullCheck = fmt.Sprintf("%s IS NOT NULL AND jsonb_array_length(%s) > 0", arrayExpr, arrayExpr)
default:
return condition
}
return fmt.Sprintf("(%s AND %s)", condition, nullCheck)
}
func (r *renderer) jsonBoolPredicate(field Field) (string, error) {
expr := jsonExtractExpr(r.dialect, field)
switch r.dialect {
case DialectSQLite:
return fmt.Sprintf("%s IS TRUE", expr), nil
case DialectMySQL:
return fmt.Sprintf("COALESCE(%s, CAST('false' AS JSON)) = CAST('true' AS JSON)", expr), nil
case DialectPostgres:
return fmt.Sprintf("(%s)::boolean IS TRUE", expr), nil
default:
return "", errors.Errorf("unsupported dialect %s", r.dialect)
}
}
func combineAnd(left, right renderResult) renderResult {
if left.unsatisfiable || right.unsatisfiable {
return renderResult{sql: "1 = 0", unsatisfiable: true}
}
if left.trivial {
return right
}
if right.trivial {
return left
}
return renderResult{
sql: fmt.Sprintf("(%s AND %s)", left.sql, right.sql),
}
}
func combineOr(left, right renderResult) renderResult {
if left.trivial || right.trivial {
return renderResult{trivial: true}
}
if left.unsatisfiable {
return right
}
if right.unsatisfiable {
return left
}
return renderResult{
sql: fmt.Sprintf("(%s OR %s)", left.sql, right.sql),
}
}
func (r *renderer) addArg(value any) string {
r.placeholderCounter++
r.args = append(r.args, value)
if r.dialect == DialectPostgres {
return fmt.Sprintf("$%d", r.placeholderOffset+r.placeholderCounter)
}
return "?"
}
func (r *renderer) addBoolArg(value bool) string {
var v any
switch r.dialect {
case DialectSQLite:
if value {
v = 1
} else {
v = 0
}
default:
v = value
}
return r.addArg(v)
}
func expectLiteral(expr ValueExpr) (any, error) {
lit, ok := expr.(*LiteralValue)
if !ok {
return nil, errors.New("expression must be a literal")
}
return lit.Value, nil
}
func expectBool(expr ValueExpr) (bool, error) {
lit, err := expectLiteral(expr)
if err != nil {
return false, err
}
value, ok := lit.(bool)
if !ok {
return false, errors.New("boolean literal required")
}
return value, nil
}
func expectNumericLiteral(expr ValueExpr) (int64, error) {
lit, err := expectLiteral(expr)
if err != nil {
return 0, err
}
return toInt64(lit)
}
func toInt64(value any) (int64, error) {
switch v := value.(type) {
case int:
return int64(v), nil
case int32:
return int64(v), nil
case int64:
return v, nil
case uint32:
return int64(v), nil
case uint64:
return int64(v), nil
case float32:
return int64(v), nil
case float64:
return int64(v), nil
default:
return 0, errors.Errorf("cannot convert %T to int64", value)
}
}
func sqlOperator(op ComparisonOperator) string {
return string(op)
}
func qualifyColumn(d DialectName, col Column) string {
switch d {
case DialectPostgres:
return fmt.Sprintf("%s.%s", col.Table, col.Name)
default:
return fmt.Sprintf("`%s`.`%s`", col.Table, col.Name)
}
}
func jsonPath(field Field) string {
return "$." + strings.Join(field.JSONPath, ".")
}
func jsonExtractExpr(d DialectName, field Field) string {
column := qualifyColumn(d, field.Column)
switch d {
case DialectSQLite, DialectMySQL:
return fmt.Sprintf("JSON_EXTRACT(%s, '%s')", column, jsonPath(field))
case DialectPostgres:
return buildPostgresJSONAccessor(column, field.JSONPath, true)
default:
return ""
}
}
func jsonArrayExpr(d DialectName, field Field) string {
column := qualifyColumn(d, field.Column)
switch d {
case DialectSQLite, DialectMySQL:
return fmt.Sprintf("JSON_EXTRACT(%s, '%s')", column, jsonPath(field))
case DialectPostgres:
return buildPostgresJSONAccessor(column, field.JSONPath, false)
default:
return ""
}
}
func jsonArrayLengthExpr(d DialectName, field Field) string {
arrayExpr := jsonArrayExpr(d, field)
switch d {
case DialectSQLite:
return fmt.Sprintf("JSON_ARRAY_LENGTH(COALESCE(%s, JSON_ARRAY()))", arrayExpr)
case DialectMySQL:
return fmt.Sprintf("JSON_LENGTH(COALESCE(%s, JSON_ARRAY()))", arrayExpr)
case DialectPostgres:
return fmt.Sprintf("jsonb_array_length(COALESCE(%s, '[]'::jsonb))", arrayExpr)
default:
return ""
}
}
func buildPostgresJSONAccessor(base string, path []string, terminalText bool) string {
expr := base
for idx, part := range path {
if idx == len(path)-1 && terminalText {
expr = fmt.Sprintf("%s->>'%s'", expr, part)
} else {
expr = fmt.Sprintf("%s->'%s'", expr, part)
}
}
return expr
}
+327
View File
@@ -0,0 +1,327 @@
package filter
import (
"fmt"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/ext"
)
// DialectName enumerates supported SQL dialects.
type DialectName string
const (
DialectSQLite DialectName = "sqlite"
DialectMySQL DialectName = "mysql"
DialectPostgres DialectName = "postgres"
)
// FieldType represents the logical type of a field.
type FieldType string
const (
FieldTypeString FieldType = "string"
FieldTypeInt FieldType = "int"
FieldTypeBool FieldType = "bool"
FieldTypeTimestamp FieldType = "timestamp"
)
// FieldKind describes how a field is stored.
type FieldKind string
const (
FieldKindScalar FieldKind = "scalar"
FieldKindBoolColumn FieldKind = "bool_column"
FieldKindJSONBool FieldKind = "json_bool"
FieldKindJSONList FieldKind = "json_list"
FieldKindVirtualAlias FieldKind = "virtual_alias"
)
// Column identifies the backing table column.
type Column struct {
Table string
Name string
}
// Field captures the schema metadata for an exposed CEL identifier.
type Field struct {
Name string
Kind FieldKind
Type FieldType
Column Column
JSONPath []string
AliasFor string
SupportsContains bool
Expressions map[DialectName]string
AllowedComparisonOps map[ComparisonOperator]bool
}
// Schema collects CEL environment options and field metadata.
type Schema struct {
Name string
Fields map[string]Field
EnvOptions []cel.EnvOption
}
// Field returns the field metadata if present.
func (s Schema) Field(name string) (Field, bool) {
f, ok := s.Fields[name]
return f, ok
}
// ResolveAlias resolves a virtual alias to its target field.
func (s Schema) ResolveAlias(name string) (Field, bool) {
field, ok := s.Fields[name]
if !ok {
return Field{}, false
}
if field.Kind == FieldKindVirtualAlias {
target, ok := s.Fields[field.AliasFor]
if !ok {
return Field{}, false
}
return target, true
}
return field, true
}
// NewSchema constructs the memo filter schema and CEL environment.
func NewSchema() Schema {
fields := map[string]Field{
"content": {
Name: "content",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "memo", Name: "content"},
SupportsContains: true,
Expressions: map[DialectName]string{},
},
"creator": {
Name: "creator",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "memo_creator", Name: "username"},
Expressions: map[DialectName]string{
DialectSQLite: "('users/' || %s)",
DialectMySQL: "CONCAT('users/', %s)",
DialectPostgres: "('users/' || %s)",
},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"creator_id": {
Name: "creator_id",
Kind: FieldKindScalar,
Type: FieldTypeInt,
Column: Column{Table: "memo", Name: "creator_id"},
Expressions: map[DialectName]string{},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"created_ts": {
Name: "created_ts",
Kind: FieldKindScalar,
Type: FieldTypeTimestamp,
Column: Column{Table: "memo", Name: "created_ts"},
Expressions: map[DialectName]string{
// MySQL stores created_ts as TIMESTAMP, needs conversion to epoch
DialectMySQL: "UNIX_TIMESTAMP(%s)",
// PostgreSQL and SQLite store created_ts as BIGINT (epoch), no conversion needed
DialectPostgres: "%s",
DialectSQLite: "%s",
},
},
"updated_ts": {
Name: "updated_ts",
Kind: FieldKindScalar,
Type: FieldTypeTimestamp,
Column: Column{Table: "memo", Name: "updated_ts"},
Expressions: map[DialectName]string{
// MySQL stores updated_ts as TIMESTAMP, needs conversion to epoch
DialectMySQL: "UNIX_TIMESTAMP(%s)",
// PostgreSQL and SQLite store updated_ts as BIGINT (epoch), no conversion needed
DialectPostgres: "%s",
DialectSQLite: "%s",
},
},
"pinned": {
Name: "pinned",
Kind: FieldKindBoolColumn,
Type: FieldTypeBool,
Column: Column{Table: "memo", Name: "pinned"},
Expressions: map[DialectName]string{},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"visibility": {
Name: "visibility",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "memo", Name: "visibility"},
Expressions: map[DialectName]string{},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"tags": {
Name: "tags",
Kind: FieldKindJSONList,
Type: FieldTypeString,
Column: Column{Table: "memo", Name: "payload"},
JSONPath: []string{"tags"},
},
"tag": {
Name: "tag",
Kind: FieldKindVirtualAlias,
Type: FieldTypeString,
AliasFor: "tags",
},
"has_task_list": {
Name: "has_task_list",
Kind: FieldKindJSONBool,
Type: FieldTypeBool,
Column: Column{Table: "memo", Name: "payload"},
JSONPath: []string{"property", "hasTaskList"},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"has_link": {
Name: "has_link",
Kind: FieldKindJSONBool,
Type: FieldTypeBool,
Column: Column{Table: "memo", Name: "payload"},
JSONPath: []string{"property", "hasLink"},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"has_code": {
Name: "has_code",
Kind: FieldKindJSONBool,
Type: FieldTypeBool,
Column: Column{Table: "memo", Name: "payload"},
JSONPath: []string{"property", "hasCode"},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
"has_incomplete_tasks": {
Name: "has_incomplete_tasks",
Kind: FieldKindJSONBool,
Type: FieldTypeBool,
Column: Column{Table: "memo", Name: "payload"},
JSONPath: []string{"property", "hasIncompleteTasks"},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
}
envOptions := []cel.EnvOption{
cel.Variable("content", cel.StringType),
cel.Variable("creator", cel.StringType),
cel.Variable("creator_id", cel.IntType),
cel.Variable("created_ts", cel.TimestampType),
cel.Variable("updated_ts", cel.TimestampType),
cel.Variable("pinned", cel.BoolType),
cel.Variable("tag", cel.StringType),
cel.Variable("tags", cel.ListType(cel.StringType)),
cel.Variable("visibility", cel.StringType),
cel.Variable("has_task_list", cel.BoolType),
cel.Variable("has_link", cel.BoolType),
cel.Variable("has_code", cel.BoolType),
cel.Variable("has_incomplete_tasks", cel.BoolType),
cel.Variable("now", cel.TimestampType),
ext.Sets(),
cel.ASTValidators(cel.ValidateRegexLiterals()),
}
return Schema{
Name: "memo",
Fields: fields,
EnvOptions: envOptions,
}
}
// NewAttachmentSchema constructs the attachment filter schema and CEL environment.
func NewAttachmentSchema() Schema {
fields := map[string]Field{
"filename": {
Name: "filename",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "attachment", Name: "filename"},
SupportsContains: true,
Expressions: map[DialectName]string{},
},
"mime_type": {
Name: "mime_type",
Kind: FieldKindScalar,
Type: FieldTypeString,
Column: Column{Table: "attachment", Name: "type"},
SupportsContains: true,
Expressions: map[DialectName]string{},
},
"create_time": {
Name: "create_time",
Kind: FieldKindScalar,
Type: FieldTypeTimestamp,
Column: Column{Table: "attachment", Name: "created_ts"},
Expressions: map[DialectName]string{
// MySQL stores created_ts as TIMESTAMP, needs conversion to epoch
DialectMySQL: "UNIX_TIMESTAMP(%s)",
// PostgreSQL and SQLite store created_ts as BIGINT (epoch), no conversion needed
DialectPostgres: "%s",
DialectSQLite: "%s",
},
},
"memo_id": {
Name: "memo_id",
Kind: FieldKindScalar,
Type: FieldTypeInt,
Column: Column{Table: "attachment", Name: "memo_id"},
Expressions: map[DialectName]string{},
AllowedComparisonOps: map[ComparisonOperator]bool{
CompareEq: true,
CompareNeq: true,
},
},
}
envOptions := []cel.EnvOption{
cel.Variable("filename", cel.StringType),
cel.Variable("mime_type", cel.StringType),
cel.Variable("create_time", cel.TimestampType),
cel.Variable("memo_id", cel.AnyType),
cel.Variable("now", cel.TimestampType),
cel.ASTValidators(cel.ValidateRegexLiterals()),
}
return Schema{
Name: "attachment",
Fields: fields,
EnvOptions: envOptions,
}
}
// columnExpr returns the field expression for the given dialect, applying
// any schema-specific overrides (e.g. UNIX timestamp conversions).
func (f Field) columnExpr(d DialectName) string {
base := qualifyColumn(d, f.Column)
if expr, ok := f.Expressions[d]; ok && expr != "" {
return fmt.Sprintf(expr, base)
}
return base
}
+100
View File
@@ -0,0 +1,100 @@
package filter
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// fixedClock returns a deterministic clock for asserting folded `now` values.
func fixedClock(epoch int64) func() time.Time {
return func() time.Time { return time.Unix(epoch, 0) }
}
func memoEngineAt(t *testing.T, epoch int64) *Engine {
t.Helper()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
engine.nowFunc = fixedClock(epoch)
return engine
}
func TestCompileNowVariableFoldsToInjectedClock(t *testing.T) {
t.Parallel()
engine := memoEngineAt(t, 1750000000)
stmt, err := engine.CompileToStatement(context.Background(), `created_ts >= now`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(1750000000)}, stmt.Args)
}
func TestCompileNowFunctionIsRemoved(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewSchema())
require.NoError(t, err)
// now() was the legacy custom function; it is replaced by the `now` variable.
_, err = engine.Compile(context.Background(), `created_ts >= now()`)
require.Error(t, err)
}
func TestCompileNowMinusDurationFolds(t *testing.T) {
t.Parallel()
engine := memoEngineAt(t, 1750000000)
stmt, err := engine.CompileToStatement(context.Background(), `created_ts >= now - duration("1h")`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(1750000000 - 3600)}, stmt.Args)
}
func TestCompileNowPlusDurationFolds(t *testing.T) {
t.Parallel()
engine := memoEngineAt(t, 1750000000)
stmt, err := engine.CompileToStatement(context.Background(), `updated_ts < now + duration("24h")`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(1750000000 + 86400)}, stmt.Args)
}
func TestCompileAbsoluteTimestampStringFolds(t *testing.T) {
t.Parallel()
engine := memoEngineAt(t, 1750000000)
stmt, err := engine.CompileToStatement(context.Background(), `created_ts >= timestamp("2025-01-01T00:00:00Z")`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(1735689600)}, stmt.Args)
}
func TestCompileTimestampFromEpochIntFolds(t *testing.T) {
t.Parallel()
// This is the shape the frontend date-range filter emits.
engine := memoEngineAt(t, 1750000000)
stmt, err := engine.CompileToStatement(context.Background(), `created_ts >= timestamp(1730000000)`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(1730000000)}, stmt.Args)
}
func TestCompileInvalidDurationLiteralErrors(t *testing.T) {
t.Parallel()
engine := memoEngineAt(t, 1750000000)
_, err := engine.Compile(context.Background(), `created_ts >= now - duration("garbage")`)
require.Error(t, err)
require.Contains(t, err.Error(), "duration")
}
func TestCompileAttachmentCreateTimeUsesNow(t *testing.T) {
t.Parallel()
engine, err := NewEngine(NewAttachmentSchema())
require.NoError(t, err)
engine.nowFunc = fixedClock(1750000000)
stmt, err := engine.CompileToStatement(context.Background(), `create_time >= now - duration("24h")`, RenderOptions{Dialect: DialectSQLite})
require.NoError(t, err)
require.Equal(t, []any{int64(1750000000 - 86400)}, stmt.Args)
}
@@ -1,11 +1,14 @@
package httpgetter
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/pkg/errors"
"golang.org/x/net/html"
@@ -14,16 +17,114 @@ import (
var ErrInternalIP = errors.New("internal IP addresses are not allowed")
var httpClient = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if err := validateURL(req.URL.String()); err != nil {
return errors.Wrap(err, "redirect to internal IP")
const maxHTMLMetaBytes = 512 * 1024
var (
lookupIPAddr = net.DefaultResolver.LookupIPAddr
dialContext = (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext
httpClient = newHTTPClient()
)
func newHTTPClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = secureDialContext
return &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if err := validateURL(req.URL.String()); err != nil {
return errors.Wrap(err, "redirect to internal IP")
}
if len(via) >= 10 {
return errors.New("too many redirects")
}
return nil
},
}
}
func secureDialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errors.Wrap(err, "invalid address")
}
ips, err := resolveAllowedIPs(ctx, host)
if err != nil {
return nil, err
}
var dialErr error
for _, ip := range ips {
conn, err := dialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
if len(via) >= 10 {
return errors.New("too many redirects")
dialErr = err
}
return nil, dialErr
}
func resolveAllowedIPs(ctx context.Context, host string) ([]net.IP, error) {
if ip := net.ParseIP(host); ip != nil {
if isInternalIP(ip) {
return nil, errors.Wrap(ErrInternalIP, ip.String())
}
return nil
},
return []net.IP{ip}, nil
}
addrs, err := lookupIPAddr(ctx, host)
if err != nil {
return nil, errors.Errorf("failed to resolve hostname: %v", err)
}
ips := make([]net.IP, 0, len(addrs))
for _, addr := range addrs {
ip := addr.IP
if ip == nil {
continue
}
if isInternalIP(ip) {
return nil, errors.Wrapf(ErrInternalIP, "host=%s, ip=%s", host, ip.String())
}
ips = append(ips, ip)
}
if len(ips) == 0 {
return nil, errors.New("hostname resolved to no addresses")
}
return ips, nil
}
func isInternalIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}
func validateURL(urlStr string) error {
u, err := url.Parse(urlStr)
if err != nil {
return errors.New("invalid URL format")
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("only http/https protocols are allowed")
}
host := u.Hostname()
if host == "" {
return errors.New("empty hostname")
}
if ip := net.ParseIP(host); ip != nil && isInternalIP(ip) {
return errors.Wrap(ErrInternalIP, ip.String())
}
return nil
}
type HTMLMeta struct {
@@ -51,9 +152,7 @@ func GetHTMLMeta(urlStr string) (*HTMLMeta, error) {
return nil, errors.New("not a HTML page")
}
// TODO: limit the size of the response body
htmlMeta := extractHTMLMeta(response.Body)
htmlMeta := extractHTMLMeta(io.LimitReader(response.Body, maxHTMLMetaBytes))
enrichSiteMeta(response.Request.URL, htmlMeta)
return htmlMeta, nil
}
@@ -77,11 +176,6 @@ func extractHTMLMeta(resp io.Reader) *HTMLMeta {
token := tokenizer.Token()
htmlMeta.Title = token.Data
} else if token.DataAtom == atom.Meta {
description, ok := extractMetaProperty(token, "description")
if ok {
htmlMeta.Description = description
}
ogTitle, ok := extractMetaProperty(token, "og:title")
if ok {
htmlMeta.Title = ogTitle
@@ -96,6 +190,11 @@ func extractHTMLMeta(resp io.Reader) *HTMLMeta {
if ok {
htmlMeta.Image = ogImage
}
description, ok := extractMetaProperty(token, "description")
if ok && htmlMeta.Description == "" {
htmlMeta.Description = description
}
}
}
}
@@ -106,7 +205,7 @@ func extractHTMLMeta(resp io.Reader) *HTMLMeta {
func extractMetaProperty(token html.Token, prop string) (content string, ok bool) {
content, ok = "", false
for _, attr := range token.Attr {
if attr.Key == "property" && attr.Val == prop {
if (attr.Key == "property" || attr.Key == "name") && strings.EqualFold(attr.Val, prop) {
ok = true
}
if attr.Key == "content" {
@@ -116,44 +215,6 @@ func extractMetaProperty(token html.Token, prop string) (content string, ok bool
return content, ok
}
func validateURL(urlStr string) error {
u, err := url.Parse(urlStr)
if err != nil {
return errors.New("invalid URL format")
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("only http/https protocols are allowed")
}
host := u.Hostname()
if host == "" {
return errors.New("empty hostname")
}
// check if the hostname is an IP
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
return errors.Wrap(ErrInternalIP, ip.String())
}
return nil
}
// check if it's a hostname, resolve it and check all returned IPs
ips, err := net.LookupIP(host)
if err != nil {
return errors.Errorf("failed to resolve hostname: %v", err)
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
return errors.Wrapf(ErrInternalIP, "host=%s, ip=%s", host, ip.String())
}
}
return nil
}
func enrichSiteMeta(url *url.URL, meta *HTMLMeta) {
if url.Hostname() == "www.youtube.com" {
if url.Path == "/watch" {
+189
View File
@@ -0,0 +1,189 @@
package httpgetter
import (
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestGetHTMLMeta(t *testing.T) {
originalHTTPClient := httpClient
t.Cleanup(func() {
httpClient = originalHTTPClient
})
httpClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
require.Equal(t, "http://93.184.216.34/article", req.URL.String())
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: io.NopCloser(strings.NewReader(`<!doctype html>
<html>
<head>
<title>Fallback title</title>
<meta name="description" content="Fallback description">
<meta property="og:title" content="Open Graph title">
<meta property="og:description" content="Open Graph description">
<meta property="og:image" content="https://example.com/cover.png">
</head>
<body>ignored</body>
</html>`)),
Request: req,
}, nil
}),
}
metadata, err := GetHTMLMeta("http://93.184.216.34/article")
require.NoError(t, err)
require.Equal(t, HTMLMeta{
Title: "Open Graph title",
Description: "Open Graph description",
Image: "https://example.com/cover.png",
}, *metadata)
}
func TestGetHTMLMetaWithNameOnly(t *testing.T) {
originalHTTPClient := httpClient
t.Cleanup(func() {
httpClient = originalHTTPClient
})
httpClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
require.Equal(t, "http://93.184.216.34/blog", req.URL.String())
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: io.NopCloser(strings.NewReader(`<!doctype html>
<html>
<head>
<title>Sample Page</title>
<meta name="description" content="This description should appear in the link preview.">
</head>
<body>Hello</body>
</html>`)),
Request: req,
}, nil
}),
}
metadata, err := GetHTMLMeta("http://93.184.216.34/blog")
require.NoError(t, err)
require.Equal(t, HTMLMeta{
Title: "Sample Page",
Description: "This description should appear in the link preview.",
Image: "",
}, *metadata)
}
func TestGetHTMLMetaWithNameCaseInsensitive(t *testing.T) {
originalHTTPClient := httpClient
t.Cleanup(func() {
httpClient = originalHTTPClient
})
httpClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
require.Equal(t, "http://93.184.216.34/blog", req.URL.String())
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: io.NopCloser(strings.NewReader(`<!doctype html>
<html>
<head>
<title>Sample Page</title>
<meta name="Description" content="Case insensitive description match.">
</head>
<body>Hello</body>
</html>`)),
Request: req,
}, nil
}),
}
metadata, err := GetHTMLMeta("http://93.184.216.34/blog")
require.NoError(t, err)
require.Equal(t, HTMLMeta{
Title: "Sample Page",
Description: "Case insensitive description match.",
Image: "",
}, *metadata)
}
func TestGetHTMLMetaForInternal(t *testing.T) {
// test for internal IP
if _, err := GetHTMLMeta("http://192.168.0.1"); !errors.Is(err, ErrInternalIP) {
t.Errorf("Expected error for internal IP, got %v", err)
}
// test for resolved internal IP
if _, err := GetHTMLMeta("http://localhost"); !errors.Is(err, ErrInternalIP) {
t.Errorf("Expected error for resolved internal IP, got %v", err)
}
}
func TestHTTPClientHasTimeout(t *testing.T) {
require.NotZero(t, httpClient.Timeout)
}
func TestSecureDialContextRejectsResolvedInternalIP(t *testing.T) {
originalLookupIPAddr := lookupIPAddr
originalDialContext := dialContext
t.Cleanup(func() {
lookupIPAddr = originalLookupIPAddr
dialContext = originalDialContext
})
lookupIPAddr = func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
}
dialContext = func(context.Context, string, string) (net.Conn, error) {
t.Fatal("internal IP should be rejected before dialing")
return nil, nil
}
_, err := secureDialContext(context.Background(), "tcp", "rebind.example:80")
require.ErrorIs(t, err, ErrInternalIP)
}
func TestSecureDialContextDialsResolvedIP(t *testing.T) {
originalLookupIPAddr := lookupIPAddr
originalDialContext := dialContext
t.Cleanup(func() {
lookupIPAddr = originalLookupIPAddr
dialContext = originalDialContext
})
lookupIPAddr = func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
}
var dialedAddress string
dialContext = func(_ context.Context, _ string, address string) (net.Conn, error) {
dialedAddress = address
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
clientConn.Close()
serverConn.Close()
})
return clientConn, nil
}
conn, err := secureDialContext(context.Background(), "tcp", "rebind.example:80")
require.NoError(t, err)
require.NotNil(t, conn)
require.Equal(t, "93.184.216.34:80", dialedAddress)
}
+1
View File
@@ -0,0 +1 @@
package httpgetter
@@ -29,7 +29,7 @@ func GetImage(urlStr string) (*Image, error) {
return nil, err
}
if !strings.HasPrefix(mediatype, "image/") {
return nil, errors.New("Wrong image mediatype")
return nil, errors.New("wrong image mediatype")
}
bodyBytes, err := io.ReadAll(response.Body)
@@ -4,4 +4,5 @@ type IdentityProviderUserInfo struct {
Identifier string
DisplayName string
Email string
AvatarURL string
}
@@ -1,4 +1,4 @@
// Package oauth2 is the plugin for OAuth2 Identity Provider.
// Package oauth2 implements the OAuth2 identity provider integration.
package oauth2
import (
@@ -7,11 +7,12 @@ import (
"fmt"
"io"
"net/http"
"time"
"github.com/pkg/errors"
"golang.org/x/oauth2"
"github.com/usememos/memos/plugin/idp"
"github.com/usememos/memos/internal/idp"
storepb "github.com/usememos/memos/proto/gen/store"
)
@@ -20,6 +21,8 @@ type IdentityProvider struct {
config *storepb.OAuth2Config
}
const userInfoRequestTimeout = 10 * time.Second
// NewIdentityProvider initializes a new OAuth2 Identity Provider with the given configuration.
func NewIdentityProvider(config *storepb.OAuth2Config) (*IdentityProvider, error) {
for v, field := range map[string]string{
@@ -40,7 +43,8 @@ func NewIdentityProvider(config *storepb.OAuth2Config) (*IdentityProvider, error
}
// ExchangeToken returns the exchanged OAuth2 token using the given authorization code.
func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code string) (string, error) {
// If codeVerifier is provided, it will be used for PKCE (Proof Key for Code Exchange) validation.
func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code, codeVerifier string) (string, error) {
conf := &oauth2.Config{
ClientID: p.config.ClientId,
ClientSecret: p.config.ClientSecret,
@@ -49,29 +53,38 @@ func (p *IdentityProvider) ExchangeToken(ctx context.Context, redirectURL, code
Endpoint: oauth2.Endpoint{
AuthURL: p.config.AuthUrl,
TokenURL: p.config.TokenUrl,
AuthStyle: oauth2.AuthStyleInParams,
AuthStyle: oauth2.AuthStyleAutoDetect,
},
}
token, err := conf.Exchange(ctx, code)
// Prepare token exchange options
opts := []oauth2.AuthCodeOption{}
// Add PKCE code_verifier if provided
if codeVerifier != "" {
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", codeVerifier))
}
token, err := conf.Exchange(ctx, code, opts...)
if err != nil {
return "", errors.Wrap(err, "failed to exchange access token")
}
accessToken, ok := token.Extra("access_token").(string)
if !ok {
return "", errors.New(`missing "access_token" from authorization response`)
// Use the standard AccessToken field instead of Extra()
// This is more reliable across different OAuth providers
if token.AccessToken == "" {
return "", errors.New("missing access token from authorization response")
}
return accessToken, nil
return token.AccessToken, nil
}
// UserInfo returns the parsed user information using the given OAuth2 token.
func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo, error) {
client := &http.Client{}
req, err := http.NewRequest(http.MethodGet, p.config.UserInfoUrl, nil)
func (p *IdentityProvider) UserInfo(ctx context.Context, token string) (*idp.IdentityProviderUserInfo, error) {
client := &http.Client{Timeout: userInfoRequestTimeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.config.UserInfoUrl, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to new http request")
return nil, errors.Wrap(err, "failed to create http request")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
@@ -79,18 +92,25 @@ func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo
if err != nil {
return nil, errors.Wrap(err, "failed to get user information")
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return nil, errors.Wrap(readErr, "failed to read error response body")
}
return nil, errors.Errorf("userinfo request failed with status %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "failed to read response body")
}
defer resp.Body.Close()
var claims map[string]any
err = json.Unmarshal(body, &claims)
if err != nil {
if err := json.Unmarshal(body, &claims); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal response body")
}
userInfo := &idp.IdentityProviderUserInfo{}
if v, ok := claims[p.config.FieldMapping.Identifier].(string); ok {
userInfo.Identifier = v
@@ -113,5 +133,10 @@ func (p *IdentityProvider) UserInfo(token string) (*idp.IdentityProviderUserInfo
userInfo.Email = v
}
}
if p.config.FieldMapping.AvatarUrl != "" {
if v, ok := claims[p.config.FieldMapping.AvatarUrl].(string); ok {
userInfo.AvatarURL = v
}
}
return userInfo, nil
}
@@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/idp"
"github.com/usememos/memos/internal/idp"
storepb "github.com/usememos/memos/proto/gen/store"
)
@@ -147,11 +147,12 @@ func TestIdentityProvider(t *testing.T) {
require.NoError(t, err)
redirectURL := "https://example.com/oauth/callback"
oauthToken, err := oauth2.ExchangeToken(ctx, redirectURL, testCode)
// Test without PKCE (backward compatibility)
oauthToken, err := oauth2.ExchangeToken(ctx, redirectURL, testCode, "")
require.NoError(t, err)
require.Equal(t, testAccessToken, oauthToken)
userInfoResult, err := oauth2.UserInfo(oauthToken)
userInfoResult, err := oauth2.UserInfo(ctx, oauthToken)
require.NoError(t, err)
wantUserInfo := &idp.IdentityProviderUserInfo{
@@ -161,3 +162,130 @@ func TestIdentityProvider(t *testing.T) {
}
assert.Equal(t, wantUserInfo, userInfoResult)
}
func TestIdentityProviderExchangeTokenClientAuthentication(t *testing.T) {
const (
clientID = "test-client-id"
clientSecret = "test-client-secret"
code = "test-code"
accessToken = "test-access-token"
codeVerifier = "test-code-verifier"
)
tests := []struct {
name string
acceptBasicAuth bool
expectedRequests int
}{
{
name: "client secret basic",
acceptBasicAuth: true,
expectedRequests: 1,
},
{
name: "client secret post fallback",
acceptBasicAuth: false,
expectedRequests: 2,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
require.NoError(t, r.ParseForm())
require.Equal(t, code, r.Form.Get("code"))
require.Equal(t, codeVerifier, r.Form.Get("code_verifier"))
username, password, hasBasicAuth := r.BasicAuth()
if test.acceptBasicAuth {
require.True(t, hasBasicAuth)
require.Equal(t, clientID, username)
require.Equal(t, clientSecret, password)
require.Empty(t, r.Form.Get("client_id"))
require.Empty(t, r.Form.Get("client_secret"))
} else if hasBasicAuth {
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
return
} else {
require.Equal(t, clientID, r.Form.Get("client_id"))
require.Equal(t, clientSecret, r.Form.Get("client_secret"))
}
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"access_token": accessToken,
"token_type": "Bearer",
}))
}))
defer server.Close()
provider, err := NewIdentityProvider(&storepb.OAuth2Config{
ClientId: clientID,
ClientSecret: clientSecret,
TokenUrl: server.URL,
UserInfoUrl: "https://example.com/oauth2/userinfo",
FieldMapping: &storepb.FieldMapping{Identifier: "sub"},
})
require.NoError(t, err)
token, err := provider.ExchangeToken(context.Background(), "https://example.com/auth/callback", code, codeVerifier)
require.NoError(t, err)
assert.Equal(t, accessToken, token)
assert.Equal(t, test.expectedRequests, requestCount)
})
}
}
func TestIdentityProviderUserInfoUsesContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer s.Close()
oauth2, err := NewIdentityProvider(
&storepb.OAuth2Config{
ClientId: "test-client-id",
ClientSecret: "test-client-secret",
TokenUrl: "https://example.com/oauth2/token",
UserInfoUrl: s.URL,
FieldMapping: &storepb.FieldMapping{
Identifier: "sub",
},
},
)
require.NoError(t, err)
_, err = oauth2.UserInfo(ctx, "test-access-token")
require.Error(t, err)
assert.ErrorContains(t, err, "failed to get user information")
}
func TestIdentityProviderUserInfoRejectsNon2xx(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "upstream failure", http.StatusBadGateway)
}))
defer s.Close()
oauth2, err := NewIdentityProvider(
&storepb.OAuth2Config{
ClientId: "test-client-id",
ClientSecret: "test-client-secret",
TokenUrl: "https://example.com/oauth2/token",
UserInfoUrl: s.URL,
FieldMapping: &storepb.FieldMapping{
Identifier: "sub",
},
},
)
require.NoError(t, err)
_, err = oauth2.UserInfo(context.Background(), "test-access-token")
require.Error(t, err)
assert.ErrorContains(t, err, "userinfo request failed with status 502")
assert.ErrorContains(t, err, "upstream failure")
}
+28
View File
@@ -0,0 +1,28 @@
package ast
import (
gast "github.com/yuin/goldmark/ast"
)
// MentionNode represents an @mention in the markdown AST.
type MentionNode struct {
gast.BaseInline
// Username without the @ prefix.
Username []byte
}
// KindMention is the NodeKind for MentionNode.
var KindMention = gast.NewNodeKind("Mention")
// Kind returns KindMention.
func (*MentionNode) Kind() gast.NodeKind {
return KindMention
}
// Dump implements Node.Dump for debugging.
func (n *MentionNode) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, map[string]string{
"Username": string(n.Username),
}, nil)
}
+28
View File
@@ -0,0 +1,28 @@
package ast
import (
gast "github.com/yuin/goldmark/ast"
)
// TagNode represents a #tag in the markdown AST.
type TagNode struct {
gast.BaseInline
// Tag name without the # prefix
Tag []byte
}
// KindTag is the NodeKind for TagNode.
var KindTag = gast.NewNodeKind("Tag")
// Kind returns KindTag.
func (*TagNode) Kind() gast.NodeKind {
return KindTag
}
// Dump implements Node.Dump for debugging.
func (n *TagNode) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, map[string]string{
"Tag": string(n.Tag),
}, nil)
}
+24
View File
@@ -0,0 +1,24 @@
package extensions
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
mparser "github.com/usememos/memos/internal/markdown/parser"
)
type mentionExtension struct{}
// MentionExtension is a goldmark extension for @mention syntax.
var MentionExtension = &mentionExtension{}
// Extend extends the goldmark parser with mention support.
func (*mentionExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithInlineParsers(
// Priority 200 - run before standard link parser (500).
util.Prioritized(mparser.NewMentionParser(), 200),
),
)
}
+24
View File
@@ -0,0 +1,24 @@
package extensions
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
mparser "github.com/usememos/memos/internal/markdown/parser"
)
type tagExtension struct{}
// TagExtension is a goldmark extension for #tag syntax.
var TagExtension = &tagExtension{}
// Extend extends the goldmark parser with tag support.
func (*tagExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithInlineParsers(
// Priority 200 - run before standard link parser (500)
util.Prioritized(mparser.NewTagParser(), 200),
),
)
}
+489
View File
@@ -0,0 +1,489 @@
package markdown
import (
"bytes"
"strings"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
east "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/internal/markdown/ast"
"github.com/usememos/memos/internal/markdown/extensions"
"github.com/usememos/memos/internal/markdown/renderer"
storepb "github.com/usememos/memos/proto/gen/store"
)
// ExtractedData contains all metadata extracted from markdown in a single pass.
type ExtractedData struct {
Tags []string
Mentions []string
Property *storepb.MemoPayload_Property
}
// Service handles markdown metadata extraction.
// It uses goldmark to parse markdown and extract tags, properties, and snippets.
// HTML rendering is primarily done on frontend using markdown-it, but backend provides
// RenderHTML for RSS feeds and other server-side rendering needs.
type Service interface {
// ExtractAll extracts tags, properties, and references in a single parse (most efficient)
ExtractAll(content []byte) (*ExtractedData, error)
// ExtractTags returns all #tags found in content
ExtractTags(content []byte) ([]string, error)
// ExtractProperties computes boolean properties
ExtractProperties(content []byte) (*storepb.MemoPayload_Property, error)
// RenderMarkdown renders goldmark AST back to markdown text
RenderMarkdown(content []byte) (string, error)
// RenderHTML renders markdown content to HTML
RenderHTML(content []byte) (string, error)
// GenerateSnippet creates plain text summary
GenerateSnippet(content []byte, maxLength int) (string, error)
// ValidateContent checks for syntax errors
ValidateContent(content []byte) error
// RenameTag renames all occurrences of oldTag to newTag in content
RenameTag(content []byte, oldTag, newTag string) (string, error)
}
// service implements the Service interface.
type service struct {
md goldmark.Markdown
}
// Option configures the markdown service.
type Option func(*config)
type config struct {
enableTags bool
enableMentions bool
}
// WithTagExtension enables #tag parsing.
func WithTagExtension() Option {
return func(c *config) {
c.enableTags = true
}
}
// WithMentionExtension enables @mention parsing.
func WithMentionExtension() Option {
return func(c *config) {
c.enableMentions = true
}
}
// NewService creates a new markdown service with the given options.
func NewService(opts ...Option) Service {
cfg := &config{}
for _, opt := range opts {
opt(cfg)
}
exts := []goldmark.Extender{
extension.GFM, // GitHub Flavored Markdown (tables, strikethrough, task lists, autolinks)
}
// Add custom extensions based on config
if cfg.enableTags {
exts = append(exts, extensions.TagExtension)
}
if cfg.enableMentions {
exts = append(exts, extensions.MentionExtension)
}
md := goldmark.New(
goldmark.WithExtensions(exts...),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(), // Generate heading IDs
),
)
return &service{
md: md,
}
}
// parse is an internal helper to parse content into AST.
func (s *service) parse(content []byte) (gast.Node, error) {
reader := text.NewReader(content)
doc := s.md.Parser().Parse(reader)
return doc, nil
}
func isTagNodeInLinkOrImage(n gast.Node) bool {
for parent := n.Parent(); parent != nil; parent = parent.Parent() {
switch parent.Kind() {
case gast.KindLink, gast.KindImage:
return true
default:
// Keep walking ancestors.
}
}
return false
}
func asMemoTagNode(n gast.Node) (*mast.TagNode, bool) {
tagNode, ok := n.(*mast.TagNode)
if !ok || isTagNodeInLinkOrImage(n) {
return nil, false
}
return tagNode, true
}
// ExtractTags returns all #tags found in content.
func (s *service) ExtractTags(content []byte) ([]string, error) {
root, err := s.parse(content)
if err != nil {
return nil, err
}
var tags []string
// Walk the AST to find tag nodes
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if tagNode, ok := asMemoTagNode(n); ok {
tags = append(tags, string(tagNode.Tag))
}
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
// Deduplicate tags while preserving original case
return uniquePreserveCase(tags), nil
}
// extractHeadingText extracts plain text content from a heading node.
func extractHeadingText(n gast.Node, source []byte) string {
var buf strings.Builder
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
extractTextFromNode(child, source, &buf)
}
return buf.String()
}
// extractTextFromNode recursively extracts plain text from a node and its children.
func extractTextFromNode(n gast.Node, source []byte, buf *strings.Builder) {
if textNode, ok := n.(*gast.Text); ok {
buf.Write(textNode.Segment.Value(source))
return
}
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
extractTextFromNode(child, source, buf)
}
}
// ExtractProperties computes boolean properties about the content.
func (s *service) ExtractProperties(content []byte) (*storepb.MemoPayload_Property, error) {
root, err := s.parse(content)
if err != nil {
return nil, err
}
prop := &storepb.MemoPayload_Property{}
firstBlockChecked := false
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
// Check if the first block-level child of the document is an H1 heading.
if !firstBlockChecked && n.Parent() != nil && n.Parent().Kind() == gast.KindDocument {
firstBlockChecked = true
if heading, ok := n.(*gast.Heading); ok && heading.Level == 1 {
prop.Title = extractHeadingText(n, content)
}
}
switch n.Kind() {
case gast.KindLink:
prop.HasLink = true
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
prop.HasCode = true
case east.KindTaskCheckBox:
prop.HasTaskList = true
if checkBox, ok := n.(*east.TaskCheckBox); ok {
if !checkBox.IsChecked {
prop.HasIncompleteTasks = true
}
}
default:
// No special handling for other node types
}
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
return prop, nil
}
// RenderMarkdown renders goldmark AST back to markdown text.
func (s *service) RenderMarkdown(content []byte) (string, error) {
root, err := s.parse(content)
if err != nil {
return "", err
}
mdRenderer := renderer.NewMarkdownRenderer()
return mdRenderer.Render(root, content), nil
}
// RenderHTML renders markdown content to HTML using goldmark's built-in HTML renderer.
func (s *service) RenderHTML(content []byte) (string, error) {
var buf bytes.Buffer
if err := s.md.Convert(content, &buf); err != nil {
return "", err
}
return buf.String(), nil
}
// GenerateSnippet creates a plain text summary from markdown content.
func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error) {
root, err := s.parse(content)
if err != nil {
return "", err
}
var buf strings.Builder
var lastNodeWasBlock bool
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if entering {
// Skip code blocks entirely (but keep inline code spans for snippet text)
switch n.Kind() {
case gast.KindCodeBlock, gast.KindFencedCodeBlock:
return gast.WalkSkipChildren, nil
default:
// Continue walking for other node types
}
// Add space before block elements (except first)
switch n.Kind() {
case gast.KindParagraph, gast.KindHeading, gast.KindListItem, east.KindTableCell, east.KindTableRow, east.KindTableHeader:
if buf.Len() > 0 && lastNodeWasBlock {
buf.WriteByte(' ')
}
default:
// No space needed for other node types
}
}
if !entering {
// Mark that we just exited a block element
switch n.Kind() {
case gast.KindParagraph, gast.KindHeading, gast.KindListItem, east.KindTableCell, east.KindTableRow, east.KindTableHeader:
lastNodeWasBlock = true
default:
// Not a block element
}
return gast.WalkContinue, nil
}
lastNodeWasBlock = false
// Extract text from various node types
switch node := n.(type) {
case *gast.Text:
segment := node.Segment
buf.Write(segment.Value(content))
if node.SoftLineBreak() {
buf.WriteByte(' ')
}
case *gast.AutoLink:
buf.Write(node.URL(content))
return gast.WalkSkipChildren, nil
case *mast.TagNode:
buf.WriteByte('#')
buf.Write(node.Tag)
default:
// Ignore other node types.
}
// Stop walking if we've exceeded double the max length
// (we'll truncate precisely later)
if buf.Len() > maxLength*2 {
return gast.WalkStop, nil
}
return gast.WalkContinue, nil
})
if err != nil {
return "", err
}
snippet := buf.String()
// Truncate at word boundary if needed
if len(snippet) > maxLength {
snippet = truncateAtWord(snippet, maxLength)
}
return strings.TrimSpace(snippet), nil
}
// ValidateContent checks if the markdown content is valid.
func (s *service) ValidateContent(content []byte) error {
// Try to parse the content
_, err := s.parse(content)
return err
}
// ExtractAll extracts tags, properties, and references in a single parse for efficiency.
func (s *service) ExtractAll(content []byte) (*ExtractedData, error) {
root, err := s.parse(content)
if err != nil {
return nil, err
}
data := &ExtractedData{
Tags: []string{},
Mentions: []string{},
Property: &storepb.MemoPayload_Property{},
}
firstBlockChecked := false
// Single walk to collect all data
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if tagNode, ok := asMemoTagNode(n); ok {
data.Tags = append(data.Tags, string(tagNode.Tag))
}
if mentionNode, ok := n.(*mast.MentionNode); ok {
data.Mentions = append(data.Mentions, strings.ToLower(string(mentionNode.Username)))
}
// Check if the first block-level child of the document is an H1 heading.
if !firstBlockChecked && n.Parent() != nil && n.Parent().Kind() == gast.KindDocument {
firstBlockChecked = true
if heading, ok := n.(*gast.Heading); ok && heading.Level == 1 {
data.Property.Title = extractHeadingText(n, content)
}
}
// Extract properties based on node kind
switch n.Kind() {
case gast.KindLink:
data.Property.HasLink = true
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
data.Property.HasCode = true
case east.KindTaskCheckBox:
data.Property.HasTaskList = true
if checkBox, ok := n.(*east.TaskCheckBox); ok {
if !checkBox.IsChecked {
data.Property.HasIncompleteTasks = true
}
}
default:
// No special handling for other node types
}
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
// Deduplicate tags while preserving original case
data.Tags = uniquePreserveCase(data.Tags)
data.Mentions = uniquePreserveCase(data.Mentions)
return data, nil
}
// RenameTag renames all occurrences of oldTag to newTag in content.
func (s *service) RenameTag(content []byte, oldTag, newTag string) (string, error) {
root, err := s.parse(content)
if err != nil {
return "", err
}
// Walk the AST to find and rename tag nodes
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if tagNode, ok := asMemoTagNode(n); ok {
if string(tagNode.Tag) == oldTag {
tagNode.Tag = []byte(newTag)
}
}
return gast.WalkContinue, nil
})
if err != nil {
return "", err
}
// Render back to markdown using the already-parsed AST
mdRenderer := renderer.NewMarkdownRenderer()
return mdRenderer.Render(root, content), nil
}
// uniquePreserveCase returns unique strings from input while preserving case.
func uniquePreserveCase(strs []string) []string {
seen := make(map[string]struct{})
var result []string
for _, s := range strs {
if _, exists := seen[s]; !exists {
seen[s] = struct{}{}
result = append(result, s)
}
}
return result
}
// truncateAtWord truncates a string at the last word boundary before maxLength.
// maxLength is treated as a rune (character) count to properly handle UTF-8 multi-byte characters.
func truncateAtWord(s string, maxLength int) string {
// Convert to runes to properly handle multi-byte UTF-8 characters
runes := []rune(s)
if len(runes) <= maxLength {
return s
}
// Truncate to max length (by character count, not byte count)
truncated := string(runes[:maxLength])
// Find last space to avoid cutting in the middle of a word
lastSpace := strings.LastIndexAny(truncated, " \t\n\r")
if lastSpace > 0 {
truncated = truncated[:lastSpace]
}
return truncated + " ..."
}
+664
View File
@@ -0,0 +1,664 @@
package markdown
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewService(t *testing.T) {
svc := NewService()
assert.NotNil(t, svc)
}
func TestValidateContent(t *testing.T) {
svc := NewService()
tests := []struct {
name string
content string
wantErr bool
}{
{
name: "valid markdown",
content: "# Hello\n\nThis is **bold** text.",
wantErr: false,
},
{
name: "empty content",
content: "",
wantErr: false,
},
{
name: "complex markdown",
content: "# Title\n\n- List item 1\n- List item 2\n\n```go\ncode block\n```",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := svc.ValidateContent([]byte(tt.content))
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGenerateSnippet(t *testing.T) {
svc := NewService()
tests := []struct {
name string
content string
maxLength int
expected string
}{
{
name: "simple text",
content: "Hello world",
maxLength: 100,
expected: "Hello world",
},
{
name: "text with formatting",
content: "This is **bold** and *italic* text.",
maxLength: 100,
expected: "This is bold and italic text.",
},
{
name: "truncate long text",
content: "This is a very long piece of text that should be truncated at a word boundary.",
maxLength: 30,
expected: "This is a very long piece of ...",
},
{
name: "heading and paragraph",
content: "# My Title\n\nThis is the first paragraph.",
maxLength: 100,
expected: "My Title This is the first paragraph.",
},
{
name: "code block removed",
content: "Text before\n\n```go\ncode\n```\n\nText after",
maxLength: 100,
expected: "Text before Text after",
},
{
name: "list items",
content: "- Item 1\n- Item 2\n- Item 3",
maxLength: 100,
expected: "Item 1 Item 2 Item 3",
},
{
name: "inline code preserved",
content: "`console.log('hello')`",
maxLength: 100,
expected: "console.log('hello')",
},
{
name: "text with inline code",
content: "Use `fmt.Println` to print output.",
maxLength: 100,
expected: "Use fmt.Println to print output.",
},
{
name: "image alt text",
content: "![alt text](https://example.com/img.png)",
maxLength: 100,
expected: "alt text",
},
{
name: "strikethrough text",
content: "~~deleted text~~",
maxLength: 100,
expected: "deleted text",
},
{
name: "blockquote",
content: "> quoted text",
maxLength: 100,
expected: "quoted text",
},
{
name: "table cells spaced",
content: "| a | b |\n|---|---|\n| 1 | 2 |",
maxLength: 100,
expected: "a b 1 2",
},
{
name: "plain URL autolink",
content: "https://usememos.com",
maxLength: 100,
expected: "https://usememos.com",
},
{
name: "text with plain URL",
content: "Check out https://usememos.com for more info.",
maxLength: 100,
expected: "Check out https://usememos.com for more info.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
snippet, err := svc.GenerateSnippet([]byte(tt.content), tt.maxLength)
require.NoError(t, err)
assert.Equal(t, tt.expected, snippet)
})
}
// Test with tag extension enabled (matches production config).
svcWithTags := NewService(WithTagExtension())
tagTests := []struct {
name string
content string
maxLength int
expected string
}{
{
name: "tag only",
content: "#todo",
maxLength: 100,
expected: "#todo",
},
{
name: "text with tags",
content: "Remember to #review the #code",
maxLength: 100,
expected: "Remember to #review the #code",
},
}
for _, tt := range tagTests {
t.Run(tt.name, func(t *testing.T) {
snippet, err := svcWithTags.GenerateSnippet([]byte(tt.content), tt.maxLength)
require.NoError(t, err)
assert.Equal(t, tt.expected, snippet)
})
}
}
func TestExtractProperties(t *testing.T) {
tests := []struct {
name string
content string
hasLink bool
hasCode bool
hasTasks bool
hasInc bool
title string
}{
{
name: "plain text",
content: "Just plain text",
hasLink: false,
hasCode: false,
hasTasks: false,
hasInc: false,
title: "",
},
{
name: "with link",
content: "Check out [this link](https://example.com)",
hasLink: true,
hasCode: false,
hasTasks: false,
hasInc: false,
title: "",
},
{
name: "with inline code",
content: "Use `console.log()` to debug",
hasLink: false,
hasCode: true,
hasTasks: false,
hasInc: false,
title: "",
},
{
name: "with code block",
content: "```go\nfunc main() {}\n```",
hasLink: false,
hasCode: true,
hasTasks: false,
hasInc: false,
title: "",
},
{
name: "with completed task",
content: "- [x] Completed task",
hasLink: false,
hasCode: false,
hasTasks: true,
hasInc: false,
title: "",
},
{
name: "with incomplete task",
content: "- [ ] Todo item",
hasLink: false,
hasCode: false,
hasTasks: true,
hasInc: true,
title: "",
},
{
name: "mixed tasks",
content: "- [x] Done\n- [ ] Not done",
hasLink: false,
hasCode: false,
hasTasks: true,
hasInc: true,
title: "",
},
{
name: "everything",
content: "# Title\n\n[Link](url)\n\n`code`\n\n- [ ] Task",
hasLink: true,
hasCode: true,
hasTasks: true,
hasInc: true,
title: "Title",
},
{
name: "h1 as first node extracts title",
content: "# My Article Title\n\nBody text here.",
title: "My Article Title",
},
{
name: "h2 as first node does not extract title",
content: "## Sub Heading\n\nBody text.",
title: "",
},
{
name: "h1 not first node does not extract title",
content: "Some text\n\n# Heading Later",
title: "",
},
{
name: "h1 with inline formatting extracts plain text",
content: "# Title with **bold** and *italic*\n\nBody.",
title: "Title with bold and italic",
},
{
name: "empty content has no title",
content: "",
title: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewService()
props, err := svc.ExtractProperties([]byte(tt.content))
require.NoError(t, err)
assert.Equal(t, tt.hasLink, props.HasLink, "HasLink")
assert.Equal(t, tt.hasCode, props.HasCode, "HasCode")
assert.Equal(t, tt.hasTasks, props.HasTaskList, "HasTaskList")
assert.Equal(t, tt.hasInc, props.HasIncompleteTasks, "HasIncompleteTasks")
assert.Equal(t, tt.title, props.Title, "Title")
})
}
}
func TestExtractAllTitle(t *testing.T) {
svc := NewService(WithTagExtension())
tests := []struct {
name string
content string
title string
}{
{
name: "h1 first node",
content: "# Article Title\n\nContent with #tag",
title: "Article Title",
},
{
name: "no h1",
content: "Just text with #tag",
title: "",
},
{
name: "h1 not first",
content: "Intro\n\n# Late Heading",
title: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := svc.ExtractAll([]byte(tt.content))
require.NoError(t, err)
assert.Equal(t, tt.title, data.Property.Title, "Title")
})
}
}
func TestExtractAllMentions(t *testing.T) {
svc := NewService(WithTagExtension(), WithMentionExtension())
data, err := svc.ExtractAll([]byte("Hi @Alice and @bob. Email support@example.com should stay plain. #tag"))
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alice", "bob"}, data.Mentions)
assert.ElementsMatch(t, []string{"tag"}, data.Tags)
maxLengthUsername := "a" + strings.Repeat("b", 62)
data, err = svc.ExtractAll([]byte("@" + maxLengthUsername))
require.NoError(t, err)
assert.Equal(t, []string{maxLengthUsername}, data.Mentions)
data, err = svc.ExtractAll([]byte("@" + maxLengthUsername + "c"))
require.NoError(t, err)
assert.Empty(t, data.Mentions)
}
func TestExtractAllSkipsTagsInsideLinks(t *testing.T) {
svc := NewService(WithTagExtension())
data, err := svc.ExtractAll([]byte(
"[release #notes](https://example.com/releases#release-notes)\n\n" +
"![preview #image](https://example.com/image#preview)\n\n" +
"Outside #memo-tag",
))
require.NoError(t, err)
assert.ElementsMatch(t, []string{"memo-tag"}, data.Tags)
assert.True(t, data.Property.HasLink)
}
func TestExtractTags(t *testing.T) {
tests := []struct {
name string
content string
withExt bool
expected []string
}{
{
name: "no tags",
content: "Just plain text",
withExt: false,
expected: []string{},
},
{
name: "single tag",
content: "Text with #tag",
withExt: true,
expected: []string{"tag"},
},
{
name: "multiple tags",
content: "Text with #tag1 and #tag2",
withExt: true,
expected: []string{"tag1", "tag2"},
},
{
name: "duplicate tags",
content: "#work is important. #Work #WORK",
withExt: true,
expected: []string{"work", "Work", "WORK"},
},
{
name: "tags with hyphens and underscores",
content: "Tags: #work-notes #2024_plans",
withExt: true,
expected: []string{"work-notes", "2024_plans"},
},
{
name: "tags at end of sentence",
content: "This is important #urgent.",
withExt: true,
expected: []string{"urgent"},
},
{
name: "headings not tags",
content: "## Heading\n\n# Title\n\nText with #realtag",
withExt: true,
expected: []string{"realtag"},
},
{
name: "numeric tag",
content: "Issue #123",
withExt: true,
expected: []string{"123"},
},
{
name: "tag in list",
content: "- Item 1 #todo\n- Item 2 #done",
withExt: true,
expected: []string{"todo", "done"},
},
{
name: "autolink URL fragment not tag",
content: "https://github.com/dmtrKovalenko/fff#pi-agent-extension\n\nProject #memo-tag",
withExt: true,
expected: []string{"memo-tag"},
},
{
name: "markdown link text and fragment not tags",
content: "[release #notes](https://example.com/releases#release-notes) Outside #memo-tag",
withExt: true,
expected: []string{"memo-tag"},
},
{
name: "reference link text and fragment not tags",
content: "[reference #anchor][docs]\n\n[docs]: https://example.com/docs#reference-anchor\n\nOutside #memo-tag",
withExt: true,
expected: []string{"memo-tag"},
},
{
name: "image alt text and fragment not tags",
content: "![preview #image](https://example.com/image#preview)\n\nOutside #memo-tag",
withExt: true,
expected: []string{"memo-tag"},
},
{
name: "no extension enabled",
content: "Text with #tag",
withExt: false,
expected: []string{},
},
{
name: "Chinese tag",
content: "Text with #测试",
withExt: true,
expected: []string{"测试"},
},
{
name: "Chinese tag followed by punctuation",
content: "Text #测试。 More text",
withExt: true,
expected: []string{"测试"},
},
{
name: "mixed Chinese and ASCII tag",
content: "#测试test123 content",
withExt: true,
expected: []string{"测试test123"},
},
{
name: "Japanese tag",
content: "#日本語 content",
withExt: true,
expected: []string{"日本語"},
},
{
name: "Korean tag",
content: "#한국어 content",
withExt: true,
expected: []string{"한국어"},
},
{
name: "hierarchical tag with Chinese",
content: "#work/测试/项目",
withExt: true,
expected: []string{"work/测试/项目"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var svc Service
if tt.withExt {
svc = NewService(WithTagExtension())
} else {
svc = NewService()
}
tags, err := svc.ExtractTags([]byte(tt.content))
require.NoError(t, err)
assert.ElementsMatch(t, tt.expected, tags)
})
}
}
func TestRenameTagSkipsTagsInsideLinks(t *testing.T) {
svc := NewService(WithTagExtension())
result, err := svc.RenameTag(
[]byte("[release #notes](https://example.com/releases#release-notes)\n\nOutside #notes"),
"notes",
"done",
)
require.NoError(t, err)
assert.Equal(t, "[release #notes](https://example.com/releases#release-notes)\n\nOutside #done", result)
}
func TestUniquePreserveCase(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "empty",
input: []string{},
expected: []string{},
},
{
name: "unique items",
input: []string{"tag1", "tag2", "tag3"},
expected: []string{"tag1", "tag2", "tag3"},
},
{
name: "duplicates",
input: []string{"tag", "TAG", "Tag"},
expected: []string{"tag", "TAG", "Tag"},
},
{
name: "mixed",
input: []string{"Work", "work", "Important", "work"},
expected: []string{"Work", "work", "Important"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := uniquePreserveCase(tt.input)
assert.ElementsMatch(t, tt.expected, result)
})
}
}
func TestTruncateAtWord(t *testing.T) {
tests := []struct {
name string
input string
maxLength int
expected string
}{
{
name: "no truncation needed",
input: "short",
maxLength: 10,
expected: "short",
},
{
name: "exact length",
input: "exactly ten",
maxLength: 11,
expected: "exactly ten",
},
{
name: "truncate at word",
input: "this is a long sentence",
maxLength: 10,
expected: "this is a ...",
},
{
name: "truncate very long word",
input: "supercalifragilisticexpialidocious",
maxLength: 10,
expected: "supercalif ...",
},
{
name: "CJK characters without spaces",
input: "这是一个很长的中文句子没有空格的情况下也要正确处理",
maxLength: 15,
expected: "这是一个很长的中文句子没有空格 ...",
},
{
name: "mixed CJK and Latin",
input: "这是中文mixed with English文字",
maxLength: 10,
expected: "这是中文mixed ...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateAtWord(tt.input, tt.maxLength)
assert.Equal(t, tt.expected, result)
})
}
}
// Benchmark tests.
func BenchmarkGenerateSnippet(b *testing.B) {
svc := NewService()
content := []byte(`# Large Document
This is a large document with multiple paragraphs and formatting.
## Section 1
Here is some **bold** text and *italic* text with [links](https://example.com).
- List item 1
- List item 2
- List item 3
## Section 2
More content here with ` + "`inline code`" + ` and other elements.
` + "```go\nfunc example() {\n return true\n}\n```")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := svc.GenerateSnippet(content, 200)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkExtractProperties(b *testing.B) {
svc := NewService()
content := []byte("# Title\n\n[Link](url)\n\n`code`\n\n- [ ] Task\n- [x] Done")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := svc.ExtractProperties(content)
if err != nil {
b.Fatal(err)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package parser
import (
"unicode"
"unicode/utf8"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/internal/markdown/ast"
)
const (
// MaxMentionLength matches the username token length accepted by the API.
MaxMentionLength = 63
)
type mentionParser struct{}
// NewMentionParser creates a new inline parser for @mention syntax.
func NewMentionParser() parser.InlineParser {
return &mentionParser{}
}
// Trigger returns the characters that trigger this parser.
func (*mentionParser) Trigger() []byte {
return []byte{'@'}
}
func isValidMentionRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsNumber(r) || r == '-'
}
func isMentionBoundary(r rune) bool {
return unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)
}
// Parse parses @mention syntax while avoiding email-address matches.
func (*mentionParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.Node {
line, _ := block.PeekLine()
if len(line) == 0 || line[0] != '@' {
return nil
}
prev := block.PrecendingCharacter()
if prev != '\n' && !isMentionBoundary(prev) {
return nil
}
start := 1
pos := start
runeCount := 0
hasLetterOrNumber := false
for pos < len(line) {
r, size := utf8.DecodeRune(line[pos:])
if r == utf8.RuneError && size == 1 {
break
}
if !isValidMentionRune(r) {
break
}
if unicode.IsLetter(r) || unicode.IsNumber(r) {
hasLetterOrNumber = true
}
runeCount++
if runeCount > MaxMentionLength {
return nil
}
pos += size
}
if pos <= start || !hasLetterOrNumber {
return nil
}
username := line[start:pos]
usernameCopy := make([]byte, len(username))
copy(usernameCopy, username)
block.Advance(pos)
return &mast.MentionNode{
Username: usernameCopy,
}
}
+150
View File
@@ -0,0 +1,150 @@
package parser
import (
"unicode"
"unicode/utf8"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/internal/markdown/ast"
)
const (
// MaxTagLength defines the maximum number of runes allowed in a tag.
MaxTagLength = 100
)
type tagParser struct{}
// NewTagParser creates a new inline parser for #tag syntax.
func NewTagParser() parser.InlineParser {
return &tagParser{}
}
// Trigger returns the characters that trigger this parser.
func (*tagParser) Trigger() []byte {
return []byte{'#'}
}
// isValidTagRune checks if a Unicode rune is valid in a tag.
// Uses Unicode categories for proper international character support.
func isValidTagRune(r rune) bool {
// Allow Unicode letters (any script: Latin, CJK, Arabic, Cyrillic, etc.)
if unicode.IsLetter(r) {
return true
}
// Allow Unicode digits
if unicode.IsNumber(r) {
return true
}
// Allow emoji and symbols (So category: Symbol, Other)
// This includes emoji, which are essential for social media-style tagging
if unicode.IsSymbol(r) {
return true
}
// Allow marks (non-spacing, spacing combining, enclosing)
// This covers variation selectors (e.g. VS16 \uFE0F) and combining marks (e.g. Keycap \u20E3, accents)
if unicode.IsMark(r) {
return true
}
// Allow Zero Width Joiner (ZWJ) for emoji sequences
if r == '\u200D' {
return true
}
// Allow specific ASCII symbols for tag structure
// Underscore: word separation (snake_case)
// Hyphen: word separation (kebab-case)
// Forward slash: hierarchical tags (category/subcategory)
// Ampersand: compound tags (science&tech)
if r == '_' || r == '-' || r == '/' || r == '&' {
return true
}
return false
}
// Parse parses #tag syntax using Unicode-aware validation.
// Tags support international characters and follow these rules:
// - Must start with # followed by valid tag characters
// - Valid characters: Unicode letters, Unicode digits, underscore (_), hyphen (-), forward slash (/)
// - Maximum length: 100 runes (Unicode characters)
// - Stops at: whitespace, punctuation, or other invalid characters
func (*tagParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.Node {
line, _ := block.PeekLine()
// Must start with #
if len(line) == 0 || line[0] != '#' {
return nil
}
// Check if it's a heading (## or space after #)
if len(line) > 1 {
if line[1] == '#' {
// It's a heading (##), not a tag
return nil
}
if line[1] == ' ' {
// Space after # - heading or just a hash
return nil
}
} else {
// Just a lone #
return nil
}
// Parse tag using UTF-8 aware rune iteration
tagStart := 1
pos := tagStart
runeCount := 0
for pos < len(line) {
r, size := utf8.DecodeRune(line[pos:])
// Stop at invalid UTF-8
if r == utf8.RuneError && size == 1 {
break
}
// Validate character using Unicode categories
if !isValidTagRune(r) {
break
}
// Enforce max length (by rune count, not byte count)
runeCount++
if runeCount > MaxTagLength {
break
}
pos += size
}
// Must have at least one character after #
if pos <= tagStart {
return nil
}
// Extract tag (without #)
tagName := line[tagStart:pos]
// Make a copy of the tag name
tagCopy := make([]byte, len(tagName))
copy(tagCopy, tagName)
// Advance reader
block.Advance(pos)
// Create node
node := &mast.TagNode{
Tag: tagCopy,
}
return node
}
+263
View File
@@ -0,0 +1,263 @@
package parser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/internal/markdown/ast"
)
func TestTagParser(t *testing.T) {
tests := []struct {
name string
input string
expectedTag string
shouldParse bool
}{
{
name: "basic tag",
input: "#tag",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag with hyphen",
input: "#work-notes",
expectedTag: "work-notes",
shouldParse: true,
},
{
name: "tag with ampersand",
input: "#science&tech",
expectedTag: "science&tech",
shouldParse: true,
},
{
name: "tag with underscore",
input: "#2024_plans",
expectedTag: "2024_plans",
shouldParse: true,
},
{
name: "numeric tag",
input: "#123",
expectedTag: "123",
shouldParse: true,
},
{
name: "tag followed by space",
input: "#tag ",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag followed by punctuation",
input: "#tag.",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag in sentence",
input: "#important task",
expectedTag: "important",
shouldParse: true,
},
{
name: "heading (##)",
input: "## Heading",
expectedTag: "",
shouldParse: false,
},
{
name: "space after hash",
input: "# heading",
expectedTag: "",
shouldParse: false,
},
{
name: "lone hash",
input: "#",
expectedTag: "",
shouldParse: false,
},
{
name: "hash with space",
input: "# ",
expectedTag: "",
shouldParse: false,
},
{
name: "special characters",
input: "#tag@special",
expectedTag: "tag",
shouldParse: true,
},
{
name: "mixed case",
input: "#WorkNotes",
expectedTag: "WorkNotes",
shouldParse: true,
},
{
name: "hierarchical tag with slash",
input: "#tag1/subtag",
expectedTag: "tag1/subtag",
shouldParse: true,
},
{
name: "hierarchical tag with multiple levels",
input: "#tag1/subtag/subtag2",
expectedTag: "tag1/subtag/subtag2",
shouldParse: true,
},
{
name: "hierarchical tag followed by space",
input: "#work/notes ",
expectedTag: "work/notes",
shouldParse: true,
},
{
name: "hierarchical tag followed by punctuation",
input: "#project/2024.",
expectedTag: "project/2024",
shouldParse: true,
},
{
name: "hierarchical tag with numbers and dashes",
input: "#work-log/2024/q1",
expectedTag: "work-log/2024/q1",
shouldParse: true,
},
{
name: "Chinese characters",
input: "#测试",
expectedTag: "测试",
shouldParse: true,
},
{
name: "Chinese tag followed by space",
input: "#测试 some text",
expectedTag: "测试",
shouldParse: true,
},
{
name: "Chinese tag followed by punctuation",
input: "#测试。",
expectedTag: "测试",
shouldParse: true,
},
{
name: "mixed Chinese and ASCII",
input: "#测试test123",
expectedTag: "测试test123",
shouldParse: true,
},
{
name: "Japanese characters",
input: "#テスト",
expectedTag: "テスト",
shouldParse: true,
},
{
name: "Korean characters",
input: "#테스트",
expectedTag: "테스트",
shouldParse: true,
},
{
name: "emoji",
input: "#test🚀",
expectedTag: "test🚀",
shouldParse: true,
},
{
name: "emoji with VS16",
input: "#test👁️", // Eye + VS16
expectedTag: "test👁️",
shouldParse: true,
},
{
name: "emoji with ZWJ sequence",
input: "#family👨‍👩‍👧‍👦", // Family ZWJ sequence
expectedTag: "family👨‍👩‍👧‍👦",
shouldParse: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewTagParser()
reader := text.NewReader([]byte(tt.input))
ctx := parser.NewContext()
node := p.Parse(nil, reader, ctx)
if tt.shouldParse {
require.NotNil(t, node, "Expected tag to be parsed")
require.IsType(t, &mast.TagNode{}, node)
tagNode, ok := node.(*mast.TagNode)
require.True(t, ok, "Expected node to be *mast.TagNode")
assert.Equal(t, tt.expectedTag, string(tagNode.Tag))
} else {
assert.Nil(t, node, "Expected tag NOT to be parsed")
}
})
}
}
func TestTagParser_Trigger(t *testing.T) {
p := NewTagParser()
triggers := p.Trigger()
assert.Equal(t, []byte{'#'}, triggers)
}
func TestTagParser_MultipleTags(t *testing.T) {
// Test that parser correctly handles multiple tags in sequence
input := "#tag1 #tag2"
p := NewTagParser()
reader := text.NewReader([]byte(input))
ctx := parser.NewContext()
// Parse first tag
node1 := p.Parse(nil, reader, ctx)
require.NotNil(t, node1)
tagNode1, ok := node1.(*mast.TagNode)
require.True(t, ok, "Expected node1 to be *mast.TagNode")
assert.Equal(t, "tag1", string(tagNode1.Tag))
// Advance past the space
reader.Advance(1)
// Parse second tag
node2 := p.Parse(nil, reader, ctx)
require.NotNil(t, node2)
tagNode2, ok := node2.(*mast.TagNode)
require.True(t, ok, "Expected node2 to be *mast.TagNode")
assert.Equal(t, "tag2", string(tagNode2.Tag))
}
func TestTagNode_Kind(t *testing.T) {
node := &mast.TagNode{
Tag: []byte("test"),
}
assert.Equal(t, mast.KindTag, node.Kind())
}
func TestTagNode_Dump(t *testing.T) {
node := &mast.TagNode{
Tag: []byte("test"),
}
// Should not panic
assert.NotPanics(t, func() {
node.Dump([]byte("#test"), 0)
})
}
@@ -0,0 +1,270 @@
package renderer
import (
"bytes"
"fmt"
"strings"
gast "github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
mast "github.com/usememos/memos/internal/markdown/ast"
)
// MarkdownRenderer renders goldmark AST back to markdown text.
type MarkdownRenderer struct {
buf *bytes.Buffer
}
// NewMarkdownRenderer creates a new markdown renderer.
func NewMarkdownRenderer() *MarkdownRenderer {
return &MarkdownRenderer{
buf: &bytes.Buffer{},
}
}
// Render renders the AST node to markdown and returns the result.
func (r *MarkdownRenderer) Render(node gast.Node, source []byte) string {
r.buf.Reset()
r.renderNode(node, source, 0)
return r.buf.String()
}
// renderNode renders a single node and its children.
func (r *MarkdownRenderer) renderNode(node gast.Node, source []byte, depth int) {
switch n := node.(type) {
case *gast.Document:
r.renderChildren(n, source, depth)
case *gast.Paragraph:
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.Text:
// Text nodes store their content as segments in the source
segment := n.Segment
r.buf.Write(segment.Value(source))
if n.SoftLineBreak() {
r.buf.WriteByte('\n')
} else if n.HardLineBreak() {
r.buf.WriteString(" \n")
}
case *gast.CodeSpan:
r.buf.WriteByte('`')
r.renderChildren(n, source, depth)
r.buf.WriteByte('`')
case *gast.Emphasis:
symbol := "*"
if n.Level == 2 {
symbol = "**"
}
r.buf.WriteString(symbol)
r.renderChildren(n, source, depth)
r.buf.WriteString(symbol)
case *gast.Link:
r.buf.WriteString("[")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.AutoLink:
url := n.URL(source)
if n.AutoLinkType == gast.AutoLinkEmail {
r.buf.WriteString("<")
r.buf.Write(url)
r.buf.WriteString(">")
} else {
r.buf.Write(url)
}
case *gast.Image:
r.buf.WriteString("![")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.Heading:
r.buf.WriteString(strings.Repeat("#", n.Level))
r.buf.WriteByte(' ')
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.CodeBlock, *gast.FencedCodeBlock:
r.renderCodeBlock(n, source)
case *gast.Blockquote:
// Render each child line with "> " prefix
r.renderBlockquote(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.List:
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.ListItem:
r.renderListItem(n, source, depth)
case *gast.ThematicBreak:
r.buf.WriteString("---")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *east.Strikethrough:
r.buf.WriteString("~~")
r.renderChildren(n, source, depth)
r.buf.WriteString("~~")
case *east.TaskCheckBox:
if n.IsChecked {
r.buf.WriteString("[x] ")
} else {
r.buf.WriteString("[ ] ")
}
case *east.Table:
r.renderTable(n, source)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
// Custom Memos nodes
case *mast.TagNode:
r.buf.WriteByte('#')
r.buf.Write(n.Tag)
case *mast.MentionNode:
r.buf.WriteByte('@')
r.buf.Write(n.Username)
default:
// For unknown nodes, try to render children
r.renderChildren(n, source, depth)
}
}
// renderChildren renders all children of a node.
func (r *MarkdownRenderer) renderChildren(node gast.Node, source []byte, depth int) {
child := node.FirstChild()
for child != nil {
r.renderNode(child, source, depth+1)
child = child.NextSibling()
}
}
// renderCodeBlock renders a code block.
func (r *MarkdownRenderer) renderCodeBlock(node gast.Node, source []byte) {
if fenced, ok := node.(*gast.FencedCodeBlock); ok {
// Fenced code block with language
r.buf.WriteString("```")
if lang := fenced.Language(source); len(lang) > 0 {
r.buf.Write(lang)
}
r.buf.WriteByte('\n')
// Write all lines
lines := fenced.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.Write(line.Value(source))
}
r.buf.WriteString("```")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
} else if codeBlock, ok := node.(*gast.CodeBlock); ok {
// Indented code block
lines := codeBlock.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.WriteString(" ")
r.buf.Write(line.Value(source))
}
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
}
}
// renderBlockquote renders a blockquote with "> " prefix.
func (r *MarkdownRenderer) renderBlockquote(node *gast.Blockquote, source []byte, depth int) {
// Create a temporary buffer for the blockquote content
tempBuf := &bytes.Buffer{}
tempRenderer := &MarkdownRenderer{buf: tempBuf}
tempRenderer.renderChildren(node, source, depth)
// Add "> " prefix to each line
content := tempBuf.String()
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
for i, line := range lines {
r.buf.WriteString("> ")
r.buf.WriteString(line)
if i < len(lines)-1 {
r.buf.WriteByte('\n')
}
}
}
// renderListItem renders a list item with proper indentation and markers.
func (r *MarkdownRenderer) renderListItem(node *gast.ListItem, source []byte, depth int) {
parent := node.Parent()
list, ok := parent.(*gast.List)
if !ok {
r.renderChildren(node, source, depth)
return
}
// Add indentation only for nested lists
// Document=0, List=1, ListItem=2 (no indent), nested ListItem=3+ (indent)
if depth > 2 {
indent := strings.Repeat(" ", depth-2)
r.buf.WriteString(indent)
}
// Add list marker
if list.IsOrdered() {
fmt.Fprintf(r.buf, "%d. ", list.Start)
list.Start++ // Increment for next item
} else {
r.buf.WriteString("- ")
}
// Render content
r.renderChildren(node, source, depth)
// Add newline if there's a next sibling
if node.NextSibling() != nil {
r.buf.WriteByte('\n')
}
}
// renderTable renders a table in markdown format.
func (r *MarkdownRenderer) renderTable(table *east.Table, source []byte) {
// This is a simplified table renderer
// A full implementation would need to handle alignment, etc.
r.renderChildren(table, source, 0)
}
@@ -0,0 +1,176 @@
package renderer
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/usememos/memos/internal/markdown/extensions"
)
func TestMarkdownRenderer(t *testing.T) {
// Create goldmark instance with all extensions
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
)
tests := []struct {
name string
input string
expected string
}{
{
name: "simple text",
input: "Hello world",
expected: "Hello world",
},
{
name: "paragraph with newlines",
input: "First paragraph\n\nSecond paragraph",
expected: "First paragraph\n\nSecond paragraph",
},
{
name: "emphasis",
input: "This is *italic* and **bold** text",
expected: "This is *italic* and **bold** text",
},
{
name: "headings",
input: "# Heading 1\n\n## Heading 2\n\n### Heading 3",
expected: "# Heading 1\n\n## Heading 2\n\n### Heading 3",
},
{
name: "link",
input: "Check [this link](https://example.com)",
expected: "Check [this link](https://example.com)",
},
{
name: "image",
input: "![alt text](image.png)",
expected: "![alt text](image.png)",
},
{
name: "code inline",
input: "This is `inline code` here",
expected: "This is `inline code` here",
},
{
name: "code block fenced",
input: "```go\nfunc main() {\n}\n```",
expected: "```go\nfunc main() {\n}\n```",
},
{
name: "unordered list",
input: "- Item 1\n- Item 2\n- Item 3",
expected: "- Item 1\n- Item 2\n- Item 3",
},
{
name: "ordered list",
input: "1. First\n2. Second\n3. Third",
expected: "1. First\n2. Second\n3. Third",
},
{
name: "blockquote",
input: "> This is a quote\n> Second line",
expected: "> This is a quote\n> Second line",
},
{
name: "horizontal rule",
input: "Text before\n\n---\n\nText after",
expected: "Text before\n\n---\n\nText after",
},
{
name: "strikethrough",
input: "This is ~~deleted~~ text",
expected: "This is ~~deleted~~ text",
},
{
name: "task list",
input: "- [x] Completed task\n- [ ] Incomplete task",
expected: "- [x] Completed task\n- [ ] Incomplete task",
},
{
name: "tag",
input: "This has #tag in it",
expected: "This has #tag in it",
},
{
name: "multiple tags",
input: "#work #important meeting notes",
expected: "#work #important meeting notes",
},
{
name: "complex mixed content",
input: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\n```python\nprint('hello')\n```",
expected: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\n```python\nprint('hello')\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Parse the input
source := []byte(tt.input)
reader := text.NewReader(source)
doc := md.Parser().Parse(reader)
require.NotNil(t, doc)
// Render back to markdown
renderer := NewMarkdownRenderer()
result := renderer.Render(doc, source)
// For debugging
if result != tt.expected {
t.Logf("Input: %q", tt.input)
t.Logf("Expected: %q", tt.expected)
t.Logf("Got: %q", result)
}
assert.Equal(t, tt.expected, result)
})
}
}
func TestMarkdownRendererPreservesStructure(t *testing.T) {
// Test that parsing and rendering preserves structure
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
),
)
inputs := []string{
"# Title\n\nParagraph",
"**Bold** and *italic*",
"- List\n- Items",
"#tag #another",
"> Quote",
}
renderer := NewMarkdownRenderer()
for _, input := range inputs {
t.Run(input, func(t *testing.T) {
source := []byte(input)
reader := text.NewReader(source)
doc := md.Parser().Parse(reader)
result := renderer.Render(doc, source)
// The result should be structurally similar
// (may have minor formatting differences)
assert.NotEmpty(t, result)
})
}
}
+110
View File
@@ -0,0 +1,110 @@
package motionphoto
import (
"bytes"
"encoding/binary"
"regexp"
"strconv"
)
type Detection struct {
VideoStart int
PresentationTimestampUs int64
}
var (
motionPhotoMarkerRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhoto|GCamera:MotionPhoto|MicroVideo)["'=:\s>]+1`)
presentationRegex = regexp.MustCompile(`(?i)(?:Camera:MotionPhotoPresentationTimestampUs|GCamera:MotionPhotoPresentationTimestampUs)["'=:\s>]+(-?\d+)`)
microVideoOffsetRegex = regexp.MustCompile(`(?i)(?:Camera:MicroVideoOffset|GCamera:MicroVideoOffset)["'=:\s>]+(\d+)`)
)
const maxMetadataScanBytes = 256 * 1024
func DetectJPEG(blob []byte) *Detection {
if len(blob) < 16 || !bytes.HasPrefix(blob, []byte{0xFF, 0xD8}) {
return nil
}
text := string(blob[:min(len(blob), maxMetadataScanBytes)])
if !motionPhotoMarkerRegex.MatchString(text) {
return nil
}
videoStart := detectVideoStart(blob, text)
if videoStart < 0 || videoStart >= len(blob) {
return nil
}
return &Detection{
VideoStart: videoStart,
PresentationTimestampUs: parsePresentationTimestampUs(text),
}
}
func ExtractVideo(blob []byte) ([]byte, *Detection) {
detection := DetectJPEG(blob)
if detection == nil {
return nil, nil
}
videoBlob := blob[detection.VideoStart:]
if !looksLikeMP4(videoBlob) {
return nil, nil
}
return videoBlob, detection
}
func detectVideoStart(blob []byte, text string) int {
if matches := microVideoOffsetRegex.FindStringSubmatch(text); len(matches) == 2 {
if offset, err := strconv.Atoi(matches[1]); err == nil && offset > 0 && offset < len(blob) {
start := len(blob) - offset
if looksLikeMP4(blob[start:]) {
return start
}
}
}
return findEmbeddedMP4Start(blob)
}
func parsePresentationTimestampUs(text string) int64 {
matches := presentationRegex.FindStringSubmatch(text)
if len(matches) != 2 {
return 0
}
value, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return 0
}
return value
}
func findEmbeddedMP4Start(blob []byte) int {
searchFrom := len(blob)
for searchFrom > 8 {
index := bytes.LastIndex(blob[:searchFrom], []byte("ftyp"))
if index < 4 {
return -1
}
start := index - 4
if looksLikeMP4(blob[start:]) {
return start
}
searchFrom = index - 1
}
return -1
}
func looksLikeMP4(blob []byte) bool {
if len(blob) < 12 || !bytes.Equal(blob[4:8], []byte("ftyp")) {
return false
}
size := binary.BigEndian.Uint32(blob[:4])
return size == 1 || size >= 8
}
+25
View File
@@ -0,0 +1,25 @@
package motionphoto
import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/testutil"
)
func TestDetectJPEG(t *testing.T) {
t.Parallel()
blob := testutil.BuildMotionPhotoJPEG()
detection := DetectJPEG(blob)
require.NotNil(t, detection)
require.Positive(t, detection.VideoStart)
require.EqualValues(t, 123456, detection.PresentationTimestampUs)
videoBlob, extracted := ExtractVideo(blob)
require.NotNil(t, extracted)
require.True(t, bytes.Equal(videoBlob[:4], []byte{0x00, 0x00, 0x00, 0x10}))
require.Equal(t, []byte("ftyp"), videoBlob[4:8])
}
+120
View File
@@ -0,0 +1,120 @@
package profile
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/pkg/errors"
)
// Profile is the configuration to start main server.
type Profile struct {
// Demo indicates if the server is in demo mode
Demo bool
// Addr is the binding address for server
Addr string
// Port is the binding port for server
Port int
// UNIXSock is the IPC binding path. Overrides Addr and Port
UNIXSock string
// Data is the data directory
Data string
// DSN points to where memos stores its own data
DSN string
// Driver is the database driver
// sqlite, mysql
Driver string
// Version is the current version of server
Version string
// Commit is the current build commit of server
Commit string
// InstanceURL is the url of your memos instance.
InstanceURL string
}
// AllowAnonymous reports whether unauthenticated visitors may access the instance.
//
// Anonymous access is enabled only when an InstanceURL is configured. An instance
// with no InstanceURL set is treated as private: anonymous callers are limited to
// the auth-bootstrap endpoints (sign-in, share links, etc.) and the web UI redirects
// them to the sign-in page instead of the public Explore view. Authenticated callers
// (session, access token, or personal access token) are never affected.
func (p *Profile) AllowAnonymous() bool {
return strings.TrimSpace(p.InstanceURL) != ""
}
func checkDataDir(dataDir string) (string, error) {
// Convert to absolute path if relative path is supplied.
if !filepath.IsAbs(dataDir) {
// Use current working directory, not the binary's directory
// This ensures we use the actual working directory where the process runs
absDir, err := filepath.Abs(dataDir)
if err != nil {
return "", err
}
dataDir = absDir
}
// Trim trailing \ or / in case user supplies
dataDir = strings.TrimRight(dataDir, "\\/")
if _, err := os.Stat(dataDir); err != nil {
return "", errors.Wrapf(err, "unable to access data folder %s", dataDir)
}
return dataDir, nil
}
func (p *Profile) Validate() error {
// Set default data directory if not specified
if p.Data == "" {
if runtime.GOOS == "windows" {
p.Data = filepath.Join(os.Getenv("ProgramData"), "memos")
} else {
// On Linux/macOS, check if /var/opt/memos exists and is writable (Docker scenario)
if info, err := os.Stat("/var/opt/memos"); err == nil && info.IsDir() {
// Check if we can write to this directory
testFile := filepath.Join("/var/opt/memos", ".write-test")
if err := os.WriteFile(testFile, []byte("test"), 0600); err == nil {
os.Remove(testFile)
p.Data = "/var/opt/memos"
} else {
// /var/opt/memos exists but is not writable, use current directory
slog.Warn("/var/opt/memos is not writable, using current directory")
p.Data = "."
}
} else {
// /var/opt/memos doesn't exist, use current directory (local development)
p.Data = "."
}
}
}
// Create data directory if it doesn't exist
if _, err := os.Stat(p.Data); os.IsNotExist(err) {
if err := os.MkdirAll(p.Data, 0770); err != nil {
slog.Error("failed to create data directory", slog.String("data", p.Data), slog.String("error", err.Error()))
return err
}
}
dataDir, err := checkDataDir(p.Data)
if err != nil {
slog.Error("failed to check dsn", slog.String("data", dataDir), slog.String("error", err.Error()))
return err
}
p.Data = dataDir
if p.Driver == "sqlite" && p.DSN == "" {
mode := "prod"
if p.Demo {
mode = "demo"
}
dbFile := fmt.Sprintf("memos_%s.db", mode)
p.DSN = filepath.Join(dataDir, dbFile)
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package profile
import "testing"
func TestAllowAnonymous(t *testing.T) {
cases := []struct {
name string
url string
want bool
}{
{"empty is private", "", false},
{"whitespace only is private", " ", false},
{"configured url is public", "https://memos.example.com", true},
{"configured url with padding is public", " https://memos.example.com ", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := &Profile{InstanceURL: c.url}
if got := p.AllowAnonymous(); got != c.want {
t.Fatalf("AllowAnonymous() with InstanceURL=%q = %v, want %v", c.url, got, c.want)
}
})
}
}
+367
View File
@@ -0,0 +1,367 @@
# Scheduler Plugin
A production-ready, GitHub Actions-inspired cron job scheduler for Go.
## Features
- **Standard Cron Syntax**: Supports both 5-field and 6-field (with seconds) cron expressions
- **Timezone-Aware**: Explicit timezone handling to avoid DST surprises
- **Middleware Pattern**: Composable job wrappers for logging, metrics, panic recovery, timeouts
- **Graceful Shutdown**: Jobs complete cleanly or cancel when context expires
- **Zero Dependencies**: Core functionality uses only the standard library
- **Type-Safe**: Strong typing with clear error messages
- **Well-Tested**: Comprehensive test coverage
## Installation
This package is included with Memos. No separate installation required.
## Quick Start
```go
package main
import (
"context"
"fmt"
"github.com/usememos/memos/internal/scheduler"
)
func main() {
s := scheduler.New()
s.Register(&scheduler.Job{
Name: "daily-cleanup",
Schedule: "0 2 * * *", // 2 AM daily
Handler: func(ctx context.Context) error {
fmt.Println("Running cleanup...")
return nil
},
})
s.Start()
defer s.Stop(context.Background())
// Keep running...
select {}
}
```
## Cron Expression Format
### 5-Field Format (Standard)
```
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 7) (Sunday = 0 or 7)
│ │ │ │ │
* * * * *
```
### 6-Field Format (With Seconds)
```
┌───────────── second (0 - 59)
│ ┌───────────── minute (0 - 59)
│ │ ┌───────────── hour (0 - 23)
│ │ │ ┌───────────── day of month (1 - 31)
│ │ │ │ ┌───────────── month (1 - 12)
│ │ │ │ │ ┌───────────── day of week (0 - 7)
│ │ │ │ │ │
* * * * * *
```
### Special Characters
- `*` - Any value (every minute, every hour, etc.)
- `,` - List of values: `1,15,30` (1st, 15th, and 30th)
- `-` - Range: `9-17` (9 AM through 5 PM)
- `/` - Step: `*/15` (every 15 units)
### Common Examples
| Schedule | Description |
|----------|-------------|
| `* * * * *` | Every minute |
| `0 * * * *` | Every hour |
| `0 0 * * *` | Daily at midnight |
| `0 9 * * 1-5` | Weekdays at 9 AM |
| `*/15 * * * *` | Every 15 minutes |
| `0 0 1 * *` | First day of every month |
| `0 0 * * 0` | Every Sunday at midnight |
| `30 14 * * *` | Every day at 2:30 PM |
## Timezone Support
```go
// Global timezone for all jobs
s := scheduler.New(
scheduler.WithTimezone("America/New_York"),
)
// Per-job timezone (overrides global)
s.Register(&scheduler.Job{
Name: "tokyo-report",
Schedule: "0 9 * * *", // 9 AM Tokyo time
Timezone: "Asia/Tokyo",
Handler: func(ctx context.Context) error {
// Runs at 9 AM in Tokyo
return nil
},
})
```
**Important**: Always use IANA timezone names (`America/New_York`, not `EST`).
## Middleware
Middleware wraps job handlers to add cross-cutting behavior. Multiple middleware can be chained together.
### Built-in Middleware
#### Recovery (Panic Handling)
```go
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(func(jobName string, r interface{}) {
log.Printf("Job %s panicked: %v", jobName, r)
}),
),
)
```
#### Logging
```go
type Logger interface {
Info(msg string, args ...interface{})
Error(msg string, args ...interface{})
}
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Logging(myLogger),
),
)
```
#### Timeout
```go
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Timeout(5 * time.Minute),
),
)
```
### Combining Middleware
```go
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(panicHandler),
scheduler.Logging(logger),
scheduler.Timeout(10 * time.Minute),
),
)
```
**Order matters**: Middleware are applied left-to-right. In the example above:
1. Recovery (outermost) catches panics from everything
2. Logging logs the execution
3. Timeout (innermost) wraps the actual handler
### Custom Middleware
```go
func Metrics(recorder MetricsRecorder) scheduler.Middleware {
return func(next scheduler.JobHandler) scheduler.JobHandler {
return func(ctx context.Context) error {
start := time.Now()
err := next(ctx)
duration := time.Since(start)
jobName := scheduler.GetJobName(ctx)
recorder.Record(jobName, duration, err)
return err
}
}
}
```
## Graceful Shutdown
Always use `Stop()` with a context to allow jobs to finish cleanly:
```go
// Give jobs up to 30 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
}
```
Jobs should respect context cancellation:
```go
Handler: func(ctx context.Context) error {
for i := 0; i < 100; i++ {
select {
case <-ctx.Done():
return ctx.Err() // Canceled
default:
// Do work
}
}
return nil
}
```
## Best Practices
### 1. Always Name Your Jobs
Names are used for logging, metrics, and debugging:
```go
Name: "user-cleanup-job" // Good
Name: "job1" // Bad
```
### 2. Add Descriptions and Tags
```go
s.Register(&scheduler.Job{
Name: "stale-session-cleanup",
Description: "Removes user sessions older than 30 days",
Tags: []string{"maintenance", "security"},
Schedule: "0 3 * * *",
Handler: cleanupSessions,
})
```
### 3. Use Appropriate Middleware
Always include Recovery and Logging in production:
```go
scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(logPanic),
scheduler.Logging(logger),
),
)
```
### 4. Avoid Scheduling Exactly on the Hour
Many systems schedule jobs at `:00`, causing load spikes. Stagger your jobs:
```go
"5 2 * * *" // 2:05 AM (good)
"0 2 * * *" // 2:00 AM (often overloaded)
```
### 5. Make Jobs Idempotent
Jobs may run multiple times (crash recovery, etc.). Design them to be safely re-runnable:
```go
Handler: func(ctx context.Context) error {
// Use unique constraint or check-before-insert
db.Exec("INSERT IGNORE INTO processed_items ...")
return nil
}
```
### 6. Handle Timezones Explicitly
Always specify timezone for business-hour jobs:
```go
Timezone: "America/New_York" // Good
// Timezone: "" // Bad (defaults to UTC)
```
### 7. Test Your Cron Expressions
Use a cron expression calculator before deploying:
- [crontab.guru](https://crontab.guru/)
- Write unit tests with the parser
## Testing Jobs
Test job handlers independently of the scheduler:
```go
func TestCleanupJob(t *testing.T) {
ctx := context.Background()
err := cleanupHandler(ctx)
if err != nil {
t.Fatalf("cleanup failed: %v", err)
}
// Verify cleanup occurred
}
```
Test schedule parsing:
```go
func TestScheduleParsing(t *testing.T) {
job := &scheduler.Job{
Name: "test",
Schedule: "0 2 * * *",
Handler: func(ctx context.Context) error { return nil },
}
if err := job.Validate(); err != nil {
t.Fatalf("invalid job: %v", err)
}
}
```
## Comparison to Other Solutions
| Feature | scheduler | robfig/cron | github.com/go-co-op/gocron |
|---------|-----------|-------------|----------------------------|
| Standard cron syntax | ✅ | ✅ | ✅ |
| Seconds support | ✅ | ✅ | ✅ |
| Timezone support | ✅ | ✅ | ✅ |
| Middleware pattern | ✅ | ⚠️ (basic) | ❌ |
| Graceful shutdown | ✅ | ⚠️ (basic) | ✅ |
| Zero dependencies | ✅ | ❌ | ❌ |
| Job metadata | ✅ | ❌ | ⚠️ (limited) |
## API Reference
See [example_test.go](./example_test.go) for comprehensive examples.
### Core Types
- `Scheduler` - Manages scheduled jobs
- `Job` - Job definition with schedule and handler
- `Middleware` - Function that wraps job handlers
### Functions
- `New(opts ...Option) *Scheduler` - Create new scheduler
- `WithTimezone(tz string) Option` - Set default timezone
- `WithMiddleware(mw ...Middleware) Option` - Add middleware
### Methods
- `Register(job *Job) error` - Add job to scheduler
- `Start() error` - Begin executing jobs
- `Stop(ctx context.Context) error` - Graceful shutdown
## License
This package is part of the Memos project and shares its license.
+35
View File
@@ -0,0 +1,35 @@
// Package scheduler provides a GitHub Actions-inspired cron job scheduler.
//
// Features:
// - Standard cron expression syntax (5-field and 6-field formats)
// - Timezone-aware scheduling
// - Middleware pattern for cross-cutting concerns (logging, metrics, recovery)
// - Graceful shutdown with context cancellation
// - Zero external dependencies
//
// Basic usage:
//
// s := scheduler.New()
//
// s.Register(&scheduler.Job{
// Name: "daily-cleanup",
// Schedule: "0 2 * * *", // 2 AM daily
// Handler: func(ctx context.Context) error {
// // Your cleanup logic here
// return nil
// },
// })
//
// s.Start()
// defer s.Stop(context.Background())
//
// With middleware:
//
// s := scheduler.New(
// scheduler.WithTimezone("America/New_York"),
// scheduler.WithMiddleware(
// scheduler.Recovery(),
// scheduler.Logging(),
// ),
// )
package scheduler
+165
View File
@@ -0,0 +1,165 @@
package scheduler_test
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"github.com/usememos/memos/internal/scheduler"
)
// Example demonstrates basic scheduler usage.
func Example_basic() {
s := scheduler.New()
s.Register(&scheduler.Job{
Name: "hello",
Schedule: "*/5 * * * *", // Every 5 minutes
Description: "Say hello",
Handler: func(_ context.Context) error {
fmt.Println("Hello from scheduler!")
return nil
},
})
s.Start()
defer s.Stop(context.Background())
// Scheduler runs in background
time.Sleep(100 * time.Millisecond)
}
// Example demonstrates timezone-aware scheduling.
func Example_timezone() {
s := scheduler.New(
scheduler.WithTimezone("America/New_York"),
)
s.Register(&scheduler.Job{
Name: "daily-report",
Schedule: "0 9 * * *", // 9 AM in New York
Handler: func(_ context.Context) error {
fmt.Println("Generating daily report...")
return nil
},
})
s.Start()
defer s.Stop(context.Background())
}
// Example demonstrates middleware usage.
func Example_middleware() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(func(jobName string, r interface{}) {
logger.Error("Job panicked", "job", jobName, "panic", r)
}),
scheduler.Logging(&slogAdapter{logger}),
scheduler.Timeout(5*time.Minute),
),
)
s.Register(&scheduler.Job{
Name: "data-sync",
Schedule: "0 */2 * * *", // Every 2 hours
Handler: func(_ context.Context) error {
// Your sync logic here
return nil
},
})
s.Start()
defer s.Stop(context.Background())
}
// slogAdapter adapts slog.Logger to scheduler.Logger interface.
type slogAdapter struct {
logger *slog.Logger
}
func (a *slogAdapter) Info(msg string, args ...interface{}) {
a.logger.Info(msg, args...)
}
func (a *slogAdapter) Error(msg string, args ...interface{}) {
a.logger.Error(msg, args...)
}
// Example demonstrates multiple jobs with different schedules.
func Example_multipleJobs() {
s := scheduler.New()
// Cleanup old data every night at 2 AM
s.Register(&scheduler.Job{
Name: "cleanup",
Schedule: "0 2 * * *",
Tags: []string{"maintenance"},
Handler: func(_ context.Context) error {
fmt.Println("Cleaning up old data...")
return nil
},
})
// Health check every 5 minutes
s.Register(&scheduler.Job{
Name: "health-check",
Schedule: "*/5 * * * *",
Tags: []string{"monitoring"},
Handler: func(_ context.Context) error {
fmt.Println("Running health check...")
return nil
},
})
// Weekly backup on Sundays at 1 AM
s.Register(&scheduler.Job{
Name: "weekly-backup",
Schedule: "0 1 * * 0",
Tags: []string{"backup"},
Handler: func(_ context.Context) error {
fmt.Println("Creating weekly backup...")
return nil
},
})
s.Start()
defer s.Stop(context.Background())
}
// Example demonstrates graceful shutdown with timeout.
func Example_gracefulShutdown() {
s := scheduler.New()
s.Register(&scheduler.Job{
Name: "long-running",
Schedule: "* * * * *",
Handler: func(ctx context.Context) error {
select {
case <-time.After(30 * time.Second):
fmt.Println("Job completed")
case <-ctx.Done():
fmt.Println("Job canceled, cleaning up...")
return ctx.Err()
}
return nil
},
})
s.Start()
// Simulate shutdown signal
time.Sleep(5 * time.Second)
// Give jobs 10 seconds to finish
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.Stop(shutdownCtx); err != nil {
fmt.Printf("Shutdown error: %v\n", err)
}
}
+393
View File
@@ -0,0 +1,393 @@
package scheduler_test
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/usememos/memos/internal/scheduler"
)
// TestRealWorldScenario tests a realistic multi-job scenario.
func TestRealWorldScenario(t *testing.T) {
var (
quickJobCount atomic.Int32
hourlyJobCount atomic.Int32
logEntries []string
logMu sync.Mutex
)
logger := &testLogger{
onInfo: func(msg string, _ ...interface{}) {
logMu.Lock()
logEntries = append(logEntries, fmt.Sprintf("INFO: %s", msg))
logMu.Unlock()
},
onError: func(msg string, _ ...interface{}) {
logMu.Lock()
logEntries = append(logEntries, fmt.Sprintf("ERROR: %s", msg))
logMu.Unlock()
},
}
s := scheduler.New(
scheduler.WithTimezone("UTC"),
scheduler.WithMiddleware(
scheduler.Recovery(func(jobName string, r interface{}) {
t.Logf("Job %s panicked: %v", jobName, r)
}),
scheduler.Logging(logger),
scheduler.Timeout(5*time.Second),
),
)
// Quick job (every second)
s.Register(&scheduler.Job{
Name: "quick-check",
Schedule: "* * * * * *",
Handler: func(_ context.Context) error {
quickJobCount.Add(1)
time.Sleep(100 * time.Millisecond)
return nil
},
})
// Slower job (every 2 seconds)
s.Register(&scheduler.Job{
Name: "slow-process",
Schedule: "*/2 * * * * *",
Handler: func(_ context.Context) error {
hourlyJobCount.Add(1)
time.Sleep(500 * time.Millisecond)
return nil
},
})
// Start scheduler
if err := s.Start(); err != nil {
t.Fatalf("failed to start scheduler: %v", err)
}
// Let it run for 5 seconds
time.Sleep(5 * time.Second)
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
t.Fatalf("failed to stop scheduler: %v", err)
}
// Verify execution counts
quick := quickJobCount.Load()
slow := hourlyJobCount.Load()
t.Logf("Quick job ran %d times", quick)
t.Logf("Slow job ran %d times", slow)
if quick < 4 {
t.Errorf("expected quick job to run at least 4 times, ran %d", quick)
}
if slow < 2 {
t.Errorf("expected slow job to run at least 2 times, ran %d", slow)
}
// Verify logging
logMu.Lock()
defer logMu.Unlock()
hasStartLog := false
hasCompleteLog := false
for _, entry := range logEntries {
if contains(entry, "Job started") {
hasStartLog = true
}
if contains(entry, "Job completed") {
hasCompleteLog = true
}
}
if !hasStartLog {
t.Error("expected job start logs")
}
if !hasCompleteLog {
t.Error("expected job completion logs")
}
}
// TestCancellationDuringExecution verifies jobs can be canceled mid-execution.
func TestCancellationDuringExecution(t *testing.T) {
var canceled atomic.Bool
var started atomic.Bool
s := scheduler.New()
s.Register(&scheduler.Job{
Name: "long-job",
Schedule: "* * * * * *",
Handler: func(ctx context.Context) error {
started.Store(true)
// Simulate long-running work
for i := 0; i < 100; i++ {
select {
case <-ctx.Done():
canceled.Store(true)
return ctx.Err()
case <-time.After(100 * time.Millisecond):
// Keep working
}
}
return nil
},
})
if err := s.Start(); err != nil {
t.Fatalf("failed to start: %v", err)
}
// Wait until job starts
for i := 0; i < 30; i++ {
if started.Load() {
break
}
time.Sleep(100 * time.Millisecond)
}
if !started.Load() {
t.Fatal("job did not start within timeout")
}
// Stop with reasonable timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
t.Logf("stop returned error (may be expected): %v", err)
}
if !canceled.Load() {
t.Error("expected job to detect cancellation")
}
}
// TestTimezoneHandling verifies timezone-aware scheduling.
func TestTimezoneHandling(t *testing.T) {
// Parse a schedule in a specific timezone
schedule, err := scheduler.ParseCronExpression("0 9 * * *") // 9 AM
if err != nil {
t.Fatalf("failed to parse schedule: %v", err)
}
// Test in New York timezone
nyc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatalf("failed to load timezone: %v", err)
}
// Current time: 8:30 AM in New York
now := time.Date(2025, 1, 15, 8, 30, 0, 0, nyc)
// Next run should be 9:00 AM same day
next := schedule.Next(now)
expected := time.Date(2025, 1, 15, 9, 0, 0, 0, nyc)
if !next.Equal(expected) {
t.Errorf("next = %v, expected %v", next, expected)
}
// If it's already past 9 AM
now = time.Date(2025, 1, 15, 9, 30, 0, 0, nyc)
next = schedule.Next(now)
expected = time.Date(2025, 1, 16, 9, 0, 0, 0, nyc)
if !next.Equal(expected) {
t.Errorf("next = %v, expected %v", next, expected)
}
}
// TestErrorPropagation verifies error handling.
func TestErrorPropagation(t *testing.T) {
var errorLogged atomic.Bool
logger := &testLogger{
onError: func(msg string, _ ...interface{}) {
if msg == "Job failed" {
errorLogged.Store(true)
}
},
}
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Logging(logger),
),
)
s.Register(&scheduler.Job{
Name: "failing-job",
Schedule: "* * * * * *",
Handler: func(_ context.Context) error {
return errors.New("intentional error")
},
})
if err := s.Start(); err != nil {
t.Fatalf("failed to start: %v", err)
}
// Let it run once
time.Sleep(1500 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
t.Fatalf("failed to stop: %v", err)
}
if !errorLogged.Load() {
t.Error("expected error to be logged")
}
}
// TestPanicRecovery verifies panic recovery middleware.
func TestPanicRecovery(t *testing.T) {
var panicRecovered atomic.Bool
s := scheduler.New(
scheduler.WithMiddleware(
scheduler.Recovery(func(jobName string, r interface{}) {
panicRecovered.Store(true)
t.Logf("Recovered from panic in job %s: %v", jobName, r)
}),
),
)
s.Register(&scheduler.Job{
Name: "panicking-job",
Schedule: "* * * * * *",
Handler: func(_ context.Context) error {
panic("intentional panic for testing")
},
})
if err := s.Start(); err != nil {
t.Fatalf("failed to start: %v", err)
}
// Let it run once
time.Sleep(1500 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
t.Fatalf("failed to stop: %v", err)
}
if !panicRecovered.Load() {
t.Error("expected panic to be recovered")
}
}
// TestMultipleJobsWithDifferentSchedules verifies concurrent job execution.
func TestMultipleJobsWithDifferentSchedules(t *testing.T) {
var (
job1Count atomic.Int32
job2Count atomic.Int32
job3Count atomic.Int32
)
s := scheduler.New()
// Job 1: Every second
s.Register(&scheduler.Job{
Name: "job-1sec",
Schedule: "* * * * * *",
Handler: func(_ context.Context) error {
job1Count.Add(1)
return nil
},
})
// Job 2: Every 2 seconds
s.Register(&scheduler.Job{
Name: "job-2sec",
Schedule: "*/2 * * * * *",
Handler: func(_ context.Context) error {
job2Count.Add(1)
return nil
},
})
// Job 3: Every 3 seconds
s.Register(&scheduler.Job{
Name: "job-3sec",
Schedule: "*/3 * * * * *",
Handler: func(_ context.Context) error {
job3Count.Add(1)
return nil
},
})
if err := s.Start(); err != nil {
t.Fatalf("failed to start: %v", err)
}
// Let them run for 6 seconds
time.Sleep(6 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
t.Fatalf("failed to stop: %v", err)
}
// Verify counts (allowing for timing variance)
c1 := job1Count.Load()
c2 := job2Count.Load()
c3 := job3Count.Load()
t.Logf("Job 1 ran %d times, Job 2 ran %d times, Job 3 ran %d times", c1, c2, c3)
if c1 < 5 {
t.Errorf("expected job1 to run at least 5 times, ran %d", c1)
}
if c2 < 2 {
t.Errorf("expected job2 to run at least 2 times, ran %d", c2)
}
if c3 < 1 {
t.Errorf("expected job3 to run at least 1 time, ran %d", c3)
}
}
// Helpers
type testLogger struct {
onInfo func(msg string, args ...interface{})
onError func(msg string, args ...interface{})
}
func (l *testLogger) Info(msg string, args ...interface{}) {
if l.onInfo != nil {
l.onInfo(msg, args...)
}
}
func (l *testLogger) Error(msg string, args ...interface{}) {
if l.onError != nil {
l.onError(msg, args...)
}
}
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}

Some files were not shown because too many files have changed in this diff Show More