mirror of
https://github.com/zadam/trilium.git
synced 2026-05-07 12:47:10 +02:00
Standalone extra improvements (#9191)
This commit is contained in:
274
CLAUDE.md
274
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2226,7 +2226,7 @@
|
||||
"sample_sankey": "桑基圖",
|
||||
"sample_timeline": "時間軸",
|
||||
"sample_treemap": "樹狀圖",
|
||||
"sample_user_journey": "用戶旅程",
|
||||
"sample_user_journey": "使用者旅程",
|
||||
"sample_xy": "XY 圖表",
|
||||
"sample_venn": "韋恩圖",
|
||||
"sample_ishikawa": "魚骨圖"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"eslint.config.mjs"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/trilium-core/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../server/tsconfig.app.json"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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}'`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(/ /g, " "); // nbsp isn't in XML standard (only HTML)
|
||||
|
||||
const stripped = stripTags(newLines);
|
||||
const stripped = utils.stripTags(newLines);
|
||||
|
||||
const escaped = escapeXmlAttribute(stripped);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
2
docs/README-pl.md
vendored
@@ -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
|
||||
|
||||
20
package.json
20
package.json
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
49
packages/trilium-core/src/meta.ts
Normal file
49
packages/trilium-core/src/meta.ts
Normal 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[];
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
26
packages/trilium-core/src/routes/api/files.ts
Normal file
26
packages/trilium-core/src/routes/api/files.ts
Normal 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,
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
|
||||
33
packages/trilium-core/src/routes/helpers.ts
Normal file
33
packages/trilium-core/src/routes/helpers.ts
Normal 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());
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
backupNow() {
|
||||
backupNow(name: string) {
|
||||
console.warn("Backup not yet available.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,10 @@
|
||||
export default {
|
||||
General: {
|
||||
readOnly: false
|
||||
},
|
||||
Sync: {
|
||||
syncServerHost: "",
|
||||
syncServerTimeout: "120000",
|
||||
syncProxy: ""
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface SqlServiceParams {
|
||||
provider: DatabaseProvider;
|
||||
onTransactionRollback: () => void;
|
||||
onTransactionCommit: () => void;
|
||||
onDatabaseNotInitialized?: () => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user