Skip to content

adham-omran/freememo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

628 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FreeMemo

Incremental reading with AI-generated flashcards. Import PDFs, EPUBs, and web articles, extract key concepts into reviewable cards, and sync with Anki.

Try it at freememo.net — no setup required. Or self-host using the instructions below.

Built with Hyperfiddle Electric v3 (Clojure/ClojureScript) — a reactive framework where client and server code coexist in the same .cljc files with automatic WebSocket sync.

Features

  • Import — PDFs, EPUBs, web articles (paste or Wikipedia lookup)
  • Live Document — append photos (camera or library) as PDF pages; HEIC auto-converted to JPEG, rotate before saving, oversized images compressed
  • AI flashcard generation — OpenAI extracts key concepts into basic and cloze cards
  • OCR — OpenAI Vision converts PDF pages to editable semantic HTML
  • Rich text editor — Quill-based editor with highlighting and selection-based card generation
  • Anki sync — Push/pull cards directly to your Anki collection via AnkiConnect
  • Zotero import — Pull PDFs (with citation metadata) straight from your local Zotero library via the FreeMemo for Zotero plugin
  • PDF viewer — In-browser rendering via PDF.js with zoom and navigation
  • Learn queue — Priority-first spaced-review ordering (SuperMemo model): due date gates the queue, priority orders it, ties shuffle daily (details)
  • Subset review — Review a chosen subset of cards from any topic
  • Search — Full-text search across topics and cards
  • Library — Browse imported documents
  • Contents — Hierarchical knowledge tree with virtual scrolling
  • Status — Progress overview across all documents

Quick Start

Prerequisites

  • Java 17+
  • Clojure CLI (install guide)
  • Docker (for PostgreSQL)
  • Node.js (for shadow-cljs)
  • ImageMagick (optional — only for HEIC photo uploads in Live Documents; conversion shells out to magick/convert)
  • Graphviz.
    • The sfdp binary needs its layout-engine plugin — on Debian/Ubuntu the plugin is a separate package: apt install graphviz libgvplugin-neato-layout8 (without it sfdp aborts with "no layout engine support for sfdp"). Fedora: dnf install graphviz; macOS: brew install graphviz — both bundle the plugin. Missing sfdp throws Cannot run program "sfdp" and closes the graph page's WebSocket.

Run

# Start PostgreSQL
docker compose up -d

# Start dev server (hot reload, port 8080)
clj -M:dev -m dev

Open http://localhost:8080.

Configuration

AI keys — FreeMemo uses two AI providers:

  • Card generation uses OpenAI. Set OPENAI_API_KEY server-wide, or let each user enter their own key in Settings (per-user BYOK). When a server-wide key is set, the per-user field is hidden and a "Server key" badge is shown.
  • OCR (scanning PDF pages to text) uses OpenRouter, not OpenAI. Set PLATFORM_OPENROUTER_API_KEY. Without it, OCR is unavailable even if an OpenAI key is set.

Sign-inAUTH_MODE selects the login controls:

  • password (self-host default) — username/password only; no Google setup needed.
  • google — Google OAuth only; requires GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REDIRECT_URI (or a resources/google_client.json from Google Cloud Console).
  • both — show both.

For password login, seed the first account with ADMIN_USER / ADMIN_PASSWORD (created at boot only if absent — see Self-hosting).

Optional: Zotero import

Zotero import is gated by a small Zotero plugin (the FreeMemo for Zotero add-on under freememo-zotero-plugin/). The plugin runs inside the user's Zotero, exposes a CORS-permissive HTTP API under /freememo/* on Zotero's loopback server (port 23119), and streams attachment bytes inline. The FreeMemo browser tab talks to it directly — Zotero data never travels through the FreeMemo server.

To enable end-to-end:

  1. Get the .xpi. Either:
  2. Install in Zotero: Tools → Plugins → gear icon → "Install Plugin From File…", pick the .xpi. Restart Zotero.
  3. Zotero → Settings → Advanced → enable "Allow other applications on this computer to communicate with Zotero".
  4. In FreeMemo: Settings → Zotero → toggle Enable Zotero import on. Click Test Connection to verify.

Why a plugin: Zotero's built-in API sends no CORS headers, returns file:// redirects for binary attachments (unreachable from web JS), and silently cancels any "browser-shaped" request as a CSRF mitigation. A Zotero plugin is the smallest layer that solves all three. See freememo-zotero-plugin/README.md for the full rationale, endpoint contracts, and dev-proxy workflow.

