Standalone extra improvements (#9191)

This commit is contained in:
Elian Doran
2026-03-27 09:15:03 +02:00
committed by GitHub
110 changed files with 3405 additions and 3121 deletions

2
.nvmrc
View File

@@ -1 +1 @@
24.14.0
24.14.1

274
CLAUDE.md
View File

@@ -4,149 +4,197 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
Trilium Notes is a hierarchical note-taking application with synchronization, scripting, and rich text editing. TypeScript monorepo using pnpm with multiple apps and shared packages.
## Development Commands
### Setup
- `pnpm install` - Install all dependencies
- `corepack enable` - Enable pnpm if not available
```bash
# Setup
corepack enable && pnpm install
### Running Applications
- `pnpm run server:start` - Start development server (http://localhost:8080)
- `pnpm run server:start-prod` - Run server in production mode
# Run
pnpm server:start # Dev server at http://localhost:8080
pnpm desktop:start # Electron dev app
pnpm standalone:start # Standalone client dev
### Building
- `pnpm run client:build` - Build client application
- `pnpm run server:build` - Build server application
- `pnpm run electron:build` - Build desktop application
# Build
pnpm client:build # Frontend
pnpm server:build # Backend
pnpm desktop:build # Electron
### Testing
- `pnpm test:all` - Run all tests (parallel + sequential)
- `pnpm test:parallel` - Run tests that can run in parallel
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
- `pnpm coverage` - Generate coverage reports
# Test
pnpm test:all # All tests (parallel + sequential)
pnpm test:parallel # Client + most package tests
pnpm test:sequential # Server, ckeditor5-mermaid, ckeditor5-math (shared DB)
pnpm --filter server test # Single package tests
pnpm coverage # Coverage reports
## Architecture Overview
# Lint & Format
pnpm dev:linter-check # ESLint check
pnpm dev:linter-fix # ESLint fix
pnpm dev:format-check # Format check (stricter stylistic rules)
pnpm dev:format-fix # Format fix
pnpm typecheck # TypeScript type check across all projects
```
### Monorepo Structure
- **apps/**: Runnable applications
- `client/` - Frontend application (shared by server and desktop)
- `server/` - Node.js server with web interface
- `desktop/` - Electron desktop application
- `web-clipper/` - Browser extension for saving web content
- Additional tools: `db-compare`, `dump-db`, `edit-docs`
**Running a single test file**: `pnpm --filter server test spec/etapi/search.spec.ts`
- **packages/**: Shared libraries
- `commons/` - Shared interfaces and utilities
- `ckeditor5/` - Custom rich text editor with Trilium-specific plugins
- `codemirror/` - Code editor customizations
- `highlightjs/` - Syntax highlighting
- Custom CKEditor plugins: `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid`
## Main Applications
### Core Architecture Patterns
The four main apps share `packages/trilium-core/` for business logic but differ in runtime:
#### Three-Layer Cache System
- **Becca** (Backend Cache): Server-side entity cache (`apps/server/src/becca/`)
- **Froca** (Frontend Cache): Client-side mirror of backend data (`apps/client/src/services/froca.ts`)
- **Shaca** (Share Cache): Optimized cache for shared/published notes (`apps/server/src/share/`)
- **client** (`apps/client/`): Preact frontend with jQuery widget system. Shared UI layer used by both server and desktop.
- **server** (`apps/server/`): Node.js backend (Express, better-sqlite3). Serves the client and provides REST/WebSocket APIs.
- **desktop** (`apps/desktop/`): Electron wrapper around server + client, running both in a single process.
- **standalone** (`apps/client-standalone/` + `apps/standalone-desktop/`): Runs the entire stack in the browser — server logic compiled to WASM via sql.js, executed in a service worker. No Node.js dependency at runtime.
#### Entity System
Core entities are defined in `apps/server/src/becca/entities/`:
- `BNote` - Notes with content and metadata
- `BBranch` - Hierarchical relationships between notes (allows multiple parents)
- `BAttribute` - Key-value metadata attached to notes
- `BRevision` - Note version history
- `BOption` - Application configuration
## Monorepo Structure
#### Widget-Based UI
Frontend uses a widget system (`apps/client/src/widgets/`):
- `BasicWidget` - Base class for all UI components
- `NoteContextAwareWidget` - Widgets that respond to note changes
- `RightPanelWidget` - Widgets displayed in the right panel
```
apps/
client/ # Preact frontend (shared by server, desktop, standalone)
server/ # Node.js backend (Express, better-sqlite3)
desktop/ # Electron (bundles server + client)
client-standalone/ # Standalone client (WASM + service workers, no Node.js)
standalone-desktop/ # Standalone desktop variant
server-e2e/ # Playwright E2E tests for server
web-clipper/ # Browser extension
website/ # Project website
db-compare/, dump-db/, edit-docs/, build-docs/, icon-pack-builder/
packages/
trilium-core/ # Core business logic: entities, services, SQL, sync
commons/ # Shared interfaces and utilities
ckeditor5/ # Custom rich text editor bundle
codemirror/ # Code editor integration
highlightjs/ # Syntax highlighting
share-theme/ # Theme for shared/published notes
ckeditor5-admonition/, ckeditor5-footnotes/, ckeditor5-math/, ckeditor5-mermaid/
ckeditor5-keyboard-marker/, express-partial-content/, pdfjs-viewer/, splitjs/
turndown-plugin-gfm/
```
Use `pnpm --filter <package-name> <command>` to run commands in specific packages.
## Core Architecture
### Three-Layer Cache System
All data access goes through cache layers — never bypass with direct DB queries:
- **Becca** (`packages/trilium-core/src/becca/`): Server-side entity cache. Access via `becca.notes[noteId]`.
- **Froca** (`apps/client/src/services/froca.ts`): Client-side mirror synced via WebSocket. Access via `froca.getNote()`.
- **Shaca** (`apps/server/src/share/`): Optimized cache for shared/published notes.
**Critical**: Always use cache methods, not direct DB writes. Cache methods create `EntityChange` records needed for synchronization.
### Entity System
Core entities live in `packages/trilium-core/src/becca/entities/` (not `apps/server/`):
- `BNote` — Notes with content and metadata
- `BBranch` — Multi-parent tree relationships (cloning supported)
- `BAttribute` — Key-value metadata (labels and relations)
- `BRevision` — Version history
- `BOption` — Application configuration
- `BBlob` — Binary content storage
Entities extend `AbstractBeccaEntity<T>` with built-in change tracking, hash generation, and date management.
### Entity Change & Sync Protocol
Every entity modification creates an `EntityChange` record driving sync:
1. Login with HMAC authentication (document secret + timestamp)
2. Push changes → Pull changes → Push again (conflict resolution)
3. Content hash verification with retry loop
Sync services: `packages/trilium-core/src/services/sync.ts`, `syncMutexService`, `syncUpdateService`.
### Widget-Based UI
Frontend widgets in `apps/client/src/widgets/`:
- `BasicWidget` / `TypedBasicWidget` — Base classes (jQuery `this.$widget` for DOM)
- `NoteContextAwareWidget` — Responds to note changes
- `RightPanelWidget` — Sidebar widgets with position ordering
- Type-specific widgets in `type_widgets/` directory
#### API Architecture
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
**Widget lifecycle**: `doRenderBody()` for initial render, `refreshWithNote()` for note changes, `entitiesReloadedEvent({loadResults})` for entity updates. Uses jQuery — don't mix React patterns.
### Key Files for Understanding Architecture
Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position-based ordering.
1. **Application Entry Points**:
- `apps/server/src/main.ts` - Server startup
- `apps/client/src/desktop.ts` - Client initialization
### API Architecture
2. **Core Services**:
- `apps/server/src/becca/becca.ts` - Backend data management
- `apps/client/src/services/froca.ts` - Frontend data synchronization
- `apps/server/src/services/backend_script_api.ts` - Scripting API
- **Internal API** (`apps/server/src/routes/api/`): REST endpoints, trusts frontend
- **ETAPI** (`apps/server/src/etapi/`): External API with basic auth tokens — maintain backwards compatibility
- **WebSocket** (`apps/server/src/services/ws.ts`): Real-time sync
3. **Database Schema**:
- `apps/server/src/assets/db/schema.sql` - Core database structure
### Platform Abstraction
4. **Configuration**:
- `package.json` - Project dependencies and scripts
`packages/trilium-core/src/services/platform.ts` defines `PlatformProvider` interface with implementations in `apps/desktop/`, `apps/server/`, and `apps/client-standalone/`. Singleton via `initPlatform()`/`getPlatform()`.
## Note Types and Features
**PlatformProvider** provides:
- `crash(message)` — Platform-specific fatal error handling
- `getEnv(key)` — Environment variable access (server/desktop use `process.env`, standalone maps URL query params like `?safeMode``TRILIUM_SAFE_MODE`)
- `isElectron`, `isMac`, `isWindows` — Platform detection flags
Trilium supports multiple note types, each with specialized widgets:
- **Text**: Rich text with CKEditor5 (markdown import/export)
- **Code**: Syntax-highlighted code editing with CodeMirror
- **File**: Binary file attachments
- **Image**: Image display with editing capabilities
- **Canvas**: Drawing/diagramming with Excalidraw
- **Mermaid**: Diagram generation
- **Relation Map**: Visual note relationship mapping
- **Web View**: Embedded web pages
- **Doc/Book**: Hierarchical documentation structure
**Critical rules for `trilium-core`**:
- **No `process.env` in core** — use `getPlatform().getEnv()` instead (not available in standalone/browser)
- **No `import path from "path"` in core** — Node's `path` module is externalized in browser builds. Use `packages/trilium-core/src/services/utils/path.ts` for `extname()`/`basename()` equivalents
- **No Node.js built-in modules in core** — core runs in both Node.js and the browser (standalone). Use platform-agnostic alternatives or platform providers
- **Platform detection via functions** — `isElectron()`, `isMac()`, `isWindows()` from `utils/index.ts` are functions (not constants) that call `getPlatform()`. They can only be called after `initializeCore()`, not at module top-level. If used in static definitions, wrap in a closure: `value: () => isWindows() ? "0.9" : "1.0"`
- **Barrel import caution** — `import { x } from "@triliumnext/core"` loads ALL core exports. Early-loading modules like `config.ts` should import specific subpaths (e.g. `@triliumnext/core/src/services/utils/index`) to avoid circular dependencies or initialization ordering issues
- **Electron IPC** — In desktop mode, client API calls use Electron IPC (not HTTP). The IPC handler in `apps/server/src/routes/electron.ts` must be registered via `utils.isElectron` from the **server's** utils (which correctly checks `process.versions["electron"]`), not from core's utils
## Development Guidelines
### Database
### Testing Strategy
- Server tests run sequentially due to shared database
- Client tests can run in parallel
- E2E tests use Playwright for both server and desktop apps
- Build validation tests check artifact integrity
SQLite via `better-sqlite3`. SQL abstraction in `packages/trilium-core/src/services/sql/` with `DatabaseProvider` interface, prepared statement caching, and transaction support.
### Scripting System
Trilium provides powerful user scripting capabilities:
- Frontend scripts run in browser context
- Backend scripts run in Node.js context with full API access
- Script API documentation available in `docs/Script API/`
- Schema: `apps/server/src/assets/db/schema.sql`
- Migrations: `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
### Internationalization
- Translation files in `apps/client/src/translations/`
- Supported languages: English, German, Spanish, French, Romanian, Chinese
### Attribute Inheritance
### Security Considerations
- Per-note encryption with granular protected sessions
- CSRF protection for API endpoints
- OpenID and TOTP authentication support
- Sanitization of user-generated content
Three inheritance mechanisms:
1. **Standard**: `note.getInheritableAttributes()` walks parent tree
2. **Child prefix**: `child:label` on parent copies to children
3. **Template relation**: `#template=noteNoteId` includes template's inheritable attributes
## Common Development Tasks
Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited.
### Adding New Note Types
1. Create widget in `apps/client/src/widgets/type_widgets/`
2. Register in `apps/client/src/services/note_types.ts`
3. Add backend handling in `apps/server/src/services/notes.ts`
## Important Patterns
### Extending Search
- Search expressions handled in `apps/server/src/services/search/`
- Add new search operators in search context files
- **Protected notes**: Check `note.isContentAvailable()` before accessing content; use `note.getTitleOrProtected()` for safe title access
- **Long operations**: Use `TaskContext` for progress reporting via WebSocket
- **Event system** (`packages/trilium-core/src/services/events.ts`): Events emitted in order (notes → branches → attributes) during load for referential integrity
- **Search**: Expression-based, scoring happens in-memory — cannot add SQL-level LIMIT/OFFSET without losing scoring
- **Widget cleanup**: Unsubscribe from events in `cleanup()`/`doDestroy()` to prevent memory leaks
### Custom CKEditor Plugins
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
## Code Style
### Database Migrations
- Add migration scripts in `apps/server/src/migrations/`
- Update schema in `apps/server/src/assets/db/schema.sql`
- 4-space indentation, semicolons always required
- Double quotes (enforced by format config)
- Max line length: 100 characters
- Unix line endings
- Import sorting via `eslint-plugin-simple-import-sort`
## Build System Notes
- Uses pnpm for monorepo management
- Vite for fast development builds
- ESBuild for production optimization
- pnpm workspaces for dependency management
- Docker support with multi-stage builds
## Testing
- **Server tests** (`apps/server/spec/`): Vitest, must run sequentially (shared DB), forks pool, max 6 workers
- **Client tests** (`apps/client/src/`): Vitest with happy-dom environment, can run in parallel
- **E2E tests** (`apps/server-e2e/`): Playwright, Chromium, server started automatically on port 8082
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
## Documentation
- `docs/Script API/` — Auto-generated, never edit directly
- `docs/User Guide/` — Edit via `pnpm edit-docs:edit-docs`, not manually
- `docs/Developer Guide/` and `docs/Release Notes/` — Safe for direct Markdown editing
## Key Entry Points
- `apps/server/src/main.ts` — Server startup
- `apps/client/src/desktop.ts` — Client initialization
- `packages/trilium-core/src/becca/becca.ts` — Backend data management
- `apps/client/src/services/froca.ts` — Frontend cache
- `apps/server/src/routes/routes.ts` — API route registration
- `packages/trilium-core/src/services/sql/sql.ts` — Database abstraction

View File

@@ -14,15 +14,15 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.32.1",
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@redocly/cli": "2.24.1",
"@redocly/cli": "2.25.1",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"typedoc": "0.28.17",
"typedoc": "0.28.18",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

@@ -20,10 +20,10 @@
"@fullcalendar/multimonth": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.0",
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.5.1",
"@preact/signals": "2.8.2",
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
@@ -32,7 +32,7 @@
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.1",
"@zumer/snapdom": "2.6.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
@@ -40,48 +40,48 @@
"color": "5.0.3",
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "17.0.0",
"i18next": "25.7.3",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "25.10.10",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"js-sha1": "0.7.0",
"js-sha256": "0.11.1",
"js-sha512": "0.9.0",
"jsplumb": "2.15.6",
"katex": "0.16.27",
"katex": "0.16.43",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.4.0",
"marked": "17.0.5",
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.2",
"react-i18next": "16.5.1",
"react-window": "2.2.3",
"reveal.js": "5.2.1",
"panzoom": "9.4.4",
"preact": "10.29.0",
"react-i18next": "16.6.6",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"tabulator-tables": "6.4.0",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0",
"@preact/preset-vite": "2.10.2",
"@types/bootstrap": "5.2.10",
"@types/jquery": "3.5.33",
"@types/jquery": "4.0.0",
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "13.0.1",
"copy-webpack-plugin": "14.0.0",
"cross-env": "7.0.3",
"happy-dom": "20.0.11",
"happy-dom": "20.8.8",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.4"
"vite-plugin-static-copy": "3.4.0"
}
}

View File

@@ -4,7 +4,7 @@
*/
import { BootstrapDefinition } from '@triliumnext/commons';
import { entity_changes, getContext, getSharedBootstrapItems, getSql, routes, sql_init } from '@triliumnext/core';
import { entity_changes, getContext, getPlatform, getSharedBootstrapItems, getSql, routes, sql_init } from '@triliumnext/core';
import packageJson from '../../package.json' with { type: 'json' };
import { type BrowserRequest, BrowserRouter } from './browser_router';
@@ -241,41 +241,36 @@ function bootstrapRoute(): BootstrapDefinition {
const assetPath = ".";
const isDbInitialized = sql_init.isDbInitialized();
const commonItems = getSharedBootstrapItems(assetPath, isDbInitialized);
const commonItems = {
...getSharedBootstrapItems(assetPath, isDbInitialized),
isDev: import.meta.env.DEV,
isStandalone: true,
isMainWindow: true,
isElectron: false,
hasNativeTitleBar: false,
hasBackgroundEffects: false,
triliumVersion: packageJson.version,
device: false as const, // Let the client detect device type.
appPath: assetPath,
instanceName: "standalone",
TRILIUM_SAFE_MODE: !!getPlatform().getEnv("TRILIUM_SAFE_MODE")
};
if (!isDbInitialized) {
return {
...commonItems,
isStandalone: true,
baseApiUrl: "../api/",
isProtectedSessionAvailable: false,
};
}
return {
...commonItems,
appPath: assetPath,
device: false, // Let the client detect device type.
csrfToken: "dummy-csrf-token",
themeCssUrl: false,
themeUseNextAsBase: "next",
triliumVersion: packageJson.version,
baseApiUrl: "../api/",
headingStyle: "plain",
layoutOrientation: "vertical",
platform: "web",
isDev: import.meta.env.DEV,
isMainWindow: true,
isElectron: false,
isStandalone: true,
hasNativeTitleBar: false,
hasBackgroundEffects: false,
// TODO: Fill properly
currentLocale: { id: "en", name: "English", rtl: false },
isRtl: false,
instanceName: null,
appCssNoteIds: [],
TRILIUM_SAFE_MODE: false
};
}

View File

@@ -1,6 +1,27 @@
import type { PlatformProvider } from "@triliumnext/core";
/** Maps URL query parameter names to TRILIUM_ environment variable names. */
const QUERY_TO_ENV: Record<string, string> = {
"safeMode": "TRILIUM_SAFE_MODE",
"startNoteId": "TRILIUM_START_NOTE_ID",
};
export default class StandalonePlatformProvider implements PlatformProvider {
readonly isElectron = false;
readonly isMac = false;
readonly isWindows = false;
private envMap: Record<string, string> = {};
constructor(queryString: string) {
const params = new URLSearchParams(queryString);
for (const [queryKey, envKey] of Object.entries(QUERY_TO_ENV)) {
if (params.has(queryKey)) {
this.envMap[envKey] = params.get(queryKey) || "true";
}
}
}
crash(message: string): void {
console.error("[Standalone] FATAL:", message);
self.postMessage({
@@ -8,4 +29,8 @@ export default class StandalonePlatformProvider implements PlatformProvider {
message
});
}
getEnv(key: string): string | undefined {
return this.envMap[key];
}
}

View File

@@ -9,6 +9,7 @@ function showFatalErrorDialog(message: string) {
export function startLocalServerWorker() {
if (localWorker) return localWorker;
localWorker = new LocalServerWorker();
localWorker.postMessage({ type: "INIT", queryString: location.search });
// Handle worker errors during initialization
localWorker.onerror = (event) => {

View File

@@ -69,6 +69,7 @@ let coreModule: typeof import("@triliumnext/core") | null = null;
let router: BrowserRouter | null = null;
let initPromise: Promise<void> | null = null;
let initError: Error | null = null;
let queryString = "";
/**
* Load all required modules using dynamic imports.
@@ -153,7 +154,7 @@ async function initialize(): Promise<void> {
crypto: new BrowserCryptoProvider(),
messaging: messagingProvider!,
request: new FetchRequestProvider(),
platform: new StandalonePlatformProvider(),
platform: new StandalonePlatformProvider(queryString),
translations: translationProvider,
schema: schemaModule.default,
dbConfig: {
@@ -241,7 +242,14 @@ initialize().catch(err => {
self.onmessage = async (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_REQUEST") return;
if (!msg) return;
if (msg.type === "INIT") {
queryString = msg.queryString || "";
return;
}
if (msg.type !== "LOCAL_REQUEST") return;
const { id, request } = msg;

View File

@@ -43,7 +43,7 @@
"@univerjs/preset-sheets-note": "0.18.0",
"@univerjs/preset-sheets-sort": "0.18.0",
"@univerjs/presets": "0.18.0",
"@zumer/snapdom": "2.5.0",
"@zumer/snapdom": "2.6.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
@@ -53,12 +53,12 @@
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "25.10.3",
"i18next": "25.10.10",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.40",
"katex": "0.16.43",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
@@ -66,9 +66,9 @@
"mermaid": "11.13.0",
"mind-elixir": "5.9.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"panzoom": "9.4.4",
"preact": "10.29.0",
"react-i18next": "16.6.0",
"react-i18next": "16.6.6",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
@@ -86,9 +86,9 @@
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.8.4",
"happy-dom": "20.8.8",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.3.0"
"vite-plugin-static-copy": "3.4.0"
}
}

View File

@@ -7,16 +7,15 @@ import { t } from "./i18n.js";
import options from "./options.js";
import server from "./server.js";
import toastService from "./toast.js";
import toast from "./toast.js";
import utils from "./utils.js";
type MessageHandler = (message: WebSocketMessage) => void;
let messageHandlers: MessageHandler[] = [];
let ws: WebSocket;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad;
let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad ?? 0;
let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad ?? 0;
let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad ?? 0;
let lastPingTs: number;
let frontendUpdateDataQueue: EntityChange[] = [];
@@ -261,7 +260,7 @@ async function sendPing() {
if (Date.now() - lastPingTs > 30000) {
console.warn(utils.now(), "Lost websocket connection to the backend");
toast.showPersistent({
toastService.showPersistent({
id: "lost-websocket-connection",
title: t("ws.lost-websocket-connection-title"),
message: t("ws.lost-websocket-connection-message"),
@@ -270,7 +269,7 @@ async function sendPing() {
}
if (ws.readyState === ws.OPEN) {
toast.closePersistent("lost-websocket-connection");
toastService.closePersistent("lost-websocket-connection");
ws.send(
JSON.stringify({
type: "ping",

View File

@@ -93,7 +93,10 @@
"digits": "dígits",
"inheritable": "Heretable",
"delete": "Suprimeix",
"color_type": "Color"
"color_type": "Color",
"textarea": "Text multi linia",
"date_time": "Data i hora",
"precision_title": "Quants dígits han d'estar disponibles per a coma flotant a la interfície de configuració."
},
"rename_label": {
"to": "Per"

View File

@@ -446,7 +446,8 @@
"and_more": "... 以及另外 {{count}} 个。",
"print_landscape": "导出为 PDF 时,将页面方向更改为横向而不是纵向。",
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
"color_type": "颜色"
"color_type": "颜色",
"textarea": "多行文本"
},
"attribute_editor": {
"help_text_body1": "要添加标签,只需输入例如 <code>#rock</code> 或者如果您还想添加值,则例如 <code>#year = 2020</code>",
@@ -2167,5 +2168,52 @@
},
"setup_form": {
"more_info": "了解更多"
},
"media": {
"play": "播放(空格)",
"pause": "暂停(空格)",
"back-10s": "后退10秒左箭头键",
"forward-30s": "前进30秒",
"mute": "静音M",
"unmute": "取消静音M",
"playback-speed": "播放速度",
"loop": "循环播放",
"disable-loop": "禁用循环播放",
"rotate": "旋转",
"picture-in-picture": "画中画",
"exit-picture-in-picture": "退出画中画",
"fullscreen": "全屏F",
"exit-fullscreen": "退出全屏",
"unsupported-format": "此文件格式不支持媒体预览:\n{{mime}}",
"zoom-to-fit": "缩放以填充",
"zoom-reset": "重置缩放以填充"
},
"mermaid": {
"sample_diagrams": "示例图:",
"sample_flowchart": "流程图",
"sample_class": "类图",
"sample_sequence": "时序图",
"sample_entity_relationship": "实体关系图",
"sample_state": "状态图",
"sample_mindmap": "思维导图",
"sample_architecture": "架构图",
"sample_block": "模块图",
"sample_c4": "C4 图",
"sample_gantt": "甘特图",
"sample_git": "Git 流程图",
"sample_kanban": "看板图",
"sample_packet": "数据包图",
"sample_pie": "饼图",
"sample_quadrant": "象限图",
"sample_radar": "雷达图",
"sample_requirement": "需求图",
"sample_sankey": "桑基图",
"sample_timeline": "时间轴图",
"sample_treemap": "树形图",
"sample_user_journey": "用户旅程图",
"sample_xy": "散点图",
"sample_venn": "韦恩图",
"sample_ishikawa": "鱼骨图",
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
}
}

View File

@@ -446,7 +446,8 @@
"and_more": "... und {{count}} mehr.",
"print_landscape": "Beim Export als PDF, wird die Seitenausrichtung Querformat anstatt Hochformat verwendet.",
"print_page_size": "Beim Export als PDF, wird die Größe der Seite angepasst. Unterstützte Größen: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Farbe"
"color_type": "Farbe",
"textarea": "Mehrzeilen-Text"
},
"attribute_editor": {
"help_text_body1": "Um ein Label hinzuzufügen, gebe einfach z.B. ein. <code>#rock</code> oder wenn du auch einen Wert hinzufügen möchten, dann z.B. <code>#year = 2024</code>",

View File

@@ -477,7 +477,8 @@
"and_more": "... agus {{count}} eile.",
"print_landscape": "Agus é á onnmhairiú go PDF, athraítear treoshuíomh an leathanaigh go tírdhreach seachas portráid.",
"print_page_size": "Agus é á easpórtáil go PDF, athraítear méid an leathanaigh. Luachanna tacaithe: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Dath"
"color_type": "Dath",
"textarea": "Téacs Il-líne"
},
"attribute_editor": {
"help_text_body1": "Chun lipéad a chur leis, clóscríobh m.sh. <code>#rock</code> nó más mian leat luach a chur leis freisin ansin m.sh. <code>#year = 2020</code>",

View File

@@ -520,7 +520,7 @@
"custom_name_label": "Nome del motore di ricerca personalizzato",
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
"custom_url_placeholder": "Personalizza indirizzo url del motore di ricerca"
},
"sql_table_schemas": {
"tables": "Tabelle"
@@ -917,7 +917,8 @@
"print_landscape": "Quando si esporta in PDF, cambia l'orientamento della pagina da verticale a orizzontale.",
"print_page_size": "Quando si esporta in PDF, modifica le dimensioni della pagina. Valori supportati: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Colore",
"share_root": "segna la nota che viene servita su /share root."
"share_root": "segna la nota che viene servita su /share root.",
"textarea": "Testo su più righe"
},
"attribute_editor": {
"help_text_body1": "Per aggiungere un'etichetta, basta digitare ad esempio <code>#rock</code> oppure, se si desidera aggiungere anche un valore, ad esempio <code>#year = 2020</code>",
@@ -2197,5 +2198,52 @@
},
"setup_form": {
"more_info": "Per saperne di più"
},
"media": {
"play": "Gioca (Barra spaziatrice)",
"pause": "Pausa (Barra spaziatrice)",
"back-10s": "Indietro di 10 (tasto freccia sinistra)",
"forward-30s": "Avanti 30s",
"mute": "Muto (M)",
"unmute": "Riattiva audio (M)",
"playback-speed": "Velocità di riproduzione",
"loop": "Ciclo",
"disable-loop": "Disattiva il ciclo",
"rotate": "Ruota",
"picture-in-picture": "Immagine nell'immagine",
"exit-picture-in-picture": "Esci dalla modalità picture-in-picture",
"fullscreen": "Schermo intero (F)",
"exit-fullscreen": "Esci dalla modalità a schermo intero",
"unsupported-format": "Per questo formato di file non è disponibile l'anteprima multimediale:\n{{mime}}",
"zoom-to-fit": "Ingrandisci per riempire",
"zoom-reset": "Ripristina lo zoom a schermo intero"
},
"mermaid": {
"placeholder": "Digita il contenuto del tuo diagramma Mermaid oppure utilizza uno dei diagrammi di esempio riportati di seguito.",
"sample_diagrams": "Esempi di diagrammi:",
"sample_flowchart": "Diagramma di flusso",
"sample_class": "Classe",
"sample_sequence": "Sequenza",
"sample_entity_relationship": "Relazioni tra entità",
"sample_state": "Stato",
"sample_mindmap": "Mappa mentale",
"sample_architecture": "Architettura",
"sample_block": "Blocco",
"sample_c4": "C4",
"sample_gantt": "Gantt",
"sample_git": "Git",
"sample_kanban": "Kanban",
"sample_packet": "Packet",
"sample_pie": "Torta",
"sample_quadrant": "Quadrante",
"sample_radar": "Radar",
"sample_requirement": "Requisito",
"sample_sankey": "Chiave",
"sample_timeline": "Cronologia",
"sample_treemap": "Treemap",
"sample_user_journey": "Percorso dell'utente",
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
}
}

View File

@@ -117,7 +117,7 @@
"no_path_to_clone_to": "Brak ścieżki do sklonowania.",
"note_cloned": "Notatka \"{{clonedTitle}}\" została sklonowana do \"{{targetTitle}}\"",
"help_on_links": "Pomoc dotycząca linków",
"target_parent_note": "Docelowa notatka nadrzędna"
"target_parent_note": "Docelowa notatka pierwotna"
},
"help": {
"title": "Ściągawka",
@@ -126,7 +126,7 @@
"collapseExpand": "zwiń/rozwiń węzeł",
"notSet": "nie ustawiono",
"goBackForwards": "idź wstecz / do przodu w historii",
"showJumpToNoteDialog": "pokaż okno <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
"showJumpToNoteDialog": "pokaż <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
"scrollToActiveNote": "przewiń do aktywnej notatki",
"jumpToParentNote": "przejdź do notatki nadrzędnej",
"collapseWholeTree": "zwiń całe drzewo notatek",
@@ -402,7 +402,8 @@
"and_more": "... i {{count}} więcej.",
"print_landscape": "Podczas eksportowania do PDF zmienia orientację strony na poziomą zamiast pionowej.",
"print_page_size": "Podczas eksportowania do PDF zmienia rozmiar strony. Obsługiwane wartości: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Kolor"
"color_type": "Kolor",
"textarea": "Wiele linii tekstu"
},
"import": {
"importIntoNote": "Importuj do notatki",
@@ -1613,7 +1614,7 @@
"password_changed_success": "Hasło zostało zmienione. Trilium zostanie przeładowane po naciśnięciu OK."
},
"multi_factor_authentication": {
"title": "Uwierzytelnianie wieloskładnikowe (MFA)",
"title": "Uwierzytelnianie wieloskładnikowe",
"description": "Uwierzytelnianie wieloskładnikowe (MFA) dodaje dodatkową warstwę zabezpieczeń do Twojego konta. Zamiast tylko wpisywać hasło do logowania, MFA wymaga podania jednego lub więcej dodatkowych dowodów tożsamości. W ten sposób, nawet jeśli ktoś zdobędzie Twoje hasło, nadal nie będzie mógł uzyskać dostępu do Twojego konta bez drugiej informacji. To jak dodanie dodatkowego zamka do drzwi, utrudniającego włamanie.<br><br>Proszę postępować zgodnie z poniższymi instrukcjami, aby włączyć MFA. Jeśli nie skonfigurujesz poprawnie, logowanie powróci do samego hasła.",
"mfa_enabled": "Włącz uwierzytelnianie wieloskładnikowe",
"mfa_method": "Metoda MFA",
@@ -1628,7 +1629,7 @@
"totp_secret_generated": "Sekret TOTP wygenerowany",
"totp_secret_warning": "Proszę zapisać wygenerowany sekret w bezpiecznym miejscu. Nie zostanie pokazany ponownie.",
"totp_secret_regenerate_confirm": "Czy na pewno chcesz ponownie wygenerować sekret TOTP? To unieważni poprzedni sekret TOTP i wszystkie istniejące kody odzyskiwania.",
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego (SSO)",
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego",
"recovery_keys_description": "Klucze odzyskiwania logowania jednokrotnego służą do logowania w przypadku braku dostępu do kodów Authenticator.",
"recovery_keys_description_warning": "Klucze odzyskiwania nie zostaną pokazane ponownie po opuszczeniu strony, przechowuj je w bezpiecznym miejscu.<br>Po użyciu klucza odzyskiwania nie można go użyć ponownie.",
"recovery_keys_error": "Błąd generowania kodów odzyskiwania",
@@ -1766,7 +1767,7 @@
"book": "Kolekcja",
"mermaid-diagram": "Diagram Mermaid",
"canvas": "Płótno",
"web-view": "Widok WWW",
"web-view": "Widok strony web",
"mind-map": "Mapa myśli",
"file": "Plik",
"image": "Obraz",
@@ -1815,9 +1816,9 @@
"modal_title": "Konfiguracja listy wyróżnień",
"menu_configure": "Konfiguracja listy wyróżnień...",
"no_highlights": "Nie znaleziono wyróżnień.",
"title_with_count_one": "{{count}} podświetlenie",
"title_with_count_few": "{{count}} podświetlenia",
"title_with_count_many": "{{count}} podświetleń"
"title_with_count_one": "{{count}} wyróżnienie",
"title_with_count_few": "{{count}} wyróżnienia",
"title_with_count_many": "{{count}} wyróżnień"
},
"quick-search": {
"placeholder": "Szybkie wyszukiwanie",
@@ -2070,7 +2071,7 @@
"read_only_temporarily_disabled_description": "Ta notatka jest obecnie edytowalna, ale normalnie jest tylko do odczytu. Notatka powróci do trybu tylko do odczytu, gdy tylko przejdziesz do innej notatki.\n\nKliknij, aby ponownie włączyć tryb tylko do odczytu.",
"shared_publicly": "Udostępniona publicznie",
"shared_locally": "Udostępniona lokalnie",
"clipped_note": "Wycinek WWW",
"clipped_note": "Wycinek z sieci",
"clipped_note_description": "Ta notatka została pierwotnie pobrana z {{url}}.\n\nKliknij, aby przejść do źródłowej strony internetowej.",
"execute_script": "Uruchom skrypt",
"execute_script_description": "Ta notatka jest notatką skryptową. Kliknij, aby wykonać skrypt.",
@@ -2236,7 +2237,7 @@
"sample_c4": "C4",
"sample_gantt": "Wykres Gantta",
"sample_git": "Diagram Git",
"sample_kanban": "Kanban",
"sample_kanban": "Tablica Kanban",
"sample_packet": "Diagram pakietów",
"sample_pie": "Wykres kołowy",
"sample_quadrant": "Diagram kwadrantowy",

View File

@@ -2226,7 +2226,7 @@
"sample_sankey": "桑基圖",
"sample_timeline": "時間軸",
"sample_treemap": "樹狀圖",
"sample_user_journey": "用戶旅程",
"sample_user_journey": "使用者旅程",
"sample_xy": "XY 圖表",
"sample_venn": "韋恩圖",
"sample_ishikawa": "魚骨圖"

View File

@@ -1,6 +1,6 @@
import "./PromotedAttributes.css";
import { UpdateAttributeResponse } from "@triliumnext/commons";
import { DefinitionObject, LabelType, UpdateAttributeResponse } from "@triliumnext/commons";
import clsx from "clsx";
import { ComponentChild, createElement, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from "preact/hooks";
@@ -11,7 +11,7 @@ import FNote from "../entities/fnote";
import { Attribute } from "../services/attribute_parser";
import attributes from "../services/attributes";
import { t } from "../services/i18n";
import { DefinitionObject, extractAttributeDefinitionTypeAndName, LabelType } from "../services/promoted_attribute_definition_parser";
import { extractAttributeDefinitionTypeAndName } from "../services/promoted_attribute_definition_parser";
import server from "../services/server";
import { randomString } from "../services/utils";
import ws from "../services/ws";

View File

@@ -1,8 +1,9 @@
import { it, describe, expect } from "vitest";
import { buildNote } from "../../../test/easy-froca";
import { getBoardData } from "./data";
import { describe, expect, it } from "vitest";
import FBranch from "../../../entities/fbranch";
import froca from "../../../services/froca";
import { buildNote } from "../../../test/easy-froca";
import { getBoardData } from "./data";
describe("Board data", () => {
it("deduplicates cloned notes", async () => {

View File

@@ -284,7 +284,7 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
}
}
function RevisionContentText({ content }: { content: string | Buffer<ArrayBufferLike> | undefined }) {
function RevisionContentText({ content }: { content: string | Uint8Array | undefined }) {
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current?.querySelector("span.math-tex")) {
@@ -296,7 +296,7 @@ function RevisionContentText({ content }: { content: string | Buffer<ArrayBuffer
function RevisionContentDiff({ noteContent, itemContent, itemType }: {
noteContent?: string,
itemContent: string | Buffer<ArrayBufferLike> | undefined,
itemContent: string | Uint8Array | undefined,
itemType: string
}) {
const contentRef = useRef<HTMLDivElement>(null);

View File

@@ -5,7 +5,7 @@
"description": "Tool to compare content of Trilium databases. Useful for debugging sync problems.",
"dependencies": {
"colors": "1.4.0",
"diff": "8.0.3",
"diff": "8.0.4",
"sqlite": "5.1.1",
"sqlite3": "6.0.1"
},

View File

@@ -36,7 +36,7 @@
"@triliumnext/core": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "41.0.3",
"electron": "41.0.4",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -2,8 +2,16 @@ import { PlatformProvider, t } from "@triliumnext/core";
import electron from "electron";
export default class DesktopPlatformProvider implements PlatformProvider {
readonly isElectron = true;
readonly isMac = process.platform === "darwin";
readonly isWindows = process.platform === "win32";
crash(message: string): void {
electron.dialog.showErrorBox(t("modals.error_title"), message);
electron.app.exit(1);
}
getEnv(key: string): string | undefined {
return process.env[key];
}
}

View File

@@ -9,15 +9,26 @@
"node",
"express"
],
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo"
"tsBuildInfoFile": "dist/tsconfig.forge.tsbuildinfo"
},
"include": [
"**/*.ts",
"../server/src/*.d.ts"
"../server/src/*.d.ts",
"package.json"
],
"exclude": [
"eslint.config.js",
"eslint.config.cjs",
"eslint.config.mjs"
"eslint.config.mjs",
"scripts/**",
"dist/**"
],
"references": [
{
"path": "../server/tsconfig.app.json"
},
{
"path": "../../packages/commons/tsconfig.lib.json"
}
]
}

View File

@@ -9,10 +9,11 @@
},
"devDependencies": {
"@triliumnext/client": "workspace:*",
"@triliumnext/core": "workspace:*",
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.0.3",
"electron": "41.0.4",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -1,9 +1,9 @@
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js";
import debounce from "@triliumnext/client/src/services/debounce.js";
import cls from "@triliumnext/server/src/services/cls.js";
import fs from "fs/promises";
import { join } from "path";
import cls from "@triliumnext/server/src/services/cls.js";
import { extractZip, importData, startElectron } from "./utils.js";
// Paths are relative to apps/edit-docs/dist.
const DEMO_ZIP_PATH = join(__dirname, "../../server/src/assets/db/demo.zip");
@@ -15,8 +15,7 @@ async function main() {
setTimeout(() => registerHandlers(), 10_000);
});
await initializeTranslations();
await initializeDatabase(true);
// TODO: Initialize core.
cls.init(async () => {
await importData(DEMO_ZIP_DIR_PATH);
setOptions();
@@ -34,8 +33,8 @@ async function setOptions() {
}
async function registerHandlers() {
const events = (await import("@triliumnext/server/src/services/events.js")).default;
const eraseService = (await import("@triliumnext/server/src/services/erase.js")).default;
const { events } = await import("@triliumnext/core");
const { erase: eraseService } = await import("@triliumnext/core");
const debouncer = debounce(async () => {
console.log("Exporting data");
eraseService.eraseUnusedAttachmentsNow();

View File

@@ -121,11 +121,10 @@ async function main() {
}, 10_000);
});
await initializeTranslations();
await initializeDatabase(true);
// TODO: Initialize core.
// Wait for becca to be loaded before importing data
const beccaLoader = await import("@triliumnext/server/src/becca/becca_loader.js");
const { becca_loader: beccaLoader } = await import("@triliumnext/core");
await beccaLoader.beccaLoaded;
cls.init(async () => {
@@ -252,8 +251,8 @@ async function cleanUpMeta(outputPath: string, minify: boolean) {
}
async function registerHandlers() {
const events = (await import("@triliumnext/server/src/services/events.js")).default;
const eraseService = (await import("@triliumnext/server/src/services/erase.js")).default;
const { events } = await import("@triliumnext/core");
const { erase: eraseService } = await import("@triliumnext/core");
const debouncer = debounce(async () => {
eraseService.eraseUnusedAttachmentsNow();

View File

@@ -23,6 +23,9 @@
"eslint.config.mjs"
],
"references": [
{
"path": "../../packages/trilium-core/tsconfig.lib.json"
},
{
"path": "../server/tsconfig.app.json"
},

View File

@@ -32,7 +32,6 @@
"dependencies": {
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"node-html-parser": "7.1.0",
"sucrase": "3.35.1"
},
"devDependencies": {
@@ -79,12 +78,12 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "41.0.3",
"electron": "41.0.4",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.19.4",
"express-openid-connect": "2.20.0",
"express-rate-limit": "8.3.1",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
@@ -94,9 +93,9 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "8.0.0",
"https-proxy-agent": "8.0.0",
"i18next": "25.10.3",
"i18next": "25.10.10",
"i18next-fs-backend": "2.6.1",
"image-type": "6.0.0",
"image-type": "6.1.0",
"ini": "6.0.0",
"is-animated": "2.0.2",
"is-svg": "6.1.0",
@@ -117,7 +116,7 @@
"time2fa": "1.4.2",
"tmp": "0.2.5",
"turnish": "1.8.0",
"vite": "8.0.1",
"vite": "8.0.2",
"ws": "8.20.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"

View File

@@ -1,7 +1,12 @@
import { beforeAll } from "vitest";
import i18next from "i18next";
import { readFileSync } from "fs";
import { join } from "path";
import { setDayjsLocale } from "@triliumnext/commons";
import { initializeCore } from "@triliumnext/core";
import ClsHookedExecutionContext from "../src/cls_provider.js";
import NodejsCryptoProvider from "../src/crypto_provider.js";
import ServerPlatformProvider from "../src/platform_provider.js";
import BetterSqlite3Provider from "../src/sql_provider.js";
import { initializeTranslations } from "../src/services/i18n.js";
// Initialize environment variables.
process.env.TRILIUM_DATA_DIR = join(__dirname, "db");
@@ -11,20 +16,20 @@ process.env.TRILIUM_ENV = "dev";
process.env.TRILIUM_PUBLIC_SERVER = "http://localhost:4200";
beforeAll(async () => {
// Initialize the translations manually to avoid any side effects.
const Backend = (await import("i18next-fs-backend")).default;
const dbProvider = new BetterSqlite3Provider();
dbProvider.loadFromMemory();
// Initialize translations
await i18next.use(Backend).init({
lng: "en",
fallbackLng: "en",
ns: "server",
backend: {
loadPath: join(__dirname, "../src/assets/translations/{{lng}}/{{ns}}.json")
await initializeCore({
dbConfig: {
provider: dbProvider,
isReadOnly: false,
onTransactionCommit() {},
onTransactionRollback() {}
},
showSupportNotice: false
crypto: new NodejsCryptoProvider(),
executionContext: new ClsHookedExecutionContext(),
schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"),
platform: new ServerPlatformProvider(),
translations: initializeTranslations
});
// Initialize dayjs
await setDayjsLocale("en");
});

View File

@@ -14,7 +14,7 @@
"creating-and-moving-notes": "Tworzenie i przenoszenie notatek",
"create-note-after": "Utwórz notatkę po aktywnej notatce",
"create-note-into": "Utwórz notatkę jako podrzędną aktywnej notatki",
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowana) lub notatkę dnia",
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowano) lub w notatce dziennej",
"delete-note": "Usuń notatkę",
"move-note-up": "Przenieś notatkę w górę",
"move-note-down": "Przenieś notatkę w dół",
@@ -59,7 +59,7 @@
"show-backend-log": "Otwórz stronę \"Logi backendu\"",
"show-help": "Otwórz wbudowany Poradnik Użytkownika",
"show-cheatsheet": "Pokaż listę skrótów klawiszowych",
"text-note-operations": "Operacje na notatkach tekstowych",
"text-note-operations": "Operacje na notatkach",
"add-link-to-text": "Otwórz okno dodawania linku do tekstu",
"follow-link-under-cursor": "Podążaj za linkiem pod kursorem",
"insert-date-and-time-to-text": "Wstaw aktualną datę i czas",

View File

@@ -1,13 +1,14 @@
import type { Router, Request, Response, NextFunction } from "express";
import eu from "./etapi_utils.js";
import sql from "../services/sql.js";
import type { NextFunction,Request, Response, Router } from "express";
import appInfo from "../services/app_info.js";
import sql from "../services/sql.js";
import eu from "./etapi_utils.js";
interface MetricsData {
version: {
app: string;
db: number;
node: string;
node?: string;
sync: number;
buildDate: string;
buildRevision: string;
@@ -45,7 +46,7 @@ function formatPrometheusMetrics(data: MetricsData): string {
const lines: string[] = [];
// Helper function to add a metric
const addMetric = (name: string, value: number | null, help: string, type: string = 'gauge', labels: Record<string, string> = {}) => {
const addMetric = (name: string, value: number | null, help: string, type: string = 'gauge', labels: Record<string, string | undefined> = {}) => {
if (value === null) return;
lines.push(`# HELP ${name} ${help}`);
@@ -233,8 +234,8 @@ function register(router: Router): void {
} else if (format === 'prometheus') {
const prometheusText = formatPrometheusMetrics(metrics);
res.status(200)
.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
.send(prometheusText);
.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
.send(prometheusText);
} else {
throw new eu.EtapiError(400, "INVALID_FORMAT", "Supported formats: 'prometheus' (default), 'json'");
}

View File

@@ -1,4 +1,4 @@
import { NoteParams } from "@triliumnext/core";
import { NoteParams, SearchParams } from "@triliumnext/core";
import type { Request, Router } from "express";
import type { ParsedQs } from "qs";
@@ -9,7 +9,6 @@ import zipImportService from "../services/import/zip.js";
import noteService from "../services/notes.js";
import SearchContext from "../services/search/search_context.js";
import searchService from "../services/search/services/search.js";
import type { SearchParams } from "../services/search/services/types.js";
import TaskContext from "../services/task_context.js";
import utils from "../services/utils.js";
import eu from "./etapi_utils.js";

View File

@@ -1,8 +1,16 @@
import { getLog, PlatformProvider } from "@triliumnext/core";
export default class ServerPlatformProvider implements PlatformProvider {
readonly isElectron = !!process.versions["electron"];
readonly isMac = process.platform === "darwin";
readonly isWindows = process.platform === "win32";
crash(message: string): void {
getLog().error(message);
process.exit(1);
}
getEnv(key: string): string | undefined {
return process.env[key];
}
}

View File

@@ -1,5 +1,5 @@
import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
import { becca_loader, ValidationError } from "@triliumnext/core";
import { becca_loader, rebuildIntegrationTestDatabase as rebuildIntegrationTestDatabaseCore,ValidationError } from "@triliumnext/core";
import type { Request } from "express";
import anonymizationService from "../../services/anonymization.js";
@@ -30,7 +30,7 @@ function findAndFixConsistencyIssues() {
}
async function rebuildIntegrationTestDatabase() {
sql.rebuildIntegrationTestDatabase();
rebuildIntegrationTestDatabaseCore();
sql_init.initializeDb();
becca_loader.load();
}

View File

@@ -1,17 +1,14 @@
import { ValidationError } from "@triliumnext/core";
import chokidar from "chokidar";
import type { Request, Response } from "express";
import type { Request } from "express";
import fs from "fs";
import { Readable } from "stream";
import tmp from "tmp";
import becca from "../../becca/becca.js";
import type BAttachment from "../../becca/entities/battachment.js";
import type BNote from "../../becca/entities/bnote.js";
import dataDirs from "../../services/data_dir.js";
import log from "../../services/log.js";
import noteService from "../../services/notes.js";
import protectedSessionService from "../../services/protected_session.js";
import utils from "../../services/utils.js";
import ws from "../../services/ws.js";
@@ -64,49 +61,6 @@ function updateAttachment(req: Request<{ attachmentId: string }>) {
};
}
function downloadData(noteOrAttachment: BNote | BAttachment, res: Response, contentDisposition: boolean) {
if (noteOrAttachment.isProtected && !protectedSessionService.isProtectedSessionAvailable()) {
return res.status(401).send("Protected session not available");
}
if (contentDisposition) {
const fileName = noteOrAttachment.getFileName();
res.setHeader("Content-Disposition", utils.getContentDisposition(fileName));
}
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", noteOrAttachment.mime);
res.send(noteOrAttachment.getContent());
}
function downloadNoteInt(noteId: string, res: Response, contentDisposition = true) {
const note = becca.getNote(noteId);
if (!note) {
return res.setHeader("Content-Type", "text/plain").status(404).send(`Note '${noteId}' doesn't exist.`);
}
return downloadData(note, res, contentDisposition);
}
function downloadAttachmentInt(attachmentId: string, res: Response, contentDisposition = true) {
const attachment = becca.getAttachment(attachmentId);
if (!attachment) {
return res.setHeader("Content-Type", "text/plain").status(404).send(`Attachment '${attachmentId}' doesn't exist.`);
}
return downloadData(attachment, res, contentDisposition);
}
const downloadFile = (req: Request<{ noteId: string }>, res: Response) => downloadNoteInt(req.params.noteId, res, true);
const openFile = (req: Request<{ noteId: string }>, res: Response) => downloadNoteInt(req.params.noteId, res, false);
const downloadAttachment = (req: Request<{ attachmentId: string }>, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, true);
const openAttachment = (req: Request<{ attachmentId: string }>, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, false);
function fileContentProvider(req: Request<{ noteId: string }>) {
// Read the file name from route params.
const note = becca.getNoteOrThrow(req.params.noteId);
@@ -248,13 +202,8 @@ function uploadModifiedFileToAttachment(req: Request<{ attachmentId: string }>)
export default {
updateFile,
updateAttachment,
openFile,
fileContentProvider,
downloadFile,
downloadNoteInt,
saveNoteToTmpDir,
openAttachment,
downloadAttachment,
saveAttachmentToTmpDir,
attachmentContentProvider,
uploadModifiedFileToNote,

View File

@@ -1,383 +0,0 @@
import { BacklinkCountResponse, BacklinksResponse } from "@triliumnext/commons";
import type { Request } from "express";
import { HTMLElement, parse, TextNode } from "node-html-parser";
import becca from "../../becca/becca.js";
import type BAttribute from "../../becca/entities/battribute.js";
import type BNote from "../../becca/entities/bnote.js";
interface TreeLink {
sourceNoteId: string;
targetNoteId: string;
}
function buildDescendantCountMap(noteIdsToCount: string[]) {
if (!Array.isArray(noteIdsToCount)) {
throw new Error("noteIdsToCount: type error");
}
const noteIdToCountMap = Object.create(null);
function getCount(noteId: string) {
if (!(noteId in noteIdToCountMap)) {
const note = becca.getNote(noteId);
if (!note) {
return;
}
const hiddenImageNoteIds = note.getRelations("imageLink").map((rel) => rel.value);
const childNoteIds = note.children.map((child) => child.noteId);
const nonHiddenNoteIds = childNoteIds.filter((childNoteId) => !hiddenImageNoteIds.includes(childNoteId));
noteIdToCountMap[noteId] = nonHiddenNoteIds.length;
for (const child of note.children) {
noteIdToCountMap[noteId] += getCount(child.noteId);
}
}
return noteIdToCountMap[noteId];
}
noteIdsToCount.forEach((noteId) => {
getCount(noteId);
});
return noteIdToCountMap;
}
function getNeighbors(note: BNote, depth: number): string[] {
if (depth === 0) {
return [];
}
const retNoteIds: string[] = [];
function isIgnoredRelation(relation: BAttribute) {
return ["relationMapLink", "template", "inherit", "image", "ancestor"].includes(relation.name);
}
// forward links
for (const relation of note.getRelations()) {
if (isIgnoredRelation(relation)) {
continue;
}
const targetNote = relation.getTargetNote();
if (!targetNote || targetNote.isLabelTruthy("excludeFromNoteMap")) {
continue;
}
retNoteIds.push(targetNote.noteId);
for (const noteId of getNeighbors(targetNote, depth - 1)) {
retNoteIds.push(noteId);
}
}
// backward links
for (const relation of note.getTargetRelations()) {
if (isIgnoredRelation(relation)) {
continue;
}
const sourceNote = relation.getNote();
if (!sourceNote || sourceNote.isLabelTruthy("excludeFromNoteMap")) {
continue;
}
retNoteIds.push(sourceNote.noteId);
for (const noteId of getNeighbors(sourceNote, depth - 1)) {
retNoteIds.push(noteId);
}
}
return retNoteIds;
}
function getLinkMap(req: Request<{ noteId: string }>) {
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
// if the map root itself has "excludeFromNoteMap" attribute (journal typically) then there wouldn't be anything
// to display, so we'll just ignore it
const ignoreExcludeFromNoteMap = mapRootNote.isLabelTruthy("excludeFromNoteMap");
let unfilteredNotes;
const toSet = (data: unknown) => new Set<string>(data instanceof Array ? data : []);
const excludeRelations = toSet(req.body.excludeRelations);
const includeRelations = toSet(req.body.includeRelations);
if (mapRootNote.type === "search") {
// for search notes, we want to consider the direct search results only without the descendants
unfilteredNotes = mapRootNote.getSearchResultNotes();
} else {
unfilteredNotes = mapRootNote.getSubtree({
includeArchived: false,
resolveSearch: true,
includeHidden: mapRootNote.isInHiddenSubtree()
}).notes;
}
const noteIds = new Set<string>(unfilteredNotes.filter((note) => ignoreExcludeFromNoteMap || !note.isLabelTruthy("excludeFromNoteMap")).map((note) => note.noteId));
if (mapRootNote.type === "search") {
noteIds.delete(mapRootNote.noteId);
}
for (const noteId of getNeighbors(mapRootNote, 3)) {
noteIds.add(noteId);
}
const noteIdsArray = Array.from(noteIds);
const notes = noteIdsArray.map((noteId) => {
const note = becca.getNoteOrThrow(noteId);
return [note.noteId, note.getTitleOrProtected(), note.type, note.getLabelValue("color")];
});
const links = Object.values(becca.attributes)
.filter((rel) => {
if (rel.type !== "relation" || rel.name === "relationMapLink" || rel.name === "template" || rel.name === "inherit") {
return false;
} else if (!noteIds.has(rel.noteId) || !noteIds.has(rel.value)) {
return false;
} else if (rel.name === "imageLink") {
const parentNote = becca.getNote(rel.noteId);
if (!parentNote) {
return false;
}
return !parentNote.getChildNotes().find((childNote) => childNote.noteId === rel.value);
} else if (includeRelations.size != 0 && !includeRelations.has(rel.name)) {
return false;
} else if (excludeRelations.has(rel.name)) {
return false;
}
return true;
})
.map((rel) => ({
id: `${rel.noteId}-${rel.name}-${rel.value}`,
sourceNoteId: rel.noteId,
targetNoteId: rel.value,
name: rel.name
}));
return {
notes,
noteIdToDescendantCountMap: buildDescendantCountMap(noteIdsArray),
links
};
}
function getTreeMap(req: Request<{ noteId: string }>) {
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
// if the map root itself has "excludeFromNoteMap" (journal typically) then there wouldn't be anything to display,
// so we'll just ignore it
const ignoreExcludeFromNoteMap = mapRootNote.isLabelTruthy("excludeFromNoteMap");
const subtree = mapRootNote.getSubtree({
includeArchived: false,
resolveSearch: true,
includeHidden: mapRootNote.isInHiddenSubtree()
});
const notes = subtree.notes
.filter((note) => ignoreExcludeFromNoteMap || !note.isLabelTruthy("excludeFromNoteMap"))
.filter((note) => {
if (note.type !== "image" || note.getChildNotes().length > 0) {
return true;
}
const imageLinkRelation = note.getTargetRelations().find((rel) => rel.name === "imageLink");
if (!imageLinkRelation) {
return true;
}
return !note.getParentNotes().find((parentNote) => parentNote.noteId === imageLinkRelation.noteId);
})
.map((note) => [note.noteId, note.getTitleOrProtected(), note.type, note.getLabelValue("color")]);
const noteIds = new Set<string>();
notes.forEach(([noteId]) => noteId && noteIds.add(noteId));
const links: TreeLink[] = [];
for (const { parentNoteId, childNoteId } of subtree.relationships) {
if (!noteIds.has(parentNoteId) || !noteIds.has(childNoteId)) {
continue;
}
links.push({
sourceNoteId: parentNoteId,
targetNoteId: childNoteId
});
}
const noteIdToDescendantCountMap = buildDescendantCountMap(Array.from(noteIds));
updateDescendantCountMapForSearch(noteIdToDescendantCountMap, subtree.relationships);
return {
notes,
noteIdToDescendantCountMap,
links
};
}
function updateDescendantCountMapForSearch(noteIdToDescendantCountMap: Record<string, number>, relationships: { parentNoteId: string; childNoteId: string }[]) {
for (const { parentNoteId, childNoteId } of relationships) {
const parentNote = becca.notes[parentNoteId];
if (!parentNote || parentNote.type !== "search") {
continue;
}
noteIdToDescendantCountMap[parentNote.noteId] = noteIdToDescendantCountMap[parentNoteId] || 0;
noteIdToDescendantCountMap[parentNote.noteId] += noteIdToDescendantCountMap[childNoteId] || 1;
}
}
function removeImages(document: HTMLElement) {
const images = document.getElementsByTagName("img");
for (const image of images) {
image.remove();
}
}
const EXCERPT_CHAR_LIMIT = 200;
type ElementOrText = HTMLElement | TextNode;
export function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
const html = sourceNote.getContent();
const document = parse(html.toString());
const excerpts: string[] = [];
removeImages(document);
for (const linkEl of document.querySelectorAll("a")) {
console.log("Got ", linkEl.innerHTML);
const href = linkEl.getAttribute("href");
if (!href || !href.endsWith(referencedNoteId)) {
continue;
}
linkEl.classList.add("backlink-link");
let centerEl: HTMLElement = linkEl;
while (centerEl.tagName !== "BODY" && centerEl.parentNode && (centerEl.parentNode?.textContent?.length || 0) <= EXCERPT_CHAR_LIMIT) {
centerEl = centerEl.parentNode;
}
const excerptEls: ElementOrText[] = [centerEl];
let excerptLength = centerEl.textContent?.length || 0;
let left: ElementOrText = centerEl;
let right: ElementOrText = centerEl;
while (excerptLength < EXCERPT_CHAR_LIMIT) {
let added = false;
const prev: HTMLElement | null = left.previousElementSibling;
if (prev) {
const prevText = prev.textContent || "";
if (prevText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const prefix = prevText.substr(prevText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
const textNode = new TextNode(`${prefix}`);
excerptEls.unshift(textNode);
break;
}
left = prev;
excerptEls.unshift(left);
excerptLength += prevText.length;
added = true;
}
const next: HTMLElement | null = right.nextElementSibling;
if (next) {
const nextText = next.textContent;
if (nextText && nextText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const suffix = nextText.substr(nextText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
const textNode = new TextNode(`${suffix}`);
excerptEls.push(textNode);
break;
}
right = next;
excerptEls.push(right);
excerptLength += nextText?.length || 0;
added = true;
}
if (!added) {
break;
}
}
const excerptWrapper = new HTMLElement("div", {});
excerptWrapper.classList.add("ck-content");
excerptWrapper.classList.add("backlink-excerpt");
for (const childEl of excerptEls) {
excerptWrapper.appendChild(childEl);
}
excerpts.push(excerptWrapper.outerHTML);
}
return excerpts;
}
// TODO: Deduplicate with core
function getFilteredBacklinks(note: BNote): BAttribute[] {
return (
note
.getTargetRelations()
// search notes have "ancestor" relations which are not interesting
.filter((relation) => !!relation.getNote() && relation.getNote().type !== "search")
);
}
function getBacklinks(req: Request<{ noteId: string }>): BacklinksResponse {
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
let backlinksWithExcerptCount = 0;
return getFilteredBacklinks(note).map((backlink) => {
const sourceNote = backlink.note;
if (sourceNote.type !== "text" || backlinksWithExcerptCount > 50) {
return {
noteId: sourceNote.noteId,
relationName: backlink.name
} satisfies BacklinksResponse[number];
}
backlinksWithExcerptCount++;
const excerpts = findExcerpts(sourceNote, noteId);
return {
noteId: sourceNote.noteId,
excerpts
} satisfies BacklinksResponse[number];
});
}
export default {
getLinkMap,
getTreeMap,
getBacklinks
};

View File

@@ -1,3 +1,4 @@
import { routeHelpers,utils } from "@triliumnext/core";
import type { Request, Response, Router } from "express";
import becca from "../becca/becca.js";
@@ -6,8 +7,6 @@ import cls from "../services/cls.js";
import log from "../services/log.js";
import scriptService from "../services/script.js";
import sql from "../services/sql.js";
import { normalizeCustomHandlerPattern,safeExtractMessageAndStackFromError } from "../services/utils.js";
import fileService from "./api/files.js";
function handleRequest(req: Request, res: Response) {
@@ -41,7 +40,7 @@ function handleRequest(req: Request, res: Response) {
}
// Get normalized patterns to handle both trailing slash cases
const patterns = normalizeCustomHandlerPattern(attr.value);
const patterns = utils.normalizeCustomHandlerPattern(attr.value);
let match: RegExpMatchArray | null = null;
try {
@@ -54,7 +53,7 @@ function handleRequest(req: Request, res: Response) {
}
}
} catch (e: unknown) {
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
const [errMessage, errStack] = utils.safeExtractMessageAndStackFromError(e);
log.error(`Testing path for label '${attr.attributeId}', regex '${attr.value}' failed with error: ${errMessage}, stack: ${errStack}`);
continue;
}
@@ -75,12 +74,12 @@ function handleRequest(req: Request, res: Response) {
res
});
} catch (e: unknown) {
const [errMessage, errStack] = safeExtractMessageAndStackFromError(e);
const [errMessage, errStack] = utils.safeExtractMessageAndStackFromError(e);
log.error(`Custom handler '${note.noteId}' failed with: ${errMessage}, ${errStack}`);
res.setHeader("Content-Type", "text/plain").status(500).send(errMessage);
}
} else if (attr.name === "customResourceProvider") {
fileService.downloadNoteInt(attr.noteId, res);
routeHelpers.downloadNoteInt(attr.noteId, res);
} else {
throw new Error(`Unrecognized attribute name '${attr.name}'`);
}

View File

@@ -10,7 +10,7 @@ import attributeService from "../services/attributes.js";
import config from "../services/config.js";
import log from "../services/log.js";
import optionService from "../services/options.js";
import { isDev, isElectron, isMac, isWindows11 } from "../services/utils.js";
import { isDev, isElectron, isMac, isWindows, isWindows11 } from "../services/utils.js";
import { generateCsrfToken } from "./csrf_protection.js";
type View = "desktop" | "mobile" | "print";
@@ -25,18 +25,31 @@ export function bootstrap(req: Request, res: Response) {
req.session.csrfInitialized = true;
}
const view = getView(req);
const isDbInitialized = sql_init.isDbInitialized();
const commonItems = getSharedBootstrapItems(assetPath, isDbInitialized);
const commonItems = {
...getSharedBootstrapItems(assetPath, isDbInitialized),
baseApiUrl: "api/",
appPath,
isStandalone: false,
isElectron,
isDev,
triliumVersion: packageJson.version,
device: view,
TRILIUM_SAFE_MODE: !!process.env.TRILIUM_SAFE_MODE,
instanceName: config.General ? config.General.instanceName : null
};
if (!isDbInitialized) {
res.send({
...commonItems,
baseApiUrl: "api/",
componentId: ""
});
hasNativeTitleBar: false,
hasBackgroundEffects: isElectron && (isWindows11 || isMac),
isMainWindow: true,
appCssNoteIds: [],
} satisfies BootstrapDefinition);
return;
}
const options = optionService.getOptionMap();
const csrfToken = generateCsrfToken(req, res, {
overwrite: false,
@@ -44,43 +57,27 @@ export function bootstrap(req: Request, res: Response) {
});
log.info(`CSRF token generation: ${csrfToken ? "Successful" : "Failed"}`);
const view = getView(req);
const theme = options.theme;
const themeNote = attributeService.getNoteWithLabel("appTheme", theme);
const options = optionService.getOptionMap();
const nativeTitleBarVisible = options.nativeTitleBarVisible === "true";
const iconPacks = iconPackService.getIconPacks();
const sql = getSql();
res.send({
...commonItems,
dbInitialized: true,
device: view,
csrfToken,
themeCssUrl: getThemeCssUrl(theme, themeNote),
themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase") as "next" | "next-light" | "next-dark",
platform: process.platform,
isElectron,
hasNativeTitleBar: isElectron && nativeTitleBarVisible,
hasBackgroundEffects: options.backgroundEffects === "true"
&& isElectron
&& (isWindows11 || isMac)
&& !nativeTitleBarVisible,
maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
maxEntityChangeSyncIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
instanceName: config.General ? config.General.instanceName : null,
appCssNoteIds: getAppCssNoteIds(),
isDev,
isMainWindow: view === "mobile" ? true : !req.query.extraWindow,
triliumVersion: packageJson.version,
appPath,
baseApiUrl: 'api/',
iconPackCss: iconPacks
.map((p: iconPackService.ProcessedIconPack) => iconPackService.generateCss(p, p.builtin
? `${assetPath}/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
: `api/attachments/download/${p.fontAttachmentId}`))
.filter(Boolean)
.join("\n\n"),
TRILIUM_SAFE_MODE: !!process.env.TRILIUM_SAFE_MODE
} satisfies BootstrapDefinition);
}
@@ -120,28 +117,3 @@ function getView(req: Request): View {
return "desktop";
}
function getThemeCssUrl(theme: string, themeNote: BNote | null) {
if (theme === "auto") {
return `${assetPath}/stylesheets/theme.css`;
} else if (theme === "light") {
// light theme is always loaded as baseline
return false;
} else if (theme === "dark") {
return `${assetPath}/stylesheets/theme-dark.css`;
} else if (theme === "next") {
return `${assetPath}/stylesheets/theme-next.css`;
} else if (theme === "next-light") {
return `${assetPath}/stylesheets/theme-next-light.css`;
} else if (theme === "next-dark") {
return `${assetPath}/stylesheets/theme-next-dark.css`;
} else if (!process.env.TRILIUM_SAFE_MODE && themeNote) {
return `api/notes/download/${themeNote.noteId}`;
}
// baseline light theme
return false;
}
function getAppCssNoteIds() {
return attributeService.getNotesWithLabel("appCss").map((note) => note.noteId);
}

View File

@@ -102,8 +102,7 @@ function internalRoute<P extends ParamsDictionary>(method: HttpMethod, path: str
return;
}
if (result?.then) {
// promise
if (result instanceof Promise) {
result.then((promiseResult: unknown) => handleResponse(resultHandler, req, res, promiseResult, start)).catch((e: unknown) => handleException(e, method, path, res));
} else {
handleResponse(resultHandler, req, res, result, start);

View File

@@ -28,7 +28,6 @@ import fontsRoute from "./api/fonts.js";
import importRoute from "./api/import.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import noteMapRoute from "./api/note_map.js";
import otherRoute from "./api/other.js";
import passwordApiRoute from "./api/password.js";
import recoveryCodes from './api/recovery_codes.js';
@@ -94,7 +93,6 @@ function register(app: express.Application) {
});
route(PUT, "/api/notes/:noteId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateFile, apiResultHandler);
route(GET, "/api/notes/:noteId/open", [auth.checkApiAuthOrElectron], filesRoute.openFile);
asyncRoute(
GET,
"/api/notes/:noteId/open-partial",
@@ -105,15 +103,11 @@ function register(app: express.Application) {
}
})
);
route(GET, "/api/notes/:noteId/download", [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
// this "hacky" path is used for easier referencing of CSS resources
route(GET, "/api/notes/download/:noteId", [auth.checkApiAuthOrElectron], filesRoute.downloadFile);
apiRoute(PST, "/api/notes/:noteId/save-to-tmp-dir", filesRoute.saveNoteToTmpDir);
apiRoute(PST, "/api/notes/:noteId/upload-modified-file", filesRoute.uploadModifiedFileToNote);
// TODO: Bring back attachment uploading
// route(PST, "/api/notes/:noteId/attachments/upload", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], attachmentsApiRoute.uploadAttachment, apiResultHandler);
route(GET, "/api/attachments/:attachmentId/open", [auth.checkApiAuthOrElectron], filesRoute.openAttachment);
asyncRoute(
GET,
"/api/attachments/:attachmentId/open-partial",
@@ -124,9 +118,7 @@ function register(app: express.Application) {
}
})
);
route(GET, "/api/attachments/:attachmentId/download", [auth.checkApiAuthOrElectron], filesRoute.downloadAttachment);
// this "hacky" path is used for easier referencing of CSS resources
route(GET, "/api/attachments/download/:attachmentId", [auth.checkApiAuthOrElectron], filesRoute.downloadAttachment);
apiRoute(PST, "/api/attachments/:attachmentId/save-to-tmp-dir", filesRoute.saveAttachmentToTmpDir);
apiRoute(PST, "/api/attachments/:attachmentId/upload-modified-file", filesRoute.uploadModifiedFileToAttachment);
route(PUT, "/api/attachments/:attachmentId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateAttachment, apiResultHandler);
@@ -210,10 +202,6 @@ function register(app: express.Application) {
apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown);
apiRoute(PST, "/api/other/to-markdown", otherRoute.toMarkdown);
apiRoute(PST, "/api/note-map/:noteId/tree", noteMapRoute.getTreeMap);
apiRoute(PST, "/api/note-map/:noteId/link", noteMapRoute.getLinkMap);
apiRoute(GET, "/api/note-map/:noteId/backlinks", noteMapRoute.getBacklinks);
shareRoutes.register(router);
etapiAuthRoutes.register(router, [loginRateLimiter]);

View File

@@ -19,12 +19,13 @@
* ╚════════════════════════════════════════════════════════════════════════════╝
*/
import ini from "ini";
import { utils } from "@triliumnext/core";
import fs from "fs";
import dataDir from "./data_dir.js";
import ini from "ini";
import path from "path";
import dataDir from "./data_dir.js";
import resourceDir from "./resource_dir.js";
import { envToBoolean, stringToInt } from "./utils.js";
/**
* Path to the sample configuration file that serves as a template for new installations.
@@ -253,7 +254,7 @@ function getIniSection(sectionName: string): IniConfigSection | undefined {
*/
function transformBoolean(value: unknown): boolean {
// First try the standard envToBoolean function which handles "true"/"false" strings
const result = envToBoolean(String(value));
const result = utils.envToBoolean(String(value));
if (result !== undefined) return result;
// Handle numeric boolean values (both string and number types)
@@ -456,7 +457,7 @@ const configMapping = {
aliasEnvVars: ['TRILIUM_LOGGING_RETENTION_DAYS'],
iniGetter: () => getIniSection("Logging")?.retentionDays,
defaultValue: LOGGING_DEFAULT_RETENTION_DAYS,
transformer: (value: unknown) => stringToInt(String(value)) ?? LOGGING_DEFAULT_RETENTION_DAYS
transformer: (value: unknown) => utils.stringToInt(String(value)) ?? LOGGING_DEFAULT_RETENTION_DAYS
}
}
};

View File

@@ -1,11 +1,10 @@
"use strict";
import { getContentDisposition, stripTags } from "../utils.js";
import becca from "../../becca/becca.js";
import type TaskContext from "../task_context.js";
import type BBranch from "../../becca/entities/bbranch.js";
import { utils } from "@triliumnext/core";
import type { Response } from "express";
import becca from "../../becca/becca.js";
import type BBranch from "../../becca/entities/bbranch.js";
import type TaskContext from "../task_context.js";
function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, version: string, res: Response) {
if (!["1.0", "2.0"].includes(version)) {
throw new Error(`Unrecognized OPML version ${version}`);
@@ -59,7 +58,7 @@ function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, versi
const filename = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.title}.opml`;
res.setHeader("Content-Disposition", getContentDisposition(filename));
res.setHeader("Content-Disposition", utils.getContentDisposition(filename));
res.setHeader("Content-Type", "text/x-opml");
res.write(`<?xml version="1.0" encoding="UTF-8"?>
@@ -83,7 +82,7 @@ function exportToOpml(taskContext: TaskContext<"export">, branch: BBranch, versi
function prepareText(text: string) {
const newLines = text.replace(/(<p[^>]*>|<br\s*\/?>)/g, "\n").replace(/&nbsp;/g, " "); // nbsp isn't in XML standard (only HTML)
const stripped = stripTags(newLines);
const stripped = utils.stripTags(newLines);
const escaped = escapeXmlAttribute(stripped);

View File

@@ -1,4 +1,5 @@
import { NoteType } from "@triliumnext/commons";
import { ExportFormat } from "@triliumnext/core";
import { Archiver } from "archiver";
import mimeTypes from "mime-types";
@@ -6,9 +7,9 @@ import type BBranch from "../../../becca/entities/bbranch.js";
import type BNote from "../../../becca/entities/bnote.js";
import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
export type { ExportFormat, NoteMeta } from "@triliumnext/core";
export type ExportFormat = "html" | "markdown" | "share";
type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string;
export interface AdvancedExportOptions {
/**

View File

@@ -1,12 +1,11 @@
import { deferred, LOCALES } from "@triliumnext/commons";
import { becca_loader, i18n } from "@triliumnext/core";
import { beforeAll, describe, expect, it } from "vitest";
import becca from "../becca/becca.js";
import becca_loader from "../becca/becca_loader.js";
import branches from "./branches.js";
import cls from "./cls.js";
import hiddenSubtreeService from "./hidden_subtree.js";
import { changeLanguage } from "./i18n.js";
import notes from "./notes.js";
import sql_init from "./sql_init.js";
@@ -141,7 +140,7 @@ describe("Hidden Subtree", () => {
}
try {
await changeLanguage(locale.id);
await i18n.changeLanguage(locale.id);
} catch (error) {
done.reject(error);
}

View File

@@ -1,10 +1,10 @@
import type { NoteType } from "@triliumnext/commons";
import { sanitize } from "@triliumnext/core";
import { sanitize, utils } from "@triliumnext/core";
import type BNote from "../../becca/entities/bnote.js";
import imageService from "../../services/image.js";
import noteService from "../../services/notes.js";
import { getNoteTitle, processStringOrBuffer } from "../../services/utils.js";
import { processStringOrBuffer } from "../../services/utils.js";
import protectedSessionService from "../protected_session.js";
import type TaskContext from "../task_context.js";
import type { File } from "./common.js";
@@ -57,7 +57,7 @@ function importFile(taskContext: TaskContext<"importNotes">, file: File, parentN
const mime = mimeService.getMime(originalName) || file.mimetype;
const { note } = noteService.createNewNote({
parentNoteId: parentNote.noteId,
title: getNoteTitle(originalName, mime === "application/pdf", { mime }),
title: utils.getNoteTitle(originalName, mime === "application/pdf", { mime }),
content: file.buffer,
isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable(),
type: "file",
@@ -72,7 +72,7 @@ function importFile(taskContext: TaskContext<"importNotes">, file: File, parentN
}
function importCodeNote(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const title = utils.getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const content = processStringOrBuffer(file.buffer);
const detectedMime = mimeService.getMime(file.originalname) || file.mimetype;
const mime = mimeService.normalizeMimeType(detectedMime);
@@ -97,7 +97,7 @@ function importCodeNote(taskContext: TaskContext<"importNotes">, file: File, par
}
function importCustomType(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote, type: NoteType, mime: string) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const title = utils.getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const content = processStringOrBuffer(file.buffer);
const { note } = noteService.createNewNote({
@@ -115,7 +115,7 @@ function importCustomType(taskContext: TaskContext<"importNotes">, file: File, p
}
function importPlainText(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const title = utils.getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const plainTextContent = processStringOrBuffer(file.buffer);
const htmlContent = convertTextToHtml(plainTextContent);
@@ -150,7 +150,7 @@ function convertTextToHtml(text: string) {
}
function importMarkdown(taskContext: TaskContext<"importNotes">, file: File, parentNote: BNote) {
const title = getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const title = utils.getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const markdownContent = processStringOrBuffer(file.buffer);
let htmlContent = markdownService.renderToHtml(markdownContent, title);
@@ -179,7 +179,7 @@ function importHtml(taskContext: TaskContext<"importNotes">, file: File, parentN
// Try to get title from HTML first, fall back to filename
// We do this before sanitization since that turns all <h1>s into <h2>
const htmlTitle = importUtils.extractHtmlTitle(content);
const title = htmlTitle || getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
const title = htmlTitle || utils.getNoteTitle(file.originalname, !!taskContext.data?.replaceUnderscoresWithSpaces);
content = importUtils.handleH1(content, title);

View File

@@ -1,7 +1,7 @@
import { ALLOWED_NOTE_TYPES, type NoteType } from "@triliumnext/commons";
import { sanitize } from "@triliumnext/core";
import { sanitize, utils } from "@triliumnext/core";
import path from "path";
import type { Stream } from "stream";
import yauzl from "yauzl";
@@ -14,7 +14,7 @@ import type BNote from "../../becca/entities/bnote.js";
import attributeService from "../../services/attributes.js";
import log from "../../services/log.js";
import noteService from "../../services/notes.js";
import { getNoteTitle, newEntityId, processStringOrBuffer, removeFileExtension, unescapeHtml } from "../../services/utils.js";
import { newEntityId, processStringOrBuffer, unescapeHtml } from "../../services/utils.js";
import type AttributeMeta from "../meta/attribute_meta.js";
import type NoteMeta from "../meta/note_meta.js";
import protectedSessionService from "../protected_session.js";
@@ -162,7 +162,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
// in case we lack metadata, we treat e.g. "Programming.html" and "Programming" as the same note
// (one data file, the other directory for children)
const filePathNoExt = removeFileExtension(filePath);
const filePathNoExt = utils.removeFileExtension(filePath);
if (filePathNoExt in createdPaths) {
return createdPaths[filePathNoExt];
@@ -234,7 +234,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
return;
}
const noteTitle = getNoteTitle(filePath, !!taskContext.data?.replaceUnderscoresWithSpaces, noteMeta);
const noteTitle = utils.getNoteTitle(filePath, !!taskContext.data?.replaceUnderscoresWithSpaces, noteMeta);
const parentNoteId = getParentNoteId(filePath, parentNoteMeta);
if (!parentNoteId) {
@@ -467,7 +467,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
content = processStringOrBuffer(content);
}
const noteTitle = getNoteTitle(filePath, taskContext.data?.replaceUnderscoresWithSpaces || false, noteMeta);
const noteTitle = utils.getNoteTitle(filePath, taskContext.data?.replaceUnderscoresWithSpaces || false, noteMeta);
content = processNoteContent(noteMeta, type, mime, content, noteTitle || "", filePath);

View File

@@ -1,8 +1 @@
export default interface AttachmentMeta {
attachmentId?: string;
title: string;
role: string;
mime: string;
position?: number;
dataFileName: string;
}
export type { AttachmentMeta as default } from "@triliumnext/core";

View File

@@ -1,10 +1 @@
import type { AttributeType } from "@triliumnext/commons";
export default interface AttributeMeta {
noteId?: string;
type: AttributeType;
name: string;
value: string;
isInheritable?: boolean;
position?: number;
}
export type { AttributeMeta as default } from "@triliumnext/core";

View File

@@ -1,32 +1 @@
import type { NoteType } from "@triliumnext/commons";
import type AttachmentMeta from "./attachment_meta.js";
import type AttributeMeta from "./attribute_meta.js";
import type { ExportFormat } from "../export/zip/abstract_provider.js";
export interface NoteMetaFile {
formatVersion: number;
appVersion: string;
files: NoteMeta[];
}
export default interface NoteMeta {
noteId?: string;
notePath?: string[];
isClone?: boolean;
title?: string;
notePosition?: number;
prefix?: string | null;
isExpanded?: boolean;
type?: NoteType;
mime?: string;
/** 'html' or 'markdown', applicable to text notes only */
format?: ExportFormat;
dataFileName?: string;
dirFileName?: string;
/** this file should not be imported (e.g., HTML navigation) */
noImport?: boolean;
isImportRoot?: boolean;
attributes?: AttributeMeta[];
attachments?: AttachmentMeta[];
children?: NoteMeta[];
}
export { type NoteMeta as default, type NoteMetaFile } from "@triliumnext/core";

View File

@@ -1,6 +1,7 @@
import { toObject } from "./utils.js";
import BackendScriptApi from "./backend_script_api.js";
import { utils } from "@triliumnext/core";
import type BNote from "../becca/entities/bnote.js";
import BackendScriptApi from "./backend_script_api.js";
import type { ApiParams } from "./backend_script_api_interface.js";
type Module = {
@@ -16,8 +17,8 @@ class ScriptContext {
constructor(allNotes: BNote[], apiParams: ApiParams) {
this.allNotes = allNotes;
this.modules = {};
this.notes = toObject(allNotes, (note) => [note.noteId, note]);
this.apis = toObject(allNotes, (note) => [note.noteId, new BackendScriptApi(note, apiParams)]);
this.notes = utils.toObject(allNotes, (note) => [note.noteId, note]);
this.apis = utils.toObject(allNotes, (note) => [note.noteId, new BackendScriptApi(note, apiParams)]);
}
require(moduleNoteIds: string[]) {

View File

@@ -1,707 +1,9 @@
import { describe, expect,it } from "vitest";
import { describe, expect, it } from "vitest";
import utils from "./utils.js";
type TestCase<T extends (...args: any) => any> = [desc: string, fnParams: Parameters<T>, expected: ReturnType<T>];
describe("#newEntityId", () => {
it("should return a string with a length of 12", () => {
const result = utils.newEntityId();
expect(result).toBeTypeOf("string");
expect(result).toHaveLength(12);
});
});
describe("#randomString", () => {
it("should return a string with a length as per argument", () => {
const stringLength = 5;
const result = utils.randomString(stringLength);
expect(result).toBeTypeOf("string");
expect(result).toHaveLength(stringLength);
});
});
// TriliumNextTODO: should use mocks and assert that functions get called
describe("#randomSecureToken", () => {
// base64 -> 4 * (bytes/3) length -> if padding and rounding up is ignored for simplicity
// https://stackoverflow.com/a/13378842
const byteToBase64Length = (bytes: number) => 4 * (bytes / 3);
it("should return a string and use 32 bytes by default", () => {
const result = utils.randomSecureToken();
expect(result).toBeTypeOf("string");
expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(32));
});
it("should return a string and use passed byte length", () => {
const bytes = 16;
const result = utils.randomSecureToken(bytes);
expect(result).toBeTypeOf("string");
expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(bytes));
expect(result.length).toBeLessThan(44); // default argument uses 32 bytes -> which translates to 44 base64 legal chars
});
});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#md5", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hashedBlobId", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#toBase64", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#fromBase64", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hmac", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hash", () => {});
describe("#isEmptyOrWhitespace", () => {
const testCases: TestCase<typeof utils.isEmptyOrWhitespace>[] = [
[ "w/ 'null' it should return true", [ null ], true ],
[ "w/ 'null' it should return true", [ null ], true ],
[ "w/ undefined it should return true", [ undefined ], true ],
[ "w/ empty string '' it should return true", [ "" ], true ],
[ "w/ single whitespace string ' ' it should return true", [ " " ], true ],
[ "w/ multiple whitespace string ' ' it should return true", [ " " ], true ],
[ "w/ non-empty string ' t ' it should return false", [ " t " ], false ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.isEmptyOrWhitespace(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#sanitizeSqlIdentifier", () => {
const testCases: TestCase<typeof utils.sanitizeSqlIdentifier>[] = [
[ "w/ 'test' it should not strip anything", [ "test" ], "test" ],
[ "w/ 'test123' it should not strip anything", [ "test123" ], "test123" ],
[ "w/ 'tEst_TeSt' it should not strip anything", [ "tEst_TeSt" ], "tEst_TeSt" ],
[ "w/ 'test_test' it should not strip '_'", [ "test_test" ], "test_test" ],
[ "w/ 'test-' it should strip the '-'", [ "test-" ], "test" ],
[ "w/ 'test-test' it should strip the '-'", [ "test-test" ], "testtest" ],
[ "w/ 'test; --test' it should strip the '; --'", [ "test; --test" ], "testtest" ],
[ "w/ 'test test' it should strip the ' '", [ "test test" ], "testtest" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.sanitizeSqlIdentifier(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#escapeHtml", () => {
it("should re-export 'escape-html' npm module as escapeHtml", () => {
expect(utils.escapeHtml).toBeTypeOf("function");
});
});
describe("#unescapeHtml", () => {
it("should re-export 'unescape' npm module as unescapeHtml", () => {
expect(utils.unescapeHtml).toBeTypeOf("function");
});
});
describe("#toObject", () => {
it("should return an object with keys and value being set from the supplied Function", () => {
type TestListEntry = { testPropA: string; testPropB: string };
type TestListFn = (testListEntry: TestListEntry) => [string, string];
const testList: [TestListEntry, TestListEntry] = [
{ testPropA: "keyA", testPropB: "valueA" },
{ testPropA: "keyB", testPropB: "valueB" }
];
const fn: TestListFn = (testListEntry: TestListEntry) => [ `${testListEntry.testPropA }_fn`, `${testListEntry.testPropB }_fn` ];
const result = utils.toObject(testList, fn);
expect(result).toStrictEqual({
keyA_fn: "valueA_fn",
keyB_fn: "valueB_fn"
});
});
});
describe("#stripTags", () => {
//prettier-ignore
const htmlWithNewlines =
`<p>abc
def</p>
<p>ghi</p>`;
const testCases: TestCase<typeof utils.stripTags>[] = [
[ "should strip all tags and only return the content, leaving new lines and spaces in tact", [ htmlWithNewlines ], "abc\ndef\nghi" ],
//TriliumNextTODO: should this actually insert a space between content to prevent concatenated text?
[ "should strip all tags and only return the content", [ "<h1>abc</h1><p>def</p>" ], "abcdef" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.stripTags(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#escapeRegExp", () => {});
describe.todo("#crash", () => {});
describe("#getContentDisposition", () => {
const defaultFallBackDisposition = `file; filename="file"; filename*=UTF-8''file`;
const testCases: TestCase<typeof utils.getContentDisposition>[] = [
[
"when passed filename is empty, it should fallback to default value 'file'",
[ " " ],
defaultFallBackDisposition
],
[
"when passed filename '..' would cause sanitized filename to be empty, it should fallback to default value 'file'",
[ ".." ],
defaultFallBackDisposition
],
// COM1 is a Windows specific "illegal filename" that sanitize filename strips away
[
"when passed filename 'COM1' would cause sanitized filename to be empty, it should fallback to default value 'file'",
[ "COM1" ],
defaultFallBackDisposition
],
[
"sanitized passed filename should be returned URIEncoded",
[ "test file.csv" ],
`file; filename="test%20file.csv"; filename*=UTF-8''test%20file.csv`
]
];
testCases.forEach(testCase => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.getContentDisposition(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#isStringNote", () => {
const testCases: TestCase<typeof utils.isStringNote>[] = [
[
"w/ 'undefined' note type, but a string mime type, it should return true",
[ undefined, "application/javascript" ],
true
],
[
"w/ non-string note type, it should return false",
[ "image", "image/jpeg" ],
false
],
[
"w/ string note type (text), it should return true",
[ "text", "text/html" ],
true
],
[
"w/ string note type (code), it should return true",
[ "code", "application/json" ],
true
],
[
"w/ non-string note type (file), but string mime type, it should return true",
[ "file", "application/json" ],
true
],
[
"w/ non-string note type (file), but mime type starting with 'text/', it should return true",
[ "file", "text/html" ],
true
]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.isStringNote(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#quoteRegex", () => {});
describe.todo("#replaceAll", () => {});
describe("#removeFileExtension", () => {
const testCases: TestCase<typeof utils.removeFileExtension>[] = [
[ "w/ 'test.md' it should strip '.md'", [ "test.md" ], "test" ],
[ "w/ 'test.markdown' it should strip '.markdown'", [ "test.markdown" ], "test" ],
[ "w/ 'test.html' it should strip '.html'", [ "test.html" ], "test" ],
[ "w/ 'test.htm' it should strip '.htm'", [ "test.htm" ], "test" ],
[ "w/ 'test.zip' it should NOT strip '.zip'", [ "test.zip" ], "test.zip" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.removeFileExtension(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#getNoteTitle", () => {
const testCases: TestCase<typeof utils.getNoteTitle>[] = [
[
"when file has no spaces, and no special file extension, it should return the filename unaltered",
[ "test.json", true, undefined ],
"test.json"
],
[
"when replaceUnderscoresWithSpaces is false, it should keep the underscores in the title",
[ "test_file.json", false, undefined ],
"test_file.json"
],
[
"when replaceUnderscoresWithSpaces is true, it should replace the underscores in the title",
[ "test_file.json", true, undefined ],
"test file.json"
],
[
"when filePath ends with one of the extra handled endings (.md), it should strip the file extension from the title",
[ "test_file.md", false, undefined ],
"test_file"
],
[
"when filePath ends with one of the extra handled endings (.md) and replaceUnderscoresWithSpaces is true, it should strip the file extension from the title and replace underscores",
[ "test_file.md", true, undefined ],
"test file"
],
[
"when filepath contains a full path, it should only return the basename of the file",
[ "Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.zip", true, undefined ],
"template.zip"
],
[
"when filepath contains a full path and has extra handled ending (.html), it should only return the basename of the file and strip the file extension",
[ "Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.html", true, undefined ],
"template"
],
[
"when a noteMeta object is passed, it should use the title from the noteMeta, if present",
[ "test_file.md", true, { title: "some other title" } ],
"some other title"
],
[
"when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed",
[ "test_file.md", true, { title: "" } ],
"test file"
],
[
"when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed",
[ "test_file.json", false, { title: " " } ],
"test_file.json"
]
];
testCases.forEach(testCase => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.getNoteTitle(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#timeLimit", () => {
it("when promise execution does NOT exceed timeout, it should resolve with promises' value", async () => {
const resolvedValue = `resolved: ${new Date().toISOString()}`;
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res(resolvedValue);
}, 200);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 1_000)).resolves.toBe(resolvedValue);
});
it("when promise execution rejects within timeout, it should return the original promises' rejected value, not the custom set one", async () => {
const rejectedValue = `rejected: ${new Date().toISOString()}`;
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
//return res("resolved");
rej(rejectedValue);
}, 100);
});
await expect(utils.timeLimit(testPromise, 200, "Custom Error")).rejects.toThrow(rejectedValue);
});
it("when promise execution exceeds the set timeout, and 'errorMessage' is NOT set, it should reject the promise and display default error message", async () => {
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res("resolved");
}, 500);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 200)).rejects.toThrow(`Process exceeded time limit 200`);
});
it("when promise execution exceeds the set timeout, and 'errorMessage' is set, it should reject the promise and display set error message", async () => {
const customErrorMsg = "Custom Error";
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res("resolved");
}, 500);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 200, customErrorMsg)).rejects.toThrow(customErrorMsg);
});
// TriliumNextTODO: since TS avoids this from ever happening do we need this check?
it("when the passed promise is not a promise but 'undefined', it should return 'undefined'", async () => {
//@ts-expect-error - passing in illegal type 'undefined'
expect(utils.timeLimit(undefined, 200)).toBe(undefined);
});
// TriliumNextTODO: since TS avoids this from ever happening do we need this check?
it("when the passed promise is not a promise, it should return the passed value", async () => {
//@ts-expect-error - passing in illegal type 'object'
expect(utils.timeLimit({ test: 1 }, 200)).toStrictEqual({ test: 1 });
});
});
describe("#removeDiacritic", () => {
const testCases: TestCase<typeof utils.removeDiacritic>[] = [
[ "w/ 'Äpfel' it should replace the 'Ä'", [ "Äpfel" ], "Apfel" ],
[ "w/ 'Été' it should replace the 'É' and 'é'", [ "Été" ], "Ete" ],
[ "w/ 'Fête' it should replace the 'ê'", [ "Fête" ], "Fete" ],
[ "w/ 'Αλφαβήτα' it should replace the 'ή'", [ "Αλφαβήτα" ], "Αλφαβητα" ],
[ "w/ '' (empty string) it should return empty string", [ "" ], "" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.removeDiacritic(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#normalize", () => {
const testCases: TestCase<typeof utils.normalize>[] = [
[ "w/ 'Äpfel' it should replace the 'Ä' and return lowercased", [ "Äpfel" ], "apfel" ],
[ "w/ 'Été' it should replace the 'É' and 'é' and return lowercased", [ "Été" ], "ete" ],
[ "w/ 'FêTe' it should replace the 'ê' and return lowercased", [ "FêTe" ], "fete" ],
[ "w/ 'ΑλΦαβήΤα' it should replace the 'ή' and return lowercased", [ "ΑλΦαβήΤα" ], "αλφαβητα" ],
[ "w/ '' (empty string) it should return empty string", [ "" ], "" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.normalize(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#toMap", () => {
it("should return an instace of Map, with the correct size and keys, when supplied with a list and existing keys", () => {
const testList = [ { title: "test", propA: "text", propB: 123 }, { title: "test2", propA: "prop2", propB: 456 } ];
const result = utils.toMap(testList, "title");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(Array.from(result.keys())).toStrictEqual([ "test", "test2" ]);
});
it("should return an instace of Map, with an empty size, when the supplied list does not contain the supplied key", () => {
const testList = [ { title: "test", propA: "text", propB: 123 }, { title: "test2", propA: "prop2", propB: 456 } ];
//@ts-expect-error - key is non-existing on supplied list type
const result = utils.toMap(testList, "nonExistingKey");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it.fails("should correctly handle duplicate keys? (currently it will overwrite the entry, so returned size will be 1 instead of 2)", () => {
const testList = [ { title: "testDupeTitle", propA: "text", propB: 123 }, { title: "testDupeTitle", propA: "prop2", propB: 456 } ];
const result = utils.toMap(testList, "title");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
});
});
describe("#envToBoolean", () => {
const testCases: TestCase<typeof utils.envToBoolean>[] = [
[ "w/ 'true' it should return boolean 'true'", [ "true" ], true ],
[ "w/ 'True' it should return boolean 'true'", [ "True" ], true ],
[ "w/ 'TRUE' it should return boolean 'true'", [ "TRUE" ], true ],
[ "w/ 'true ' it should return boolean 'true'", [ "true " ], true ],
[ "w/ 'false' it should return boolean 'false'", [ "false" ], false ],
[ "w/ 'False' it should return boolean 'false'", [ "False" ], false ],
[ "w/ 'FALSE' it should return boolean 'false'", [ "FALSE" ], false ],
[ "w/ 'false ' it should return boolean 'false'", [ "false " ], false ],
[ "w/ 'whatever' (non-boolean string) it should return undefined", [ "whatever" ], undefined ],
[ "w/ '-' (non-boolean string) it should return undefined", [ "-" ], undefined ],
[ "w/ '' (empty string) it should return undefined", [ "" ], undefined ],
[ "w/ ' ' (white space string) it should return undefined", [ " " ], undefined ],
[ "w/ undefined it should return undefined", [ undefined ], undefined ],
//@ts-expect-error - pass wrong type as param
[ "w/ number 1 it should return undefined", [ 1 ], undefined ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.envToBoolean(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#getResourceDir", () => {});
describe("#isElectron", () => {
it("should export a boolean", () => {
expect(utils.isElectron).toBeTypeOf("boolean");
});
});
describe("#isMac", () => {
it("should export a boolean", () => {
expect(utils.isMac).toBeTypeOf("boolean");
});
});
describe("#isWindows", () => {
it("should export a boolean", () => {
expect(utils.isWindows).toBeTypeOf("boolean");
});
});
import utils from "./utils";
describe("#isDev", () => {
it("should export a boolean", () => {
expect(utils.isDev).toBeTypeOf("boolean");
});
});
describe("#safeExtractMessageAndStackFromError", () => {
it("should correctly extract the message and stack property if it gets passed an instance of an Error", () => {
const testMessage = "Test Message";
const testError = new Error(testMessage);
const actual = utils.safeExtractMessageAndStackFromError(testError);
expect(actual[0]).toBe(testMessage);
expect(actual[1]).not.toBeUndefined();
});
it("should use the fallback 'Unknown Error' message, if it gets passed anything else than an instance of an Error", () => {
const testNonError = "this is not an instance of an Error, but JS technically allows us to throw this anyways";
const actual = utils.safeExtractMessageAndStackFromError(testNonError);
expect(actual[0]).toBe("Unknown Error");
expect(actual[1]).toBeUndefined();
});
});
describe("#formatDownloadTitle", () => {
//prettier-ignore
const testCases: [fnValue: Parameters<typeof utils.formatDownloadTitle>, expectedValue: ReturnType<typeof utils.formatDownloadTitle>][] = [
// empty fileName tests
[
[ "", "text", "" ],
"untitled.html"
],
[
[ "", "canvas", "" ],
"untitled.json"
],
[
[ "", null, "" ],
"untitled"
],
// json extension from type tests
[
[ "test_file", "canvas", "" ],
"test_file.json"
],
[
[ "test_file", "relationMap", "" ],
"test_file.json"
],
[
[ "test_file", "search", "" ],
"test_file.json"
],
// extension based on mime type
[
[ "test_file", null, "text/csv" ],
"test_file.csv"
],
[
[ "test_file_wo_ext", "image", "image/svg+xml" ],
"test_file_wo_ext.svg"
],
[
[ "test_file_wo_ext", "file", "application/json" ],
"test_file_wo_ext.json"
],
[
[ "test_file_w_fake_ext.ext", "image", "image/svg+xml" ],
"test_file_w_fake_ext.ext.svg"
],
[
[ "test_file_w_correct_ext.svg", "image", "image/svg+xml" ],
"test_file_w_correct_ext.svg"
],
[
[ "test_file_w_correct_ext.svgz", "image", "image/svg+xml" ],
"test_file_w_correct_ext.svgz"
],
[
[ "test_file.zip", "file", "application/zip" ],
"test_file.zip"
],
[
[ "test_file", "file", "application/zip" ],
"test_file.zip"
],
// application/octet-stream tests
[
[ "test_file", "file", "application/octet-stream" ],
"test_file"
],
[
[ "test_file.zip", "file", "application/octet-stream" ],
"test_file.zip"
],
[
[ "test_file.unknown", null, "application/octet-stream" ],
"test_file.unknown"
],
// sanitized filename tests
[
[ "test/file", null, "application/octet-stream" ],
"testfile"
],
[
[ "test:file.zip", "file", "application/zip" ],
"testfile.zip"
],
[
[ ":::", "file", "application/zip" ],
".zip"
],
[
[ ":::a", "file", "application/zip" ],
"a.zip"
]
];
testCases.forEach((testCase) => {
const [ fnParams, expected ] = testCase;
return it(`With args '${JSON.stringify(fnParams)}', it should return '${expected}'`, () => {
const actual = utils.formatDownloadTitle(...fnParams);
expect(actual).toStrictEqual(expected);
});
});
});
describe("#normalizeUrl", () => {
const testCases: TestCase<typeof utils.normalizeUrl>[] = [
[ "should remove trailing slash from simple URL", [ "https://example.com/" ], "https://example.com" ],
[ "should remove trailing slash from URL with path", [ "https://example.com/path/" ], "https://example.com/path" ],
[ "should preserve URL without trailing slash", [ "https://example.com" ], "https://example.com" ],
[ "should preserve URL without trailing slash with path", [ "https://example.com/path" ], "https://example.com/path" ],
[ "should preserve protocol-only URLs", [ "https://" ], "https://" ],
[ "should preserve protocol-only URLs", [ "http://" ], "http://" ],
[ "should fix double slashes in path", [ "https://example.com//api//test" ], "https://example.com/api/test" ],
[ "should handle multiple double slashes", [ "https://example.com///api///test" ], "https://example.com/api/test" ],
[ "should handle trailing slash with double slashes", [ "https://example.com//api//" ], "https://example.com/api" ],
[ "should preserve protocol double slash", [ "https://example.com/api" ], "https://example.com/api" ],
[ "should handle empty string", [ "" ], "" ],
[ "should handle whitespace-only string", [ " " ], "" ],
[ "should trim whitespace", [ " https://example.com/ " ], "https://example.com" ],
[ "should handle null as empty", [ null as any ], null ],
[ "should handle undefined as empty", [ undefined as any ], undefined ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.normalizeUrl(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#normalizeCustomHandlerPattern", () => {
const testCases: TestCase<typeof utils.normalizeCustomHandlerPattern>[] = [
[ "should handle pattern without ending - add both versions", [ "foo" ], [ "foo", "foo/" ] ],
[ "should handle pattern with trailing slash - add both versions", [ "foo/" ], [ "foo", "foo/" ] ],
[ "should handle pattern ending with $ - add optional slash", [ "foo$" ], [ "foo/?$" ] ],
[ "should handle pattern with trailing slash and $ - add both versions", [ "foo/$" ], [ "foo$", "foo/$" ] ],
[ "should preserve existing optional slash pattern", [ "foo/?$" ], [ "foo/?$" ] ],
[ "should preserve existing optional slash pattern (alternative)", [ "foo/?)" ], [ "foo/?)" ] ],
[ "should handle regex pattern with special chars", [ "api/[a-z]+$" ], [ "api/[a-z]+/?$" ] ],
[ "should handle complex regex pattern", [ "user/([0-9]+)/profile$" ], [ "user/([0-9]+)/profile/?$" ] ],
[ "should handle empty string", [ "" ], [ "" ] ],
[ "should handle whitespace-only string", [ " " ], [ "" ] ],
[ "should handle null", [ null as any ], [ null ] ],
[ "should handle undefined", [ undefined as any ], [ undefined ] ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.normalizeCustomHandlerPattern(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#slugify", () => {
it("should return a slugified string", () => {
const testString = "This is a Test String! With unicode & Special #Chars.";
const expectedSlug = "this-is-a-test-string-with-unicode-special-chars";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
it("supports CJK characters without alteration", () => {
const testString = "测试中文字符";
const expectedSlug = "测试中文字符";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
it("supports Cyrillic characters without alteration", () => {
const testString = "Тестирование кириллических символов";
const expectedSlug = "тестирование-кириллических-символов";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
// preserves diacritic marks
it("preserves diacritic marks", () => {
const testString = "Café naïve façade jalapeño";
const expectedSlug = "café-naïve-façade-jalapeño";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
});

View File

@@ -1,14 +1,10 @@
import { getCrypto,utils as coreUtils } from "@triliumnext/core";
import chardet from "chardet";
import crypto from "crypto";
import { t } from "i18next";
import { release as osRelease } from "os";
import path from "path";
import stripBom from "strip-bom";
import log from "./log.js";
import type NoteMeta from "./meta/note_meta.js";
const osVersion = osRelease().split('.').map(Number);
export const isMac = process.platform === "darwin";
@@ -83,22 +79,6 @@ export function constantTimeCompare(a: string | null | undefined, b: string | nu
return crypto.timingSafeEqual(bufA, bufB);
}
export function toObject<T, K extends string | number | symbol, V>(array: T[], fn: (item: T) => [K, V]): Record<K, V> {
const obj: Record<K, V> = {} as Record<K, V>; // TODO: unsafe?
for (const item of array) {
const ret = fn(item);
obj[ret[0]] = ret[1];
}
return obj;
}
export function stripTags(text: string) {
return text.replace(/<(?:.|\n)*?>/gm, "");
}
/** @deprecated */
export function getContentDisposition(filename: string) {
return coreUtils.getContentDisposition(filename);
@@ -124,37 +104,6 @@ export function formatDownloadTitle(fileName: string, type: string | null, mime:
return coreUtils.formatDownloadTitle(fileName, type, mime);
}
export function removeFileExtension(filePath: string, mime?: string) {
const extension = path.extname(filePath).toLowerCase();
if (mime?.startsWith("video/") || mime?.startsWith("audio/")) {
return filePath.substring(0, filePath.length - extension.length);
}
switch (extension) {
case ".md":
case ".mdx":
case ".markdown":
case ".html":
case ".htm":
case ".excalidraw":
case ".mermaid":
case ".mmd":
case ".pdf":
return filePath.substring(0, filePath.length - extension.length);
default:
return filePath;
}
}
export function getNoteTitle(filePath: string, replaceUnderscoresWithSpaces: boolean, noteMeta?: NoteMeta) {
const trimmedNoteMeta = noteMeta?.title?.trim();
if (trimmedNoteMeta) return trimmedNoteMeta;
const basename = path.basename(removeFileExtension(filePath, noteMeta?.mime));
return replaceUnderscoresWithSpaces ? basename.replace(/_/g, " ").trim() : basename;
}
/** @deprecated */
export function removeDiacritic(str: string) {
return coreUtils.removeDiacritic(str);
@@ -170,37 +119,6 @@ export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) {
return coreUtils.toMap(list, key);
}
// try to turn 'true' and 'false' strings from process.env variables into boolean values or undefined
export function envToBoolean(val: string | undefined) {
if (val === undefined || typeof val !== "string") return undefined;
const valLc = val.toLowerCase().trim();
if (valLc === "true") return true;
if (valLc === "false") return false;
return undefined;
}
/**
* Parses a string value to an integer. If the resulting number is NaN or undefined, the result is also undefined.
*
* @param val the value to parse.
* @returns the parsed value.
*/
export function stringToInt(val: string | undefined) {
if (!val) {
return undefined;
}
const parsed = parseInt(val, 10);
if (Number.isNaN(parsed)) {
return undefined;
}
return parsed;
}
/**
* Returns the directory for resources. On Electron builds this corresponds to the `resources` subdirectory inside the distributable package.
* On development builds, this simply refers to the src directory of the application.
@@ -220,48 +138,6 @@ export function getResourceDir() {
return path.join(__dirname, "..");
}
// TODO: Deduplicate with src/public/app/services/utils.ts
/**
* Compares two semantic version strings.
* Returns:
* 1 if v1 is greater than v2
* 0 if v1 is equal to v2
* -1 if v1 is less than v2
*
* @param v1 First version string
* @param v2 Second version string
* @returns
*/
function compareVersions(v1: string, v2: string): number {
// Remove 'v' prefix and everything after dash if present
v1 = v1.replace(/^v/, "").split("-")[0];
v2 = v2.replace(/^v/, "").split("-")[0];
const v1parts = v1.split(".").map(Number);
const v2parts = v2.split(".").map(Number);
// Pad shorter version with zeros
while (v1parts.length < 3) v1parts.push(0);
while (v2parts.length < 3) v2parts.push(0);
// Compare major version
if (v1parts[0] !== v2parts[0]) {
return v1parts[0] > v2parts[0] ? 1 : -1;
}
// Compare minor version
if (v1parts[1] !== v2parts[1]) {
return v1parts[1] > v2parts[1] ? 1 : -1;
}
// Compare patch version
if (v1parts[2] !== v2parts[2]) {
return v1parts[2] > v2parts[2] ? 1 : -1;
}
return 0;
}
/**
* For buffers, they are scanned for a supported encoding and decoded (UTF-8, UTF-16). In some cases, the BOM is also stripped.
*
@@ -291,82 +167,6 @@ export function processStringOrBuffer(data: string | Buffer | null) {
}
}
/**
* Normalizes a path pattern for custom request handlers.
* Ensures both trailing slash and non-trailing slash versions are handled.
*
* @param pattern The original pattern from customRequestHandler attribute
* @returns An array of patterns to match both with and without trailing slash
*/
export function normalizeCustomHandlerPattern(pattern: string | null | undefined): (string | null | undefined)[] {
if (!pattern || typeof pattern !== 'string') {
return [pattern];
}
pattern = pattern.trim();
if (!pattern) {
return [pattern];
}
// If pattern already ends with optional trailing slash, return as-is
if (pattern.endsWith('/?$') || pattern.endsWith('/?)')) {
return [pattern];
}
// If pattern ends with $, handle it specially
if (pattern.endsWith('$')) {
const basePattern = pattern.slice(0, -1);
// If already ends with slash, create both versions
if (basePattern.endsWith('/')) {
const withoutSlash = `${basePattern.slice(0, -1) }$`;
const withSlash = pattern;
return [withoutSlash, withSlash];
}
// Add optional trailing slash
const withSlash = `${basePattern }/?$`;
return [withSlash];
}
// For patterns without $, add both versions
if (pattern.endsWith('/')) {
const withoutSlash = pattern.slice(0, -1);
return [withoutSlash, pattern];
}
const withSlash = `${pattern }/`;
return [pattern, withSlash];
}
export function formatUtcTime(time: string) {
return time.replace("T", " ").substring(0, 19);
}
// TODO: Deduplicate with client utils
export function formatSize(size: number | null | undefined) {
if (size === null || size === undefined) {
return "";
}
size = Math.max(Math.round(size / 1024), 1);
if (size < 1024) {
return `${size} KiB`;
}
return `${Math.round(size / 102.4) / 10} MiB`;
}
function slugify(text: string) {
return text
.normalize("NFC") // keep composed form, preserves accents
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, "-") // replace non-letter/number with "-"
.replace(/(^-|-$)+/g, ""); // trim dashes
}
/** @deprecated */
export const escapeHtml = coreUtils.escapeHtml;
/** @deprecated */
@@ -392,15 +192,12 @@ export function waitForStreamToFinish(stream: any): Promise<void> {
}
export default {
compareVersions,
constantTimeCompare,
envToBoolean,
escapeHtml,
escapeRegExp,
formatDownloadTitle,
fromBase64,
getContentDisposition,
getNoteTitle,
getResourceDir,
hashedBlobId,
hmac,
@@ -413,19 +210,14 @@ export default {
md5,
newEntityId,
normalize,
normalizeCustomHandlerPattern,
quoteRegex,
randomSecureToken,
randomString,
removeDiacritic,
removeFileExtension,
replaceAll,
safeExtractMessageAndStackFromError,
stripTags,
slugify,
toBase64,
toMap,
toObject,
unescapeHtml,
waitForStreamToFinish
};

View File

@@ -24,7 +24,7 @@ export default class WebSocketMessagingProvider implements MessagingProvider {
init(httpServer: HttpServer, sessionParser: express.RequestHandler) {
this.webSocketServer = new WebSocketServer({
verifyClient: (info, done) => {
sessionParser(info.req as express.Request, {}, () => {
sessionParser(info.req as express.Request, {} as express.Response, () => {
const allowed = isElectron || (info.req as any).session.loggedIn || (config.General && config.General.noAuthentication);
if (!allowed) {

View File

@@ -44,7 +44,8 @@ export default class BetterSqlite3Provider implements DatabaseProvider {
prepare(query: string): Statement {
if (!this.dbConnection) throw new Error("DB not open.");
return this.dbConnection.prepare(query);
// Cast is safe: better-sqlite3 only returns bigint when safeIntegers() is enabled, which we don't use.
return this.dbConnection.prepare(query) as unknown as Statement;
}
transaction<T>(func: (statement: Statement) => T): Transaction {

View File

@@ -1,134 +1,3 @@
import { NoteType } from "@triliumnext/commons";
import BAttachment from "../becca/entities/battachment.js";
import BAttribute from "../becca/entities/battribute.js";
import BBranch from "../becca/entities/bbranch.js";
import BNote from "../becca/entities/bnote.js";
import utils, { randomString } from "../services/utils.js";
type AttributeDefinitions = { [key in `#${string}`]: string; };
type RelationDefinitions = { [key in `~${string}`]: string; };
interface NoteDefinition extends AttributeDefinitions, RelationDefinitions {
id?: string | undefined;
title?: string;
content?: string;
type?: NoteType;
mime?: string;
children?: NoteDefinition[];
attachments?: {
title: string;
role: string;
mime: string;
}[];
}
/**
* Creates the given notes with the given title and optionally one or more attributes.
*
* For a label to be created, simply pass on a key prefixed with `#` and any desired value.
*
* The notes and attributes will be injected in the froca.
*
* @param notes
* @returns an array containing the IDs of the created notes.
* @example
* buildNotes([
* { title: "A", "#startDate": "2025-05-05" },
* { title: "B", "#startDate": "2025-05-07" }
* ]);
*/
export function buildNotes(notes: NoteDefinition[]) {
const ids: string[] = [];
for (const noteDef of notes) {
ids.push(buildNote(noteDef).noteId);
}
return ids;
}
export function buildNote(noteDef: NoteDefinition) {
const note = new BNote({
noteId: noteDef.id ?? utils.randomString(12),
title: noteDef.title ?? "New note",
type: noteDef.type ?? "text",
mime: noteDef.mime ?? "text/html",
isProtected: false,
blobId: ""
});
// Handle content.
if (noteDef.content !== undefined) {
note.getContent = () => noteDef.content!;
}
// Handle children
if (noteDef.children) {
for (const childDef of noteDef.children) {
const childNote = buildNote(childDef);
new BBranch({
noteId: childNote.noteId,
parentNoteId: note.noteId,
branchId: `${note.noteId}_${childNote.noteId}`
});
}
}
// Handle labels and relations.
let position = 0;
for (const [ key, value ] of Object.entries(noteDef)) {
const attributeId = utils.randomString(12);
const name = key.substring(1);
let attribute: BAttribute | null = null;
if (key.startsWith("#")) {
attribute = new BAttribute({
noteId: note.noteId,
attributeId,
type: "label",
name,
value,
position,
isInheritable: false
});
}
if (key.startsWith("~")) {
attribute = new BAttribute({
noteId: note.noteId,
attributeId,
type: "relation",
name,
value,
position,
isInheritable: false
});
}
if (!attribute) {
continue;
}
position++;
}
// Handle attachments.
if (noteDef.attachments) {
const allAttachments: BAttachment[] = [];
for (const { title, role, mime } of noteDef.attachments) {
const attachment = new BAttachment({
attachmentId: randomString(10),
ownerId: note.noteId,
title,
role,
mime
});
allAttachments.push(attachment);
}
note.getAttachmentsByRole = (role) => allAttachments.filter(a => a.role === role);
}
return note;
}
import { becca_easy_mocking } from "@triliumnext/core";
export const buildNote = becca_easy_mocking.buildNote;
export const buildNotes = becca_easy_mocking.buildNotes;

View File

@@ -1,76 +1,2 @@
import BNote from "../becca/entities/bnote.js";
import BBranch from "../becca/entities/bbranch.js";
import BAttribute from "../becca/entities/battribute.js";
import becca from "../becca/becca.js";
import randtoken from "rand-token";
import type SearchResult from "../services/search/search_result.js";
import type { NoteRow, NoteType } from "@triliumnext/commons";
randtoken.generator({ source: "crypto" });
export function findNoteByTitle(searchResults: Array<SearchResult>, title: string): BNote | undefined {
return searchResults.map((sr) => becca.notes[sr.noteId]).find((note) => note.title === title);
}
export class NoteBuilder {
note: BNote;
constructor(note: BNote) {
this.note = note;
}
label(name: string, value = "", isInheritable = false) {
new BAttribute({
attributeId: id(),
noteId: this.note.noteId,
type: "label",
isInheritable,
name,
value
});
return this;
}
relation(name: string, targetNote: BNote) {
new BAttribute({
attributeId: id(),
noteId: this.note.noteId,
type: "relation",
name,
value: targetNote.noteId
});
return this;
}
child(childNoteBuilder: NoteBuilder, prefix = "") {
new BBranch({
branchId: id(),
noteId: childNoteBuilder.note.noteId,
parentNoteId: this.note.noteId,
prefix,
notePosition: 10
});
return this;
}
}
export function id() {
return randtoken.generate(10);
}
export function note(title: string, extraParams: Partial<NoteRow> = {}) {
const row = Object.assign(
{
noteId: id(),
title: title,
type: "text" as NoteType,
mime: "text/html"
},
extraParams
);
const note = new BNote(row);
return new NoteBuilder(note);
}
import { becca_mocking } from "@triliumnext/core";
export const NoteBuilder = becca_mocking.NoteBuilder;

View File

@@ -1,4 +1,4 @@
import { getMessagingProvider } from "@triliumnext/core";
import { getMessagingProvider, utils } from "@triliumnext/core";
import type { Express } from "express";
import fs from "fs";
import http from "http";
@@ -12,7 +12,6 @@ import host from "./services/host.js";
import log from "./services/log.js";
import port from "./services/port.js";
import { getDbSize } from "./services/sql_init.js";
import utils, { formatSize, formatUtcTime } from "./services/utils.js";
import WebSocketMessagingProvider from "./services/ws_messaging_provider.js";
const MINIMUM_NODE_VERSION = "20.0.0";
@@ -62,7 +61,7 @@ export default async function startTriliumServer() {
const sessionParser = (await import("./routes/session_parser.js")).default;
(getMessagingProvider() as WebSocketMessagingProvider).init(httpServer, sessionParser);
if (utils.isElectron) {
if (utils.isElectron()) {
const electronRouting = await import("./routes/electron.js");
electronRouting.default(app);
}
@@ -71,9 +70,9 @@ export default async function startTriliumServer() {
async function displayStartupMessage() {
log.info(`\n${LOGO.replace("[version]", appInfo.appVersion)}`);
log.info(`📦 Versions: app=${appInfo.appVersion} db=${appInfo.dbVersion} sync=${appInfo.syncVersion} clipper=${appInfo.clipperProtocolVersion}`);
log.info(`🔧 Build: ${formatUtcTime(appInfo.buildDate)} (${appInfo.buildRevision.substring(0, 10)})`);
log.info(`🔧 Build: ${utils.formatUtcTime(appInfo.buildDate)} (${appInfo.buildRevision.substring(0, 10)})`);
log.info(`📂 Data dir: ${appInfo.dataDirectory}`);
log.info(`⏰ UTC time: ${formatUtcTime(appInfo.utcDateTime)}`);
log.info(`⏰ UTC time: ${utils.formatUtcTime(appInfo.utcDateTime)}`);
// for perf. issues it's good to know the rough configuration
const cpuInfos = (await import("os")).cpus();
@@ -82,7 +81,7 @@ async function displayStartupMessage() {
const cpuModel = (cpuInfos[0].model || "").trimEnd();
log.info(`💻 CPU: ${cpuModel} (${cpuInfos.length}-core @ ${cpuInfos[0].speed} Mhz)`);
}
log.info(`💾 DB size: ${formatSize(getDbSize() * 1024)}`);
log.info(`💾 DB size: ${utils.formatSize(getDbSize() * 1024)}`);
log.info("");
}
@@ -155,7 +154,7 @@ function startHttpServer(app: Express) {
}
}
if (utils.isElectron) {
if (utils.isElectron()) {
import("electron").then(({ app, dialog }) => {
// Not all situations require showing an error dialog. When Trilium is already open,
// clicking the shortcut, the software icon, or the taskbar icon, or when creating a new window,

View File

@@ -13,7 +13,7 @@
"postinstall": "wxt prepare"
},
"keywords": [],
"packageManager": "pnpm@10.32.1",
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@wxt-dev/auto-icons": "1.1.1",
"wxt": "0.20.20"

View File

@@ -9,21 +9,21 @@
"preview": "pnpm build && vite preview"
},
"dependencies": {
"i18next": "25.10.3",
"i18next": "25.10.10",
"i18next-http-backend": "3.0.2",
"preact": "10.29.0",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "16.6.0"
"react-i18next": "16.6.6"
},
"devDependencies": {
"@preact/preset-vite": "2.10.5",
"eslint": "10.1.0",
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",
"vite": "8.0.1",
"vitest": "4.1.0"
"user-agent-data-types": "0.4.3",
"vite": "8.0.2",
"vitest": "4.1.2"
},
"eslintConfig": {
"extends": "preact"

View File

@@ -201,7 +201,7 @@
"resources": {
"title": "Risorse",
"icon_packs": "Pacchetti di icone",
"icon_packs_intro": "Ampliate la selezione di icone disponibili per le vostre note utilizzando un pacchetto di icone. Per ulteriori informazioni sui pacchetti di icone, consultate la<DocumentationLink>documentazione ufficiale</DocumentationLink>.",
"icon_packs_intro": "Ampliate la selezione di icone disponibili per le vostre note utilizzando un pacchetto di icone. Per ulteriori informazioni sui pacchetti di icone, consultate la <DocumentationLink>documentazione ufficiale</DocumentationLink>.",
"download": "Scarica",
"website": "Sito web"
}

View File

@@ -201,7 +201,7 @@
"resources": {
"title": "Zasoby",
"icon_packs": "Paczki ikon",
"icon_packs_intro": "Rozszerz wybór dostępnych ikon dla swoich notatek, korzystając z pakietu ikon. Więcej informacji na temat pakietów ikon znajdziesz w <DocumentationLink> dokumentacji </DocumentationLink>.",
"icon_packs_intro": "Rozszerz wybór dostępnych ikon dla swoich notatek, korzystając z pakietu ikon. Więcej informacji na temat pakietów ikon znajdziesz w <DocumentationLink> oficjalnej dokumentacji </DocumentationLink>.",
"download": "Pobieranie",
"website": "Strona internetowa"
}

2
docs/README-pl.md vendored
View File

@@ -48,7 +48,7 @@ wiedzy.
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Nasza dokumentacja jest dostępna w wielu formatach:
- **Dokumentacja Online**: Pełna dokumentacja dostępna na
- **Dokumentacja online**: Przeglądaj pełną dokumentację pod linkiem
[docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **Pomoc w aplikacji**: Naciśnij `F1` w Trilium, aby uzyskać dostęp do tej
samej dokumentacji bezpośrednio w aplikacji

View File

@@ -37,7 +37,7 @@
"test:all": "pnpm test:parallel && pnpm test:sequential",
"test:parallel": "pnpm --filter=!server --filter=!ckeditor5-mermaid --filter=!ckeditor5-math --parallel test",
"test:sequential": "pnpm --filter=server --filter=ckeditor5-mermaid --filter=ckeditor5-math --sequential test",
"typecheck": "tsc --build",
"typecheck": "tsx scripts/filter-tsc-output.mts",
"dev:format-check": "eslint -c eslint.format.config.mjs .",
"dev:format-fix": "eslint -c eslint.format.config.mjs . --fix",
"dev:linter-check": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint .",
@@ -54,9 +54,9 @@
"@types/express": "5.0.6",
"@types/js-yaml": "4.0.9",
"@types/node": "24.12.0",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/coverage-v8": "4.1.0",
"@vitest/ui": "4.1.0",
"@vitest/browser-webdriverio": "4.1.2",
"@vitest/coverage-v8": "4.1.2",
"@vitest/ui": "4.1.2",
"chalk": "5.6.2",
"cross-env": "10.1.0",
"dpdm": "4.0.1",
@@ -66,7 +66,7 @@
"eslint-config-prettier": "10.1.8",
"eslint-plugin-playwright": "2.10.1",
"eslint-plugin-simple-import-sort": "12.1.1",
"happy-dom": "20.8.4",
"happy-dom": "20.8.8",
"http-server": "14.1.1",
"jiti": "2.6.1",
"js-yaml": "4.1.1",
@@ -76,11 +76,11 @@
"tslib": "2.8.1",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"typescript-eslint": "8.57.2",
"upath": "2.0.1",
"vite": "8.0.1",
"vite": "8.0.2",
"vite-plugin-dts": "4.5.4",
"vitest": "4.1.0"
"vitest": "4.1.2"
},
"license": "AGPL-3.0-only",
"author": {
@@ -96,14 +96,14 @@
"url": "https://github.com/TriliumNext/Trilium/issues"
},
"homepage": "https://triliumnotes.org",
"packageManager": "pnpm@10.32.1",
"packageManager": "pnpm@10.33.0",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
"@ckeditor/ckeditor5-code-block": "patches/@ckeditor__ckeditor5-code-block.patch"
},
"overrides": {
"@codemirror/language": "6.12.2",
"@codemirror/language": "6.12.3",
"@lezer/highlight": "1.2.3",
"@lezer/common": "1.5.1",
"mermaid": "11.13.0",

View File

@@ -24,22 +24,22 @@
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitest/browser": "4.1.2",
"@vitest/coverage-istanbul": "4.1.2",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.6.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.0",
"webdriverio": "9.26.1"
"vitest": "4.1.2",
"webdriverio": "9.27.0"
},
"peerDependencies": {
"ckeditor5": "47.6.1"

View File

@@ -25,22 +25,22 @@
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitest/browser": "4.1.2",
"@vitest/coverage-istanbul": "4.1.2",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.6.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.0",
"webdriverio": "9.26.1"
"vitest": "4.1.2",
"webdriverio": "9.27.0"
},
"peerDependencies": {
"ckeditor5": "47.6.1"

View File

@@ -27,22 +27,22 @@
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitest/browser": "4.1.2",
"@vitest/coverage-istanbul": "4.1.2",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.6.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.0",
"webdriverio": "9.26.1"
"vitest": "4.1.2",
"webdriverio": "9.27.0"
},
"peerDependencies": {
"ckeditor5": "47.6.1"

View File

@@ -27,22 +27,22 @@
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitest/browser": "4.1.2",
"@vitest/coverage-istanbul": "4.1.2",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.6.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.0",
"webdriverio": "9.26.1"
"vitest": "4.1.2",
"webdriverio": "9.27.0"
},
"peerDependencies": {
"ckeditor5": "47.6.1"

View File

@@ -27,22 +27,22 @@
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitest/browser": "4.1.2",
"@vitest/coverage-istanbul": "4.1.2",
"ckeditor5": "47.6.1",
"eslint": "10.1.0",
"eslint-config-ckeditor5": ">=9.1.0",
"http-server": "14.1.1",
"lint-staged": "16.4.0",
"stylelint": "17.5.0",
"stylelint": "17.6.0",
"stylelint-config-ckeditor5": ">=9.1.0",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.1.0",
"webdriverio": "9.26.1"
"vitest": "4.1.2",
"webdriverio": "9.27.0"
},
"peerDependencies": {
"ckeditor5": "47.6.1"

View File

@@ -313,9 +313,9 @@ export interface DefinitionObject {
}
/**
* Subset of bootstrap items that are available both in the main client and in the setup page.
* Bootstrap items that the client needs to start up. These are sent by the server in the HTML and made available as `window.glob`.
*/
export interface BootstrapCommonItems {
export type BootstrapDefinition = {
dbInitialized: boolean;
baseApiUrl: string;
assetPath: string;
@@ -323,24 +323,17 @@ export interface BootstrapCommonItems {
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
iconPackCss: string;
iconRegistry: IconRegistry;
}
/**
* Bootstrap items that the client needs to start up. These are sent by the server in the HTML and made available as `window.glob`.
*/
export type BootstrapDefinition = BootstrapCommonItems & ({
dbInitialized: true;
device: "mobile" | "desktop" | "print" | false;
csrfToken: string;
csrfToken?: string;
headingStyle: "plain" | "underline" | "markdown";
layoutOrientation: "vertical" | "horizontal";
platform?: typeof process.platform | "web";
isElectron: boolean;
isStandalone?: boolean;
isStandalone: boolean;
hasNativeTitleBar: boolean;
hasBackgroundEffects: boolean;
maxEntityChangeIdAtLoad: number;
maxEntityChangeSyncIdAtLoad: number;
maxEntityChangeIdAtLoad?: number;
maxEntityChangeSyncIdAtLoad?: number;
instanceName: string | null;
appCssNoteIds: string[];
isDev: boolean;
@@ -351,9 +344,8 @@ export type BootstrapDefinition = BootstrapCommonItems & ({
currentLocale: Locale;
isRtl: boolean;
TRILIUM_SAFE_MODE: boolean;
} | {
dbInitialized: false;
});
componentId?: string;
};
/**
* Response for /api/setup/status.

View File

@@ -25,14 +25,14 @@
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.40",
"katex": "0.16.43",
"mermaid": "11.13.0"
},
"devDependencies": {
"@digitak/esrun": "3.2.26",
"@triliumnext/ckeditor5": "workspace:*",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"dotenv": "17.3.1",
"esbuild": "0.27.4",
"eslint": "10.1.0",

View File

@@ -9,17 +9,18 @@
"dependencies": {
"@braintree/sanitize-url": "7.1.1",
"@triliumnext/commons": "workspace:*",
"async-mutex": "0.5.0",
"escape-html": "1.0.3",
"i18next": "25.10.3",
"i18next": "25.10.10",
"mime-types": "3.0.2",
"node-html-parser": "7.1.0",
"sanitize-filename": "1.6.4",
"sanitize-html": "2.17.2",
"unescape": "1.0.1",
"async-mutex": "0.5.0"
"unescape": "1.0.1"
},
"devDependencies": {
"@types/escape-html": "1.0.4",
"@types/mime-types": "3.0.1",
"@types/sanitize-html": "2.16.1"
}
}
}

View File

@@ -8,7 +8,7 @@ import { initRequest, RequestProvider } from "./services/request";
import { initTranslations, TranslationProvider } from "./services/i18n";
import { initSchema } from "./services/sql_init";
import appInfo from "./services/app_info";
import PlatformProvider, { initPlatform } from "./services/platform";
import { type PlatformProvider, initPlatform } from "./services/platform";
export { getLog } from "./services/log";
export type * from "./services/sql/types";
@@ -77,7 +77,9 @@ export type { NotePojo } from "./becca/becca-interface";
export { default as NoteSet } from "./services/search/note_set";
export { default as SearchContext } from "./services/search/search_context";
export { default as search } from "./services/search/services/search";
export { default as search, } from "./services/search/services/search";
export { type default as SearchResult } from "./services/search/search_result";
export { type SearchParams } from "./services/search/services/types";
export { default as note_service } from "./services/notes";
export type { NoteParams } from "./services/notes";
export * as sanitize from "./services/sanitizer";
@@ -94,6 +96,11 @@ export { default as setup } from "./services/setup";
export { getPlatform, type PlatformProvider } from "./services/platform";
export { t } from "i18next";
export type { RequestProvider, ExecOpts, CookieJar } from "./services/request";
export type * from "./meta";
export * as routeHelpers from "./routes/helpers";
export * as becca_easy_mocking from "./test/becca_easy_mocking";
export * as becca_mocking from "./test/becca_mocking";
export async function initializeCore({ dbConfig, executionContext, crypto, translations, messaging, request, schema, extraAppInfo, platform }: {
dbConfig: SqlServiceParams,
@@ -114,7 +121,7 @@ export async function initializeCore({ dbConfig, executionContext, crypto, trans
await initTranslations(translations);
initCrypto(crypto);
initContext(executionContext);
initSql(new SqlService(dbConfig, getLog()), dbConfig.onDatabaseNotInitialized);
initSql(new SqlService(dbConfig, getLog()));
initSchema(schema);
Object.assign(appInfo, extraAppInfo);
if (messaging) {

View File

@@ -0,0 +1,49 @@
import type { AttributeType, NoteType } from "@triliumnext/commons";
export type ExportFormat = "html" | "markdown" | "share";
export interface AttachmentMeta {
attachmentId?: string;
title: string;
role: string;
mime: string;
position?: number;
dataFileName: string;
}
export interface AttributeMeta {
noteId?: string;
type: AttributeType;
name: string;
value: string;
isInheritable?: boolean;
position?: number;
}
export interface NoteMetaFile {
formatVersion: number;
appVersion: string;
files: NoteMeta[];
}
export interface NoteMeta {
noteId?: string;
notePath?: string[];
isClone?: boolean;
title?: string;
notePosition?: number;
prefix?: string | null;
isExpanded?: boolean;
type?: NoteType;
mime?: string;
/** 'html' or 'markdown', applicable to text notes only */
format?: ExportFormat;
dataFileName?: string;
dirFileName?: string;
/** this file should not be imported (e.g., HTML navigation) */
noImport?: boolean;
isImportRoot?: boolean;
attributes?: AttributeMeta[];
attachments?: AttachmentMeta[];
children?: NoteMeta[];
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach } from "vitest";
import * as cls from "../services/context.js";
import { getSql } from "../services/sql/index.js";
import { getSql, rebuildIntegrationTestDatabase } from "../services/sql/index.js";
import becca from "../becca/becca.js";
import becca_loader from "../becca/becca_loader.js";
import migration from "./0233__migrate_geo_map_to_collection.js";
@@ -23,7 +23,7 @@ describe("Migration 0233: Migrate geoMap to collection", () => {
beforeEach(async () => {
// Set up a clean in-memory database for each test
sql.rebuildIntegrationTestDatabase();
rebuildIntegrationTestDatabase();
await new Promise<void>((resolve) => {
cls.getContext().init(() => {

View File

@@ -46,7 +46,7 @@ function saveAttachment(req: Request<{ noteId: string }>) {
function uploadAttachment(req: Request<{ noteId: string }>) {
const { noteId } = req.params;
const { file } = req;
const { file } = req as any; // TODO: Add support for file upload in type definitions and remove 'as any' cast
if (!file) {
return {

View File

@@ -0,0 +1,26 @@
import { Request, Response } from "express";
import becca from "../../becca/becca";
import { downloadData, downloadNoteInt } from "../helpers";
const downloadFile = (req: Request<{ noteId: string }>, res: Response) => downloadNoteInt(req.params.noteId, res, true);
const openFile = (req: Request<{ noteId: string }>, res: Response) => downloadNoteInt(req.params.noteId, res, false);
const downloadAttachment = (req: Request<{ attachmentId: string }>, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, true);
const openAttachment = (req: Request<{ attachmentId: string }>, res: Response) => downloadAttachmentInt(req.params.attachmentId, res, false);
function downloadAttachmentInt(attachmentId: string, res: Response, contentDisposition = true) {
const attachment = becca.getAttachment(attachmentId);
if (!attachment) {
return res.setHeader("Content-Type", "text/plain").status(404).send(`Attachment '${attachmentId}' doesn't exist.`);
}
return downloadData(attachment, res, contentDisposition);
}
export default {
openFile,
downloadFile,
openAttachment,
downloadAttachment,
}

View File

@@ -93,7 +93,7 @@ function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Respon
function updateImage(req: Request<{ noteId: string }>) {
const { noteId } = req.params;
const { file } = req;
const { file } = req as any; // TODO: Add support for file upload in type definitions and remove 'as any' cast
const _note = becca.getNoteOrThrow(noteId);

View File

@@ -1,8 +1,342 @@
import type { Request } from "express";
import BAttribute from "../../becca/entities/battribute";
import BNote from "../../becca/entities/bnote";
import becca from "../../becca/becca";
import type { BacklinkCountResponse } from "@triliumnext/commons";
import type { BacklinkCountResponse, BacklinksResponse } from "@triliumnext/commons";
import type { Request } from "express";
import { HTMLElement, parse, TextNode } from "node-html-parser";
interface TreeLink {
sourceNoteId: string;
targetNoteId: string;
}
function buildDescendantCountMap(noteIdsToCount: string[]) {
if (!Array.isArray(noteIdsToCount)) {
throw new Error("noteIdsToCount: type error");
}
const noteIdToCountMap: Record<string, number> = Object.create(null);
function getCount(noteId: string): number {
if (!(noteId in noteIdToCountMap)) {
const note = becca.getNote(noteId);
if (!note) {
return 0;
}
const hiddenImageNoteIds = note.getRelations("imageLink").map((rel) => rel.value);
const childNoteIds = note.children.map((child) => child.noteId);
const nonHiddenNoteIds = childNoteIds.filter((childNoteId) => !hiddenImageNoteIds.includes(childNoteId));
noteIdToCountMap[noteId] = nonHiddenNoteIds.length;
for (const child of note.children) {
noteIdToCountMap[noteId] += getCount(child.noteId);
}
}
return noteIdToCountMap[noteId];
}
noteIdsToCount.forEach((noteId) => {
getCount(noteId);
});
return noteIdToCountMap;
}
function getNeighbors(note: BNote, depth: number): string[] {
if (depth === 0) {
return [];
}
const retNoteIds: string[] = [];
function isIgnoredRelation(relation: BAttribute) {
return ["relationMapLink", "template", "inherit", "image", "ancestor"].includes(relation.name);
}
// forward links
for (const relation of note.getRelations()) {
if (isIgnoredRelation(relation)) {
continue;
}
const targetNote = relation.getTargetNote();
if (!targetNote || targetNote.isLabelTruthy("excludeFromNoteMap")) {
continue;
}
retNoteIds.push(targetNote.noteId);
for (const noteId of getNeighbors(targetNote, depth - 1)) {
retNoteIds.push(noteId);
}
}
// backward links
for (const relation of note.getTargetRelations()) {
if (isIgnoredRelation(relation)) {
continue;
}
const sourceNote = relation.getNote();
if (!sourceNote || sourceNote.isLabelTruthy("excludeFromNoteMap")) {
continue;
}
retNoteIds.push(sourceNote.noteId);
for (const noteId of getNeighbors(sourceNote, depth - 1)) {
retNoteIds.push(noteId);
}
}
return retNoteIds;
}
function getLinkMap(req: Request<{ noteId: string }>) {
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
// if the map root itself has "excludeFromNoteMap" attribute (journal typically) then there wouldn't be anything
// to display, so we'll just ignore it
const ignoreExcludeFromNoteMap = mapRootNote.isLabelTruthy("excludeFromNoteMap");
let unfilteredNotes;
const toSet = (data: unknown) => new Set<string>(data instanceof Array ? data : []);
const excludeRelations = toSet(req.body.excludeRelations);
const includeRelations = toSet(req.body.includeRelations);
if (mapRootNote.type === "search") {
// for search notes, we want to consider the direct search results only without the descendants
unfilteredNotes = mapRootNote.getSearchResultNotes();
} else {
unfilteredNotes = mapRootNote.getSubtree({
includeArchived: false,
resolveSearch: true,
includeHidden: mapRootNote.isInHiddenSubtree()
}).notes;
}
const noteIds = new Set<string>(unfilteredNotes.filter((note) => ignoreExcludeFromNoteMap || !note.isLabelTruthy("excludeFromNoteMap")).map((note) => note.noteId));
if (mapRootNote.type === "search") {
noteIds.delete(mapRootNote.noteId);
}
for (const noteId of getNeighbors(mapRootNote, 3)) {
noteIds.add(noteId);
}
const noteIdsArray = Array.from(noteIds);
const notes = noteIdsArray.map((noteId) => {
const note = becca.getNoteOrThrow(noteId);
return [note.noteId, note.getTitleOrProtected(), note.type, note.getLabelValue("color")];
});
const links = Object.values(becca.attributes)
.filter((rel) => {
if (rel.type !== "relation" || rel.name === "relationMapLink" || rel.name === "template" || rel.name === "inherit") {
return false;
} else if (!noteIds.has(rel.noteId) || !noteIds.has(rel.value)) {
return false;
} else if (rel.name === "imageLink") {
const parentNote = becca.getNote(rel.noteId);
if (!parentNote) {
return false;
}
return !parentNote.getChildNotes().find((childNote) => childNote.noteId === rel.value);
} else if (includeRelations.size != 0 && !includeRelations.has(rel.name)) {
return false;
} else if (excludeRelations.has(rel.name)) {
return false;
}
return true;
})
.map((rel) => ({
id: `${rel.noteId}-${rel.name}-${rel.value}`,
sourceNoteId: rel.noteId,
targetNoteId: rel.value,
name: rel.name
}));
return {
notes,
noteIdToDescendantCountMap: buildDescendantCountMap(noteIdsArray),
links
};
}
function getTreeMap(req: Request<{ noteId: string }>) {
const mapRootNote = becca.getNoteOrThrow(req.params.noteId);
// if the map root itself has "excludeFromNoteMap" (journal typically) then there wouldn't be anything to display,
// so we'll just ignore it
const ignoreExcludeFromNoteMap = mapRootNote.isLabelTruthy("excludeFromNoteMap");
const subtree = mapRootNote.getSubtree({
includeArchived: false,
resolveSearch: true,
includeHidden: mapRootNote.isInHiddenSubtree()
});
const notes = subtree.notes
.filter((note) => ignoreExcludeFromNoteMap || !note.isLabelTruthy("excludeFromNoteMap"))
.filter((note) => {
if (note.type !== "image" || note.getChildNotes().length > 0) {
return true;
}
const imageLinkRelation = note.getTargetRelations().find((rel) => rel.name === "imageLink");
if (!imageLinkRelation) {
return true;
}
return !note.getParentNotes().find((parentNote) => parentNote.noteId === imageLinkRelation.noteId);
})
.map((note) => [note.noteId, note.getTitleOrProtected(), note.type, note.getLabelValue("color")]);
const noteIds = new Set<string>();
notes.forEach(([noteId]) => noteId && noteIds.add(noteId));
const links: TreeLink[] = [];
for (const { parentNoteId, childNoteId } of subtree.relationships) {
if (!noteIds.has(parentNoteId) || !noteIds.has(childNoteId)) {
continue;
}
links.push({
sourceNoteId: parentNoteId,
targetNoteId: childNoteId
});
}
const noteIdToDescendantCountMap = buildDescendantCountMap(Array.from(noteIds));
updateDescendantCountMapForSearch(noteIdToDescendantCountMap, subtree.relationships);
return {
notes,
noteIdToDescendantCountMap,
links
};
}
function updateDescendantCountMapForSearch(noteIdToDescendantCountMap: Record<string, number>, relationships: { parentNoteId: string; childNoteId: string }[]) {
for (const { parentNoteId, childNoteId } of relationships) {
const parentNote = becca.notes[parentNoteId];
if (!parentNote || parentNote.type !== "search") {
continue;
}
noteIdToDescendantCountMap[parentNote.noteId] = noteIdToDescendantCountMap[parentNoteId] || 0;
noteIdToDescendantCountMap[parentNote.noteId] += noteIdToDescendantCountMap[childNoteId] || 1;
}
}
function removeImages(document: HTMLElement) {
const images = document.getElementsByTagName("img");
for (const image of images) {
image.remove();
}
}
const EXCERPT_CHAR_LIMIT = 200;
type ElementOrText = HTMLElement | TextNode;
export function findExcerpts(sourceNote: BNote, referencedNoteId: string) {
const html = sourceNote.getContent();
const document = parse(html.toString());
const excerpts: string[] = [];
removeImages(document);
for (const linkEl of document.querySelectorAll("a")) {
const href = linkEl.getAttribute("href");
if (!href || !href.endsWith(referencedNoteId)) {
continue;
}
linkEl.classList.add("backlink-link");
let centerEl: HTMLElement = linkEl;
while (centerEl.tagName !== "BODY" && centerEl.parentNode && (centerEl.parentNode?.textContent?.length || 0) <= EXCERPT_CHAR_LIMIT) {
centerEl = centerEl.parentNode;
}
const excerptEls: ElementOrText[] = [centerEl];
let excerptLength = centerEl.textContent?.length || 0;
let left: ElementOrText = centerEl;
let right: ElementOrText = centerEl;
while (excerptLength < EXCERPT_CHAR_LIMIT) {
let added = false;
const prev: HTMLElement | null = left.previousElementSibling;
if (prev) {
const prevText = prev.textContent || "";
if (prevText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const prefix = prevText.substr(prevText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
const textNode = new TextNode(`${prefix}`);
excerptEls.unshift(textNode);
break;
}
left = prev;
excerptEls.unshift(left);
excerptLength += prevText.length;
added = true;
}
const next: HTMLElement | null = right.nextElementSibling;
if (next) {
const nextText = next.textContent;
if (nextText && nextText.length + excerptLength > EXCERPT_CHAR_LIMIT) {
const suffix = nextText.substr(nextText.length - (EXCERPT_CHAR_LIMIT - excerptLength));
const textNode = new TextNode(`${suffix}`);
excerptEls.push(textNode);
break;
}
right = next;
excerptEls.push(right);
excerptLength += nextText?.length || 0;
added = true;
}
if (!added) {
break;
}
}
const excerptWrapper = new HTMLElement("div", {});
excerptWrapper.classList.add("ck-content");
excerptWrapper.classList.add("backlink-excerpt");
for (const childEl of excerptEls) {
excerptWrapper.appendChild(childEl);
}
excerpts.push(excerptWrapper.outerHTML);
}
return excerpts;
}
function getFilteredBacklinks(note: BNote): BAttribute[] {
return (
@@ -13,7 +347,33 @@ function getFilteredBacklinks(note: BNote): BAttribute[] {
);
}
function getBacklinkCount(req: Request) {
function getBacklinks(req: Request<{ noteId: string }>): BacklinksResponse {
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
let backlinksWithExcerptCount = 0;
return getFilteredBacklinks(note).map((backlink) => {
const sourceNote = backlink.note;
if (sourceNote.type !== "text" || backlinksWithExcerptCount > 50) {
return {
noteId: sourceNote.noteId,
relationName: backlink.name
} satisfies BacklinksResponse[number];
}
backlinksWithExcerptCount++;
const excerpts = findExcerpts(sourceNote, noteId);
return {
noteId: sourceNote.noteId,
excerpts
} satisfies BacklinksResponse[number];
});
}
function getBacklinkCount(req: Request<{ noteId: string }>): BacklinkCountResponse {
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
@@ -24,5 +384,8 @@ function getBacklinkCount(req: Request) {
}
export default {
getLinkMap,
getTreeMap,
getBacklinks,
getBacklinkCount
}
};

View File

@@ -108,7 +108,6 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
]);
function getOptions() {
console.log("Got opts");
const optionMap = optionService.getOptionMap();
const resultMap: Record<string, string> = {};

View File

@@ -0,0 +1,33 @@
import { Response } from "express";
import becca from "../becca/becca";
import BNote from "../becca/entities/bnote";
import protected_session from "../services/protected_session";
import BAttachment from "../becca/entities/battachment";
import { getContentDisposition } from "../services/utils/index";
export function downloadNoteInt(noteId: string, res: Response, contentDisposition = true) {
const note = becca.getNote(noteId);
if (!note) {
return res.setHeader("Content-Type", "text/plain").status(404).send(`Note '${noteId}' doesn't exist.`);
}
return downloadData(note, res, contentDisposition);
}
export function downloadData(noteOrAttachment: BNote | BAttachment, res: Response, contentDisposition: boolean) {
if (noteOrAttachment.isProtected && !protected_session.isProtectedSessionAvailable()) {
return res.status(401).send("Protected session not available");
}
if (contentDisposition) {
const fileName = noteOrAttachment.getFileName();
res.setHeader("Content-Disposition", getContentDisposition(fileName));
}
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Content-Type", noteOrAttachment.mime);
res.send(noteOrAttachment.getContent());
}

View File

@@ -24,6 +24,7 @@ import autocompleteApiRoute from "./api/autocomplete";
import similarNotesRoute from "./api/similar_notes";
import imageRoute from "./api/image";
import setupApiRoute from "./api/setup";
import filesRoute from "./api/files";
// TODO: Deduplicate with routes.ts
const GET = "get",
@@ -166,6 +167,9 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout
apiRoute(PST, "/api/special-notes/launchers/:parentNoteId/:launcherType", specialNotesRoute.createLauncher);
apiRoute(PUT, "/api/special-notes/api-script-launcher", specialNotesRoute.createOrUpdateScriptLauncherFromApi);
apiRoute(PST, "/api/note-map/:noteId/tree", noteMapRoute.getTreeMap);
apiRoute(PST, "/api/note-map/:noteId/link", noteMapRoute.getLinkMap);
apiRoute(GET, "/api/note-map/:noteId/backlinks", noteMapRoute.getBacklinks);
apiRoute(GET, "/api/note-map/:noteId/backlink-count", noteMapRoute.getBacklinkCount);
apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote);
@@ -187,6 +191,17 @@ export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRout
asyncApiRoute(GET, "/api/similar-notes/:noteId", similarNotesRoute.getSimilarNotes);
apiRoute(PST, "/api/relation-map", relationMapApiRoute.getRelationMap);
apiRoute(GET, "/api/recent-changes/:ancestorNoteId", recentChangesApiRoute.getRecentChanges);
//#region Files
route(GET, "/api/notes/:noteId/open", [checkApiAuthOrElectron], filesRoute.openFile);
route(GET, "/api/notes/:noteId/download", [checkApiAuthOrElectron], filesRoute.downloadFile);
// this "hacky" path is used for easier referencing of CSS resources
route(GET, "/api/notes/download/:noteId", [checkApiAuthOrElectron], filesRoute.downloadFile);
route(GET, "/api/attachments/:attachmentId/open", [checkApiAuthOrElectron], filesRoute.openAttachment);
route(GET, "/api/attachments/:attachmentId/download", [checkApiAuthOrElectron], filesRoute.downloadAttachment);
// this "hacky" path is used for easier referencing of CSS resources
route(GET, "/api/attachments/download/:attachmentId", [checkApiAuthOrElectron], filesRoute.downloadAttachment);
//#endregion
}
/** Handling common patterns. If entity is not caught, serialization to JSON will fail */

View File

@@ -1,5 +1,5 @@
export default {
backupNow() {
backupNow(name: string) {
console.warn("Backup not yet available.");
}
}

View File

@@ -2,8 +2,11 @@ import { BootstrapDefinition } from "@triliumnext/commons";
import { getSql } from "./sql";
import protected_session from "./protected_session";
import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPPINGS } from "./icon_packs";
import options from "./options";
import optionService from "./options";
import { getCurrentLocale } from "./i18n";
import attributes from "./attributes";
import BNote from "../becca/entities/bnote";
import { getPlatform } from "./platform";
export default function getSharedBootstrapItems(assetPath: string, dbInitialized: boolean) {
const sql = getSql();
@@ -12,26 +15,39 @@ export default function getSharedBootstrapItems(assetPath: string, dbInitialized
const commonItems = {
assetPath,
dbInitialized,
currentLocale,
isRtl: !!currentLocale.rtl,
isProtectedSessionAvailable: false,
layoutOrientation: "vertical" as const,
headingStyle: "plain" as const,
componentId: "",
...getIconConfig(assetPath)
};
// Setup not yet finished.
if (!dbInitialized) {
return {
...commonItems,
themeCssUrl: false,
themeUseNextAsBase: "next"
themeCssUrl: false as const,
themeUseNextAsBase: "next" as const,
appCssNoteIds: []
};
}
// Database initialized.
const options = optionService.getOptionMap();
const theme = options.theme;
const themeNote = attributes.getNoteWithLabel("appTheme", theme);
return {
...commonItems,
headingStyle: options.getOption("headingStyle") as "plain" | "underline" | "markdown",
layoutOrientation: options.getOption("layoutOrientation") as "vertical" | "horizontal",
maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
maxEntityChangeSyncIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
headingStyle: options.headingStyle as "plain" | "underline" | "markdown",
layoutOrientation: options.layoutOrientation as "vertical" | "horizontal",
maxEntityChangeIdAtLoad: sql.getValue<number>("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
maxEntityChangeSyncIdAtLoad: sql.getValue<number>("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
isProtectedSessionAvailable: protected_session.isProtectedSessionAvailable(),
currentLocale,
isRtl: !!currentLocale.rtl,
themeCssUrl: getThemeCssUrl(theme, commonItems.assetPath, themeNote) as string | false,
themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase") as "next" | "next-light" | "next-dark",
appCssNoteIds: getAppCssNoteIds(),
}
}
@@ -48,3 +64,28 @@ export function getIconConfig(assetPath: string): Pick<BootstrapDefinition, "ico
.join("\n\n"),
};
}
function getAppCssNoteIds() {
return attributes.getNotesWithLabel("appCss").map((note) => note.noteId);
}
function getThemeCssUrl(theme: string, assetPath: string, themeNote: BNote | null) {
if (theme === "auto") {
return `${assetPath}/stylesheets/theme.css`;
} else if (theme === "light") {
// light theme is always loaded as baseline
return false;
} else if (theme === "dark") {
return `${assetPath}/stylesheets/theme-dark.css`;
} else if (theme === "next") {
return `${assetPath}/stylesheets/theme-next.css`;
} else if (theme === "next-light") {
return `${assetPath}/stylesheets/theme-next-light.css`;
} else if (theme === "next-dark") {
return `${assetPath}/stylesheets/theme-next-dark.css`;
} else if (!getPlatform().getEnv("TRILIUM_SAFE_MODE") && themeNote) {
return `api/notes/download/${themeNote.noteId}`;
}
// baseline light theme
return false;
}

View File

@@ -2,5 +2,10 @@
export default {
General: {
readOnly: false
},
Sync: {
syncServerHost: "",
syncServerTimeout: "120000",
syncProxy: ""
}
};

View File

@@ -1,5 +1,14 @@
export default {
saveImageToAttachment(noteId: string, imageBuffer: Uint8Array, title: string, b1: boolean, b2: boolean) {
console.warn("Image save ignored", noteId, title);
return {
attachmentId: null,
title: ""
};
},
updateImage(noteId: string, imageBuffer: Uint8Array, title: string) {
console.warn("Image update ignored", noteId, title);
}
}

View File

@@ -20,7 +20,7 @@ function getDefaultKeyboardActions() {
friendlyName: t("keyboard_action_names.back-in-note-history"),
iconClass: "bx bxs-chevron-left",
// Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
defaultShortcuts: isMac ? ["CommandOrControl+["] : ["Alt+Left"],
defaultShortcuts: isMac() ? ["CommandOrControl+["] : ["Alt+Left"],
description: t("keyboard_actions.back-in-note-history"),
scope: "window"
},
@@ -29,7 +29,7 @@ function getDefaultKeyboardActions() {
friendlyName: t("keyboard_action_names.forward-in-note-history"),
iconClass: "bx bxs-chevron-right",
// Mac has a different history navigation shortcuts - https://github.com/zadam/trilium/issues/376
defaultShortcuts: isMac ? ["CommandOrControl+]"] : ["Alt+Right"],
defaultShortcuts: isMac() ? ["CommandOrControl+]"] : ["Alt+Right"],
description: t("keyboard_actions.forward-in-note-history"),
scope: "window"
},
@@ -153,7 +153,7 @@ function getDefaultKeyboardActions() {
actionName: "moveNoteUp",
friendlyName: t("keyboard_action_names.move-note-up"),
iconClass: "bx bx-up-arrow-alt",
defaultShortcuts: isMac ? ["Alt+Up"] : ["CommandOrControl+Up"],
defaultShortcuts: isMac() ? ["Alt+Up"] : ["CommandOrControl+Up"],
description: t("keyboard_actions.move-note-up"),
scope: "note-tree"
},
@@ -161,7 +161,7 @@ function getDefaultKeyboardActions() {
actionName: "moveNoteDown",
friendlyName: t("keyboard_action_names.move-note-down"),
iconClass: "bx bx-down-arrow-alt",
defaultShortcuts: isMac ? ["Alt+Down"] : ["CommandOrControl+Down"],
defaultShortcuts: isMac() ? ["Alt+Down"] : ["CommandOrControl+Down"],
description: t("keyboard_actions.move-note-down"),
scope: "note-tree"
},
@@ -169,7 +169,7 @@ function getDefaultKeyboardActions() {
actionName: "moveNoteUpInHierarchy",
friendlyName: t("keyboard_action_names.move-note-up-in-hierarchy"),
iconClass: "bx bx-arrow-from-bottom",
defaultShortcuts: isMac ? ["Alt+Left"] : ["CommandOrControl+Left"],
defaultShortcuts: isMac() ? ["Alt+Left"] : ["CommandOrControl+Left"],
description: t("keyboard_actions.move-note-up-in-hierarchy"),
scope: "note-tree"
},
@@ -177,7 +177,7 @@ function getDefaultKeyboardActions() {
actionName: "moveNoteDownInHierarchy",
friendlyName: t("keyboard_action_names.move-note-down-in-hierarchy"),
iconClass: "bx bx-arrow-from-top",
defaultShortcuts: isMac ? ["Alt+Right"] : ["CommandOrControl+Right"],
defaultShortcuts: isMac() ? ["Alt+Right"] : ["CommandOrControl+Right"],
description: t("keyboard_actions.move-note-down-in-hierarchy"),
scope: "note-tree"
},
@@ -282,7 +282,7 @@ function getDefaultKeyboardActions() {
actionName: "openNewTab",
friendlyName: t("keyboard_action_names.open-new-tab"),
iconClass: "bx bx-plus",
defaultShortcuts: isElectron ? ["CommandOrControl+T"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+T"] : [],
description: t("keyboard_actions.open-new-tab"),
scope: "window"
},
@@ -290,7 +290,7 @@ function getDefaultKeyboardActions() {
actionName: "closeActiveTab",
friendlyName: t("keyboard_action_names.close-active-tab"),
iconClass: "bx bx-minus",
defaultShortcuts: isElectron ? ["CommandOrControl+W"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+W"] : [],
description: t("keyboard_actions.close-active-tab"),
scope: "window"
},
@@ -298,7 +298,7 @@ function getDefaultKeyboardActions() {
actionName: "reopenLastTab",
friendlyName: t("keyboard_action_names.reopen-last-tab"),
iconClass: "bx bx-undo",
defaultShortcuts: isElectron ? ["CommandOrControl+Shift+T"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+Shift+T"] : [],
isElectronOnly: true,
description: t("keyboard_actions.reopen-last-tab"),
scope: "window"
@@ -307,7 +307,7 @@ function getDefaultKeyboardActions() {
actionName: "activateNextTab",
friendlyName: t("keyboard_action_names.activate-next-tab"),
iconClass: "bx bx-skip-next",
defaultShortcuts: isElectron ? ["CommandOrControl+Tab", "CommandOrControl+PageDown"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+Tab", "CommandOrControl+PageDown"] : [],
description: t("keyboard_actions.activate-next-tab"),
scope: "window"
},
@@ -315,7 +315,7 @@ function getDefaultKeyboardActions() {
actionName: "activatePreviousTab",
friendlyName: t("keyboard_action_names.activate-previous-tab"),
iconClass: "bx bx-skip-previous",
defaultShortcuts: isElectron ? ["CommandOrControl+Shift+Tab", "CommandOrControl+PageUp"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+Shift+Tab", "CommandOrControl+PageUp"] : [],
description: t("keyboard_actions.activate-previous-tab"),
scope: "window"
},
@@ -757,7 +757,7 @@ function getDefaultKeyboardActions() {
actionName: "openDevTools",
friendlyName: t("keyboard_action_names.open-developer-tools"),
iconClass: "bx bx-bug-alt",
defaultShortcuts: isElectron ? ["CommandOrControl+Shift+I"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+Shift+I"] : [],
isElectronOnly: true,
description: t("keyboard_actions.open-dev-tools"),
scope: "window"
@@ -766,7 +766,7 @@ function getDefaultKeyboardActions() {
actionName: "findInText",
friendlyName: t("keyboard_action_names.find-in-text"),
iconClass: "bx bx-search",
defaultShortcuts: isElectron ? ["CommandOrControl+F"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+F"] : [],
description: t("keyboard_actions.find-in-text"),
scope: "window"
},
@@ -790,7 +790,7 @@ function getDefaultKeyboardActions() {
actionName: "zoomOut",
friendlyName: t("keyboard_action_names.zoom-out"),
iconClass: "bx bx-zoom-out",
defaultShortcuts: isElectron ? ["CommandOrControl+-"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+-"] : [],
isElectronOnly: true,
description: t("keyboard_actions.zoom-out"),
scope: "window"
@@ -800,7 +800,7 @@ function getDefaultKeyboardActions() {
friendlyName: t("keyboard_action_names.zoom-in"),
iconClass: "bx bx-zoom-in",
description: t("keyboard_actions.zoom-in"),
defaultShortcuts: isElectron ? ["CommandOrControl+="] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+="] : [],
isElectronOnly: true,
scope: "window"
},
@@ -809,7 +809,7 @@ function getDefaultKeyboardActions() {
friendlyName: t("keyboard_action_names.reset-zoom-level"),
iconClass: "bx bx-search-alt",
description: t("keyboard_actions.reset-zoom-level"),
defaultShortcuts: isElectron ? ["CommandOrControl+0"] : [],
defaultShortcuts: isElectron() ? ["CommandOrControl+0"] : [],
isElectronOnly: true,
scope: "window"
},
@@ -834,7 +834,7 @@ function getDefaultKeyboardActions() {
/*
* Apply macOS-specific tweaks.
*/
const platformModifier = isMac ? "Meta" : "Ctrl";
const platformModifier = isMac() ? "Meta" : "Ctrl";
for (const action of DEFAULT_KEYBOARD_ACTIONS) {
if ("defaultShortcuts" in action && action.defaultShortcuts) {

View File

@@ -12,7 +12,8 @@ export default class LogService {
console.error("ERROR: ", message);
}
banner(message: string) {
banner(message: string | undefined) {
if (!message) return;
const maxContent = 76; // 80 - 4 (border + padding)
const words = message.split(" ");
const lines: string[] = [];

View File

@@ -1,18 +1,16 @@
import { describe, expect, it } from "vitest";
import cls from "./cls.js";
import { getContext } from "./context.js";
describe("Migration", () => {
it("migrates from v214", async () => {
await new Promise<void>((resolve) => {
cls.init(async () => {
await import("../app.js");
const sql = (await (import("./sql.js"))).default;
sql.rebuildIntegrationTestDatabase("spec/db/document_v214.db");
getContext().init(async () => {
const { getSql, rebuildIntegrationTestDatabase } = (await (import("./sql/index.js")));
rebuildIntegrationTestDatabase("spec/db/document_v214.db");
const migration = (await import("./migration.js")).default;
await migration.migrateIfNecessary();
expect(sql.getValue("SELECT count(*) FROM blobs")).toBe(118);
expect(getSql().getValue("SELECT count(*) FROM blobs")).toBe(118);
resolve();
});
});

View File

@@ -24,7 +24,7 @@ async function migrate() {
}
// backup before attempting migration
if (!process.env.TRILIUM_INTEGRATION_TEST) {
if (!getPlatform().getEnv("TRILIUM_INTEGRATION_TEST")) {
await backupService.backupNow(
// creating a special backup for version 0.60.4, the changes in 0.61 are major.
currentDbVersion === 214 ? `before-migration-v060` : "before-migration"
@@ -123,7 +123,7 @@ function isDbUpToDate() {
async function migrateIfNecessary() {
const currentDbVersion = getDbVersion();
if (currentDbVersion > appInfo.dbVersion && process.env.TRILIUM_IGNORE_DB_VERSION !== "true") {
if (currentDbVersion > appInfo.dbVersion && getPlatform().getEnv("TRILIUM_IGNORE_DB_VERSION") !== "true") {
getPlatform().crash(t("migration.wrong_db_version", { version: currentDbVersion, targetVersion: appInfo.dbVersion }));
}

View File

@@ -548,7 +548,7 @@ async function downloadImage(noteId: string, imageUrl: string) {
});
});
} else {
imageBuffer = await request.getImage(unescapedUrl);
imageBuffer = new Uint8Array(await request.getImage(unescapedUrl));
}
const parsedUrl = url.parse(unescapedUrl);

View File

@@ -1,6 +1,7 @@
import { type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
import appInfo from "./app_info.js";
import { getPlatform } from "./platform.js";
import dateUtils from "./utils/date.js";
import keyboardActions from "./keyboard_actions.js";
import { getLog } from "./log.js";
@@ -79,7 +80,7 @@ const defaultOptions: DefaultOption[] = [
{ name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true },
{ name: "protectedSessionTimeout", value: "600", isSynced: true },
{ name: "protectedSessionTimeoutTimeScale", value: "60", isSynced: true },
{ name: "zoomFactor", value: isWindows ? "0.9" : "1.0", isSynced: false },
{ name: "zoomFactor", value: () => isWindows() ? "0.9" : "1.0", isSynced: false },
{ name: "overrideThemeFonts", value: "false", isSynced: false },
{ name: "mainFontFamily", value: "theme", isSynced: false },
{ name: "mainFontSize", value: "100", isSynced: false },
@@ -237,12 +238,12 @@ export function initStartupOptions() {
}
}
if (process.env.TRILIUM_START_NOTE_ID || process.env.TRILIUM_SAFE_MODE) {
if (getPlatform().getEnv("TRILIUM_START_NOTE_ID") || getPlatform().getEnv("TRILIUM_SAFE_MODE")) {
optionService.setOption(
"openNoteContexts",
JSON.stringify([
{
notePath: process.env.TRILIUM_START_NOTE_ID || "root",
notePath: getPlatform().getEnv("TRILIUM_START_NOTE_ID") || "root",
active: true
}
])

View File

@@ -3,6 +3,11 @@
*/
export interface PlatformProvider {
crash(message: string): void;
/** Returns the value of an environment variable, or undefined if not set. */
getEnv(key: string): string | undefined;
readonly isElectron: boolean;
readonly isMac: boolean;
readonly isWindows: boolean;
}
let platformProvider: PlatformProvider | null = null;

View File

@@ -1,6 +1,6 @@
import type BNote from "../becca/entities/bnote";
export function executeNoteNoException(script: unknown) {
export function executeNoteNoException(script: unknown, { originEntity: unknown }) {
console.warn("Skipped script execution");
}

View File

@@ -3,7 +3,7 @@ import searchService from "./search.js";
import BNote from "../../../becca/entities/bnote.js";
import BBranch from "../../../becca/entities/bbranch.js";
import SearchContext from "../search_context.js";
import dateUtils from "../../date_utils.js";
import dateUtils from "../../utils/date.js";
import becca from "../../../becca/becca.js";
import { findNoteByTitle, note, NoteBuilder } from "../../../test/becca_mocking.js";
@@ -710,22 +710,22 @@ describe("Search", () => {
// Create a moderate-sized dataset to test performance
const countries = ["Austria", "Belgium", "Croatia", "Denmark", "Estonia", "Finland", "Germany", "Hungary", "Ireland", "Japan"];
const europeanCountries = note("Europe");
countries.forEach(country => {
europeanCountries.child(note(country).label("type", "country").label("continent", "Europe"));
});
rootNote.child(europeanCountries);
const searchContext = new SearchContext();
const startTime = Date.now();
// Perform a search that exercises multiple features
const searchResults = searchService.findResultsWithQuery("#type=country AND continent", searchContext);
const endTime = Date.now();
const duration = endTime - startTime;
// Search should complete in under 1 second for reasonable dataset
expect(duration).toBeLessThan(1000);
expect(searchResults.length).toEqual(10);
@@ -748,14 +748,14 @@ describe("Search", () => {
// Get note titles in result order
const resultTitles = searchResults.map(r => becca.notes[r.noteId].title);
// Find all exact matches (contain "analysis")
const exactMatchIndices = resultTitles.map((title, index) =>
const exactMatchIndices = resultTitles.map((title, index) =>
title.toLowerCase().includes("analysis") ? index : -1
).filter(index => index !== -1);
// Find all fuzzy matches (contain typos)
const fuzzyMatchIndices = resultTitles.map((title, index) =>
const fuzzyMatchIndices = resultTitles.map((title, index) =>
(title.includes("Anaylsis") || title.includes("Anlaysis")) ? index : -1
).filter(index => index !== -1);
@@ -765,7 +765,7 @@ describe("Search", () => {
// CRITICAL: All exact matches must appear before all fuzzy matches
const lastExactIndex = Math.max(...exactMatchIndices);
const firstFuzzyIndex = Math.min(...fuzzyMatchIndices);
expect(lastExactIndex).toBeLessThan(firstFuzzyIndex);
});

View File

@@ -13,3 +13,7 @@ export function getSql(): SqlService {
if (!sql) throw new Error("SQL not initialized");
return sql;
}
export function rebuildIntegrationTestDatabase(path?: string) {
throw new Error("Not implemented");
}

View File

@@ -11,7 +11,6 @@ export interface SqlServiceParams {
provider: DatabaseProvider;
onTransactionRollback: () => void;
onTransactionCommit: () => void;
onDatabaseNotInitialized?: () => void;
isReadOnly: boolean;
}

View File

@@ -4,8 +4,8 @@ import becca from "../becca/becca.js";
import BBranch from "../becca/entities/bbranch.js";
import BNote from "../becca/entities/bnote.js";
import tree from "./tree.js";
import cls from "./cls.js";
import {buildNote} from "../test/becca_easy_mocking.js";
import { getContext } from "./context.js";
describe("Tree", () => {
let rootNote!: NoteBuilder;
@@ -58,7 +58,7 @@ describe("Tree", () => {
],
"#sorted": "",
});
cls.init(() => {
getContext().init(() => {
tree.sortNotesIfNeeded(note.noteId);
});
const orderedTitles = note.children.map((child) => child.title);
@@ -85,7 +85,7 @@ describe("Tree", () => {
// Sort a few times to ensure that the resulting order is the same.
for (let i = 0; i < 5; i++) {
cls.init(() => {
getContext().init(() => {
tree.sortNotesIfNeeded(rootNote.note.noteId);
});
@@ -106,7 +106,7 @@ describe("Tree", () => {
],
"#sorted": ""
});
cls.init(() => {
getContext().init(() => {
tree.sortNotesIfNeeded(note.noteId);
});
const orderedTitles = note.children.map((child) => child.title);
@@ -126,7 +126,7 @@ describe("Tree", () => {
"#sorted": "",
"#sortDirection": "desc"
});
cls.init(() => {
getContext().init(() => {
tree.sortNotesIfNeeded(note.noteId);
});
const orderedTitles = note.children.map((child) => child.title);
@@ -148,7 +148,7 @@ describe("Tree", () => {
"#sorted": "",
"#sortFoldersFirst": ""
});
cls.init(() => {
getContext().init(() => {
tree.sortNotesIfNeeded(note.noteId);
});
const orderedTitles = note.children.map((child) => child.title);
@@ -169,7 +169,7 @@ describe("Tree", () => {
"#sorted": "",
"#sortNatural": ""
});
cls.init(() => {
getContext().init(() => {
tree.sortNotesIfNeeded(note.noteId);
});
const orderedTitles = note.children.map((child) => child.title);

View File

@@ -1,5 +1,5 @@
import { expect, describe, it } from "vitest";
import { sanitizeAttributeName } from "./index";
import * as utils from "./index";
// fn value, expected value
const testCases: [fnValue: string, expectedValue: string][] = [
@@ -30,8 +30,706 @@ describe("sanitizeAttributeName unit tests", () => {
testCases.forEach((testCase) => {
return it(`'${testCase[0]}' should return '${testCase[1]}'`, () => {
const [ value, expected ] = testCase;
const actual = sanitizeAttributeName(value);
const actual = utils.sanitizeAttributeName(value);
expect(actual).toStrictEqual(expected);
});
});
});
type TestCase<T extends (...args: any) => any> = [desc: string, fnParams: Parameters<T>, expected: ReturnType<T>];
describe("#newEntityId", () => {
it("should return a string with a length of 12", () => {
const result = utils.newEntityId();
expect(result).toBeTypeOf("string");
expect(result).toHaveLength(12);
});
});
describe("#randomString", () => {
it("should return a string with a length as per argument", () => {
const stringLength = 5;
const result = utils.randomString(stringLength);
expect(result).toBeTypeOf("string");
expect(result).toHaveLength(stringLength);
});
});
// TriliumNextTODO: should use mocks and assert that functions get called
describe("#randomSecureToken", () => {
// base64 -> 4 * (bytes/3) length -> if padding and rounding up is ignored for simplicity
// https://stackoverflow.com/a/13378842
const byteToBase64Length = (bytes: number) => 4 * (bytes / 3);
it("should return a string and use 32 bytes by default", () => {
const result = utils.randomSecureToken();
expect(result).toBeTypeOf("string");
expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(32));
});
it("should return a string and use passed byte length", () => {
const bytes = 16;
const result = utils.randomSecureToken(bytes);
expect(result).toBeTypeOf("string");
expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(bytes));
expect(result.length).toBeLessThan(44); // default argument uses 32 bytes -> which translates to 44 base64 legal chars
});
});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#md5", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hashedBlobId", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#toBase64", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#fromBase64", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hmac", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hash", () => {});
describe("#isEmptyOrWhitespace", () => {
const testCases: TestCase<typeof utils.isEmptyOrWhitespace>[] = [
[ "w/ 'null' it should return true", [ null ], true ],
[ "w/ 'null' it should return true", [ null ], true ],
[ "w/ undefined it should return true", [ undefined ], true ],
[ "w/ empty string '' it should return true", [ "" ], true ],
[ "w/ single whitespace string ' ' it should return true", [ " " ], true ],
[ "w/ multiple whitespace string ' ' it should return true", [ " " ], true ],
[ "w/ non-empty string ' t ' it should return false", [ " t " ], false ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.isEmptyOrWhitespace(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#sanitizeSqlIdentifier", () => {
const testCases: TestCase<typeof utils.sanitizeSqlIdentifier>[] = [
[ "w/ 'test' it should not strip anything", [ "test" ], "test" ],
[ "w/ 'test123' it should not strip anything", [ "test123" ], "test123" ],
[ "w/ 'tEst_TeSt' it should not strip anything", [ "tEst_TeSt" ], "tEst_TeSt" ],
[ "w/ 'test_test' it should not strip '_'", [ "test_test" ], "test_test" ],
[ "w/ 'test-' it should strip the '-'", [ "test-" ], "test" ],
[ "w/ 'test-test' it should strip the '-'", [ "test-test" ], "testtest" ],
[ "w/ 'test; --test' it should strip the '; --'", [ "test; --test" ], "testtest" ],
[ "w/ 'test test' it should strip the ' '", [ "test test" ], "testtest" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.sanitizeSqlIdentifier(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#escapeHtml", () => {
it("should re-export 'escape-html' npm module as escapeHtml", () => {
expect(utils.escapeHtml).toBeTypeOf("function");
});
});
describe("#unescapeHtml", () => {
it("should re-export 'unescape' npm module as unescapeHtml", () => {
expect(utils.unescapeHtml).toBeTypeOf("function");
});
});
describe("#toObject", () => {
it("should return an object with keys and value being set from the supplied Function", () => {
type TestListEntry = { testPropA: string; testPropB: string };
type TestListFn = (testListEntry: TestListEntry) => [string, string];
const testList: [TestListEntry, TestListEntry] = [
{ testPropA: "keyA", testPropB: "valueA" },
{ testPropA: "keyB", testPropB: "valueB" }
];
const fn: TestListFn = (testListEntry: TestListEntry) => [ `${testListEntry.testPropA }_fn`, `${testListEntry.testPropB }_fn` ];
const result = utils.toObject(testList, fn);
expect(result).toStrictEqual({
keyA_fn: "valueA_fn",
keyB_fn: "valueB_fn"
});
});
});
describe("#stripTags", () => {
//prettier-ignore
const htmlWithNewlines =
`<p>abc
def</p>
<p>ghi</p>`;
const testCases: TestCase<typeof utils.stripTags>[] = [
[ "should strip all tags and only return the content, leaving new lines and spaces in tact", [ htmlWithNewlines ], "abc\ndef\nghi" ],
//TriliumNextTODO: should this actually insert a space between content to prevent concatenated text?
[ "should strip all tags and only return the content", [ "<h1>abc</h1><p>def</p>" ], "abcdef" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.stripTags(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#escapeRegExp", () => {});
describe.todo("#crash", () => {});
describe("#getContentDisposition", () => {
const defaultFallBackDisposition = `file; filename="file"; filename*=UTF-8''file`;
const testCases: TestCase<typeof utils.getContentDisposition>[] = [
[
"when passed filename is empty, it should fallback to default value 'file'",
[ " " ],
defaultFallBackDisposition
],
[
"when passed filename '..' would cause sanitized filename to be empty, it should fallback to default value 'file'",
[ ".." ],
defaultFallBackDisposition
],
// COM1 is a Windows specific "illegal filename" that sanitize filename strips away
[
"when passed filename 'COM1' would cause sanitized filename to be empty, it should fallback to default value 'file'",
[ "COM1" ],
defaultFallBackDisposition
],
[
"sanitized passed filename should be returned URIEncoded",
[ "test file.csv" ],
`file; filename="test%20file.csv"; filename*=UTF-8''test%20file.csv`
]
];
testCases.forEach(testCase => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.getContentDisposition(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#isStringNote", () => {
const testCases: TestCase<typeof utils.isStringNote>[] = [
[
"w/ 'undefined' note type, but a string mime type, it should return true",
[ undefined, "application/javascript" ],
true
],
[
"w/ non-string note type, it should return false",
[ "image", "image/jpeg" ],
false
],
[
"w/ string note type (text), it should return true",
[ "text", "text/html" ],
true
],
[
"w/ string note type (code), it should return true",
[ "code", "application/json" ],
true
],
[
"w/ non-string note type (file), but string mime type, it should return true",
[ "file", "application/json" ],
true
],
[
"w/ non-string note type (file), but mime type starting with 'text/', it should return true",
[ "file", "text/html" ],
true
]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.isStringNote(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#quoteRegex", () => {});
describe.todo("#replaceAll", () => {});
describe("#removeFileExtension", () => {
const testCases: TestCase<typeof utils.removeFileExtension>[] = [
[ "w/ 'test.md' it should strip '.md'", [ "test.md" ], "test" ],
[ "w/ 'test.markdown' it should strip '.markdown'", [ "test.markdown" ], "test" ],
[ "w/ 'test.html' it should strip '.html'", [ "test.html" ], "test" ],
[ "w/ 'test.htm' it should strip '.htm'", [ "test.htm" ], "test" ],
[ "w/ 'test.zip' it should NOT strip '.zip'", [ "test.zip" ], "test.zip" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.removeFileExtension(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#getNoteTitle", () => {
const testCases: TestCase<typeof utils.getNoteTitle>[] = [
[
"when file has no spaces, and no special file extension, it should return the filename unaltered",
[ "test.json", true, undefined ],
"test.json"
],
[
"when replaceUnderscoresWithSpaces is false, it should keep the underscores in the title",
[ "test_file.json", false, undefined ],
"test_file.json"
],
[
"when replaceUnderscoresWithSpaces is true, it should replace the underscores in the title",
[ "test_file.json", true, undefined ],
"test file.json"
],
[
"when filePath ends with one of the extra handled endings (.md), it should strip the file extension from the title",
[ "test_file.md", false, undefined ],
"test_file"
],
[
"when filePath ends with one of the extra handled endings (.md) and replaceUnderscoresWithSpaces is true, it should strip the file extension from the title and replace underscores",
[ "test_file.md", true, undefined ],
"test file"
],
[
"when filepath contains a full path, it should only return the basename of the file",
[ "Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.zip", true, undefined ],
"template.zip"
],
[
"when filepath contains a full path and has extra handled ending (.html), it should only return the basename of the file and strip the file extension",
[ "Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.html", true, undefined ],
"template"
],
[
"when a noteMeta object is passed, it should use the title from the noteMeta, if present",
[ "test_file.md", true, { title: "some other title" } ],
"some other title"
],
[
"when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed",
[ "test_file.md", true, { title: "" } ],
"test file"
],
[
"when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed",
[ "test_file.json", false, { title: " " } ],
"test_file.json"
]
];
testCases.forEach(testCase => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.getNoteTitle(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#timeLimit", () => {
it("when promise execution does NOT exceed timeout, it should resolve with promises' value", async () => {
const resolvedValue = `resolved: ${new Date().toISOString()}`;
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res(resolvedValue);
}, 200);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 1_000)).resolves.toBe(resolvedValue);
});
it("when promise execution rejects within timeout, it should return the original promises' rejected value, not the custom set one", async () => {
const rejectedValue = `rejected: ${new Date().toISOString()}`;
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
//return res("resolved");
rej(rejectedValue);
}, 100);
});
await expect(utils.timeLimit(testPromise, 200, "Custom Error")).rejects.toThrow(rejectedValue);
});
it("when promise execution exceeds the set timeout, and 'errorMessage' is NOT set, it should reject the promise and display default error message", async () => {
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res("resolved");
}, 500);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 200)).rejects.toThrow(`Process exceeded time limit 200`);
});
it("when promise execution exceeds the set timeout, and 'errorMessage' is set, it should reject the promise and display set error message", async () => {
const customErrorMsg = "Custom Error";
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res("resolved");
}, 500);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 200, customErrorMsg)).rejects.toThrow(customErrorMsg);
});
// TriliumNextTODO: since TS avoids this from ever happening do we need this check?
it("when the passed promise is not a promise but 'undefined', it should return 'undefined'", async () => {
//@ts-expect-error - passing in illegal type 'undefined'
expect(utils.timeLimit(undefined, 200)).toBe(undefined);
});
// TriliumNextTODO: since TS avoids this from ever happening do we need this check?
it("when the passed promise is not a promise, it should return the passed value", async () => {
//@ts-expect-error - passing in illegal type 'object'
expect(utils.timeLimit({ test: 1 }, 200)).toStrictEqual({ test: 1 });
});
});
describe("#removeDiacritic", () => {
const testCases: TestCase<typeof utils.removeDiacritic>[] = [
[ "w/ 'Äpfel' it should replace the 'Ä'", [ "Äpfel" ], "Apfel" ],
[ "w/ 'Été' it should replace the 'É' and 'é'", [ "Été" ], "Ete" ],
[ "w/ 'Fête' it should replace the 'ê'", [ "Fête" ], "Fete" ],
[ "w/ 'Αλφαβήτα' it should replace the 'ή'", [ "Αλφαβήτα" ], "Αλφαβητα" ],
[ "w/ '' (empty string) it should return empty string", [ "" ], "" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.removeDiacritic(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#normalize", () => {
const testCases: TestCase<typeof utils.normalize>[] = [
[ "w/ 'Äpfel' it should replace the 'Ä' and return lowercased", [ "Äpfel" ], "apfel" ],
[ "w/ 'Été' it should replace the 'É' and 'é' and return lowercased", [ "Été" ], "ete" ],
[ "w/ 'FêTe' it should replace the 'ê' and return lowercased", [ "FêTe" ], "fete" ],
[ "w/ 'ΑλΦαβήΤα' it should replace the 'ή' and return lowercased", [ "ΑλΦαβήΤα" ], "αλφαβητα" ],
[ "w/ '' (empty string) it should return empty string", [ "" ], "" ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.normalize(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#toMap", () => {
it("should return an instace of Map, with the correct size and keys, when supplied with a list and existing keys", () => {
const testList = [ { title: "test", propA: "text", propB: 123 }, { title: "test2", propA: "prop2", propB: 456 } ];
const result = utils.toMap(testList, "title");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(Array.from(result.keys())).toStrictEqual([ "test", "test2" ]);
});
it("should return an instace of Map, with an empty size, when the supplied list does not contain the supplied key", () => {
const testList = [ { title: "test", propA: "text", propB: 123 }, { title: "test2", propA: "prop2", propB: 456 } ];
//@ts-expect-error - key is non-existing on supplied list type
const result = utils.toMap(testList, "nonExistingKey");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it.fails("should correctly handle duplicate keys? (currently it will overwrite the entry, so returned size will be 1 instead of 2)", () => {
const testList = [ { title: "testDupeTitle", propA: "text", propB: 123 }, { title: "testDupeTitle", propA: "prop2", propB: 456 } ];
const result = utils.toMap(testList, "title");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
});
});
describe("#envToBoolean", () => {
const testCases: TestCase<typeof utils.envToBoolean>[] = [
[ "w/ 'true' it should return boolean 'true'", [ "true" ], true ],
[ "w/ 'True' it should return boolean 'true'", [ "True" ], true ],
[ "w/ 'TRUE' it should return boolean 'true'", [ "TRUE" ], true ],
[ "w/ 'true ' it should return boolean 'true'", [ "true " ], true ],
[ "w/ 'false' it should return boolean 'false'", [ "false" ], false ],
[ "w/ 'False' it should return boolean 'false'", [ "False" ], false ],
[ "w/ 'FALSE' it should return boolean 'false'", [ "FALSE" ], false ],
[ "w/ 'false ' it should return boolean 'false'", [ "false " ], false ],
[ "w/ 'whatever' (non-boolean string) it should return undefined", [ "whatever" ], undefined ],
[ "w/ '-' (non-boolean string) it should return undefined", [ "-" ], undefined ],
[ "w/ '' (empty string) it should return undefined", [ "" ], undefined ],
[ "w/ ' ' (white space string) it should return undefined", [ " " ], undefined ],
[ "w/ undefined it should return undefined", [ undefined ], undefined ],
//@ts-expect-error - pass wrong type as param
[ "w/ number 1 it should return undefined", [ 1 ], undefined ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.envToBoolean(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#getResourceDir", () => {});
describe("#isElectron", () => {
it("should export a boolean", () => {
expect(utils.isElectron()).toBeTypeOf("boolean");
});
});
describe("#isMac", () => {
it("should export a boolean", () => {
expect(utils.isMac()).toBeTypeOf("boolean");
});
});
describe("#isWindows", () => {
it("should export a boolean", () => {
expect(utils.isWindows()).toBeTypeOf("boolean");
});
});
describe("#safeExtractMessageAndStackFromError", () => {
it("should correctly extract the message and stack property if it gets passed an instance of an Error", () => {
const testMessage = "Test Message";
const testError = new Error(testMessage);
const actual = utils.safeExtractMessageAndStackFromError(testError);
expect(actual[0]).toBe(testMessage);
expect(actual[1]).not.toBeUndefined();
});
it("should use the fallback 'Unknown Error' message, if it gets passed anything else than an instance of an Error", () => {
const testNonError = "this is not an instance of an Error, but JS technically allows us to throw this anyways";
const actual = utils.safeExtractMessageAndStackFromError(testNonError);
expect(actual[0]).toBe("Unknown Error");
expect(actual[1]).toBeUndefined();
});
});
describe("#formatDownloadTitle", () => {
//prettier-ignore
const testCases: [fnValue: Parameters<typeof utils.formatDownloadTitle>, expectedValue: ReturnType<typeof utils.formatDownloadTitle>][] = [
// empty fileName tests
[
[ "", "text", "" ],
"untitled.html"
],
[
[ "", "canvas", "" ],
"untitled.json"
],
[
[ "", null, "" ],
"untitled"
],
// json extension from type tests
[
[ "test_file", "canvas", "" ],
"test_file.json"
],
[
[ "test_file", "relationMap", "" ],
"test_file.json"
],
[
[ "test_file", "search", "" ],
"test_file.json"
],
// extension based on mime type
[
[ "test_file", null, "text/csv" ],
"test_file.csv"
],
[
[ "test_file_wo_ext", "image", "image/svg+xml" ],
"test_file_wo_ext.svg"
],
[
[ "test_file_wo_ext", "file", "application/json" ],
"test_file_wo_ext.json"
],
[
[ "test_file_w_fake_ext.ext", "image", "image/svg+xml" ],
"test_file_w_fake_ext.ext.svg"
],
[
[ "test_file_w_correct_ext.svg", "image", "image/svg+xml" ],
"test_file_w_correct_ext.svg"
],
[
[ "test_file_w_correct_ext.svgz", "image", "image/svg+xml" ],
"test_file_w_correct_ext.svgz"
],
[
[ "test_file.zip", "file", "application/zip" ],
"test_file.zip"
],
[
[ "test_file", "file", "application/zip" ],
"test_file.zip"
],
// application/octet-stream tests
[
[ "test_file", "file", "application/octet-stream" ],
"test_file"
],
[
[ "test_file.zip", "file", "application/octet-stream" ],
"test_file.zip"
],
[
[ "test_file.unknown", null, "application/octet-stream" ],
"test_file.unknown"
],
// sanitized filename tests
[
[ "test/file", null, "application/octet-stream" ],
"testfile"
],
[
[ "test:file.zip", "file", "application/zip" ],
"testfile.zip"
],
[
[ ":::", "file", "application/zip" ],
".zip"
],
[
[ ":::a", "file", "application/zip" ],
"a.zip"
]
];
testCases.forEach((testCase) => {
const [ fnParams, expected ] = testCase;
return it(`With args '${JSON.stringify(fnParams)}', it should return '${expected}'`, () => {
const actual = utils.formatDownloadTitle(...fnParams);
expect(actual).toStrictEqual(expected);
});
});
});
describe("#normalizeUrl", () => {
const testCases: TestCase<typeof utils.normalizeUrl>[] = [
[ "should remove trailing slash from simple URL", [ "https://example.com/" ], "https://example.com" ],
[ "should remove trailing slash from URL with path", [ "https://example.com/path/" ], "https://example.com/path" ],
[ "should preserve URL without trailing slash", [ "https://example.com" ], "https://example.com" ],
[ "should preserve URL without trailing slash with path", [ "https://example.com/path" ], "https://example.com/path" ],
[ "should preserve protocol-only URLs", [ "https://" ], "https://" ],
[ "should preserve protocol-only URLs", [ "http://" ], "http://" ],
[ "should fix double slashes in path", [ "https://example.com//api//test" ], "https://example.com/api/test" ],
[ "should handle multiple double slashes", [ "https://example.com///api///test" ], "https://example.com/api/test" ],
[ "should handle trailing slash with double slashes", [ "https://example.com//api//" ], "https://example.com/api" ],
[ "should preserve protocol double slash", [ "https://example.com/api" ], "https://example.com/api" ],
[ "should handle empty string", [ "" ], "" ],
[ "should handle whitespace-only string", [ " " ], "" ],
[ "should trim whitespace", [ " https://example.com/ " ], "https://example.com" ],
[ "should handle null as empty", [ null as any ], null ],
[ "should handle undefined as empty", [ undefined as any ], undefined ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.normalizeUrl(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#normalizeCustomHandlerPattern", () => {
const testCases: TestCase<typeof utils.normalizeCustomHandlerPattern>[] = [
[ "should handle pattern without ending - add both versions", [ "foo" ], [ "foo", "foo/" ] ],
[ "should handle pattern with trailing slash - add both versions", [ "foo/" ], [ "foo", "foo/" ] ],
[ "should handle pattern ending with $ - add optional slash", [ "foo$" ], [ "foo/?$" ] ],
[ "should handle pattern with trailing slash and $ - add both versions", [ "foo/$" ], [ "foo$", "foo/$" ] ],
[ "should preserve existing optional slash pattern", [ "foo/?$" ], [ "foo/?$" ] ],
[ "should preserve existing optional slash pattern (alternative)", [ "foo/?)" ], [ "foo/?)" ] ],
[ "should handle regex pattern with special chars", [ "api/[a-z]+$" ], [ "api/[a-z]+/?$" ] ],
[ "should handle complex regex pattern", [ "user/([0-9]+)/profile$" ], [ "user/([0-9]+)/profile/?$" ] ],
[ "should handle empty string", [ "" ], [ "" ] ],
[ "should handle whitespace-only string", [ " " ], [ "" ] ],
[ "should handle null", [ null as any ], [ null ] ],
[ "should handle undefined", [ undefined as any ], [ undefined ] ]
];
testCases.forEach((testCase) => {
const [ desc, fnParams, expected ] = testCase;
it(desc, () => {
const result = utils.normalizeCustomHandlerPattern(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#slugify", () => {
it("should return a slugified string", () => {
const testString = "This is a Test String! With unicode & Special #Chars.";
const expectedSlug = "this-is-a-test-string-with-unicode-special-chars";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
it("supports CJK characters without alteration", () => {
const testString = "测试中文字符";
const expectedSlug = "测试中文字符";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
it("supports Cyrillic characters without alteration", () => {
const testString = "Тестирование кириллических символов";
const expectedSlug = "тестирование-кириллических-символов";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
// preserves diacritic marks
it("preserves diacritic marks", () => {
const testString = "Café naïve façade jalapeño";
const expectedSlug = "café-naïve-façade-jalapeño";
const result = utils.slugify(testString);
expect(result).toBe(expectedSlug);
});
});

Some files were not shown because too many files have changed in this diff Show More