Register BASIC with highlight.js (preview mode) and add it to the code modal language picker. CodeMirror falls back to plain text in the modal editor as no CM6 BASIC parser is available.
21 KiB
Joplock Agent Guide
Purpose
This repo owns Joplock, standalone thin-client sidecar web UI for stock Joplin Server.
Use this guide when working in this repository.
Product Direction
- Joplin Server stays unmodified
- Joplock stays separate project and separate repo
- Reuses existing Joplin Server auth/session/user model through sidecar logic
- Keeps compatibility with desktop/mobile/CLI clients on same server and same DB
- Browser stays thin and untrusted
- Shared-browser safety matters: logout should clear client-visible state/cache as much as platform allows
- Installable PWA shell, no offline notes/editing
- Uses same Postgres database as Joplin Server, no separate app DB
Architecture Overview
Stack
- Server: Node.js HTTP server, no framework
- Client: SSR HTML + htmx fragment swaps + shared browser logic in
public/app.js - Editor: CodeMirror 6 for markdown editing, contenteditable rendered markdown preview mode
- Code blocks: Full-screen code modal with CM6 editor and language picker; highlight.js for preview mode syntax highlighting
- Autosave: htmx delayed PUT after typing pause (deferred while modals are open)
- Markdown: server-side
renderMarkdown(), client-side TurndownhtmlToMarkdown() - Auth: reuses Joplin Server
sessionIdcookie - DB access: reads direct from shared Postgres; writes go through stock Joplin Server API
Runtime Shape
- Initial page load is full SSR HTML from
layoutPage()inapp/templates/pages.js - After load, most interactions are fragment-driven via htmx
- The browser is intentionally thin: most state is DOM state, form state, or small client-only UI state in
public/app.js - There is no frontend router and no SPA store
- Desktop and mobile share the same server routes and most of the same editor code; mobile is a different screen shell around the same editor fragment
Request Flow
- Browser hits Joplock
- Joplock validates
sessionIdagainst Joplin session/user tables - Fragment endpoints return HTML chunks; htmx swaps DOM
- Writes serialize note/folder/resource and send upstream to stock Joplin Server API
Main UI Flow
GET /renders the full shell- Navigation / notes / editor content is loaded from fragment endpoints
- Selecting a folder swaps the notes list or nav tree fragment
- Selecting a note swaps in
editorFragment() - Autosave sends
PUT /fragments/editor/:idwith the current form state - Preview rendering uses
POST /fragments/preview
Fragment Conventions
app/templates/**/*.jsreturns raw HTML strings, not JSX/templates/components- htmx targets are mostly
#nav-panel,#notelist-panel,#editor-panel, and mobile-specific targets like#mobile-editor-body - Out-of-band swaps are used sparingly; note metadata is one example
- Client logic often relies on stable IDs, so be careful renaming DOM IDs used by inline JS
Core Rules
- Do not modify Joplin Server source for Joplock features unless explicitly approved.
- Server authoritative. Browser ephemeral.
- Preserve sync compatibility with normal Joplin clients.
- Do not build browser-local authoritative storage.
- Keep sidecar API app-oriented. Do not expose raw sync/storage model to frontend.
- Treat logout as client cleanup event on shared machines.
Vault / Encryption Model
- Vaults are notebooks/folders with metadata stored in
joplock_vaults - Titles stay plaintext
- Notebook names stay plaintext
- Note body ciphertext is stored in normal Joplin note bodies using Joplock markers for compatibility
- Browser crypto stays client-side only; server never receives vault passwords
- A note inside a vault notebook must be treated as protected even if its stored body is still plaintext during transition states
- Locked vault notes render the lock overlay plus hidden editor shells; do not remove the hidden editor DOM because unlock logic depends on it
- Clicking a vault lock while unlocked should lock immediately and close the open note if it belongs to that vault
- Startup/refresh must never auto-resume an encrypted note or a note inside a vault notebook
Service Responsibilities
Stock Joplin Server
Owns:
- login/session/auth source of truth
- sync endpoints
- canonical storage rules
- existing user/session tables
Joplock
Owns:
- thin-client UI
- sidecar API endpoints
- session validation against shared DB
- markdown rendering and editor behavior
- resource upload/serving
- app-specific settings in
joplock_settings - PWA shell/assets
Does not own:
- canonical note/folder/resource persistence rules
- sync protocol semantics
- auth/session source of truth
- offline-first storage
File Map
Entry / Server
server.js— entry point, env wiring, server startupapp/createServer.js— server assembly, shared context, full-page/render, static serving
Route Handlers
app/routes/fragments.js— desktop/shared fragment routesapp/routes/mobile.js— mobile folder/note/search routesapp/routes/api.js— JSON API endpointsapp/routes/auth.js,app/routes/settings.js,app/routes/admin.js,app/routes/history.js,app/routes/resources.js
Templates / UI
app/templates/index.js— central template re-exportapp/templates/pages.js— full-page layout/login/MFA shellsapp/templates/fragments.js— nav, editor, search, history, OOB fragmentsapp/templates/mobile.js— mobile folder/note/search fragmentsapp/templates/shared.js— escaping, markdown rendering, title normalizationapp/templates/settings.js— settings/admin page sections
Client Runtime
public/app.js— shared client logic for editor, autosave, vault flows, mobile screen stack, search, and modals
Important subareas:
settingsPage()— Settings UI and simple client save helperseditorFragment()— shared editor DOM used by desktop and mobilelayoutPage()— logged-in app shell and mobile shell containerrenderMarkdown()— server-side markdown-to-HTML for preview/render modepublic/app.jsmobile helpers — folder-first mobile UI, note list, search, editor screen stack
Auth
app/auth/cookies.js— cookie parsingapp/auth/sessionService.js— shared DB session lookupapp/auth/mfaService.js— env-driven TOTP verification and otpauth/QR generation
Data
app/items/itemService.js— DB reads for folders, notes, search, resourcesapp/items/itemWriteService.js— note/folder/resource serialization and upstream writesapp/settingsService.js— Joplock-owned settings table accessapp/vaultService.js— vault metadata CRUD injoplock_vaults
How Reads vs Writes Work
- Reads come from the shared Postgres DB for speed and to match the current server state
- Writes do not write directly to Joplin tables; they go through stock Joplin Server APIs
- That split is intentional: Joplock can stay lightweight while preserving compatibility with normal Joplin clients
- If behavior looks inconsistent after a write, inspect both the sidecar request path and the upstream Joplin API call path
Static Assets
public/htmx.min.jspublic/codemirror.min.js— CM6 bundle with 11 language parsers (built fromcm-build/)public/hljs.min.js— highlight.js bundle for preview mode code highlighting (built fromhljs-build/)public/styles.csspublic/service-worker.jspublic/manifest.webmanifest
Bundle Build Sources
cm-build/— CM6 bundle source (npm install && npm run build→public/codemirror.min.js)hljs-build/— highlight.js bundle source (npm install && npm run build→public/hljs.min.js)
Tests
tests/*.test.js- Run:
node --test tests/**/*.test.js
Deployment
Dockerfiledocker-compose.yml— sidecar-only exampledocker-compose.example-full.yml— Postgres + Joplin Server + Joplock example.env.example
MFA Notes
- MFA is per-user, managed via Settings → Security → Two-Factor Authentication.
- Each user's TOTP seed is stored in
joplock_settings.totp_seedin the shared Postgres DB. - No global/shared TOTP seed. The old
JOPLOCK_TOTP_SEED/JOPLOCK_TOTP_ISSUERenv vars are removed. IGNORE_ADMIN_MFA=trueskips the per-user MFA check at login for the docker-defined admin account (JOPLOCK_ADMIN_EMAIL). Other users are unaffected.- Admin can force-enable/disable MFA for any user via the Admin tab (no code required).
Design Decisions
Separate repo
Joplock lives outside Joplin monorepo. Keep standalone build, test, docs, Docker flow working without Joplin source tree.
Shared Postgres database
Joplock reads same Postgres database as Joplin Server. No data duplication. Writes still go through Joplin Server API for compatibility and validation.
Configurable open mode
Notes can open in rendered mode or markdown mode based on the per-user noteOpenMode setting. Desktop and mobile both respect the same setting.
Shared editor fragment
Desktop and mobile do not have separate editor implementations. Both use the same editorFragment() and client editor logic; mobile wraps it in a mobile-specific shell and screen navigation layer.
PWA shell
Cache shell/static assets only. Do not cache note/resource/API responses in ways that break shared-browser safety.
Mobile-first navigation without SPA rewrite
Mobile uses a folders screen, notes screen, and editor screen implemented in SSR + htmx + inline JS. Do not introduce a client router or framework state layer to solve mobile flow problems.
Tablet behavior
Tablet still uses the mobile shell in the current responsive design. Mobile/tablet editor behavior should be reasoned about by editor container context, not just viewport width.
Editor Model
Two modes
- Markdown mode: CodeMirror 6 is visible, textarea is sync target
- Rendered mode: contenteditable preview is visible, Turndown converts edited HTML back to markdown
Source of truth during editing
- The hidden textarea
#note-bodyis the form field used for saves - In markdown mode, CodeMirror changes sync into
#note-body - In rendered mode, preview DOM changes sync back into markdown via
htmlToMarkdown() - File/image uploads should alter markdown first, then refresh rendered preview from markdown; do not treat preview-only DOM insertion as authoritative state
- The title is mirrored between
.editor-title, hidden title input, and mobile title header when applicable
Save lifecycle
markEdited()updates UI state toEditedscheduleSave()triggers delayed autosave for body/form changesscheduleSaveTitle()is a shorter timer for title changes- If
scheduleSave()orscheduleSaveTitle()sees the same form hash as_savedHash, the visible save state should return toSaved, not remainEdited flushSave()is the forced-save path used before leaving a dirty note; it must also handle vault-note encryption before navigation proceedshtmx:afterRequeston the editor save path transitions UI state back toSaved- Offline/request failure paths set status to
Offline
Upload behavior
- The hidden file input
#file-uploadsupports multi-select uploads handleFilePicker()snapshots the selectedFileListbefore clearing the input so mobile/desktop pickers do not lose filesuploadFiles()should batch multi-file selections and avoid mid-batch autosave races that can reload the editor before later files are applied- Desktop and mobile rendered-mode uploads must preserve selection order across multiple images
- Image-only uploads must not promote the image filename into the note title; auto-title should ignore image-only first lines
Important fragility points
- DOM IDs and class names are part of the editor contract with inline JS
- Preview HTML must remain convertible back to markdown with acceptable fidelity
- Checkbox, code block, and blank-line handling are easy to regress
- The code modal is outside the fragment-swapped editor so it survives swaps
Mobile UI Model
Shell structure
#mobile-folders-screen#mobile-notes-screen#mobile-editor-screen
These screens are shown/hidden by inline JS in layoutPage() using class changes, not route changes.
Mobile navigation behavior
- Folder-first flow: folders -> notes -> editor
- Search has its own mobile header state
- Mobile note creation uses dedicated fragment endpoints and server headers to drive the next UI step
- The floating action button is only a mobile affordance; desktop should stay unaffected
- FAB visibility should follow screen state directly (
folders/notesvisible,editorhidden), not only htmx swap side effects - Mobile folder rows can include a vault lock button and it must stay inline with the row actions
Mobile editor behavior
- Mobile hides the desktop title bar and uses the mobile header instead
- Mobile header mirrors note title and save state
- Mode buttons should remain visible and clearly indicate the active mode
- Toolbar visibility should be keyed to being inside the mobile editor container, not only screen width
- Newly-created empty mobile notes may be discarded on back if still blank/untitled
- Locked mobile notes should not reveal plaintext/editor surfaces until unlock
Tablet expectations
- Tablet is still in the mobile shell range
- Existing note open path and new note open path should behave the same with respect to default open mode, toolbar visibility, and title/save-state UI
- When debugging tablet issues, compare the exact htmx target and after-settle path used by new-note vs existing-note opens
Settings Model
Storage
- Settings are stored per-user in
joplock_settings.settingsas JSONB app/settingsService.jsowns defaults and normalization- Unknown or invalid values should normalize back to safe defaults
Current notable settings
themenoteFontSizemobileNoteFontSizecodeFontSizenoteMonospacenoteOpenModeresumeLastNotedateFormatdatetimeFormatliveSearchconfirmTrashautoLogoutautoLogoutMinutesencryptionAutoLockMinutes
Adding a new setting
- Add default + normalization in
app/settingsService.js - Allow the key in
/api/web/settingsinapp/createServer.js - Add the UI in
settingsPage()inapp/templates/settings.js - If needed, inject the normalized setting into
layoutPage()/public/app.js - Rebuild with
./scripts/rebuild-dev.sh
Route Notes
Useful route groups in app/createServer.js:
- auth pages and login/logout
- full page render for
/ - fragment routes for nav, notes, editor, preview
- mobile fragment routes for folders, notes, search, mobile note creation
- resource upload and resource serving
- settings save endpoints
- history endpoints
If a UI action appears broken, check:
- Which endpoint it hits
- Which htmx target it swaps
- Which client event handler expects to run after swap/request
- Whether the response includes headers or OOB fragments the client depends on
Coding Guidance
- Keep changes minimal
- Preserve sidecar/frontend boundary
public/app.jsis DOM-contract fragile; validate escaping-heavy changes and stable IDs carefully- The code modal lives in
loggedInLayout, not insidenavigationFragmentoreditorFragment, so it survives htmx OOB swaps - Be careful with checkbox text handling,
\n, regex escaping, and DOM-to-markdown round trips - Keep standalone repo paths/docs/scripts correct; avoid reintroducing monorepo assumptions
- Prefer changing existing inline helpers over introducing a new abstraction unless there is clear reuse
- When fixing mobile behavior, verify desktop is unchanged
- When fixing desktop editor behavior, verify mobile still works because both use the same editor fragment
- Be cautious with
htmx:afterRequestassumptions; in htmx 2.x, response headers are often more reliable than old event-property assumptions - If changing vault behavior, verify desktop + mobile, locked + unlocked, existing note + newly-created note, and refresh/restart behavior
Debugging Guidance
If a code change does not appear in the app
- Rebuild with
./scripts/rebuild-dev.sh - Do not rely on
docker compose ... restart joplockafter source edits - If still stale, inspect the built container logs and confirm the right compose stack is running
If mobile note creation/opening misbehaves
- Check whether the server response includes the expected mobile header such as
X-Mobile-Note-Id - Check the
htmx:afterRequesthandler that consumes that header - Compare new-note path vs existing-note path
- Check whether the note is in a vault and whether the editor was initialized in locked vs unlocked state
If vault behavior misbehaves
- Check whether the folder is marked with
isVault - Check whether the note is marked with
inVault/isEncrypted/vaultId - Check
toggleVaultLock(),unlockNote(),_completeUnlock(), andflushSave()inpublic/app.js - Check whether the hidden editor shells still exist in locked editor HTML
If startup/resume behavior is wrong
- Check the
/render path inapp/createServer.js - Check
resumeLastNote,lastNoteId, andlastNoteFolderId - Refresh/restart must not reopen encrypted notes or notes inside vault notebooks
If toolbar/mode behavior is inconsistent
- Verify whether the current editor is actually inside
#mobile-editor-body - Check
syncEditorModeButtons()andsetEditorMode() - Check whether the note was initialized with the expected
noteOpenMode - If switching modes marks the note
Edited, confirm the current form hash differs from_savedHash; unchanged hashes should showSaved
If title UI drifts
- Check
.editor-title - Check hidden input
.editor-title-hidden - Check
#mobile-editor-title - Check
autoTitle()andsyncTitle()
If save-state UI drifts
- Check
setSaveState() - Check
#autosave-status - Check
#mobile-editor-status - Check htmx save success/failure handlers and upload progress handlers
Verification
- Run tests:
npm test - Build image:
npm run docker:build - Sidecar-only compose:
npm run docker:up - Full example compose:
npm run docker:up:full
Development Stack
Use the dev compose stack for all development work. It includes Postgres, Joplin Server, and Joplock together.
- Rebuild Joplock app container after code changes:
./scripts/rebuild-dev.sh - Start / restart full dev stack:
docker compose -f docker-compose.dev.yml up -d --build - Stop dev stack:
docker compose -f docker-compose.dev.yml down
Do not use the sidecar-only docker-compose.yml for development.
Important:
docker compose ... restart joplockis not enough after source edits because the Docker image copiesapp/,public/, andserver.jsat build time.- For app code changes, use
./scripts/rebuild-dev.shfrom now on.
Recommended inner loop:
- Edit source
- Rebuild with
./scripts/rebuild-dev.sh - Refresh the app
- Check
docker compose -f docker-compose.dev.yml logs --tail=... joplockif something looks wrong
Reference Material
- Mobile UX reference:
~/dev/joplin/packages/app-mobile/ - Use it for interaction ideas and behavior parity targets, not as a copy-paste implementation source
- Joplock must still fit the SSR + htmx sidecar architecture
Current Baseline
- standalone repo at
abort-retry-ignore/joplock - tests passing in standalone repo
- Docker build passing in standalone repo
- full example compose verified with alternate free host ports
- CI: GitHub Actions builds and pushes image to
ghcr.ioon every push tomaster
Recently Completed Work
- Lazy nav loading: folder note lists load on first expand, not on page load
- Search pagination:
pg_trgmGIN index, paginated search results with Load More - Mobile pagination: paginated note lists on mobile
- Note flash fix: eliminated redundant
/fragments/previewfetch on note load - Search input fix: value captured at
htmx:beforeSwapso characters typed during in-flight request are not lost - Mobile spinner: inline spinner in editor screen body instead of broken fixed overlay
- Tablet-on-phone fix: CSS/JS breakpoint raised from 481px to 600px
- Gzip compression: all HTML responses compressed via Node
zlibwhen client sendsAccept-Encoding: gzip - hx- sanitization*:
renderMarkdown()stripshx-*attributes from user HTML to prevent htmx injection - All Notes fix:
/fragments/folder-notesnow normalizes__all_notes__→__all__so the virtual folder loads correctly - Service worker cache bump:
v12forces PWA to fetch fresh CSS/JS after update - Checkbox styling: checked items show accent-colored bold icon via
.md-cb-iconspan; icon is styled independently from text using flexbox layout; turndown serializer, click-toggle handler, and new-checkbox inserter all updated to match - Multi-image uploads: picker uploads now support multiple files, update markdown as the source of truth, preserve upload order in rendered mode, and refresh preview from markdown after each batch
Key Conventions
plans/is gitignored — do not commit plan files- Do not push to remote unless the user explicitly asks
- Run
npm testbefore every commit