Tech Stack

Layer Technology
Framework Electric v3 — reactive full-stack Clojure
Database PostgreSQL 16 (next.jdbc + HikariCP + HoneySQL)
Build deps.edn + shadow-cljs
PDF rendering PDF.js 3.11.174 (CDN)
Rich text Quill 2.0.3 (CDN)
Graph layout Graphviz sfdp (server-side, Knowledge Graph view)
Logging Telemere (structured, CLJ + CLJS)
AI OpenAI GPT-5.1 (card generation) + Vision (OCR)

Loading indicators

Loading state lives in freememo.loading (Spinner, WithLoading) plus the .spinner CSS class (index.css, @keyframes spin).

WithLoading exists because of an Electric subtlety: a pending e/Offload (or any unresolved e/server value) latches to an empty e/amb, not nil, so the naive (if (nil? v) loading loaded) never shows the loading branch. WithLoading captures the resolved value into a client atom via a one-shot e/Token, giving a real nil → value transition the UI can branch on.

Two consumption shapes:

  • Value render(loading/WithLoading Thunk Loaded) renders the spinner until Thunk's server value resolves, then (Loaded value). Re-fetch = remount (key the call site). See ocr_compare, copy_text, bibliography_form.
  • Token-commit flow — when a Forms5 commit runs an e/Offload whose result drives navigation/branching, you can't swap the view to a loading screen: that unmounts the e/for token body and cancels the in-flight offload. Instead set a busy atom inside (when token …) (as a case test so it evaluates) and clear it with e/on-unmount; render a spinner overlay from that atom while the form stays mounted. See import_modal's !busy-msg overlay.

Command architecture

Every user-invocable action is a command: an entry in the central registry freememo.commands/registry (pure data), plus an effect implementation. The registry is the single inventory behind three frontends — buttons, keyboard shortcuts, and the Cmd/Ctrl-K command palette — and behind reactive invalidation.

A registry entry:

:anki-sync {:label "Push to Anki…"          ; palette text
            :class :mutation                ; :mutation | :query | :nav
            :exec  :ui-button               ; how the effect runs (below)
            :bind  "meta+shift+x"           ; keyboard shortcut (nil = palette-only)
            :when  #{:viewer}               ; tabs where the command applies
            :data  {...}                    ; payload fields (documentation)
            :table #{:flashcards}           ; DB tables written (audit/docs)
            :views #{:sync-mutations}}      ; invalidation channels bumped

Why :views exists. Electric's reactivity does not cover the database: views re-query only because a per-user counter atom (freememo.user-state, channels like :card-mutations, :refresh) is bumped after a mutation. Pre-registry, every mutation site hand-picked channels; a missed bump meant a stale view. Now there is one bump authority: only freememo.commands increments channel atoms. Domain code declares :views in the registry and calls (commands/bump! user-id :command-id) at its boundary — or bump-channels! for the two runtime-dynamic cases (undo, staged delete). freememo.commands-test fails the build if any other namespace bumps by hand.

Execution modes (:exec).

  • :queue — server effect. The client enqueues {:id :type :payload} onto the per-user optimistic queue; the always-mounted CommandDispatcher pump runs optimistic/execute!, which calls the command's (defmethod optimistic/run-command! :my-command …), then bumps its registry :views, then drops the queue entry. Methods own effect + toast only — they must not bump or drop.
  • :ui-button — the flow lives in a mounted Electric component (tokens, client continuations like post-delete navigation). The component publishes an invoke handle via command-bus/publish-invoker! (retracting on unmount); keyboard/palette/menus trigger the same flow the visible button runs. Availability = the handle is published. The flow's terminal server step calls bump!.
  • :client — synchronous client handler (navigation, modal toggles, queries) registered with command-bus/register-handler!.
  • Long-running background work (card generation, OCR scan) keeps its processor; the processor calls bump! at its own terminal step.

Invocation. All three frontends call command-bus/dispatch!: freememo.keyboard registers every :bind from the registry on one KeyboardShortcutHandler; the palette (freememo.command-palette, Cmd/Ctrl-K, mounted in Main) fuzzy-matches labels and shows bindings; buttons and dropdown menu items call dispatch! directly. Context (active tab, navigate!, modal atoms) is published into command-bus/!ctx by the components that own it, retracted on unmount.

Adding a command:

  1. Add the registry entry in freememo.commands (domain-registry) — label, class, exec, optional :bind/:when, and :views (which channels its mutation invalidates).
  2. Implement the effect: a run-command! defmethod (:queue), a published invoker on the owning button (:ui-button), or a registered client handler (:client).
  3. Invoke it via (bus/dispatch! :my-command) (payload optional — a registered preparer can build it from context).
  4. Run freememo.commands-test (clj -M:test -e "(require 'freememo.commands-test) (clojure.test/run-tests 'freememo.commands-test)").

Tooltips

Tooltips are driven by the data-tooltip attribute plus a CSS :hover rule (index.css ~line 2041) — no JS, instant on hover, unlike the browser's delayed title.

Elements that open a menu suppress their tooltip while open via [data-tooltip][aria-expanded="true"]:hover::after { visibility: hidden }. Without it the cursor stays over the trigger after a click, :hover remains true, and the tooltip paints over the open menu. Menu triggers already set aria-expanded (e.g. pdf_action_dropdowns.cljc), so any new menu-button gets the fix for free by setting that attribute.

Database

PostgreSQL 16 via docker-compose.yml. Default connection: cardmaker:dev@localhost:5432/cardmaker.

Schema auto-creates on first startup (no migration framework). Configure via environment variables — see below.

Environment Variables

Variable Default Notes
DB_HOST localhost
DB_PORT 5432
DB_NAME cardmaker
DB_USER cardmaker
DB_PASSWORD dev (dev) / required (prod) Production boot fails if unset. Set before the first up — Postgres fixes it at init.
ENC_KEY_SECRET (none) Session-cookie key derivation. Required in production (boot fails if unset). Generate: openssl rand -hex 32
AUTH_MODE google (credits) / password (self-host) Login controls: password, google, or both.
ADMIN_USER (none) Seed username, created at boot if absent.
ADMIN_PASSWORD (none) Seed password. Changing it later does not reset an existing account.
COOKIE_SECURE true Session-cookie Secure attribute. Set false to log in over plain HTTP (localhost/LAN without TLS).
OPENAI_API_KEY (none) Server-wide OpenAI key for card generation. Falls back to OPENAI_DEMO_KEY.
PLATFORM_OPENROUTER_API_KEY (none) OpenRouter key for OCR. Required for document scanning.
OPENAI_DEMO_KEY (none) Backward-compatible alias for OPENAI_API_KEY.
LOG_LEVEL info (prod) / debug (dev) trace, debug, info, warn, error, fatal
PORT 8080 HTTP port
STORAGE_QUOTA_BYTES 1073741824 (1 GB) Total per-user storage. 0 = unlimited.
STORAGE_PER_FILE_MAX_BYTES 104857600 (100 MB) Default per-upload cap (per-user override via users.upload_max_bytes). 0 = unlimited. Also sizes the WebSocket message limit.
STORAGE_REQUEST_MAX_BYTES 104857600 (100 MB) Absolute HTTP request-body ceiling for upload routes (per-user-blind). 0 = unlimited. A per-user cap above this is still bounded here — raise this too to allow larger uploads.
APP_BASE_URL https://freememo.net Public base URL embedded in Anki card source anchors. Set to your domain when self-hosting.
GOOGLE_CLIENT_ID (none) Google OAuth client ID. Required when AUTH_MODE is google/both.
GOOGLE_CLIENT_SECRET (none) Google OAuth client secret.
GOOGLE_REDIRECT_URI http://localhost:8080/auth/google/callback Must match the URI registered in Google Cloud Console.

Alternative to the three GOOGLE_* envs: drop the OAuth client JSON downloaded from Google Cloud Console at resources/google_client.json (gitignored).

Production

Self-hosting with Docker

Self-host on Linux or macOS with Docker — username/password login, no Google account required. Everything (Postgres + app) is defined in a single file, docker-compose.selfhost.yml.

1. Configure .env:

cp .env.example .env

Edit .env and set, at minimum:

DB_PASSWORD=$(openssl rand -hex 32)       # set BEFORE first up (Postgres fixes it at init)
ENC_KEY_SECRET=$(openssl rand -hex 32)    # session-cookie key
AUTH_MODE=password                        # username/password login
ADMIN_USER=admin
ADMIN_PASSWORD=your-password-here
COOKIE_SECURE=false                       # required to log in over plain HTTP (see security note)
OPENAI_API_KEY=sk-...                     # card generation
PLATFORM_OPENROUTER_API_KEY=sk-or-...     # OCR (document scanning)

2. Start:

APP_VERSION=$(git describe --tags --long --always --dirty 2>/dev/null || echo selfhost) \
  docker compose -f docker-compose.selfhost.yml up -d --build

The app refuses to boot if DB_PASSWORD or ENC_KEY_SECRET is unset. APP_VERSION is optional here (defaults to selfhost); it's baked into the build for client/server version matching, which holds because one build produces both.

3. Log in at http://localhost:8080 with your ADMIN_USER / ADMIN_PASSWORD. The account is created at boot only if it doesn't already exist — changing ADMIN_PASSWORD later will not reset it.

Override the published port with APP_PORT (default 8080). Storage caps default to unlimited; set STORAGE_QUOTA_BYTES / STORAGE_PER_FILE_MAX_BYTES in .env. To accept uploads larger than 100 MB, also raise STORAGE_REQUEST_MAX_BYTES (the HTTP request-body ceiling) — it caps upload requests regardless of the per-user limit.

⚠️ Security — plain HTTP over a LAN. COOKIE_SECURE=false is needed to log in without HTTPS, but it means the login password and session cookie travel unencrypted. The app binds 0.0.0.0, so anything on your network can reach it. Use plain HTTP only on a trusted local network. For any real or internet-facing deployment, put the app behind an HTTPS reverse proxy (see Reverse Proxy) and set COOKIE_SECURE=true.

Non-Docker (run the jar on the host)

Requires a reachable Postgres (e.g. docker compose up for just the DB), ImageMagick on PATH (HEIC upload conversion shells out to magick), and — for the Knowledge Graph view — Graphviz on PATH (layout shells out to sfdp; Debian/Ubuntu also needs the separate libgvplugin-neato-layout8).

# Build client + uberjar
clj -X:build:prod build-client
clj -J-Xss4m -X:build:prod uberjar :build/jar-name '"app.jar"'

# Run. DB_HOST/PORT/NAME/USER default to localhost / cardmaker; DB_PASSWORD and
# ENC_KEY_SECRET are required (boot fails otherwise).
DB_PASSWORD=secret ENC_KEY_SECRET=$(openssl rand -hex 32) \
  AUTH_MODE=password ADMIN_USER=admin ADMIN_PASSWORD=pw COOKIE_SECURE=false \
  OPENAI_API_KEY=sk-... PLATFORM_OPENROUTER_API_KEY=sk-or-... PORT=9091 \
  java -cp target/app.jar clojure.main -m prod

Reverse Proxy

The app runs on port 8080. For HTTPS, put it behind nginx or Caddy. WebSocket support is required — Electric v3 uses persistent WebSocket connections.

nginx essentials:

location / {
    proxy_pass http://localhost:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 300s;
    client_max_body_size 100M;  # must be ≥ STORAGE_REQUEST_MAX_BYTES (or 0/large when unlimited)
}

Caddy (automatic HTTPS):

your-domain.com {
    reverse_proxy localhost:8080
}

Backup & Restore

Backup the database:

docker compose exec postgres pg_dump -U cardmaker cardmaker > backup.sql

Restore:

cat backup.sql | docker compose exec -T postgres psql -U cardmaker cardmaker

A remote-pull variant is in scripts/backup.sh.

Geo-IP Database

The app uses DB-IP Lite Country to switch the Wayl checkout page between IQD and USD display based on the client's country. The MMDB file lives at resources/geo/dbip-country-lite.mmdb and ships inside the uberjar via :paths ["src" "resources"] in deps.edn.

DB-IP refreshes the free database monthly. To pull the latest:

mkdir -p resources/geo && \
  curl -L "https://download.db-ip.com/free/dbip-country-lite-$(date +%Y-%m).mmdb.gz" \
  | gunzip > resources/geo/dbip-country-lite.mmdb

Then rebuild the uberjar.

Attribution

The free IP to Country Lite database by DB-IP is licensed under a Creative Commons Attribution 4.0 International License. Use is permitted in this application provided DB-IP.com is credited for the data. The required link on any page that displays or uses results from the database:

<a href='https://db-ip.com'>IP Geolocation by DB-IP</a>

License

Electric v3 is free for bootstrappers and non-commercial use, and available commercially under a business source license. See Electric v3 license.

About

No description, website, or topics provided.

Resources

License

Stars

2 stars

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages