fix mobile resume startup and editor targeting

This commit is contained in:
igor 2026-04-25 20:51:37 +12:00
commit d606ecf60f
81 changed files with 18060 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
node_modules
npm-debug.log*
data
.env

55
.github/workflows/docker-publish.yml vendored Normal file
View file

@ -0,0 +1,55 @@
name: Build and push Joplock image
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/joplock
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=sha,format=short
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
npm-debug.log*
.env
.DS_Store
data/
docker-compose.dev.yml

382
AGENT_GUIDE.md Normal file
View file

@ -0,0 +1,382 @@
# Joplock Agent Guide
<!-- cSpell:disable -->
## 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
- **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 Turndown `htmlToMarkdown()`
- **Auth**: reuses Joplin Server `sessionId` cookie
- **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()` in `app/templates.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 inline 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
1. Browser hits Joplock
2. Joplock validates `sessionId` against Joplin session/user tables
3. Fragment endpoints return HTML chunks; htmx swaps DOM
4. Writes serialize note/folder/resource and send upstream to stock Joplin Server API
### Main UI Flow
1. `GET /` renders the full shell
2. Navigation / notes / editor content is loaded from fragment endpoints
3. Selecting a folder swaps the notes list or nav tree fragment
4. Selecting a note swaps in `editorFragment()`
5. Autosave sends `PUT /fragments/editor/:id` with the current form state
6. Preview rendering uses `POST /fragments/preview`
### Fragment Conventions
- `templates.js` returns 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
1. Do not modify Joplin Server source for Joplock features unless explicitly approved.
2. Server authoritative. Browser ephemeral.
3. Preserve sync compatibility with normal Joplin clients.
4. Do not build browser-local authoritative storage.
5. Keep sidecar API app-oriented. Do not expose raw sync/storage model to frontend.
6. Treat logout as client cleanup event on shared machines.
## 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 startup
- `app/createServer.js` — all HTTP routes, SSR, preview, upload, resources, auth pages
### Templates / UI
- `app/templates.js` — SSR HTML, inline client JS, markdown rendering, editor mode logic
Important subareas inside `templates.js`:
- `settingsPage()` — Settings UI and simple client save helpers
- `editorFragment()` — shared editor DOM used by desktop and mobile
- `layoutPage()` — logged-in app shell, inline client JS, mobile shell, autosave/editor wiring
- `renderMarkdown()` — server-side markdown-to-HTML for preview/render mode
- mobile fragments and shell logic — folder-first mobile UI, note list, search, editor screen stack
### Auth
- `app/auth/cookies.js` — cookie parsing
- `app/auth/sessionService.js` — shared DB session lookup
- `app/auth/mfaService.js` — env-driven TOTP verification and otpauth/QR generation
### Data
- `app/items/itemService.js` — DB reads for folders, notes, search, resources
- `app/items/itemWriteService.js` — note/folder/resource serialization and upstream writes
- `app/settingsService.js` — Joplock-owned settings table access
### 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.js`
- `public/codemirror.min.js` — CM6 bundle with 11 language parsers (built from `cm-build/`)
- `public/hljs.min.js` — highlight.js bundle for preview mode code highlighting (built from `hljs-build/`)
- `public/styles.css`
- `public/service-worker.js`
- `public/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
- `Dockerfile`
- `docker-compose.yml` — sidecar-only example
- `docker-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_seed` in the shared Postgres DB.
- No global/shared TOTP seed. The old `JOPLOCK_TOTP_SEED` / `JOPLOCK_TOTP_ISSUER` env vars are removed.
- `IGNORE_ADMIN_MFA=true` skips 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-body` is 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()`
- The title is mirrored between `.editor-title`, hidden title input, and mobile title header when applicable
### Save lifecycle
- `markEdited()` updates UI state to `Edited`
- `scheduleSave()` triggers delayed autosave for body/form changes
- `scheduleSaveTitle()` is a shorter timer for title changes
- `htmx:afterRequest` on the editor save path transitions UI state back to `Saved`
- Offline/request failure paths set status to `Offline`
### 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
### 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
### 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.settings` as JSONB
- `app/settingsService.js` owns defaults and normalization
- Unknown or invalid values should normalize back to safe defaults
### Current notable settings
- `theme`
- `noteFontSize`
- `mobileNoteFontSize`
- `codeFontSize`
- `noteMonospace`
- `noteOpenMode`
- `dateFormat`
- `datetimeFormat`
- `liveSearch`
- `autoLogout`
- `autoLogoutMinutes`
### Adding a new setting
1. Add default + normalization in `app/settingsService.js`
2. Allow the key in `/api/web/settings` in `app/createServer.js`
3. Add the UI in `settingsPage()` in `app/templates.js`
4. If needed, inject the normalized setting into `layoutPage()` client JS
5. 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:
1. Which endpoint it hits
2. Which htmx target it swaps
3. Which client event handler expects to run after swap/request
4. Whether the response includes headers or OOB fragments the client depends on
## Coding Guidance
- Keep changes minimal
- Preserve sidecar/frontend boundary
- Inline JS in `templates.js` is fragile; validate escaping-heavy changes carefully
- The code modal lives in `loggedInLayout`, not inside `navigationFragment` or `editorFragment`, 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:afterRequest` assumptions; in htmx 2.x, response headers are often more reliable than old event-property assumptions
## 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 joplock` after 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:afterRequest` handler that consumes that header
- Compare new-note path vs existing-note path
### If toolbar/mode behavior is inconsistent
- Verify whether the current editor is actually inside `#mobile-editor-body`
- Check `syncEditorModeButtons()` and `setEditorMode()`
- Check whether the note was initialized with the expected `noteOpenMode`
### If title UI drifts
- Check `.editor-title`
- Check hidden input `.editor-title-hidden`
- Check `#mobile-editor-title`
- Check `autoTitle()` and `syncTitle()`
### 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 joplock` is not enough after source edits because the Docker image copies `app/`, `public/`, and `server.js` at build time.
- For app code changes, use `./scripts/rebuild-dev.sh` from now on.
Recommended inner loop:
1. Edit source
2. Rebuild with `./scripts/rebuild-dev.sh`
3. Refresh the app
4. Check `docker compose -f docker-compose.dev.yml logs --tail=... joplock` if 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

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./package.json
COPY package-lock.json ./package-lock.json
RUN npm install --omit=dev
COPY app ./app
COPY server.js ./server.js
COPY public ./public
EXPOSE 3001
CMD ["node", "server.js"]

69
README.md Normal file
View file

@ -0,0 +1,69 @@
# Joplock
A secure, fast web client for [Joplin Server](https://github.com/laurent22/joplin).
Joplock runs as a sidecar alongside an unmodified Joplin Server instance, sharing the same Postgres database, sessions, notes, folders, and resources. It gives you a lightweight browser-based interface to your Joplin notes without modifying Joplin Server itself. Keep using the other Joplin clients too, this won't interfere.
### Key Features
- **Full Joplin compatibility** -- desktop, mobile, CLI, and Joplock all work with the same account and data simultaneously
- **Low Resource usage** -- minimal memory usage on the client, fast and responsive
- **Security-first design** -- no private data stored on the client; sessions are cleaned up on logout; per-user settings and admin controls for user management
- **User creation from Joplock UI** -- create and modify users directly from Joplock settings page
- **Multi-factor authentication** -- optional TOTP-based MFA on top of standard Joplin sessions
- **Fast search** -- searches titles and note bodies directly in Postgres; optional live-as-you-type search
- **Near-instant autosave** -- debounced saves with conflict detection, hash-based deduplication, and an undo ring buffer with full note history snapshots
- **PWA support** -- installable as a home screen app on mobile and desktop with splash screens, offline indicator, and service worker shell
- **Server-side rendering** -- SSR with htmx for minimal client-side JavaScript; CodeMirror editor for markdown, rich preview mode with WYSIWYG editing
## Runtime Model
Joplock:
- reads Joplin data directly from the shared Postgres database
- validates the same `sessionId` cookie used by Joplin Server
- writes notes, folders, and resources through stock Joplin Server APIs
That keeps desktop, mobile, CLI, and Joplock compatible with the same account and data.
## Requirements
- docker
- an existng Joplin Server instance, or run the fullstack option
## Environment
All configuration is done directly in the compose files via inline environment variables with comments. No `.env` file is needed -- just edit the values in `docker-compose.yml` or `docker-compose.example-full.yml` before starting.
## Docker
Published container image:
- `ghcr.io/abort-retry-ignore/joplock:latest`
### Sidecar Install
Use this when you already have Joplin Server and Postgres running elsewhere. Edit the environment values in `docker-compose.yml` to point at your existing setup, or copy into your existing compose. Then:
```bash
docker compose up -d
```
This pulls the pre-built image from GitHub Container Registry. To build from source instead:
```bash
docker compose -f docker-compose-build.yml up -d --build
```
On Linux, the compose files map `host.docker.internal` to the host gateway so Joplock can reach host services by default.
### Full Example Stack
Use this as a reference/demo stack with Postgres, Joplin Server, and Joplock together. Edit the values in `docker-compose.example-full.yml` as needed, then:
```bash
docker compose -f docker-compose.example-full.yml up -d
```
The full example uses the public `joplin/server:latest` image. Joplock is exposed on `http://localhost:5444` by default. Joplin Server is internal-only unless you add a port mapping.
The full example is meant as a working reference compose file. Adjust it for your real deployment.

266
app/adminService.js Normal file
View file

@ -0,0 +1,266 @@
'use strict';
const http = require('http');
const bcrypt = require('bcryptjs');
// ---------- password strength ----------
const isStrongPassword = password => {
if (!password || password.length < 12) return false;
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /[0-9]/.test(password);
const hasSpecial = /[^a-zA-Z0-9]/.test(password);
const categories = [hasLower, hasUpper, hasDigit, hasSpecial].filter(Boolean).length;
return categories >= 3;
};
// ---------- HTTP helper to Joplin internal API ----------
const requestJoplin = (origin, method, path, body, sessionId, publicUrl) => new Promise((resolve, reject) => {
const url = new URL(origin);
const pub = publicUrl ? new URL(publicUrl) : url;
const payload = body ? JSON.stringify(body) : null;
const headers = {
'Content-Type': 'application/json',
Host: pub.host,
Origin: pub.origin,
'X-Forwarded-Host': pub.host,
'X-Forwarded-Proto': pub.protocol.replace(':', ''),
};
if (sessionId) headers['x-api-auth'] = sessionId;
if (payload) headers['Content-Length'] = Buffer.byteLength(payload);
const req = http.request({
hostname: url.hostname,
port: url.port,
path,
method,
headers,
}, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json = null;
try { json = JSON.parse(text); } catch {}
resolve({ statusCode: res.statusCode, body: json, raw: text });
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
const createAdminService = ({ database, joplinServerOrigin, joplinServerPublicUrl, adminEmail, adminPassword }) => {
// token cache
let _token = null;
let _tokenExpiry = 0;
const TOKEN_TTL_MS = 11 * 60 * 60 * 1000; // 11h
const getAdminToken = async () => {
if (_token && Date.now() < _tokenExpiry) return _token;
const res = await requestJoplin(joplinServerOrigin, 'POST', '/api/sessions', {
email: adminEmail,
password: adminPassword,
}, null, joplinServerPublicUrl);
if (!res.body || !res.body.id) {
throw new Error(`Admin login failed (${res.statusCode}): ${res.raw}`);
}
_token = res.body.id;
_tokenExpiry = Date.now() + TOKEN_TTL_MS;
return _token;
};
// ---------- Bootstrap: ensure admin user exists ----------
const ensureAdminUser = async () => {
// Validate password strength first
if (!isStrongPassword(adminPassword)) {
process.stderr.write('[joplock] FATAL: JOPLOCK_ADMIN_PASSWORD is too weak. Must be ≥12 chars with letters, numbers, and at least 3 character categories.\n');
process.exit(1);
}
// Check if admin user exists in Joplin DB
let rows;
try {
const result = await database.query(
'SELECT id, email, is_admin, enabled FROM users WHERE email = $1 LIMIT 1',
[adminEmail],
);
rows = result.rows;
} catch (err) {
process.stderr.write(`[joplock] WARNING: Could not query users table for admin bootstrap: ${err.message}\n`);
return;
}
if (rows.length === 0) {
// Fresh install — create user directly in DB
process.stdout.write(`[joplock] Creating admin user ${adminEmail}...\n`);
const passwordHash = await bcrypt.hash(adminPassword, 10);
const now = Date.now();
const id = require('crypto').randomBytes(16).toString('hex');
try {
await database.query(`
INSERT INTO users (id, email, password, is_admin, enabled, email_confirmed, created_time, updated_time, must_set_password, account_type, max_item_size, can_share_folder, can_share_note, max_total_item_size)
VALUES ($1, $2, $3, 1, 1, 1, $4, $4, 0, 0, 0, 1, 1, 0)
`, [id, adminEmail, passwordHash, now]);
process.stdout.write(`[joplock] Admin user created.\n`);
} catch (err) {
// Fallback: minimal insert if some columns don't exist
try {
await database.query(`
INSERT INTO users (id, email, password, is_admin, enabled, email_confirmed, created_time, updated_time)
VALUES ($1, $2, $3, 1, 1, 1, $4, $4)
`, [id, adminEmail, passwordHash, now]);
process.stdout.write(`[joplock] Admin user created (minimal).\n`);
} catch (err2) {
process.stderr.write(`[joplock] ERROR: Could not create admin user: ${err2.message}\n`);
}
}
} else {
const user = rows[0];
const needsFix = !Number(user.is_admin) || !Number(user.enabled);
// Always reset password to match docker-defined JOPLOCK_ADMIN_PASSWORD
const passwordHash = await bcrypt.hash(adminPassword, 10);
const now = Date.now();
try {
await database.query(
'UPDATE users SET password=$1, is_admin=1, enabled=1, updated_time=$2 WHERE email=$3',
[passwordHash, now, adminEmail],
);
if (needsFix) {
process.stdout.write(`[joplock] Admin user fixed (is_admin/enabled) and password reset.\n`);
}
} catch (err) {
process.stderr.write(`[joplock] WARNING: Could not reset admin password: ${err.message}\n`);
}
}
};
// ---------- User operations ----------
const listUsers = async () => {
const token = await getAdminToken();
const res = await requestJoplin(joplinServerOrigin, 'GET', '/api/users', null, token, joplinServerPublicUrl);
if (!res.body || !res.body.items) throw Object.assign(new Error('Failed to list users'), { statusCode: res.statusCode });
// Augment with enabled flag from DB
const apiUsers = res.body.items;
const ids = apiUsers.map(u => u.id);
let enabledMap = {};
if (ids.length) {
try {
const result = await database.query(
`SELECT id, enabled FROM users WHERE id = ANY($1)`,
[ids],
);
for (const row of result.rows) {
enabledMap[row.id] = !!Number(row.enabled);
}
} catch {}
}
return apiUsers.map(u => ({ ...u, enabled: enabledMap[u.id] !== undefined ? enabledMap[u.id] : true }));
};
const createUser = async (email, fullName, password) => {
const token = await getAdminToken();
// Step 1: create user (Joplin sets a random password + must_set_password=1)
const createRes = await requestJoplin(joplinServerOrigin, 'POST', '/api/users', { email, full_name: fullName }, token, joplinServerPublicUrl);
if (!createRes.body || !createRes.body.id) {
throw Object.assign(new Error(createRes.body && createRes.body.error ? createRes.body.error : 'Create user failed'), { statusCode: createRes.statusCode });
}
const userId = createRes.body.id;
// Step 2: set real password + clear must_set_password
const passwordRes = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
password,
must_set_password: 0,
}, token, joplinServerPublicUrl);
if (passwordRes.statusCode >= 400) {
throw Object.assign(new Error(passwordRes.body && passwordRes.body.error ? passwordRes.body.error : 'Set password failed'), { statusCode: passwordRes.statusCode });
}
// Step 3: confirm email via direct DB write
try {
await database.query('UPDATE users SET email_confirmed=1, enabled=1 WHERE id=$1', [userId]);
} catch {}
return { id: userId, email, full_name: fullName };
};
const resetPassword = async (userId, newPassword) => {
const token = await getAdminToken();
const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
password: newPassword,
must_set_password: 0,
}, token, joplinServerPublicUrl);
if (res.statusCode >= 400) {
throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Reset password failed'), { statusCode: res.statusCode });
}
};
const setEnabled = async (userId, enabled) => {
await database.query('UPDATE users SET enabled=$1 WHERE id=$2', [enabled ? 1 : 0, userId]);
};
const deleteUser = async (userId) => {
// Disable first, then schedule deletion via user_deletions table
await setEnabled(userId, false);
const now = Date.now();
try {
await database.query(`
INSERT INTO user_deletions (id, owner_id, process_data, process_account, scheduled_time)
VALUES ($1, $2, 1, 1, $3)
ON CONFLICT DO NOTHING
`, [require('crypto').randomBytes(16).toString('hex'), userId, now]);
} catch (err) {
// Try without ON CONFLICT if old schema
try {
await database.query(`
INSERT INTO user_deletions (id, owner_id, process_data, process_account, scheduled_time)
VALUES ($1, $2, 1, 1, $3)
`, [require('crypto').randomBytes(16).toString('hex'), userId, now]);
} catch (err2) {
process.stderr.write(`[joplock] WARNING: Could not insert user_deletions: ${err2.message}\n`);
}
}
};
const updateProfile = async (sessionToken, userId, { fullName, email }) => {
const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
...(fullName !== undefined ? { full_name: fullName } : {}),
...(email !== undefined ? { email } : {}),
}, sessionToken, joplinServerPublicUrl);
if (res.statusCode >= 400) {
throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Update profile failed'), { statusCode: res.statusCode });
}
return res.body;
};
const verifyPassword = async (email, password) => {
const res = await requestJoplin(joplinServerOrigin, 'POST', '/api/sessions', { email, password }, null, joplinServerPublicUrl);
if (res.statusCode < 200 || res.statusCode >= 300) return null;
return res.body && res.body.id ? res.body.id : null;
};
const changePassword = async (sessionToken, userId, newPassword) => {
const res = await requestJoplin(joplinServerOrigin, 'PATCH', `/api/users/${userId}`, {
password: newPassword,
must_set_password: 0,
}, sessionToken, joplinServerPublicUrl);
if (res.statusCode >= 400) {
throw Object.assign(new Error(res.body && res.body.error ? res.body.error : 'Change password failed'), { statusCode: res.statusCode });
}
};
return {
ensureAdminUser,
getAdminToken,
listUsers,
createUser,
resetPassword,
setEnabled,
deleteUser,
updateProfile,
verifyPassword,
changePassword,
adminEmail,
};
};
module.exports = { createAdminService, isStrongPassword };

