feat(editor): markdown as default open mode; fix blank editor on vault unlock
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run

- Default noteOpenMode is now 'markdown' (was 'preview'); explicit
  'preview' choice is still respected. Settings dropdown reordered to
  match.
- preferredEditorMode() reads the live window._joplockConfig instead of
  a parse-time snapshot, so the preference can't stick at a stale
  default if script order ever shifts. initEditorPanel no longer lets
  mobile read-only override the open-mode preference.
- _joplockConfig moved into <head>, before app.js loads.

- Fix: unlocking an encrypted note with markdown as the preferred mode
  showed a blank editor. Locked notes skip editor init, so TinyMCE
  never held the note's content; _completeUnlock's old
  setEditorMode('markdown') call ran tinyMCESyncToTA(), which
  overwrote the just-decrypted plaintext with stale/empty iframe
  content. Fix seeds CM6 directly from the decrypted plaintext and
  makes tinyMCESyncToTA() refuse to write when #tinymce-host isn't
  visible.

- Wire graft repo context graph into AGENTS.md; add joplock db schema
  diagram (docs/).

Tests: tests/noteOpenMode.test.js, tests/vaultUnlockMarkdown.test.js
(new), plus updated settingsService/templates coverage. Full suite
836/836 passing.
This commit is contained in:
igor 2026-09-21 10:26:00 +12:00
parent b701e5098d
commit 0cbec48800
14 changed files with 642 additions and 37 deletions

3
.gitignore vendored
View file

@ -7,3 +7,6 @@ docker-compose.dev.yml
plans/
playwright-report/
test-results/
# graft's local graph cache — regenerable, not committed (run `graft build`).
/graft/

5
.ignore Normal file
View file

@ -0,0 +1,5 @@
# graft's cards are gitignored but should stay greppable: ripgrep reads
# .ignore before .gitignore, so this re-admits the tree to search only.
!graft/
graft/.cache/
graft/.graph/

View file

