Language: English | 简体中文
A personal memo and note-taking app with file attachments, rich text, clips, and full-text search. Built entirely on Cloudflare — SvelteKit + Workers + D1 (SQLite) + R2 (object storage) + Pages.
Live at: https://memo.1000600.xyz
| Layer | Technology |
|---|---|
| Frontend | SvelteKit 2 + Svelte 5 (runes), hosted on Cloudflare Pages |
| Backend | Cloudflare Worker, TypeScript + Hono 4 (worker.ts) |
| ORM | Drizzle ORM for D1 CRUD; raw SQL only where Drizzle can't help (FTS5 virtual table, settings upsert) |
| Database | Cloudflare D1 (SQLite + FTS5) |
| File storage | Cloudflare R2 |
The frontend talks directly to the Worker API (https://memo-worker.ausz.workers.dev), authenticated on every request via a ?t= query token. The Worker and the frontend are deployed independently — see Deployment.
- Grid and list view toggle, persisted across sessions
- Tag filter bar with per-tag counts — tags are case-insensitive (stored lowercase)
- Full-text search across titles, descriptions, notes, tags, UID, file names, captions, and clip content (FTS5)
- Timeline scrubber on the right edge (grid view) — drag to jump by date
- Sort: Newest / Oldest / Recently updated; pinned memos always sort first
- Duplicate memo — copies all metadata and files to a new memo
- Trash bin — soft delete, restore, permanent delete (purges R2 objects too), empty trash
- Storage usage indicator — total R2 usage vs. a display-only 10 GB label (not an enforced limit)
- ⟳ Reindex — rebuilds the FTS5 search index after a schema change
- iOS Shortcuts Setup — a built-in guide with your Worker URL, token, and memo IDs for wiring up the Shortcuts app (see iOS Shortcuts quick-capture)
Rich-text note editor (contenteditable, document.execCommand-driven), autosaves 2s after typing stops or on blur (⌘S also saves explicitly). Toolbar:
- Text style (Heading / Subheading / Body), Bold, Italic, Underline, Strikethrough
- Font size +/−, text colour picker, highlight colour (4 presets + remove)
- Menlo monospace font dropdown (11px / 12px / Default) — wraps the current selection in Menlo, useful when pasted terminal/code text arrives without its original formatting
- Bullet/numbered lists, page break, indent/outdent, checkbox tasks, Clear
- Pasted images are inlined as base64
data:images directly in the note
A lightweight capture panel, separate from the main note, stored in R2 as its own _snippets JSON array. Click 📋 Clip, paste content, give it a title:
- Auto-saves 1200ms after you stop typing/pasting, or immediately on blur or image paste — no need to remember to click Save
- Save clip still exists as an explicit "flush and close" action; Cancel discards the in-progress draft
- Accepts pasted images (inlined as base64, same as Notes)
- Has its own Menlo monospace font toolbar (11px / 12px / Default)
- Renders as a list of collapsible cards with expand/collapse-all, inline rename, delete, and "copy as plain text"
- Export clips as .md — downloads every clip as an individual Markdown file in a
.zip - Can also be created remotely via the iOS Shortcuts quick-capture endpoint
Note on paste fidelity: neither Notes nor Clips ever sanitize pasted content — whatever HTML the OS clipboard provides is kept as-is. Whether fonts/colours/tables survive a paste depends entirely on the source app (e.g. Warp renders AI output as real HTML and preserves everything; Terminal.app and Claude Code sessions in most terminals only put plain text on the clipboard). The Menlo font tool above is a manual fallback for when a paste arrives unstyled.
- Drag-and-drop, click-to-browse, or upload folder (bulk
webkitdirectoryupload) - Virtual folders (key prefixes) — create, rename, remove; drag files between folders
- Grid/list view, sortable by name/type/date/size
- Lightbox for images/video/audio/PDF with keyboard navigation
- Per-file captions and tags (feed into full-text search)
- Soft-delete per file (trash → restore or permanent delete)
- Multi-select bulk actions: tag, move, delete, download (as a zip)
- Upload progress bar with cancel; 100 MB per-file limit, enforced both client- and server-side (Cloudflare Free plan R2 cap)
- UID, tags (autocomplete from all existing tags), links (label + URL), created date, cover image selector — all autosaved
- Cover image auto-set from the first uploaded image if none is set
- QR label — printable label with a QR code (via
api.qrserver.com), memo ID, and UID - Export note as standalone HTML — a self-contained, styled HTML document with title/description/links/clips/files
- Email note to myself — sends the note via Resend, with the full HTML export attached
- Add to Google Calendar — deep-links to
calendar.google.comwith the memo's title/note prefilled; supports separate "Call" and "SMS" calendar IDs, remembered via thesettingstable
Public, unauthenticated read-only view at /share?token={share_token} (generate/revoke the link from the memo detail page). Shows cover, title/description/tags, the note, clips (read-only, collapsible), and files (with its own lightbox and download links).
Toggled via a floating button, persisted in localStorage. Fully CSS-custom-property driven.
Two auth-gated Worker endpoints let a Shortcuts.app automation push content straight into a memo:
POST /quick-capture— JSON body,type: "text"creates a new clip,type: "image"|"file"uploads a base64-encoded filePOST /quick-capture-file— raw binary body upload (no base64 overhead), for the iOS Share Sheet
| Column | Type | Notes |
|---|---|---|
id |
TEXT PK | UUID |
memo_id |
TEXT UNIQUE | YYYYMMDD-XXXXXXX format |
uid |
TEXT | Optional human identifier e.g. STOR-001 |
title |
TEXT | |
description |
TEXT | |
cover_file |
TEXT | R2 key relative to memo prefix |
tags |
TEXT | JSON array, lowercase |
pinned |
INTEGER | 0 or 1 |
links |
TEXT | JSON array of {label, url} |
search_text |
TEXT | Denormalised FTS blob |
share_token |
TEXT | Nullable — set when a public share link is generated |
deleted_at |
TEXT | Soft delete — NULL when active |
created_at |
TEXT | ISO 8601 |
updated_at |
TEXT | ISO 8601 |
| Column | Type | Notes |
|---|---|---|
key |
TEXT PK | e.g. gcal_call_id, gcal_sms_id |
value |
TEXT |
Plus a memos_fts FTS5 virtual table with sync triggers, managed via raw SQL (see schema.sql and POST /search/rebuild) — Drizzle doesn't support virtual tables, so this part is hand-written and lives outside the drizzle/ migrations.
schema.sql is a complete, from-scratch bootstrap script (drops and recreates everything) kept in sync with src/db/schema.ts by hand; drizzle/ holds the incremental migration history for anyone using drizzle-kit migrate instead. Both currently describe the same end state — if you add a column to schema.ts, update both.
| Key | Content |
|---|---|
filename or folder/filename |
Uploaded file |
_note |
Rich-text HTML note |
_meta |
JSON: { files: {key: {caption, tags}}, folders: [], trash: [...] } |
_snippets |
JSON array of clips: [{id, title, content, created_at}] |
_trash/filename |
Soft-deleted files |
This app has two independently deployed parts — don't conflate them.
Push to main — GitHub Actions (.github/workflows/deploy-pages.yml) automatically builds and deploys.
- Triggers on changes to
src/**,static/**,svelte.config.js,vite.config.ts,package.json,package-lock.json - Runs
npm ci && npm run build, thenwrangler pages deploy .svelte-kit/cloudflare --project-name=memo-frontend
Required GitHub secrets:
| Secret | Value |
|---|---|
CLOUDFLARE_API_TOKEN |
API token with Cloudflare Pages edit permission |
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare account ID |
There is no CI workflow for the Worker — deploy it manually:
sh deploy.shThis bundles worker.ts with esbuild, copies the bundle plus wrangler.toml into a temp directory, and runs wrangler deploy from there.
Never run
wrangler deploydirectly from the repo root.wrangler.jsonc(auto-generated by@sveltejs/adapter-cloudflarefor the Pages build) has no D1/R2 bindings — if wrangler picks it up instead ofwrangler.toml, it deploys a broken worker with no database access.deploy.shexists specifically to avoid this.
Apply to D1 once on initial setup:
wrangler d1 execute memo-db --file=schema.sqlThis creates both tables (memos, settings) and the FTS5 index/triggers in one shot.
After deploying a worker with schema changes, click ⟳ Reindex on the home page to rebuild the FTS5 search index.
Two independent suites, matching the two independently-deployed halves of the app:
npm test # worker — Vitest + @cloudflare/vitest-pool-workers, runs against real D1/R2
# bindings inside the actual workerd runtime via Miniflare. Fully local,
# never touches production.
npm run test:e2e # frontend — Playwright, drives a real Chromium browser against a
# production build (npm run build && preview). All requests to the
# Worker are intercepted and mocked (see test-e2e/helpers.ts), so this
# never touches production either.Both are smoke-level coverage (auth, core CRUD, the two editors' autosave behaviour), not exhaustive — there's no coverage of file uploads/lightbox/share pages/email/calendar/iOS quick-capture yet.
A single shared passphrase protects all routes except /share/*. Set it as a Worker secret in the Cloudflare dashboard:
Workers & Pages → memo-worker → Settings → Variables and Secrets → MEMO_AUTH_TOKEN
The token is appended as ?t={token} on every API request and stored in localStorage on the frontend. A 401 response anywhere clears the stored token and re-prompts.
Email (OWNER_EMAIL, RESEND_API_KEY, RESEND_FROM) is configured the same way — as Worker secrets, not through any settings UI. There is no dedicated Settings page; the settings D1 table is currently only used for the two Google Calendar IDs.
| Resource | Name | ID |
|---|---|---|
| Worker | memo-worker |
— |
| Worker URL | memo-worker.ausz.workers.dev |
— |
| Pages project | memo-frontend |
— |
| D1 database | memo-db |
2554e206-c3d9-45a9-a5b6-96e06e428e1d |
| R2 bucket | memo-files |
— |
- The 10 GB storage figure shown on the home page is a display label, not an enforced quota.