27
app/auth/cookies.js Normal file
View file

@ -0,0 +1,27 @@
const parseCookies = cookieHeader => {
if (!cookieHeader) return {};
const output = {};
for (const part of cookieHeader.split(';')) {
const trimmed = part.trim();
if (!trimmed) continue;
const separatorIndex = trimmed.indexOf('=');
if (separatorIndex < 0) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim();
if (!key) continue;
output[key] = decodeURIComponent(value);
}
return output;
};
const sessionIdFromHeaders = (headers, cookieName = 'sessionId') => {
const cookies = parseCookies(headers.cookie || '');
return cookies[cookieName] || '';
};
module.exports = {
parseCookies,
sessionIdFromHeaders,
};

150
app/auth/mfaService.js Normal file
View file

@ -0,0 +1,150 @@
const crypto = require('crypto');
const qrImage = require('qr-image');
const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const normalizeSeed = seed => `${seed || ''}`.trim().toUpperCase().replace(/\s+/g, '');
const base32Decode = value => {
const normalized = normalizeSeed(value).replace(/=+$/g, '');
let bits = '';
for (const char of normalized) {
const index = base32Alphabet.indexOf(char);
if (index < 0) throw new Error('Invalid TOTP seed');
bits += index.toString(2).padStart(5, '0');
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
}
return Buffer.from(bytes);
};
const base32Encode = buffer => {
let bits = '';
for (const byte of buffer) bits += byte.toString(2).padStart(8, '0');
let result = '';
for (let i = 0; i < bits.length; i += 5) {
const chunk = bits.slice(i, i + 5).padEnd(5, '0');
result += base32Alphabet[Number.parseInt(chunk, 2)];
}
return result;
};
const generateSeed = () => {
// 20 bytes = 160 bits, standard for TOTP
const bytes = crypto.randomBytes(20);
return base32Encode(bytes);
};
const hotp = (secret, counter) => {
const buf = Buffer.alloc(8);
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
buf.writeUInt32BE(counter % 0x100000000, 4);
const digest = crypto.createHmac('sha1', secret).update(buf).digest();
const offset = digest[digest.length - 1] & 0x0f;
const binary = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff);
return `${binary % 1000000}`.padStart(6, '0');
};
// Verify TOTP code against arbitrary seed
const verifyWithSeed = (seed, code, now = Date.now()) => {
if (!seed) return false;
const token = `${code || ''}`.replace(/\s+/g, '');
if (!/^\d{6}$/.test(token)) return false;
try {
const secret = base32Decode(seed);
const counter = Math.floor(now / 30000);
for (let offset = -1; offset <= 1; offset++) {
if (hotp(secret, counter + offset) === token) return true;
}
} catch {
return false;
}
return false;
};
// Generate otpauth URI for arbitrary seed
const otpauthUri = (seed, accountLabel, issuer = 'Joplock') => {
if (!seed) return '';
const normalizedSeed = normalizeSeed(seed);
const label = encodeURIComponent(`${issuer}:${accountLabel}`);
return `otpauth://totp/${label}?secret=${normalizedSeed}&issuer=${encodeURIComponent(issuer)}`;
};
// Generate QR code as SVG data URL (synchronous for template use)
let _qrCache = new Map();
const qrCodeDataUrl = (text) => {
if (!text) return '';
if (_qrCache.has(text)) return _qrCache.get(text);
let svg = '';
try {
svg = qrImage.imageSync(text, { type: 'svg', margin: 2 });
} catch {
return '';
}
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
_qrCache.set(text, dataUrl);
// Limit cache size
if (_qrCache.size > 100) {
const first = _qrCache.keys().next().value;
_qrCache.delete(first);
}
return dataUrl;
};
const createMfaService = options => {
const seed = normalizeSeed(options.seed);
const issuer = options.issuer || 'Joplock';
const enabled = !!seed;
const secret = enabled ? base32Decode(seed) : null;
return {
enabled() {
return enabled;
},
issuer() {
return issuer;
},
otpauthUri(accountLabel) {
if (!enabled) return '';
const label = encodeURIComponent(`${issuer}:${accountLabel}`);
return `otpauth://totp/${label}?secret=${seed}&issuer=${encodeURIComponent(issuer)}`;
},
verify(code, now = Date.now()) {
if (!enabled) return true;
const token = `${code || ''}`.replace(/\s+/g, '');
if (!/^\d{6}$/.test(token)) return false;
const counter = Math.floor(now / 30000);
for (let offset = -1; offset <= 1; offset++) {
if (hotp(secret, counter + offset) === token) return true;
}
return false;
},
maskedSeed() {
if (!enabled) return '';
return seed;
},
qrDataUrl(accountLabel) {
if (!enabled) return '';
return qrCodeDataUrl(this.otpauthUri(accountLabel));
},
};
};
module.exports = {
base32Decode,
base32Encode,
createMfaService,
generateSeed,
hotp,
normalizeSeed,
otpauthUri,
qrCodeDataUrl,
verifyWithSeed,
};

View file

@ -0,0 +1,73 @@
const { Pool } = require('pg');
const defaultSessionTtlMs = 12 * 60 * 60 * 1000;
const createPoolFromEnv = env => {
return new Pool({
host: env.POSTGRES_HOST || '127.0.0.1',
port: Number(env.POSTGRES_PORT || '5432'),
user: env.POSTGRES_USER || 'joplin',
password: env.POSTGRES_PASSWORD || 'joplin',
database: env.POSTGRES_DATABASE || 'joplin',
});
};
const isSessionExpired = (createdTime, now = Date.now()) => {
const numericCreatedTime = Number(createdTime || 0);
if (!numericCreatedTime) return true;
return now - numericCreatedTime >= defaultSessionTtlMs;
};
const createSessionService = database => {
return {
async userBySessionId(sessionId) {
if (!sessionId) return null;
const result = await database.query(`
SELECT
s.id AS session_id,
s.user_id,
s.created_time AS session_created_time,
u.id,
u.email,
u.full_name,
u.is_admin,
u.can_upload,
u.email_confirmed,
u.account_type,
u.created_time,
u.updated_time,
u.enabled
FROM sessions s
INNER JOIN users u ON u.id = s.user_id
WHERE s.id = $1
LIMIT 1
`, [sessionId]);
const row = result.rows[0];
if (!row) return null;
if (isSessionExpired(row.session_created_time)) return null;
if (!Number(row.enabled)) return null;
return {
id: row.id,
sessionId: row.session_id,
email: row.email,
fullName: row.full_name,
isAdmin: !!Number(row.is_admin),
canUpload: !!Number(row.can_upload),
emailConfirmed: !!Number(row.email_confirmed),
accountType: Number(row.account_type || 0),
createdTime: Number(row.created_time || 0),
updatedTime: Number(row.updated_time || 0),
};
},
};
};
module.exports = {
createPoolFromEnv,
createSessionService,
defaultSessionTtlMs,
isSessionExpired,
};

1756
app/createServer.js Normal file

File diff suppressed because it is too large Load diff

129
app/historyService.js Normal file
View file

@ -0,0 +1,129 @@
const MAX_SNAPSHOTS = 50;
const MIN_INTERVAL_MS = 30000; // minimum 30s between snapshots for a given note
// simple djb2 hash for body deduplication
const hashBody = str => {
let h = 5381;
for (let i = 0; i < str.length; i++) h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0;
return h.toString(36);
};
const nowMs = () => Date.now();
const createHistoryService = database => {
let _tableAvailable = null;
const ensureTable = async () => {
if (_tableAvailable !== null) return;
try {
await database.query(`
CREATE TABLE IF NOT EXISTS joplock_history (
id BIGSERIAL PRIMARY KEY,
note_id VARCHAR(32) NOT NULL,
user_id VARCHAR(32) NOT NULL,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
body_hash VARCHAR(16) NOT NULL DEFAULT '',
saved_time BIGINT NOT NULL
)
`);
await database.query(`
CREATE INDEX IF NOT EXISTS joplock_history_note_time
ON joplock_history (note_id, saved_time DESC)
`);
_tableAvailable = true;
} catch {
_tableAvailable = false;
}
};
return {
/**
* Save a snapshot. Skips if:
* - table unavailable
* - body hash identical to most recent snapshot for this note
* - last snapshot for this note was < MIN_INTERVAL_MS ago
* After insert, prunes to MAX_SNAPSHOTS per note.
*/
async saveSnapshot(userId, noteId, title, body) {
await ensureTable();
if (!_tableAvailable) return;
const hash = hashBody(body);
const ts = nowMs();
try {
// check most recent snapshot
const last = await database.query(
'SELECT body_hash, saved_time FROM joplock_history WHERE note_id = $1 ORDER BY saved_time DESC LIMIT 1',
[noteId],
);
const prev = last.rows[0];
if (prev) {
if (prev.body_hash === hash) return; // identical content
if (ts - Number(prev.saved_time) < MIN_INTERVAL_MS) return; // too soon
}
await database.query(
'INSERT INTO joplock_history (note_id, user_id, title, body, body_hash, saved_time) VALUES ($1,$2,$3,$4,$5,$6)',
[noteId, userId, title || '', body || '', hash, ts],
);
// prune old entries beyond MAX_SNAPSHOTS
await database.query(`
DELETE FROM joplock_history
WHERE note_id = $1 AND id NOT IN (
SELECT id FROM joplock_history WHERE note_id = $1
ORDER BY saved_time DESC LIMIT $2
)
`, [noteId, MAX_SNAPSHOTS]);
} catch {
// history is best-effort; never break saves
}
},
/**
* List snapshots for a note (newest first), metadata only (no body).
*/
async listSnapshots(noteId) {
await ensureTable();
if (!_tableAvailable) return [];
try {
const result = await database.query(
'SELECT id, title, saved_time FROM joplock_history WHERE note_id = $1 ORDER BY saved_time DESC LIMIT $2',
[noteId, MAX_SNAPSHOTS],
);
return result.rows.map(r => ({
id: String(r.id),
title: r.title,
savedTime: Number(r.saved_time),
}));
} catch {
return [];
}
},
/**
* Get a single snapshot by id (includes body).
*/
async getSnapshot(snapshotId) {
await ensureTable();
if (!_tableAvailable) return null;
try {
const result = await database.query(
'SELECT id, note_id, title, body, saved_time FROM joplock_history WHERE id = $1 LIMIT 1',
[snapshotId],
);
const r = result.rows[0];
if (!r) return null;
return {
id: String(r.id),
noteId: r.note_id,
title: r.title,
body: r.body,
savedTime: Number(r.saved_time),
};
} catch {
return null;
}
},
};
};
module.exports = { createHistoryService, hashBody };

212
app/items/itemService.js Normal file
View file

@ -0,0 +1,212 @@
const MODEL_TYPE_NOTE = 1;
const MODEL_TYPE_FOLDER = 2;
const MODEL_TYPE_RESOURCE = 4;
const TRASH_FOLDER_ID = 'de1e7ede1e7ede1e7ede1e7ede1e7ede';
const decodeItemContent = content => {
if (!content) return {};
const raw = Buffer.isBuffer(content) ? content.toString('utf8') : `${content}`;
if (!raw) return {};
return JSON.parse(raw);
};
const mapFolderRow = row => {
const content = decodeItemContent(row.content);
return {
id: row.jop_id,
parentId: row.jop_parent_id || '',
title: content.title || '',
icon: content.icon || '',
deletedTime: Number(content.deleted_time || 0),
createdTime: Number(content.created_time || row.created_time || 0),
updatedTime: Number(row.jop_updated_time || content.updated_time || 0),
};
};
const mapNoteRow = row => {
const content = decodeItemContent(row.content);
const body = content.body || '';
return {
id: row.jop_id,
parentId: row.jop_parent_id || '',
title: content.title || '',
body,
bodyPreview: body.slice(0, 240),
isTodo: !!Number(content.is_todo || 0),
todoCompleted: Number(content.todo_completed || 0),
deletedTime: Number(content.deleted_time || 0),
createdTime: Number(content.created_time || row.created_time || 0),
updatedTime: Number(row.jop_updated_time || content.updated_time || 0),
};
};
const mapNoteHeaderRow = row => {
return {
id: row.jop_id,
parentId: row.jop_parent_id || '',
title: row.title || '',
deletedTime: Number(row.deleted_time || 0),
updatedTime: Number(row.jop_updated_time || 0),
};
};
const deletedFilterSql = mode => {
if (mode === 'only') return ' AND COALESCE((convert_from(content, \'UTF8\')::json->>\'deleted_time\')::bigint, 0) > 0';
if (mode === 'all') return '';
return ' AND COALESCE((convert_from(content, \'UTF8\')::json->>\'deleted_time\')::bigint, 0) = 0';
};
const createItemService = database => {
return {
async foldersByUserId(userId) {
const result = await database.query(`
SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
FROM items
WHERE owner_id = $1 AND jop_type = $2${deletedFilterSql('exclude')}
ORDER BY LOWER(COALESCE(convert_from(content, 'UTF8')::json->>'title', '')) ASC, created_time ASC
`, [userId, MODEL_TYPE_FOLDER]);
return result.rows.map(mapFolderRow);
},
async folderByUserIdAndJopId(userId, folderId) {
const result = await database.query(`
SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
FROM items
WHERE owner_id = $1 AND jop_type = $2 AND jop_id = $3
LIMIT 1
`, [userId, MODEL_TYPE_FOLDER, folderId]);
const row = result.rows[0];
if (!row) return null;
return mapFolderRow(row);
},
async notesByUserId(userId, options = {}) {
const folderId = options.folderId || '';
const deleted = options.deleted || 'exclude';
const params = [userId, MODEL_TYPE_NOTE];
let where = `WHERE owner_id = $1 AND jop_type = $2${deletedFilterSql(deleted)}`;
if (folderId) {
params.push(folderId);
where += ` AND jop_parent_id = $${params.length}`;
}
const result = await database.query(`
SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
FROM items
${where}
ORDER BY jop_updated_time DESC, created_time DESC
`, params);
return result.rows.map(mapNoteRow);
},
async noteHeadersByUserId(userId, options = {}) {
const deleted = options.deleted || 'exclude';
const result = await database.query(`
SELECT
jop_id,
jop_parent_id,
jop_updated_time,
COALESCE(convert_from(content, 'UTF8')::json->>'title', '') AS title,
COALESCE((convert_from(content, 'UTF8')::json->>'deleted_time')::bigint, 0) AS deleted_time
FROM items
WHERE owner_id = $1 AND jop_type = $2${deletedFilterSql(deleted)}
ORDER BY jop_updated_time DESC, created_time DESC
`, [userId, MODEL_TYPE_NOTE]);
return result.rows.map(mapNoteHeaderRow);
},
async searchNotes(userId, query) {
if (!query || !query.trim()) return [];
const pattern = `%${query.trim()}%`;
const result = await database.query(`
SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
FROM (
SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content,
convert_from(content, 'UTF8')::json AS parsed
FROM items
WHERE owner_id = $1 AND jop_type = $2
) sub
WHERE COALESCE((parsed->>'deleted_time')::bigint, 0) = 0
AND (
parsed->>'title' ILIKE $3
OR regexp_replace(
regexp_replace(parsed->>'body', '!?\[[^\]]*\]\(:/[a-f0-9]+\)', '', 'g'),
'data:image/[^;]+;base64,[A-Za-z0-9+/=]+', '', 'g'
) ILIKE $3
)
ORDER BY jop_updated_time DESC, created_time DESC
LIMIT 50
`, [userId, MODEL_TYPE_NOTE, pattern]);
return result.rows.map(mapNoteRow);
},
async noteByUserIdAndJopId(userId, noteId, options = {}) {
const deleted = options.deleted || 'exclude';
const result = await database.query(`
SELECT id, jop_id, jop_parent_id, jop_updated_time, created_time, content
FROM items
WHERE owner_id = $1 AND jop_type = $2 AND jop_id = $3${deletedFilterSql(deleted)}
LIMIT 1
`, [userId, MODEL_TYPE_NOTE, noteId]);
const row = result.rows[0];
if (!row) return null;
return mapNoteRow(row);
},
// Returns the binary content of a resource blob (.resource/<id>)
async resourceBlobByUserId(userId, resourceId) {
const blobName = `.resource/${resourceId}`;
const result = await database.query(`
SELECT content
FROM items
WHERE owner_id = $1 AND name = $2
LIMIT 1
`, [userId, blobName]);
const row = result.rows[0];
if (!row) return null;
return row.content; // Buffer
},
// Returns resource metadata (mime, filename, etc.) from the .md item
async resourceMetaByUserId(userId, resourceId) {
const result = await database.query(`
SELECT content
FROM items
WHERE owner_id = $1 AND jop_type = $2 AND jop_id = $3
LIMIT 1
`, [userId, MODEL_TYPE_RESOURCE, resourceId]);
const row = result.rows[0];
if (!row) return null;
const content = decodeItemContent(row.content);
return {
id: resourceId,
title: content.title || '',
mime: content.mime || 'application/octet-stream',
filename: content.filename || '',
fileExtension: content.file_extension || '',
size: Number(content.size || 0),
};
},
};
};
module.exports = {
MODEL_TYPE_FOLDER,
MODEL_TYPE_NOTE,
MODEL_TYPE_RESOURCE,
TRASH_FOLDER_ID,
createItemService,
decodeItemContent,
mapFolderRow,
mapNoteHeaderRow,
mapNoteRow,
};

View file

@ -0,0 +1,295 @@
const http = require('http');
const { randomBytes } = require('crypto');
const notePath = noteId => `root:/${noteId}.md:`;
const folderPath = folderId => `root:/${folderId}.md:`;
const resourceMetaPath = resourceId => `root:/${resourceId}.md:`;
const resourceBlobPath = resourceId => `root:/.resource/${resourceId}:`;
const itemId = suffix => {
const token = randomBytes(16).toString('hex').slice(0, 31);
return `${token}${suffix}`;
};
const formatTimestamp = timestamp => new Date(timestamp).toISOString();
const serializeNote = note => {
const now = Date.now();
const noteId = note.id || itemId('1');
const parentId = note.parentId || '';
const createdTime = note.createdTime || now;
const deletedTime = note.deletedTime || 0;
return {
id: noteId,
path: notePath(noteId),
body: `${note.title || 'Untitled note'}
${note.body || ''}
id: ${noteId}
parent_id: ${parentId}
created_time: ${formatTimestamp(createdTime)}
updated_time: ${formatTimestamp(now)}
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author:
source_url:
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplock-web
source_application: net.cozic.joplock-web
application_data:
order: 0
user_created_time: ${formatTimestamp(createdTime)}
user_updated_time: ${formatTimestamp(now)}
encryption_cipher_text:
encryption_applied: 0
markup_language: 1
is_shared: 0
share_id:
conflict_original_id:
master_key_id:
user_data:
deleted_time: ${deletedTime}
type_: 1`,
};
};
const serializeFolder = folder => {
const now = Date.now();
const folderId = folder.id || itemId('2');
const parentId = folder.parentId || '';
return {
id: folderId,
path: folderPath(folderId),
body: `${folder.title || 'Untitled folder'}
id: ${folderId}
created_time: ${formatTimestamp(now)}
updated_time: ${formatTimestamp(now)}
user_created_time: ${formatTimestamp(now)}
user_updated_time: ${formatTimestamp(now)}
encryption_cipher_text:
encryption_applied: 0
parent_id: ${parentId}
is_shared: 0
share_id:
user_data:
type_: 2`,
};
};
const serializeResource = resource => {
const now = Date.now();
const resourceId = resource.id || itemId('4');
const mime = resource.mime || 'application/octet-stream';
const filename = resource.filename || '';
const fileExtension = resource.fileExtension || '';
const size = resource.size || 0;
return {
id: resourceId,
metaPath: resourceMetaPath(resourceId),
blobPath: resourceBlobPath(resourceId),
body: `${resource.title || filename || 'Untitled resource'}
id: ${resourceId}
mime: ${mime}
filename: ${filename}
created_time: ${formatTimestamp(now)}
updated_time: ${formatTimestamp(now)}
user_created_time: ${formatTimestamp(now)}
user_updated_time: ${formatTimestamp(now)}
file_extension: ${fileExtension}
encryption_cipher_text:
encryption_applied: 0
encryption_blob_encrypted: 0
size: ${size}
is_shared: 0
share_id:
master_key_id:
user_data:
blob_updated_time: ${formatTimestamp(now)}
type_: 4`,
};
};
const requestUpstream = (origin, options = {}, body = null) => {
const target = new URL(origin);
const requestHeaders = { ...(options.headers || {}) };
requestHeaders.host = options.publicHost || requestHeaders.host || '';
requestHeaders['x-forwarded-host'] = options.publicHost || requestHeaders.host || '';
requestHeaders['x-forwarded-proto'] = options.publicProtocol || 'http';
delete requestHeaders.origin;
delete requestHeaders.referer;
if (body !== null && !requestHeaders['content-length']) {
requestHeaders['content-length'] = Buffer.byteLength(body);
}
return new Promise((resolve, reject) => {
const request = http.request({
hostname: target.hostname,
port: target.port,
path: options.path || '/',
method: options.method || 'GET',
headers: requestHeaders,
}, response => {
const chunks = [];
response.on('data', chunk => {
chunks.push(chunk);
});
response.on('end', () => {
resolve({
statusCode: response.statusCode || 500,
body: Buffer.concat(chunks),
headers: response.headers,
});
});
});
request.on('error', reject);
if (body !== null) request.write(body);
request.end();
});
};
const checkUpstreamResponse = response => {
if (response.statusCode >= 200 && response.statusCode < 300) return;
const message = response.body.toString('utf8') || `Upstream request failed: ${response.statusCode}`;
const error = new Error(message);
error.statusCode = response.statusCode;
throw error;
};
const createItemWriteService = options => {
const { joplinServerOrigin, joplinServerPublicUrl } = options;
const configuredPublicUrl = new URL(joplinServerPublicUrl);
const putSerializedItem = async (sessionId, serializedItem, requestContext = {}) => {
const response = await requestUpstream(joplinServerOrigin, {
method: 'PUT',
path: `/api/items/${serializedItem.path}/content`,
publicHost: requestContext.host || configuredPublicUrl.host,
publicProtocol: requestContext.protocol || configuredPublicUrl.protocol.replace(':', ''),
headers: {
'content-type': 'multipart/form-data; boundary=----joplockboundary',
'x-api-auth': sessionId,
},
}, `------joplockboundary\r\nContent-Disposition: form-data; name="file"; filename="item.md"\r\nContent-Type: text/markdown\r\n\r\n${serializedItem.body}\r\n------joplockboundary--\r\n`);
checkUpstreamResponse(response);
return serializedItem.id;
};
const putBinaryItem = async (sessionId, itemPath, binaryBuffer, contentType, requestContext = {}) => {
const boundary = '----joplockblobbound';
const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="blob"\r\nContent-Type: ${contentType}\r\n\r\n`;
const footer = `\r\n--${boundary}--\r\n`;
const body = Buffer.concat([
Buffer.from(header, 'utf8'),
binaryBuffer,
Buffer.from(footer, 'utf8'),
]);
const response = await requestUpstream(joplinServerOrigin, {
method: 'PUT',
path: `/api/items/${itemPath}/content`,
publicHost: requestContext.host || configuredPublicUrl.host,
publicProtocol: requestContext.protocol || configuredPublicUrl.protocol.replace(':', ''),
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
'x-api-auth': sessionId,
},
}, body);
checkUpstreamResponse(response);
};
const deleteItem = async (sessionId, itemPath, requestContext = {}) => {
const response = await requestUpstream(joplinServerOrigin, {
method: 'DELETE',
path: `/api/items/${itemPath}`,
publicHost: requestContext.host || configuredPublicUrl.host,
publicProtocol: requestContext.protocol || configuredPublicUrl.protocol.replace(':', ''),
headers: {
'x-api-auth': sessionId,
},
});
checkUpstreamResponse(response);
};
return {
async createFolder(sessionId, folder, requestContext) {
const serialized = serializeFolder(folder);
await putSerializedItem(sessionId, serialized, requestContext);
return { id: serialized.id };
},
async deleteFolder(sessionId, folderId, requestContext) {
await deleteItem(sessionId, folderPath(folderId), requestContext);
},
async updateFolder(sessionId, existingFolder, updates, requestContext) {
const serialized = serializeFolder({
id: existingFolder.id,
title: updates.title !== undefined ? updates.title : existingFolder.title,
parentId: updates.parentId !== undefined ? updates.parentId : existingFolder.parentId,
});
await putSerializedItem(sessionId, serialized, requestContext);
return { id: serialized.id };
},
async createNote(sessionId, note, requestContext) {
const serialized = serializeNote(note);
await putSerializedItem(sessionId, serialized, requestContext);
return { id: serialized.id };
},
async updateNote(sessionId, existingNote, updates, requestContext) {
const serialized = serializeNote({
id: existingNote.id,
title: updates.title !== undefined ? updates.title : existingNote.title,
body: updates.body !== undefined ? updates.body : existingNote.body,
parentId: updates.parentId !== undefined ? updates.parentId : existingNote.parentId,
createdTime: existingNote.createdTime,
deletedTime: updates.deletedTime !== undefined ? updates.deletedTime : existingNote.deletedTime,
});
await putSerializedItem(sessionId, serialized, requestContext);
return { id: serialized.id };
},
async deleteNote(sessionId, noteId, requestContext) {
await deleteItem(sessionId, notePath(noteId), requestContext);
},
async trashNote(sessionId, existingNote, requestContext) {
return this.updateNote(sessionId, existingNote, { deletedTime: Date.now() }, requestContext);
},
async restoreNote(sessionId, existingNote, restoreParentId, requestContext) {
return this.updateNote(sessionId, existingNote, { deletedTime: 0, parentId: restoreParentId }, requestContext);
},
async createResource(sessionId, resource, binaryBuffer, requestContext) {
const serialized = serializeResource(resource);
// Upload metadata .md first, then binary blob
await putSerializedItem(sessionId, { id: serialized.id, path: serialized.metaPath, body: serialized.body }, requestContext);
await putBinaryItem(sessionId, serialized.blobPath, binaryBuffer, resource.mime || 'application/octet-stream', requestContext);
return { id: serialized.id };
},
};
};
module.exports = {
createItemWriteService,
serializeFolder,
serializeNote,
serializeResource,
};

179
app/settingsService.js Normal file
View file

@ -0,0 +1,179 @@
const validThemes = ['matrix','matrix-blue','matrix-purple','matrix-amber','matrix-orange','dark-grey','dark-red','dark','light','oled-dark','solarized-light','solarized-dark','nord','dracula','aritim-dark'];
const validDateFormats = [
'YYYY-MM-DD',
'MM/DD/YYYY',
'DD/MM/YYYY',
'MMM DD, YYYY',
'DD MMM YYYY',
'YYYY.MM.DD',
];
const validDatetimeFormats = [
'YYYY-MM-DD HH:mm',
'MM/DD/YYYY HH:mm',
'DD/MM/YYYY HH:mm',
'MMM DD, YYYY HH:mm',
'DD MMM YYYY HH:mm',
'YYYY.MM.DD HH:mm',
'YYYY-MM-DD HH:mm:ss',
'MM/DD/YYYY hh:mm A',
'DD/MM/YYYY hh:mm A',
];
const defaultSettings = Object.freeze({
noteFontSize: 15,
mobileNoteFontSize: 17,
codeFontSize: 12,
noteMonospace: false,
noteOpenMode: 'preview',
resumeLastNote: true,
lastNoteId: '',
lastNoteFolderId: '',
dateFormat: 'YYYY-MM-DD',
datetimeFormat: 'YYYY-MM-DD HH:mm',
autoLogout: false,
autoLogoutMinutes: 15,
theme: 'matrix',
liveSearch: false,
});
const nowMs = () => Date.now();
const normalizeInteger = (value, fallback, min, max) => {
const numeric = Number.parseInt(`${value}`, 10);
if (Number.isNaN(numeric)) return fallback;
return Math.max(min, Math.min(max, numeric));
};
const normalizeSettings = settings => ({
noteFontSize: normalizeInteger(settings.noteFontSize, defaultSettings.noteFontSize, 12, 24),
mobileNoteFontSize: normalizeInteger(settings.mobileNoteFontSize, normalizeInteger(settings.noteFontSize, defaultSettings.noteFontSize, 12, 24) + 2, 12, 28),
codeFontSize: normalizeInteger(settings.codeFontSize, defaultSettings.codeFontSize, 10, 22),
noteMonospace: !!Number(settings.noteMonospace) || settings.noteMonospace === true || settings.noteMonospace === '1',
noteOpenMode: settings.noteOpenMode === 'markdown' ? 'markdown' : defaultSettings.noteOpenMode,
resumeLastNote: !!Number(settings.resumeLastNote) || settings.resumeLastNote === true || settings.resumeLastNote === '1',
lastNoteId: `${settings.lastNoteId || ''}`,
lastNoteFolderId: `${settings.lastNoteFolderId || ''}`,
dateFormat: validDateFormats.includes(settings.dateFormat) ? settings.dateFormat : defaultSettings.dateFormat,
datetimeFormat: validDatetimeFormats.includes(settings.datetimeFormat) ? settings.datetimeFormat : defaultSettings.datetimeFormat,
autoLogout: !!Number(settings.autoLogout) || settings.autoLogout === true || settings.autoLogout === '1',
autoLogoutMinutes: normalizeInteger(settings.autoLogoutMinutes, defaultSettings.autoLogoutMinutes, 1, 480),
theme: validThemes.includes(settings.theme) ? settings.theme : defaultSettings.theme,
liveSearch: !!Number(settings.liveSearch) || settings.liveSearch === true || settings.liveSearch === '1',
});
const createSettingsService = database => {
let _tableAvailable = null;
const ensureTable = async () => {
if (_tableAvailable !== null) return;
try {
await database.query(`
CREATE TABLE IF NOT EXISTS joplock_settings (
user_id VARCHAR(32) PRIMARY KEY,
settings JSONB NOT NULL DEFAULT '{}',
updated_time BIGINT NOT NULL,
totp_seed VARCHAR(64)
)
`);
// Add totp_seed column if missing (migration for existing tables)
await database.query(`
ALTER TABLE joplock_settings ADD COLUMN IF NOT EXISTS totp_seed VARCHAR(64)
`).catch(() => {});
_tableAvailable = true;
} catch {
_tableAvailable = false;
}
};
return {
async settingsByUserId(userId) {
await ensureTable();
if (!_tableAvailable) return { ...defaultSettings };
try {
const result = await database.query(
'SELECT settings FROM joplock_settings WHERE user_id = $1 LIMIT 1',
[userId],
);
const row = result.rows[0];
if (!row) return { ...defaultSettings };
const json = typeof row.settings === 'string' ? JSON.parse(row.settings) : (row.settings || {});
return normalizeSettings({ ...defaultSettings, ...json });
} catch {
return { ...defaultSettings };
}
},
async saveSettings(userId, settings) {
await ensureTable();
const normalized = normalizeSettings(settings);
if (!_tableAvailable) return normalized;
const timestamp = nowMs();
await database.query(`
INSERT INTO joplock_settings (user_id, settings, updated_time)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET
settings = EXCLUDED.settings,
updated_time = EXCLUDED.updated_time
`, [userId, JSON.stringify(normalized), timestamp]);
return normalized;
},
async getTotpSeed(userId) {
await ensureTable();
if (!_tableAvailable) return null;
try {
const result = await database.query(
'SELECT totp_seed FROM joplock_settings WHERE user_id = $1 LIMIT 1',
[userId],
);
return result.rows[0]?.totp_seed || null;
} catch {
return null;
}
},
async setTotpSeed(userId, seed) {
await ensureTable();
if (!_tableAvailable) return false;
const timestamp = nowMs();
try {
await database.query(`
INSERT INTO joplock_settings (user_id, settings, updated_time, totp_seed)
VALUES ($1, '{}', $2, $3)
ON CONFLICT (user_id) DO UPDATE SET
totp_seed = EXCLUDED.totp_seed,
updated_time = EXCLUDED.updated_time
`, [userId, timestamp, seed]);
return true;
} catch {
return false;
}
},
async clearTotpSeed(userId) {
await ensureTable();
if (!_tableAvailable) return false;
const timestamp = nowMs();
try {
await database.query(
'UPDATE joplock_settings SET totp_seed = NULL, updated_time = $2 WHERE user_id = $1',
[userId, timestamp],
);
return true;
} catch {
return false;
}
},
};
};
module.exports = {
createSettingsService,
defaultSettings,
normalizeSettings,
validDateFormats,
validDatetimeFormats,
validThemes,
};

1864
app/templates.js Normal file

File diff suppressed because it is too large Load diff

66
cm-build/index.js Normal file
View file

@ -0,0 +1,66 @@
// CM6 bundle entry point — exports everything on window.CM
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { keymap, placeholder, drawSelection, highlightActiveLine } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, HighlightStyle, StreamLanguage } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import { searchKeymap, highlightSelectionMatches, openSearchPanel, SearchQuery, setSearchQuery } from "@codemirror/search";
// Language parsers
import { javascript } from "@codemirror/lang-javascript";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { sql } from "@codemirror/lang-sql";
import { python } from "@codemirror/lang-python";
import { xml } from "@codemirror/lang-xml";
import { go } from "@codemirror/lang-go";
import { cpp } from "@codemirror/lang-cpp";
import { yaml } from "@codemirror/lang-yaml";
import { shell } from "@codemirror/legacy-modes/mode/shell";
// Language description imports for codeLanguages mapping
import { LanguageDescription } from "@codemirror/language";
window.CM = {
// Core (same as before)
EditorView,
EditorState,
markdown,
markdownLanguage,
keymap,
placeholder,
drawSelection,
highlightActiveLine,
defaultKeymap,
history,
historyKeymap,
indentWithTab,
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
searchKeymap,
highlightSelectionMatches,
openSearchPanel,
SearchQuery,
setSearchQuery,
tags,
HighlightStyle,
// Language parsers (new)
javascript,
html,
css,
json,
sql,
python,
xml,
go,
cpp,
yaml,
shell,
StreamLanguage,
LanguageDescription,
};

832
cm-build/package-lock.json generated Normal file
View file

@ -0,0 +1,832 @@
{
"name": "cm-build",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-go": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-sql": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-yaml": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/legacy-modes": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"esbuild": "^0.20.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-cpp": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
"integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/cpp": "^1.0.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.41.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz",
"integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/cpp": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz",
"integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
}
}
}

28
cm-build/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"private": true,
"type": "module",
"scripts": {
"build": "esbuild index.js --bundle --minify --outfile=../public/codemirror.min.js --format=iife --global-name=__CM"
},
"dependencies": {
"@codemirror/view": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-sql": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-go": "^6.0.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-yaml": "^6.0.0",
"@codemirror/legacy-modes": "^6.0.0",
"esbuild": "^0.20.0"
}
}

View file

@ -0,0 +1,67 @@
# Full-stack development example: Postgres + Joplin Server + Joplock (built from source)
#
# Same as docker-compose.example-full.yml but builds Joplock from source
# instead of pulling the pre-built image.
#
# Usage:
# docker compose -f docker-compose.example-full-build.yml up -d --build
#
# Joplock UI: http://localhost:5444
# Joplin Server: internal only (not exposed to host by default)
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: joplin
POSTGRES_PASSWORD: joplin
POSTGRES_DB: joplin
volumes:
- db_data:/var/lib/postgresql/data
server:
image: joplin/server:latest
restart: unless-stopped
depends_on:
- db
environment:
APP_PORT: 22300
APP_BASE_URL: http://server:22300
SIGNUP_ENABLED: 'true'
DB_CLIENT: pg
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DATABASE: joplin
POSTGRES_USER: joplin
POSTGRES_PASSWORD: joplin
joplock:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- server
ports:
- '5444:3001'
environment:
HOST: 0.0.0.0
PORT: 3001
JOPLOCK_PUBLIC_BASE_URL: http://localhost:5444
JOPLIN_SERVER_ORIGIN: http://server:22300
JOPLIN_SERVER_PUBLIC_URL: http://server:22300
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DATABASE: joplin
POSTGRES_USER: joplin
POSTGRES_PASSWORD: joplin
# Create or reset Admin account in Joplin Server — optional,
# set to enable the admin panel or change admin password
# also can be used to turn off MFA if lost
JOPLOCK_ADMIN_EMAIL: ''
JOPLOCK_ADMIN_PASSWORD: ''
IGNORE_ADMIN_MFA: 'false'
volumes:
db_data:

View file

@ -0,0 +1,84 @@
# Full-stack example: Postgres + Joplin Server + Joplock
#
# Zero-setup rollout for users who don't have an existing Joplin Server.
# Pulls pre-built images — no source checkout required.
#
# Usage:
# 1. Set JOPLOCK_PUBLIC_BASE_URL to the actual reachable URL for this host, such
# as a hostname served from a reverse proxy.
# (e.g. http://myhost:5444 or a Tailscale DNS name)
# 2. Set JOPLOCK_ADMIN_EMAIL and JOPLOCK_ADMIN_PASSWORD for the admin account
# 3. docker compose -f docker-compose.example-full.yml up -d
#
# Joplock UI: http://localhost:5444
# Joplin Server: internal only (not exposed to host by default)
#
# Note: On WSL2, the raw WSL IP may not be reliably reachable from browsers.
# A Windows host port proxy or a Tailscale DNS name can work better.
#
# To connect desktop/mobile Joplin clients, use the Joplin Server URL if exposed
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: joplin
POSTGRES_PASSWORD: joplin
POSTGRES_DB: joplin
volumes:
- db_data:/var/lib/postgresql/data
server:
image: joplin/server:latest
restart: unless-stopped
depends_on:
- db
environment:
APP_PORT: 22300
# Internal base URL — Joplin Server is not exposed to the host.
# Joplock proxies writes to it over the internal Docker network.
APP_BASE_URL: http://server:22300
# Set to false to disable new user self-registration
SIGNUP_ENABLED: 'true'
DB_CLIENT: pg
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DATABASE: joplin
POSTGRES_USER: joplin
POSTGRES_PASSWORD: joplin
joplock:
image: ghcr.io/abort-retry-ignore/joplock:latest
restart: unless-stopped
depends_on:
- server
ports:
- '5444:3001'
environment:
HOST: 0.0.0.0
PORT: 3001
# Public URL where Joplock is reachable.
# Use the real browser-reachable host here, such as a LAN hostname,
# reverse-proxy URL, or Tailscale DNS name.
JOPLOCK_PUBLIC_BASE_URL: http://localhost:5444
# Internal URL Joplock uses to talk to Joplin Server
JOPLIN_SERVER_ORIGIN: http://server:22300
# Joplin Server is not publicly exposed in this example.
# Desktop/mobile sync clients cannot connect directly — Joplock is the only UI.
# To expose Joplin Server for sync clients, add a ports mapping to the server
# service and set this to its public URL (e.g. http://10.0.1.14:22300).
JOPLIN_SERVER_PUBLIC_URL: http://server:22300
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DATABASE: joplin
POSTGRES_USER: joplin
POSTGRES_PASSWORD: joplin
# Admin account — set both to enable the admin panel
JOPLOCK_ADMIN_EMAIL: ''
JOPLOCK_ADMIN_PASSWORD: ''
# Set to true to skip MFA for the admin account defined above
IGNORE_ADMIN_MFA: 'false'
volumes:
db_data:

42
docker-compose.yml Normal file
View file

@ -0,0 +1,42 @@
# Sidecar deployment example: Joplock alongside an existing Joplin Server + Postgres
#
# Usage:
# 1. Edit the environment values below to match your existing setup
# 2. docker compose up -d
#
# ALTERNATELY you can just paste the joplock section into your existing joplin server
# compose file.
#
# On Linux, host.docker.internal is mapped to the host gateway so Joplock
# can reach services running on the host by default.
services:
joplock:
image: ghcr.io/abort-retry-ignore/joplock:latest
ports:
# Host port : container port
- '5444:3001'
environment:
HOST: 0.0.0.0
PORT: 3001
# Public URL where Joplock is reachable from a browser
JOPLOCK_PUBLIC_BASE_URL: http://localhost:5444
# Internal URL Joplock uses to call Joplin Server API
JOPLIN_SERVER_ORIGIN: http://host.docker.internal:22300
# Public URL users already use for Joplin Server (for sync clients)
JOPLIN_SERVER_PUBLIC_URL: http://localhost:22300
# Base path prefix if Joplin Server sits behind a reverse proxy subpath
JOPLIN_PUBLIC_BASE_PATH: ''
# Postgres connection — must be the same database used by Joplin Server
POSTGRES_HOST: host.docker.internal
POSTGRES_PORT: 5432
POSTGRES_DATABASE: joplin
POSTGRES_USER: joplin
POSTGRES_PASSWORD: joplin
# Admin account — set both to enable the admin panel in Joplock
JOPLOCK_ADMIN_EMAIL: ''
JOPLOCK_ADMIN_PASSWORD: ''
# Set to true to skip MFA for the admin account defined above
IGNORE_ADMIN_MFA: 'false'
extra_hosts:
- 'host.docker.internal:host-gateway'

41
hljs-build/index.js Normal file
View file

@ -0,0 +1,41 @@
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml'; // html
import css from 'highlight.js/lib/languages/css';
import json from 'highlight.js/lib/languages/json';
import sql from 'highlight.js/lib/languages/sql';
import python from 'highlight.js/lib/languages/python';
import go from 'highlight.js/lib/languages/go';
import cpp from 'highlight.js/lib/languages/cpp';
import c from 'highlight.js/lib/languages/c';
import yaml from 'highlight.js/lib/languages/yaml';
import bash from 'highlight.js/lib/languages/bash';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('css', css);
hljs.registerLanguage('json', json);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('python', python);
hljs.registerLanguage('py', python);
hljs.registerLanguage('go', go);
hljs.registerLanguage('golang', go);
hljs.registerLanguage('cpp', cpp);
hljs.registerLanguage('c++', cpp);
hljs.registerLanguage('c', c);
hljs.registerLanguage('yaml', yaml);
hljs.registerLanguage('yml', yaml);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('shell', bash);
hljs.registerLanguage('zsh', bash);
hljs.registerLanguage('dockerfile', dockerfile);
hljs.registerLanguage('docker-compose', yaml);
window.hljs = hljs;

428
hljs-build/package-lock.json generated Normal file
View file

@ -0,0 +1,428 @@
{
"name": "hljs-build",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"esbuild": "^0.20.0",
"highlight.js": "^11.11.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
}
}
}

11
hljs-build/package.json Normal file
View file

@ -0,0 +1,11 @@
{
"private": true,
"type": "module",
"scripts": {
"build": "esbuild index.js --bundle --minify --outfile=../public/hljs.min.js --format=iife --global-name=__hljs"
},
"dependencies": {
"highlight.js": "^11.11.1",
"esbuild": "^0.20.0"
}
}

1358
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "joplock",
"private": true,
"version": "0.1.0",
"description": "Thin-client sidecar web UI for stock Joplin Server",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node --test tests/*.test.js",
"generate:pwa-assets": "node ./scripts/generatePwaAssets.mjs",
"docker:build": "docker build -t joplock .",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:up:build": "docker compose -f docker-compose-build.yml up -d --build",
"docker:down:build": "docker compose -f docker-compose-build.yml down",
"docker:up:dev": "docker compose -f docker-compose.dev.yml up -d --build",
"docker:down:dev": "docker compose -f docker-compose.dev.yml down",
"docker:up:full": "docker compose -f docker-compose.example-full.yml up -d",
"docker:down:full": "docker compose -f docker-compose.example-full.yml down"
},
"dependencies": {
"@adobe/css-tools": "4.4.4",
"@mixmark-io/domino": "2.2.0",
"bcryptjs": "^3.0.3",
"html-entities": "1.4.0",
"pg": "8.16.3",
"qr-image": "^3.2.0"
},
"devDependencies": {
"jsdom": "26.1.0",
"sharp": "^0.34.5"
}
}

View file

@ -0,0 +1,57 @@
# Code Block Syntax Highlighting + Language Picker Modal
## Overview
Add syntax highlighting for fenced code blocks inside the CM6 markdown editor, with a modal for inserting code blocks that lets the user pick a language.
## 1. Rebuild CM6 bundle with language parsers
**Install packages:**
- `@codemirror/lang-javascript`, `lang-html`, `lang-css`, `lang-json`, `lang-sql`, `lang-python`, `lang-xml`, `lang-go`, `lang-cpp`, `lang-yaml`
- `@codemirror/legacy-modes` (for shell via `StreamLanguage`)
**Bundle build:** Add all language constructors to `window.CM` exports. Rebuild `public/codemirror.min.js`.
**`initCM()` changes:** Pass a `codeLanguages` map to `markdown()` that maps info strings to parsers, including aliases:
- `js`, `javascript`, `jsx`, `ts`, `typescript`, `tsx` -> javascript
- `html` -> html
- `css` -> css
- `json` -> json
- `sql` -> sql
- `python`, `py` -> python
- `xml` -> xml
- `go`, `golang` -> go
- `c`, `cpp`, `c++` -> cpp
- `yaml`, `yml`, `dockerfile`, `docker-compose` -> yaml
- `bash`, `sh`, `shell`, `zsh` -> shell (via StreamLanguage)
## 2. Code block language picker modal
**Modal HTML** (following link modal pattern):
- `#code-modal-backdrop` + `#code-modal` with `.folder-modal` / `.folder-modal-card` classes
- `<select id="code-lang">` with options: Plain text, Bash, C, C++, CSS, Go, HTML, JavaScript, JSON, Python, SQL, TypeScript, XML, YAML
- `<textarea id="code-input">` for pasting/entering code (tall, monospace, takes over the edit area)
- Cancel + Insert buttons
**JS functions:**
- `openCodeModal()` -- if text selected (CM6 or preview), pre-populate textarea. Show modal. Focus textarea.
- `closeCodeModal()` -- hide modal + backdrop
- `submitCode(event)` -- read language + code, close modal, insert fenced code block:
- Markdown mode: insert ` ```lang\ncode\n``` ` via CM6 dispatch (replacing selection if any)
- Preview mode: insert `<pre><code class="language-XXX">escaped</code></pre>`
**Toolbar change:** `{ }` button onclick -> `openCodeModal()` instead of `wrapSel('\n```\n','\n```\n')`
## 3. CSS
- Code modal textarea: monospace font, near-full-width/height within modal card
- Modal card sized larger than link modal to "take over" the edit area
## 4. Service worker + tests
- Bump SW cache version
- Verify code block insertion works in both modes
## Decisions
- Explicit info strings only, no heuristic language detection
- No CSV (no official CM6 package)
- Dockerfile/docker-compose mapped to YAML highlighting
- Plain `<select>` dropdown (14 options, no need for search)
- Selected text pre-populates the code textarea in the modal

92
plans/mobile.md Normal file
View file

@ -0,0 +1,92 @@
# Mobile PWA Plan
Goal: Make Joplock's mobile PWA feel like Joplin's native mobile app.
## Current State
Joplock mobile has:
- Slide-out nav drawer (folders + notes tree)
- Single 768px breakpoint
- Title editing disabled on mobile
- Horizontal scroll toolbar
- PWA shell with splash screens
## Target State
Match Joplin mobile UX patterns:
- Folder list → note list → editor (3-screen stack navigation)
- Swipe-to-open drawer
- FAB (floating action button) for new note
- Bottom toolbar in editor (stays above keyboard)
- Back button navigation
- Mobile title editing
## Phases
### Phase 1: Screen-based navigation
Replace the tree-view sidebar with a 3-screen mobile flow:
1. **Folders screen**: List of folders with note counts, "All Notes" at top, Trash at bottom. Tap folder → note list.
2. **Note list screen**: Notes in selected folder, title-only rows. Tap note → editor. Back → folders.
3. **Editor screen**: Title + body editing. Back → note list.
Implementation:
- New mobile-specific fragments: `/fragments/mobile/folders`, `/fragments/mobile/notes?folderId=X`, existing editor fragment works
- Mobile layout: single `#mobile-screen` container that swaps content
- Navigation state tracked in JS: `_mobileStack = ['folders']`, push/pop
- Back button (top-left) pops stack, hardware back (popstate) does same
- Use CSS transitions for slide-left/slide-right screen transitions
- Keep desktop layout unchanged — detect via `window.innerWidth <= 768` or `body.mobile-layout` class
### Phase 2: FAB + new note flow
- Floating `+` button, bottom-right, `position:fixed`
- Tap → creates note in current folder, navigates to editor
- If on folders screen, create in General folder
### Phase 3: Swipe gestures
- Swipe right from left edge → open folders drawer (on note list and editor screens)
- Swipe left → close drawer
- Use `touchstart`/`touchmove`/`touchend` with 30px edge threshold
- Animated translateX with requestAnimationFrame
### Phase 4: Editor improvements
- Enable title editing on mobile (remove `applyMobileTitleMode` readonly)
- Bottom toolbar: move toolbar below editor, `position:fixed; bottom:0` or use `visualViewport` API to sit above keyboard
- Toolbar: bold, italic, list, heading, code, link — horizontal scroll
- Preview/edit toggle button in header
### Phase 5: Search
- Search icon in folder/note list header
- Tap → search input slides in, replaces header
- Results shown as flat note list (same as current "Search Results" folder behavior)
- Escape/X clears search, back returns to previous screen
### Phase 6: Polish
- Long-press note → context menu (delete, move to folder)
- Pull-down on note list → refresh
- Empty states: "No notes yet" with create prompt
- Transition animations between screens
- Safe area handling for notch/home indicator
- Tablet breakpoint (768-1024px): show folders + note list side by side
## Constraints
- Mobile layout only activates at `max-width: 768px` — desktop is completely untouched
- All mobile-specific HTML is conditionally rendered or hidden via CSS media queries
- No changes to desktop layout, behavior, or endpoints
- All changes are CSS + client JS + htmx fragments — no architectural changes
- Keep SSR + htmx model, no client-side framework
- Minimize new endpoints — reuse existing fragments where possible
## Files to modify
- `app/templates.js` — mobile layout shell, mobile fragments, mobile JS
- `app/createServer.js` — new mobile fragment endpoints
- `public/styles.css` — mobile screen styles, FAB, transitions
- Possibly split mobile JS into `public/mobile.js` if it gets large

65
plans/search-speed.md Normal file
View file

@ -0,0 +1,65 @@
# Plan: Speed Up Note Search
## Problem
`searchNotes` runs `ILIKE '%query%'` on the entire `content` bytea blob via `convert_from(content, 'UTF8')`. For 2k+ notes this means:
1. Every row's binary content is decoded on every search
2. The full JSON string is scanned — including JSON keys, resource IDs, metadata
3. No index can accelerate the `ILIKE` on the raw blob
4. Attachments/resources are matched via embedded JSON fields (undesirable)
## Constraints
- No Joplin Server modifications (AGENT_GUIDE.md)
- No changes to the `items` table schema (owned by Joplin Server)
- Writes continue through Joplin Server API
- Must stay compatible with Joplin desktop/mobile/CLI clients
## Changes
### 1. Targeted field search — DONE
Replace the current `ILIKE` on the full JSON blob with extraction of `title` and `body` fields only:
```sql
-- Before
AND convert_from(content, 'UTF8') ILIKE $3
-- After
AND (
convert_from(content, 'UTF8')::json->>'title' ILIKE $3
OR convert_from(content, 'UTF8')::json->>'body' ILIKE $3
)
```
This fixes two problems at once:
- Stops matching on JSON structure, resource IDs, and metadata fields
- Never searches attachment content (only note title and body text)
No database changes required — purely a query change in application code, taking effect immediately at query time.
### 2. pg_trgm GIN indexes — not implemented
Postgres requires all functions in an expression index to be marked `IMMUTABLE`. `convert_from(bytea, text)` is not immutable, so expression indexes on `convert_from(content, 'UTF8')::json->>'title'` fail at creation time with:
> functions in index expression must be marked IMMUTABLE
Workarounds (immutable wrapper functions, generated columns) would require touching the shared `items` table schema more invasively. Given the query fix alone eliminates the main source of waste (full-blob scan, attachment matching), the index was dropped from scope.
### 3. Live search setting — DONE
Add a user setting `liveSearch` (boolean, default `false`) that switches the search input from submit-on-enter to search-on-every-keystroke (≥3 chars, 300ms debounce via htmx).
- Default off — preserves existing behavior for all users
- When enabled, `hx-trigger` changes from `keyup[key==='Enter']` to `input changed delay:300ms` with a minimum length guard
- Controlled via a checkbox in Settings → Appearance, saved via the existing `PUT /api/web/settings` mechanism
- Setting stored in `joplock_settings` JSONB — no schema change needed
## Files changed
| File | Change |
|---|---|
| `app/items/itemService.js` | Update `searchNotes` SQL to search `->>'title'` and `->>'body'` only |
| `app/settingsService.js` | Add `liveSearch` to defaults, normalizer |
| `app/templates.js` | Add `liveSearch` checkbox to Settings → Appearance; pass setting to `noteListFragment`; conditional `hx-trigger` on search input |

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

39
public/codemirror.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

3
public/hljs.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
public/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

6
public/icon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
<rect width="256" height="256" rx="56" fill="#08110b"/>
<rect x="36" y="36" width="184" height="184" rx="28" fill="#0f1811" stroke="#68ff99" stroke-opacity="0.25"/>
<path d="M86 66H114V160C114 184.301 94.3005 204 70 204H62V176H70C78.8366 176 86 168.837 86 160V66Z" fill="#32ff70"/>
<path d="M126 66H194V94H154V204H126V66Z" fill="#2af6d8"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

View file

@ -0,0 +1,43 @@
{
"name": "Joplock",
"short_name": "Joplock",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#08110b",
"theme_color": "#08110b",
"description": "Thin-client web frontend for Joplin Server.",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/maskable-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/maskable-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png",
"purpose": "any"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

48
public/service-worker.js Normal file
View file

@ -0,0 +1,48 @@
const CACHE_NAME = 'joplock-shell-v11';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/hljs.min.js', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png', '/maskable-icon-192.png', '/maskable-icon-512.png', '/apple-touch-icon.png', '/fonts/CascadiaMono-Regular.woff2', '/fonts/CascadiaMono-Bold.woff2'];
self.addEventListener('install', event => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(STATIC_ASSETS);
})(),
);
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)));
})(),
);
self.clients.claim();
});
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Only serve static assets from cache; everything else goes straight to network
if (!STATIC_ASSETS.includes(url.pathname)) return;
event.respondWith(
(async () => {
// Network-first for static assets, cache fallback for offline
try {
const networkResponse = await fetch(event.request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (e) {
const cached = await caches.match(event.request);
if (cached) return cached;
throw e;
}
})(),
);
});