@ -1,3 +1,45 @@
# Agents
Read [`AGENT_GUIDE.md`](./AGENT_GUIDE.md) first. It is the source of truth for repo conventions, architecture, editor model, mobile shell, settings, debugging workflow, and coding rules.
<!-- graft:start -->
## Graft — repo context graph
This repo is indexed in `graft/`: small linked markdown nodes that explain each
system and carry exact file:line spans, kept in sync with the code through git.
For ANY task here — understanding how something works, finding where code lives,
or scoping a change — get context from the graph before grepping or opening
source files. Re-ask freely (it's cheap) and reuse literal identifiers you
already have (symbol, error string, file name) as the query. New to this repo?
Run `graft map` first — a token-budgeted orientation (dir clusters, hubs,
hotspots), no LLM, no key.
- Run `graft ask "<your question>" --source` → ranked nodes with the relevant
code spans inlined (each hit's ≤8-line crux by default; `--full` for whole
definitions when the crux isn't enough). Match the tool to the task shape:
for understanding or editing, the top node IS the answer — cite its
`covers:` file:line spans and edit straight from `--source`. For
exhaustive tasks ("every occurrence / every caller of this pattern"), ranked
results are top-N, not complete — run `graft grep "<literal>"` instead
(exhaustive over indexed files, grouped by enclosing symbol), falling back
to raw `grep -rn` only for unindexed files.
- `graft skeleton <file>` → every definition's signature + span, ~10× cheaper
than reading the file; use it to skim an API surface.
- `graft callers <symbol>` gives precomputed, exact edges — who calls this.
Add `--direction out` for what it calls, or `--depth N` to walk
transitively for the full blast radius. For structural questions, skip
ranking and use this directly.
- Or browse: `graft/INDEX.md` lists every node; follow the links.
- Monorepos and folders of multiple repos rank fairly across sub-projects —
hits carry `[scope/]` labels naming which one they're from. Narrow with
`graft ask "<task>" --in <scope>/` once you know where you're working.
If a returned span is truncated ("+N more lines"), open the file at that exact
range before finalizing. Only open source files when a node genuinely lacks a
needed detail, and then at the exact file:line the node points to — never
re-read whole files.
After big code changes, refresh the graph with `graft build` (deterministic,
no API key, $0).
<!-- graft:end -->

View file

@ -124,6 +124,7 @@ Use this guide when working in this repository.
- 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
- Unlocking a vault note with markdown as the preferred open mode must seed CM6 from the decrypted `plaintext` argument. Do not call `setEditorMode('markdown')` on unlock: that path `tinyMCESyncToTA()`s leftover/empty TinyMCE content over the just-decrypted body (locked notes skip editor init, so TinyMCE never held this note). `tinyMCESyncToTA()` must also refuse to write when `#tinymce-host` is not visible.
- 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
@ -290,7 +291,7 @@ Joplock lives outside Joplin monorepo. Keep standalone build, test, docs, Docker
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.
Notes can open in rendered mode or markdown mode based on the per-user `noteOpenMode` setting (default **markdown**). Explicit `preview` is kept. Desktop and mobile both respect the same setting. `initEditorPanel` / `_completeUnlock` must call `preferredEditorMode()` (live `_joplockConfig.noteOpenMode`). Do not let `_tinymceReadonlyDefault()` / mobile-shell read-only force rendered mode — that override made Settings → "Open notes in: Markdown" a no-op on tablet/narrow windows. Mobile read-only still applies, but only when the note actually opens in rendered mode. `_joplockConfig` is inlined in `<head>` *before* `app.js`.
### 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.

View file

@ -133,7 +133,7 @@ const defaultSettings = Object.freeze({
noteMonospace: false,
noteFontFamily: 'sans',
newlineBehavior: 'linebreak',
noteOpenMode: 'preview',
noteOpenMode: 'markdown',
resumeLastNote: true,
lastNoteId: '',
lastNoteFolderId: '',
@ -208,7 +208,7 @@ const normalizeSettings = settings => ({
codeFontSize: normalizeInteger(settings.codeFontSize, defaultSettings.codeFontSize, 10, 22),
markdownFontSize: normalizeInteger(settings.markdownFontSize, defaultSettings.markdownFontSize, 12, 24),
noteMonospace: !!Number(settings.noteMonospace) || settings.noteMonospace === true || settings.noteMonospace === '1',
noteOpenMode: settings.noteOpenMode === 'markdown' ? 'markdown' : defaultSettings.noteOpenMode,
noteOpenMode: settings.noteOpenMode === 'preview' ? 'preview' : defaultSettings.noteOpenMode,
resumeLastNote: !!Number(settings.resumeLastNote) || settings.resumeLastNote === true || settings.resumeLastNote === '1',
lastNoteId: `${settings.lastNoteId || ''}`,
lastNoteFolderId: `${settings.lastNoteFolderId || ''}`,

View file

@ -325,8 +325,8 @@ const editorFragment = (note, folders, currentFolderId = '', viewerUserId = '',
<span id="autosave-status"></span>
<button type="button" id="undo-save-btn" class="btn btn-sm btn-secondary undo-save-btn" title="Undo last save (Ctrl+Shift+Z)" onclick="undoSnapshot()" hidden>Undo</button>
<span id="autosave-indicator" class="htmx-indicator">Saving...</span>
<button type="button" class="btn btn-sm" id="markdown-toggle" title="Markdown source" onclick="setEditorMode('markdown')">MD</button>
<button type="button" class="btn btn-icon active" id="preview-toggle" title="Rendered view" onclick="setEditorMode('rich')">&#128065;</button>
<button type="button" class="btn btn-sm active" id="markdown-toggle" title="Markdown source" onclick="setEditorMode('markdown')">MD</button>
<button type="button" class="btn btn-icon" id="preview-toggle" title="Rendered view" onclick="setEditorMode('rich')">&#128065;</button>
${note.deletedTime && isOwner ? `<button type="button" class="btn btn-sm" title="Restore from trash"
hx-post="/fragments/notes/${encodeURIComponent(note.id)}/restore"
hx-target="#nav-panel"

View file

@ -10,7 +10,7 @@ const {
} = require('./shared');
const { noteMetaFragment } = require('./fragments');
const ASSET_VERSION = '20260903flushbase1';
const ASSET_VERSION = '20260903mddefault';
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';
@ -111,6 +111,25 @@ const layoutPage = (options = {}) => {
<script src="/hljs.min.js"></script>
<script src="/html-docx.js"></script>
<script>window.joplockStripNoteTitle=${stripMarkdownForTitle.toString()};</script>
<script>
window._joplockConfig={
debug:${debug ? 'true' : 'false'},
noteOpenMode:${JSON.stringify(settings.noteOpenMode || 'markdown')},
mobileStartup:${JSON.stringify(mobileStartup || null)},
theme:${JSON.stringify(settings.theme || 'earth')},
dateFormat:${JSON.stringify(String(settings.dateFormat || 'MMM-DD-YY'))},
datetimeFormat:${JSON.stringify(String(settings.datetimeFormat || 'YYYY-MM-DD HH:mm'))},
liveSearch:${settings.liveSearch ? 'true' : 'false'},
highlightActiveLine:${settings.highlightActiveLine !== false ? 'true' : 'false'},
confirmTrash:${settings.confirmTrash !== false ? 'true' : 'false'},
encryptionAutoLockMinutes:${JSON.stringify(settings.encryptionAutoLockMinutes || 5)},
uiMode:${JSON.stringify(settings.uiMode || 'auto')},
proseAutocompleteSentenceCount:${JSON.stringify(settings.proseAutocompleteSentenceCount || 1)},
maxUploadMb:${JSON.stringify(maxUploadMb)},
textExpanders:${JSON.stringify(Array.isArray(settings.textExpanders) ? settings.textExpanders : [])},
openRouterEnabled:${(Array.isArray(settings.aiProfiles) ? settings.aiProfiles.some(p => p.apiKey) : !!settings.openRouterApiKey) ? 'true' : 'false'}
};
</script>
<script src="/app.js?v=${ASSET_VERSION}" defer></script>
<title>Joplock</title>
</head>
@ -318,25 +337,6 @@ const layoutPage = (options = {}) => {
</div>
</form>
</div>
<script>
window._joplockConfig={
debug:${debug ? 'true' : 'false'},
noteOpenMode:${JSON.stringify(settings.noteOpenMode || 'preview')},
mobileStartup:${JSON.stringify(mobileStartup || null)},
theme:${JSON.stringify(settings.theme || 'earth')},
dateFormat:${JSON.stringify(String(settings.dateFormat || 'MMM-DD-YY'))},
datetimeFormat:${JSON.stringify(String(settings.datetimeFormat || 'YYYY-MM-DD HH:mm'))},
liveSearch:${settings.liveSearch ? 'true' : 'false'},
highlightActiveLine:${settings.highlightActiveLine !== false ? 'true' : 'false'},
confirmTrash:${settings.confirmTrash !== false ? 'true' : 'false'},
encryptionAutoLockMinutes:${JSON.stringify(settings.encryptionAutoLockMinutes || 5)},
uiMode:${JSON.stringify(settings.uiMode || 'auto')},
proseAutocompleteSentenceCount:${JSON.stringify(settings.proseAutocompleteSentenceCount || 1)},
maxUploadMb:${JSON.stringify(maxUploadMb)},
textExpanders:${JSON.stringify(Array.isArray(settings.textExpanders) ? settings.textExpanders : [])},
openRouterEnabled:${(Array.isArray(settings.aiProfiles) ? settings.aiProfiles.some(p => p.apiKey) : !!settings.openRouterApiKey) ? 'true' : 'false'}
};
</script>
</body>
</html>`;
};

View file

@ -230,8 +230,8 @@ const settingsPage = (options = {}) => {
<label class="settings-field">
<span>Open notes in</span>
<select id="settings-note-open-mode" class="login-input" onchange="saveSetting('noteOpenMode',this.value)">
<option value="preview"${(settings.noteOpenMode || 'preview') === 'preview' ? ' selected' : ''}>Rendered mode</option>
<option value="markdown"${settings.noteOpenMode === 'markdown' ? ' selected' : ''}>Markdown mode</option>
<option value="markdown"${settings.noteOpenMode !== 'preview' ? ' selected' : ''}>Markdown mode</option>
<option value="preview"${settings.noteOpenMode === 'preview' ? ' selected' : ''}>Rendered mode</option>
</select>
</label>
<label class="settings-field">

248
docs/joplock-db-schema.html Normal file
View file

@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Joplock sidecar schema</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #f5f5f5;
--color-ink: #2d3142;
--color-muted: #4f5d75;
--color-accent: #eb6c36;
--font-sans: 'Geist', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
body {
font-family: var(--font-sans);
background: var(--color-paper);
color: var(--color-ink);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
}
.frame { max-width: 960px; width: 100%; }
.eyebrow {
font-family: var(--font-mono);
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-muted);
margin-bottom: 0.5rem;
}
h1 {
font-family: var(--font-serif);
font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.15;
color: var(--color-ink);
margin-bottom: 0.4rem;
}
.lede {
font-size: 0.92rem;
color: var(--color-muted);
margin-bottom: 1.5rem;
max-width: 40rem;
line-height: 1.45;
}
svg { width: 100%; min-width: 720px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Database schema · Joplock sidecar</p>
<h1>Four tables. Same Postgres. No Joplin FKs.</h1>
<p class="lede">Joplock owns these four tables. Joplin users / items / sessions stay Joplin’s. IDs bind in application code, not in DDL.</p>
<svg viewBox="0 0 960 520" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="joplock-db-schema-title joplock-db-schema-desc">
<title id="joplock-db-schema-title">Joplock sidecar schema</title>
<desc id="joplock-db-schema-desc">Physical schema of the four Joplock sidecar tables in public Postgres: sessions, settings, vaults, and history. No SQL foreign keys; user_id, note_id, folder_id, and session_id are application-level pointers into Joplin.</desc>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#4f5d75"/></marker>
<marker id="arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#eb6c36"/></marker>
<marker id="arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#2e5aa8"/></marker>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<!-- Callout 1: __app__ sentinel on settings.user_id -->
<text x="912" y="28" fill="#2d3142" font-size="14" font-style="italic" font-family="'Instrument Serif', serif" text-anchor="end">user_id = '__app__' is admin settings</text>
<path d="M 780 36 Q 760 56 720 80" fill="none" stroke="rgba(45,49,66,0.40)" stroke-width="1" stroke-dasharray="4,3"/>
<circle cx="720" cy="80" r="2" fill="#2d3142"/>
<!-- ========== public.joplock_sessions x=48 y=40 w=400 h=76 ========== -->
<rect x="48" y="40" width="400" height="76" rx="6" fill="#f5f5f5"/>
<rect x="48" y="40" width="400" height="76" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<rect x="48" y="40" width="400" height="28" rx="6" fill="rgba(45,49,66,0.04)" stroke="none"/>
<rect x="48" y="60" width="400" height="8" fill="rgba(45,49,66,0.04)"/>
<line x1="48" y1="68" x2="448" y2="68" stroke="rgba(45,49,66,0.22)" stroke-width="1"/>
<text x="60" y="58" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">public.joplock_sessions</text>
<rect x="396" y="48" width="40" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.40)" stroke-width="0.8"/>
<text x="416" y="57" fill="#2d3142" opacity="0.8" font-size="7" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">TABLE</text>
<rect x="48" y="68" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="60" y="84" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">session_id</text>
<rect x="268" y="76" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="278" y="85" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">PK</text>
<rect x="292" y="76" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="302" y="85" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="436" y="84" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(64)</text>
<text x="60" y="108" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">last_seen</text>
<rect x="268" y="100" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="278" y="109" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="436" y="108" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">bigint</text>
<!-- ========== public.joplock_settings x=512 y=40 w=400 h=124 ========== -->
<rect x="512" y="40" width="400" height="124" rx="6" fill="#f5f5f5"/>
<rect x="512" y="40" width="400" height="124" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<rect x="512" y="40" width="400" height="28" rx="6" fill="rgba(45,49,66,0.04)" stroke="none"/>
<rect x="512" y="60" width="400" height="8" fill="rgba(45,49,66,0.04)"/>
<line x1="512" y1="68" x2="912" y2="68" stroke="rgba(45,49,66,0.22)" stroke-width="1"/>
<text x="524" y="58" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">public.joplock_settings</text>
<rect x="860" y="48" width="40" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.40)" stroke-width="0.8"/>
<text x="880" y="57" fill="#2d3142" opacity="0.8" font-size="7" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">TABLE</text>
<rect x="512" y="68" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="524" y="84" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">user_id</text>
<rect x="732" y="76" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="85" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">PK</text>
<text x="900" y="84" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(32)</text>
<text x="524" y="108" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">settings</text>
<rect x="732" y="100" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="109" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="108" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">jsonb</text>
<rect x="512" y="116" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="524" y="132" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">updated_time</text>
<rect x="732" y="124" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="133" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="132" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">bigint</text>
<text x="524" y="156" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">totp_seed</text>
<text x="900" y="156" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(64)</text>
<!-- ========== public.joplock_vaults x=48 y=192 w=400 h=212 ========== -->
<rect x="48" y="192" width="400" height="212" rx="6" fill="#f5f5f5"/>
<rect x="48" y="192" width="400" height="212" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<rect x="48" y="192" width="400" height="28" rx="6" fill="rgba(45,49,66,0.04)" stroke="none"/>
<rect x="48" y="212" width="400" height="8" fill="rgba(45,49,66,0.04)"/>
<line x1="48" y1="220" x2="448" y2="220" stroke="rgba(45,49,66,0.22)" stroke-width="1"/>
<text x="60" y="210" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">public.joplock_vaults</text>
<rect x="396" y="200" width="40" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.40)" stroke-width="0.8"/>
<text x="416" y="209" fill="#2d3142" opacity="0.8" font-size="7" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">TABLE</text>
<rect x="48" y="220" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="60" y="236" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">id</text>
<rect x="268" y="228" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="278" y="237" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">PK</text>
<text x="436" y="236" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">serial</text>
<text x="60" y="260" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">user_id</text>
<rect x="268" y="252" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="278" y="261" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="436" y="260" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(64)</text>
<rect x="48" y="268" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="60" y="284" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">folder_id</text>
<rect x="268" y="276" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="278" y="285" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="436" y="284" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(64)</text>
<text x="60" y="308" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">salt</text>
<rect x="268" y="300" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="278" y="309" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="436" y="308" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">text</text>
<rect x="48" y="316" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="60" y="332" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">verify</text>
<rect x="268" y="324" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="278" y="333" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="436" y="332" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">text</text>
<text x="60" y="356" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">created_at</text>
<text x="436" y="356" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">timestamptz</text>
<line x1="48" y1="364" x2="448" y2="364" stroke="rgba(45,49,66,0.12)" stroke-width="0.8"/>
<text x="60" y="380" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.14em">INDEXES</text>
<text x="60" y="396" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">UNIQUE (user_id, folder_id)</text>
<!-- ========== public.joplock_history x=512 y=192 w=400 h=236 ========== -->
<rect x="512" y="192" width="400" height="236" rx="6" fill="#f5f5f5"/>
<rect x="512" y="192" width="400" height="236" rx="6" fill="#ffffff" stroke="#2d3142" stroke-width="1"/>
<rect x="512" y="192" width="400" height="28" rx="6" fill="rgba(45,49,66,0.04)" stroke="none"/>
<rect x="512" y="212" width="400" height="8" fill="rgba(45,49,66,0.04)"/>
<line x1="512" y1="220" x2="912" y2="220" stroke="rgba(45,49,66,0.22)" stroke-width="1"/>
<text x="524" y="210" fill="#2d3142" font-size="12" font-weight="600" font-family="'Geist', sans-serif">public.joplock_history</text>
<rect x="860" y="200" width="40" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.40)" stroke-width="0.8"/>
<text x="880" y="209" fill="#2d3142" opacity="0.8" font-size="7" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">TABLE</text>
<rect x="512" y="220" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="524" y="236" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">id</text>
<rect x="732" y="228" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="237" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">PK</text>
<text x="900" y="236" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">bigserial</text>
<text x="524" y="260" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">note_id</text>
<rect x="732" y="252" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="261" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="260" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(32)</text>
<rect x="512" y="268" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="524" y="284" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">user_id</text>
<rect x="732" y="276" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="285" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="284" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(32)</text>
<text x="524" y="308" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">title</text>
<rect x="732" y="300" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="309" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="308" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">text</text>
<rect x="512" y="316" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="524" y="332" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">body</text>
<rect x="732" y="324" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="333" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="332" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">text</text>
<text x="524" y="356" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">body_hash</text>
<rect x="732" y="348" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="357" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="356" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">varchar(16)</text>
<rect x="512" y="364" width="400" height="24" fill="rgba(45,49,66,0.02)"/>
<text x="524" y="380" fill="#2d3142" font-size="12" font-family="'Geist', sans-serif">saved_time</text>
<rect x="732" y="372" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="742" y="381" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="900" y="380" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">bigint</text>
<line x1="512" y1="388" x2="912" y2="388" stroke="rgba(45,49,66,0.12)" stroke-width="0.8"/>
<text x="524" y="404" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.14em">INDEXES</text>
<text x="524" y="420" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">joplock_history_note_time (note_id, saved_time DESC)</text>
<!-- Legend -->
<line x1="48" y1="452" x2="912" y2="452" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="48" y="468" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.14em">LEGEND</text>
<rect x="140" y="458" width="40" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.40)" stroke-width="0.8"/>
<text x="160" y="467" fill="#2d3142" opacity="0.8" font-size="7" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">TABLE</text>
<text x="188" y="468" fill="#4f5d75" font-size="8" font-family="'Geist', sans-serif">sidecar table</text>
<rect x="300" y="458" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="310" y="467" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">PK</text>
<text x="328" y="468" fill="#4f5d75" font-size="8" font-family="'Geist', sans-serif">primary key</text>
<rect x="428" y="458" width="20" height="12" rx="2" fill="none" stroke="rgba(45,49,66,0.35)" stroke-width="0.8"/>
<text x="438" y="467" fill="#2d3142" opacity="0.75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">NN</text>
<text x="456" y="468" fill="#4f5d75" font-size="8" font-family="'Geist', sans-serif">not null</text>
<text x="560" y="468" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace">INDEXES</text>
<text x="628" y="468" fill="#4f5d75" font-size="8" font-family="'Geist', sans-serif">unique / lookup</text>
</svg>
</div>
</body>
</html>

View file

@ -267,7 +267,13 @@ function startAutoLockTimer(){
// They are session-scoped and are explicitly cleared on logout/login cleanup.
;
var _defaultNoteOpenMode=_cfg.noteOpenMode||'preview';
var _defaultNoteOpenMode=_cfg.noteOpenMode||'markdown';
// Live read of the Settings preference. `_cfg` is snapshotted at parse time;
// if `_joplockConfig` was not on the page yet (script order), `_defaultNoteOpenMode`
// would stick at 'preview' and every note would open rendered. Always prefer the
// live config object. Mobile read-only must NOT override this — a markdown
// preference means open in CM6, on desktop and mobile.
function preferredEditorMode(){var v='';try{v=(window._joplockConfig&&window._joplockConfig.noteOpenMode)||''}catch(_e){}if(!v)v=_defaultNoteOpenMode||'';return v==='markdown'?'markdown':'rich'}
var _highlightActiveLine=_cfg.highlightActiveLine!==false;
var _mobileStartup=_cfg.mobileStartup||null;
var _uiMode=_cfg.uiMode||'auto';
@ -465,7 +471,7 @@ function cmSetVal(v){if(!_cmView)return;var cur=_cmView.state.doc.toString();if(
function getTinyMCE(){return _tinymceEditor}
function tinyMCEContent(){return _tinymceEditor?_tinymceEditor.getContent():''}
function tinyMCESetContent(html){if(_tinymceEditor)_tinymceEditor.setContent(html)}
function tinyMCESyncToTA(){var ta=getTA();if(ta&&_tinymceEditor){var _synNoteId=_formNoteId(activeEditorForm());if(_tinymceContentNoteId&&_synNoteId&&_tinymceContentNoteId!==_synNoteId){_log('tinyMCESyncToTA skipped: TinyMCE content belongs to another note',_tinymceContentNoteId,_synNoteId);return false}var html=_tinymceEditor.getContent();var md=tinymceToMarkdown(html);if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));return true}}return false}
function tinyMCESyncToTA(){var ta=getTA();if(ta&&_tinymceEditor){var _synNoteId=_formNoteId(activeEditorForm());if(_tinymceContentNoteId&&_synNoteId&&_tinymceContentNoteId!==_synNoteId){_log('tinyMCESyncToTA skipped: TinyMCE content belongs to another note',_tinymceContentNoteId,_synNoteId);return false}var host=document.getElementById('tinymce-host');if(!host||!host.classList.contains('tinymce-host-visible')){_log('tinyMCESyncToTA skipped: TinyMCE host not visible');return false}var html=_tinymceEditor.getContent();var md=tinymceToMarkdown(html);if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));return true}}return false}
function _isMarkdownModeActive(){return _editorMode==='markdown'||_editorMode==='md'}
/* ---------------- Note export (rendered mode only): MD / HTML / DOCX / PDF ---------------- */
@ -1200,7 +1206,7 @@ function initPersistentTinyMCE(){
if(goEditable){
// Entering edit mode: honor the saved note-open preference,
// which may be markdown (read-only always shows rendered).
if(_defaultNoteOpenMode==='markdown'&&_editorMode!=='markdown'){setEditorMode('markdown');}
if(preferredEditorMode()==='markdown'&&_editorMode!=='markdown'){setEditorMode('markdown');}
else{try{editor.focus()}catch(_e){}}
}else{
// Leaving edit mode: always drop back to rendered (read-only)
@ -4441,7 +4447,7 @@ function snapshotHash(){var form=activeEditorForm();_savedHash=formHash(form);_d
function _isLockedOverlayEventTarget(target){return !!(target&&target.closest&&target.closest('#editor-locked'))}
function initEditorPanel(){var form=activeEditorForm();if(form)_displayedNoteId=_formNoteId(form);if(!form||form.dataset.editorInit)return;form.dataset.editorInit='1';if(form.dataset.shareReadonly==='1'){_applyFormReadonly(true);}_resetRingBuffer('note-switch');_dbgline('initEditorPanel begin',form.getAttribute('hx-put'));if(isMobileShellMode())closeNav();_previewDirty=false;setSaveState('','');snapshotHash();_snapshots=[];var undoBtn=queryActiveEditor('#undo-save-btn');if(undoBtn)undoBtn.hidden=true;pushSnapshot();form.addEventListener('input',function(e){if(_isLockedOverlayEventTarget(e.target))return;_dbgline('form input',{tag:e.target&&e.target.tagName,id:e.target&&e.target.id,name:e.target&&e.target.name});markEdited();scheduleSave()});form.addEventListener('change',function(e){if(_isLockedOverlayEventTarget(e.target))return;_dbgline('form change',{tag:e.target&&e.target.tagName,id:e.target&&e.target.id,name:e.target&&e.target.name});markEdited();scheduleSave()});initAutoTitle();applyMobileTitleMode();renderNoteMeta(); var ta=getTA();if(ta){ta.addEventListener('input',function(){autoTitle()});ta.addEventListener('keydown',function(e){if(_editorMode!=='markdown'&&_editorMode!=='md')return;if(e.key!=='Enter')return;var mac=navigator.platform&&navigator.platform.indexOf('Mac')!==-1;var mod=mac?e.metaKey:e.ctrlKey;if(mod){// Ctrl/Cmd+Enter = soft break (\n, same paragraph)
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+1;ta.dispatchEvent(new Event('input',{bubbles:true}))}else{// Enter = new paragraph (\n\n)
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+2;ta.dispatchEvent(new Event('input',{bubbles:true}))}})}var pendingSearch=(window._pendingNoteSearchTerm||'').trim();var mobileEditor=inMobileEditor();if(mobileEditor&&pendingSearch){var header=document.getElementById('mobile-editor-header');var searchHeader=document.getElementById('mobile-editor-search-header');if(header)header.style.display='none';if(searchHeader)searchHeader.style.display=''}var searchInput=activeSearchInput();if(searchInput&&pendingSearch&&!searchInput.value)searchInput.value=pendingSearch;window._pendingNoteSearchTerm='';/* Persistent TinyMCE: refresh content for this note (skip locked encrypted notes) */if(form.dataset.encrypted!=='1'){var _mobileRO=_tinymceReadonlyDefault();_editorMode=(_mobileRO?false:_defaultNoteOpenMode==='markdown')?'markdown':'rich';_tinymceReadonly=_mobileRO;syncEditorModeButtons();if(_editorMode==='markdown'){hideTinyMCEHost();applyEditorModeVisibility('markdown');var mdta=getTA();mountMarkdownEditor(mdta?mdta.value:'');initPersistentTinyMCE()}else{initPersistentTinyMCE();refreshTinyMCEForActiveNote()}if(pendingSearch){var _pendTerm=pendingSearch;if(_editorMode==='markdown'){setTimeout(function(){var si=activeSearchInput();if(si&&!si.value)si.value=_pendTerm;applySearchHighlight()},0)}else{/* rich: highlight is (re)applied by _setTinyMCEContent once the body is painted (covers sync + async render) */var si2=activeSearchInput();if(si2&&!si2.value)si2.value=_pendTerm;_log('initEditorPanel: setting pendingSearchHighlight, rich mode, term='+(_pendTerm||''));_pendingSearchHighlight=true;/* Fallback: if no setContent fires (e.g. same-note reopen with body already loaded), apply once the body has text. */var _tries=0;var _fb=setInterval(function(){_tries++;if(!_pendingSearchHighlight||_tries>20){clearInterval(_fb);return}var _b=_tinymceEditor&&_tinymceEditor.getBody&&_tinymceEditor.getBody();if(_b&&(_b.textContent||'').trim()&&activeSearchTerm()&&activeSearchTerm().trim()){_pendingSearchHighlight=false;clearInterval(_fb);applySearchHighlight()}},50)}}}else{hideTinyMCEHost()}}
e.preventDefault();var start=ta.selectionStart,end=ta.selectionEnd;ta.value=ta.value.slice(0,start)+'\n\n'+ta.value.slice(end);ta.selectionStart=ta.selectionEnd=start+2;ta.dispatchEvent(new Event('input',{bubbles:true}))}})}var pendingSearch=(window._pendingNoteSearchTerm||'').trim();var mobileEditor=inMobileEditor();if(mobileEditor&&pendingSearch){var header=document.getElementById('mobile-editor-header');var searchHeader=document.getElementById('mobile-editor-search-header');if(header)header.style.display='none';if(searchHeader)searchHeader.style.display=''}var searchInput=activeSearchInput();if(searchInput&&pendingSearch&&!searchInput.value)searchInput.value=pendingSearch;window._pendingNoteSearchTerm='';/* Persistent TinyMCE: refresh content for this note (skip locked encrypted notes) */if(form.dataset.encrypted!=='1'){var _mobileRO=_tinymceReadonlyDefault();_editorMode=preferredEditorMode();_tinymceReadonly=_mobileRO&&_editorMode!=='markdown';syncEditorModeButtons();if(_editorMode==='markdown'){hideTinyMCEHost();applyEditorModeVisibility('markdown');var mdta=getTA();mountMarkdownEditor(mdta?mdta.value:'');initPersistentTinyMCE()}else{initPersistentTinyMCE();refreshTinyMCEForActiveNote()}if(pendingSearch){var _pendTerm=pendingSearch;if(_editorMode==='markdown'){setTimeout(function(){var si=activeSearchInput();if(si&&!si.value)si.value=_pendTerm;applySearchHighlight()},0)}else{/* rich: highlight is (re)applied by _setTinyMCEContent once the body is painted (covers sync + async render) */var si2=activeSearchInput();if(si2&&!si2.value)si2.value=_pendTerm;_log('initEditorPanel: setting pendingSearchHighlight, rich mode, term='+(_pendTerm||''));_pendingSearchHighlight=true;/* Fallback: if no setContent fires (e.g. same-note reopen with body already loaded), apply once the body has text. */var _tries=0;var _fb=setInterval(function(){_tries++;if(!_pendingSearchHighlight||_tries>20){clearInterval(_fb);return}var _b=_tinymceEditor&&_tinymceEditor.getBody&&_tinymceEditor.getBody();if(_b&&(_b.textContent||'').trim()&&activeSearchTerm()&&activeSearchTerm().trim()){_pendingSearchHighlight=false;clearInterval(_fb);applySearchHighlight()}},50)}}}else{hideTinyMCEHost()}}
function applySearchHighlight(){var term=activeSearchTerm();_log('applySearchHighlight mode='+_editorMode+' term='+(term||''));var bar=document.getElementById('search-nav-bar');if(bar)bar.hidden=true;_searchMarks=[];_searchMarkIdx=0;var pv=queryActiveEditor('#note-preview');if(pv)clearPreviewSearchMarks(pv);clearTinyMCESearchMarks();if(!term||!term.trim()){clearCodeMirrorSearch();return}term=term.trim();if(_editorMode==='markdown'||_editorMode==='md'){_log('applySearchHighlight: markdown/CM6 branch');if(_cmView&&window.CM&&window.CM.SearchQuery&&window.CM.setSearchQuery){window.CM.openSearchPanel(_cmView);var _qParts=term.split(/\s+/).filter(Boolean);var q=_qParts.length>1?new window.CM.SearchQuery({search:_qParts.map(function(t){return escapeRegex(t)}).join('|'),caseSensitive:false,regexp:true}):new window.CM.SearchQuery({search:term,caseSensitive:false});_cmView.dispatch({effects:window.CM.setSearchQuery.of(q)});_cmSearchMatches=collectCodeMirrorSearchMatches(q);if(_cmSearchMatches.length)setCodeMirrorSearchActive(0);else searchNavShow(0,0)}}else if(_editorMode==='preview'&&pv){clearCodeMirrorSearch();var savedHandler=pv.oninput;pv.oninput=null;highlightInPreview(pv,term);pv.oninput=savedHandler}else{_log('applySearchHighlight: rich/TinyMCE branch');clearCodeMirrorSearch();highlightInTinyMCE(term)}}
function escapeRegex(s){var specials=['.','+','*','?','^','$','(',')','{','}','[',']','|','\\'];return s.split('').map(function(c){return specials.indexOf(c)>=0?'\\'+c:c}).join('')}
// Multi-term searches match each word independently, so highlight every term
@ -5714,8 +5720,22 @@ function _completeUnlock(noteId,plaintext,vaultId){
if(mdBtn)mdBtn.style.display='';
if(pvBtn)pvBtn.style.display='';
// Open in the user's preferred mode — setEditorMode handles CM6 mount / TinyMCE setContent.
setEditorMode(_defaultNoteOpenMode==='markdown'?'markdown':'rich');
// Open in the user's preferred mode.
// Locked notes skip editor init, so TinyMCE never received this note's
// plaintext. setEditorMode('markdown') would tinyMCESyncToTA() leftover
// (or empty) iframe content over the just-decrypted body — unlock then
// shows a blank editor. Seed markdown from `plaintext` directly. Rich
// mode is safe: cmSyncToTA is a no-op when CM isn't mounted, then the
// preview fetch uses ta.value (plaintext).
if(preferredEditorMode()==='markdown'){
applyEditorModeVisibility('markdown');
mountMarkdownEditor(plaintext);
_editorMode='markdown';
syncEditorModeButtons();
_reconcileSaveStateAfterModeSwitch();
}else{
setEditorMode('rich');
}
snapshotHash();
_updateLockToggle(noteId,true);

View file

@ -0,0 +1,77 @@
/**
* Settings → "Open notes in: Markdown" must actually open notes in CM6.
*
* Two regressions this pins:
* 1. `_joplockConfig` must be inlined before app.js so `_cfg.noteOpenMode`
* is set when app.js parses (otherwise it sticks at 'preview').
* 2. initEditorPanel must not let mobile read-only (`_mobileRO`) force
* rendered mode — that made the setting a no-op on tablet/narrow windows.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in app.js`);
let depth = 0, i = start;
while (i < appSrc.length) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
i++;
}
throw new Error(`Could not find closing brace for function ${name}`);
}
test('preferredEditorMode reads live _joplockConfig, not only the parse-time snapshot', () => {
const src = extractFn('preferredEditorMode');
assert.ok(src.includes('window._joplockConfig'), 'must re-read live config');
assert.ok(src.includes("==='markdown'?'markdown':'rich'"), 'markdown preference must map to markdown mode');
});
test('initEditorPanel uses preferredEditorMode and does not let mobile RO force rich', () => {
const src = extractFn('initEditorPanel');
assert.ok(src.includes('preferredEditorMode()'), 'initEditorPanel must honor Settings noteOpenMode via preferredEditorMode()');
assert.ok(!src.includes('_mobileRO?false:_defaultNoteOpenMode'), 'must not let mobile read-only override the open-mode preference');
assert.ok(src.includes('_tinymceReadonly=_mobileRO&&_editorMode!==\'markdown\''), 'mobile read-only applies only when the note opens rendered');
});
test('preferredEditorMode: live markdown config wins over a stale preview snapshot', () => {
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: 'https://joplock.test' });
const ctx = vm.createContext({
window: dom.window,
document: dom.window.document,
_defaultNoteOpenMode: 'preview',
});
dom.window._joplockConfig = { noteOpenMode: 'markdown' };
vm.runInContext(extractFn('preferredEditorMode'), ctx);
assert.equal(vm.runInContext('preferredEditorMode()', ctx), 'markdown');
});
test('preferredEditorMode: missing live config falls back to snapshot', () => {
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: 'https://joplock.test' });
const ctx = vm.createContext({
window: dom.window,
document: dom.window.document,
_defaultNoteOpenMode: 'markdown',
});
vm.runInContext(extractFn('preferredEditorMode'), ctx);
assert.equal(vm.runInContext('preferredEditorMode()', ctx), 'markdown');
});
test('preferredEditorMode: preview / unknown → rich', () => {
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: 'https://joplock.test' });
const ctx = vm.createContext({
window: dom.window,
document: dom.window.document,
_defaultNoteOpenMode: 'preview',
});
dom.window._joplockConfig = { noteOpenMode: 'preview' };
vm.runInContext(extractFn('preferredEditorMode'), ctx);
assert.equal(vm.runInContext('preferredEditorMode()', ctx), 'rich');
});

View file

@ -116,6 +116,14 @@ test('settingsService returns defaults when table creation fails', async () => {
assert.deepEqual(settings, defaultSettings);
});
test('normalizeSettings defaults noteOpenMode to markdown', () => {
assert.equal(defaultSettings.noteOpenMode, 'markdown');
assert.equal(normalizeSettings({ noteOpenMode: 'markdown' }).noteOpenMode, 'markdown');
assert.equal(normalizeSettings({ noteOpenMode: 'preview' }).noteOpenMode, 'preview', 'explicit rendered preference must be kept');
assert.equal(normalizeSettings({ noteOpenMode: 'nope' }).noteOpenMode, 'markdown');
assert.equal(normalizeSettings({}).noteOpenMode, 'markdown');
});
test('normalizeSettings clamps autoLogoutMinutes', () => {
assert.equal(normalizeSettings({ autoLogoutMinutes: 0 }).autoLogoutMinutes, 1);
assert.equal(normalizeSettings({ autoLogoutMinutes: 999 }).autoLogoutMinutes, 480);

View file

@ -723,16 +723,20 @@ test('editorFragment uses openUploadModal for upload toolbar action', () => {
});
test('logged in layout emits inline config script that parses', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '<div></div>' });
// The last script before </body> is now just the config object
const match = html.match(/<script>\s*(window\._joplockConfig[\s\S]*?)<\/script>\s*<\/body>/);
assert.ok(match, 'should have inline config script before </body>');
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '<div></div>', settings: { noteOpenMode: 'markdown' } });
const match = html.match(/<script>\s*(window\._joplockConfig[\s\S]*?)<\/script>/);
assert.ok(match, 'should have inline config script');
assert.doesNotThrow(() => new vm.Script(match[1]));
assert.ok(match[1].includes('window._joplockConfig'));
assert.ok(match[1].includes('noteOpenMode'));
assert.ok(match[1].includes('"markdown"'), 'markdown preference must be injected into _joplockConfig');
assert.ok(match[1].includes('liveSearch'));
assert.ok(!match[1].includes('proseAutocompleteManualTrigger'));
assert.ok(!match[1].includes('proseAutocompleteManualTriggerOptions'));
// Config MUST precede app.js so `_cfg.noteOpenMode` is set when app.js parses.
const cfgIdx = html.indexOf('window._joplockConfig');
const appIdx = html.indexOf('/app.js');
assert.ok(cfgIdx !== -1 && appIdx !== -1 && cfgIdx < appIdx, '_joplockConfig must be defined before app.js loads');
// Functions are in app.js, not inline
assert.ok(!html.includes('function openFolderContextMenu(event,id,title)'));
assert.ok(html.includes('/app.js'));

View file

@ -0,0 +1,197 @@
/**
* Regression: unlocking an encrypted note with markdown as the preferred
* open mode showed a blank editor.
*
* Root cause: locked notes skip editor init, so TinyMCE never received this
* note's plaintext (and `#tinymce-host` stays hidden). `_completeUnlock`
* used to call `setEditorMode('markdown')`, which `tinyMCESyncToTA()`s the
* leftover/empty iframe over the just-decrypted textarea, then mounts CM6
* from that wiped body.
*
* Pins:
* * `_completeUnlock` seeds markdown from the `plaintext` argument.
* * `tinyMCESyncToTA` refuses to write when the TinyMCE host is hidden
* (empty `_tinymceContentNoteId` is otherwise permissive).
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `function ${name} not found in app.js`);
let depth = 0, i = start;
while (i < appSrc.length) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
i++;
}
throw new Error(`Could not find closing brace for function ${name}`);
}
const NOTE_ID = 'a'.repeat(32);
const VAULT_ID = 'b'.repeat(32);
// ---------------------------------------------------------------------------
// Static wiring
// ---------------------------------------------------------------------------
test('_completeUnlock markdown path seeds CM6 from plaintext, not setEditorMode', () => {
const src = extractFn('_completeUnlock');
assert.ok(src.includes("if(preferredEditorMode()==='markdown')"), '_completeUnlock must branch on markdown preference');
assert.ok(src.includes('mountMarkdownEditor(plaintext)'), '_completeUnlock must mount CM6 from the decrypted plaintext argument');
assert.ok(!src.includes("setEditorMode(_defaultNoteOpenMode==='markdown'?'markdown':'rich')"), '_completeUnlock must not route markdown unlock through setEditorMode (that tinyMCESyncToTA()s leftover iframe content over the decrypted body)');
assert.ok(src.includes("setEditorMode('rich')"), 'rich-preference unlock may still use setEditorMode (cmSyncToTA is a no-op when CM is unmounted)');
});
test('tinyMCESyncToTA skips when the TinyMCE host is not visible', () => {
const src = extractFn('tinyMCESyncToTA');
assert.ok(src.includes("tinyMCESyncToTA skipped: TinyMCE host not visible"), 'tinyMCESyncToTA must refuse to write when #tinymce-host is hidden');
assert.ok(src.includes('tinymce-host-visible'), 'visibility check must use the tinymce-host-visible class');
});
// ---------------------------------------------------------------------------
// Behavioral
// ---------------------------------------------------------------------------
function makeUnlockDom({ hostVisible = false } = {}) {
const hostClass = hostVisible ? 'tinymce-host tinymce-host-visible' : 'tinymce-host';
return new JSDOM(`<!DOCTYPE html>
<body>
<form id="note-editor-form" class="editor-form"
data-note-id="${NOTE_ID}" data-encrypted="1" data-vault-id="${VAULT_ID}"
hx-put="/fragments/editor/${NOTE_ID}">
<div class="editor-locked" id="editor-locked">locked</div>
<div class="editor-toolbar" id="editor-toolbar" style="display:none"></div>
<button type="button" id="markdown-toggle" style="display:none">MD</button>
<button type="button" id="preview-toggle" style="display:none">PV</button>
<textarea name="body" class="editor-body" id="note-body">CIPHERTEXT</textarea>
<div id="cm-host" class="cm-host" style="display:none"></div>
<div id="tinymce-slot" class="tinymce-slot"></div>
</form>
<div id="tinymce-host" class="${hostClass}"></div>
</body>`, { url: 'https://joplock.test' });
}
function makeSyncSandbox({ hostVisible, taValue, tinymceHtml, contentNoteId = '' }) {
const dom = makeUnlockDom({ hostVisible });
const ta = dom.window.document.getElementById('note-body');
ta.value = taValue;
const ctx = vm.createContext({
document: dom.window.document,
window: dom.window,
Event: dom.window.Event,
_tinymceEditor: { getContent() { return tinymceHtml; } },
_tinymceContentNoteId: contentNoteId,
_logs: [],
});
vm.runInContext(`
function activeEditorForm(){return document.getElementById('note-editor-form')}
function _formNoteId(form){return (form&&form.dataset&&form.dataset.noteId)||''}
function getTA(){return document.getElementById('note-body')}
function tinymceToMarkdown(html){return String(html||'').replace(/<[^>]+>/g,'').trim()}
function _log(){ _logs.push([].slice.call(arguments).join(' ')); }
`, ctx);
vm.runInContext(extractFn('tinyMCESyncToTA'), ctx);
return { ctx, ta };
}
test('tinyMCESyncToTA does not clobber textarea when TinyMCE host is hidden', () => {
const { ctx, ta } = makeSyncSandbox({
hostVisible: false,
taValue: 'just decrypted plaintext',
tinymceHtml: '<p></p>',
contentNoteId: '',
});
const changed = vm.runInContext('tinyMCESyncToTA()', ctx);
assert.equal(changed, false, 'must report no change');
assert.equal(ta.value, 'just decrypted plaintext', 'hidden TinyMCE leftover/empty content must not overwrite decrypted plaintext');
});
test('tinyMCESyncToTA still syncs when TinyMCE host is visible (rich→markdown switch)', () => {
const { ctx, ta } = makeSyncSandbox({
hostVisible: true,
taValue: 'stale textarea',
tinymceHtml: '<p>live rich edits</p>',
contentNoteId: NOTE_ID,
});
const changed = vm.runInContext('tinyMCESyncToTA()', ctx);
assert.equal(changed, true, 'visible TinyMCE must sync into the textarea');
assert.equal(ta.value, 'live rich edits', 'visible TinyMCE content is the source of truth for a real mode switch');
});
function makeUnlockSandbox({ openMode = 'markdown' } = {}) {
const dom = makeUnlockDom({ hostVisible: false });
const mounted = [];
const ctx = vm.createContext({
document: dom.window.document,
window: dom.window,
Event: dom.window.Event,
_defaultNoteOpenMode: openMode,
_editorMode: 'rich',
_tinymceEditor: { getContent() { return '<p>LEFTOVER FROM PREVIOUS NOTE</p>'; } },
_tinymceContentNoteId: '',
_mounted: mounted,
_saveTimer: null,
_savedHash: 0,
SVG_LOCK_OPEN: 'open',
SVG_LOCK_CLOSED: 'closed',
});
vm.runInContext(`
function activeEditorForm(){return document.getElementById('note-editor-form')}
function _formNoteId(form){return (form&&form.dataset&&form.dataset.noteId)||''}
function _activeEditorNoteId(){return _formNoteId(activeEditorForm())}
function getTA(){return document.getElementById('note-body')}
function queryActiveEditor(sel){var f=activeEditorForm();return f&&f.querySelector?f.querySelector(sel):null}
function touchVaultActivity(){}
function _log(){}
function applyEditorModeVisibility(mode){
var host=queryActiveEditor('#cm-host');
var ta=getTA();
if(mode==='markdown'){if(host)host.style.display='';if(ta)ta.style.display='none'}
}
function mountMarkdownEditor(content){ _mounted.push(content); }
function syncEditorModeButtons(){}
function _reconcileSaveStateAfterModeSwitch(){}
function snapshotHash(){ _savedHash = 1; }
function _updateLockToggle(){}
function _updateNoteLockIcon(){}
function _refreshVaultIcon(){}
function setEditorMode(mode){
// Deliberately the OLD clobbering behaviour, so the test fails if
// _completeUnlock still routes markdown unlock through this.
var ta=getTA();
if(mode==='markdown'&&ta) ta.value='CLOBBERED BY TINYMCE SYNC';
_editorMode=mode;
}
function preferredEditorMode(){return _defaultNoteOpenMode==='markdown'?'markdown':'rich'}
function tinymceToMarkdown(html){return String(html||'').replace(/<[^>]+>/g,'').trim()}
`, ctx);
vm.runInContext(extractFn('_completeUnlock'), ctx);
return { ctx, dom, mounted };
}
test('_completeUnlock with markdown preference keeps decrypted plaintext and mounts it', () => {
const { ctx, dom, mounted } = makeUnlockSandbox({ openMode: 'markdown' });
vm.runInContext(`_completeUnlock(${JSON.stringify(NOTE_ID)}, 'hello from the vault', ${JSON.stringify(VAULT_ID)})`, ctx);
const ta = dom.window.document.getElementById('note-body');
assert.equal(ta.value, 'hello from the vault', 'textarea must keep decrypted plaintext');
assert.deepEqual(mounted, ['hello from the vault'], 'CM6 must be seeded from decrypted plaintext');
assert.equal(ctx._editorMode, 'markdown');
assert.equal(dom.window.document.getElementById('editor-locked').style.display, 'none', 'lock overlay must hide');
assert.equal(dom.window.document.getElementById('editor-toolbar').style.display, '', 'toolbar must show');
});
test('_completeUnlock with rendered preference still uses setEditorMode(rich)', () => {
const { ctx, dom, mounted } = makeUnlockSandbox({ openMode: 'preview' });
vm.runInContext(`_completeUnlock(${JSON.stringify(NOTE_ID)}, 'hello from the vault', ${JSON.stringify(VAULT_ID)})`, ctx);
const ta = dom.window.document.getElementById('note-body');
assert.equal(ta.value, 'hello from the vault', 'rich unlock must not clobber plaintext either');
assert.equal(mounted.length, 0, 'markdown editor must not mount when preference is rendered');
assert.equal(ctx._editorMode, 'rich');
});