fix mobile resume startup and editor targeting
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.git
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
data
|
||||
.env
|
||||
55
.github/workflows/docker-publish.yml
vendored
Normal 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
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
npm-debug.log*
|
||||
.env
|
||||
.DS_Store
|
||||
data/
|
||||
docker-compose.dev.yml
|
||||
382
AGENT_GUIDE.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
73
app/auth/sessionService.js
Normal 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
129
app/historyService.js
Normal 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
|
|
@ -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,
|
||||
};
|
||||
295
app/items/itemWriteService.js
Normal 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
|
|
@ -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
66
cm-build/index.js
Normal 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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
67
docker-compose.example-full-build.yml
Normal 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:
|
||||
84
docker-compose.example-full.yml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
33
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
57
plans/code-block-highlighting.md
Normal 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
|
|
@ -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
|
|
@ -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 |
|
||||
BIN
public/apple-splash/1125x2436.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
public/apple-splash/1170x2532.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
public/apple-splash/1179x2556.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
public/apple-splash/1242x2688.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
public/apple-splash/1290x2796.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
public/apple-splash/1320x2868.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
public/apple-splash/1536x2048.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
public/apple-splash/1640x2360.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
public/apple-splash/1668x2388.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
public/apple-splash/1792x828.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/apple-splash/2048x1536.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
public/apple-splash/2048x2732.png
Normal file
|
After Width: | Height: | Size: 396 KiB |
BIN
public/apple-splash/2360x1640.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
public/apple-splash/2388x1668.png
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
public/apple-splash/2436x1125.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
public/apple-splash/2532x1170.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
public/apple-splash/2556x1179.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
public/apple-splash/2688x1242.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
public/apple-splash/2732x2048.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
public/apple-splash/2796x1290.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
public/apple-splash/2868x1320.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
public/apple-splash/828x1792.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
39
public/codemirror.min.js
vendored
Normal file
BIN
public/fonts/CascadiaMono-Bold.woff2
Normal file
BIN
public/fonts/CascadiaMono-Regular.woff2
Normal file
3
public/hljs.min.js
vendored
Normal file
1
public/htmx.min.js
vendored
Normal file
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
6
public/icon.svg
Normal 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 |
43
public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/maskable-icon-192.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/maskable-icon-512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
48
public/service-worker.js
Normal 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
612
public/turndown.min.js
vendored
Normal 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;
|
||||
})();
|
||||
117
scripts/generatePwaAssets.mjs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
130
tests/historyService.test.js
Normal 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
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
53
tests/itemWriteService.test.js
Normal 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/);
|
||||
});
|
||||
181
tests/previewRoundTrip.test.js
Normal 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)',
|
||||
'',
|
||||
'',
|
||||
].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'));
|
||||
});
|
||||
109
tests/settingsService.test.js
Normal 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
|
|
@ -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('🗑'));
|
||||
});
|
||||
|
||||
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=\'{"parentId":"__all_notes__"}\''));
|
||||
});
|
||||
|
||||
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">⚙</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)">↪</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(' `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);'));
|
||||
});
|
||||