2451
public/styles.css Normal file

File diff suppressed because it is too large Load diff

612
public/turndown.min.js vendored Normal file
View file

@ -0,0 +1,612 @@
var TurndownService = (function () {
'use strict';
function extend(destination) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) destination[key] = source[key];
}
}
return destination;
}
function repeat(character, count) {
return Array(count + 1).join(character);
}
function trimLeadingNewlines(string) {
return string.replace(/^\n*/, '');
}
function trimTrailingNewlines(string) {
var indexEnd = string.length;
while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--;
return string.substring(0, indexEnd);
}
function trimNewlines(string) {
return trimTrailingNewlines(trimLeadingNewlines(string));
}
var blockElements = ['ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS', 'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES', 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL'];
function isBlock(node) {
return is(node, blockElements);
}
var voidElements = ['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'];
function isVoid(node) {
return is(node, voidElements);
}
function hasVoid(node) {
return has(node, voidElements);
}
var meaningfulWhenBlankElements = ['A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT', 'AUDIO', 'VIDEO'];
function isMeaningfulWhenBlank(node) {
return is(node, meaningfulWhenBlankElements);
}
function hasMeaningfulWhenBlank(node) {
return has(node, meaningfulWhenBlankElements);
}
function is(node, tagNames) {
return tagNames.indexOf(node.nodeName) >= 0;
}
function has(node, tagNames) {
return node.getElementsByTagName && tagNames.some(function (tagName) {
return node.getElementsByTagName(tagName).length;
});
}
var markdownEscapes = [[/\\/g, '\\\\'], [/\*/g, '\\*'], [/^-/g, '\\-'], [/^\+ /g, '\\+ '], [/^(=+)/g, '\\$1'], [/^(#{1,6}) /g, '\\$1 '], [/`/g, '\\`'], [/^~~~/g, '\\~~~'], [/\[/g, '\\['], [/\]/g, '\\]'], [/^>/g, '\\>'], [/_/g, '\\_'], [/^(\d+)\. /g, '$1\\. ']];
function escapeMarkdown(string) {
return markdownEscapes.reduce(function (accumulator, escape) {
return accumulator.replace(escape[0], escape[1]);
}, string);
}
var rules = {};
rules.paragraph = {
filter: 'p',
replacement: function (content) {
return '\n\n' + content + '\n\n';
}
};
rules.lineBreak = {
filter: 'br',
replacement: function (content, node, options) {
return options.br + '\n';
}
};
rules.heading = {
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
replacement: function (content, node, options) {
var hLevel = Number(node.nodeName.charAt(1));
if (options.headingStyle === 'setext' && hLevel < 3) {
var underline = repeat(hLevel === 1 ? '=' : '-', content.length);
return '\n\n' + content + '\n' + underline + '\n\n';
} else {
return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n';
}
}
};
rules.blockquote = {
filter: 'blockquote',
replacement: function (content) {
content = trimNewlines(content).replace(/^/gm, '> ');
return '\n\n' + content + '\n\n';
}
};
rules.list = {
filter: ['ul', 'ol'],
replacement: function (content, node) {
var parent = node.parentNode;
if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
return '\n' + content;
} else {
return '\n\n' + content + '\n\n';
}
}
};
rules.listItem = {
filter: 'li',
replacement: function (content, node, options) {
var prefix = options.bulletListMarker + ' ';
var parent = node.parentNode;
if (parent.nodeName === 'OL') {
var start = parent.getAttribute('start');
var index = Array.prototype.indexOf.call(parent.children, node);
prefix = (start ? Number(start) + index : index + 1) + '. ';
}
var isParagraph = /\n$/.test(content);
content = trimNewlines(content) + (isParagraph ? '\n' : '');
content = content.replace(/\n/gm, '\n' + ' '.repeat(prefix.length));
return prefix + content + (node.nextSibling ? '\n' : '');
}
};
rules.indentedCodeBlock = {
filter: function (node, options) {
return options.codeBlockStyle === 'indented' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
},
replacement: function (content, node, options) {
return '\n\n ' + node.firstChild.textContent.replace(/\n/g, '\n ') + '\n\n';
}
};
rules.fencedCodeBlock = {
filter: function (node, options) {
return options.codeBlockStyle === 'fenced' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
},
replacement: function (content, node, options) {
var className = node.firstChild.getAttribute('class') || '';
var language = (className.match(/language-(\S+)/) || [null, ''])[1];
var code = node.firstChild.textContent;
var fenceChar = options.fence.charAt(0);
var fenceSize = 3;
var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');
var match;
while (match = fenceInCodeRegex.exec(code)) {
if (match[0].length >= fenceSize) {
fenceSize = match[0].length + 1;
}
}
var fence = repeat(fenceChar, fenceSize);
return '\n\n' + fence + language + '\n' + code.replace(/\n$/, '') + '\n' + fence + '\n\n';
}
};
rules.horizontalRule = {
filter: 'hr',
replacement: function (content, node, options) {
return '\n\n' + options.hr + '\n\n';
}
};
rules.inlineLink = {
filter: function (node, options) {
return options.linkStyle === 'inlined' && node.nodeName === 'A' && node.getAttribute('href');
},
replacement: function (content, node) {
var href = escapeLinkDestination(node.getAttribute('href'));
var title = escapeLinkTitle(cleanAttribute(node.getAttribute('title')));
var titlePart = title ? ' "' + title + '"' : '';
return '[' + content + '](' + href + titlePart + ')';
}
};
rules.referenceLink = {
filter: function (node, options) {
return options.linkStyle === 'referenced' && node.nodeName === 'A' && node.getAttribute('href');
},
replacement: function (content, node, options) {
var href = escapeLinkDestination(node.getAttribute('href'));
var title = cleanAttribute(node.getAttribute('title'));
if (title) title = ' "' + escapeLinkTitle(title) + '"';
var replacement;
var reference;
switch (options.linkReferenceStyle) {
case 'collapsed':
replacement = '[' + content + '][]';
reference = '[' + content + ']: ' + href + title;
break;
case 'shortcut':
replacement = '[' + content + ']';
reference = '[' + content + ']: ' + href + title;
break;
default:
var id = this.references.length + 1;
replacement = '[' + content + '][' + id + ']';
reference = '[' + id + ']: ' + href + title;
}
this.references.push(reference);
return replacement;
},
references: [],
append: function (options) {
var references = '';
if (this.references.length) {
references = '\n\n' + this.references.join('\n') + '\n\n';
this.references = [];
}
return references;
}
};
rules.emphasis = {
filter: ['em', 'i'],
replacement: function (content, node, options) {
if (!content.trim()) return '';
return options.emDelimiter + content + options.emDelimiter;
}
};
rules.strong = {
filter: ['strong', 'b'],
replacement: function (content, node, options) {
if (!content.trim()) return '';
return options.strongDelimiter + content + options.strongDelimiter;
}
};
rules.code = {
filter: function (node) {
var hasSiblings = node.previousSibling || node.nextSibling;
var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings;
return node.nodeName === 'CODE' && !isCodeBlock;
},
replacement: function (content) {
if (!content) return '';
content = content.replace(/\r?\n|\r/g, ' ');
var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : '';
var delimiter = '`';
var matches = content.match(/`+/gm) || [];
while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`';
return delimiter + extraSpace + content + extraSpace + delimiter;
}
};
rules.image = {
filter: 'img',
replacement: function (content, node) {
var alt = escapeMarkdown(cleanAttribute(node.getAttribute('alt')));
var src = escapeLinkDestination(node.getAttribute('src') || '');
var title = cleanAttribute(node.getAttribute('title'));
var titlePart = title ? ' "' + escapeLinkTitle(title) + '"' : '';
return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : '';
}
};
function cleanAttribute(attribute) {
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '';
}
function escapeLinkDestination(destination) {
var escaped = destination.replace(/([<>()])/g, '\\$1');
return escaped.indexOf(' ') >= 0 ? '<' + escaped + '>' : escaped;
}
function escapeLinkTitle(title) {
return title.replace(/"/g, '\\"');
}
function Rules(options) {
this.options = options;
this._keep = [];
this._remove = [];
this.blankRule = {
replacement: options.blankReplacement
};
this.keepReplacement = options.keepReplacement;
this.defaultRule = {
replacement: options.defaultReplacement
};
this.array = [];
for (var key in options.rules) this.array.push(options.rules[key]);
}
Rules.prototype = {
add: function (key, rule) {
this.array.unshift(rule);
},
keep: function (filter) {
this._keep.unshift({
filter: filter,
replacement: this.keepReplacement
});
},
remove: function (filter) {
this._remove.unshift({
filter: filter,
replacement: function () {
return '';
}
});
},
forNode: function (node) {
if (node.isBlank) return this.blankRule;
var rule;
if (rule = findRule(this.array, node, this.options)) return rule;
if (rule = findRule(this._keep, node, this.options)) return rule;
if (rule = findRule(this._remove, node, this.options)) return rule;
return this.defaultRule;
},
forEach: function (fn) {
for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
}
};
function findRule(rules, node, options) {
for (var i = 0; i < rules.length; i++) {
var rule = rules[i];
if (filterValue(rule, node, options)) return rule;
}
return undefined;
}
function filterValue(rule, node, options) {
var filter = rule.filter;
if (typeof filter === 'string') {
if (filter === node.nodeName.toLowerCase()) return true;
} else if (Array.isArray(filter)) {
if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true;
} else if (typeof filter === 'function') {
if (filter.call(rule, node, options)) return true;
} else {
throw new TypeError('`filter` needs to be a string, array, or function');
}
}
function collapseWhitespace(options) {
var element = options.element;
var isBlock = options.isBlock;
var isVoid = options.isVoid;
var isPre = options.isPre || function (node) {
return node.nodeName === 'PRE';
};
if (!element.firstChild || isPre(element)) return;
var prevText = null;
var keepLeadingWs = false;
var prev = null;
var node = next(prev, element, isPre);
while (node !== element) {
if (node.nodeType === 3 || node.nodeType === 4) {
var text = node.data.replace(/[ \r\n\t]+/g, ' ');
if ((!prevText || / $/.test(prevText.data)) && !keepLeadingWs && text[0] === ' ') {
text = text.substr(1);
}
if (!text) {
node = remove(node);
continue;
}
node.data = text;
prevText = node;
} else if (node.nodeType === 1) {
if (isBlock(node) || node.nodeName === 'BR') {
if (prevText) {
prevText.data = prevText.data.replace(/ $/, '');
}
prevText = null;
keepLeadingWs = false;
} else if (isVoid(node) || isPre(node)) {
prevText = null;
keepLeadingWs = true;
} else if (prevText) {
keepLeadingWs = false;
}
} else {
node = remove(node);
continue;
}
var nextNode = next(prev, node, isPre);
prev = node;
node = nextNode;
}
if (prevText) {
prevText.data = prevText.data.replace(/ $/, '');
if (!prevText.data) {
remove(prevText);
}
}
}
function remove(node) {
var next = node.nextSibling || node.parentNode;
node.parentNode.removeChild(node);
return next;
}
function next(prev, current, isPre) {
if (prev && prev.parentNode === current || isPre(current)) {
return current.nextSibling || current.parentNode;
}
return current.firstChild || current.nextSibling || current.parentNode;
}
var root = typeof window !== 'undefined' ? window : {};
function canParseHTMLNatively() {
var Parser = root.DOMParser;
var canParse = false;
try {
if (new Parser().parseFromString('', 'text/html')) {
canParse = true;
}
} catch (e) {}
return canParse;
}
function createHTMLParser() {
var Parser = function () {};
{
if (shouldUseActiveX()) {
Parser.prototype.parseFromString = function (string) {
var doc = new window.ActiveXObject('htmlfile');
doc.designMode = 'on';
doc.open();
doc.write(string);
doc.close();
return doc;
};
} else {
Parser.prototype.parseFromString = function (string) {
var doc = document.implementation.createHTMLDocument('');
doc.open();
doc.write(string);
doc.close();
return doc;
};
}
}
return Parser;
}
function shouldUseActiveX() {
var useActiveX = false;
try {
document.implementation.createHTMLDocument('').open();
} catch (e) {
if (root.ActiveXObject) useActiveX = true;
}
return useActiveX;
}
var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser();
function RootNode(input, options) {
var root;
if (typeof input === 'string') {
var doc = htmlParser().parseFromString(
'<x-turndown id="turndown-root">' + input + '</x-turndown>', 'text/html');
root = doc.getElementById('turndown-root');
} else {
root = input.cloneNode(true);
}
collapseWhitespace({
element: root,
isBlock: isBlock,
isVoid: isVoid,
isPre: options.preformattedCode ? isPreOrCode : null
});
return root;
}
var _htmlParser;
function htmlParser() {
_htmlParser = _htmlParser || new HTMLParser();
return _htmlParser;
}
function isPreOrCode(node) {
return node.nodeName === 'PRE' || node.nodeName === 'CODE';
}
function Node(node, options) {
node.isBlock = isBlock(node);
node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode;
node.isBlank = isBlank(node);
node.flankingWhitespace = flankingWhitespace(node, options);
return node;
}
function isBlank(node) {
return !isVoid(node) && !isMeaningfulWhenBlank(node) && /^\s*$/i.test(node.textContent) && !hasVoid(node) && !hasMeaningfulWhenBlank(node);
}
function flankingWhitespace(node, options) {
if (node.isBlock || options.preformattedCode && node.isCode) {
return {
leading: '',
trailing: ''
};
}
var edges = edgeWhitespace(node.textContent);
if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) {
edges.leading = edges.leadingNonAscii;
}
if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) {
edges.trailing = edges.trailingNonAscii;
}
return {
leading: edges.leading,
trailing: edges.trailing
};
}
function edgeWhitespace(string) {
var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);
return {
leading: m[1],
leadingAscii: m[2],
leadingNonAscii: m[3],
trailing: m[4],
trailingNonAscii: m[5],
trailingAscii: m[6]
};
}
function isFlankedByWhitespace(side, node, options) {
var sibling;
var regExp;
var isFlanked;
if (side === 'left') {
sibling = node.previousSibling;
regExp = / $/;
} else {
sibling = node.nextSibling;
regExp = /^ /;
}
if (sibling) {
if (sibling.nodeType === 3) {
isFlanked = regExp.test(sibling.nodeValue);
} else if (options.preformattedCode && sibling.nodeName === 'CODE') {
isFlanked = false;
} else if (sibling.nodeType === 1 && !isBlock(sibling)) {
isFlanked = regExp.test(sibling.textContent);
}
}
return isFlanked;
}
var reduce = Array.prototype.reduce;
function TurndownService(options) {
if (!(this instanceof TurndownService)) return new TurndownService(options);
var defaults = {
rules: rules,
headingStyle: 'setext',
hr: '* * *',
bulletListMarker: '*',
codeBlockStyle: 'indented',
fence: '```',
emDelimiter: '_',
strongDelimiter: '**',
linkStyle: 'inlined',
linkReferenceStyle: 'full',
br: ' ',
preformattedCode: false,
blankReplacement: function (content, node) {
return node.isBlock ? '\n\n' : '';
},
keepReplacement: function (content, node) {
return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML;
},
defaultReplacement: function (content, node) {
return node.isBlock ? '\n\n' + content + '\n\n' : content;
}
};
this.options = extend({}, defaults, options);
this.rules = new Rules(this.options);
}
TurndownService.prototype = {
turndown: function (input) {
if (!canConvert(input)) {
throw new TypeError(input + ' is not a string, or an element/document/fragment node.');
}
if (input === '') return '';
var output = process.call(this, new RootNode(input, this.options));
return postProcess.call(this, output);
},
use: function (plugin) {
if (Array.isArray(plugin)) {
for (var i = 0; i < plugin.length; i++) this.use(plugin[i]);
} else if (typeof plugin === 'function') {
plugin(this);
} else {
throw new TypeError('plugin must be a Function or an Array of Functions');
}
return this;
},
addRule: function (key, rule) {
this.rules.add(key, rule);
return this;
},
keep: function (filter) {
this.rules.keep(filter);
return this;
},
remove: function (filter) {
this.rules.remove(filter);
return this;
},
escape: function (string) {
return escapeMarkdown(string);
}
};
function process(parentNode) {
var self = this;
return reduce.call(parentNode.childNodes, function (output, node) {
node = new Node(node, self.options);
var replacement = '';
if (node.nodeType === 3) {
replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue);
} else if (node.nodeType === 1) {
replacement = replacementForNode.call(self, node);
}
return join(output, replacement);
}, '');
}
function postProcess(output) {
var self = this;
this.rules.forEach(function (rule) {
if (typeof rule.append === 'function') {
output = join(output, rule.append(self.options));
}
});
return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
}
function replacementForNode(node) {
var rule = this.rules.forNode(node);
var content = process.call(this, node);
var whitespace = node.flankingWhitespace;
if (whitespace.leading || whitespace.trailing) content = content.trim();
return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
}
function join(output, replacement) {
var s1 = trimTrailingNewlines(output);
var s2 = trimLeadingNewlines(replacement);
var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
var separator = '\n\n'.substring(0, nls);
return s1 + separator + s2;
}
function canConvert(input) {
return input != null && (typeof input === 'string' || input.nodeType && (input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11));
}
return TurndownService;
})();

