mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-27 18:12:29 +08:00
Merge branch 'develop' into fix/nx-project-discovery
This commit is contained in:
commit
2fe34b1b0d
5
.github/workflows/nightly.yml
vendored
5
.github/workflows/nightly.yml
vendored
@ -11,6 +11,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- .github/actions/build-electron/*
|
- .github/actions/build-electron/*
|
||||||
|
- .github/workflows/nightly.yml
|
||||||
- forge.config.cjs
|
- forge.config.cjs
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@ -76,7 +77,7 @@ jobs:
|
|||||||
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
|
||||||
|
|
||||||
- name: Publish release
|
- 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' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
make_latest: false
|
make_latest: false
|
||||||
@ -116,7 +117,7 @@ jobs:
|
|||||||
arch: ${{ matrix.arch }}
|
arch: ${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Publish release
|
- 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' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
with:
|
with:
|
||||||
make_latest: false
|
make_latest: false
|
||||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -114,7 +114,7 @@ jobs:
|
|||||||
path: upload
|
path: upload
|
||||||
|
|
||||||
- name: Publish stable release
|
- name: Publish stable release
|
||||||
uses: softprops/action-gh-release@v2.2.2
|
uses: softprops/action-gh-release@v2.3.2
|
||||||
with:
|
with:
|
||||||
draft: false
|
draft: false
|
||||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||||
|
@ -51,8 +51,7 @@
|
|||||||
"mind-elixir": "4.6.0",
|
"mind-elixir": "4.6.0",
|
||||||
"normalize.css": "8.0.1",
|
"normalize.css": "8.0.1",
|
||||||
"panzoom": "9.4.3",
|
"panzoom": "9.4.3",
|
||||||
"react": "19.1.0",
|
"preact": "10.26.8",
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"split.js": "1.6.5",
|
"split.js": "1.6.5",
|
||||||
"svg-pan-zoom": "3.6.2",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"vanilla-js-wheel-zoom": "9.0.4"
|
"vanilla-js-wheel-zoom": "9.0.4"
|
||||||
@ -64,10 +63,8 @@
|
|||||||
"@types/leaflet": "1.9.18",
|
"@types/leaflet": "1.9.18",
|
||||||
"@types/leaflet-gpx": "1.3.7",
|
"@types/leaflet-gpx": "1.3.7",
|
||||||
"@types/mark.js": "8.11.12",
|
"@types/mark.js": "8.11.12",
|
||||||
"@types/react": "19.1.7",
|
|
||||||
"@types/react-dom": "19.1.6",
|
|
||||||
"copy-webpack-plugin": "13.0.0",
|
"copy-webpack-plugin": "13.0.0",
|
||||||
"happy-dom": "17.6.3",
|
"happy-dom": "18.0.1",
|
||||||
"script-loader": "0.7.2",
|
"script-loader": "0.7.2",
|
||||||
"vite-plugin-static-copy": "3.0.0"
|
"vite-plugin-static-copy": "3.0.0"
|
||||||
},
|
},
|
||||||
@ -75,7 +72,9 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"targets": {
|
"targets": {
|
||||||
"serve": {
|
"serve": {
|
||||||
"dependsOn": ["^build"]
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1333,7 +1333,7 @@
|
|||||||
"recovery_keys_used": "已使用: {{date}}",
|
"recovery_keys_used": "已使用: {{date}}",
|
||||||
"recovery_keys_unused": "恢复代码 {{index}} 未使用",
|
"recovery_keys_unused": "恢复代码 {{index}} 未使用",
|
||||||
"oauth_title": "OAuth/OpenID 认证",
|
"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_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_missing_vars": "缺少以下设置项: {{missingVars}}",
|
||||||
"oauth_user_account": "用户账号:",
|
"oauth_user_account": "用户账号:",
|
||||||
|
@ -1493,7 +1493,7 @@
|
|||||||
"recovery_keys_used": "Used: {{date}}",
|
"recovery_keys_used": "Used: {{date}}",
|
||||||
"recovery_keys_unused": "Recovery code {{index}} is unused",
|
"recovery_keys_unused": "Recovery code {{index}} is unused",
|
||||||
"oauth_title": "OAuth/OpenID",
|
"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_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_missing_vars": "Missing settings: {{variables}}",
|
||||||
"oauth_user_account": "User Account: ",
|
"oauth_user_account": "User Account: ",
|
||||||
|
2
apps/client/src/types-assets.d.ts
vendored
2
apps/client/src/types-assets.d.ts
vendored
@ -3,7 +3,7 @@ declare module "*.png" {
|
|||||||
export default path;
|
export default path;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@triliumnext/ckeditor5/emoji_definitions/en.json?url" {
|
declare module "*?url" {
|
||||||
var path: string;
|
var path: string;
|
||||||
export default path;
|
export default path;
|
||||||
}
|
}
|
||||||
|
2
apps/client/src/types.d.ts
vendored
2
apps/client/src/types.d.ts
vendored
@ -57,6 +57,8 @@ declare global {
|
|||||||
|
|
||||||
process?: ElectronProcess;
|
process?: ElectronProcess;
|
||||||
glob?: CustomGlobals;
|
glob?: CustomGlobals;
|
||||||
|
|
||||||
|
EXCALIDRAW_ASSET_PATH?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutoCompleteConfig {
|
interface AutoCompleteConfig {
|
||||||
|
@ -1,16 +1,11 @@
|
|||||||
import TypeWidget from "./type_widget.js";
|
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 server from "../../services/server.js";
|
||||||
import type FNote from "../../entities/fnote.js";
|
import type FNote from "../../entities/fnote.js";
|
||||||
import options from "../../services/options.js";
|
import options from "../../services/options.js";
|
||||||
import type { ExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
|
import type { LibraryItem } from "@excalidraw/excalidraw/types";
|
||||||
import type { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem, SceneData } from "@excalidraw/excalidraw/types";
|
import type { Theme } from "@excalidraw/excalidraw/element/types";
|
||||||
import type { JSX } from "react";
|
import type Canvas from "./canvas_el.js";
|
||||||
import type React from "react";
|
import { CanvasContent } from "./canvas_el.js";
|
||||||
import type { Root } from "react-dom/client";
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
|
||||||
import asset_path from "../../asset_path.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
const TPL = /*html*/`
|
||||||
<div class="canvas-widget note-detail-canvas note-detail-printable note-detail">
|
<div class="canvas-widget note-detail-canvas note-detail-printable note-detail">
|
||||||
@ -28,6 +23,7 @@ const TPL = /*html*/`
|
|||||||
|
|
||||||
.excalidraw-wrapper {
|
.excalidraw-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:root[dir="ltr"]
|
:root[dir="ltr"]
|
||||||
.excalidraw
|
.excalidraw
|
||||||
@ -51,11 +47,7 @@ const TPL = /*html*/`
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface CanvasContent {
|
|
||||||
elements: ExcalidrawElement[];
|
|
||||||
files: BinaryFileData[];
|
|
||||||
appState: Partial<AppState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AttachmentMetadata {
|
interface AttachmentMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
@ -107,37 +99,22 @@ interface AttachmentMetadata {
|
|||||||
*/
|
*/
|
||||||
export default class ExcalidrawTypeWidget extends TypeWidget {
|
export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||||
|
|
||||||
private readonly SCENE_VERSION_INITIAL: number;
|
|
||||||
private readonly SCENE_VERSION_ERROR: number;
|
|
||||||
|
|
||||||
private currentNoteId: string;
|
private currentNoteId: string;
|
||||||
private currentSceneVersion: number;
|
|
||||||
private libraryChanged: boolean;
|
private libraryChanged: boolean;
|
||||||
private librarycache: LibraryItem[];
|
private librarycache: LibraryItem[];
|
||||||
private attachmentMetadata: AttachmentMetadata[];
|
private attachmentMetadata: AttachmentMetadata[];
|
||||||
private themeStyle!: Theme;
|
private themeStyle!: Theme;
|
||||||
private excalidrawLib!: typeof import("@excalidraw/excalidraw");
|
|
||||||
private excalidrawApi!: ExcalidrawImperativeAPI;
|
|
||||||
private excalidrawWrapperRef!: React.RefObject<HTMLElement | null>;
|
|
||||||
|
|
||||||
private $render!: JQuery<HTMLElement>;
|
private $render!: JQuery<HTMLElement>;
|
||||||
private root?: Root;
|
|
||||||
private reactHandlers!: JQuery<HTMLElement>;
|
private reactHandlers!: JQuery<HTMLElement>;
|
||||||
|
private canvasInstance!: Canvas;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
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
|
// temporary vars
|
||||||
this.currentNoteId = "";
|
this.currentNoteId = "";
|
||||||
this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
|
|
||||||
|
|
||||||
// will be overwritten
|
// will be overwritten
|
||||||
this.$render;
|
this.$render;
|
||||||
@ -182,34 +159,48 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
throw new Error("Unable to find element to render.");
|
throw new Error("Unable to find element to render.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/excalidraw/excalidraw/issues/7899.
|
const Canvas = (await import("./canvas_el.js")).default;
|
||||||
if (!window.process) {
|
this.canvasInstance = new Canvas({
|
||||||
(window.process as any) = {};
|
// this makes sure that 1) manual theme switch button is hidden 2) theme stays as it should after opening menu
|
||||||
}
|
theme: this.themeStyle,
|
||||||
if (!window.process.env) {
|
onChange: () => this.onChangeHandler(),
|
||||||
window.process.env = {};
|
viewModeEnabled: options.is("databaseReadonly"),
|
||||||
}
|
zenModeEnabled: false,
|
||||||
(window.process.env as any).PREACT = false;
|
gridModeEnabled: false,
|
||||||
|
isCollaborating: false,
|
||||||
|
detectScroll: false,
|
||||||
|
handleKeyboardGlobally: false,
|
||||||
|
autoFocus: false,
|
||||||
|
UIOptions: {
|
||||||
|
canvasActions: {
|
||||||
|
saveToActiveFile: false,
|
||||||
|
export: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLibraryChange: () => {
|
||||||
|
this.libraryChanged = true;
|
||||||
|
|
||||||
const excalidraw = await import("@excalidraw/excalidraw");
|
this.saveData();
|
||||||
this.excalidrawLib = excalidraw;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { createRoot } = await import("react-dom/client");
|
await setupFonts();
|
||||||
const React = (await import("react")).default;
|
this.canvasInstance.renderCanvas(renderElement);
|
||||||
this.root?.unmount();
|
|
||||||
this.root = createRoot(renderElement);
|
|
||||||
this.root.render(React.createElement(() => this.createExcalidrawReactApp(React, excalidraw.Excalidraw)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* called to populate the widget container with the note content
|
* called to populate the widget container with the note content
|
||||||
*/
|
*/
|
||||||
async doRefresh(note: FNote) {
|
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
|
// see if the note changed, since we do not get a new class for a new note
|
||||||
const noteChanged = this.currentNoteId !== note.noteId;
|
const noteChanged = this.currentNoteId !== note.noteId;
|
||||||
if (noteChanged) {
|
if (noteChanged) {
|
||||||
// reset the scene to omit unnecessary onchange handler
|
// reset the scene to omit unnecessary onchange handler
|
||||||
this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
|
this.canvasInstance.resetSceneVersion();
|
||||||
}
|
}
|
||||||
this.currentNoteId = note.noteId;
|
this.currentNoteId = note.noteId;
|
||||||
|
|
||||||
@ -217,10 +208,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
const blob = await note.getBlob();
|
const blob = await note.getBlob();
|
||||||
|
|
||||||
// before we load content into excalidraw, make sure excalidraw has loaded
|
// before we load content into excalidraw, make sure excalidraw has loaded
|
||||||
while (!this.excalidrawApi) {
|
await this.canvasInstance.waitForApiToBecomeAvailable();
|
||||||
console.log("excalidrawApi not yet loaded, sleep 200ms...");
|
|
||||||
await utils.sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* new and empty note - make sure that canvas is empty.
|
* new and empty note - make sure that canvas is empty.
|
||||||
@ -229,15 +217,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
* newly instantiated?
|
* newly instantiated?
|
||||||
*/
|
*/
|
||||||
if (!blob?.content?.trim()) {
|
if (!blob?.content?.trim()) {
|
||||||
const sceneData: SceneData = {
|
this.canvasInstance.resetScene(this.themeStyle);
|
||||||
elements: [],
|
|
||||||
appState: {
|
|
||||||
theme: this.themeStyle
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Props mismatch.
|
|
||||||
this.excalidrawApi.updateScene(sceneData as any);
|
|
||||||
} else if (blob.content) {
|
} else if (blob.content) {
|
||||||
let content: CanvasContent;
|
let content: CanvasContent;
|
||||||
|
|
||||||
@ -254,36 +234,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { elements, files } = content;
|
this.canvasInstance.loadData(content, this.themeStyle);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
|
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
|
||||||
@ -310,23 +261,19 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
const metadata = results.map((result) => result.metadata);
|
const metadata = results.map((result) => result.metadata);
|
||||||
|
|
||||||
// Update the library and save to independent variables
|
// 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.
|
// save state of library to compare it to the new state later.
|
||||||
this.librarycache = libraryItems;
|
this.librarycache = libraryItems;
|
||||||
this.attachmentMetadata = metadata;
|
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
|
// set initial scene version
|
||||||
if (this.currentSceneVersion === this.SCENE_VERSION_INITIAL) {
|
if (this.canvasInstance.isInitialScene()) {
|
||||||
this.currentSceneVersion = this.getSceneVersion();
|
this.canvasInstance.updateSceneVersion();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,56 +282,14 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
* this is automatically called after this.saveData();
|
* this is automatically called after this.saveData();
|
||||||
*/
|
*/
|
||||||
async getData() {
|
async getData() {
|
||||||
const elements = this.excalidrawApi.getSceneElements();
|
const { content, svg } = await this.canvasInstance.getData();
|
||||||
const appState = this.excalidrawApi.getAppState();
|
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 }];
|
|
||||||
|
|
||||||
if (this.libraryChanged) {
|
if (this.libraryChanged) {
|
||||||
// this.libraryChanged is unset in dataSaved()
|
// this.libraryChanged is unset in dataSaved()
|
||||||
|
|
||||||
// there's no separate method to get library items, so have to abuse this one
|
// there's no separate method to get library items, so have to abuse this one
|
||||||
const libraryItems = await this.excalidrawApi.updateLibrary({
|
const libraryItems = await this.canvasInstance.getLibraryItems();
|
||||||
libraryItems() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
merge: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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.
|
// 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.
|
//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.
|
// 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.
|
// 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
|
* 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
|
* 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
|
// 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;
|
const shouldSave = isNewSceneVersion && isNotInitialScene;
|
||||||
|
|
||||||
if (shouldSave) {
|
if (shouldSave) {
|
||||||
this.updateSceneVersion();
|
this.canvasInstance.updateSceneVersion();
|
||||||
this.saveData();
|
this.saveData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createExcalidrawReactApp(react: typeof React, excalidrawComponent: React.MemoExoticComponent<(props: ExcalidrawProps) => JSX.Element>) {
|
}
|
||||||
const excalidrawWrapperRef = react.useRef<HTMLElement>(null);
|
|
||||||
this.excalidrawWrapperRef = excalidrawWrapperRef;
|
async function setupFonts() {
|
||||||
const [dimensions, setDimensions] = react.useState<{ width?: number; height?: number }>({
|
if (window.EXCALIDRAW_ASSET_PATH) {
|
||||||
width: undefined,
|
return;
|
||||||
height: undefined
|
}
|
||||||
});
|
|
||||||
|
// currently required by excalidraw, in order to allows self-hosting fonts locally.
|
||||||
react.useEffect(() => {
|
// this avoids making excalidraw load the fonts from an external CDN.
|
||||||
if (excalidrawWrapperRef.current) {
|
let path: string;
|
||||||
const dimensions = {
|
if (!glob.isDev) {
|
||||||
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
|
path = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
|
||||||
height: excalidrawWrapperRef.current.getBoundingClientRect().height
|
} else {
|
||||||
};
|
path = (await import("../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2?url")).default;
|
||||||
setDimensions(dimensions);
|
let pathComponents = path.split("/");
|
||||||
}
|
path = pathComponents.slice(0, pathComponents.length - 2).join("/");
|
||||||
|
}
|
||||||
const onResize = () => {
|
|
||||||
if (this.note?.type !== "canvas") {
|
window.EXCALIDRAW_ASSET_PATH = 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);
|
|
||||||
} else {
|
|
||||||
return this.SCENE_VERSION_ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSceneVersion() {
|
|
||||||
this.currentSceneVersion = this.getSceneVersion();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
179
apps/client/src/widgets/type_widgets/canvas_el.ts
Normal file
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -45,7 +45,7 @@ import { t } from "../../services/i18n.js";
|
|||||||
import LanguageOptions from "./options/i18n/language.js";
|
import LanguageOptions from "./options/i18n/language.js";
|
||||||
import type BasicWidget from "../basic_widget.js";
|
import type BasicWidget from "../basic_widget.js";
|
||||||
import CodeTheme from "./options/code_notes/code_theme.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">
|
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
|
||||||
<style>
|
<style>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import type FNote from "../../../entities/fnote";
|
import type { OptionPages } from "../../content_widget";
|
||||||
import type { OptionPages } from "../content_widget";
|
import OptionsWidget from "../options_widget";
|
||||||
import OptionsWidget from "./options_widget";
|
|
||||||
|
|
||||||
const TPL = `\
|
const TPL = `\
|
||||||
<div class="options-section">
|
<div class="options-section">
|
@ -43,11 +43,22 @@ export default defineConfig(() => ({
|
|||||||
{
|
{
|
||||||
find: "@triliumnext/highlightjs",
|
find: "@triliumnext/highlightjs",
|
||||||
replacement: resolve(__dirname, "node_modules/@triliumnext/highlightjs/dist")
|
replacement: resolve(__dirname, "node_modules/@triliumnext/highlightjs/dist")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
find: "react",
|
||||||
|
replacement: "preact/compat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
find: "react-dom",
|
||||||
|
replacement: "preact/compat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
dedupe: [
|
dedupe: [
|
||||||
"react",
|
"react",
|
||||||
"react-dom"
|
"react-dom",
|
||||||
|
"preact",
|
||||||
|
"preact/compat",
|
||||||
|
"preact/hooks"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Uncomment this if you are using workers.
|
// Uncomment this if you are using workers.
|
||||||
@ -59,7 +70,7 @@ export default defineConfig(() => ({
|
|||||||
outDir: './dist',
|
outDir: './dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
reportCompressedSize: true,
|
reportCompressedSize: true,
|
||||||
sourcemap: process.env.NODE_ENV === "production",
|
sourcemap: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
desktop: join(__dirname, "src", "desktop.ts"),
|
desktop: join(__dirname, "src", "desktop.ts"),
|
||||||
@ -97,5 +108,8 @@ export default defineConfig(() => ({
|
|||||||
},
|
},
|
||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
transformMixedEsModules: true,
|
transformMixedEsModules: true,
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs-extra");
|
const fs = require("fs-extra");
|
||||||
|
const { LOCALES } = require("@triliumnext/commons");
|
||||||
|
|
||||||
const ELECTRON_FORGE_DIR = __dirname;
|
const ELECTRON_FORGE_DIR = __dirname;
|
||||||
|
|
||||||
@ -141,6 +142,35 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
|
// Remove unused locales from the packaged app to save some space.
|
||||||
|
postPackage(_, packageResult) {
|
||||||
|
const localesToKeep = LOCALES
|
||||||
|
.filter(locale => !locale.contentOnly)
|
||||||
|
.map(locale => locale.electronLocale.replace("_", "-"));
|
||||||
|
|
||||||
|
for (const outputPath of packageResult.outputPaths) {
|
||||||
|
const localesDir = path.join(outputPath, 'locales');
|
||||||
|
|
||||||
|
if (!fs.existsSync(localesDir)) {
|
||||||
|
console.log('No locales directory found. Skipping cleanup.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(localesDir);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const localeName = path.basename(file, ".pak");
|
||||||
|
if (localesToKeep.includes(localeName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Removing unused locale file: ${file}`);
|
||||||
|
const filePath = path.join(localesDir, file);
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Gather all the artifacts produced by the makers and copy them to a common upload directory.
|
||||||
postMake(_, makeResults) {
|
postMake(_, makeResults) {
|
||||||
const outputDir = path.join(__dirname, "..", "upload");
|
const outputDir = path.join(__dirname, "..", "upload");
|
||||||
fs.mkdirpSync(outputDir);
|
fs.mkdirpSync(outputDir);
|
||||||
|
@ -48,6 +48,17 @@
|
|||||||
"outputs": [
|
"outputs": [
|
||||||
"{options.outputPath}"
|
"{options.outputPath}"
|
||||||
],
|
],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"minify": true,
|
||||||
|
"sourcemap": false
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"minify": false,
|
||||||
|
"sourcemap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"main": "apps/desktop/src/electron-main.ts",
|
"main": "apps/desktop/src/electron-main.ts",
|
||||||
"outputPath": "apps/desktop/dist",
|
"outputPath": "apps/desktop/dist",
|
||||||
@ -63,10 +74,8 @@
|
|||||||
"format": [
|
"format": [
|
||||||
"cjs"
|
"cjs"
|
||||||
],
|
],
|
||||||
"minify": true,
|
|
||||||
"thirdParty": true,
|
"thirdParty": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"sourcemap": true,
|
|
||||||
"esbuildOptions": {
|
"esbuildOptions": {
|
||||||
"splitting": false,
|
"splitting": false,
|
||||||
"loader": {
|
"loader": {
|
||||||
|
@ -24,6 +24,9 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "../server/tsconfig.app.json"
|
"path": "../server/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../packages/commons/tsconfig.lib.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../server"
|
"path": "../server"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../packages/commons"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.app.json"
|
"path": "./tsconfig.app.json"
|
||||||
}
|
}
|
||||||
|
@ -268,6 +268,17 @@
|
|||||||
"^build",
|
"^build",
|
||||||
"client:build"
|
"client:build"
|
||||||
],
|
],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"minify": true,
|
||||||
|
"sourcemap": false
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"minify": false,
|
||||||
|
"sourcemap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"main": "apps/server/src/main.ts",
|
"main": "apps/server/src/main.ts",
|
||||||
"outputPath": "apps/server/dist",
|
"outputPath": "apps/server/dist",
|
||||||
@ -283,10 +294,8 @@
|
|||||||
"cjs"
|
"cjs"
|
||||||
],
|
],
|
||||||
"declarationRootDir": "apps/server/src",
|
"declarationRootDir": "apps/server/src",
|
||||||
"minify": true,
|
|
||||||
"thirdParty": true,
|
"thirdParty": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"sourcemap": true,
|
|
||||||
"esbuildOptions": {
|
"esbuildOptions": {
|
||||||
"splitting": false,
|
"splitting": false,
|
||||||
"loader": {
|
"loader": {
|
||||||
|
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
2
apps/server/src/assets/doc_notes/en/User Guide/!!!meta.json
generated
vendored
File diff suppressed because one or more lines are too long
@ -46,7 +46,7 @@
|
|||||||
variable to something larger than the integer <code>250</code> (e.g. <code>450</code> in
|
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>
|
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>
|
<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>
|
<h2>Reverse Proxy Setup</h2>
|
||||||
<p>To configure a reverse proxy for Trilium, you can use either <strong>nginx</strong> or <strong>Apache</strong>.
|
<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>
|
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>
|
||||||
<li>
|
<li>
|
||||||
<p>Fill the file with the context shown below, part of the setting show be
|
<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 {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name trilium.example.net; #change trilium.example.net to your domain without HTTPS or HTTP.
|
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 X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "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_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
|
<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>
|
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 {
|
</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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "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_cookie_path / /trilium/instance-one
|
||||||
proxy_read_timeout 90;
|
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>
|
</code></pre>
|
||||||
</li>
|
</li>
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
by adding the following to <code>config.ini</code>:</p><pre><code class="language-text-x-trilium-auto">[General]
|
by adding the following to <code>config.ini</code>:</p><pre><code class="language-text-x-trilium-auto">[General]
|
||||||
noAuthentication=true</code></pre>
|
noAuthentication=true</code></pre>
|
||||||
<p>Disabling authentication will bypass even the <a class="reference-link"
|
<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>
|
v0.94.1.</p>
|
||||||
<h2>Understanding how the session works</h2>
|
<h2>Understanding how the session works</h2>
|
||||||
<p>Once logged into Trilium, the application will store this information
|
<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>
|
the <em>last interaction with the application</em>.</p>
|
||||||
<h2>Viewing active sessions</h2>
|
<h2>Viewing active sessions</h2>
|
||||||
<p>The login sessions are now stored in the same <a class="reference-link"
|
<p>The login sessions are now stored in the same <a class="reference-link"
|
||||||
href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_wX4HbRucYSDD">Database</a> as
|
href="#root/_help_wX4HbRucYSDD">Database</a> as the user data. In
|
||||||
the user data. In order to view which sessions are active, open the
|
order to view which sessions are active, open the <a class="reference-link"
|
||||||
<a
|
href="#root/_help_YKWqdJhzi2VY">SQL Console</a> and run the following
|
||||||
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>
|
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
|
<p>Expired sessions are periodically cleaned by the server, generally an
|
||||||
hourly interval.</p>
|
hourly interval.</p>
|
||||||
<h2>See also</h2>
|
<h2>See also</h2>
|
||||||
<ul>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
@ -41,10 +41,6 @@ class="admonition warning">
|
|||||||
the page).</li>
|
the page).</li>
|
||||||
</ol>
|
</ol>
|
||||||
<h3>OpenID</h3>
|
<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.
|
<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
|
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>
|
setup an OpenID service through google.</p>
|
||||||
@ -61,4 +57,12 @@ class="admonition warning">
|
|||||||
<li>Click the “Enable Multi-Factor Authentication” checkbox if not checked</li>
|
<li>Click the “Enable Multi-Factor Authentication” checkbox if not checked</li>
|
||||||
<li>Choose “OAuth/OpenID” under MFA Method</li>
|
<li>Choose “OAuth/OpenID” under MFA Method</li>
|
||||||
<li>Refresh the page and login through OpenID provider</li>
|
<li>Refresh the page and login through OpenID provider</li>
|
||||||
</ol>
|
</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>
|
@ -135,7 +135,8 @@ body.electron:not(.native-titlebar) {
|
|||||||
<h2>Custom fonts</h2>
|
<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>.
|
<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
|
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>
|
<h2>Dark and light themes</h2>
|
||||||
<p>A light theme needs to have the following CSS:</p><pre><code class="language-text-css">:root {
|
<p>A light theme needs to have the following CSS:</p><pre><code class="language-text-css">:root {
|
||||||
--theme-style: light;
|
--theme-style: light;
|
||||||
|
@ -94,6 +94,83 @@ describe('configuration_helpers', () => {
|
|||||||
fullIdentifier: ''
|
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', () => {
|
describe('createModelConfig', () => {
|
||||||
@ -155,6 +232,34 @@ describe('configuration_helpers', () => {
|
|||||||
expect(result).toBe('llama2');
|
expect(result).toBe('llama2');
|
||||||
expect(optionService.getOption).toHaveBeenCalledWith('ollamaDefaultModel');
|
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', () => {
|
describe('getProviderSettings', () => {
|
||||||
@ -381,4 +486,122 @@ describe('configuration_helpers', () => {
|
|||||||
expect(() => configHelpers.clearConfigurationCache()).not.toThrow();
|
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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
389
apps/server/src/services/llm/providers/model_selection.spec.ts
Normal file
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
2
docs/Developer Guide/!!!meta.json
vendored
2
docs/Developer Guide/!!!meta.json
vendored
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 2,
|
"formatVersion": 2,
|
||||||
"appVersion": "0.94.0",
|
"appVersion": "0.94.1",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"isClone": false,
|
"isClone": false,
|
||||||
|
2
docs/Release Notes/!!!meta.json
vendored
2
docs/Release Notes/!!!meta.json
vendored
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 2,
|
"formatVersion": 2,
|
||||||
"appVersion": "0.94.0",
|
"appVersion": "0.94.1",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"isClone": false,
|
"isClone": false,
|
||||||
|
2
docs/Release Notes/Release Notes/v0.94.1.md
vendored
2
docs/Release Notes/Release Notes/v0.94.1.md
vendored
@ -1,6 +1,6 @@
|
|||||||
# v0.94.1
|
# v0.94.1
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> *TriliumNext Notes* will rebrand itself back to Trilium Notes since @zadam was kind enough to give us the original name. See [#2190](https://github.com/orgs/TriliumNext/discussions/2190) for more info. This will probably be the "last" version branded as *TriliumNext Notes*.
|
> _TriliumNext Notes_ will rebrand itself back to Trilium Notes since @zadam was kind enough to give us the original name. See [#2190](https://github.com/orgs/TriliumNext/discussions/2190) for more info. This will probably be the "last" version branded as _TriliumNext Notes_.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> If you enjoyed this release, consider showing a token of appreciation by:
|
> If you enjoyed this release, consider showing a token of appreciation by:
|
||||||
|
51
docs/User Guide/!!!meta.json
vendored
51
docs/User Guide/!!!meta.json
vendored
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 2,
|
"formatVersion": 2,
|
||||||
"appVersion": "0.94.0",
|
"appVersion": "0.94.1",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"isClone": false,
|
"isClone": false,
|
||||||
@ -189,23 +189,23 @@
|
|||||||
{
|
{
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"name": "internalLink",
|
"name": "internalLink",
|
||||||
"value": "fDLvzOx29Pfg",
|
"value": "0hzsNCP31IAB",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 120
|
"position": 120
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "relation",
|
||||||
|
"name": "internalLink",
|
||||||
|
"value": "fDLvzOx29Pfg",
|
||||||
|
"isInheritable": false,
|
||||||
|
"position": 130
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "label",
|
"type": "label",
|
||||||
"name": "shareAlias",
|
"name": "shareAlias",
|
||||||
"value": "server-installation",
|
"value": "server-installation",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 30
|
"position": 10
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "relation",
|
|
||||||
"name": "internalLink",
|
|
||||||
"value": "0hzsNCP31IAB",
|
|
||||||
"isInheritable": false,
|
|
||||||
"position": 130
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
@ -269,14 +269,14 @@
|
|||||||
"name": "shareAlias",
|
"name": "shareAlias",
|
||||||
"value": "packaged-server-installation",
|
"value": "packaged-server-installation",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 20
|
"position": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "label",
|
"type": "label",
|
||||||
"name": "iconClass",
|
"name": "iconClass",
|
||||||
"value": "bx bxl-tux",
|
"value": "bx bxl-tux",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 30
|
"position": 20
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
@ -674,32 +674,32 @@
|
|||||||
"mime": "text/html",
|
"mime": "text/html",
|
||||||
"attributes": [
|
"attributes": [
|
||||||
{
|
{
|
||||||
"type": "label",
|
"type": "relation",
|
||||||
"name": "iconClass",
|
"name": "internalLink",
|
||||||
"value": "bx bx-lock-alt",
|
"value": "wX4HbRucYSDD",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 10
|
"position": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"name": "internalLink",
|
"name": "internalLink",
|
||||||
"value": "7DAiwaf8Z7Rz",
|
"value": "YKWqdJhzi2VY",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 20
|
"position": 20
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"name": "internalLink",
|
"name": "internalLink",
|
||||||
"value": "wX4HbRucYSDD",
|
"value": "7DAiwaf8Z7Rz",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 30
|
"position": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "relation",
|
"type": "label",
|
||||||
"name": "internalLink",
|
"name": "iconClass",
|
||||||
"value": "YKWqdJhzi2VY",
|
"value": "bx bx-lock-alt",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 40
|
"position": 10
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
@ -736,19 +736,12 @@
|
|||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 10
|
"position": 10
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "label",
|
|
||||||
"name": "shareHiddenFromTree",
|
|
||||||
"value": "",
|
|
||||||
"isInheritable": false,
|
|
||||||
"position": 20
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "label",
|
"type": "label",
|
||||||
"name": "iconClass",
|
"name": "iconClass",
|
||||||
"value": "bx bx-stopwatch",
|
"value": "bx bx-stopwatch",
|
||||||
"isInheritable": false,
|
"isInheritable": false,
|
||||||
"position": 30
|
"position": 20
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
|
@ -73,4 +73,4 @@ Configure Nginx proxy and HTTPS. The operating system here is Ubuntu 18.04.
|
|||||||
proxy_read_timeout 90;
|
proxy_read_timeout 90;
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
@ -27,7 +27,7 @@ When “Remember me” is unchecked, the behavior is different. At client/browse
|
|||||||
|
|
||||||
The login sessions are now stored in the same <a class="reference-link" href="../../Advanced%20Usage/Database.md">Database</a> as the user data. In order to view which sessions are active, open the <a class="reference-link" href="../../Advanced%20Usage/Database/Manually%20altering%20the%20database/SQL%20Console.md">SQL Console</a> and run the following query:
|
The login sessions are now stored in the same <a class="reference-link" href="../../Advanced%20Usage/Database.md">Database</a> as the user data. In order to view which sessions are active, open the <a class="reference-link" href="../../Advanced%20Usage/Database/Manually%20altering%20the%20database/SQL%20Console.md">SQL Console</a> and run the following query:
|
||||||
|
|
||||||
```trilium
|
```
|
||||||
SELECT * FROM sessions
|
SELECT * FROM sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -34,9 +34,6 @@ MFA can only be set up on a server instance.
|
|||||||
|
|
||||||
### OpenID
|
### OpenID
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Currently only compatible with Google. Other services like Authentik and Auth0 are planned on being added.
|
|
||||||
|
|
||||||
In order to setup OpenID, you will need to setup a authentication provider. This requires a bit of extra setup. Follow [these instructions](https://developers.google.com/identity/openid-connect/openid-connect) to setup an OpenID service through google.
|
In order to setup OpenID, you will need to setup a authentication provider. This requires a bit of extra setup. Follow [these instructions](https://developers.google.com/identity/openid-connect/openid-connect) to setup an OpenID service through google.
|
||||||
|
|
||||||
1. Set the `oauthBaseUrl`, `oauthClientId` and `oauthClientSecret` in the `config.ini` file (check <a class="reference-link" href="../../Advanced%20Usage/Configuration%20(config.ini%20or%20e.md">Configuration (config.ini or environment variables)</a> for more information).
|
1. Set the `oauthBaseUrl`, `oauthClientId` and `oauthClientSecret` in the `config.ini` file (check <a class="reference-link" href="../../Advanced%20Usage/Configuration%20(config.ini%20or%20e.md">Configuration (config.ini or environment variables)</a> for more information).
|
||||||
@ -45,4 +42,7 @@ In order to setup OpenID, you will need to setup a authentication provider. This
|
|||||||
3. Go to "Menu" -> "Options" -> "MFA"
|
3. Go to "Menu" -> "Options" -> "MFA"
|
||||||
4. Click the “Enable Multi-Factor Authentication” checkbox if not checked
|
4. Click the “Enable Multi-Factor Authentication” checkbox if not checked
|
||||||
5. Choose “OAuth/OpenID” under MFA Method
|
5. Choose “OAuth/OpenID” under MFA Method
|
||||||
6. Refresh the page and login through OpenID provider
|
6. Refresh the page and login through OpenID provider
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The default OAuth issuer is Google. To use other services such as Authentik or Auth0, you can configure the settings via `oauthIssuerBaseUrl`, `oauthIssuerName`, and `oauthIssuerIcon` in the `config.ini` file. Alternatively, these values can be set using environment variables: `TRILIUM_OAUTH_ISSUER_BASE_URL`, `TRILIUM_OAUTH_ISSUER_NAME`, and `TRILIUM_OAUTH_ISSUER_ICON`. `oauthIssuerName` and `oauthIssuerIcon` are required for displaying correct issuer information at the Login page.
|
@ -195,4 +195,4 @@ If the theme is auto (e.g. supports both light or dark based on `prefers-color-s
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This will affect the behavior of the Electron application by informing the operating system of the color preference (e.g. background effects will appear correct on Windows).
|
This will affect the behavior of the Electron application by informing the operating system of the color preference (e.g. background effects will appear correct on Windows).
|
@ -49,7 +49,7 @@
|
|||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
"eslint-config-prettier": "^10.0.0",
|
"eslint-config-prettier": "^10.0.0",
|
||||||
"eslint-plugin-playwright": "^2.0.0",
|
"eslint-plugin-playwright": "^2.0.0",
|
||||||
"happy-dom": "~17.6.0",
|
"happy-dom": "~18.0.0",
|
||||||
"jiti": "2.4.2",
|
"jiti": "2.4.2",
|
||||||
"jsdom": "~26.1.0",
|
"jsdom": "~26.1.0",
|
||||||
"jsonc-eslint-parser": "^2.1.0",
|
"jsonc-eslint-parser": "^2.1.0",
|
||||||
@ -92,6 +92,9 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"node-abi": "4.9.0",
|
"node-abi": "4.9.0",
|
||||||
|
"mermaid": "11.6.0",
|
||||||
|
"preact": "10.26.8",
|
||||||
|
"roughjs": "4.6.6",
|
||||||
"@types/express-serve-static-core": "5.0.6",
|
"@types/express-serve-static-core": "5.0.6",
|
||||||
"flat@<5.0.1": ">=5.0.1",
|
"flat@<5.0.1": ">=5.0.1",
|
||||||
"debug@>=3.2.0 <3.2.7": ">=3.2.7",
|
"debug@>=3.2.0 <3.2.7": ">=3.2.7",
|
||||||
|
@ -2,71 +2,144 @@
|
|||||||
* https://github.com/TriliumNext/Notes/issues/1002
|
* https://github.com/TriliumNext/Notes/issues/1002
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Command, DocumentSelection, Element, Node, Plugin } from 'ckeditor5';
|
import { Command, DocumentSelection, Element, Node, Plugin, Range } from 'ckeditor5';
|
||||||
|
|
||||||
export default class MoveBlockUpDownPlugin extends Plugin {
|
export default class MoveBlockUpDownPlugin extends Plugin {
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const editor = this.editor;
|
const editor = this.editor;
|
||||||
editor.config.define('moveBlockUp', {
|
|
||||||
keystroke: ['ctrl+arrowup', 'alt+arrowup'],
|
|
||||||
});
|
|
||||||
editor.config.define('moveBlockDown', {
|
|
||||||
keystroke: ['ctrl+arrowdown', 'alt+arrowdown'],
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.commands.add('moveBlockUp', new MoveBlockUpCommand(editor));
|
editor.commands.add('moveBlockUp', new MoveBlockUpCommand(editor));
|
||||||
editor.commands.add('moveBlockDown', new MoveBlockDownCommand(editor));
|
editor.commands.add('moveBlockDown', new MoveBlockDownCommand(editor));
|
||||||
|
|
||||||
for (const keystroke of editor.config.get('moveBlockUp.keystroke') ?? []) {
|
// Use native DOM capturing to intercept Ctrl/Alt + ↑/↓,
|
||||||
editor.keystrokes.set(keystroke, 'moveBlockUp');
|
// as plugin-level keystroke handling may fail when the selection is near an object.
|
||||||
}
|
this.bindMoveBlockShortcuts(editor);
|
||||||
for (const keystroke of editor.config.get('moveBlockDown.keystroke') ?? []) {
|
|
||||||
editor.keystrokes.set(keystroke, 'moveBlockDown');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bindMoveBlockShortcuts(editor: any) {
|
||||||
|
editor.editing.view.once('render', () => {
|
||||||
|
const domRoot = editor.editing.view.getDomRoot();
|
||||||
|
if (!domRoot) return;
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
const keyMap = {
|
||||||
|
ArrowUp: 'moveBlockUp',
|
||||||
|
ArrowDown: 'moveBlockDown'
|
||||||
|
};
|
||||||
|
|
||||||
|
const command = keyMap[e.key];
|
||||||
|
const isCtrl = e.ctrlKey || e.metaKey;
|
||||||
|
const hasModifier = (isCtrl || e.altKey) && !(isCtrl && e.altKey);
|
||||||
|
|
||||||
|
if (command && hasModifier) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
editor.execute(command);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
domRoot.addEventListener('keydown', handleKeydown, { capture: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class MoveBlockUpDownCommand extends Command {
|
abstract class MoveBlockUpDownCommand extends Command {
|
||||||
|
|
||||||
abstract getSelectedBlocks(selection: DocumentSelection): Element[];
|
|
||||||
abstract getSibling(selectedBlock: Element): Node | null;
|
abstract getSibling(selectedBlock: Element): Node | null;
|
||||||
abstract get offset(): "before" | "after";
|
abstract get offset(): "before" | "after";
|
||||||
|
|
||||||
override refresh() {
|
|
||||||
const selection = this.editor.model.document.selection;
|
|
||||||
const selectedBlocks = this.getSelectedBlocks(selection);
|
|
||||||
|
|
||||||
this.isEnabled = true;
|
|
||||||
for (const selectedBlock of selectedBlocks) {
|
|
||||||
if (!this.getSibling(selectedBlock)) this.isEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override execute() {
|
override execute() {
|
||||||
const model = this.editor.model;
|
const model = this.editor.model;
|
||||||
const selection = model.document.selection;
|
const selection = model.document.selection;
|
||||||
const selectedBlocks = this.getSelectedBlocks(selection);
|
const selectedBlocks = this.getSelectedBlocks(selection);
|
||||||
|
const isEnabled = selectedBlocks.length > 0
|
||||||
|
&& selectedBlocks.every(block => !!this.getSibling(block));
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movingBlocks = this.offset === 'before'
|
||||||
|
? selectedBlocks
|
||||||
|
: [...selectedBlocks].reverse();
|
||||||
|
|
||||||
|
// Store selection offsets
|
||||||
|
const firstBlock = selectedBlocks[0];
|
||||||
|
const lastBlock = selectedBlocks[selectedBlocks.length - 1];
|
||||||
|
const startOffset = model.document.selection.getFirstPosition()?.offset ?? 0;
|
||||||
|
const endOffset = model.document.selection.getLastPosition()?.offset ?? 0;
|
||||||
|
|
||||||
model.change((writer) => {
|
model.change((writer) => {
|
||||||
for (const selectedBlock of selectedBlocks) {
|
// Move blocks
|
||||||
const sibling = this.getSibling(selectedBlock);
|
for (const block of movingBlocks) {
|
||||||
|
const sibling = this.getSibling(block);
|
||||||
if (sibling) {
|
if (sibling) {
|
||||||
const range = model.createRangeOn(selectedBlock);
|
const range = model.createRangeOn(block);
|
||||||
writer.move(range, sibling, this.offset);
|
writer.move(range, sibling, this.offset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore selection
|
||||||
|
let range: Range;
|
||||||
|
const maxStart = firstBlock.maxOffset ?? startOffset;
|
||||||
|
const maxEnd = lastBlock.maxOffset ?? endOffset;
|
||||||
|
// If original offsets valid within bounds, restore partial selection
|
||||||
|
if (startOffset <= maxStart && endOffset <= maxEnd) {
|
||||||
|
const clampedStart = Math.min(startOffset, maxStart);
|
||||||
|
const clampedEnd = Math.min(endOffset, maxEnd);
|
||||||
|
range = writer.createRange(
|
||||||
|
writer.createPositionAt(firstBlock, clampedStart),
|
||||||
|
writer.createPositionAt(lastBlock, clampedEnd)
|
||||||
|
);
|
||||||
|
} else { // Fallback: select entire moved blocks (handles tables)
|
||||||
|
range = writer.createRange(
|
||||||
|
writer.createPositionBefore(firstBlock),
|
||||||
|
writer.createPositionAfter(lastBlock)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writer.setSelection(range);
|
||||||
|
this.editor.editing.view.focus();
|
||||||
|
|
||||||
|
this.scrollToSelection();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedBlocks(selection: DocumentSelection) {
|
||||||
|
const blocks = [...selection.getSelectedBlocks()];
|
||||||
|
const resolved: Element[] = [];
|
||||||
|
|
||||||
|
// Selects elements (such as Mermaid) when there are no blocks
|
||||||
|
if (!blocks.length) {
|
||||||
|
const selectedObj = selection.getSelectedElement();
|
||||||
|
if (selectedObj) {
|
||||||
|
return [selectedObj];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
let el: Element = block;
|
||||||
|
// Traverse up until the parent is the root ($root) or there is no parent
|
||||||
|
while (el.parent && el.parent.name !== '$root') {
|
||||||
|
el = el.parent as Element;
|
||||||
|
}
|
||||||
|
resolved.push(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate adjacent duplicates (e.g., nested selections resolving to same block)
|
||||||
|
return resolved.filter((blk, idx) => idx === 0 || blk !== resolved[idx - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToSelection() {
|
||||||
|
// Ensure scroll happens in sync with DOM updates
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.editor.editing.view.scrollToTheSelection();
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class MoveBlockUpCommand extends MoveBlockUpDownCommand {
|
class MoveBlockUpCommand extends MoveBlockUpDownCommand {
|
||||||
|
|
||||||
getSelectedBlocks(selection: DocumentSelection) {
|
|
||||||
return [...selection.getSelectedBlocks()];
|
|
||||||
}
|
|
||||||
|
|
||||||
getSibling(selectedBlock: Element) {
|
getSibling(selectedBlock: Element) {
|
||||||
return selectedBlock.previousSibling;
|
return selectedBlock.previousSibling;
|
||||||
}
|
}
|
||||||
@ -79,11 +152,6 @@ class MoveBlockUpCommand extends MoveBlockUpDownCommand {
|
|||||||
|
|
||||||
class MoveBlockDownCommand extends MoveBlockUpDownCommand {
|
class MoveBlockDownCommand extends MoveBlockUpDownCommand {
|
||||||
|
|
||||||
/** @override */
|
|
||||||
getSelectedBlocks(selection: DocumentSelection) {
|
|
||||||
return [...selection.getSelectedBlocks()].reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @override */
|
/** @override */
|
||||||
getSibling(selectedBlock: Element) {
|
getSibling(selectedBlock: Element) {
|
||||||
return selectedBlock.nextSibling;
|
return selectedBlock.nextSibling;
|
||||||
|
@ -24,11 +24,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@digitak/esrun": "^3.2.24",
|
"@digitak/esrun": "^3.2.24",
|
||||||
"@types/swagger-ui": "^5.0.0",
|
"@types/swagger-ui": "^5.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
"@typescript-eslint/parser": "^6.7.2",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"eslint": "^8.49.0",
|
"eslint": "^9.0.0",
|
||||||
"highlight.js": "^11.8.0",
|
"highlight.js": "^11.8.0",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
|
1129
pnpm-lock.yaml
generated
1129
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user