Merge branch 'develop' into feat/llm-tool-improvement
2
.github/workflows/dev.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- uses: nrwl/nx-set-shas@v4
|
||||
- name: Check affected
|
||||
run: pnpm nx affected --verbose -t typecheck build rebuild-deps
|
||||
run: pnpm nx affected --verbose -t typecheck build rebuild-deps test-build
|
||||
|
||||
test_dev:
|
||||
name: Test development
|
||||
|
||||
7
.github/workflows/nightly.yml
vendored
@ -11,7 +11,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/actions/build-electron/*
|
||||
- forge.config.cjs
|
||||
- .github/workflows/nightly.yml
|
||||
- forge.config.ts
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@ -76,7 +77,7 @@ jobs:
|
||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.2.2
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
@ -116,7 +117,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.2.2
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
|
||||
2
.github/workflows/release.yml
vendored
@ -114,7 +114,7 @@ jobs:
|
||||
path: upload
|
||||
|
||||
- name: Publish stable release
|
||||
uses: softprops/action-gh-release@v2.2.2
|
||||
uses: softprops/action-gh-release@v2.3.2
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
|
||||
1
.gitignore
vendored
@ -46,3 +46,4 @@ upload
|
||||
*.tsbuildinfo
|
||||
|
||||
/result
|
||||
.svelte-kit
|
||||
@ -1,7 +1,2 @@
|
||||
_regroup
|
||||
_regroup_monorepo
|
||||
|
||||
# Asset copying respects .gitignore / .nxignore for some reason.
|
||||
# See https://github.com/nrwl/nx/issues/20309
|
||||
!dist
|
||||
!node_modules
|
||||
4
.vscode/extensions.json
vendored
@ -9,6 +9,8 @@
|
||||
"redhat.vscode-yaml",
|
||||
"tobermory.es6-string-html",
|
||||
"vitest.explorer",
|
||||
"yzhang.markdown-all-in-one"
|
||||
"yzhang.markdown-all-in-one",
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
|
||||
* Seamless [note versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
|
||||
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be used for note organization, querying and advanced [scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
|
||||
* UI available in English, German, Spanish, French, Romanian, and Chinese (simplified and traditional)
|
||||
* Direct [OpenID and TOTP integration](.docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md") for more secure login
|
||||
* Direct [OpenID and TOTP integration](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) for more secure login
|
||||
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization) with self-hosted sync server
|
||||
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
|
||||
* [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes to public internet
|
||||
|
||||
@ -44,7 +44,6 @@ export default tseslint.config(
|
||||
"dist/*",
|
||||
"docs/*",
|
||||
"demo/*",
|
||||
"libraries/*",
|
||||
"src/public/app-dist/*",
|
||||
"src/public/app/doc_notes/*"
|
||||
]
|
||||
|
||||
@ -38,7 +38,6 @@ export default [
|
||||
"dist/*",
|
||||
"docs/*",
|
||||
"demo/*",
|
||||
"libraries/*",
|
||||
// TriliumNextTODO: check if we want to format packages here as well - for now skipping it
|
||||
"packages/*",
|
||||
"src/public/app-dist/*",
|
||||
|
||||
@ -38,10 +38,10 @@
|
||||
"@playwright/test": "1.53.0",
|
||||
"@stylistic/eslint-plugin": "4.4.1",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.15.31",
|
||||
"@types/node": "22.15.32",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.3",
|
||||
"eslint": "9.28.0",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.29.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"esm": "3.2.25",
|
||||
"jsdoc": "4.0.4",
|
||||
|
||||
4
apps/client/.env
Normal file
@ -0,0 +1,4 @@
|
||||
# The development license key for premium CKEditor features.
|
||||
# Note: This key must only be used for the Trilium Notes project.
|
||||
# Expires on: 2025-09-13
|
||||
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3NTc3MjE1OTksImp0aSI6ImFiN2E0NjZmLWJlZGMtNDNiYy1iMzU4LTk0NGQ0YWJhY2I3ZiIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOlsic2giLCJkcnVwYWwiXSwid2hpdGVMYWJlbCI6dHJ1ZSwiZmVhdHVyZXMiOlsiRFJVUCIsIkNNVCIsIkRPIiwiRlAiLCJTQyIsIlRPQyIsIlRQTCIsIlBPRSIsIkNDIiwiTUYiLCJTRUUiLCJFQ0giLCJFSVMiXSwidmMiOiI1MzlkOWY5YyJ9.2rvKPql4hmukyXhEtWPZ8MLxKvzPIwzCdykO653g7IxRRZy2QJpeRszElZx9DakKYZKXekVRAwQKgHxwkgbE_w
|
||||
1
apps/client/.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.94.1",
|
||||
"version": "0.95.0",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@ -10,7 +10,7 @@
|
||||
"url": "https://github.com/TriliumNext/Notes"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.28.0",
|
||||
"@eslint/js": "9.29.0",
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@fullcalendar/core": "6.1.17",
|
||||
"@fullcalendar/daygrid": "6.1.17",
|
||||
@ -27,7 +27,7 @@
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.6",
|
||||
"bootstrap": "5.3.7",
|
||||
"boxicons": "2.1.4",
|
||||
"dayjs": "1.11.13",
|
||||
"dayjs-plugin-utc": "0.1.2",
|
||||
@ -48,11 +48,10 @@
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "15.0.12",
|
||||
"mermaid": "11.6.0",
|
||||
"mind-elixir": "4.6.0",
|
||||
"mind-elixir": "4.6.1",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"preact": "10.26.9",
|
||||
"split.js": "1.6.5",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"vanilla-js-wheel-zoom": "9.0.4"
|
||||
@ -64,18 +63,18 @@
|
||||
"@types/leaflet": "1.9.18",
|
||||
"@types/leaflet-gpx": "1.3.7",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/react": "19.1.7",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"happy-dom": "18.0.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.0.0"
|
||||
"vite-plugin-static-copy": "3.0.2"
|
||||
},
|
||||
"nx": {
|
||||
"name": "client",
|
||||
"targets": {
|
||||
"serve": {
|
||||
"dependsOn": ["^build"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import type { NativeImage, TouchBar } from "electron";
|
||||
import TouchBarComponent from "./touch_bar.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import type CodeMirror from "@triliumnext/codemirror";
|
||||
import { StartupChecks } from "./startup_checks.js";
|
||||
|
||||
interface Layout {
|
||||
getRootWidget: (appContext: AppContext) => RootWidget;
|
||||
@ -128,6 +129,7 @@ export type CommandMappings = {
|
||||
openAboutDialog: CommandData;
|
||||
hideFloatingButtons: {};
|
||||
hideLeftPane: CommandData;
|
||||
showCpuArchWarning: CommandData;
|
||||
showLeftPane: CommandData;
|
||||
hoistNote: CommandData & { noteId: string };
|
||||
leaveProtectedSession: CommandData;
|
||||
@ -279,6 +281,7 @@ export type CommandMappings = {
|
||||
buildIcon(name: string): NativeImage;
|
||||
};
|
||||
refreshTouchBar: CommandData;
|
||||
reloadTextEditor: CommandData;
|
||||
};
|
||||
|
||||
type EventMappings = {
|
||||
@ -473,7 +476,14 @@ export class AppContext extends Component {
|
||||
initComponents() {
|
||||
this.tabManager = new TabManager();
|
||||
|
||||
this.components = [this.tabManager, new RootCommandExecutor(), new Entrypoints(), new MainTreeExecutors(), new ShortcutComponent()];
|
||||
this.components = [
|
||||
this.tabManager,
|
||||
new RootCommandExecutor(),
|
||||
new Entrypoints(),
|
||||
new MainTreeExecutors(),
|
||||
new ShortcutComponent(),
|
||||
new StartupChecks()
|
||||
];
|
||||
|
||||
if (utils.isMobile()) {
|
||||
this.components.push(new MobileScreenSwitcherExecutor());
|
||||
|
||||
26
apps/client/src/components/startup_checks.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import server from "../services/server";
|
||||
import Component from "./component";
|
||||
|
||||
// TODO: Deduplicate.
|
||||
interface CpuArchResponse {
|
||||
isCpuArchMismatch: boolean;
|
||||
}
|
||||
|
||||
export class StartupChecks extends Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.checkCpuArchMismatch();
|
||||
}
|
||||
|
||||
async checkCpuArchMismatch() {
|
||||
try {
|
||||
const response = await server.get("system-checks") as CpuArchResponse;
|
||||
if (response.isCpuArchMismatch) {
|
||||
this.triggerCommand("showCpuArchWarning", {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not check CPU arch status:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
import options from "./services/options.js";
|
||||
import server from "./services/server.js";
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
|
||||
@ -21,6 +21,7 @@ import ConfirmDialog from "../widgets/dialogs/confirm.js";
|
||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
|
||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
|
||||
import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
@ -45,4 +46,5 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(new InfoDialog())
|
||||
.child(new ConfirmDialog())
|
||||
.child(new PromptDialog())
|
||||
.child(new IncorrectCpuArchDialog())
|
||||
}
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
// Source: https://github.com/codemirror/codemirror5/pull/7080/files
|
||||
|
||||
// CodeMirror, copyright (c) by Marijn Haverbeke and others
|
||||
// Distributed under an MIT license: https://codemirror.net/5/LICENSE
|
||||
|
||||
(function (mod) {
|
||||
if (typeof exports == "object" && typeof module == "object") // CommonJS
|
||||
mod(require("../../lib/codemirror"));
|
||||
else if (typeof define == "function" && define.amd) // AMD
|
||||
define(["../../lib/codemirror"], mod);
|
||||
else // Plain browser env
|
||||
mod(CodeMirror);
|
||||
})(function (CodeMirror) {
|
||||
"use strict";
|
||||
|
||||
CodeMirror.defineMode("hcl", function (config) {
|
||||
var indentUnit = config.indentUnit;
|
||||
|
||||
var keywords = {
|
||||
"resource": true,
|
||||
"variable": true,
|
||||
"output": true,
|
||||
"module": true,
|
||||
"provider": true,
|
||||
"data": true,
|
||||
"locals": true,
|
||||
"terraform": true,
|
||||
"if": true,
|
||||
"else": true,
|
||||
"for": true,
|
||||
"foreach": true,
|
||||
"in": true,
|
||||
"true": true,
|
||||
"false": true,
|
||||
"null": true,
|
||||
};
|
||||
|
||||
var atoms = {
|
||||
"true": true,
|
||||
"false": true,
|
||||
"null": true,
|
||||
};
|
||||
|
||||
var isOperatorChar = /[+\-*&^%:=<>!|\/]/;
|
||||
|
||||
var curPunc;
|
||||
|
||||
function tokenBase(stream, state) {
|
||||
var ch = stream.next();
|
||||
if (ch == '"' || ch == "'" || ch == "`") {
|
||||
state.tokenize = tokenString(ch);
|
||||
return state.tokenize(stream, state);
|
||||
}
|
||||
if (/[\d\.]/.test(ch)) {
|
||||
if (ch == ".") {
|
||||
stream.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/);
|
||||
} else {
|
||||
stream.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/);
|
||||
}
|
||||
return "number";
|
||||
}
|
||||
if (/[\[\]{}\(\),;\:\.]/.test(ch)) {
|
||||
curPunc = ch;
|
||||
return null;
|
||||
}
|
||||
if (ch == "/") {
|
||||
if (stream.eat("*")) {
|
||||
state.tokenize = tokenComment;
|
||||
return tokenComment(stream, state);
|
||||
}
|
||||
if (stream.eat("/")) {
|
||||
stream.skipToEnd();
|
||||
return "comment";
|
||||
}
|
||||
}
|
||||
if (isOperatorChar.test(ch)) {
|
||||
stream.eatWhile(isOperatorChar);
|
||||
return "operator";
|
||||
}
|
||||
stream.eatWhile(/[\w\$_\xa1-\uffff]/);
|
||||
var cur = stream.current();
|
||||
if (keywords.propertyIsEnumerable(cur)) {
|
||||
return "keyword";
|
||||
}
|
||||
if (atoms.propertyIsEnumerable(cur)) return "atom";
|
||||
return "variable";
|
||||
}
|
||||
|
||||
function tokenString(quote) {
|
||||
return function (stream, state) {
|
||||
var escaped = false,
|
||||
next,
|
||||
end = false;
|
||||
while ((next = stream.next()) != null) {
|
||||
if (next == quote && !escaped) {
|
||||
end = true;
|
||||
break;
|
||||
}
|
||||
escaped = !escaped && quote != "`" && next == "\\";
|
||||
}
|
||||
if (end || !(escaped || quote == "`"))
|
||||
state.tokenize = tokenBase;
|
||||
return "string";
|
||||
};
|
||||
}
|
||||
|
||||
function tokenComment(stream, state) {
|
||||
var maybeEnd = false,
|
||||
ch;
|
||||
while (ch = stream.next()) {
|
||||
if (ch == "/" && maybeEnd) {
|
||||
state.tokenize = tokenBase;
|
||||
break;
|
||||
}
|
||||
maybeEnd = (ch == "*");
|
||||
}
|
||||
return "comment";
|
||||
}
|
||||
|
||||
function Context(indented, column, type, align, prev) {
|
||||
this.indented = indented;
|
||||
this.column = column;
|
||||
this.type = type;
|
||||
this.align = align;
|
||||
this.prev = prev;
|
||||
}
|
||||
function pushContext(state, col, type) {
|
||||
return state.context = new Context(state.indented, col, type, null, state.context);
|
||||
}
|
||||
function popContext(state) {
|
||||
if (!state.context.prev) return;
|
||||
var t = state.context.type;
|
||||
if (t == ")" || t == "]" || t == "}")
|
||||
state.indented = state.context.indented;
|
||||
return state.context = state.context.prev;
|
||||
}
|
||||
|
||||
// Interface
|
||||
|
||||
return {
|
||||
startState: function (basecolumn) {
|
||||
return {
|
||||
tokenize: null,
|
||||
context: new Context((basecolumn || 0) - indentUnit, 0, "top", false),
|
||||
indented: 0,
|
||||
startOfLine: true
|
||||
};
|
||||
},
|
||||
|
||||
token: function (stream, state) {
|
||||
var ctx = state.context;
|
||||
if (stream.sol()) {
|
||||
if (ctx.align == null) ctx.align = false;
|
||||
state.indented = stream.indentation();
|
||||
state.startOfLine = true;
|
||||
}
|
||||
if (stream.eatSpace()) return null;
|
||||
curPunc = null;
|
||||
var style = (state.tokenize || tokenBase)(stream, state);
|
||||
if (style == "comment") return style;
|
||||
if (ctx.align == null) ctx.align = true;
|
||||
|
||||
if (curPunc == "{") pushContext(state, stream.column(), "}");
|
||||
else if (curPunc == "[") pushContext(state, stream.column(), "]");
|
||||
else if (curPunc == "(") pushContext(state, stream.column(), ")");
|
||||
else if (curPunc == "}" && ctx.type == "}") popContext(state);
|
||||
else if (curPunc == ctx.type) popContext(state);
|
||||
state.startOfLine = false;
|
||||
return style;
|
||||
},
|
||||
|
||||
indent: function (state, textAfter) {
|
||||
if (state.tokenize != tokenBase && state.tokenize != null) return CodeMirror.Pass;
|
||||
var ctx = state.context, firstChar = textAfter && textAfter.charAt(0);
|
||||
if (firstChar == "#" || firstChar == ";") return 0;
|
||||
if (stream.sol()) {
|
||||
if (ctx.type == "case" && /^(?:case|default)\b/.test(textAfter)) {
|
||||
state.context.type = "}";
|
||||
return ctx.indented;
|
||||
}
|
||||
var closing = firstChar == ctx.type;
|
||||
if (ctx.align) return ctx.column + (closing ? 0 : 1);
|
||||
else return ctx.indented + (closing ? 0 : indentUnit);
|
||||
}
|
||||
},
|
||||
|
||||
electricChars: "{}):",
|
||||
closeBrackets: "()[]{}''\"\"``",
|
||||
fold: "brace",
|
||||
blockCommentStart: "/*",
|
||||
blockCommentEnd: "*/",
|
||||
lineComment: "//"
|
||||
};
|
||||
});
|
||||
|
||||
CodeMirror.defineMIME("text/x-hcl", "hcl");
|
||||
CodeMirror.modeInfo.push({
|
||||
ext: [ "hcl " ],
|
||||
mime: "text/x-hcl",
|
||||
mode: "hcl",
|
||||
name: "Terraform (HCL)"
|
||||
});
|
||||
|
||||
});
|
||||
@ -194,14 +194,15 @@ class ContextMenu {
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!this.isMobile) {
|
||||
$item.on("mouseup", (e) => {
|
||||
// Prevent submenu from failing to expand on mobile
|
||||
if (!this.isMobile || !("items" in item && item.items)) {
|
||||
e.stopPropagation();
|
||||
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
|
||||
this.hide();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
|
||||
$item.addClass("disabled");
|
||||
|
||||
@ -245,6 +245,10 @@ class FrocaImpl implements Froca {
|
||||
}
|
||||
|
||||
async getNotes(noteIds: string[] | JQuery<string>, silentNotFoundError = false): Promise<FNote[]> {
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
noteIds = Array.from(new Set(noteIds)); // make unique
|
||||
const missingNoteIds = noteIds.filter((noteId) => !this.notes[noteId]);
|
||||
|
||||
|
||||
@ -289,13 +289,11 @@ function initNoteAutocomplete($el: JQuery<HTMLElement>, options?: Options) {
|
||||
}
|
||||
|
||||
if (suggestion.action === "create-note") {
|
||||
const { success, noteType, templateNoteId } = await noteCreateService.chooseNoteType();
|
||||
|
||||
const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType();
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { note } = await noteCreateService.createNote(suggestion.parentNoteId, {
|
||||
const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, {
|
||||
title: suggestion.noteTitle,
|
||||
activate: false,
|
||||
type: noteType,
|
||||
|
||||
@ -116,7 +116,7 @@ async function chooseNoteType() {
|
||||
}
|
||||
|
||||
async function createNoteWithTypePrompt(parentNotePath: string, options: CreateNoteOpts = {}) {
|
||||
const { success, noteType, templateNoteId } = await chooseNoteType();
|
||||
const { success, noteType, templateNoteId, notePath } = await chooseNoteType();
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
@ -125,7 +125,7 @@ async function createNoteWithTypePrompt(parentNotePath: string, options: CreateN
|
||||
options.type = noteType;
|
||||
options.templateNoteId = templateNoteId;
|
||||
|
||||
return await createNote(parentNotePath, options);
|
||||
return await createNote(notePath || parentNotePath, options);
|
||||
}
|
||||
|
||||
/* If the first element is heading, parse it out and use it as a new heading. */
|
||||
|
||||
@ -4,6 +4,8 @@ import { t } from "./i18n.js";
|
||||
import type { MenuItem } from "../menus/context_menu.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
|
||||
const SEPARATOR = { title: "----" };
|
||||
|
||||
async function getNoteTypeItems(command?: TreeCommandNames) {
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
{ title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" },
|
||||
@ -18,14 +20,23 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
|
||||
{ title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" },
|
||||
{ title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" },
|
||||
{ title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" },
|
||||
...await getBuiltInTemplates(command),
|
||||
...await getUserTemplates(command)
|
||||
];
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function getUserTemplates(command?: TreeCommandNames) {
|
||||
const templateNoteIds = await server.get<string[]>("search-templates");
|
||||
const templateNotes = await froca.getNotes(templateNoteIds);
|
||||
if (templateNotes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (templateNotes.length > 0) {
|
||||
items.push({ title: "----" });
|
||||
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
SEPARATOR
|
||||
];
|
||||
for (const templateNote of templateNotes) {
|
||||
items.push({
|
||||
title: templateNote.title,
|
||||
@ -35,8 +46,33 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
|
||||
templateNoteId: templateNote.noteId
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function getBuiltInTemplates(command?: TreeCommandNames) {
|
||||
const templatesRoot = await froca.getNote("_templates");
|
||||
if (!templatesRoot) {
|
||||
console.warn("Unable to find template root.");
|
||||
return [];
|
||||
}
|
||||
|
||||
const childNotes = await templatesRoot.getChildNotes();
|
||||
if (childNotes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
SEPARATOR
|
||||
];
|
||||
for (const templateNote of childNotes) {
|
||||
items.push({
|
||||
title: templateNote.title,
|
||||
uiIcon: templateNote.getIcon(),
|
||||
command: command,
|
||||
type: templateNote.type,
|
||||
templateNoteId: templateNote.noteId
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@ -332,8 +332,6 @@ async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true) {
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Fix once keyboard_actions is ported.
|
||||
// @ts-ignore
|
||||
const keyboardActionsService = (await import("./keyboard_actions.js")).default;
|
||||
keyboardActionsService.updateDisplayedShortcuts($dialog);
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
--bs-body-font-weight: var(--main-font-weight) !important;
|
||||
--bs-body-color: var(--main-text-color) !important;
|
||||
--bs-body-bg: var(--main-background-color) !important;
|
||||
--ck-mention-list-max-height: 500px;
|
||||
}
|
||||
|
||||
.table {
|
||||
@ -391,7 +392,7 @@ body.desktop .dropdown-menu {
|
||||
}
|
||||
|
||||
body.desktop .dropdown-menu:not(#context-menu-container) .dropdown-item,
|
||||
body.desktop #context-menu-container .dropdown-item > span {
|
||||
body #context-menu-container .dropdown-item > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@ -439,10 +440,11 @@ body.desktop #context-menu-container .dropdown-item > span {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin: 4px;
|
||||
font-size: var(--monospace-font-size);
|
||||
}
|
||||
|
||||
body .cm-editor {
|
||||
font-size: var(--monospace-font-size);
|
||||
.cm-scroller {
|
||||
font-family: var(--monospace-font-family) !important;
|
||||
}
|
||||
|
||||
body .cm-editor .cm-gutters {
|
||||
@ -1273,6 +1275,29 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
/* Slash commands */
|
||||
|
||||
.ck.ck-slash-command-button {
|
||||
padding: 0.5em 1em !important;
|
||||
}
|
||||
|
||||
.ck.ck-slash-command-button__text-part,
|
||||
.ck.ck-template-form__text-part {
|
||||
margin-left: 0.5em;
|
||||
line-height: 1.2em !important;
|
||||
}
|
||||
|
||||
.ck.ck-slash-command-button__text-part > span,
|
||||
.ck.ck-template-form__text-part > span {
|
||||
line-height: inherit !important;
|
||||
}
|
||||
|
||||
.ck.ck-slash-command-button__text-part .ck.ck-slash-command-button__description,
|
||||
.ck.ck-template-form__text-part .ck-template-form__description {
|
||||
display: block;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.area-expander {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@ -396,3 +396,19 @@ div.tn-tool-dialog {
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE TYPE CHOOSER DIALOG
|
||||
*/
|
||||
|
||||
.note-type-chooser-dialog div.note-type-dropdown {
|
||||
/* Disable the active item highlighting since there is no use for it here */
|
||||
--active-item-text-color: initial;
|
||||
--active-item-background-color: initial;
|
||||
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
.note-type-chooser-dialog div.note-type-dropdown .dropdown-item span.bx {
|
||||
margin-right: .25em;
|
||||
}
|
||||
@ -267,7 +267,7 @@ input::selection,
|
||||
}
|
||||
|
||||
.input-group button:focus-visible,
|
||||
.input-group a:focus-visible {
|
||||
.input-group a:focus-visible:not(.dropdown-item) {
|
||||
box-shadow: unset;
|
||||
outline: transparent;
|
||||
border: transparent;
|
||||
@ -349,7 +349,7 @@ select:hover,
|
||||
select.form-select:hover,
|
||||
select.form-control:hover,
|
||||
.select-button.dropdown-toggle.btn:hover {
|
||||
background: var(--input-hover-background) var(--dropdown-arrow);
|
||||
background: var(--input-hover-background) var(--dropdown-arrow,);
|
||||
color: var(--input-hover-color);
|
||||
}
|
||||
|
||||
|
||||
@ -201,6 +201,11 @@
|
||||
color: var(--menu-item-icon-color);
|
||||
}
|
||||
|
||||
/* Slash commands */
|
||||
.ck.ck-slash-command-button__text-part .ck.ck-button__label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Separator */
|
||||
:root .ck .ck-list__separator {
|
||||
margin: .5em 0;
|
||||
|
||||
@ -142,6 +142,12 @@ div.note-detail-empty {
|
||||
border: unset;
|
||||
}
|
||||
|
||||
/* NOTE ATTACHMENTS */
|
||||
|
||||
.attachment-list div.links-wrapper {
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
/*
|
||||
* OPTIONS PAGES
|
||||
*/
|
||||
|
||||
@ -354,7 +354,7 @@ body.layout-horizontal > .horizontal {
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header .calendar-month-selector .select-button {
|
||||
--select-arrow-svg: ""; /* Disable the dropdown arrow */
|
||||
--select-arrow-svg: initial; /* Disable the dropdown arrow */
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
@ -1145,12 +1145,18 @@ body.mobile .note-title {
|
||||
|
||||
/* The "Change note icon" button */
|
||||
|
||||
.note-icon-widget .note-icon {
|
||||
:root .note-icon-widget button.note-icon,
|
||||
:root .note-icon-widget button.note-icon:hover {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note-icon-widget .note-icon:hover {
|
||||
/* Dropdown open */
|
||||
:root .note-icon-widget button.note-icon.show {
|
||||
background: var(--ck-editor-toolbar-dropdown-button-open-background);
|
||||
}
|
||||
|
||||
:root .note-icon-widget button.note-icon:not(:disabled):hover {
|
||||
background: var(--icon-button-hover-background);
|
||||
color: var(--icon-button-hover-color);
|
||||
}
|
||||
|
||||
@ -1333,7 +1333,7 @@
|
||||
"recovery_keys_used": "已使用: {{date}}",
|
||||
"recovery_keys_unused": "恢复代码 {{index}} 未使用",
|
||||
"oauth_title": "OAuth/OpenID 认证",
|
||||
"oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google)的账户登录网站,以验证您的身份。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。",
|
||||
"oauth_description": "OpenID 是一种标准化方式,允许您使用其他服务(如 Google)的账号登录网站来验证您的身份。默认的身份提供者是 Google,但您可以更改为任何其他 OpenID 提供者。点击<a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">这里</a>了解更多信息。请参阅这些 <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">指南</a> 通过 Google 设置 OpenID 服务。",
|
||||
"oauth_description_warning": "要启用 OAuth/OpenID,您需要设置 config.ini 文件中的 OAuth/OpenID 基础 URL、客户端 ID 和客户端密钥,并重新启动应用程序。如果要从环境变量设置,请设置 TRILIUM_OAUTH_BASE_URL、TRILIUM_OAUTH_CLIENT_ID 和 TRILIUM_OAUTH_CLIENT_SECRET 环境变量。",
|
||||
"oauth_missing_vars": "缺少以下设置项: {{missingVars}}",
|
||||
"oauth_user_account": "用户账号:",
|
||||
|
||||
@ -233,6 +233,8 @@
|
||||
"move_success_message": "Selected notes have been moved into "
|
||||
},
|
||||
"note_type_chooser": {
|
||||
"change_path_prompt": "Change where to create the new note:",
|
||||
"search_placeholder": "search path by name (default if empty)",
|
||||
"modal_title": "Choose note type",
|
||||
"close": "Close",
|
||||
"modal_body": "Choose note type / template of the new note:",
|
||||
@ -1493,7 +1495,7 @@
|
||||
"recovery_keys_used": "Used: {{date}}",
|
||||
"recovery_keys_unused": "Recovery code {{index}} is unused",
|
||||
"oauth_title": "OAuth/OpenID",
|
||||
"oauth_description": "OpenID is a standardized way to let you log into websites using an account from another service, like Google, to verify your identity. Follow these <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">instructions</a> to setup an OpenID service through Google.",
|
||||
"oauth_description": "OpenID is a standardized way to let you log into websites using an account from another service, like Google, to verify your identity. The default issuer is Google, but you can change it to any other OpenID provider. Check <a href=\"#root/_hidden/_help/_help_Otzi9La2YAUX/_help_WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz\">here</a> for more information. Follow these <a href=\"https://developers.google.com/identity/openid-connect/openid-connect\">instructions</a> to setup an OpenID service through Google.",
|
||||
"oauth_description_warning": "To enable OAuth/OpenID, you need to set the OAuth/OpenID base URL, client ID and client secret in the config.ini file and restart the application. If you want to set from environment variables, please set TRILIUM_OAUTH_BASE_URL, TRILIUM_OAUTH_CLIENT_ID and TRILIUM_OAUTH_CLIENT_SECRET.",
|
||||
"oauth_missing_vars": "Missing settings: {{variables}}",
|
||||
"oauth_user_account": "User Account: ",
|
||||
@ -1918,5 +1920,14 @@
|
||||
"title": "Appearance",
|
||||
"word_wrapping": "Word wrapping",
|
||||
"color-scheme": "Color scheme"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "Please download the ARM64 version",
|
||||
"message_macos": "TriliumNext is currently running under Rosetta 2 translation, which means you're using the Intel (x64) version on Apple Silicon Mac. This will significantly impact performance and battery life.",
|
||||
"message_windows": "TriliumNext is currently running emulation, which means you're using the Intel (x64) version on a Windows on ARM device. This will significantly impact performance and battery life.",
|
||||
"recommendation": "For the best experience, please download the native ARM64 version of TriliumNext from our releases page.",
|
||||
"download_link": "Download Native Version",
|
||||
"continue_anyway": "Continue Anyway",
|
||||
"dont_show_again": "Don't show this warning again"
|
||||
}
|
||||
}
|
||||
|
||||
7
apps/client/src/types-assets.d.ts
vendored
@ -3,9 +3,14 @@ declare module "*.png" {
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "@triliumnext/ckeditor5/emoji_definitions/en.json?url" {
|
||||
declare module "*?url" {
|
||||
var path: string;
|
||||
export default path;
|
||||
}
|
||||
|
||||
declare module "*?raw" {
|
||||
var content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "boxicons/css/boxicons.min.css" { }
|
||||
|
||||
2
apps/client/src/types.d.ts
vendored
@ -57,6 +57,8 @@ declare global {
|
||||
|
||||
process?: ElectronProcess;
|
||||
glob?: CustomGlobals;
|
||||
|
||||
EXCALIDRAW_ASSET_PATH?: string;
|
||||
}
|
||||
|
||||
interface AutoCompleteConfig {
|
||||
|
||||
16
apps/client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ViteTypeOptions {
|
||||
strictImportMetaEnv: unknown
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** The license key for CKEditor premium features. */
|
||||
readonly VITE_CKEDITOR_KEY?: string;
|
||||
/** Whether to enable the CKEditor inspector (see https://ckeditor.com/docs/ckeditor5/latest/framework/develpment-tools/inspector.html). */
|
||||
readonly VITE_CKEDITOR_ENABLE_INSPECTOR?: "true" | "false";
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@ -111,7 +111,7 @@ export default class AddLinkDialog extends BasicWidget {
|
||||
|
||||
this.updateTitleSettingsVisibility();
|
||||
|
||||
utils.openDialog(this.$widget);
|
||||
await utils.openDialog(this.$widget);
|
||||
|
||||
this.$autoComplete.val("");
|
||||
this.$linkTitle.val("");
|
||||
|
||||
59
apps/client/src/widgets/dialogs/incorrect_cpu_arch.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Modal } from "bootstrap";
|
||||
import utils from "../../services/utils.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="cpu-arch-dialog modal mx-auto" tabindex="-1" role="dialog" style="z-index: 2000;">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${t("cpu_arch_warning.title")}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${utils.isMac() ? t("cpu_arch_warning.message_macos") : t("cpu_arch_warning.message_windows")}</p>
|
||||
|
||||
<p>${t("cpu_arch_warning.recommendation")}</p>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between align-items-center">
|
||||
<button class="download-correct-version-button btn btn-primary btn-lg me-2">
|
||||
<span class="bx bx-download"></span>
|
||||
${t("cpu_arch_warning.download_link")}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">${t("cpu_arch_warning.continue_anyway")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export default class IncorrectCpuArchDialog extends BasicWidget {
|
||||
private modal!: Modal;
|
||||
private $downloadButton!: JQuery<HTMLElement>;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
this.$downloadButton = this.$widget.find(".download-correct-version-button");
|
||||
|
||||
this.$downloadButton.on("click", () => {
|
||||
// Open the releases page where users can download the correct version
|
||||
if (utils.isElectron()) {
|
||||
const { shell } = utils.dynamicRequire("electron");
|
||||
shell.openExternal("https://github.com/TriliumNext/Notes/releases/latest");
|
||||
} else {
|
||||
window.open("https://github.com/TriliumNext/Notes/releases/latest", "_blank");
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-focus the download button when shown
|
||||
this.$widget.on("shown.bs.modal", () => {
|
||||
this.$downloadButton.trigger("focus");
|
||||
});
|
||||
}
|
||||
|
||||
showCpuArchWarningEvent() {
|
||||
this.modal.show();
|
||||
}
|
||||
}
|
||||
@ -69,6 +69,7 @@ export default class MarkdownImportDialog extends BasicWidget {
|
||||
const modelFragment = textEditor.data.toModel(viewFragment);
|
||||
|
||||
textEditor.model.insertContent(modelFragment, textEditor.model.document.selection);
|
||||
textEditor.editing.view.focus();
|
||||
|
||||
toastService.showMessage(t("markdown_import.import_success"));
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { CommandNames } from "../../components/app_context.js";
|
||||
import type { MenuCommandItem } from "../../menus/context_menu.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import noteTypesService from "../../services/note_types.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import BasicWidget from "../basic_widget.js";
|
||||
import { Dropdown, Modal } from "bootstrap";
|
||||
|
||||
@ -13,6 +14,11 @@ const TPL = /*html*/`
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
.note-type-chooser-dialog .input-group {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.note-type-chooser-dialog .note-type-dropdown {
|
||||
position: relative;
|
||||
font-size: large;
|
||||
@ -30,6 +36,12 @@ const TPL = /*html*/`
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("note_type_chooser.close")}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${t("note_type_chooser.change_path_prompt")}
|
||||
|
||||
<div class="input-group">
|
||||
<input class="choose-note-path form-control" placeholder="${t("note_type_chooser.search_placeholder")}">
|
||||
</div>
|
||||
|
||||
${t("note_type_chooser.modal_body")}
|
||||
|
||||
<div class="dropdown" style="display: flex;">
|
||||
@ -37,7 +49,7 @@ const TPL = /*html*/`
|
||||
data-bs-toggle="dropdown" data-bs-display="static">
|
||||
</button>
|
||||
|
||||
<div class="note-type-dropdown dropdown-menu"></div>
|
||||
<div class="note-type-dropdown dropdown-menu static"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -48,6 +60,7 @@ export interface ChooseNoteTypeResponse {
|
||||
success: boolean;
|
||||
noteType?: string;
|
||||
templateNoteId?: string;
|
||||
notePath?: string;
|
||||
}
|
||||
|
||||
type Callback = (data: ChooseNoteTypeResponse) => void;
|
||||
@ -57,6 +70,7 @@ export default class NoteTypeChooserDialog extends BasicWidget {
|
||||
private dropdown!: Dropdown;
|
||||
private modal!: Modal;
|
||||
private $noteTypeDropdown!: JQuery<HTMLElement>;
|
||||
private $autoComplete!: JQuery<HTMLElement>;
|
||||
private $originalFocused: JQuery<HTMLElement> | null;
|
||||
private $originalDialog: JQuery<HTMLElement> | null;
|
||||
|
||||
@ -72,6 +86,7 @@ export default class NoteTypeChooserDialog extends BasicWidget {
|
||||
this.$widget = $(TPL);
|
||||
this.modal = Modal.getOrCreateInstance(this.$widget[0]);
|
||||
|
||||
this.$autoComplete = this.$widget.find(".choose-note-path");
|
||||
this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown");
|
||||
this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find(".note-type-dropdown-trigger")[0]);
|
||||
|
||||
@ -116,9 +131,20 @@ export default class NoteTypeChooserDialog extends BasicWidget {
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
noteAutocompleteService
|
||||
.initNoteAutocomplete(this.$autoComplete, {
|
||||
allowCreatingNotes: false,
|
||||
hideGoToSelectedNoteButton: true,
|
||||
allowJumpToSearchNotes: false,
|
||||
})
|
||||
}
|
||||
|
||||
async chooseNoteTypeEvent({ callback }: { callback: Callback }) {
|
||||
this.$originalFocused = $(":focus");
|
||||
|
||||
await this.refresh();
|
||||
|
||||
const noteTypes = await noteTypesService.getNoteTypeItems();
|
||||
|
||||
this.$noteTypeDropdown.empty();
|
||||
@ -153,12 +179,14 @@ export default class NoteTypeChooserDialog extends BasicWidget {
|
||||
const $item = $(e.target).closest(".dropdown-item");
|
||||
const noteType = $item.attr("data-note-type");
|
||||
const templateNoteId = $item.attr("data-template-note-id");
|
||||
const notePath = this.$autoComplete.getSelectedNotePath() || undefined;
|
||||
|
||||
if (this.resolve) {
|
||||
this.resolve({
|
||||
success: true,
|
||||
noteType,
|
||||
templateNoteId
|
||||
templateNoteId,
|
||||
notePath
|
||||
});
|
||||
}
|
||||
this.resolve = null;
|
||||
|
||||
@ -52,9 +52,9 @@ export default class CodeButtonsWidget extends NoteContextAwareWidget {
|
||||
toastService.showMessage(t("code_buttons.opening_api_docs_message"));
|
||||
|
||||
if (this.note?.mime.endsWith("frontend")) {
|
||||
window.open("https://zadam.github.io/trilium/frontend_api/FrontendScriptApi.html", "_blank");
|
||||
window.open("https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html", "_blank");
|
||||
} else {
|
||||
window.open("https://zadam.github.io/trilium/backend_api/BackendScriptApi.html", "_blank");
|
||||
window.open("https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html", "_blank");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -56,6 +56,8 @@ export default class ContextualHelpButton extends NoteContextAwareWidget {
|
||||
return byNoteType[note.type];
|
||||
} else if (note?.hasLabel("calendarRoot")) {
|
||||
return "l0tKav7yLHGF";
|
||||
} else if (note?.hasLabel("textSnippet")) {
|
||||
return "pwc194wlRzcH";
|
||||
} else if (note && note.type === "book") {
|
||||
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
|
||||
}
|
||||
|
||||
@ -1507,6 +1507,12 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
);
|
||||
|
||||
this.toggleHiddenNode(true); // hoisting will handle hidden note visibility
|
||||
|
||||
// Automatically expand the hoisted note by default
|
||||
const node = this.getActiveNode();
|
||||
if (node.data.noteId === this.noteContext.hoistedNoteId){
|
||||
this.setExpanded(node.data.branchId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
import BasicWidget from "./basic_widget.js";
|
||||
|
||||
/**
|
||||
* Base class for widgets that need to track the active tab/note
|
||||
*/
|
||||
export default class TabAwareWidget extends BasicWidget {
|
||||
constructor() {
|
||||
super();
|
||||
this.noteId = null;
|
||||
this.noteType = null;
|
||||
this.notePath = null;
|
||||
this.isActiveTab = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the active note is switched
|
||||
*
|
||||
* @param {string} noteId
|
||||
* @param {string|null} noteType
|
||||
* @param {string|null} notePath
|
||||
*/
|
||||
async noteSwitched(noteId, noteType, notePath) {
|
||||
this.noteId = noteId;
|
||||
this.noteType = noteType;
|
||||
this.notePath = notePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the widget's tab becomes active or inactive
|
||||
*
|
||||
* @param {boolean} active
|
||||
*/
|
||||
activeTabChanged(active) {
|
||||
this.isActiveTab = active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when entities (notes, attributes, etc.) are reloaded
|
||||
*/
|
||||
entitiesReloaded() {}
|
||||
|
||||
/**
|
||||
* Check if this widget is enabled
|
||||
*/
|
||||
isEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh widget with current data
|
||||
*/
|
||||
async refresh() {}
|
||||
}
|
||||
@ -17,6 +17,7 @@ export default class AbstractTextTypeWidget extends TypeWidget {
|
||||
this.$widget.on("dblclick", "img", (e) => this.openImageInCurrentTab($(e.target)));
|
||||
|
||||
this.$widget.on("click", "img", (e) => {
|
||||
e.stopPropagation();
|
||||
const isLeftClick = e.which === 1;
|
||||
const isMiddleClick = e.which === 2;
|
||||
const ctrlKey = utils.isCtrlKey(e);
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import server from "../../services/server.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import options from "../../services/options.js";
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
|
||||
import type { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem, SceneData } from "@excalidraw/excalidraw/types";
|
||||
import type { JSX } from "react";
|
||||
import type React from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import asset_path from "../../asset_path.js";
|
||||
import type { LibraryItem } from "@excalidraw/excalidraw/types";
|
||||
import type { Theme } from "@excalidraw/excalidraw/element/types";
|
||||
import type Canvas from "./canvas_el.js";
|
||||
import { CanvasContent } from "./canvas_el.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="canvas-widget note-detail-canvas note-detail-printable note-detail">
|
||||
@ -28,6 +23,7 @@ const TPL = /*html*/`
|
||||
|
||||
.excalidraw-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:root[dir="ltr"]
|
||||
.excalidraw
|
||||
@ -51,11 +47,7 @@ const TPL = /*html*/`
|
||||
</div>
|
||||
`;
|
||||
|
||||
interface CanvasContent {
|
||||
elements: ExcalidrawElement[];
|
||||
files: BinaryFileData[];
|
||||
appState: Partial<AppState>;
|
||||
}
|
||||
|
||||
|
||||
interface AttachmentMetadata {
|
||||
title: string;
|
||||
@ -107,37 +99,22 @@ interface AttachmentMetadata {
|
||||
*/
|
||||
export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
|
||||
private readonly SCENE_VERSION_INITIAL: number;
|
||||
private readonly SCENE_VERSION_ERROR: number;
|
||||
|
||||
private currentNoteId: string;
|
||||
private currentSceneVersion: number;
|
||||
|
||||
private libraryChanged: boolean;
|
||||
private librarycache: LibraryItem[];
|
||||
private attachmentMetadata: AttachmentMetadata[];
|
||||
private themeStyle!: Theme;
|
||||
private excalidrawLib!: typeof import("@excalidraw/excalidraw");
|
||||
private excalidrawApi!: ExcalidrawImperativeAPI;
|
||||
private excalidrawWrapperRef!: React.RefObject<HTMLElement | null>;
|
||||
|
||||
private $render!: JQuery<HTMLElement>;
|
||||
private root?: Root;
|
||||
private reactHandlers!: JQuery<HTMLElement>;
|
||||
private canvasInstance!: Canvas;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// constants
|
||||
this.SCENE_VERSION_INITIAL = -1; // -1 indicates that it is fresh. excalidraw scene version is always >0
|
||||
this.SCENE_VERSION_ERROR = -2; // -2 indicates error
|
||||
|
||||
// currently required by excalidraw, in order to allows self-hosting fonts locally.
|
||||
// this avoids making excalidraw load the fonts from an external CDN.
|
||||
(window as any).EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
|
||||
|
||||
// temporary vars
|
||||
this.currentNoteId = "";
|
||||
this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
|
||||
|
||||
// will be overwritten
|
||||
this.$render;
|
||||
@ -182,34 +159,48 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
throw new Error("Unable to find element to render.");
|
||||
}
|
||||
|
||||
// See https://github.com/excalidraw/excalidraw/issues/7899.
|
||||
if (!window.process) {
|
||||
(window.process as any) = {};
|
||||
const Canvas = (await import("./canvas_el.js")).default;
|
||||
this.canvasInstance = new Canvas({
|
||||
// this makes sure that 1) manual theme switch button is hidden 2) theme stays as it should after opening menu
|
||||
theme: this.themeStyle,
|
||||
onChange: () => this.onChangeHandler(),
|
||||
viewModeEnabled: options.is("databaseReadonly"),
|
||||
zenModeEnabled: false,
|
||||
gridModeEnabled: false,
|
||||
isCollaborating: false,
|
||||
detectScroll: false,
|
||||
handleKeyboardGlobally: false,
|
||||
autoFocus: false,
|
||||
UIOptions: {
|
||||
canvasActions: {
|
||||
saveToActiveFile: false,
|
||||
export: false
|
||||
}
|
||||
if (!window.process.env) {
|
||||
window.process.env = {};
|
||||
}
|
||||
(window.process.env as any).PREACT = false;
|
||||
},
|
||||
onLibraryChange: () => {
|
||||
this.libraryChanged = true;
|
||||
|
||||
const excalidraw = await import("@excalidraw/excalidraw");
|
||||
this.excalidrawLib = excalidraw;
|
||||
this.saveData();
|
||||
},
|
||||
});
|
||||
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const React = (await import("react")).default;
|
||||
this.root?.unmount();
|
||||
this.root = createRoot(renderElement);
|
||||
this.root.render(React.createElement(() => this.createExcalidrawReactApp(React, excalidraw.Excalidraw)));
|
||||
await setupFonts();
|
||||
this.canvasInstance.renderCanvas(renderElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* called to populate the widget container with the note content
|
||||
*/
|
||||
async doRefresh(note: FNote) {
|
||||
if (!this.canvasInstance) {
|
||||
await this.#init();
|
||||
}
|
||||
|
||||
// see if the note changed, since we do not get a new class for a new note
|
||||
const noteChanged = this.currentNoteId !== note.noteId;
|
||||
if (noteChanged) {
|
||||
// reset the scene to omit unnecessary onchange handler
|
||||
this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
|
||||
this.canvasInstance.resetSceneVersion();
|
||||
}
|
||||
this.currentNoteId = note.noteId;
|
||||
|
||||
@ -217,10 +208,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
const blob = await note.getBlob();
|
||||
|
||||
// before we load content into excalidraw, make sure excalidraw has loaded
|
||||
while (!this.excalidrawApi) {
|
||||
console.log("excalidrawApi not yet loaded, sleep 200ms...");
|
||||
await utils.sleep(200);
|
||||
}
|
||||
await this.canvasInstance.waitForApiToBecomeAvailable();
|
||||
|
||||
/**
|
||||
* new and empty note - make sure that canvas is empty.
|
||||
@ -229,15 +217,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
* newly instantiated?
|
||||
*/
|
||||
if (!blob?.content?.trim()) {
|
||||
const sceneData: SceneData = {
|
||||
elements: [],
|
||||
appState: {
|
||||
theme: this.themeStyle
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Props mismatch.
|
||||
this.excalidrawApi.updateScene(sceneData as any);
|
||||
this.canvasInstance.resetScene(this.themeStyle);
|
||||
} else if (blob.content) {
|
||||
let content: CanvasContent;
|
||||
|
||||
@ -254,36 +234,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
};
|
||||
}
|
||||
|
||||
const { elements, files } = content;
|
||||
const appState: Partial<AppState> = content.appState ?? {};
|
||||
|
||||
appState.theme = this.themeStyle;
|
||||
|
||||
if (this.excalidrawWrapperRef.current) {
|
||||
const boundingClientRect = this.excalidrawWrapperRef.current.getBoundingClientRect();
|
||||
appState.width = boundingClientRect.width;
|
||||
appState.height = boundingClientRect.height;
|
||||
appState.offsetLeft = boundingClientRect.left;
|
||||
appState.offsetTop = boundingClientRect.top;
|
||||
}
|
||||
|
||||
const sceneData: SceneData = {
|
||||
elements,
|
||||
appState
|
||||
};
|
||||
|
||||
// files are expected in an array when loading. they are stored as a key-index object
|
||||
// see example for loading here:
|
||||
// https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68
|
||||
const fileArray: BinaryFileData[] = [];
|
||||
for (const fileId in files) {
|
||||
const file = files[fileId];
|
||||
// TODO: dataURL is replaceable with a trilium image url
|
||||
// maybe we can save normal images (pasted) with base64 data url, and trilium images
|
||||
// with their respective url! nice
|
||||
// file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png";
|
||||
fileArray.push(file);
|
||||
}
|
||||
this.canvasInstance.loadData(content, this.themeStyle);
|
||||
|
||||
Promise.all(
|
||||
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
|
||||
@ -310,23 +261,19 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
const metadata = results.map((result) => result.metadata);
|
||||
|
||||
// Update the library and save to independent variables
|
||||
this.excalidrawApi.updateLibrary({ libraryItems, merge: false });
|
||||
this.canvasInstance.updateLibrary(libraryItems);
|
||||
|
||||
// save state of library to compare it to the new state later.
|
||||
this.librarycache = libraryItems;
|
||||
this.attachmentMetadata = metadata;
|
||||
});
|
||||
|
||||
// Update the scene
|
||||
// TODO: Fix type of sceneData
|
||||
this.excalidrawApi.updateScene(sceneData as any);
|
||||
this.excalidrawApi.addFiles(fileArray);
|
||||
this.excalidrawApi.history.clear();
|
||||
|
||||
}
|
||||
|
||||
// set initial scene version
|
||||
if (this.currentSceneVersion === this.SCENE_VERSION_INITIAL) {
|
||||
this.currentSceneVersion = this.getSceneVersion();
|
||||
if (this.canvasInstance.isInitialScene()) {
|
||||
this.canvasInstance.updateSceneVersion();
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,56 +282,14 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
* this is automatically called after this.saveData();
|
||||
*/
|
||||
async getData() {
|
||||
const elements = this.excalidrawApi.getSceneElements();
|
||||
const appState = this.excalidrawApi.getAppState();
|
||||
|
||||
/**
|
||||
* A file is not deleted, even though removed from canvas. Therefore, we only keep
|
||||
* files that are referenced by an element. Maybe this will change with a new excalidraw version?
|
||||
*/
|
||||
const files = this.excalidrawApi.getFiles();
|
||||
|
||||
// parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share
|
||||
const svg = await this.excalidrawLib.exportToSvg({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding: 5, // 5 px padding
|
||||
files
|
||||
});
|
||||
const svgString = svg.outerHTML;
|
||||
|
||||
const activeFiles: Record<string, BinaryFileData> = {};
|
||||
// TODO: Used any where upstream typings appear to be broken.
|
||||
elements.forEach((element: any) => {
|
||||
if ("fileId" in element && element.fileId) {
|
||||
activeFiles[element.fileId] = files[element.fileId];
|
||||
}
|
||||
});
|
||||
|
||||
const content = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements,
|
||||
files: activeFiles,
|
||||
appState: {
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom
|
||||
}
|
||||
};
|
||||
|
||||
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svgString, position: 0 }];
|
||||
const { content, svg } = await this.canvasInstance.getData();
|
||||
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
|
||||
|
||||
if (this.libraryChanged) {
|
||||
// this.libraryChanged is unset in dataSaved()
|
||||
|
||||
// there's no separate method to get library items, so have to abuse this one
|
||||
const libraryItems = await this.excalidrawApi.updateLibrary({
|
||||
libraryItems() {
|
||||
return [];
|
||||
},
|
||||
merge: true
|
||||
});
|
||||
const libraryItems = await this.canvasInstance.getLibraryItems();
|
||||
|
||||
// excalidraw saves the library as a own state. the items are saved to libraryItems. then we compare the library right now with a libraryitemcache. The cache is filled when we first load the Library into the note.
|
||||
//We need the cache to delete old attachments later in the server.
|
||||
@ -453,146 +358,39 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||
}
|
||||
// changeHandler is called upon any tiny change in excalidraw. button clicked, hover, etc.
|
||||
// make sure only when a new element is added, we actually save something.
|
||||
const isNewSceneVersion = this.isNewSceneVersion();
|
||||
const isNewSceneVersion = this.canvasInstance.isNewSceneVersion();
|
||||
/**
|
||||
* FIXME: however, we might want to make an exception, if viewport changed, since viewport
|
||||
* is desired to save? (add) and appState background, and some things
|
||||
*/
|
||||
|
||||
// upon updateScene, onchange is called, even though "nothing really changed" that is worth saving
|
||||
const isNotInitialScene = this.currentSceneVersion !== this.SCENE_VERSION_INITIAL;
|
||||
|
||||
const isNotInitialScene = !this.canvasInstance.isInitialScene();
|
||||
const shouldSave = isNewSceneVersion && isNotInitialScene;
|
||||
|
||||
if (shouldSave) {
|
||||
this.updateSceneVersion();
|
||||
this.canvasInstance.updateSceneVersion();
|
||||
this.saveData();
|
||||
}
|
||||
}
|
||||
|
||||
createExcalidrawReactApp(react: typeof React, excalidrawComponent: React.MemoExoticComponent<(props: ExcalidrawProps) => JSX.Element>) {
|
||||
const excalidrawWrapperRef = react.useRef<HTMLElement>(null);
|
||||
this.excalidrawWrapperRef = excalidrawWrapperRef;
|
||||
const [dimensions, setDimensions] = react.useState<{ width?: number; height?: number }>({
|
||||
width: undefined,
|
||||
height: undefined
|
||||
});
|
||||
|
||||
react.useEffect(() => {
|
||||
if (excalidrawWrapperRef.current) {
|
||||
const dimensions = {
|
||||
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
|
||||
height: excalidrawWrapperRef.current.getBoundingClientRect().height
|
||||
};
|
||||
setDimensions(dimensions);
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
if (this.note?.type !== "canvas") {
|
||||
async function setupFonts() {
|
||||
if (window.EXCALIDRAW_ASSET_PATH) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (excalidrawWrapperRef.current) {
|
||||
const dimensions = {
|
||||
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
|
||||
height: excalidrawWrapperRef.current.getBoundingClientRect().height
|
||||
};
|
||||
setDimensions(dimensions);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [excalidrawWrapperRef]);
|
||||
|
||||
const onLinkOpen = react.useCallback<NonNullable<ExcalidrawProps["onLinkOpen"]>>((element, event) => {
|
||||
let link = element.link;
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (link.startsWith("root/")) {
|
||||
link = "#" + link;
|
||||
}
|
||||
|
||||
const { nativeEvent } = event.detail;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
return linkService.goToLinkExt(nativeEvent, link, null);
|
||||
}, []);
|
||||
|
||||
return react.createElement(
|
||||
react.Fragment,
|
||||
null,
|
||||
react.createElement(
|
||||
"div",
|
||||
{
|
||||
className: "excalidraw-wrapper",
|
||||
ref: excalidrawWrapperRef
|
||||
},
|
||||
react.createElement(excalidrawComponent, {
|
||||
// this makes sure that 1) manual theme switch button is hidden 2) theme stays as it should after opening menu
|
||||
theme: this.themeStyle,
|
||||
excalidrawAPI: (api: ExcalidrawImperativeAPI) => {
|
||||
this.excalidrawApi = api;
|
||||
},
|
||||
onLibraryChange: () => {
|
||||
this.libraryChanged = true;
|
||||
|
||||
this.saveData();
|
||||
},
|
||||
onChange: () => this.onChangeHandler(),
|
||||
viewModeEnabled: options.is("databaseReadonly"),
|
||||
zenModeEnabled: false,
|
||||
gridModeEnabled: false,
|
||||
isCollaborating: false,
|
||||
detectScroll: false,
|
||||
handleKeyboardGlobally: false,
|
||||
autoFocus: false,
|
||||
onLinkOpen,
|
||||
UIOptions: {
|
||||
canvasActions: {
|
||||
saveToActiveFile: false,
|
||||
export: false
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
|
||||
* we compare the scene version as suggested in:
|
||||
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
|
||||
*
|
||||
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
|
||||
*/
|
||||
isNewSceneVersion() {
|
||||
if (options.is("databaseReadonly")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sceneVersion = this.getSceneVersion();
|
||||
|
||||
return (
|
||||
this.currentSceneVersion === this.SCENE_VERSION_INITIAL || // initial scene version update
|
||||
this.currentSceneVersion !== sceneVersion
|
||||
); // ensure scene changed
|
||||
}
|
||||
|
||||
getSceneVersion() {
|
||||
if (this.excalidrawApi) {
|
||||
const elements = this.excalidrawApi.getSceneElements();
|
||||
return this.excalidrawLib.getSceneVersion(elements);
|
||||
// currently required by excalidraw, in order to allows self-hosting fonts locally.
|
||||
// this avoids making excalidraw load the fonts from an external CDN.
|
||||
let path: string;
|
||||
if (!glob.isDev) {
|
||||
path = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
|
||||
} else {
|
||||
return this.SCENE_VERSION_ERROR;
|
||||
}
|
||||
path = (await import("../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2?url")).default;
|
||||
let pathComponents = path.split("/");
|
||||
path = pathComponents.slice(0, pathComponents.length - 2).join("/");
|
||||
}
|
||||
|
||||
updateSceneVersion() {
|
||||
this.currentSceneVersion = this.getSceneVersion();
|
||||
}
|
||||
window.EXCALIDRAW_ASSET_PATH = path;
|
||||
}
|
||||
|
||||
179
apps/client/src/widgets/type_widgets/canvas_el.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import { Excalidraw, getSceneVersion, exportToSvg } from "@excalidraw/excalidraw";
|
||||
import { createElement, render, unmountComponentAtNode } from "preact/compat";
|
||||
import { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem } from "@excalidraw/excalidraw/types";
|
||||
import type { ComponentType } from "preact";
|
||||
import { ExcalidrawElement, NonDeletedExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
|
||||
|
||||
export interface CanvasContent {
|
||||
elements: ExcalidrawElement[];
|
||||
files: BinaryFileData[];
|
||||
appState: Partial<AppState>;
|
||||
}
|
||||
|
||||
/** Indicates that it is fresh. excalidraw scene version is always >0 */
|
||||
const SCENE_VERSION_INITIAL = -1;
|
||||
|
||||
export default class Canvas {
|
||||
|
||||
private currentSceneVersion: number;
|
||||
private opts: ExcalidrawProps;
|
||||
private excalidrawApi!: ExcalidrawImperativeAPI;
|
||||
private initializedPromise: JQuery.Deferred<void>;
|
||||
|
||||
constructor(opts: ExcalidrawProps) {
|
||||
this.opts = opts;
|
||||
this.currentSceneVersion = SCENE_VERSION_INITIAL;
|
||||
this.initializedPromise = $.Deferred();
|
||||
}
|
||||
|
||||
renderCanvas(targetEl: HTMLElement) {
|
||||
unmountComponentAtNode(targetEl);
|
||||
render(this.createCanvasElement({
|
||||
...this.opts,
|
||||
excalidrawAPI: (api: ExcalidrawImperativeAPI) => {
|
||||
this.excalidrawApi = api;
|
||||
this.initializedPromise.resolve();
|
||||
},
|
||||
}), targetEl);
|
||||
}
|
||||
|
||||
async waitForApiToBecomeAvailable() {
|
||||
while (!this.excalidrawApi) {
|
||||
await this.initializedPromise;
|
||||
}
|
||||
}
|
||||
|
||||
private createCanvasElement(opts: ExcalidrawProps) {
|
||||
return createElement("div", { className: "excalidraw-wrapper", },
|
||||
createElement(Excalidraw as ComponentType<ExcalidrawProps>, opts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
|
||||
* we compare the scene version as suggested in:
|
||||
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
|
||||
*
|
||||
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
|
||||
*/
|
||||
isNewSceneVersion() {
|
||||
const sceneVersion = this.getSceneVersion();
|
||||
|
||||
return (
|
||||
this.currentSceneVersion === SCENE_VERSION_INITIAL || // initial scene version update
|
||||
this.currentSceneVersion !== sceneVersion
|
||||
); // ensure scene changed
|
||||
}
|
||||
|
||||
getSceneVersion() {
|
||||
const elements = this.excalidrawApi.getSceneElements();
|
||||
return getSceneVersion(elements);
|
||||
}
|
||||
|
||||
updateSceneVersion() {
|
||||
this.currentSceneVersion = this.getSceneVersion();
|
||||
}
|
||||
|
||||
resetSceneVersion() {
|
||||
this.currentSceneVersion = SCENE_VERSION_INITIAL;
|
||||
}
|
||||
|
||||
isInitialScene() {
|
||||
return this.currentSceneVersion === SCENE_VERSION_INITIAL;
|
||||
}
|
||||
|
||||
resetScene(theme: Theme) {
|
||||
this.excalidrawApi.updateScene({
|
||||
elements: [],
|
||||
appState: {
|
||||
theme
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadData(content: CanvasContent, theme: Theme) {
|
||||
const { elements, files } = content;
|
||||
const appState: Partial<AppState> = content.appState ?? {};
|
||||
appState.theme = theme;
|
||||
|
||||
// files are expected in an array when loading. they are stored as a key-index object
|
||||
// see example for loading here:
|
||||
// https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68
|
||||
const fileArray: BinaryFileData[] = [];
|
||||
for (const fileId in files) {
|
||||
const file = files[fileId];
|
||||
// TODO: dataURL is replaceable with a trilium image url
|
||||
// maybe we can save normal images (pasted) with base64 data url, and trilium images
|
||||
// with their respective url! nice
|
||||
// file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png";
|
||||
fileArray.push(file);
|
||||
}
|
||||
|
||||
// Update the scene
|
||||
// TODO: Fix type of sceneData
|
||||
this.excalidrawApi.updateScene({
|
||||
elements,
|
||||
appState: appState as AppState
|
||||
});
|
||||
this.excalidrawApi.addFiles(fileArray);
|
||||
this.excalidrawApi.history.clear();
|
||||
}
|
||||
|
||||
async getData() {
|
||||
const elements = this.excalidrawApi.getSceneElements();
|
||||
const appState = this.excalidrawApi.getAppState();
|
||||
|
||||
/**
|
||||
* A file is not deleted, even though removed from canvas. Therefore, we only keep
|
||||
* files that are referenced by an element. Maybe this will change with a new excalidraw version?
|
||||
*/
|
||||
const files = this.excalidrawApi.getFiles();
|
||||
// parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share
|
||||
const svg = await exportToSvg({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding: 5, // 5 px padding
|
||||
files
|
||||
});
|
||||
const svgString = svg.outerHTML;
|
||||
|
||||
const activeFiles: Record<string, BinaryFileData> = {};
|
||||
elements.forEach((element: NonDeletedExcalidrawElement) => {
|
||||
if ("fileId" in element && element.fileId) {
|
||||
activeFiles[element.fileId] = files[element.fileId];
|
||||
}
|
||||
});
|
||||
|
||||
const content = {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements,
|
||||
files: activeFiles,
|
||||
appState: {
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
zoom: appState.zoom
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
content,
|
||||
svg: svgString
|
||||
}
|
||||
}
|
||||
|
||||
async getLibraryItems() {
|
||||
return this.excalidrawApi.updateLibrary({
|
||||
libraryItems() {
|
||||
return [];
|
||||
},
|
||||
merge: true
|
||||
});
|
||||
}
|
||||
|
||||
async updateLibrary(libraryItems: LibraryItem[]) {
|
||||
this.excalidrawApi.updateLibrary({ libraryItems, merge: false });
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,19 +1,20 @@
|
||||
import { ALLOWED_PROTOCOLS } from "../../../services/link.js";
|
||||
import { MIME_TYPE_AUTO } from "@triliumnext/commons";
|
||||
import type { EditorConfig } from "@triliumnext/ckeditor5";
|
||||
import { buildExtraCommands, type EditorConfig } from "@triliumnext/ckeditor5";
|
||||
import { getHighlightJsNameForMime } from "../../../services/mime_types.js";
|
||||
import options from "../../../services/options.js";
|
||||
import { ensureMimeTypesForHighlighting, isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js";
|
||||
import utils from "../../../services/utils.js";
|
||||
import emojiDefinitionsUrl from "@triliumnext/ckeditor5/emoji_definitions/en.json?url";
|
||||
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
|
||||
import getTemplates from "./snippets.js";
|
||||
|
||||
const TEXT_FORMATTING_GROUP = {
|
||||
label: "Text formatting",
|
||||
icon: "text"
|
||||
};
|
||||
|
||||
export function buildConfig(): EditorConfig {
|
||||
export async function buildConfig(): Promise<EditorConfig> {
|
||||
return {
|
||||
image: {
|
||||
styles: {
|
||||
@ -121,6 +122,14 @@ export function buildConfig(): EditorConfig {
|
||||
clipboard: {
|
||||
copy: copyTextWithToast
|
||||
},
|
||||
slashCommand: {
|
||||
removeCommands: [],
|
||||
dropdownLimit: Number.MAX_SAFE_INTEGER,
|
||||
extraCommands: buildExtraCommands()
|
||||
},
|
||||
template: {
|
||||
definitions: await getTemplates()
|
||||
},
|
||||
// This value must be kept in sync with the language defined in webpack.config.js.
|
||||
language: "en"
|
||||
};
|
||||
@ -201,6 +210,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"insertTemplate",
|
||||
"markdownImport",
|
||||
"cuttonote",
|
||||
"findAndReplace"
|
||||
@ -257,6 +267,7 @@ export function buildFloatingToolbar() {
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"insertTemplate",
|
||||
"imageUpload",
|
||||
"markdownImport",
|
||||
"specialCharacters",
|
||||
|
||||
105
apps/client/src/widgets/type_widgets/ckeditor/snippets.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import debounce from "debounce";
|
||||
import froca from "../../../services/froca.js";
|
||||
import type LoadResults from "../../../services/load_results.js";
|
||||
import search from "../../../services/search.js";
|
||||
import type { TemplateDefinition } from "@triliumnext/ckeditor5";
|
||||
import appContext from "../../../components/app_context.js";
|
||||
import TemplateIcon from "@ckeditor/ckeditor5-icons/theme/icons/template.svg?raw";
|
||||
import type FNote from "../../../entities/fnote.js";
|
||||
|
||||
interface TemplateData {
|
||||
title: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
let templateCache: Map<string, TemplateData> = new Map();
|
||||
const debouncedHandleContentUpdate = debounce(handleContentUpdate, 1000);
|
||||
|
||||
/**
|
||||
* Generates the list of snippets based on the user's notes to be passed down to the CKEditor configuration.
|
||||
*
|
||||
* @returns the list of templates.
|
||||
*/
|
||||
export default async function getTemplates() {
|
||||
// Build the definitions and populate the cache.
|
||||
const snippets = await search.searchForNotes("#textSnippet");
|
||||
const definitions: TemplateDefinition[] = [];
|
||||
for (const snippet of snippets) {
|
||||
const { description } = await invalidateCacheFor(snippet);
|
||||
|
||||
definitions.push({
|
||||
title: snippet.title,
|
||||
data: () => templateCache.get(snippet.noteId)?.content ?? "",
|
||||
icon: TemplateIcon,
|
||||
description
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
async function invalidateCacheFor(snippet: FNote) {
|
||||
const description = snippet.getLabelValue("textSnippetDescription");
|
||||
const data: TemplateData = {
|
||||
title: snippet.title,
|
||||
description: description ?? undefined,
|
||||
content: await snippet.getContent()
|
||||
};
|
||||
templateCache.set(snippet.noteId, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function handleFullReload() {
|
||||
console.warn("Full text editor reload needed");
|
||||
appContext.triggerCommand("reloadTextEditor");
|
||||
}
|
||||
|
||||
async function handleContentUpdate(affectedNoteIds: string[]) {
|
||||
const updatedNoteIds = new Set(affectedNoteIds);
|
||||
const templateNoteIds = new Set(templateCache.keys());
|
||||
const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds);
|
||||
|
||||
await froca.getNotes(affectedNoteIds);
|
||||
|
||||
let fullReloadNeeded = false;
|
||||
for (const affectedTemplateNoteId of affectedTemplateNoteIds) {
|
||||
try {
|
||||
const template = await froca.getNote(affectedTemplateNoteId);
|
||||
if (!template) {
|
||||
console.warn("Unable to obtain template with ID ", affectedTemplateNoteId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newTitle = template.title;
|
||||
if (templateCache.get(affectedTemplateNoteId)?.title !== newTitle) {
|
||||
fullReloadNeeded = true;
|
||||
break;
|
||||
}
|
||||
|
||||
await invalidateCacheFor(template);
|
||||
} catch (e) {
|
||||
// If a note was not found while updating the cache, it means we need to do a full reload.
|
||||
fullReloadNeeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullReloadNeeded) {
|
||||
handleFullReload();
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTemplateCache(loadResults: LoadResults): boolean {
|
||||
const affectedNoteIds = loadResults.getNoteIds();
|
||||
|
||||
// React to creation or deletion of text snippets.
|
||||
if (loadResults.getAttributeRows().find((attr) =>
|
||||
attr.type === "label" &&
|
||||
(attr.name === "textSnippet" || attr.name === "textSnippetDescription"))) {
|
||||
handleFullReload();
|
||||
} else if (affectedNoteIds.length > 0) {
|
||||
// Update content and titles if one of the template notes were updated.
|
||||
debouncedHandleContentUpdate(affectedNoteIds);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@ -45,7 +45,7 @@ import { t } from "../../services/i18n.js";
|
||||
import LanguageOptions from "./options/i18n/language.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import CodeTheme from "./options/code_notes/code_theme.js";
|
||||
import RelatedSettings from "./options/related_settings.js";
|
||||
import RelatedSettings from "./options/appearance/related_settings.js";
|
||||
|
||||
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
|
||||
<style>
|
||||
|
||||
@ -18,8 +18,7 @@ import { getMermaidConfig } from "../../services/mermaid.js";
|
||||
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig } from "@triliumnext/ckeditor5";
|
||||
import "@triliumnext/ckeditor5/index.css";
|
||||
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
|
||||
|
||||
const ENABLE_INSPECTOR = false;
|
||||
import { updateTemplateCache } from "./ckeditor/snippets.js";
|
||||
|
||||
const mentionSetup: MentionFeed[] = [
|
||||
{
|
||||
@ -195,7 +194,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
|
||||
const finalConfig = {
|
||||
...editorConfig,
|
||||
...buildConfig(),
|
||||
...(await buildConfig()),
|
||||
...buildToolbarConfig(isClassicEditor),
|
||||
htmlSupport: {
|
||||
allow: JSON.parse(options.get("allowedHtmlTags")),
|
||||
@ -203,7 +202,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
classes: true,
|
||||
attributes: true
|
||||
},
|
||||
licenseKey: "GPL"
|
||||
licenseKey: getLicenseKey()
|
||||
};
|
||||
|
||||
const contentLanguage = this.note?.getLabelValue("language");
|
||||
@ -278,7 +277,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
|
||||
editor.model.document.on("change:data", () => this.spacedUpdate.scheduleUpdate());
|
||||
|
||||
if (glob.isDev && ENABLE_INSPECTOR) {
|
||||
if (import.meta.env.VITE_CKEDITOR_ENABLE_INSPECTOR === "true") {
|
||||
const CKEditorInspector = (await import("@ckeditor/ckeditor5-inspector")).default;
|
||||
CKEditorInspector.attach(editor);
|
||||
}
|
||||
@ -328,7 +327,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
const data = blob?.content || "";
|
||||
const newContentLanguage = this.note?.getLabelValue("language");
|
||||
if (this.contentLanguage !== newContentLanguage) {
|
||||
await this.reinitialize(data);
|
||||
await this.reinitializeWithData(data);
|
||||
} else {
|
||||
this.watchdog.editor?.setData(data);
|
||||
}
|
||||
@ -564,7 +563,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
this.refreshIncludedNote(this.$editor, noteId);
|
||||
}
|
||||
|
||||
async reinitialize(data: string) {
|
||||
async reinitializeWithData(data: string) {
|
||||
if (!this.watchdog) {
|
||||
return;
|
||||
}
|
||||
@ -574,9 +573,25 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
this.watchdog.editor?.setData(data);
|
||||
}
|
||||
|
||||
async onLanguageChanged() {
|
||||
async reinitialize() {
|
||||
const data = this.watchdog.editor?.getData();
|
||||
await this.reinitialize(data ?? "");
|
||||
await this.reinitializeWithData(data ?? "");
|
||||
}
|
||||
|
||||
async reloadTextEditorEvent() {
|
||||
await this.reinitialize();
|
||||
}
|
||||
|
||||
async onLanguageChanged() {
|
||||
await this.reinitialize();
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) {
|
||||
await super.entitiesReloadedEvent(e);
|
||||
|
||||
if (updateTemplateCache(e.loadResults)) {
|
||||
await this.reinitialize();
|
||||
}
|
||||
}
|
||||
|
||||
buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {
|
||||
@ -640,3 +655,13 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getLicenseKey() {
|
||||
const premiumLicenseKey = import.meta.env.VITE_CKEDITOR_KEY;
|
||||
if (!premiumLicenseKey) {
|
||||
logError("CKEditor license key is not set, premium features will not be available.");
|
||||
return "GPL";
|
||||
}
|
||||
|
||||
return premiumLicenseKey;
|
||||
}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import type FNote from "../../../entities/fnote";
|
||||
import type { OptionPages } from "../content_widget";
|
||||
import OptionsWidget from "./options_widget";
|
||||
import type { OptionPages } from "../../content_widget";
|
||||
import OptionsWidget from "../options_widget";
|
||||
|
||||
const TPL = `\
|
||||
<div class="options-section">
|
||||
<h4>Related settings</h4>
|
||||
|
||||
<nav class="related-settings">
|
||||
<nav class="related-settings use-tn-links">
|
||||
<li>Color scheme for code blocks in text notes</li>
|
||||
<li>Color scheme for code notes</li>
|
||||
</nav>
|
||||
@ -63,7 +63,7 @@ const TPL = /*html*/`
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p class="form-text">${t("i18n.first-week-info")}</p>
|
||||
<p class="form-text use-tn-links">${t("i18n.first-week-info")}</p>
|
||||
|
||||
<div class="admonition warning" role="alert">
|
||||
${t("i18n.first-week-warning")}
|
||||
|
||||
@ -9,7 +9,7 @@ const TPL = /*html*/`
|
||||
<div class="options-section">
|
||||
<h4>${t("custom_date_time_format.title")}</h4>
|
||||
|
||||
<p class="description">
|
||||
<p class="description use-tn-links">
|
||||
${t("custom_date_time_format.description")}
|
||||
</p>
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import asset_path from './src/asset_path';
|
||||
import webpackStatsPlugin from 'rollup-plugin-webpack-stats';
|
||||
|
||||
const assets = [ "assets", "stylesheets", "libraries", "fonts", "translations" ];
|
||||
const assets = [ "assets", "stylesheets", "fonts", "translations" ];
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
@ -43,11 +43,22 @@ export default defineConfig(() => ({
|
||||
{
|
||||
find: "@triliumnext/highlightjs",
|
||||
replacement: resolve(__dirname, "node_modules/@triliumnext/highlightjs/dist")
|
||||
},
|
||||
{
|
||||
find: "react",
|
||||
replacement: "preact/compat"
|
||||
},
|
||||
{
|
||||
find: "react-dom",
|
||||
replacement: "preact/compat"
|
||||
}
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom"
|
||||
"react-dom",
|
||||
"preact",
|
||||
"preact/compat",
|
||||
"preact/hooks"
|
||||
]
|
||||
},
|
||||
// Uncomment this if you are using workers.
|
||||
@ -59,7 +70,7 @@ export default defineConfig(() => ({
|
||||
outDir: './dist',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: process.env.NODE_ENV === "production",
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
desktop: join(__dirname, "src", "desktop.ts"),
|
||||
@ -73,7 +84,10 @@ export default defineConfig(() => ({
|
||||
output: {
|
||||
entryFileNames: "src/[name].js",
|
||||
chunkFileNames: "src/[name].js",
|
||||
assetFileNames: "src/[name].[ext]"
|
||||
assetFileNames: "src/[name].[ext]",
|
||||
manualChunks: {
|
||||
"ckeditor5": [ "@triliumnext/ckeditor5" ]
|
||||
},
|
||||
},
|
||||
onwarn(warning, rollupWarn) {
|
||||
if (warning.code === "MODULE_LEVEL_DIRECTIVE") {
|
||||
@ -97,5 +111,8 @@ export default defineConfig(() => ({
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||
}
|
||||
}));
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
import { PRODUCT_NAME } from "../src/app-info.js";
|
||||
|
||||
const ELECTRON_FORGE_DIR = __dirname;
|
||||
|
||||
const EXECUTABLE_NAME = "trilium"; // keep in sync with server's package.json -> packagerConfig.executableName
|
||||
const PRODUCT_NAME = "TriliumNext Notes";
|
||||
const APP_ICON_PATH = path.join(ELECTRON_FORGE_DIR, "app-icon");
|
||||
|
||||
const extraResourcesForPlatform = getExtraResourcesForPlatform();
|
||||
@ -141,6 +142,76 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
// Remove unused locales from the packaged app to save some space.
|
||||
postPackage(_, packageResult) {
|
||||
const isMac = (process.platform === "darwin");
|
||||
let localesToKeep = LOCALES
|
||||
.filter(locale => !locale.contentOnly)
|
||||
.map(locale => locale.electronLocale) as string[];
|
||||
if (!isMac) {
|
||||
localesToKeep = localesToKeep.map(locale => locale.replace("_", "-"))
|
||||
}
|
||||
|
||||
const keptLocales = new Set();
|
||||
const removedLocales: string[] = [];
|
||||
const extension = (isMac ? ".lproj" : ".pak");
|
||||
|
||||
for (const outputPath of packageResult.outputPaths) {
|
||||
const localeDirs = isMac
|
||||
? [
|
||||
path.join(outputPath, "TriliumNext Notes.app/Contents/Resources"),
|
||||
path.join(outputPath, "TriliumNext Notes.app/Contents/Frameworks/Electron Framework.framework/Resources")
|
||||
]
|
||||
: [ path.join(outputPath, 'locales') ];
|
||||
|
||||
for (const localeDir of localeDirs) {
|
||||
if (!fs.existsSync(localeDir)) {
|
||||
console.log(`No locales directory found in '${localeDir}'.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(localeDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(extension)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let localeName = path.basename(file, extension);
|
||||
if (localeName === "en-US" && !isMac) {
|
||||
// If the locale is "en-US" on Windows, we treat it as "en".
|
||||
// This is because the Windows version of Electron uses "en-US.pak" instead of "en.pak".
|
||||
localeName = "en";
|
||||
}
|
||||
|
||||
if (localesToKeep.includes(localeName)) {
|
||||
keptLocales.add(localeName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(localeDir, file);
|
||||
if (isMac) {
|
||||
fs.rm(filePath, { recursive: true });
|
||||
} else {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
removedLocales.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Removed unused locale files: ${removedLocales.join(", ")}`);
|
||||
|
||||
// Ensure all locales that should be kept are actually present.
|
||||
for (const locale of localesToKeep) {
|
||||
if (!keptLocales.has(locale)) {
|
||||
console.error(`Locale ${locale} was not found in the packaged app.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Gather all the artifacts produced by the makers and copy them to a common upload directory.
|
||||
postMake(_, makeResults) {
|
||||
const outputDir = path.join(__dirname, "..", "upload");
|
||||
fs.mkdirpSync(outputDir);
|
||||
@ -169,7 +240,7 @@ module.exports = {
|
||||
};
|
||||
|
||||
function getExtraResourcesForPlatform() {
|
||||
const resources = [];
|
||||
const resources: string[] = [];
|
||||
|
||||
const getScriptResources = () => {
|
||||
const scripts = ["trilium-portable", "trilium-safe-mode", "trilium-no-cert-check"];
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/desktop",
|
||||
"version": "0.94.1",
|
||||
"version": "0.95.0",
|
||||
"description": "Build your personal knowledge base with TriliumNext Notes",
|
||||
"private": true,
|
||||
"main": "main.cjs",
|
||||
@ -29,7 +29,7 @@
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"config": {
|
||||
"forge": "./electron-forge/forge.config.cjs"
|
||||
"forge": "./electron-forge/forge.config.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start-prod": "nx build desktop && cross-env TRILIUM_DATA_DIR=data TRILIUM_RESOURCE_DIR=dist TRILIUM_PORT=37841 electron dist/main.js"
|
||||
@ -48,6 +48,17 @@
|
||||
"outputs": [
|
||||
"{options.outputPath}"
|
||||
],
|
||||
"defaultConfiguration": "production",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"minify": true,
|
||||
"sourcemap": false
|
||||
},
|
||||
"development": {
|
||||
"minify": false,
|
||||
"sourcemap": true
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"main": "apps/desktop/src/electron-main.ts",
|
||||
"outputPath": "apps/desktop/dist",
|
||||
@ -63,10 +74,8 @@
|
||||
"format": [
|
||||
"cjs"
|
||||
],
|
||||
"minify": true,
|
||||
"thirdParty": true,
|
||||
"declaration": false,
|
||||
"sourcemap": true,
|
||||
"esbuildOptions": {
|
||||
"splitting": false,
|
||||
"loader": {
|
||||
|
||||
4
apps/desktop/src/app-info.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The Electron product name (can be used for the window WMClass or passed down to the Electron packager).
|
||||
*/
|
||||
export const PRODUCT_NAME = "TriliumNext Notes";
|
||||
@ -8,6 +8,7 @@ import options from "@triliumnext/server/src/services/options.js";
|
||||
import electronDebug from "electron-debug";
|
||||
import electronDl from "electron-dl";
|
||||
import { deferred } from "@triliumnext/server/src/services/utils.js";
|
||||
import { PRODUCT_NAME } from "./app-info";
|
||||
|
||||
async function main() {
|
||||
const serverInitializedPromise = deferred<void>();
|
||||
@ -28,6 +29,7 @@ async function main() {
|
||||
// Electron 36 crashes with "Using GTK 2/3 and GTK 4 in the same process is not supported" on some distributions.
|
||||
// See https://github.com/electron/electron/issues/46538 for more info.
|
||||
if (process.platform === "linux") {
|
||||
electron.app.setName(PRODUCT_NAME);
|
||||
electron.app.commandLine.appendSwitch("gtk-version", "3");
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,9 @@
|
||||
"references": [
|
||||
{
|
||||
"path": "../server/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/commons/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
23
apps/desktop/tsconfig.forge.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2020",
|
||||
"outDir": "dist",
|
||||
"types": [
|
||||
"node",
|
||||
"express"
|
||||
],
|
||||
"rootDir": "electron-forge",
|
||||
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"eslint.config.js",
|
||||
"eslint.config.cjs",
|
||||
"eslint.config.mjs"
|
||||
]
|
||||
}
|
||||
@ -6,8 +6,14 @@
|
||||
{
|
||||
"path": "../server"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/commons"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.forge.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/server",
|
||||
"version": "0.94.1",
|
||||
"version": "0.95.0",
|
||||
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
@ -39,14 +39,14 @@
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/xml2js": "0.4.14",
|
||||
"express-http-proxy": "2.1.1",
|
||||
"@anthropic-ai/sdk": "0.53.0",
|
||||
"@anthropic-ai/sdk": "0.54.0",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/express-partial-content": "workspace:*",
|
||||
"@triliumnext/turndown-plugin-gfm": "workspace:*",
|
||||
"archiver": "7.0.1",
|
||||
"async-mutex": "0.5.0",
|
||||
"axios": "1.9.0",
|
||||
"axios": "1.10.0",
|
||||
"bindings": "1.5.0",
|
||||
"chardet": "2.1.0",
|
||||
"cheerio": "1.1.0",
|
||||
@ -88,7 +88,7 @@
|
||||
"multer": "2.0.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"ollama": "0.5.16",
|
||||
"openai": "5.3.0",
|
||||
"openai": "5.5.1",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
@ -245,6 +245,7 @@
|
||||
},
|
||||
"declarationRootDir": "apps/server/src",
|
||||
"minify": false,
|
||||
"sourcemap": true,
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
@ -268,6 +269,17 @@
|
||||
"^build",
|
||||
"client:build"
|
||||
],
|
||||
"defaultConfiguration": "production",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"minify": true,
|
||||
"sourcemap": false
|
||||
},
|
||||
"development": {
|
||||
"minify": false,
|
||||
"sourcemap": true
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"main": "apps/server/src/main.ts",
|
||||
"outputPath": "apps/server/dist",
|
||||
@ -283,10 +295,8 @@
|
||||
"cjs"
|
||||
],
|
||||
"declarationRootDir": "apps/server/src",
|
||||
"minify": true,
|
||||
"thirdParty": true,
|
||||
"declaration": false,
|
||||
"sourcemap": true,
|
||||
"esbuildOptions": {
|
||||
"splitting": false,
|
||||
"loader": {
|
||||
@ -338,6 +348,12 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"test-build": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"command": "vitest --config {projectRoot}/vitest.build.config.mts"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
46
apps/server/spec/build-checks/artifacts.spec.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { globSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { it, describe, expect } from "vitest";
|
||||
|
||||
describe("Check artifacts are present", () => {
|
||||
const distPath = join(__dirname, "../../dist");
|
||||
|
||||
it("has the necessary node modules", async () => {
|
||||
const paths = [
|
||||
"node_modules/better-sqlite3",
|
||||
"node_modules/bindings",
|
||||
"node_modules/file-uri-to-path"
|
||||
];
|
||||
|
||||
ensurePathsExist(paths);
|
||||
});
|
||||
|
||||
it("includes the client", async () => {
|
||||
const paths = [
|
||||
"public/assets",
|
||||
"public/fonts",
|
||||
"public/node_modules",
|
||||
"public/src",
|
||||
"public/stylesheets",
|
||||
"public/translations"
|
||||
];
|
||||
|
||||
ensurePathsExist(paths);
|
||||
});
|
||||
|
||||
it("includes necessary assets", async () => {
|
||||
const paths = [
|
||||
"assets",
|
||||
"share-theme"
|
||||
];
|
||||
|
||||
ensurePathsExist(paths);
|
||||
});
|
||||
|
||||
function ensurePathsExist(paths: string[]) {
|
||||
for (const path of paths) {
|
||||
const result = globSync(join(distPath, path, "**"));
|
||||
expect(result, path).not.toHaveLength(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
200
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing.html
generated
vendored
@ -1,6 +1,175 @@
|
||||
<p>Trilium allows you to share selected notes as <strong>publicly accessible</strong> read-only
|
||||
documents. This feature is particularly useful for publishing content directly
|
||||
from your Trilium notes, making it accessible to others online.</p>
|
||||
<figure
|
||||
class="image">
|
||||
<img style="aspect-ratio:1144/660;" src="Sharing_image.png" width="1144"
|
||||
height="660">
|
||||
</figure>
|
||||
|
||||
<h2>Features, interaction and limitations</h2>
|
||||
<ul>
|
||||
<li>Searching by note title.</li>
|
||||
<li>Automatic dark/light mode based on the user's browser settings.</li>
|
||||
<li>Mobile-friendly layout, with sidebar.</li>
|
||||
<li>Collapsible tree with the same note icons as the application.</li>
|
||||
<li>Customizable logo.</li>
|
||||
<li>Toggle button for dark/light mode, which also stores the user preferences.</li>
|
||||
<li>Quick navigation buttons (previous and next note).</li>
|
||||
<li>Displaying the date of the last update of the note.</li>
|
||||
</ul>
|
||||
<h3>By note type</h3>
|
||||
<figure class="table" style="width:100%;">
|
||||
<table class="ck-table-resized">
|
||||
<colgroup>
|
||||
<col style="width:19.92%;">
|
||||
<col style="width:41.66%;">
|
||||
<col style="width:38.42%;">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Supported features</th>
|
||||
<th>Limitations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_iPIMuisry3hd">Text</a>
|
||||
</th>
|
||||
<td>
|
||||
<ul>
|
||||
<li>Table of contents.</li>
|
||||
<li>Syntax highlight of code blocks, provided a language is selected (does
|
||||
not work if “Auto-detected” is enabled).</li>
|
||||
<li>Rendering for math equations.</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>Including notes is not supported.</li>
|
||||
<li>Inline Mermaid diagrams are not rendered.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>
|
||||
</th>
|
||||
<td>
|
||||
<ul>
|
||||
<li>Basic support (displaying the contents of the note in a monospace font).</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>No syntax highlight.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_m523cpzocqaD">Saved Search</a>
|
||||
</th>
|
||||
<td>Not supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_iRwzGnHPzonm">Relation Map</a>
|
||||
</th>
|
||||
<td>Not supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_bdUJEHsAPYQR">Note Map</a>
|
||||
</th>
|
||||
<td>Not supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_HcABDtFCkbFN">Render Note</a>
|
||||
</th>
|
||||
<td>Not supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_GTwFsgaA0lCt">Book</a>
|
||||
</th>
|
||||
<td>
|
||||
<ul>
|
||||
<li>The child notes are displayed in a fixed format. </li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>More advanced view types such as the calendar view are not supported.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>
|
||||
</th>
|
||||
<td>
|
||||
<ul>
|
||||
<li>The diagram is displayed as a vector image.</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>No further interaction supported.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_grjYqerjn243">Canvas</a>
|
||||
</th>
|
||||
<td>
|
||||
<ul>
|
||||
<li>The diagram is displayed as a vector image.</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>No further interaction supported.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_1vHRoWCEjj0L">Web View</a>
|
||||
</th>
|
||||
<td>Not supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_gBbsAeiuUxI5">Mind Map</a>
|
||||
</th>
|
||||
<td>The diagram is displayed as a vector image.</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>No further interaction supported.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>
|
||||
</th>
|
||||
<td>Not supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a class="reference-link" href="#root/_help_W8vYD3Q1zjCR">File</a>
|
||||
</th>
|
||||
<td>Basic interaction (downloading the file).</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>No further interaction supported.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<p>While the sharing feature is powerful, it has some limitations:</p>
|
||||
<ul>
|
||||
<li><strong>Code Notes</strong>: No syntax highlighting.</li>
|
||||
<li><strong>Static Note Tree</strong>
|
||||
</li>
|
||||
<li><strong>Protected Notes</strong>: Cannot be shared.</li>
|
||||
<li><strong>Include Notes</strong>: Not supported.</li>
|
||||
</ul>
|
||||
<p>Some of these limitations may be addressed in future updates.</p>
|
||||
<h2>Prerequisites</h2>
|
||||
<p>To use the sharing feature, you must have a <a class="reference-link"
|
||||
href="#root/_help_WOcw2SLH6tbX">Server Installation</a> of Trilium.
|
||||
@ -19,9 +188,6 @@
|
||||
<p><strong>Access the Shared Note</strong>: The link provided will open the
|
||||
note in your browser. If your server is not configured with a public IP,
|
||||
the URL will refer to <code>localhost (127.0.0.1)</code>.</p>
|
||||
<p>
|
||||
<img src="Sharing_share-single-note-.png" alt="Shared Note Example">
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<h2>Sharing a Note Subtree</h2>
|
||||
@ -30,9 +196,6 @@
|
||||
the shared content. For example, sharing the "Formatting" subtree will
|
||||
display a page with basic navigation for exploring all the notes within
|
||||
that subtree.</p>
|
||||
<p>
|
||||
<img src="Sharing_share-multiple-not.png" alt="Shared Subtree Example">
|
||||
</p>
|
||||
<h2>Viewing All Shared Notes</h2>
|
||||
<p>You can view a list of all shared notes by clicking on "Show Shared Notes
|
||||
Subtree." This allows you to manage and navigate through all the notes
|
||||
@ -47,8 +210,8 @@
|
||||
To protect an entire subtree, make sure the label is <a href="#root/_help_bwZpz2ajCEwO">inheritable</a>.</p>
|
||||
<h2>Advanced Sharing Options</h2>
|
||||
<h3>Customizing the Appearance of Shared Notes</h3>
|
||||
<p>The default shared page is basic in design, but you can customize it using
|
||||
your own CSS:</p>
|
||||
<p>The default design should be a good starting point, but you can customize
|
||||
it using your own CSS:</p>
|
||||
<ul>
|
||||
<li><strong>Custom CSS</strong>: Link a CSS <a class="reference-link"
|
||||
href="#root/_help_6f9hih2hXXZk">Code</a> note to the shared page by
|
||||
@ -99,19 +262,6 @@ for (const attr of parentNote.attributes) {
|
||||
making it easier to use Trilium as a fully-fledged website. Consider combining
|
||||
this with the <code>#shareIndex</code> label, which will display a list of
|
||||
all shared notes.</p>
|
||||
<h2>Limitations</h2>
|
||||
<p>While the sharing feature is powerful, it has some limitations:</p>
|
||||
<ul>
|
||||
<li><strong>No Relation Map Support</strong>
|
||||
</li>
|
||||
<li><strong>Book Notes</strong>: Only show a list of child notes.</li>
|
||||
<li><strong>Code Notes</strong>: No syntax highlighting.</li>
|
||||
<li><strong>Static Note Tree</strong>
|
||||
</li>
|
||||
<li><strong>Protected Notes</strong>: Cannot be shared.</li>
|
||||
<li><strong>Include Notes</strong>: Not supported.</li>
|
||||
</ul>
|
||||
<p>Some of these limitations may be addressed in future updates.</p>
|
||||
<h2>Attribute reference</h2>
|
||||
<figure class="table">
|
||||
<table>
|
||||
@ -190,3 +340,11 @@ for (const attr of parentNote.attributes) {
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
|
||||
<h2>Credits</h2>
|
||||
<p>Since v0.95.0, a new theme was introduced (and enabled by default) which
|
||||
greatly improves the visual aspect of the Share feature, as well as its
|
||||
functionality (such as mobile support, dark/light mode, collapsible tree,
|
||||
etc.). This theme is an adaptation of the <a href="https://github.com/zerebos/trilium.rocks">Trilium Rocks!</a> by
|
||||
<a
|
||||
href="https://github.com/zerebos">zerebos</a>.</p>
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
@ -1,6 +1,7 @@
|
||||
<p>When accessing a shared note, Trilium will render it as a web page. Sometimes
|
||||
it's desirable to serve the content directly so that it can be used in
|
||||
a script or downloaded by the user.</p>
|
||||
<figure class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -11,7 +12,10 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="1_Serving directly the conte.png">
|
||||
<figure class="image">
|
||||
<img style="aspect-ratio:738/275;" src="1_Serving directly the conte.png"
|
||||
width="738" height="275">
|
||||
</figure>
|
||||
</td>
|
||||
<td>
|
||||
<img src="Serving directly the conte.png">
|
||||
@ -19,6 +23,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
|
||||
<h2>By adding an attribute to the note</h2>
|
||||
<p>Simply add the <code>#shareRaw</code> attribute and the note will always
|
||||
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing_image.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 68 KiB |
@ -146,6 +146,8 @@
|
||||
<li><code>#publicationYear %= '19[0-9]{2}'</code>: Use the '%=' operator to
|
||||
match a regular expression (regex). This feature has been available since
|
||||
Trilium 0.52.</li>
|
||||
<li><code>note.content %= '\\d{2}:\\d{2} (PM|AM)'</code>: Find notes that
|
||||
mention a time. Backslashes in a regex must be escaped.</li>
|
||||
</ul>
|
||||
<h3>Advanced Use Cases</h3>
|
||||
<ul>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
variable to something larger than the integer <code>250</code> (e.g. <code>450</code> in
|
||||
the following example):</p><pre><code class="language-text-x-trilium-auto">export MAX_ALLOWED_FILE_SIZE_MB=450</code></pre>
|
||||
<h3>Disabling Authentication</h3>
|
||||
<p>See <a class="reference-link" href="#root/pOsGYCXsbNQG/Otzi9La2YAUX/_help_0hzsNCP31IAB">Authentication</a>.</p>
|
||||
<p>See <a class="reference-link" href="#root/_help_0hzsNCP31IAB">Authentication</a>.</p>
|
||||
<h2>Reverse Proxy Setup</h2>
|
||||
<p>To configure a reverse proxy for Trilium, you can use either <strong>nginx</strong> or <strong>Apache</strong>.
|
||||
You can also check out the documentation stored in the Reverse proxy folder.</p>
|
||||
|
||||
@ -10,7 +10,14 @@ vim default.conf</code></pre>
|
||||
</li>
|
||||
<li>
|
||||
<p>Fill the file with the context shown below, part of the setting show be
|
||||
changed. Then you can enjoy your web with HTTPS forced and proxy.</p><pre><code class="language-text-x-trilium-auto"># This part is for proxy and HTTPS configure
|
||||
changed. Then you can enjoy your web with HTTPS forced and proxy.</p><pre><code class="language-text-x-trilium-auto"># This part configures, where your Trilium server is running
|
||||
upstream trilium {
|
||||
zone trilium 64k;
|
||||
server 127.0.0.1:8080; # change it to a different hostname and port if non-default is used
|
||||
keepalive 2;
|
||||
}
|
||||
|
||||
# This part is for proxy and HTTPS configure
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name trilium.example.net; #change trilium.example.net to your domain without HTTPS or HTTP.
|
||||
@ -29,9 +36,8 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://127.0.0.1:8080; # change it to a different port if non-default is used
|
||||
proxy_pass http://trilium;
|
||||
proxy_read_timeout 90;
|
||||
proxy_redirect http://127.0.0.1:8080 https://trilium.example.net; # change them based on your IP, port and domain
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,16 +58,16 @@ server {
|
||||
<li>add the <code>proxy_cookie_path</code> directive with the same path: this
|
||||
allows you to stay logged in at multiple instances at the same time.</li>
|
||||
</ul><pre><code class="language-text-x-trilium-auto"> location /trilium/instance-one {
|
||||
rewrite /trilium/instance-one/(.*) /$1 break;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://127.0.0.1:8080; # change it to a different port if non-default is used
|
||||
proxy_pass http://trilium;
|
||||
proxy_cookie_path / /trilium/instance-one
|
||||
proxy_read_timeout 90;
|
||||
proxy_redirect http://127.0.0.1:8080 https://trilium.example.net; # change them based on your IP, port and domain
|
||||
}
|
||||
</code></pre>
|
||||
</li>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
by adding the following to <code>config.ini</code>:</p><pre><code class="language-text-x-trilium-auto">[General]
|
||||
noAuthentication=true</code></pre>
|
||||
<p>Disabling authentication will bypass even the <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/Otzi9La2YAUX/WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz">Multi-Factor Authentication</a> since
|
||||
href="#root/_help_7DAiwaf8Z7Rz">Multi-Factor Authentication</a> since
|
||||
v0.94.1.</p>
|
||||
<h2>Understanding how the session works</h2>
|
||||
<p>Once logged into Trilium, the application will store this information
|
||||
@ -22,14 +22,14 @@ cookieMaxAge=86400</code></pre>
|
||||
the <em>last interaction with the application</em>.</p>
|
||||
<h2>Viewing active sessions</h2>
|
||||
<p>The login sessions are now stored in the same <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_wX4HbRucYSDD">Database</a> as
|
||||
the user data. In order to view which sessions are active, open the
|
||||
<a
|
||||
class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/wX4HbRucYSDD/oyIAJ9PvvwHX/_help_YKWqdJhzi2VY">SQL Console</a> and run the following query:</p><pre><code class="language-text-x-sqlite-schema-trilium">SELECT * FROM sessions</code></pre>
|
||||
href="#root/_help_wX4HbRucYSDD">Database</a> as the user data. In
|
||||
order to view which sessions are active, open the <a class="reference-link"
|
||||
href="#root/_help_YKWqdJhzi2VY">SQL Console</a> and run the following
|
||||
query:</p><pre><code class="language-text-x-trilium-auto">SELECT * FROM sessions</code></pre>
|
||||
<p>Expired sessions are periodically cleaned by the server, generally an
|
||||
hourly interval.</p>
|
||||
<h2>See also</h2>
|
||||
<ul>
|
||||
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/Otzi9La2YAUX/WOcw2SLH6tbX/_help_7DAiwaf8Z7Rz">Multi-Factor Authentication</a>
|
||||
<li><a class="reference-link" href="#root/_help_7DAiwaf8Z7Rz">Multi-Factor Authentication</a>
|
||||
</li>
|
||||
</ul>
|
||||
@ -41,10 +41,6 @@ class="admonition warning">
|
||||
the page).</li>
|
||||
</ol>
|
||||
<h3>OpenID</h3>
|
||||
<aside class="admonition note">
|
||||
<p>Currently only compatible with Google. Other services like Authentik and
|
||||
Auth0 are planned on being added.</p>
|
||||
</aside>
|
||||
<p>In order to setup OpenID, you will need to setup a authentication provider.
|
||||
This requires a bit of extra setup. Follow <a href="https://developers.google.com/identity/openid-connect/openid-connect">these instructions</a> to
|
||||
setup an OpenID service through google.</p>
|
||||
@ -62,3 +58,11 @@ class="admonition warning">
|
||||
<li>Choose “OAuth/OpenID” under MFA Method</li>
|
||||
<li>Refresh the page and login through OpenID provider</li>
|
||||
</ol>
|
||||
<aside class="admonition note">
|
||||
<p>The default OAuth issuer is Google. To use other services such as Authentik
|
||||
or Auth0, you can configure the settings via <code>oauthIssuerBaseUrl</code>, <code>oauthIssuerName</code>,
|
||||
and <code>oauthIssuerIcon</code> in the <code>config.ini</code> file. Alternatively,
|
||||
these values can be set using environment variables: <code>TRILIUM_OAUTH_ISSUER_BASE_URL</code>, <code>TRILIUM_OAUTH_ISSUER_NAME</code>,
|
||||
and <code>TRILIUM_OAUTH_ISSUER_ICON</code>. <code>oauthIssuerName</code> and <code>oauthIssuerIcon</code> are
|
||||
required for displaying correct issuer information at the Login page.</p>
|
||||
</aside>
|
||||
18
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text.html
generated
vendored
@ -142,17 +142,31 @@ class="table">
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>Indentation</li>
|
||||
<li>Indentation
|
||||
<ul>
|
||||
<li>Markdown import</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference-link" href="#root/_help_2x0ZAX9ePtzV">Cut to subnote</a>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_gLt3vA97tMcp">Premium features</a>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/gLt3vA97tMcp/_help_ZlN4nump6EbW">Slash Commands</a>
|
||||
</li>
|
||||
<li><a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_KC1HB96bqqHX">Templates</a>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
|
||||
<h2>Read-Only vs. Editing Mode</h2>
|
||||
<p>Text notes are usually opened in edit mode. However, they may open in
|
||||
read-only mode if the note is too big or the note is explicitly marked
|
||||
|
||||
17
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Premium features.html
generated
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
<p>The text editor we are using for <a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a> notes
|
||||
is called CKEditor and it's a commercial product. The core components are
|
||||
open-source, however they <a href="https://ckeditor.com/docs/trial/latest/index.html">offer quite a few features</a> that
|
||||
require a commercial license in order to be used.</p>
|
||||
<p>We have reached out to the CKEditor team in order to obtain a license
|
||||
in order to have some of these extra features and they have agreed, based
|
||||
on a signed agreement.</p>
|
||||
<h2>How the license works</h2>
|
||||
|
||||
<p>The license key is stored in the application and it enables the use of
|
||||
the previously described premium features. The license key has an expiration
|
||||
date which means that the features can become disabled if using an older
|
||||
version of the application for extended periods of time.</p>
|
||||
<h2>Can I opt out of these features?</h2>
|
||||
|
||||
<p>At this moment there is no way to disable this features, apart from manually
|
||||
modifying the source code. If this is a problem, <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">let us know</a>.</p>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Premium features/1_Text Snippets_image.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 377 B |
35
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Premium features/Slash Commands.html
generated
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
<figure class="image image-style-align-right">
|
||||
<img style="aspect-ratio:419/571" src="Slash Commands_image.png" width="419"
|
||||
height="571" />
|
||||
</figure>
|
||||
<aside class="admonition note">
|
||||
<p>This is a premium feature of the editor we are using (CKEditor) and we
|
||||
benefit from it thanks to an written agreement with the team. See <a class="reference-link"
|
||||
href="#root/_help_gLt3vA97tMcp">Premium features</a> for more information.</p>
|
||||
</aside>
|
||||
<p>Slash commands is a feature of <a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a> notes
|
||||
which allows easily accessing commonly used commands simply by using the
|
||||
keyboard, without having to remember dedicated <a class="reference-link"
|
||||
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/_help_A9Oc6YKKc65v">Keyboard Shortcuts</a>.</p>
|
||||
<h2>Interaction</h2>
|
||||
|
||||
<ul>
|
||||
<li>As the name suggests, to trigger the slash commands simply press the <kbd>/</kbd> key
|
||||
to trigger it. Note that this can be anywhere in a paragraph as long as
|
||||
it's not part of the word, if it doesn't show up simply press a space and
|
||||
press the <kbd>/</kbd> key again.</li>
|
||||
<li>Use <kbd>↑</kbd> and <kbd>↓</kbd> keys to navigate between options.</li>
|
||||
<li>By default, the full list of commands is displayed.</li>
|
||||
<li>To search by title or description, simply start typing for an action.</li>
|
||||
<li>To trigger an action, press the <kbd>Enter</kbd> key.</li>
|
||||
</ul>
|
||||
<h2>Integration with other features</h2>
|
||||
|
||||
<p>Apart from the common set of commands, some features are specially integrated
|
||||
with the slash commands:</p>
|
||||
<ul>
|
||||
<li>For <a href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_NwBbFdNZ9h7O">admonitions</a>,
|
||||
each admonition type (e.g. note, tip) will be individually displayed.</li>
|
||||
<li>Every <a class="reference-link" href="#root/_help_pwc194wlRzcH">Text Snippets</a> will
|
||||
also appear individually, making it easy to insert them.</li>
|
||||
</ul>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Premium features/Slash Commands_image.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 38 KiB |
56
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Premium features/Text Snippets.html
generated
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
<figure class="image image-style-align-right">
|
||||
<img style="aspect-ratio:265/108" src="Text Snippets_image.png" width="265"
|
||||
height="108" />
|
||||
</figure>
|
||||
<aside class="admonition note">
|
||||
<p>This is a premium feature of the editor we are using (CKEditor) and we
|
||||
benefit from it thanks to an written agreement with the team. See <a class="reference-link"
|
||||
href="#root/_help_gLt3vA97tMcp">Premium features</a> for more information.</p>
|
||||
</aside>
|
||||
<p>Text Snippets are closely related to <a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_KC1HB96bqqHX">Templates</a>,
|
||||
but instead of defining the content of an entire note, text snippets are
|
||||
pieces of formatted text that can easily be inserted in a text note.</p>
|
||||
<h2>Creating a text snippet</h2>
|
||||
|
||||
<p>In the <a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_oPVyFC7WL2Lp">Note Tree</a>: </p>
|
||||
<ol>
|
||||
<li>Right click a note where to place the text snippet.</li>
|
||||
<li>Select <em>Insert child note</em>.</li>
|
||||
<li>Select <em>Text snippet</em>.</li>
|
||||
</ol>
|
||||
<p>Afterwards, simply type in the content of the note the desired text. The
|
||||
text can be formatted in the same manner as a normal text note.</p>
|
||||
<p>The title of the note will become the title of the template. Optionally,
|
||||
a description can be added in the <a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_OFXdgB2nNk1F">Promoted Attributes</a> section.</p>
|
||||
<h2>Inserting a snippet</h2>
|
||||
|
||||
<p>Once a snippet is created, there are two options to insert it:</p>
|
||||
<ol>
|
||||
<li>From the <a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_nRhnJkTT8cPs">Formatting toolbar</a>,
|
||||
by looking for the
|
||||
<img src="1_Text Snippets_image.png" width="19" height="19"
|
||||
/>button.</li>
|
||||
<li>Using <a class="reference-link" href="#root/_help_ZlN4nump6EbW">Slash Commands</a>:
|
||||
<ol>
|
||||
<li>To look for a specific template, start typing the name of the template
|
||||
(its title).</li>
|
||||
<li>To look for all the templates, type <code>template</code>.</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<aside class="admonition tip">
|
||||
<p>A newly created snippet doesn't appear? Generally it takes up to a few
|
||||
seconds to refresh the list of templates once you make a change.</p>
|
||||
<p>If this doesn't happen, <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_s8alTXmpFR61">reload the application</a> and
|
||||
<a
|
||||
href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">report the issue</a>to us. </p>
|
||||
</aside>
|
||||
<h2>Limitations</h2>
|
||||
|
||||
<ul>
|
||||
<li>Whenever a snippet is created, deleted or its title/description are modified,
|
||||
all the open text notes will need to be refreshed. This causes a slight
|
||||
flash for usually under a second, but it can cause some discomfort.</li>
|
||||
<li>Unlike <a class="reference-link" href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_KC1HB96bqqHX">Templates</a>,
|
||||
the snippets cannot be limited to a particular <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_9sRHySam5fXb">workspace</a>.</li>
|
||||
</ul>
|
||||
BIN
apps/server/src/assets/doc_notes/en/User Guide/User Guide/Note Types/Text/Premium features/Text Snippets_image.png
generated
vendored
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
@ -135,7 +135,8 @@ body.electron:not(.native-titlebar) {
|
||||
<h2>Custom fonts</h2>
|
||||
<p>Currently the only way to include a custom font is to use <a href="#root/_help_d3fAXQ2diepH">Custom resource providers</a>.
|
||||
Basically import a font into Trilium and assign it <code>#customResourceProvider=fonts/myfont.ttf</code> and
|
||||
then import the font in CSS via <code>/custom/fonts/myfont.ttf</code>.</p>
|
||||
then import the font in CSS via <code>/custom/fonts/myfont.ttf</code>. Use <code>../../../custom/fonts/myfont.ttf</code> if
|
||||
you run your Trilium server on a different path than <code>/</code>.</p>
|
||||
<h2>Dark and light themes</h2>
|
||||
<p>A light theme needs to have the following CSS:</p><pre><code class="language-text-css">:root {
|
||||
--theme-style: light;
|
||||
|
||||
@ -48,7 +48,7 @@ import OpenAI from "openai";
|
||||
*/
|
||||
async function listModels(req: Request, res: Response) {
|
||||
try {
|
||||
const { baseUrl } = req.body;
|
||||
const { baseUrl } = req.body ?? {};
|
||||
|
||||
// Use provided base URL or default from options
|
||||
const openaiBaseUrl = baseUrl || await options.getOption('openaiBaseUrl') || 'https://api.openai.com/v1';
|
||||
|
||||
@ -118,7 +118,7 @@ function getRelationBundles(req: Request) {
|
||||
|
||||
function getBundle(req: Request) {
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
const { script, params } = req.body;
|
||||
const { script, params } = req.body ?? {};
|
||||
|
||||
return scriptService.getScriptBundleForFrontend(note, script, params);
|
||||
}
|
||||
|
||||
45
apps/server/src/routes/api/system_info.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { execSync } from "child_process";
|
||||
import { isMac, isWindows } from "../../services/utils";
|
||||
import { arch, cpus } from "os";
|
||||
|
||||
function systemChecks() {
|
||||
return {
|
||||
isCpuArchMismatch: isCpuArchMismatch()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the application is running under emulation on Apple Silicon or Windows on ARM.
|
||||
* This happens when an x64 version of the app is run on an M1/M2/M3 Mac or on a Windows Snapdragon chip.
|
||||
* @returns true if running on x86 emulation on ARM, false otherwise.
|
||||
*/
|
||||
export const isCpuArchMismatch = () => {
|
||||
if (isMac) {
|
||||
try {
|
||||
// Use child_process to check sysctl.proc_translated
|
||||
// This is the proper way to detect Rosetta 2 translation
|
||||
const result = execSync("sysctl -n sysctl.proc_translated 2>/dev/null", {
|
||||
encoding: "utf8",
|
||||
timeout: 1000
|
||||
}).trim();
|
||||
|
||||
// 1 means the process is being translated by Rosetta 2
|
||||
// 0 means native execution
|
||||
// If the sysctl doesn't exist (on Intel Macs), this will return empty/error
|
||||
return result === "1";
|
||||
} catch (error) {
|
||||
// If sysctl fails or doesn't exist (Intel Macs), not running under Rosetta 2
|
||||
return false;
|
||||
}
|
||||
} else if (isWindows && arch() === "x64") {
|
||||
return cpus().some(cpu =>
|
||||
cpu.model.includes('Microsoft SQ') ||
|
||||
cpu.model.includes('Snapdragon'));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
systemChecks
|
||||
};
|
||||
@ -36,7 +36,6 @@ async function register(app: express.Application) {
|
||||
|
||||
app.use(`/${assetUrlFragment}/src`, persistentCacheStatic(path.join(publicDir, "src")));
|
||||
app.use(`/${assetUrlFragment}/stylesheets`, persistentCacheStatic(path.join(publicDir, "stylesheets")));
|
||||
app.use(`/${assetUrlFragment}/libraries`, persistentCacheStatic(path.join(publicDir, "libraries")));
|
||||
app.use(`/${assetUrlFragment}/fonts`, persistentCacheStatic(path.join(publicDir, "fonts")));
|
||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
||||
@ -46,8 +45,6 @@ async function register(app: express.Application) {
|
||||
app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts")));
|
||||
app.use(`/assets/vX/images`, express.static(path.join(srcRoot, "..", "images")));
|
||||
app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets")));
|
||||
app.use(`/${assetUrlFragment}/libraries`, persistentCacheStatic(path.join(srcRoot, "public/libraries")));
|
||||
app.use(`/assets/vX/libraries`, express.static(path.join(srcRoot, "..", "libraries")));
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@ -58,6 +58,7 @@ import ollamaRoute from "./api/ollama.js";
|
||||
import openaiRoute from "./api/openai.js";
|
||||
import anthropicRoute from "./api/anthropic.js";
|
||||
import llmRoute from "./api/llm.js";
|
||||
import systemInfoRoute from "./api/system_info.js";
|
||||
|
||||
import etapiAuthRoutes from "../etapi/auth.js";
|
||||
import etapiAppInfoRoutes from "../etapi/app_info.js";
|
||||
@ -238,6 +239,7 @@ function register(app: express.Application) {
|
||||
apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote);
|
||||
apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo);
|
||||
apiRoute(GET, "/api/metrics", metricsRoute.getMetrics);
|
||||
apiRoute(GET, "/api/system-checks", systemInfoRoute.systemChecks);
|
||||
|
||||
// docker health check
|
||||
route(GET, "/api/health-check", [], () => ({ status: "ok" }), apiResultHandler);
|
||||
|
||||
@ -8,6 +8,7 @@ import migrationService from "./migration.js";
|
||||
import { t } from "i18next";
|
||||
import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js";
|
||||
import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js";
|
||||
import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js";
|
||||
|
||||
const LBTPL_ROOT = "_lbTplRoot";
|
||||
const LBTPL_BASE = "_lbTplBase";
|
||||
@ -257,7 +258,8 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
|
||||
icon: "bx-help-circle",
|
||||
children: helpSubtree,
|
||||
isExpanded: true
|
||||
}
|
||||
},
|
||||
buildHiddenSubtreeTemplates()
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
34
apps/server/src/services/hidden_subtree_templates.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { HiddenSubtreeItem } from "@triliumnext/commons";
|
||||
|
||||
export default function buildHiddenSubtreeTemplates() {
|
||||
const templates: HiddenSubtreeItem = {
|
||||
id: "_templates",
|
||||
title: "Built-in templates",
|
||||
type: "book",
|
||||
children: [
|
||||
{
|
||||
id: "_template_text_snippet",
|
||||
type: "text",
|
||||
title: "Text Snippet",
|
||||
icon: "bx-align-left",
|
||||
attributes: [
|
||||
{
|
||||
name: "template",
|
||||
type: "label"
|
||||
},
|
||||
{
|
||||
name: "textSnippet",
|
||||
type: "label"
|
||||
},
|
||||
{
|
||||
name: "label:textSnippetDescription",
|
||||
type: "label",
|
||||
value: "promoted,alias=Description,single,text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return templates;
|
||||
}
|
||||
@ -16,6 +16,7 @@ const CODE_MIME_TYPES = new Set([
|
||||
"text/x-c++src",
|
||||
"text/x-csrc",
|
||||
"text/x-dockerfile",
|
||||
"text/x-elixir",
|
||||
"text/x-erlang",
|
||||
"text/x-feature",
|
||||
"text/x-go",
|
||||
@ -55,6 +56,8 @@ const EXTENSION_TO_MIME = new Map<string, string>([
|
||||
[".cs", "text/x-csharp"],
|
||||
[".clj", "text/x-clojure"],
|
||||
[".erl", "text/x-erlang"],
|
||||
[".ex", "text/x-elixir"],
|
||||
[".exs", "text/x-elixir"],
|
||||
[".hrl", "text/x-erlang"],
|
||||
[".feature", "text/x-feature"],
|
||||
[".go", "text/x-go"],
|
||||
|
||||
@ -94,6 +94,83 @@ describe('configuration_helpers', () => {
|
||||
fullIdentifier: ''
|
||||
});
|
||||
});
|
||||
|
||||
// Tests for special characters in model names
|
||||
it('should handle model names with periods', () => {
|
||||
const result = configHelpers.parseModelIdentifier('gpt-4.1-turbo-preview');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
modelId: 'gpt-4.1-turbo-preview',
|
||||
fullIdentifier: 'gpt-4.1-turbo-preview'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model names with provider prefix and periods', () => {
|
||||
const result = configHelpers.parseModelIdentifier('openai:gpt-4.1-turbo');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
provider: 'openai',
|
||||
modelId: 'gpt-4.1-turbo',
|
||||
fullIdentifier: 'openai:gpt-4.1-turbo'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model names with multiple colons', () => {
|
||||
const result = configHelpers.parseModelIdentifier('custom:model:v1.2:latest');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
modelId: 'custom:model:v1.2:latest',
|
||||
fullIdentifier: 'custom:model:v1.2:latest'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Ollama model names with colons', () => {
|
||||
const result = configHelpers.parseModelIdentifier('ollama:llama3.1:70b-instruct-q4_K_M');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
provider: 'ollama',
|
||||
modelId: 'llama3.1:70b-instruct-q4_K_M',
|
||||
fullIdentifier: 'ollama:llama3.1:70b-instruct-q4_K_M'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model names with slashes', () => {
|
||||
const result = configHelpers.parseModelIdentifier('library/mistral:7b-instruct');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
modelId: 'library/mistral:7b-instruct',
|
||||
fullIdentifier: 'library/mistral:7b-instruct'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex model names with special characters', () => {
|
||||
const complexName = 'org/model-v1.2.3:tag@version#variant';
|
||||
const result = configHelpers.parseModelIdentifier(complexName);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
modelId: complexName,
|
||||
fullIdentifier: complexName
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model names with @ symbols', () => {
|
||||
const result = configHelpers.parseModelIdentifier('claude-3.5-sonnet@20241022');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
modelId: 'claude-3.5-sonnet@20241022',
|
||||
fullIdentifier: 'claude-3.5-sonnet@20241022'
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify or encode special characters', () => {
|
||||
const specialChars = 'model!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`';
|
||||
const result = configHelpers.parseModelIdentifier(specialChars);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
modelId: specialChars,
|
||||
fullIdentifier: specialChars
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createModelConfig', () => {
|
||||
@ -155,6 +232,34 @@ describe('configuration_helpers', () => {
|
||||
expect(result).toBe('llama2');
|
||||
expect(optionService.getOption).toHaveBeenCalledWith('ollamaDefaultModel');
|
||||
});
|
||||
|
||||
// Tests for special characters in model names
|
||||
it('should handle OpenAI model names with periods', async () => {
|
||||
const modelName = 'gpt-4.1-turbo-preview';
|
||||
vi.mocked(optionService.getOption).mockReturnValue(modelName);
|
||||
|
||||
const result = await configHelpers.getDefaultModelForProvider('openai');
|
||||
|
||||
expect(result).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle Anthropic model names with periods and @ symbols', async () => {
|
||||
const modelName = 'claude-3.5-sonnet@20241022';
|
||||
vi.mocked(optionService.getOption).mockReturnValue(modelName);
|
||||
|
||||
const result = await configHelpers.getDefaultModelForProvider('anthropic');
|
||||
|
||||
expect(result).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle Ollama model names with colons and slashes', async () => {
|
||||
const modelName = 'library/llama3.1:70b-instruct-q4_K_M';
|
||||
vi.mocked(optionService.getOption).mockReturnValue(modelName);
|
||||
|
||||
const result = await configHelpers.getDefaultModelForProvider('ollama');
|
||||
|
||||
expect(result).toBe(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderSettings', () => {
|
||||
@ -381,4 +486,122 @@ describe('configuration_helpers', () => {
|
||||
expect(() => configHelpers.clearConfigurationCache()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidModelConfig', () => {
|
||||
it('should handle model names with special characters', async () => {
|
||||
const modelName = 'gpt-4.1-turbo@latest';
|
||||
vi.mocked(optionService.getOption)
|
||||
.mockReturnValueOnce(modelName) // openaiDefaultModel
|
||||
.mockReturnValueOnce('test-key') // openaiApiKey
|
||||
.mockReturnValueOnce('') // openaiBaseUrl
|
||||
.mockReturnValueOnce(''); // openaiDefaultModel
|
||||
|
||||
const result = await configHelpers.getValidModelConfig('openai');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
model: modelName,
|
||||
provider: 'openai'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Anthropic model with complex naming', async () => {
|
||||
const modelName = 'claude-3.5-sonnet-20241022';
|
||||
vi.mocked(optionService.getOption)
|
||||
.mockReturnValueOnce(modelName) // anthropicDefaultModel
|
||||
.mockReturnValueOnce('anthropic-key') // anthropicApiKey
|
||||
.mockReturnValueOnce('') // anthropicBaseUrl
|
||||
.mockReturnValueOnce(''); // anthropicDefaultModel
|
||||
|
||||
const result = await configHelpers.getValidModelConfig('anthropic');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
model: modelName,
|
||||
provider: 'anthropic'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Ollama model with colons', async () => {
|
||||
const modelName = 'custom/llama3.1:70b-q4_K_M@latest';
|
||||
vi.mocked(optionService.getOption)
|
||||
.mockReturnValueOnce(modelName) // ollamaDefaultModel
|
||||
.mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl
|
||||
.mockReturnValueOnce(''); // ollamaDefaultModel
|
||||
|
||||
const result = await configHelpers.getValidModelConfig('ollama');
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
model: modelName,
|
||||
provider: 'ollama'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSelectedModelConfig', () => {
|
||||
it('should preserve OpenAI model names with special characters', async () => {
|
||||
const modelName = 'gpt-4.1-turbo-preview@2024';
|
||||
vi.mocked(optionService.getOption)
|
||||
.mockReturnValueOnce('openai') // aiSelectedProvider
|
||||
.mockReturnValueOnce(modelName) // openaiDefaultModel
|
||||
.mockReturnValueOnce('test-key') // openaiApiKey
|
||||
.mockReturnValueOnce('') // openaiBaseUrl
|
||||
.mockReturnValueOnce(''); // openaiDefaultModel
|
||||
|
||||
const result = await configHelpers.getSelectedModelConfig();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
model: modelName,
|
||||
provider: 'openai'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model names with URL-like patterns', async () => {
|
||||
const modelName = 'https://models.example.com/gpt-4.1';
|
||||
vi.mocked(optionService.getOption)
|
||||
.mockReturnValueOnce('openai') // aiSelectedProvider
|
||||
.mockReturnValueOnce(modelName) // openaiDefaultModel
|
||||
.mockReturnValueOnce('test-key') // openaiApiKey
|
||||
.mockReturnValueOnce('') // openaiBaseUrl
|
||||
.mockReturnValueOnce(''); // openaiDefaultModel
|
||||
|
||||
const result = await configHelpers.getSelectedModelConfig();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
model: modelName,
|
||||
provider: 'openai'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model names that look like file paths', async () => {
|
||||
const modelName = '/models/custom/gpt-4.1.safetensors';
|
||||
vi.mocked(optionService.getOption)
|
||||
.mockReturnValueOnce('ollama') // aiSelectedProvider
|
||||
.mockReturnValueOnce(modelName) // ollamaDefaultModel
|
||||
.mockReturnValueOnce('http://localhost:11434') // ollamaBaseUrl
|
||||
.mockReturnValueOnce(''); // ollamaDefaultModel
|
||||
|
||||
const result = await configHelpers.getSelectedModelConfig();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
model: modelName,
|
||||
provider: 'ollama'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model names with all possible special characters', async () => {
|
||||
const modelName = 'model!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`';
|
||||
vi.mocked(optionService.getOption)
|
||||
.mockReturnValueOnce('anthropic') // aiSelectedProvider
|
||||
.mockReturnValueOnce(modelName) // anthropicDefaultModel
|
||||
.mockReturnValueOnce('test-key') // anthropicApiKey
|
||||
.mockReturnValueOnce('') // anthropicBaseUrl
|
||||
.mockReturnValueOnce(''); // anthropicDefaultModel
|
||||
|
||||
const result = await configHelpers.getSelectedModelConfig();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
model: modelName,
|
||||
provider: 'anthropic'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,7 +11,7 @@ vi.mock('../../log.js', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Provider Streaming Integration Tests', () => {
|
||||
describe.skip('Provider Streaming Integration Tests', () => {
|
||||
let mockProviderOptions: ProviderStreamOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -479,9 +479,9 @@ describe('Provider Streaming Integration Tests', () => {
|
||||
});
|
||||
|
||||
describe('Memory Management', () => {
|
||||
it('should not leak memory during long streaming sessions', async () => {
|
||||
it.skip('should not leak memory during long streaming sessions', async () => {
|
||||
// Reduced chunk count for CI stability - still tests memory management
|
||||
const chunkCount = 1000; // Reduced from 10000
|
||||
const chunkCount = 500; // Reduced from 10000
|
||||
const longSessionIterator = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
|
||||
389
apps/server/src/services/llm/providers/model_selection.spec.ts
Normal file
@ -0,0 +1,389 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { OpenAIService } from './openai_service.js';
|
||||
import { AnthropicService } from './anthropic_service.js';
|
||||
import { OllamaService } from './ollama_service.js';
|
||||
import type { ChatCompletionOptions } from '../ai_interface.js';
|
||||
import * as providers from './providers.js';
|
||||
import options from '../../options.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../options.js', () => ({
|
||||
default: {
|
||||
getOption: vi.fn(),
|
||||
getOptionBool: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../log.js', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('openai', () => ({
|
||||
default: class MockOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: vi.fn()
|
||||
}
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => ({
|
||||
default: class MockAnthropic {
|
||||
messages = {
|
||||
create: vi.fn()
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('ollama', () => ({
|
||||
Ollama: class MockOllama {
|
||||
chat = vi.fn();
|
||||
show = vi.fn();
|
||||
}
|
||||
}));
|
||||
|
||||
describe('LLM Model Selection with Special Characters', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Set default options
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
const optionMap: Record<string, string> = {
|
||||
'aiEnabled': 'true',
|
||||
'aiTemperature': '0.7',
|
||||
'aiSystemPrompt': 'You are a helpful assistant.',
|
||||
'openaiApiKey': 'test-api-key',
|
||||
'openaiBaseUrl': 'https://api.openai.com/v1',
|
||||
'anthropicApiKey': 'test-anthropic-key',
|
||||
'anthropicBaseUrl': 'https://api.anthropic.com',
|
||||
'ollamaBaseUrl': 'http://localhost:11434'
|
||||
};
|
||||
return optionMap[key] || '';
|
||||
});
|
||||
vi.mocked(options.getOptionBool).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('OpenAI Model Names', () => {
|
||||
it('should correctly handle model names with periods', async () => {
|
||||
const modelName = 'gpt-4.1-turbo-preview';
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
if (key === 'openaiDefaultModel') return modelName;
|
||||
return '';
|
||||
});
|
||||
|
||||
const service = new OpenAIService();
|
||||
const opts: ChatCompletionOptions = {
|
||||
stream: false
|
||||
};
|
||||
|
||||
// Spy on getOpenAIOptions to verify model name is passed correctly
|
||||
const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions');
|
||||
|
||||
try {
|
||||
await service.generateChatCompletion([{ role: 'user', content: 'test' }], opts);
|
||||
} catch (error) {
|
||||
// Expected to fail due to mocked API
|
||||
}
|
||||
|
||||
expect(getOpenAIOptionsSpy).toHaveBeenCalledWith(opts);
|
||||
const result = getOpenAIOptionsSpy.mock.results[0].value;
|
||||
expect(result.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle model names with slashes', async () => {
|
||||
const modelName = 'openai/gpt-4/turbo-2024';
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
if (key === 'openaiDefaultModel') return modelName;
|
||||
return '';
|
||||
});
|
||||
|
||||
const service = new OpenAIService();
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions');
|
||||
|
||||
try {
|
||||
await service.generateChatCompletion([{ role: 'user', content: 'test' }], opts);
|
||||
} catch (error) {
|
||||
// Expected to fail due to mocked API
|
||||
}
|
||||
|
||||
const result = getOpenAIOptionsSpy.mock.results[0].value;
|
||||
expect(result.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle model names with colons', async () => {
|
||||
const modelName = 'custom:gpt-4:finetuned';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const getOpenAIOptionsSpy = vi.spyOn(providers, 'getOpenAIOptions');
|
||||
|
||||
const openaiOptions = providers.getOpenAIOptions(opts);
|
||||
expect(openaiOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle model names with underscores and hyphens', async () => {
|
||||
const modelName = 'gpt-4_turbo-preview_v2.1';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const openaiOptions = providers.getOpenAIOptions(opts);
|
||||
expect(openaiOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle model names with special characters in API request', async () => {
|
||||
const modelName = 'gpt-4.1-turbo@latest';
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
if (key === 'openaiDefaultModel') return modelName;
|
||||
if (key === 'openaiApiKey') return 'test-key';
|
||||
if (key === 'openaiBaseUrl') return 'https://api.openai.com/v1';
|
||||
return '';
|
||||
});
|
||||
|
||||
const service = new OpenAIService();
|
||||
|
||||
// Access the private openai client through the service
|
||||
const client = (service as any).getClient('test-key');
|
||||
const createSpy = vi.spyOn(client.chat.completions, 'create');
|
||||
|
||||
try {
|
||||
await service.generateChatCompletion(
|
||||
[{ role: 'user', content: 'test' }],
|
||||
{ stream: false }
|
||||
);
|
||||
} catch (error) {
|
||||
// Expected due to mock
|
||||
}
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: modelName
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anthropic Model Names', () => {
|
||||
it('should correctly handle Anthropic model names with periods', async () => {
|
||||
const modelName = 'claude-3.5-sonnet-20241022';
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
if (key === 'anthropicDefaultModel') return modelName;
|
||||
if (key === 'anthropicApiKey') return 'test-key';
|
||||
return '';
|
||||
});
|
||||
|
||||
const opts: ChatCompletionOptions = {
|
||||
stream: false
|
||||
};
|
||||
|
||||
const anthropicOptions = providers.getAnthropicOptions(opts);
|
||||
expect(anthropicOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle Anthropic model names with colons', async () => {
|
||||
const modelName = 'anthropic:claude-3:opus';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const anthropicOptions = providers.getAnthropicOptions(opts);
|
||||
expect(anthropicOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle Anthropic model names in API request', async () => {
|
||||
const modelName = 'claude-3.5-sonnet@beta';
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
if (key === 'anthropicDefaultModel') return modelName;
|
||||
if (key === 'anthropicApiKey') return 'test-key';
|
||||
if (key === 'anthropicBaseUrl') return 'https://api.anthropic.com';
|
||||
return '';
|
||||
});
|
||||
|
||||
const service = new AnthropicService();
|
||||
|
||||
// Access the private anthropic client
|
||||
const client = (service as any).getClient('test-key');
|
||||
const createSpy = vi.spyOn(client.messages, 'create');
|
||||
|
||||
try {
|
||||
await service.generateChatCompletion(
|
||||
[{ role: 'user', content: 'test' }],
|
||||
{ stream: false }
|
||||
);
|
||||
} catch (error) {
|
||||
// Expected due to mock
|
||||
}
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: modelName
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ollama Model Names', () => {
|
||||
it('should correctly handle Ollama model names with colons', async () => {
|
||||
const modelName = 'llama3.1:70b-instruct-q4_K_M';
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
if (key === 'ollamaDefaultModel') return modelName;
|
||||
if (key === 'ollamaBaseUrl') return 'http://localhost:11434';
|
||||
return '';
|
||||
});
|
||||
|
||||
const opts: ChatCompletionOptions = {
|
||||
stream: false
|
||||
};
|
||||
|
||||
const ollamaOptions = await providers.getOllamaOptions(opts);
|
||||
expect(ollamaOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle Ollama model names with slashes', async () => {
|
||||
const modelName = 'library/mistral:7b-instruct-v0.3';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const ollamaOptions = await providers.getOllamaOptions(opts);
|
||||
expect(ollamaOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle Ollama model names with special characters in options', async () => {
|
||||
const modelName = 'custom/llama3.1:70b-q4_K_M@latest';
|
||||
vi.mocked(options.getOption).mockImplementation((key: string) => {
|
||||
if (key === 'ollamaDefaultModel') return modelName;
|
||||
if (key === 'ollamaBaseUrl') return 'http://localhost:11434';
|
||||
return '';
|
||||
});
|
||||
|
||||
// Test that the model name is preserved in the options
|
||||
const opts: ChatCompletionOptions = {
|
||||
stream: false
|
||||
};
|
||||
|
||||
const ollamaOptions = await providers.getOllamaOptions(opts);
|
||||
expect(ollamaOptions.model).toBe(modelName);
|
||||
|
||||
// Also test with model specified in options
|
||||
const optsWithModel: ChatCompletionOptions = {
|
||||
model: 'another/model:v2.0@beta',
|
||||
stream: false
|
||||
};
|
||||
|
||||
const ollamaOptionsWithModel = await providers.getOllamaOptions(optsWithModel);
|
||||
expect(ollamaOptionsWithModel.model).toBe('another/model:v2.0@beta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Name Edge Cases', () => {
|
||||
it('should handle empty model names gracefully', () => {
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: '',
|
||||
stream: false
|
||||
};
|
||||
|
||||
expect(() => providers.getOpenAIOptions(opts)).toThrow('No OpenAI model configured');
|
||||
});
|
||||
|
||||
it('should handle model names with unicode characters', async () => {
|
||||
const modelName = 'gpt-4-日本語-model';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const openaiOptions = providers.getOpenAIOptions(opts);
|
||||
expect(openaiOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle model names with spaces (encoded)', async () => {
|
||||
const modelName = 'custom model v2.1';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const openaiOptions = providers.getOpenAIOptions(opts);
|
||||
expect(openaiOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should preserve exact model name without transformation', async () => {
|
||||
const complexModelName = 'org/model-v1.2.3:tag@version#variant';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: complexModelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
// Test for all providers
|
||||
const openaiOptions = providers.getOpenAIOptions(opts);
|
||||
expect(openaiOptions.model).toBe(complexModelName);
|
||||
|
||||
const anthropicOptions = providers.getAnthropicOptions(opts);
|
||||
expect(anthropicOptions.model).toBe(complexModelName);
|
||||
|
||||
const ollamaOptions = await providers.getOllamaOptions(opts);
|
||||
expect(ollamaOptions.model).toBe(complexModelName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Configuration Parsing', () => {
|
||||
it('should not confuse provider prefix with model name containing colons', async () => {
|
||||
// This model name has a colon but 'custom' is not a known provider
|
||||
const modelName = 'custom:model:v1.2';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const openaiOptions = providers.getOpenAIOptions(opts);
|
||||
expect(openaiOptions.model).toBe(modelName);
|
||||
});
|
||||
|
||||
it('should handle provider prefix correctly', async () => {
|
||||
// When model has provider prefix, it should still use the full string
|
||||
const modelName = 'openai:gpt-4.1-turbo';
|
||||
const opts: ChatCompletionOptions = {
|
||||
model: modelName,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const openaiOptions = providers.getOpenAIOptions(opts);
|
||||
expect(openaiOptions.model).toBe(modelName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with REST API', () => {
|
||||
it('should pass model names correctly through REST chat service', async () => {
|
||||
const modelName = 'gpt-4.1-turbo-preview@latest';
|
||||
|
||||
// Mock the configuration helpers
|
||||
vi.doMock('../config/configuration_helpers.js', () => ({
|
||||
getSelectedModelConfig: vi.fn().mockResolvedValue({
|
||||
model: modelName,
|
||||
provider: 'openai'
|
||||
}),
|
||||
isAIEnabled: vi.fn().mockResolvedValue(true)
|
||||
}));
|
||||
|
||||
const { getSelectedModelConfig } = await import('../config/configuration_helpers.js');
|
||||
const config = await getSelectedModelConfig();
|
||||
|
||||
expect(config?.model).toBe(modelName);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -73,7 +73,7 @@ function error(message: string | Error | unknown) {
|
||||
log(`ERROR: ${message}`);
|
||||
}
|
||||
|
||||
const requestBlacklist = ["/libraries", "/app", "/images", "/stylesheets", "/api/recent-notes"];
|
||||
const requestBlacklist = ["/app", "/images", "/stylesheets", "/api/recent-notes"];
|
||||
|
||||
function request(req: Request, res: Response, timeMs: number, responseLength: number | string = "?") {
|
||||
for (const bl of requestBlacklist) {
|
||||
|
||||
@ -98,7 +98,7 @@ const defaultOptions: DefaultOption[] = [
|
||||
{ name: "codeLineWrapEnabled", value: "true", isSynced: false },
|
||||
{
|
||||
name: "codeNotesMimeTypes",
|
||||
value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]',
|
||||
value: '["text/x-csrc","text/x-c++src","text/x-csharp","text/css","text/x-elixir","text/x-go","text/x-groovy","text/x-haskell","text/html","message/http","text/x-java","application/javascript;env=frontend","application/javascript;env=backend","application/json","text/x-kotlin","text/x-markdown","text/x-perl","text/x-php","text/x-python","text/x-ruby",null,"text/x-sql","text/x-sqlite;schema=trilium","text/x-swift","text/xml","text/x-yaml","text/x-sh","application/typescript"]',
|
||||
isSynced: true
|
||||
},
|
||||
{ name: "leftPaneWidth", value: "25", isSynced: false },
|
||||
|
||||
@ -257,7 +257,7 @@ async function configureWebContents(webContents: WebContents, spellcheckEnabled:
|
||||
}
|
||||
|
||||
function getIcon() {
|
||||
return path.join(RESOURCE_DIR, "images/app-icons/png/256x256" + (isDev ? "-dev" : "") + ".png");
|
||||
return path.join(RESOURCE_DIR, "../public/assets/icon.png");
|
||||
}
|
||||
|
||||
async function createSetupWindow() {
|
||||
|
||||
@ -11,10 +11,17 @@ export default defineConfig(() => ({
|
||||
setupFiles: ["./spec/setup.ts"],
|
||||
environment: "node",
|
||||
include: ['{src,spec}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
exclude: [
|
||||
"spec/build-checks/**",
|
||||
],
|
||||
reporters: [
|
||||
"verbose"
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
reporter: [ "text", "html" ]
|
||||
}
|
||||
},
|
||||
pool: "threads"
|
||||
},
|
||||
}));
|
||||
|
||||
18
apps/server/vitest.build.config.mts
Normal file
@ -0,0 +1,18 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/server',
|
||||
plugins: [],
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
setupFiles: ["./spec/setup.ts"],
|
||||
environment: "node",
|
||||
include: ['spec/build-checks/**'],
|
||||
reporters: [
|
||||
"verbose"
|
||||
]
|
||||
},
|
||||
}));
|
||||