View file

@ -0,0 +1,117 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..');
const publicDir = path.join(rootDir, 'public');
const splashDir = path.join(publicDir, 'apple-splash');
const splashScreens = [
{ width: 1320, height: 2868 },
{ width: 2868, height: 1320 },
{ width: 1290, height: 2796 },
{ width: 2796, height: 1290 },
{ width: 1179, height: 2556 },
{ width: 2556, height: 1179 },
{ width: 1170, height: 2532 },
{ width: 2532, height: 1170 },
{ width: 1125, height: 2436 },
{ width: 2436, height: 1125 },
{ width: 1242, height: 2688 },
{ width: 2688, height: 1242 },
{ width: 828, height: 1792 },
{ width: 1792, height: 828 },
{ width: 1536, height: 2048 },
{ width: 2048, height: 1536 },
{ width: 1668, height: 2388 },
{ width: 2388, height: 1668 },
{ width: 1640, height: 2360 },
{ width: 2360, height: 1640 },
{ width: 2048, height: 2732 },
{ width: 2732, height: 2048 },
];
fs.mkdirSync(publicDir, { recursive: true });
fs.mkdirSync(splashDir, { recursive: true });
const iconSvg = (size, maskable = false) => {
const inset = Math.round(size * (maskable ? 0.035 : 0.08));
const innerSize = size - inset * 2;
const innerRadius = Math.round(size * (maskable ? 0.24 : 0.19));
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#08110b"/>
<stop offset="100%" stop-color="#06140f"/>
</linearGradient>
<linearGradient id="panel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f1811"/>
<stop offset="100%" stop-color="#0b1614"/>
</linearGradient>
<linearGradient id="accentB" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#32ff70"/>
<stop offset="100%" stop-color="#15d95b"/>
</linearGradient>
<linearGradient id="accentC" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2af6d8"/>
<stop offset="100%" stop-color="#2a8cff"/>
</linearGradient>
</defs>
<rect width="${size}" height="${size}" rx="${Math.round(size * 0.22)}" fill="url(#bg)"/>
<rect x="${inset}" y="${inset}" width="${innerSize}" height="${innerSize}" rx="${innerRadius}" fill="url(#panel)" stroke="#68ff99" stroke-opacity="0.25" stroke-width="${Math.max(2, Math.round(size * 0.008))}"/>
<path d="M${Math.round(size * 0.336)} ${Math.round(size * 0.258)}H${Math.round(size * 0.445)}V${Math.round(size * 0.625)}C${Math.round(size * 0.445)} ${Math.round(size * 0.72)} ${Math.round(size * 0.368)} ${Math.round(size * 0.797)} ${Math.round(size * 0.273)} ${Math.round(size * 0.797)}H${Math.round(size * 0.242)}V${Math.round(size * 0.688)}H${Math.round(size * 0.273)}C${Math.round(size * 0.307)} ${Math.round(size * 0.688)} ${Math.round(size * 0.336)} ${Math.round(size * 0.66)} ${Math.round(size * 0.336)} ${Math.round(size * 0.625)}V${Math.round(size * 0.258)}Z" fill="url(#accentB)"/>
<path d="M${Math.round(size * 0.492)} ${Math.round(size * 0.258)}H${Math.round(size * 0.758)}V${Math.round(size * 0.367)}H${Math.round(size * 0.602)}V${Math.round(size * 0.797)}H${Math.round(size * 0.492)}V${Math.round(size * 0.258)}Z" fill="url(#accentC)"/>
</svg>`;
};
const splashSvg = (width, height) => {
const portrait = height >= width;
const iconBox = Math.round(Math.min(width, height) * (portrait ? 0.28 : 0.22));
const iconX = Math.round((width - iconBox) / 2);
const iconY = Math.round(height * (portrait ? 0.22 : 0.18));
const titleSize = Math.round(Math.min(width, height) * (portrait ? 0.075 : 0.06));
const subtitleSize = Math.round(titleSize * 0.3);
const titleY = iconY + iconBox + Math.round(titleSize * 1.35);
const subtitleY = titleY + Math.round(subtitleSize * 1.9);
const logo = iconSvg(iconBox, false).replace('<svg ', `<svg x="${iconX}" y="${iconY}" `);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#08110b"/>
<stop offset="100%" stop-color="#030806"/>
</linearGradient>
<radialGradient id="glowTop" cx="50%" cy="18%" r="45%">
<stop offset="0%" stop-color="rgba(50,255,112,0.22)"/>
<stop offset="100%" stop-color="rgba(50,255,112,0)"/>
</radialGradient>
<radialGradient id="glowBottom" cx="50%" cy="84%" r="44%">
<stop offset="0%" stop-color="rgba(42,246,216,0.14)"/>
<stop offset="100%" stop-color="rgba(42,246,216,0)"/>
</radialGradient>
</defs>
<rect width="${width}" height="${height}" fill="url(#bg)"/>
<rect width="${width}" height="${height}" fill="url(#glowTop)"/>
<rect width="${width}" height="${height}" fill="url(#glowBottom)"/>
${logo}
<text x="50%" y="${titleY}" text-anchor="middle" fill="#ecfff2" font-size="${titleSize}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-weight="700">Joplock</text>
<text x="50%" y="${subtitleY}" text-anchor="middle" fill="rgba(196,255,214,0.84)" font-size="${subtitleSize}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" letter-spacing="${Math.max(1, Math.round(subtitleSize * 0.22))}">THIN CLIENT FOR JOPLIN</text>
</svg>`;
};
const writePng = async (filePath, svg, size = null) => {
let image = sharp(Buffer.from(svg));
if (size) image = image.resize(size, size);
await image.png().toFile(filePath);
};
await writePng(path.join(publicDir, 'icon-192.png'), iconSvg(192), 192);
await writePng(path.join(publicDir, 'icon-512.png'), iconSvg(512), 512);
await writePng(path.join(publicDir, 'maskable-icon-192.png'), iconSvg(192, true), 192);
await writePng(path.join(publicDir, 'maskable-icon-512.png'), iconSvg(512, true), 512);
await writePng(path.join(publicDir, 'apple-touch-icon.png'), iconSvg(180), 180);
for (const screen of splashScreens) {
await sharp(Buffer.from(splashSvg(screen.width, screen.height))).png().toFile(path.join(splashDir, `${screen.width}x${screen.height}.png`));
}

17
scripts/rebuild-dev.sh Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.dev.yml"
if [[ ! -f "$COMPOSE_FILE" ]]; then
echo "Missing compose file: $COMPOSE_FILE" >&2
exit 1
fi
echo "[joplock] Rebuilding dev app container..."
docker compose -f "$COMPOSE_FILE" build joplock
docker compose -f "$COMPOSE_FILE" up -d --no-deps joplock
echo "[joplock] Dev app container rebuilt."

72
server.js Normal file
View file

@ -0,0 +1,72 @@
const path = require('path');
const { createPoolFromEnv, createSessionService } = require('./app/auth/sessionService');
const { createItemService } = require('./app/items/itemService');
const { createItemWriteService } = require('./app/items/itemWriteService');
const { createSettingsService } = require('./app/settingsService');
const { createHistoryService } = require('./app/historyService');
const { createAdminService } = require('./app/adminService');
const { createServer } = require('./app/createServer');
const host = process.env.HOST || '0.0.0.0';
const port = Number(process.env.PORT || '3001');
const joplinServerOrigin = process.env.JOPLIN_SERVER_ORIGIN || 'http://server:22300';
const joplinPublicBasePath = process.env.JOPLIN_PUBLIC_BASE_PATH || '';
const joplinPublicBaseUrl = process.env.JOPLOCK_PUBLIC_BASE_URL || `http://localhost:${port}`;
const joplinServerPublicUrl = process.env.JOPLIN_SERVER_PUBLIC_URL || `${joplinPublicBaseUrl}${joplinPublicBasePath}`;
const publicDir = path.join(__dirname, 'public');
const adminEmail = process.env.JOPLOCK_ADMIN_EMAIL || '';
const adminPassword = process.env.JOPLOCK_ADMIN_PASSWORD || '';
const ignoreAdminMfa = process.env.IGNORE_ADMIN_MFA === 'true' || process.env.IGNORE_ADMIN_MFA === '1';
const databasePool = createPoolFromEnv(process.env);
const sessionService = createSessionService(databasePool);
const itemService = createItemService(databasePool);
const settingsService = createSettingsService(databasePool);
const historyService = createHistoryService(databasePool);
const itemWriteService = createItemWriteService({
joplinServerOrigin,
joplinServerPublicUrl,
});
const adminService = adminEmail ? createAdminService({
database: databasePool,
joplinServerOrigin,
joplinServerPublicUrl,
adminEmail,
adminPassword,
}) : null;
// Bootstrap admin user (non-blocking, best-effort after server starts)
if (adminService) {
adminService.ensureAdminUser().catch(err => {
process.stderr.write(`[joplock] Admin bootstrap error: ${err.message}\n`);
});
}
const server = createServer({
publicDir,
joplinPublicBasePath,
joplinPublicBaseUrl,
joplinServerPublicUrl,
joplinServerOrigin,
sessionService,
itemService,
settingsService,
historyService,
itemWriteService,
adminService,
adminEmail,
ignoreAdminMfa,
database: databasePool,
debug: process.env.DEBUG === 'true' || process.env.DEBUG === '1',
});
server.listen(port, host, () => {
process.stdout.write(`Joplock listening on http://${host}:${port}\n`);
});
server.on('close', () => {
void databasePool.end();
});

120
tests/adminService.test.js Normal file
View file

@ -0,0 +1,120 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAdminService, isStrongPassword } = require('../app/adminService');
test('isStrongPassword rejects short password', () => {
assert.equal(isStrongPassword('Short1!'), false);
});
test('isStrongPassword rejects password with only one category', () => {
assert.equal(isStrongPassword('aaaaaaaaaaaaa'), false);
});
test('isStrongPassword rejects password with two categories', () => {
assert.equal(isStrongPassword('aaaaAAAAAAAAA'), false);
});
test('isStrongPassword accepts password with 3+ categories and length>=12', () => {
assert.equal(isStrongPassword('aaAAA123456!'), true);
});
test('isStrongPassword accepts lowercase+digits+special', () => {
assert.equal(isStrongPassword('aabbcc123!!!'), true);
});
test('isStrongPassword rejects null/undefined', () => {
assert.equal(isStrongPassword(null), false);
assert.equal(isStrongPassword(undefined), false);
assert.equal(isStrongPassword(''), false);
});
test('isStrongPassword requires at least 12 chars', () => {
assert.equal(isStrongPassword('aA1!aA1!aA1'), false); // 11 chars
assert.equal(isStrongPassword('aA1!aA1!aA1!'), true); // 12 chars
});
test('createUser clears must_set_password with integer 0', async () => {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body: body ? JSON.parse(body) : null });
if (req.method === 'POST' && req.url === '/api/sessions') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'admin-token' }));
return;
}
if (req.method === 'POST' && req.url === '/api/users') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'user-1' }));
return;
}
if (req.method === 'PATCH' && req.url === '/api/users/user-1') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'user-1' }));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
const database = { query: async () => ({ rows: [] }) };
const service = createAdminService({
database,
joplinServerOrigin: `http://127.0.0.1:${port}`,
joplinServerPublicUrl: `http://127.0.0.1:${port}`,
adminEmail: 'admin@example.com',
adminPassword: 'AdminPass123!',
});
try {
await service.createUser('new@example.com', 'New User', 'UserPass123!');
} finally {
await new Promise(resolve => server.close(resolve));
}
const patchReq = requests.find(r => r.method === 'PATCH' && r.url === '/api/users/user-1');
assert.ok(patchReq);
assert.equal(patchReq.body.must_set_password, 0);
});
test('createUser fails when password update fails', async () => {
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/sessions') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'admin-token' }));
return;
}
if (req.method === 'POST' && req.url === '/api/users') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'user-1' }));
return;
}
if (req.method === 'PATCH' && req.url === '/api/users/user-1') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'bad password update' }));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
const database = { query: async () => ({ rows: [] }) };
const service = createAdminService({
database,
joplinServerOrigin: `http://127.0.0.1:${port}`,
joplinServerPublicUrl: `http://127.0.0.1:${port}`,
adminEmail: 'admin@example.com',
adminPassword: 'AdminPass123!',
});
try {
await assert.rejects(() => service.createUser('new@example.com', 'New User', 'UserPass123!'), /bad password update/);
} finally {
await new Promise(resolve => server.close(resolve));
}
});

20
tests/cookies.test.js Normal file
View file

@ -0,0 +1,20 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { parseCookies, sessionIdFromHeaders } = require('../app/auth/cookies');
test('parseCookies should parse cookie header values', () => {
assert.deepEqual(parseCookies('a=1; sessionId=abc123; theme=dark'), {
a: '1',
sessionId: 'abc123',
theme: 'dark',
});
});
test('sessionIdFromHeaders should return sessionId cookie value', () => {
assert.equal(sessionIdFromHeaders({ cookie: 'foo=bar; sessionId=test-session; x=y' }), 'test-session');
});
test('sessionIdFromHeaders should return empty string when missing', () => {
assert.equal(sessionIdFromHeaders({ cookie: 'foo=bar' }), '');
assert.equal(sessionIdFromHeaders({}), '');
});

1334
tests/createServer.test.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,130 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createHistoryService, hashBody } = require('../app/historyService');
const makeDb = (rows = [], queries = []) => ({
query: async (sql, params) => {
queries.push({ sql, params });
if (/SELECT.*joplock_history.*LIMIT 1/.test(sql)) return { rows };
if (/SELECT.*joplock_history/.test(sql) && sql.includes('ORDER BY saved_time')) return { rows };
return { rows: [] };
},
});
test('historyService: hashBody produces consistent hash', () => {
assert.equal(hashBody('hello'), hashBody('hello'));
assert.notEqual(hashBody('hello'), hashBody('world'));
});
test('historyService: saveSnapshot skips when table unavailable', async () => {
const service = createHistoryService({
query: async () => { throw new Error('permission denied'); },
});
// should not throw
await service.saveSnapshot('u1', 'n1', 'Title', 'Body');
});
test('historyService: saveSnapshot inserts first snapshot', async () => {
const queries = [];
const service = createHistoryService({
query: async (sql, params) => {
queries.push({ sql, params });
// Most-recent check returns no previous snapshot
if (sql.includes('SELECT') && sql.includes('LIMIT 1')) return { rows: [] };
return { rows: [] };
},
});
await service.saveSnapshot('u1', 'n1', 'Title', 'Body text');
const insert = queries.find(q => q.sql.includes('INSERT INTO joplock_history'));
assert.ok(insert, 'should insert');
assert.equal(insert.params[0], 'n1');
assert.equal(insert.params[1], 'u1');
assert.equal(insert.params[2], 'Title');
assert.equal(insert.params[3], 'Body text');
});
test('historyService: saveSnapshot skips identical hash', async () => {
const queries = [];
const hash = hashBody('Same body');
const service = createHistoryService({
query: async (sql, params) => {
queries.push({ sql, params });
if (sql.includes('SELECT') && sql.includes('LIMIT 1')) {
return { rows: [{ body_hash: hash, saved_time: Date.now() - 60000 }] };
}
return { rows: [] };
},
});
await service.saveSnapshot('u1', 'n1', 'Title', 'Same body');
const insert = queries.find(q => q.sql.includes('INSERT INTO joplock_history'));
assert.ok(!insert, 'should skip insert when hash identical');
});
test('historyService: saveSnapshot skips when too recent', async () => {
const queries = [];
const service = createHistoryService({
query: async (sql) => {
queries.push({ sql });
if (sql.includes('SELECT') && sql.includes('LIMIT 1')) {
return { rows: [{ body_hash: hashBody('Old body'), saved_time: Date.now() - 5000 }] };
}
return { rows: [] };
},
});
await service.saveSnapshot('u1', 'n1', 'Title', 'New body');
const insert = queries.find(q => q.sql.includes('INSERT INTO joplock_history'));
assert.ok(!insert, 'should skip when last snapshot was < 30s ago');
});
test('historyService: listSnapshots returns empty array when table unavailable', async () => {
const service = createHistoryService({
query: async () => { throw new Error('nope'); },
});
const list = await service.listSnapshots('n1');
assert.deepEqual(list, []);
});
test('historyService: listSnapshots maps rows correctly', async () => {
const rows = [
{ id: '5', title: 'My note', saved_time: '1700000000000' },
{ id: '3', title: 'Earlier', saved_time: '1699000000000' },
];
const service = createHistoryService({
query: async (sql) => {
if (sql.includes('CREATE')) return { rows: [] };
if (sql.includes('CREATE INDEX')) return { rows: [] };
return { rows };
},
});
const list = await service.listSnapshots('n1');
assert.equal(list.length, 2);
assert.equal(list[0].id, '5');
assert.equal(list[0].title, 'My note');
assert.equal(list[0].savedTime, 1700000000000);
});
test('historyService: getSnapshot returns null when not found', async () => {
const service = createHistoryService({
query: async (sql) => {
if (sql.includes('CREATE')) return { rows: [] };
return { rows: [] };
},
});
const snap = await service.getSnapshot('999');
assert.equal(snap, null);
});
test('historyService: getSnapshot returns snapshot with body', async () => {
const service = createHistoryService({
query: async (sql) => {
if (sql.includes('CREATE')) return { rows: [] };
return { rows: [{ id: '7', note_id: 'note-abc', title: 'Hi', body: 'Hello world', saved_time: '1700000000000' }] };
},
});
const snap = await service.getSnapshot('7');
assert.ok(snap);
assert.equal(snap.id, '7');
assert.equal(snap.noteId, 'note-abc');
assert.equal(snap.body, 'Hello world');
assert.equal(snap.savedTime, 1700000000000);
});

66
tests/itemService.test.js Normal file
View file

@ -0,0 +1,66 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { decodeItemContent, mapFolderRow, mapNoteHeaderRow, mapNoteRow } = require('../app/items/itemService');
test('decodeItemContent should parse buffer JSON', () => {
const output = decodeItemContent(Buffer.from('{"title":"Folder A"}', 'utf8'));
assert.equal(output.title, 'Folder A');
});
test('mapFolderRow should combine joplin ids and JSON content', () => {
const folder = mapFolderRow({
jop_id: 'folder1',
jop_parent_id: '',
jop_updated_time: 200,
created_time: 100,
content: Buffer.from('{"title":"Projects","icon":"📁"}', 'utf8'),
});
assert.deepEqual(folder, {
id: 'folder1',
parentId: '',
title: 'Projects',
icon: '📁',
deletedTime: 0,
createdTime: 100,
updatedTime: 200,
});
});
test('mapNoteRow should build preview and note metadata', () => {
const note = mapNoteRow({
jop_id: 'note1',
jop_parent_id: 'folder1',
jop_updated_time: 400,
created_time: 150,
content: Buffer.from('{"title":"Note","body":"Hello world","is_todo":0}', 'utf8'),
});
assert.equal(note.id, 'note1');
assert.equal(note.parentId, 'folder1');
assert.equal(note.title, 'Note');
assert.equal(note.body, 'Hello world');
assert.equal(note.bodyPreview, 'Hello world');
assert.equal(note.updatedTime, 400);
assert.equal(note.createdTime, 150);
assert.equal(note.isTodo, false);
assert.equal(note.todoCompleted, 0);
});
test('mapNoteHeaderRow should use projected note fields', () => {
const note = mapNoteHeaderRow({
jop_id: 'note1',
jop_parent_id: 'folder1',
jop_updated_time: 400,
title: 'Projected Note',
deleted_time: 0,
});
assert.deepEqual(note, {
id: 'note1',
parentId: 'folder1',
title: 'Projected Note',
deletedTime: 0,
updatedTime: 400,
});
});

View file

@ -0,0 +1,53 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { serializeFolder, serializeNote, serializeResource } = require('../app/items/itemWriteService');
test('serializeFolder should include title and parent id', () => {
const folder = serializeFolder({
id: 'folder123',
title: 'Projects',
parentId: 'parent456',
});
assert.equal(folder.id, 'folder123');
assert.equal(folder.path, 'root:/folder123.md:');
assert.match(folder.body, /Projects/);
assert.match(folder.body, /parent_id: parent456/);
assert.match(folder.body, /type_: 2/);
});
test('serializeNote should include title body and parent id', () => {
const note = serializeNote({
id: 'note123',
title: 'Meeting',
body: 'Agenda items',
parentId: 'folder123',
});
assert.equal(note.id, 'note123');
assert.equal(note.path, 'root:/note123.md:');
assert.match(note.body, /Meeting/);
assert.match(note.body, /Agenda items/);
assert.match(note.body, /parent_id: folder123/);
assert.match(note.body, /type_: 1/);
});
test('serializeResource should include mime size and type 4', () => {
const resource = serializeResource({
id: 'res12345678901234567890123456789a',
title: 'photo.png',
mime: 'image/png',
filename: 'photo.png',
fileExtension: 'png',
size: 12345,
});
assert.equal(resource.id, 'res12345678901234567890123456789a');
assert.equal(resource.metaPath, 'root:/res12345678901234567890123456789a.md:');
assert.equal(resource.blobPath, 'root:/.resource/res12345678901234567890123456789a:');
assert.match(resource.body, /photo\.png/);
assert.match(resource.body, /mime: image\/png/);
assert.match(resource.body, /size: 12345/);
assert.match(resource.body, /file_extension: png/);
assert.match(resource.body, /type_: 4/);
});

View file

@ -0,0 +1,181 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { renderMarkdown, layoutPage } = require('../app/templates');
const { JSDOM } = require('jsdom');
const TurndownService = require('../vendor/turndown-lib/turndown.cjs.js');
const previewRoundTrip = markdown => {
const html = renderMarkdown(markdown);
const dom = new JSDOM(`<div id="root">${html}</div>`);
const td = new TurndownService({
headingStyle: 'atx',
hr: '---',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*',
strongDelimiter: '**',
br: '<br>',
});
td.addRule('checkbox', {
filter: node => node.nodeName === 'DIV' && node.classList.contains('md-checkbox'),
replacement: (content, node) => {
const checked = node.classList.contains('checked');
const text = content.replace(/^[\u2611\u2610\u2612\u2705\u00a0 ]+/, '');
return `${checked ? '- [x] ' : '- [ ] '}${text}\n`;
},
});
td.addRule('strikethrough', {
filter: ['del', 's', 'strike'],
replacement: content => content.trim() ? `~~${content.trim()}~~` : '',
});
td.addRule('underline', {
filter: 'u',
replacement: content => content.trim() ? `++${content.trim()}++` : '',
});
td.addRule('emptyDiv', {
filter: node => node.nodeName === 'DIV' && !node.classList.length && (!node.textContent.trim() || node.innerHTML === '<br>'),
replacement: () => '\n<br>\n',
});
td.addRule('emptyP', {
filter: node => node.nodeName === 'P' && !node.querySelector('img') && (!node.textContent.trim() || node.innerHTML === '<br>'),
replacement: () => '\n\n<br>\n\n',
});
td.addRule('blankLine', {
filter: node => node.nodeName === 'DIV' && node.classList.contains('md-blank-line'),
replacement: () => '\x00BL\x00',
});
let md = td.turndown(dom.window.document.getElementById('root').innerHTML);
const nl = String.fromCharCode(10);
const headingGapRe = new RegExp(`^(#{1,6}[^${nl}]*)${nl}{2,}(?=\\S)`, 'gm');
const headingLeadRe = new RegExp(`([^${nl}])${nl}{2,}(#{1,6}\\s)`, 'g');
md = md.split('<br/>').join('<br>');
md = md.split(`<br>${nl}`).join(nl);
while (md.includes('<br><br>')) md = md.split('<br><br>').join(`<br>${nl}`);
md = md.replace(/^-\s{2,}/gm, '- ');
md = md.replace(headingLeadRe, `$1${nl}$2`);
md = md.replace(/^(\d+)\.\s+/gm, '$1. ');
md = md.replace(headingGapRe, `$1${nl}`);
md = md.replace(new RegExp(`${nl}${nl}<br>$`), '');
// Replace runs of blank-line placeholders: count them, emit \n\n + one extra \n per placeholder
md = md.replace(/\n*(?:\x00BL\x00\n*)+/g, m => {
const count = (m.match(/\x00BL\x00/g) || []).length;
return `${nl}${nl}${nl.repeat(count)}`;
});
let out = '';
for (let i = 0; i < md.length; i++) {
const ch = md.charAt(i);
const nx = md.charAt(i + 1);
if (ch === '\\' && ['[', ']', '`', '*', '_', '\\', '$'].includes(nx)) {
out += nx;
i += 1;
continue;
}
out += ch;
}
return out;
};
const previewRoundTripWithCopyButtons = markdown => {
const html = renderMarkdown(markdown);
const dom = new JSDOM(`<div id="root">${html}</div>`);
dom.window.document.querySelectorAll('pre').forEach(pre => {
const btn = dom.window.document.createElement('button');
btn.type = 'button';
btn.className = 'pre-copy-btn';
btn.textContent = 'Copy';
pre.insertBefore(btn, pre.firstChild);
});
const td = new TurndownService({
headingStyle: 'atx',
hr: '---',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*',
strongDelimiter: '**',
br: '<br>',
});
let root = dom.window.document.getElementById('root').cloneNode(true);
root.querySelectorAll('.pre-copy-btn').forEach(btn => btn.remove());
let md = td.turndown(root.innerHTML);
return md;
};
test('preview round-trip preserves printable ascii', () => {
const asciiBody = Array.from({ length: 95 }, (_value, index) => {
const code = index + 32;
return `${String(code).padStart(3, '0')}: before${String.fromCharCode(code)}after`;
}).join('\n');
assert.equal(previewRoundTrip(asciiBody), asciiBody);
});
test('preview round-trip preserves blank-line markers', () => {
const body = 'line one\n<br>\nline two\n<br>\n<br>\nline three';
assert.equal(previewRoundTrip(body), body);
});
test('preview round-trip preserves extra blank lines (triple newlines)', () => {
assert.equal(previewRoundTrip('line one\n\n\nline two'), 'line one\n\n\nline two');
assert.equal(previewRoundTrip('line one\n\n\n\nline two'), 'line one\n\n\n\nline two');
// stable across multiple round-trips
const rt1 = previewRoundTrip('a\n\n\nb\n\n\n\nc');
assert.equal(previewRoundTrip(rt1), rt1);
});
test('preview round-trip does not add blank line after heading followed by text', () => {
const body = '# Heading\nBody';
assert.equal(previewRoundTrip(body), body);
});
test('preview round-trip does not pad around reloaded subheadings', () => {
const body = 'Intro\n## Heading 2\nBody\n### Heading 3\nMore';
assert.equal(previewRoundTrip(body), body);
});
test('preview round-trip preserves fenced code block line breaks', () => {
const body = '```\nline one\nline two\n```';
assert.equal(previewRoundTrip(body), body);
});
test('preview round-trip ignores injected code block copy buttons', () => {
const body = '```\nline one\nline two\n```';
assert.equal(previewRoundTripWithCopyButtons(body), body);
});
test('preview round-trip preserves mixed formatting note', () => {
const body = [
'# Heading 1',
'Intro with **bold**, *italic*, ++underline++, ~~strike~~, and `inline code`.',
'## Heading 2',
'1. First numbered',
'2. Second numbered',
'',
'- Bullet item',
'',
'- [ ] Checkbox item',
'- [x] Checked item',
'',
'> Quoted line',
'',
'```',
'code line one',
'code line two',
'```',
'',
'---',
'### Heading 3',
'[Example](https://example.com)',
'',
'![image](https://example.com/test.png)',
].join('\n');
assert.equal(previewRoundTrip(body), body);
});
test('logged in layout includes htmlToMarkdown normalization for preview save', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
assert.ok(html.includes('function htmlToMarkdown(el){'));
assert.ok(html.includes('var nl=String.fromCharCode(10);'));
assert.ok(html.includes('md=md.split(\'<br/>\').join(\'<br>\')'));
assert.ok(html.includes('while(md.indexOf(\'<br><br>\')>=0)'));
assert.ok(html.includes('ch.charCodeAt(0)===92'));
assert.ok(html.includes('return out'));
});

View file

@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createSettingsService, defaultSettings, normalizeSettings } = require('../app/settingsService');
test('settingsService returns defaults when row is missing', async () => {
const queries = [];
const service = createSettingsService({
query: async (sql, params) => {
queries.push({ sql, params });
if (sql.includes('SELECT')) return { rows: [] };
return { rows: [] };
},
});
const settings = await service.settingsByUserId('user-1');
assert.deepEqual(settings, defaultSettings);
assert.ok(queries[0].sql.includes('CREATE TABLE IF NOT EXISTS joplock_settings'));
});
test('settingsService saves normalized settings as JSON', async () => {
const calls = [];
const service = createSettingsService({
query: async (sql, params) => {
calls.push({ sql, params });
return { rows: [] };
},
});
const saved = await service.saveSettings('user-1', {
noteFontSize: '99',
codeFontSize: '8',
noteMonospace: '1',
resumeLastNote: '1',
lastNoteId: 'note-1',
lastNoteFolderId: '__all_notes__',
dateFormat: 'DD/MM/YYYY',
datetimeFormat: 'DD/MM/YYYY HH:mm',
autoLogout: '1',
autoLogoutMinutes: '30',
});
assert.equal(saved.noteFontSize, 24);
assert.equal(saved.codeFontSize, 10);
assert.equal(saved.noteMonospace, true);
assert.equal(saved.resumeLastNote, true);
assert.equal(saved.lastNoteId, 'note-1');
assert.equal(saved.lastNoteFolderId, '__all_notes__');
assert.equal(saved.dateFormat, 'DD/MM/YYYY');
assert.equal(saved.datetimeFormat, 'DD/MM/YYYY HH:mm');
assert.equal(saved.autoLogout, true);
assert.equal(saved.autoLogoutMinutes, 30);
const insertCall = calls.find(c => c.sql.includes('INSERT INTO joplock_settings'));
assert.ok(insertCall, 'should insert into joplock_settings');
const jsonStr = insertCall.params[1];
const parsed = JSON.parse(jsonStr);
assert.equal(parsed.autoLogout, true);
assert.equal(parsed.autoLogoutMinutes, 30);
});
test('settingsService reads JSON settings from row', async () => {
const stored = { noteFontSize: 18, codeFontSize: 14, noteMonospace: true, dateFormat: 'YYYY-MM-DD', datetimeFormat: 'YYYY-MM-DD HH:mm', autoLogout: true, autoLogoutMinutes: 60 };
const service = createSettingsService({
query: async (sql) => {
if (sql.includes('SELECT')) return { rows: [{ settings: stored }] };
return { rows: [] };
},
});
const settings = await service.settingsByUserId('user-1');
assert.equal(settings.noteFontSize, 18);
assert.equal(settings.autoLogout, true);
assert.equal(settings.autoLogoutMinutes, 60);
});
test('settingsService returns defaults when table creation fails', async () => {
const service = createSettingsService({
query: async () => { throw new Error('permission denied'); },
});
const settings = await service.settingsByUserId('user-1');
assert.deepEqual(settings, defaultSettings);
});
test('normalizeSettings clamps autoLogoutMinutes', () => {
assert.equal(normalizeSettings({ autoLogoutMinutes: 0 }).autoLogoutMinutes, 1);
assert.equal(normalizeSettings({ autoLogoutMinutes: 999 }).autoLogoutMinutes, 480);
assert.equal(normalizeSettings({ autoLogoutMinutes: 'abc' }).autoLogoutMinutes, 15);
assert.equal(normalizeSettings({ autoLogoutMinutes: 120 }).autoLogoutMinutes, 120);
});
test('normalizeSettings coerces autoLogout', () => {
assert.equal(normalizeSettings({ autoLogout: '1' }).autoLogout, true);
assert.equal(normalizeSettings({ autoLogout: true }).autoLogout, true);
assert.equal(normalizeSettings({ autoLogout: 1 }).autoLogout, true);
assert.equal(normalizeSettings({ autoLogout: '0' }).autoLogout, false);
assert.equal(normalizeSettings({ autoLogout: false }).autoLogout, false);
assert.equal(normalizeSettings({ autoLogout: undefined }).autoLogout, false);
});
test('normalizeSettings coerces resumeLastNote and preserves last note state', () => {
const settings = normalizeSettings({ resumeLastNote: '1', lastNoteId: 'n1', lastNoteFolderId: '__all_notes__' });
assert.equal(settings.resumeLastNote, true);
assert.equal(settings.lastNoteId, 'n1');
assert.equal(settings.lastNoteFolderId, '__all_notes__');
assert.equal(normalizeSettings({ resumeLastNote: '0' }).resumeLastNote, false);
});
test('normalizeSettings accepts new matrix theme variants', () => {
assert.equal(normalizeSettings({ theme: 'matrix-blue' }).theme, 'matrix-blue');
assert.equal(normalizeSettings({ theme: 'matrix-purple' }).theme, 'matrix-purple');
assert.equal(normalizeSettings({ theme: 'matrix-amber' }).theme, 'matrix-amber');
assert.equal(normalizeSettings({ theme: 'matrix-orange' }).theme, 'matrix-orange');
assert.equal(normalizeSettings({ theme: 'not-a-theme' }).theme, 'matrix');
});

327
tests/templates.test.js Normal file
View file

@ -0,0 +1,327 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const { autosaveConflictFragment, editorFragment, layoutPage, loggedOutPage, navigationFragment, renderMarkdown, settingsPage, stripMarkdownForTitle } = require('../app/templates');
test('autosaveConflictFragment wires overwrite and create copy actions', () => {
const html = autosaveConflictFragment('n1');
assert.ok(html.includes('hx-put="/fragments/editor/n1"'));
assert.ok(html.includes('hx-vals=\'{"forceSave":"1"}\''));
assert.ok(html.includes('hx-vals=\'{"createCopy":"1"}\''));
assert.ok(html.includes('hx-include="#note-editor-form"'));
});
test('editorFragment shows restore action for trashed note', () => {
const html = editorFragment({ id: 'n1', title: 'Deleted', body: 'Body', parentId: 'f1', deletedTime: 123, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
assert.ok(html.includes('hx-post="/fragments/notes/n1/restore"'));
assert.ok(html.includes('Restore'));
assert.ok(html.includes('Permanently delete this note?'));
assert.ok(!html.includes('Move this note to trash?'));
});
test('editorFragment shows trash delete prompt for active note', () => {
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
assert.ok(html.includes('Move this note to trash?'));
assert.ok(!html.includes('Permanently delete this note?'));
});
test('editorFragment includes date and datetime toolbar buttons', () => {
const html = editorFragment({ id: 'n1', title: 'Active', body: 'Body', parentId: 'f1', deletedTime: 0, createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
assert.ok(html.includes('title="Insert date"'));
assert.ok(html.includes('title="Insert date and time"'));
assert.ok(html.includes('insertStamp(\'date\')'));
assert.ok(html.includes('insertStamp(\'datetime\')'));
assert.ok(html.includes('id="markdown-toggle"'));
assert.ok(html.includes('id="preview-toggle"'));
assert.ok(html.includes('onclick="setEditorMode(\'markdown\')"'));
assert.ok(html.includes('onclick="setEditorMode(\'preview\')"'));
assert.ok(html.includes('title="Rendered Markdown"'));
});
test('navigationFragment shows trash folder empty action', () => {
const html = navigationFragment([{ id: 'de1e7ede1e7ede1e7ede1e7ede1e7ede', title: 'Trash', parentId: '' }], [], '', '');
assert.ok(html.includes('hx-post="/fragments/trash/empty"'));
assert.ok(html.includes('Empty trash permanently?'));
assert.ok(html.includes('&#128465;'));
});
test('navigationFragment shows virtual all notes without notebook actions', () => {
const html = navigationFragment([
{ id: '__all_notes__', title: 'All Notes', parentId: '', isVirtualAllNotes: true },
{ id: 'f1', title: 'Folder 1', parentId: '' },
], [
{ id: 'n1', title: 'Note 1', parentId: 'f1', deletedTime: 0 },
], '__all_notes__', 'n1', '', '__all_notes__');
assert.ok(html.includes('All Notes'));
assert.ok(html.includes('note-item-__all_notes__-n1'));
assert.ok(html.includes('hx-get="/fragments/editor/n1?currentFolderId=__all_notes__"'));
assert.ok(!html.includes('openFolderContextMenu(event,\'__all_notes__\''));
assert.ok(!html.includes('hx-vals=\'{&quot;parentId&quot;:&quot;__all_notes__&quot;}\''));
});
test('navigationFragment only marks the selected note in the clicked context as active', () => {
const html = navigationFragment([
{ id: '__all_notes__', title: 'All Notes', parentId: '', isVirtualAllNotes: true },
{ id: 'f1', title: 'Folder 1', parentId: '' },
], [
{ id: 'n1', title: 'Note 1', parentId: 'f1', deletedTime: 0 },
], '__all_notes__', 'n1', '', '__all_notes__');
assert.ok(html.includes('id="note-item-__all_notes__-n1" class="notelist-item active"'));
assert.ok(html.includes('id="note-item-f1-n1" class="notelist-item"'));
assert.ok(!html.includes('id="note-item-f1-n1" class="notelist-item active"'));
});
test('navigationFragment shows Search Results folder when query is active', () => {
const html = navigationFragment([
{ id: 'f1', title: 'Folder 1', parentId: '' },
{ id: 'f2', title: 'Folder 2', parentId: '' },
], [
{ id: 'n1', title: 'Note 1', parentId: 'f1' },
], '', '', 'note');
assert.ok(html.includes('Search Results'));
assert.ok(!html.includes('Folder 1'));
assert.ok(!html.includes('Folder 2'));
assert.ok(html.includes('Note 1'));
});
test('navigationFragment does not make empty folders expandable', () => {
const html = navigationFragment([
{ id: 'f1', title: 'Folder 1', parentId: '' },
], [], '', '');
assert.ok(html.includes('nav-folder-empty'));
assert.ok(!html.includes('onclick="toggleNavFolder(\'f1\')"'));
assert.ok(html.includes('nav-folder-toggle-placeholder'));
});
test('navigationFragment includes shared folder context menu and modal', () => {
const html = navigationFragment([{ id: 'f1', title: 'Folder 1', parentId: '' }], [], '', '');
assert.ok(html.includes('oncontextmenu="openFolderContextMenu(event,\'f1\',\'Folder 1\')"'));
assert.ok(html.includes('id="folder-context-menu"'));
assert.ok(html.includes('Edit notebook'));
assert.ok(html.includes('Delete notebook'));
assert.ok(html.includes('id="folder-modal"'));
assert.ok(html.includes('onsubmit="submitFolderEdit(event)"'));
});
test('renderMarkdown rewrites raw html resource images without self-closing slash', () => {
const html = renderMarkdown('<img src=":/49a3f012f300473d98a33b97940306b1" alt="x" width="313" height="417">');
assert.ok(html.includes('src="/resources/49a3f012f300473d98a33b97940306b1"'));
assert.ok(html.includes('width="313"'));
assert.ok(html.includes('height="417"'));
});
test('renderMarkdown opens resource links in another tab', () => {
const html = renderMarkdown('[Manual](:/49a3f012f300473d98a33b97940306b1)');
assert.ok(html.includes('href="/resources/49a3f012f300473d98a33b97940306b1"'));
assert.ok(html.includes('target="_blank"'));
assert.ok(html.includes('rel="noopener"'));
});
test('renderMarkdown handles backticks inside fenced code blocks', () => {
const md = '```\n.-```-.\ntest\n```\n\n```\nblock2\n```';
const html = renderMarkdown(md);
const preCount = (html.match(/<pre/g) || []).length;
assert.strictEqual(preCount, 2, 'should produce two code blocks');
assert.ok(html.includes('.-```-.'), 'backticks inside code block should be preserved');
});
test('logged out layout clears client storage and service worker state', () => {
const html = layoutPage({ user: null, loginError: '' });
assert.ok(html.includes('<meta name="theme-color" content="#0b0b0b" />'));
assert.ok(html.includes('<body class="theme-dark-grey'));
assert.ok(html.includes('localStorage.removeItem'));
assert.ok(!html.includes('navigator.serviceWorker.getRegistrations'), 'should not unregister service workers on login page');
assert.ok(!html.includes('caches.keys()'), 'should not clear caches on login page');
});
test('logged out page shows cleanup progress and login link', () => {
const html = loggedOutPage('');
assert.ok(html.includes('Logging out'));
assert.ok(html.includes('Clear local storage'));
assert.ok(!html.includes('Remove service workers'), 'should not show SW removal step');
assert.ok(!html.includes('Clear cached assets'), 'should not show cache clearing step');
assert.ok(html.includes('Cleanup complete'));
assert.ok(html.includes('onclick="toggleLogoutDetail(\'session\')"'));
assert.ok(html.includes('id="logout-detail-session"'));
assert.ok(html.includes('Remove local preferences like theme'));
assert.ok(html.includes('id="logout-login-link"'));
assert.ok(html.includes('Go to login'));
assert.ok(html.includes('check.className=\'logout-step-check\''));
assert.ok(html.includes('check.textContent=\'✓\''));
assert.ok(html.includes('window.toggleLogoutDetail=function(step)'));
assert.ok(html.includes('if(loginLink)loginLink.style.display=\'inline-flex\''));
});
test('logged in layout uses logout navigation link', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
assert.ok(html.includes('<a href="/settings" class="btn btn-icon status-settings-link" title="Settings">&#9881;</a>'));
assert.ok(html.includes('<a href="/logout" class="btn btn-sm btn-secondary logout-link" onclick="return confirmLogout(event)">Logout</a>'));
assert.ok(html.includes('<a href="/logout" class="mobile-header-btn" title="Logout" onclick="return confirmLogout(event)">&#8618;</a>'));
assert.ok(html.includes('function confirmLogout(event){'));
assert.ok(html.includes('Your notes and other server data remain on the server.'));
assert.ok(!html.includes('logoutNow(event)'));
assert.ok(!html.includes('hx-post="/logout"'));
});
test('settings page renders font controls and MFA details', () => {
const html = settingsPage({ user: { email: 'user@example.com' }, settings: { noteFontSize: 17, codeFontSize: 13, noteMonospace: true, dateFormat: 'DD/MM/YYYY', datetimeFormat: 'DD/MM/YYYY HH:mm', autoLogout: true, autoLogoutMinutes: 30 } });
assert.ok(html.includes('Joplock Settings'));
assert.ok(html.includes('id="settings-note-font"'));
assert.ok(html.includes('id="settings-code-font"'));
assert.ok(html.includes('id="settings-note-monospace"'));
assert.ok(html.includes('id="settings-date-format"'));
assert.ok(html.includes('id="settings-datetime-format"'));
assert.ok(html.includes('Two-Factor Authentication'));
assert.ok(html.includes('Use monospace for note text'));
assert.ok(html.includes('Reopen the last edited note on startup'));
assert.ok(html.includes('Enable auto-logout after inactivity'));
assert.ok(html.includes('name="autoLogoutMinutes"'));
assert.ok(html.includes('class="login-eye"'));
assert.ok(html.includes('saveSetting')); // auto-save function
});
test('stripMarkdownForTitle removes common markdown markers from titles', () => {
assert.equal(stripMarkdownForTitle('# **Hello** [world](https://example.com)'), 'Hello world');
assert.equal(stripMarkdownForTitle('![alt text](img.png) `code`'), 'alt text code');
assert.equal(stripMarkdownForTitle('a note in ++generals++'), 'a note in generals');
});
test('navigation and editor render plain note titles without markdown formatting', () => {
const navHtml = navigationFragment([{ id: 'f1', title: 'Folder 1', parentId: '' }], [{ id: 'n1', title: '# **Hello**', parentId: 'f1', deletedTime: 0 }], 'f1', 'n1');
const editorHtml = editorFragment({ id: 'n1', title: '# **Hello**', body: 'Body', parentId: 'f1', createdTime: 1000, updatedTime: 2000 }, [{ id: 'f1', title: 'Folder 1' }]);
assert.ok(navHtml.includes('>Hello<'));
assert.ok(!navHtml.includes('<strong>Hello</strong>'));
assert.ok(editorHtml.includes('data-placeholder="Note title">Hello</div>'));
assert.ok(editorHtml.includes('value="Hello"'));
});
test('logged in layout can render resumed editor content', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '<div>nav</div>', editorContent: '<form id="note-editor-form"></form>' });
assert.ok(html.includes('<form id="note-editor-form"></form>'));
assert.ok(html.includes('<div class="col-editor" id="editor-panel">'));
assert.ok(!html.includes('<div class="col-editor" id="editor-panel">\n\t\t\t<div class="editor-empty">Select a note</div>'));
});
test('logged in layout exposes mobile startup resume data', () => {
const html = layoutPage({
user: { email: 'user@example.com', fullName: 'User' },
navContent: '<div>nav</div>',
mobileEditorContent: '<form id="note-editor-form"><textarea id="note-body">Body</textarea></form>',
mobileStartup: { folderId: '__all_notes__', folderTitle: 'All Notes', noteId: 'n1', noteTitle: 'Hello' },
});
assert.ok(html.includes('var _mobileStartup={"folderId":"__all_notes__","folderTitle":"All Notes","noteId":"n1","noteTitle":"Hello"};'));
assert.ok(html.includes('function activeEditorForm(){if(isMobileShellMode()){'));
assert.ok(html.includes('function queryActiveEditor(selector){var form=activeEditorForm();'));
assert.ok(html.includes('function mobileResumeTarget(){'));
assert.ok(html.includes('<div class="mobile-screen-body mobile-editor-body" id="mobile-editor-body">'));
assert.ok(html.includes('<form id="note-editor-form"><textarea id="note-body">Body</textarea></form>'));
assert.ok(html.includes('showMobileScreen(\'editor\',\'forward\')'));
assert.ok(!html.includes('htmx.ajax(\'GET\',\'/fragments/editor/\'+encodeURIComponent(_mobileNoteId)+\'?currentFolderId=\'+encodeURIComponent(_mobileFolderId),{target:\'#mobile-editor-body\',swap:\'innerHTML\'})'));
assert.ok(!html.includes('function getTA(){return document.getElementById(\'note-body\')}'));
});
test('logged out layout does not show global auth code field', () => {
const html = layoutPage({ user: null, loginError: '' });
assert.ok(!html.includes('Global auth code'));
});
test('logged in layout preserves plain square brackets on preview round trip', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
assert.ok(html.includes('function htmlToMarkdown(el){'));
assert.ok(html.includes('var root=el.cloneNode(true);'));
assert.ok(html.includes("root.querySelectorAll('.pre-copy-btn').forEach(function(btn){btn.remove()})"));
assert.ok(html.includes('getTurndown().turndown(root.innerHTML)'));
assert.ok(html.includes('$1'));
});
test('logged in layout includes extended Joplin theme options', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
assert.ok(html.includes('<option value="matrix-blue">Dark Blue</option>'));
assert.ok(html.includes('<option value="matrix-purple">Dark Purple</option>'));
assert.ok(html.includes('<option value="matrix-amber">Dark Amber</option>'));
assert.ok(html.includes('<option value="matrix-orange">Dark Orange</option>'));
assert.ok(html.includes('<option value="dark-grey">Dark Grey</option>'));
assert.ok(html.includes('<option value="dark-red">Dark Red</option>'));
assert.ok(html.includes('<option value="oled-dark">OLED Dark</option>'));
assert.ok(html.includes('<option value="solarized-light">Solarized Light</option>'));
assert.ok(html.includes('<option value="solarized-dark">Solarized Dark</option>'));
assert.ok(html.includes('<option value="nord">Nord</option>'));
assert.ok(html.includes('<option value="dracula">Dracula</option>'));
assert.ok(html.includes('<option value="aritim-dark">Aritim Dark</option>'));
});
test('logged in layout uses ordered list command and block transforms in preview toolbar', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '' });
assert.ok(html.includes('function syncEditorModeButtons(){'));
assert.ok(html.includes('mdBtn.classList.toggle(\'active\',mode===\'markdown\')'));
assert.ok(html.includes('pvBtn.classList.toggle(\'active\',mode===\'preview\')'));
assert.ok(html.includes('var _previewDirty=false;'));
assert.ok(html.includes('if(pv.contentEditable===\'true\'&&_previewDirty){syncPV()}'));
assert.ok(!html.includes('clean-md-toggle'));
assert.ok(html.includes('function transformPVBlock(tagName,defaultText)'));
assert.ok(html.includes('document.execCommand(\'insertOrderedList\',false,null)'));
assert.ok(html.includes('if(p===\'> \'&&transformPVBlock(\'blockquote\',\'Quote\'))return'));
assert.ok(html.includes('var fenced=String.fromCharCode(10)+String.fromCharCode(96,96,96)+String.fromCharCode(10)'));
assert.ok(html.includes('if(a===fenced&&b===fenced&&transformPVBlock(\'pre\',\'code\'))return'));
assert.ok(html.includes('var inlineCode=String.fromCharCode(96)'));
assert.ok(html.includes('if(a===inlineCode&&b===inlineCode){document.execCommand(\'insertHTML\',false,\'<code spellcheck="false">\'+(window.getSelection().toString()||\'code\')+\'</code>\')'));
assert.ok(html.includes('function formatStamp(kind){'));
assert.ok(html.includes('var _dateFmt='));
assert.ok(html.includes('var _datetimeFmt='));
assert.ok(html.includes('fmt.replace(\'YYYY\''));
assert.ok(html.includes('function insertStamp(kind){insertTxt(formatStamp(kind))}'));
assert.ok(html.includes('var pre=el&&el.closest?el.closest(\'pre\'):null'));
assert.ok(html.includes('if(pre&&pv.contains(pre)){e.preventDefault();'));
assert.ok(html.includes('if(insertPVText(\'\\n\'))syncPV();return}'));
});
test('logged in layout emits inline script that parses', () => {
const html = layoutPage({ user: { email: 'user@example.com', fullName: 'User' }, navContent: '<div></div>' });
const match = html.match(/<script>([\s\S]*)<\/script>\s*<\/body>/);
assert.ok(match);
assert.doesNotThrow(() => new vm.Script(match[1]));
assert.ok(match[1].includes('function openFolderContextMenu(event,id,title)'));
assert.ok(match[1].includes('function submitFolderEdit(event)'));
assert.ok(match[1].includes('window.open(href,\'_blank\',\'noopener\')'));
assert.ok(match[1].includes('document.execCommand(\'insertHTML\',false,\'<a href="/resources/'));
assert.ok(match[1].includes('function confirmLogout(event){'));
});
test('styles define ordered list spacing and matrix note text token', () => {
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
assert.ok(css.includes('--text: #e8fbe8;'));
assert.ok(css.includes('.theme-matrix-blue {'));
assert.ok(css.includes('.theme-matrix-purple {'));
assert.ok(css.includes('.theme-matrix-amber {'));
assert.ok(css.includes('.theme-matrix-orange {'));
assert.ok(css.includes('.editor-preview ul, .editor-preview ol { padding-left: 1.5em; margin: 0.5em 0; }'));
assert.ok(css.includes('.editor-preview > h1:first-child,'));
assert.ok(css.includes('.logout-progress {'));
assert.ok(css.includes('.logout-step.done {'));
assert.ok(css.includes('.logout-step-check {'));
assert.ok(css.includes('.logout-detail {'));
assert.ok(css.includes('.logout-detail.open {'));
assert.ok(css.includes('body.note-body-monospace,'));
assert.ok(css.includes('.status-settings-link {'));
assert.ok(css.includes('.settings-page {'));
assert.ok(css.includes('.settings-form {'));
assert.ok(css.includes('.settings-actions {'));
assert.ok(css.includes('.settings-qr {'));
assert.ok(css.includes('.btn.active {'));
assert.ok(css.includes('--font-size-note: 15px;'));
assert.ok(css.includes('--font-size-code: 12px;'));
assert.ok(css.includes('font-size: var(--font-size-note);'));
assert.ok(css.includes('font-size: var(--font-size-code);'));
});
test('styles color folders differently from notes', () => {
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
assert.ok(css.includes('.nav-folder-title {'));
assert.ok(css.includes('.sidebar-item-name {'));
assert.ok(css.includes('.notelist-item-title {'));
assert.ok(css.includes('color: var(--accent);'));
assert.ok(css.includes('color: var(--text);'));
});

File diff suppressed because it is too large Load diff

1800
vendor/turndown-lib/turndown.cjs.js vendored Normal file

File diff suppressed because it is too large Load diff