mirror of
https://github.com/zadam/trilium.git
synced 2025-12-17 05:39:55 +01:00
Compare commits
176 Commits
v0.98.0
...
feat/impro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce298e477b | ||
|
|
81c0e508ac | ||
|
|
065740eabc | ||
|
|
4ce9102f93 | ||
|
|
8abd3ed3f1 | ||
|
|
53ed510c92 | ||
|
|
4ec46a2ebd | ||
|
|
db6f948499 | ||
|
|
05c73011f5 | ||
|
|
3b733d01f1 | ||
|
|
ebf21296d4 | ||
|
|
6f83ac4822 | ||
|
|
d358924324 | ||
|
|
f9a3606ca2 | ||
|
|
33299ad51e | ||
|
|
8752182e7e | ||
|
|
0551ac8ead | ||
|
|
6d5a11bd4d | ||
|
|
ce19d84247 | ||
|
|
f24aa45a3b | ||
|
|
64a28a7e75 | ||
|
|
249a755312 | ||
|
|
a3d51a013c | ||
|
|
839def9959 | ||
|
|
fd432a7100 | ||
|
|
60a07ce1e7 | ||
|
|
88c5700d87 | ||
|
|
d59993abf6 | ||
|
|
0754011909 | ||
|
|
376bb66cab | ||
|
|
588e15c633 | ||
|
|
93b8ad20d7 | ||
|
|
e51b3d760d | ||
|
|
91f3bc4488 | ||
|
|
3e80a99bbf | ||
|
|
37cdb55e79 | ||
|
|
58b66c0c95 | ||
|
|
e5f9db86a1 | ||
|
|
f138f99356 | ||
|
|
c42f4b9814 | ||
|
|
0a9fb886e3 | ||
|
|
3c4577201f | ||
|
|
816421188f | ||
|
|
5b15d2c4c6 | ||
|
|
4bc7165452 | ||
|
|
82d6531e8c | ||
|
|
d6209035c3 | ||
|
|
1d7799f981 | ||
|
|
51291a61e6 | ||
|
|
0841603be0 | ||
|
|
59ba6a0b1e | ||
|
|
53eda46043 | ||
|
|
cbc9fb7d08 | ||
|
|
1f479b20be | ||
|
|
f00b8e9522 | ||
|
|
c7dd271516 | ||
|
|
a947a61d65 | ||
|
|
0122f1cc5e | ||
|
|
acb905a3e6 | ||
|
|
7422eb5598 | ||
|
|
e721166f95 | ||
|
|
5a48130fa4 | ||
|
|
b60fe1ad10 | ||
|
|
1405b0147c | ||
|
|
222a7a57bc | ||
|
|
cddf9f0242 | ||
|
|
3e17ff5e7b | ||
|
|
04973094f2 | ||
|
|
018a6cb84a | ||
|
|
44825af0c0 | ||
|
|
cfb3607052 | ||
|
|
c5ec928aac | ||
|
|
8d0183a9fb | ||
|
|
ecd4079871 | ||
|
|
3ed975f2e6 | ||
|
|
c6deb537d5 | ||
|
|
e7b3d806a7 | ||
|
|
d1a0778b48 | ||
|
|
378634567f | ||
|
|
ed56ed2be0 | ||
|
|
648aa7e3b0 | ||
|
|
73ff41f2b2 | ||
|
|
3837466cb3 | ||
|
|
b97a5ef888 | ||
|
|
2ff1276ebb | ||
|
|
227cf5de85 | ||
|
|
ccf52be431 | ||
|
|
07713e988c | ||
|
|
f934318625 | ||
|
|
6fb90abd75 | ||
|
|
27cc33888a | ||
|
|
95af901808 | ||
|
|
c5a7f84250 | ||
|
|
a71d28500d | ||
|
|
436fd16f3a | ||
|
|
ca34bf42f6 | ||
|
|
fbf2315f57 | ||
|
|
72f50dcb6b | ||
|
|
fd4c2f79a7 | ||
|
|
72f9335213 | ||
|
|
53d97047a3 | ||
|
|
2ba3666e23 | ||
|
|
4a1d379ab4 | ||
|
|
73167e1e30 | ||
|
|
ffc13f5de3 | ||
|
|
9ba23d49d8 | ||
|
|
222a6c48a7 | ||
|
|
e33208e6ec | ||
|
|
af8781eaa7 | ||
|
|
167b1a8d2e | ||
|
|
0a7aff507c | ||
|
|
103532aad9 | ||
|
|
16939e9fd5 | ||
|
|
4ef6169041 | ||
|
|
9ebee42118 | ||
|
|
234d3997b1 | ||
|
|
3ba0bcea4e | ||
|
|
701855344e | ||
|
|
71b627fbc7 | ||
|
|
5a4fc2c690 | ||
|
|
0d67db52a2 | ||
|
|
d971554201 | ||
|
|
8fd7d7176e | ||
|
|
675575eed9 | ||
|
|
2122cde293 | ||
|
|
b68a554bba | ||
|
|
33043c7133 | ||
|
|
bbf8d757cd | ||
|
|
5614891d92 | ||
|
|
b9b4961f3c | ||
|
|
7b83b20339 | ||
|
|
3e00e490cf | ||
|
|
c02ed17ebc | ||
|
|
fb559d66fe | ||
|
|
25dce64c3b | ||
|
|
6f19fde76e | ||
|
|
33ae91f49c | ||
|
|
99c179e29a | ||
|
|
1dbcb5a027 | ||
|
|
54d613e00e | ||
|
|
1f8aa90482 | ||
|
|
c9dcbef014 | ||
|
|
68086ec3f1 | ||
|
|
f62078d02b | ||
|
|
c368ec3c38 | ||
|
|
c039f06c2b | ||
|
|
520effbbb7 | ||
|
|
a42d780724 | ||
|
|
da92255dd6 | ||
|
|
cce3d3bce8 | ||
|
|
f524e99290 | ||
|
|
ba19fc7cf3 | ||
|
|
22c3de582f | ||
|
|
48896e67cb | ||
|
|
16cd91eb02 | ||
|
|
7e03774b8e | ||
|
|
a04f6e3858 | ||
|
|
96eb1be556 | ||
|
|
c67c3a6861 | ||
|
|
d04897e011 | ||
|
|
64bffb82b1 | ||
|
|
81ac390eab | ||
|
|
0db556fac2 | ||
|
|
2793df06c4 | ||
|
|
e7b448e2bc | ||
|
|
d2bc72d54f | ||
|
|
83b22b4861 | ||
|
|
d42a949602 | ||
|
|
83e1512b59 | ||
|
|
ba6a1ec584 | ||
|
|
6685e583f2 | ||
|
|
d6032c912e | ||
|
|
25527ecc21 | ||
|
|
e0e7bd42cc | ||
|
|
fbc1af56ed | ||
|
|
8ff108db9e |
1
.github/workflows/checks.yml
vendored
1
.github/workflows/checks.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check if PRs have conflicts
|
- name: Check if PRs have conflicts
|
||||||
uses: eps1lon/actions-label-merge-conflict@v3
|
uses: eps1lon/actions-label-merge-conflict@v3
|
||||||
|
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||||
with:
|
with:
|
||||||
dirtyLabel: "merge-conflicts"
|
dirtyLabel: "merge-conflicts"
|
||||||
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
|
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
|
||||||
|
|||||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -27,7 +27,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nightly-electron:
|
nightly-electron:
|
||||||
if: github.repository == 'TriliumNext/Trilium'
|
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||||
name: Deploy nightly
|
name: Deploy nightly
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
path: apps/desktop/upload
|
path: apps/desktop/upload
|
||||||
|
|
||||||
nightly-server:
|
nightly-server:
|
||||||
if: github.repository == 'TriliumNext/Trilium'
|
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||||
name: Deploy server nightly
|
name: Deploy server nightly
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ The original Trilium developer ([Zadam](https://github.com/zadam)) has graciousl
|
|||||||
|
|
||||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
|
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
|
||||||
|
|
||||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
|
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest TriliumNext/Trilium version of [v0.63.7](https://github.com/TriliumNext/Trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
|
||||||
|
|
||||||
## 📖 Documentation
|
## 📖 Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ fi
|
|||||||
VERSION=$1
|
VERSION=$1
|
||||||
SERIES=${VERSION:0:4}-latest
|
SERIES=${VERSION:0:4}-latest
|
||||||
|
|
||||||
docker push zadam/trilium:$VERSION
|
docker push TriliumNext/Trilium:$VERSION
|
||||||
docker push zadam/trilium:$SERIES
|
docker push TriliumNext/Trilium:$SERIES
|
||||||
|
|
||||||
if [[ $1 != *"beta"* ]]; then
|
if [[ $1 != *"beta"* ]]; then
|
||||||
docker push zadam/trilium:latest
|
docker push TriliumNext/Trilium:latest
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@fullcalendar/multimonth": "6.1.19",
|
"@fullcalendar/multimonth": "6.1.19",
|
||||||
"@fullcalendar/timegrid": "6.1.19",
|
"@fullcalendar/timegrid": "6.1.19",
|
||||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||||
"@mermaid-js/layout-elk": "0.1.8",
|
"@mermaid-js/layout-elk": "0.1.9",
|
||||||
"@mind-elixir/node-menu": "5.0.0",
|
"@mind-elixir/node-menu": "5.0.0",
|
||||||
"@popperjs/core": "2.11.8",
|
"@popperjs/core": "2.11.8",
|
||||||
"@triliumnext/ckeditor5": "workspace:*",
|
"@triliumnext/ckeditor5": "workspace:*",
|
||||||
@@ -46,12 +46,13 @@
|
|||||||
"leaflet": "1.9.4",
|
"leaflet": "1.9.4",
|
||||||
"leaflet-gpx": "2.2.0",
|
"leaflet-gpx": "2.2.0",
|
||||||
"mark.js": "8.11.1",
|
"mark.js": "8.11.1",
|
||||||
"marked": "16.1.2",
|
"marked": "16.2.0",
|
||||||
"mermaid": "11.9.0",
|
"mermaid": "11.10.0",
|
||||||
"mind-elixir": "5.0.5",
|
"mind-elixir": "5.0.6",
|
||||||
"normalize.css": "8.0.1",
|
"normalize.css": "8.0.1",
|
||||||
"panzoom": "9.4.3",
|
"panzoom": "9.4.3",
|
||||||
"preact": "10.27.0",
|
"preact": "10.27.1",
|
||||||
|
"react-i18next": "15.6.1",
|
||||||
"split.js": "1.6.5",
|
"split.js": "1.6.5",
|
||||||
"svg-pan-zoom": "3.6.2",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"tabulator-tables": "6.3.1",
|
"tabulator-tables": "6.3.1",
|
||||||
|
|||||||
@@ -834,7 +834,7 @@ class FNote {
|
|||||||
if (a.noteId === b.noteId) {
|
if (a.noteId === b.noteId) {
|
||||||
return a.position < b.position ? -1 : 1;
|
return a.position < b.position ? -1 : 1;
|
||||||
} else {
|
} else {
|
||||||
// inherited promoted attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
|
// inherited promoted attributes should stay grouped: https://github.com/TriliumNext/Trilium/issues/3761
|
||||||
return a.noteId < b.noteId ? -1 : 1;
|
return a.noteId < b.noteId ? -1 : 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export class WidgetsByParent {
|
|||||||
this.byParent[parentName]
|
this.byParent[parentName]
|
||||||
// previously, custom widgets were provided as a single instance, but that has the disadvantage
|
// previously, custom widgets were provided as a single instance, but that has the disadvantage
|
||||||
// for splits where we actually need multiple instaces and thus having a class to instantiate is better
|
// for splits where we actually need multiple instaces and thus having a class to instantiate is better
|
||||||
// https://github.com/zadam/trilium/issues/4274
|
// https://github.com/TriliumNext/Trilium/issues/4274
|
||||||
.map((w: any) => (w.prototype ? new w() : w))
|
.map((w: any) => (w.prototype ? new w() : w))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function copy(branchIds: string[]) {
|
|||||||
clipboardMode = "copy";
|
clipboardMode = "copy";
|
||||||
|
|
||||||
if (utils.isElectron()) {
|
if (utils.isElectron()) {
|
||||||
// https://github.com/zadam/trilium/issues/2401
|
// https://github.com/TriliumNext/Trilium/issues/2401
|
||||||
const { clipboard } = require("electron");
|
const { clipboard } = require("electron");
|
||||||
const links: string[] = [];
|
const links: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
|||||||
loadResults.addOption(attributeEntity.name);
|
loadResults.addOption(attributeEntity.name);
|
||||||
} else if (ec.entityName === "attachments") {
|
} else if (ec.entityName === "attachments") {
|
||||||
processAttachment(loadResults, ec);
|
processAttachment(loadResults, ec);
|
||||||
} else if (ec.entityName === "blobs" || ec.entityName === "etapi_tokens") {
|
} else if (ec.entityName === "blobs") {
|
||||||
// NOOP - these entities are handled at the backend level and don't require frontend processing
|
// NOOP - these entities are handled at the backend level and don't require frontend processing
|
||||||
|
} else if (ec.entityName === "etapi_tokens") {
|
||||||
|
loadResults.hasEtapiTokenChanges = true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
throw new Error(`Unknown entityName '${ec.entityName}'`);
|
||||||
}
|
}
|
||||||
@@ -77,9 +79,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
|||||||
noteAttributeCache.invalidate();
|
noteAttributeCache.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove after porting the file
|
const appContext = (await import("../components/app_context.js")).default;
|
||||||
// @ts-ignore
|
|
||||||
const appContext = (await import("../components/app_context.js")).default as any;
|
|
||||||
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import i18next from "i18next";
|
|||||||
import i18nextHttpBackend from "i18next-http-backend";
|
import i18nextHttpBackend from "i18next-http-backend";
|
||||||
import server from "./server.js";
|
import server from "./server.js";
|
||||||
import type { Locale } from "@triliumnext/commons";
|
import type { Locale } from "@triliumnext/commons";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
let locales: Locale[] | null;
|
let locales: Locale[] | null;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export async function initLocale() {
|
|||||||
|
|
||||||
locales = await server.get<Locale[]>("options/locales");
|
locales = await server.get<Locale[]>("options/locales");
|
||||||
|
|
||||||
|
i18next.use(initReactI18next);
|
||||||
await i18next.use(i18nextHttpBackend).init({
|
await i18next.use(i18nextHttpBackend).init({
|
||||||
lng: locale,
|
lng: locale,
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ $(document).on("dblclick", "a", (e) => {
|
|||||||
$(document).on("mousedown", "a", (e) => {
|
$(document).on("mousedown", "a", (e) => {
|
||||||
if (e.which === 2) {
|
if (e.which === 2) {
|
||||||
// prevent paste on middle click
|
// prevent paste on middle click
|
||||||
// https://github.com/zadam/trilium/issues/2995
|
// https://github.com/TriliumNext/Trilium/issues/2995
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions
|
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AttachmentRow } from "@triliumnext/commons";
|
import type { AttachmentRow, EtapiTokenRow } from "@triliumnext/commons";
|
||||||
import type { AttributeType } from "../entities/fattribute.js";
|
import type { AttributeType } from "../entities/fattribute.js";
|
||||||
import type { EntityChange } from "../server_types.js";
|
import type { EntityChange } from "../server_types.js";
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ type EntityRowMappings = {
|
|||||||
options: OptionRow;
|
options: OptionRow;
|
||||||
revisions: RevisionRow;
|
revisions: RevisionRow;
|
||||||
note_reordering: NoteReorderingRow;
|
note_reordering: NoteReorderingRow;
|
||||||
|
etapi_tokens: EtapiTokenRow;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityRowNames = keyof EntityRowMappings;
|
export type EntityRowNames = keyof EntityRowMappings;
|
||||||
@@ -68,6 +69,7 @@ export default class LoadResults {
|
|||||||
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
|
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
|
||||||
private optionNames: string[];
|
private optionNames: string[];
|
||||||
private attachmentRows: AttachmentRow[];
|
private attachmentRows: AttachmentRow[];
|
||||||
|
public hasEtapiTokenChanges: boolean = false;
|
||||||
|
|
||||||
constructor(entityChanges: EntityChange[]) {
|
constructor(entityChanges: EntityChange[]) {
|
||||||
const entities: Record<string, Record<string, any>> = {};
|
const entities: Record<string, Record<string, any>> = {};
|
||||||
@@ -215,7 +217,8 @@ export default class LoadResults {
|
|||||||
this.revisionRows.length === 0 &&
|
this.revisionRows.length === 0 &&
|
||||||
this.contentNoteIdToComponentId.length === 0 &&
|
this.contentNoteIdToComponentId.length === 0 &&
|
||||||
this.optionNames.length === 0 &&
|
this.optionNames.length === 0 &&
|
||||||
this.attachmentRows.length === 0
|
this.attachmentRows.length === 0 &&
|
||||||
|
!this.hasEtapiTokenChanges
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ async function mouseEnterHandler(this: HTMLElement) {
|
|||||||
if ($link.filter(":hover").length > 0) {
|
if ($link.filter(":hover").length > 0) {
|
||||||
$link.tooltip({
|
$link.tooltip({
|
||||||
container: "body",
|
container: "body",
|
||||||
// https://github.com/zadam/trilium/issues/2794 https://github.com/zadam/trilium/issues/2988
|
// https://github.com/TriliumNext/Trilium/issues/2794 https://github.com/TriliumNext/Trilium/issues/2988
|
||||||
// with bottom this flickering happens a bit less
|
// with bottom this flickering happens a bit less
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
trigger: "manual",
|
trigger: "manual",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { OptionNames } from "@triliumnext/commons";
|
||||||
import server from "./server.js";
|
import server from "./server.js";
|
||||||
import { isShare } from "./utils.js";
|
import { isShare } from "./utils.js";
|
||||||
|
|
||||||
type OptionValue = number | string;
|
export type OptionValue = number | string;
|
||||||
|
|
||||||
class Options {
|
class Options {
|
||||||
initializedPromise: Promise<void>;
|
initializedPromise: Promise<void>;
|
||||||
@@ -76,6 +77,14 @@ class Options {
|
|||||||
await server.put(`options`, payload);
|
await server.put(`options`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves multiple options at once, by supplying a record where the keys are the option names and the values represent the stringified value to set.
|
||||||
|
* @param newValues the record of keys and values.
|
||||||
|
*/
|
||||||
|
async saveMany<T extends OptionNames>(newValues: Record<T, OptionValue>) {
|
||||||
|
await server.put<void>("options", newValues);
|
||||||
|
}
|
||||||
|
|
||||||
async toggle(key: string) {
|
async toggle(key: string) {
|
||||||
await this.save(key, (!this.is(key)).toString());
|
await this.save(key, (!this.is(key)).toString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,32 @@ interface ShortcutBinding {
|
|||||||
// Store all active shortcut bindings for management
|
// Store all active shortcut bindings for management
|
||||||
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
const activeBindings: Map<string, ShortcutBinding[]> = new Map();
|
||||||
|
|
||||||
|
// Handle special key mappings and aliases
|
||||||
|
const keyMap: { [key: string]: string[] } = {
|
||||||
|
'return': ['Enter'],
|
||||||
|
'enter': ['Enter'], // alias for return
|
||||||
|
'del': ['Delete'],
|
||||||
|
'delete': ['Delete'], // alias for del
|
||||||
|
'esc': ['Escape'],
|
||||||
|
'escape': ['Escape'], // alias for esc
|
||||||
|
'space': [' ', 'Space'],
|
||||||
|
'tab': ['Tab'],
|
||||||
|
'backspace': ['Backspace'],
|
||||||
|
'home': ['Home'],
|
||||||
|
'end': ['End'],
|
||||||
|
'pageup': ['PageUp'],
|
||||||
|
'pagedown': ['PageDown'],
|
||||||
|
'up': ['ArrowUp'],
|
||||||
|
'down': ['ArrowDown'],
|
||||||
|
'left': ['ArrowLeft'],
|
||||||
|
'right': ['ArrowRight']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function keys
|
||||||
|
for (let i = 1; i <= 19; i++) {
|
||||||
|
keyMap[`f${i}`] = [`F${i}`];
|
||||||
|
}
|
||||||
|
|
||||||
function removeGlobalShortcut(namespace: string) {
|
function removeGlobalShortcut(namespace: string) {
|
||||||
bindGlobalShortcut("", null, namespace);
|
bindGlobalShortcut("", null, namespace);
|
||||||
}
|
}
|
||||||
@@ -124,32 +150,6 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle special key mappings and aliases
|
|
||||||
const keyMap: { [key: string]: string[] } = {
|
|
||||||
'return': ['Enter'],
|
|
||||||
'enter': ['Enter'], // alias for return
|
|
||||||
'del': ['Delete'],
|
|
||||||
'delete': ['Delete'], // alias for del
|
|
||||||
'esc': ['Escape'],
|
|
||||||
'escape': ['Escape'], // alias for esc
|
|
||||||
'space': [' ', 'Space'],
|
|
||||||
'tab': ['Tab'],
|
|
||||||
'backspace': ['Backspace'],
|
|
||||||
'home': ['Home'],
|
|
||||||
'end': ['End'],
|
|
||||||
'pageup': ['PageUp'],
|
|
||||||
'pagedown': ['PageDown'],
|
|
||||||
'up': ['ArrowUp'],
|
|
||||||
'down': ['ArrowDown'],
|
|
||||||
'left': ['ArrowLeft'],
|
|
||||||
'right': ['ArrowRight']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function keys
|
|
||||||
for (let i = 1; i <= 19; i++) {
|
|
||||||
keyMap[`f${i}`] = [`F${i}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappedKeys = keyMap[key.toLowerCase()];
|
const mappedKeys = keyMap[key.toLowerCase()];
|
||||||
if (mappedKeys) {
|
if (mappedKeys) {
|
||||||
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
|
return mappedKeys.includes(e.key) || mappedKeys.includes(e.code);
|
||||||
@@ -163,7 +163,7 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
|||||||
|
|
||||||
// For letter keys, use the physical key code for consistency
|
// For letter keys, use the physical key code for consistency
|
||||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||||
return e.code === `Key${key.toUpperCase()}`;
|
return e.key.toLowerCase() === key.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For regular keys, check both key and code as fallback
|
// For regular keys, check both key and code as fallback
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const SVG_MIME = "image/svg+xml";
|
|||||||
|
|
||||||
export const isShare = !window.glob;
|
export const isShare = !window.glob;
|
||||||
|
|
||||||
function reloadFrontendApp(reason?: string) {
|
export function reloadFrontendApp(reason?: string) {
|
||||||
if (reason) {
|
if (reason) {
|
||||||
logInfo(`Frontend app reload: ${reason}`);
|
logInfo(`Frontend app reload: ${reason}`);
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ function reloadFrontendApp(reason?: string) {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartDesktopApp() {
|
export function restartDesktopApp() {
|
||||||
if (!isElectron()) {
|
if (!isElectron()) {
|
||||||
reloadFrontendApp();
|
reloadFrontendApp();
|
||||||
return;
|
return;
|
||||||
@@ -125,7 +125,7 @@ function formatDateISO(date: Date) {
|
|||||||
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
|
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
export function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||||
if (userSuppliedFormat?.trim()) {
|
if (userSuppliedFormat?.trim()) {
|
||||||
return dayjs(date).format(userSuppliedFormat);
|
return dayjs(date).format(userSuppliedFormat);
|
||||||
} else {
|
} else {
|
||||||
@@ -144,7 +144,7 @@ function now() {
|
|||||||
/**
|
/**
|
||||||
* Returns `true` if the client is currently running under Electron, or `false` if running in a web browser.
|
* Returns `true` if the client is currently running under Electron, or `false` if running in a web browser.
|
||||||
*/
|
*/
|
||||||
function isElectron() {
|
export function isElectron() {
|
||||||
return !!(window && window.process && window.process.type);
|
return !!(window && window.process && window.process.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ function randomString(len: number) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMobile() {
|
export function isMobile() {
|
||||||
return (
|
return (
|
||||||
window.glob?.device === "mobile" ||
|
window.glob?.device === "mobile" ||
|
||||||
// window.glob.device is not available in setup
|
// window.glob.device is not available in setup
|
||||||
@@ -306,7 +306,7 @@ function copySelectionToClipboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dynamicRequire(moduleName: string) {
|
export function dynamicRequire(moduleName: string) {
|
||||||
if (typeof __non_webpack_require__ !== "undefined") {
|
if (typeof __non_webpack_require__ !== "undefined") {
|
||||||
return __non_webpack_require__(moduleName);
|
return __non_webpack_require__(moduleName);
|
||||||
} else {
|
} else {
|
||||||
@@ -374,33 +374,42 @@ async function openInAppHelp($button: JQuery<HTMLElement>) {
|
|||||||
|
|
||||||
const inAppHelpPage = $button.attr("data-in-app-help");
|
const inAppHelpPage = $button.attr("data-in-app-help");
|
||||||
if (inAppHelpPage) {
|
if (inAppHelpPage) {
|
||||||
// Dynamic import to avoid import issues in tests.
|
openInAppHelpFromUrl(inAppHelpPage);
|
||||||
const appContext = (await import("../components/app_context.js")).default;
|
}
|
||||||
const activeContext = appContext.tabManager.getActiveContext();
|
}
|
||||||
if (!activeContext) {
|
|
||||||
return;
|
/**
|
||||||
}
|
* Opens the in-app help at the given page in a split note. If there already is a split note open with a help page, it will be replaced by this one.
|
||||||
const subContexts = activeContext.getSubContexts();
|
*
|
||||||
const targetNote = `_help_${inAppHelpPage}`;
|
* @param inAppHelpPage the ID of the help note (excluding the `_help_` prefix).
|
||||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
* @returns a promise that resolves once the help has been opened.
|
||||||
const viewScope: ViewScope = {
|
*/
|
||||||
viewMode: "contextual-help",
|
export async function openInAppHelpFromUrl(inAppHelpPage: string) {
|
||||||
};
|
// Dynamic import to avoid import issues in tests.
|
||||||
if (!helpSubcontext) {
|
const appContext = (await import("../components/app_context.js")).default;
|
||||||
// The help is not already open, open a new split with it.
|
const activeContext = appContext.tabManager.getActiveContext();
|
||||||
const { ntxId } = subContexts[subContexts.length - 1];
|
if (!activeContext) {
|
||||||
appContext.triggerCommand("openNewNoteSplit", {
|
|
||||||
ntxId,
|
|
||||||
notePath: targetNote,
|
|
||||||
hoistedNoteId: "_help",
|
|
||||||
viewScope
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// There is already a help window open, make sure it opens on the right note.
|
|
||||||
helpSubcontext.setNote(targetNote, { viewScope });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const subContexts = activeContext.getSubContexts();
|
||||||
|
const targetNote = `_help_${inAppHelpPage}`;
|
||||||
|
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||||
|
const viewScope: ViewScope = {
|
||||||
|
viewMode: "contextual-help",
|
||||||
|
};
|
||||||
|
if (!helpSubcontext) {
|
||||||
|
// The help is not already open, open a new split with it.
|
||||||
|
const { ntxId } = subContexts[subContexts.length - 1];
|
||||||
|
appContext.triggerCommand("openNewNoteSplit", {
|
||||||
|
ntxId,
|
||||||
|
notePath: targetNote,
|
||||||
|
hoistedNoteId: "_help",
|
||||||
|
viewScope
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// There is already a help window open, make sure it opens on the right note.
|
||||||
|
helpSubcontext.setNote(targetNote, { viewScope });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
|
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
|
||||||
@@ -735,6 +744,50 @@ function isLaunchBarConfig(noteId: string) {
|
|||||||
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
|
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a class to the <body> of the page, where the class name is formed via a prefix and a value.
|
||||||
|
* Useful for configurable options such as `heading-style-markdown`, where `heading-style` is the prefix and `markdown` is the dynamic value.
|
||||||
|
* There is no separator between the prefix and the value, if needed it has to be supplied manually to the prefix.
|
||||||
|
*
|
||||||
|
* @param prefix the prefix.
|
||||||
|
* @param value the value to be appended to the prefix.
|
||||||
|
*/
|
||||||
|
export function toggleBodyClass(prefix: string, value: string) {
|
||||||
|
const $body = $("body");
|
||||||
|
for (const clazz of Array.from($body[0].classList)) {
|
||||||
|
// create copy to safely iterate over while removing classes
|
||||||
|
if (clazz.startsWith(prefix)) {
|
||||||
|
$body.removeClass(clazz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body.addClass(prefix + value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic comparison for equality between the two arrays. The values are strictly checked via `===`.
|
||||||
|
*
|
||||||
|
* @param a the first array to compare.
|
||||||
|
* @param b the second array to compare.
|
||||||
|
* @returns `true` if both arrays are equals, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export function arrayEqual<T>(a: T[], b: T[]) {
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i=0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
reloadFrontendApp,
|
reloadFrontendApp,
|
||||||
restartDesktopApp,
|
restartDesktopApp,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ body {
|
|||||||
height: unset !important;
|
height: unset !important;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: unset;
|
position: unset;
|
||||||
/* https://github.com/zadam/trilium/issues/3202 */
|
/* https://github.com/TriliumNext/Trilium/issues/3202 */
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
/* this fixes FF filter vs. position fixed bug: https://github.com/zadam/trilium/issues/233 */
|
/* this fixes FF filter vs. position fixed bug: https://github.com/TriliumNext/Trilium/issues/233 */
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,15 @@ textarea,
|
|||||||
color: var(--muted-text-color);
|
color: var(--muted-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add a gap between consecutive radios / check boxes */
|
/* Add a gap between consecutive radios / check boxes */
|
||||||
label.tn-radio + label.tn-radio,
|
label.tn-radio + label.tn-radio,
|
||||||
label.tn-checkbox + label.tn-checkbox {
|
label.tn-checkbox + label.tn-checkbox {
|
||||||
@@ -534,7 +543,7 @@ button.btn-sm {
|
|||||||
transform: translateX(7px);
|
transform: translateX(7px);
|
||||||
color: var(--muted-text-color);
|
color: var(--muted-text-color);
|
||||||
background-color: var(--main-background-color);
|
background-color: var(--main-background-color);
|
||||||
/* Making this narrower because https://github.com/zadam/trilium/issues/502 (problem only in smaller font sizes) */
|
/* Making this narrower because https://github.com/TriliumNext/Trilium/issues/502 (problem only in smaller font sizes) */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -1108,7 +1117,7 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: var(--accented-background-color);
|
background-color: var(--accented-background-color);
|
||||||
display: flex; /* see https://github.com/zadam/trilium/issues/1590 */
|
display: flex; /* see https://github.com/TriliumNext/Trilium/issues/1590 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.include-note.ck-placeholder::before {
|
.include-note.ck-placeholder::before {
|
||||||
@@ -1242,7 +1251,7 @@ body.desktop li.dropdown-submenu:hover > ul.dropdown-menu {
|
|||||||
left: calc(100% - 2px); /* -2px, otherwise there's a small gap between menu and submenu where the hover can disappear */
|
left: calc(100% - 2px); /* -2px, otherwise there's a small gap between menu and submenu where the hover can disappear */
|
||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
min-width: 15rem;
|
min-width: 15rem;
|
||||||
/* to make submenu scrollable https://github.com/zadam/trilium/issues/3136 */
|
/* to make submenu scrollable https://github.com/TriliumNext/Trilium/issues/3136 */
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -1738,16 +1747,12 @@ button.close:hover {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-number-input {
|
.options-section input[type="number"] {
|
||||||
/* overriding settings from .form-control */
|
/* overriding settings from .form-control */
|
||||||
width: 10em !important;
|
width: 10em !important;
|
||||||
flex-grow: 0 !important;
|
flex-grow: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-mime-types {
|
|
||||||
column-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,9 +181,7 @@ div.note-detail-empty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.options-section:not(.tn-no-card) {
|
.options-section:not(.tn-no-card) {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
min-width: var(--options-card-min-width);
|
|
||||||
max-width: var(--options-card-max-width);
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--card-border-color) !important;
|
border: 1px solid var(--card-border-color) !important;
|
||||||
box-shadow: var(--card-box-shadow);
|
box-shadow: var(--card-box-shadow);
|
||||||
@@ -192,6 +190,11 @@ div.note-detail-empty {
|
|||||||
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
|
margin-bottom: calc(var(--options-title-offset) + 26px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.desktop .option-section:not(.tn-no-card) {
|
||||||
|
min-width: var(--options-card-min-width);
|
||||||
|
max-width: var(--options-card-max-width);
|
||||||
|
}
|
||||||
|
|
||||||
.note-detail-content-widget-content.options {
|
.note-detail-content-widget-content.options {
|
||||||
--default-padding: 15px;
|
--default-padding: 15px;
|
||||||
padding-top: calc(var(--default-padding) + var(--options-title-offset) + var(--options-title-font-size));
|
padding-top: calc(var(--default-padding) + var(--options-title-offset) + var(--options-title-font-size));
|
||||||
@@ -233,11 +236,6 @@ div.note-detail-empty {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.options-section .options-mime-types {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-section .form-group {
|
.options-section .form-group {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1165,7 +1165,7 @@
|
|||||||
},
|
},
|
||||||
"revisions_snapshot_interval": {
|
"revisions_snapshot_interval": {
|
||||||
"note_revisions_snapshot_interval_title": "笔记修订快照间隔",
|
"note_revisions_snapshot_interval_title": "笔记修订快照间隔",
|
||||||
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a>。",
|
"note_revisions_snapshot_description": "笔记修订快照间隔是创建新笔记修订的时间。有关更多信息,请参见 <doc>wiki</doc>。",
|
||||||
"snapshot_time_interval_label": "笔记修订快照时间间隔:"
|
"snapshot_time_interval_label": "笔记修订快照时间间隔:"
|
||||||
},
|
},
|
||||||
"revisions_snapshot_limit": {
|
"revisions_snapshot_limit": {
|
||||||
@@ -1878,7 +1878,7 @@
|
|||||||
},
|
},
|
||||||
"custom_date_time_format": {
|
"custom_date_time_format": {
|
||||||
"title": "自定义日期/时间格式",
|
"title": "自定义日期/时间格式",
|
||||||
"description": "通过<kbd></kbd>或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
|
"description": "通过<shortcut />或工具栏的方式可自定义日期和时间格式,有关日期/时间格式字符串中各个字符的含义,请参阅<doc>Day.js docs</doc>。",
|
||||||
"format_string": "日期/时间格式字符串:",
|
"format_string": "日期/时间格式字符串:",
|
||||||
"formatted_time": "格式化后日期/时间:"
|
"formatted_time": "格式化后日期/时间:"
|
||||||
},
|
},
|
||||||
@@ -1994,6 +1994,10 @@
|
|||||||
"call_to_action": {
|
"call_to_action": {
|
||||||
"background_effects_title": "背景效果现已推出稳定版本",
|
"background_effects_title": "背景效果现已推出稳定版本",
|
||||||
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
|
"background_effects_message": "在 Windows 装置上,背景效果现在已完全稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。此技术也用于其他应用程序,例如 Windows 资源管理器。",
|
||||||
"background_effects_button": "启用背景效果"
|
"background_effects_button": "启用背景效果",
|
||||||
|
"next_theme_title": "试用新 Trilium 主题",
|
||||||
|
"next_theme_message": "当前使用旧版主题,要试用新主题吗?",
|
||||||
|
"next_theme_button": "试用新主题",
|
||||||
|
"dismiss": "关闭"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1253,7 +1253,12 @@
|
|||||||
"selected_provider": "Selected Provider",
|
"selected_provider": "Selected Provider",
|
||||||
"selected_provider_description": "Choose the AI provider for chat and completion features",
|
"selected_provider_description": "Choose the AI provider for chat and completion features",
|
||||||
"select_model": "Select model...",
|
"select_model": "Select model...",
|
||||||
"select_provider": "Select provider..."
|
"select_provider": "Select provider...",
|
||||||
|
"ai_enabled": "AI features enabled",
|
||||||
|
"ai_disabled": "AI features disabled",
|
||||||
|
"no_models_found_online": "No models found. Please check your API key and settings.",
|
||||||
|
"no_models_found_ollama": "No Ollama models found. Please check if Ollama is running.",
|
||||||
|
"error_fetching": "Error fetching models: {{error}}"
|
||||||
},
|
},
|
||||||
"zoom_factor": {
|
"zoom_factor": {
|
||||||
"title": "Zoom Factor (desktop build only)",
|
"title": "Zoom Factor (desktop build only)",
|
||||||
@@ -1310,7 +1315,7 @@
|
|||||||
},
|
},
|
||||||
"revisions_snapshot_interval": {
|
"revisions_snapshot_interval": {
|
||||||
"note_revisions_snapshot_interval_title": "Note Revision Snapshot Interval",
|
"note_revisions_snapshot_interval_title": "Note Revision Snapshot Interval",
|
||||||
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a> for more info.",
|
"note_revisions_snapshot_description": "The Note revision snapshot interval is the time after which a new note revision will be created for the note. See <doc>wiki</doc> for more info.",
|
||||||
"snapshot_time_interval_label": "Note revision snapshot time interval:"
|
"snapshot_time_interval_label": "Note revision snapshot time interval:"
|
||||||
},
|
},
|
||||||
"revisions_snapshot_limit": {
|
"revisions_snapshot_limit": {
|
||||||
@@ -1372,7 +1377,7 @@
|
|||||||
},
|
},
|
||||||
"custom_date_time_format": {
|
"custom_date_time_format": {
|
||||||
"title": "Custom Date/Time Format",
|
"title": "Custom Date/Time Format",
|
||||||
"description": "Customize the format of the date and time inserted via <kbd></kbd> or the toolbar. See <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a> for available format tokens.",
|
"description": "Customize the format of the date and time inserted via <shortcut /> or the toolbar. See <doc>Day.js docs</doc> for available format tokens.",
|
||||||
"format_string": "Format string:",
|
"format_string": "Format string:",
|
||||||
"formatted_time": "Formatted date/time:"
|
"formatted_time": "Formatted date/time:"
|
||||||
},
|
},
|
||||||
@@ -1999,6 +2004,17 @@
|
|||||||
"next_theme_button": "Try the new theme",
|
"next_theme_button": "Try the new theme",
|
||||||
"background_effects_title": "Background effects are now stable",
|
"background_effects_title": "Background effects are now stable",
|
||||||
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
|
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
|
||||||
"background_effects_button": "Enable background effects"
|
"background_effects_button": "Enable background effects",
|
||||||
|
"dismiss": "Dismiss"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"related_settings": "Related settings"
|
||||||
|
},
|
||||||
|
"settings_appearance": {
|
||||||
|
"related_code_blocks": "Color scheme for code blocks in text notes",
|
||||||
|
"related_code_notes": "Color scheme for code notes"
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"percentage": "%"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -140,5 +140,8 @@
|
|||||||
},
|
},
|
||||||
"jump_to_note": {
|
"jump_to_note": {
|
||||||
"search_button": "Etsi koko tekstistä"
|
"search_button": "Etsi koko tekstistä"
|
||||||
|
},
|
||||||
|
"call_to_action": {
|
||||||
|
"dismiss": "Hylkää"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1163,7 +1163,7 @@
|
|||||||
},
|
},
|
||||||
"revisions_snapshot_interval": {
|
"revisions_snapshot_interval": {
|
||||||
"note_revisions_snapshot_interval_title": "Délai d'enregistrement automatique d'une version de note",
|
"note_revisions_snapshot_interval_title": "Délai d'enregistrement automatique d'une version de note",
|
||||||
"note_revisions_snapshot_description": "Le délai d'enregistrement automatique des versions de note définit le temps avant la création automatique d'une nouvelle version de note. Consultez le <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a> pour plus d'informations.",
|
"note_revisions_snapshot_description": "Le délai d'enregistrement automatique des versions de note définit le temps avant la création automatique d'une nouvelle version de note. Consultez le <doc>wiki</doc> pour plus d'informations.",
|
||||||
"snapshot_time_interval_label": "Délai d'enregistrement automatique de version de note :"
|
"snapshot_time_interval_label": "Délai d'enregistrement automatique de version de note :"
|
||||||
},
|
},
|
||||||
"revisions_snapshot_limit": {
|
"revisions_snapshot_limit": {
|
||||||
|
|||||||
1
apps/client/src/translations/hu/translation.json
Normal file
1
apps/client/src/translations/hu/translation.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -97,7 +97,9 @@
|
|||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"delete_notes_preview": "ノートのプレビューを削除"
|
"delete_notes_preview": "ノートのプレビューを削除",
|
||||||
|
"broken_relations_to_be_deleted": "次のリレーション ({{relationCount}})は壊れているので消去されます",
|
||||||
|
"deleted_relation_text": "削除予定のノート{{- note}}は{{- source}}からリレーション{{- relation}}によって参照されています."
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"mon": "月",
|
"mon": "月",
|
||||||
@@ -208,7 +210,9 @@
|
|||||||
"confirmation": "確認",
|
"confirmation": "確認",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"also_delete_note": "同時にノートを削除"
|
"also_delete_note": "同時にノートを削除",
|
||||||
|
"are_you_sure_remove_note": "本当に\"{{title}}\"をリレーションマップから除きたいですか? ",
|
||||||
|
"if_you_dont_check": "これをチェックしないと、このノートはリレーションマップからのみ除かれます。"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"export_note_title": "ノートをエクスポート",
|
"export_note_title": "ノートをエクスポート",
|
||||||
@@ -278,7 +282,9 @@
|
|||||||
"inPageSearch": "ページ内検索",
|
"inPageSearch": "ページ内検索",
|
||||||
"showJumpToNoteDialog": "<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">「ジャンプ先」ダイアログ</a>を表示",
|
"showJumpToNoteDialog": "<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">「ジャンプ先」ダイアログ</a>を表示",
|
||||||
"moveNoteUpDown": "ノートリストでノートを上/下に移動",
|
"moveNoteUpDown": "ノートリストでノートを上/下に移動",
|
||||||
"notSet": "未設定"
|
"notSet": "未設定",
|
||||||
|
"goUpDown": "ノートのリストで上下する",
|
||||||
|
"editBranchPrefix": "アクティブノートのクローンの <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">プレフィックス</a> を編集する"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"importIntoNote": "ノートにインポート",
|
"importIntoNote": "ノートにインポート",
|
||||||
@@ -679,7 +685,8 @@
|
|||||||
"description1": "検索スクリプトは、スクリプトを実行することによって検索結果を定義することができます。標準の検索では不十分な場合に、最大限の柔軟性を提供します。",
|
"description1": "検索スクリプトは、スクリプトを実行することによって検索結果を定義することができます。標準の検索では不十分な場合に、最大限の柔軟性を提供します。",
|
||||||
"description2": "検索スクリプトはノートタイプが \"code\" かつ \"JavaScript backend\" でなければならない。スクリプトは、 noteIds または note の配列を返す必要があります。",
|
"description2": "検索スクリプトはノートタイプが \"code\" かつ \"JavaScript backend\" でなければならない。スクリプトは、 noteIds または note の配列を返す必要があります。",
|
||||||
"example_title": "例は以下です:",
|
"example_title": "例は以下です:",
|
||||||
"example_code": "// 1. 標準検索によるプレフィルタリング\nconst candidateNotes = api.searchForNotes(\"#journal\"); \n\n// 2. カスタム検索条件の適用\nconst matchedNotes = candidateNotes\n .filter(note => note.title.match(/[0-9]{1,2}\\. ?[0-9]{1,2}\\. ?[0-9]{4}/));\n\nreturn matchedNotes;"
|
"example_code": "// 1. 標準検索によるプレフィルタリング\nconst candidateNotes = api.searchForNotes(\"#journal\"); \n\n// 2. カスタム検索条件の適用\nconst matchedNotes = candidateNotes\n .filter(note => note.title.match(/[0-9]{1,2}\\. ?[0-9]{1,2}\\. ?[0-9]{4}/));\n\nreturn matchedNotes;",
|
||||||
|
"note": "検索スクリプトと文字列検索は、互いに組み合わせることはできません。"
|
||||||
},
|
},
|
||||||
"include_note": {
|
"include_note": {
|
||||||
"placeholder_search": "ノート名で検索",
|
"placeholder_search": "ノート名で検索",
|
||||||
@@ -727,7 +734,12 @@
|
|||||||
"system-default": "システムのデフォルト"
|
"system-default": "システムのデフォルト"
|
||||||
},
|
},
|
||||||
"max_content_width": {
|
"max_content_width": {
|
||||||
"reload_button": "フロントエンドをリロード"
|
"reload_button": "フロントエンドをリロード",
|
||||||
|
"title": "コンテンツ幅",
|
||||||
|
"default_description": "Triliumは、ワイドスクリーンで最大化された画面での可読性を向上させるために、デフォルトでコンテンツの最大幅を制限しています。",
|
||||||
|
"max_width_label": "最大コンテンツ幅",
|
||||||
|
"max_width_unit": "ピクセル",
|
||||||
|
"apply_changes_description": "コンテンツ幅の変更を適用するには、クリックしてください"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"title": "アプリのテーマ",
|
"title": "アプリのテーマ",
|
||||||
@@ -823,7 +835,7 @@
|
|||||||
},
|
},
|
||||||
"custom_date_time_format": {
|
"custom_date_time_format": {
|
||||||
"title": "日付/時刻フォーマットのカスタム",
|
"title": "日付/時刻フォーマットのカスタム",
|
||||||
"description": "<kbd></kbd>またはツールバーから挿入される日付と時刻のフォーマットをカスタマイズする。 利用可能なトークンについては <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js ドキュメント</a> を参照してください。",
|
"description": "<shortcut />またはツールバーから挿入される日付と時刻のフォーマットをカスタマイズする。 利用可能なトークンについては <doc>Day.js ドキュメント</doc> を参照してください。",
|
||||||
"format_string": "文字列形式:",
|
"format_string": "文字列形式:",
|
||||||
"formatted_time": "日付/時刻形式:"
|
"formatted_time": "日付/時刻形式:"
|
||||||
},
|
},
|
||||||
@@ -1001,7 +1013,8 @@
|
|||||||
"labels": "ラベル",
|
"labels": "ラベル",
|
||||||
"relations": "関係",
|
"relations": "関係",
|
||||||
"notes": "ノート",
|
"notes": "ノート",
|
||||||
"other": "その他"
|
"other": "その他",
|
||||||
|
"none_yet": "アクションを上のリストからクリックして追加。"
|
||||||
},
|
},
|
||||||
"note_title": {
|
"note_title": {
|
||||||
"placeholder": "ここにノートのタイトルを入力..."
|
"placeholder": "ここにノートのタイトルを入力..."
|
||||||
@@ -1338,5 +1351,47 @@
|
|||||||
},
|
},
|
||||||
"code-editor-options": {
|
"code-editor-options": {
|
||||||
"title": "エディター"
|
"title": "エディター"
|
||||||
|
},
|
||||||
|
"search_string": {
|
||||||
|
"title_column": "文字列検索:",
|
||||||
|
"search_syntax": "検索構文",
|
||||||
|
"also_see": "詳しくは",
|
||||||
|
"complete_help": "検索構文に関する完全なヘルプ",
|
||||||
|
"full_text_search": "テキストを入力すると全文検索が可能",
|
||||||
|
"label_abc": "ラベルabcを持つノートを返す",
|
||||||
|
"label_year": "ラベル「year」の値が「2019」と一致するノート",
|
||||||
|
"label_rock_pop": "rock と pop のラベルを持つノート",
|
||||||
|
"label_rock_or_pop": "どれかのラベルが存在すること",
|
||||||
|
"label_year_comparison": "数値比較(>、>=、<も含む)。",
|
||||||
|
"label_date_created": "過去1ヶ月以内に作成されたノート",
|
||||||
|
"error": "検索エラー: {{error}}",
|
||||||
|
"search_prefix": "検索:"
|
||||||
|
},
|
||||||
|
"delete_revisions": {
|
||||||
|
"delete_note_revisions": "ノートの変更履歴を削除",
|
||||||
|
"all_past_note_revisions": "一致したノートの過去の変更履歴がすべて削除されます。ノート自体は完全に保持されます。言い換えると、ノートのリビジョンが削除されます。"
|
||||||
|
},
|
||||||
|
"rename_note": {
|
||||||
|
"rename_note": "ノート名を変更",
|
||||||
|
"new_note_title": "新しいノート名",
|
||||||
|
"rename_note_title_to": "ノート名を変更",
|
||||||
|
"example_note": "<code>Note</code> - マッチしたノートの名前をすべて'Note'に変更",
|
||||||
|
"example_new_title": "<code>NEW: ${note.title}</code> - 一致したノートの名前の前に 'NEW: ' を付ける",
|
||||||
|
"example_date_prefix": "<code>${note.dateCreatedObj.format('MM-DD:')}: ${note.title}</code> - マッチしたノートの前にノートの作成月日を付ける",
|
||||||
|
"api_docs": "詳細については、 <a href='https://zadam.github.io/trilium/backend_api/Note.html'>note</a> および <a href='https://day.js.org/docs/en/display/format'>dateCreatedObj / utcDateCreatedObj properties</a> の API ドキュメントを参照してください。",
|
||||||
|
"evaluated_as_js_string": "与えられた値はJavaScript文字列として評価されるため、注入された<code>note</code>変数(noteは名前が変更されます)を介して動的なコンテンツで強化できます。例:"
|
||||||
|
},
|
||||||
|
"electron_integration": {
|
||||||
|
"desktop-application": "デスクトップアプリケーション",
|
||||||
|
"native-title-bar": "ネイティブタイトルバー",
|
||||||
|
"native-title-bar-description": "WindowsとmacOSでは、ネイティブタイトルバーをオフにしておくと、アプリケーションがよりコンパクトに見えます。Linuxでは、ネイティブタイトルバーを表示したままの方が、他のシステムとの統一性が高まります。",
|
||||||
|
"background-effects": "背景効果を有効化(Windows 11のみ)",
|
||||||
|
"background-effects-description": "Mica効果は、アプリのウィンドウにぼかされたスタイリッシュな背景を追加し、奥行きとモダンな外観を作り出します。",
|
||||||
|
"restart-app-button": "アプリケーションを再起動して変更を反映",
|
||||||
|
"zoom-factor": "ズーム倍率"
|
||||||
|
},
|
||||||
|
"zoom_factor": {
|
||||||
|
"description": "ズームは CTRL+- と CTRL+= のショートカットでも操作可能。",
|
||||||
|
"title": "ズーム倍率(デスクトップビルドのみ)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
apps/client/src/translations/pl/translation.json
Normal file
41
apps/client/src/translations/pl/translation.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"about": {
|
||||||
|
"title": "O notatkach Trilium",
|
||||||
|
"homepage": "Strona główna:",
|
||||||
|
"app_version": "Wersja aplikacji:",
|
||||||
|
"db_version": "Wersja bazy danych:",
|
||||||
|
"sync_version": "Wersja synchronizacji:",
|
||||||
|
"build_date": "Zbudowano:",
|
||||||
|
"build_revision": "Rewizja zbudowania:",
|
||||||
|
"data_directory": "Katalog z danymi:"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"critical-error": {
|
||||||
|
"title": "Błąd krytyczny",
|
||||||
|
"message": "Wystąpił krytyczny błąd uniemożliwiający uruchomienie aplikacji:\n\n{{message}}\n\nJest to spowodowane najprawdopodobniej niespodziewanym błędem skryptu. Spróbuj uruchomić aplikację ponownie w trybie bezpiecznym i zaadresuj problem."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"add_link": {
|
||||||
|
"add_link": "Dodaj link"
|
||||||
|
},
|
||||||
|
"branch_prefix": {
|
||||||
|
"save": "Zapisz"
|
||||||
|
},
|
||||||
|
"bulk_actions": {
|
||||||
|
"labels": "Etykiety",
|
||||||
|
"notes": "Notatki",
|
||||||
|
"other": "Inne",
|
||||||
|
"relations": "Powiązania"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"ok": "OK",
|
||||||
|
"cancel": "Anuluj"
|
||||||
|
},
|
||||||
|
"delete_notes": {
|
||||||
|
"cancel": "Anuluj",
|
||||||
|
"close": "Zamknij"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"close": "Zamknij"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1076,7 +1076,7 @@
|
|||||||
"note_revisions": "Revizii ale notiței"
|
"note_revisions": "Revizii ale notiței"
|
||||||
},
|
},
|
||||||
"revisions_snapshot_interval": {
|
"revisions_snapshot_interval": {
|
||||||
"note_revisions_snapshot_description": "Intervalul de salvare a reviziilor este timpul după care se crează o nouă revizie a unei notițe. Vedeți <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki-ul</a> pentru mai multe informații.",
|
"note_revisions_snapshot_description": "Intervalul de salvare a reviziilor este timpul după care se crează o nouă revizie a unei notițe. Vedeți <doc>wiki-ul</doc> pentru mai multe informații.",
|
||||||
"note_revisions_snapshot_interval_title": "Intervalul de salvare a reviziilor",
|
"note_revisions_snapshot_interval_title": "Intervalul de salvare a reviziilor",
|
||||||
"snapshot_time_interval_label": "Intervalul de salvare a reviziilor:"
|
"snapshot_time_interval_label": "Intervalul de salvare a reviziilor:"
|
||||||
},
|
},
|
||||||
@@ -1871,7 +1871,7 @@
|
|||||||
},
|
},
|
||||||
"custom_date_time_format": {
|
"custom_date_time_format": {
|
||||||
"title": "Format dată/timp personalizat",
|
"title": "Format dată/timp personalizat",
|
||||||
"description": "Personalizați formatul de dată și timp inserat prin <kbd></kbd> sau din bara de unelte. Vedeți <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Documentația Day.js</a> pentru câmpurile de formatare disponibile.",
|
"description": "Personalizați formatul de dată și timp inserat prin <shortcut /> sau din bara de unelte. Vedeți <doc>Documentația Day.js</doc> pentru câmpurile de formatare disponibile.",
|
||||||
"format_string": "Șir de formatare:",
|
"format_string": "Șir de formatare:",
|
||||||
"formatted_time": "Data și ora formatate:"
|
"formatted_time": "Data și ora formatate:"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -156,7 +156,7 @@
|
|||||||
"other": "其他",
|
"other": "其他",
|
||||||
"quickSearch": "定位至快速搜尋框",
|
"quickSearch": "定位至快速搜尋框",
|
||||||
"inPageSearch": "頁面內搜尋",
|
"inPageSearch": "頁面內搜尋",
|
||||||
"title": "資料表",
|
"title": "列表",
|
||||||
"newTabNoteLink": "在新分頁開啟筆記連結",
|
"newTabNoteLink": "在新分頁開啟筆記連結",
|
||||||
"newTabWithActivationNoteLink": "在新分頁開啟並切換至筆記連結"
|
"newTabWithActivationNoteLink": "在新分頁開啟並切換至筆記連結"
|
||||||
},
|
},
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
"auto_read_only_disabled": "文字 / 程式碼筆記可以在太大時自動設定為唯讀模式。您可以透過向筆記新增此標籤來對單個筆記單獨設定禁用唯讀。",
|
"auto_read_only_disabled": "文字 / 程式碼筆記可以在太大時自動設定為唯讀模式。您可以透過向筆記新增此標籤來對單個筆記單獨設定禁用唯讀。",
|
||||||
"app_css": "標記載入至 Trilium 應用程式中的 CSS 筆記,因此可以用於修改 Trilium 的外觀。",
|
"app_css": "標記載入至 Trilium 應用程式中的 CSS 筆記,因此可以用於修改 Trilium 的外觀。",
|
||||||
"app_theme": "標記為完整的 Trilium 主題的 CSS 筆記,因此可以在 Trilium 選項中使用。",
|
"app_theme": "標記為完整的 Trilium 主題的 CSS 筆記,因此可以在 Trilium 選項中使用。",
|
||||||
"css_class": "該標籤的值將作為 CSS 類新增至樹中表示給定筆記的節點。這對於高級主題設定非常有用。可用於模板筆記。",
|
"css_class": "該標籤的值將作為 CSS 類新增至樹中表示給定筆記的節點。這對於進階主題設定非常有用。可用於模板筆記。",
|
||||||
"icon_class": "該標籤的值將作為 CSS 類新增至樹中圖標上,有助於從視覺上區分筆記樹裡的筆記。比如可以是 bx bx-home——圖標來自 boxicons。可用於模板筆記。",
|
"icon_class": "該標籤的值將作為 CSS 類新增至樹中圖標上,有助於從視覺上區分筆記樹裡的筆記。比如可以是 bx bx-home——圖標來自 boxicons。可用於模板筆記。",
|
||||||
"page_size": "筆記列表中每頁的項目數",
|
"page_size": "筆記列表中每頁的項目數",
|
||||||
"custom_request_handler": "請參閱<a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">自訂請求處理程序</a>",
|
"custom_request_handler": "請參閱<a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">自訂請求處理程序</a>",
|
||||||
@@ -615,9 +615,9 @@
|
|||||||
"zoom_out": "縮小",
|
"zoom_out": "縮小",
|
||||||
"reset_zoom_level": "重置縮放級別",
|
"reset_zoom_level": "重置縮放級別",
|
||||||
"zoom_in": "放大",
|
"zoom_in": "放大",
|
||||||
"configure_launchbar": "設定啟動列",
|
"configure_launchbar": "設定啟動欄",
|
||||||
"show_shared_notes_subtree": "顯示分享筆記子階層",
|
"show_shared_notes_subtree": "顯示分享筆記子階層",
|
||||||
"advanced": "高級",
|
"advanced": "進階",
|
||||||
"open_dev_tools": "打開開發者工具",
|
"open_dev_tools": "打開開發者工具",
|
||||||
"open_sql_console": "打開 SQL 控制台",
|
"open_sql_console": "打開 SQL 控制台",
|
||||||
"open_sql_console_history": "打開 SQL 控制台歷史記錄",
|
"open_sql_console_history": "打開 SQL 控制台歷史記錄",
|
||||||
@@ -625,11 +625,11 @@
|
|||||||
"show_backend_log": "顯示後台日誌",
|
"show_backend_log": "顯示後台日誌",
|
||||||
"reload_hint": "重新載入可以幫助解決一些視覺故障,而無需重新啟動整個應用程式。",
|
"reload_hint": "重新載入可以幫助解決一些視覺故障,而無需重新啟動整個應用程式。",
|
||||||
"reload_frontend": "重新載入前端",
|
"reload_frontend": "重新載入前端",
|
||||||
"show_hidden_subtree": "顯示隱藏子階層",
|
"show_hidden_subtree": "顯示隱藏的子階層",
|
||||||
"show_help": "顯示說明",
|
"show_help": "顯示說明",
|
||||||
"about": "關於 TriliumNext 筆記",
|
"about": "關於 TriliumNext 筆記",
|
||||||
"logout": "登出",
|
"logout": "登出",
|
||||||
"show-cheatsheet": "顯示工具表",
|
"show-cheatsheet": "顯示快捷鍵說明",
|
||||||
"toggle-zen-mode": "禪模式"
|
"toggle-zen-mode": "禪模式"
|
||||||
},
|
},
|
||||||
"sync_status": {
|
"sync_status": {
|
||||||
@@ -654,13 +654,13 @@
|
|||||||
"search_in_note": "在筆記中搜尋",
|
"search_in_note": "在筆記中搜尋",
|
||||||
"note_source": "筆記原始碼",
|
"note_source": "筆記原始碼",
|
||||||
"note_attachments": "筆記附件",
|
"note_attachments": "筆記附件",
|
||||||
"open_note_externally": "用外部程序打開筆記",
|
"open_note_externally": "用外部程式打開筆記",
|
||||||
"open_note_externally_title": "檔案將在外部應用程式中打開並監視其更改。然後您可以將修改後的版本上傳回 Trilium。",
|
"open_note_externally_title": "檔案將在外部應用程式中打開並監視其更改。然後您可以將修改後的版本上傳回 Trilium。",
|
||||||
"open_note_custom": "使用自訂程序打開筆記",
|
"open_note_custom": "使用自訂程式打開筆記",
|
||||||
"import_files": "匯入檔案",
|
"import_files": "匯入檔案",
|
||||||
"export_note": "匯出筆記",
|
"export_note": "匯出筆記",
|
||||||
"delete_note": "刪除筆記",
|
"delete_note": "刪除筆記",
|
||||||
"print_note": "打印筆記",
|
"print_note": "列印筆記",
|
||||||
"save_revision": "儲存筆記歷史",
|
"save_revision": "儲存筆記歷史",
|
||||||
"convert_into_attachment_failed": "筆記 '{{title}}' 轉換失敗。",
|
"convert_into_attachment_failed": "筆記 '{{title}}' 轉換失敗。",
|
||||||
"convert_into_attachment_successful": "筆記 '{{title}}' 已成功轉換為附件。",
|
"convert_into_attachment_successful": "筆記 '{{title}}' 已成功轉換為附件。",
|
||||||
@@ -675,7 +675,7 @@
|
|||||||
"inactive": "點擊進入受保護的作業階段"
|
"inactive": "點擊進入受保護的作業階段"
|
||||||
},
|
},
|
||||||
"revisions_button": {
|
"revisions_button": {
|
||||||
"note_revisions": "筆記修改歷史"
|
"note_revisions": "筆記歷史版本"
|
||||||
},
|
},
|
||||||
"update_available": {
|
"update_available": {
|
||||||
"update_available": "有更新可用"
|
"update_available": "有更新可用"
|
||||||
@@ -781,7 +781,7 @@
|
|||||||
},
|
},
|
||||||
"note_info_widget": {
|
"note_info_widget": {
|
||||||
"note_id": "筆記 ID",
|
"note_id": "筆記 ID",
|
||||||
"created": "新增時間",
|
"created": "建立時間",
|
||||||
"modified": "修改時間",
|
"modified": "修改時間",
|
||||||
"type": "類型",
|
"type": "類型",
|
||||||
"note_size": "筆記大小",
|
"note_size": "筆記大小",
|
||||||
@@ -843,7 +843,7 @@
|
|||||||
"limit": "限制",
|
"limit": "限制",
|
||||||
"limit_description": "限制結果數量",
|
"limit_description": "限制結果數量",
|
||||||
"debug": "除錯",
|
"debug": "除錯",
|
||||||
"debug_description": "除錯將打印額外的除錯資訊至控制台,以幫助除錯複雜查詢",
|
"debug_description": "除錯將顯示額外的除錯資訊至控制台,以幫助除錯複雜查詢",
|
||||||
"action": "操作",
|
"action": "操作",
|
||||||
"search_button": "搜尋 <kbd>Enter</kbd>",
|
"search_button": "搜尋 <kbd>Enter</kbd>",
|
||||||
"search_execute": "搜尋並執行操作",
|
"search_execute": "搜尋並執行操作",
|
||||||
@@ -1056,7 +1056,7 @@
|
|||||||
"theme_defined": "跟隨主題",
|
"theme_defined": "跟隨主題",
|
||||||
"fonts": "字型",
|
"fonts": "字型",
|
||||||
"main_font": "主字型",
|
"main_font": "主字型",
|
||||||
"font_family": "字型家族",
|
"font_family": "字型",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"note_tree_font": "筆記樹字型",
|
"note_tree_font": "筆記樹字型",
|
||||||
"note_detail_font": "筆記內容字型",
|
"note_detail_font": "筆記內容字型",
|
||||||
@@ -1097,12 +1097,12 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"title": "主題",
|
"title": "主題",
|
||||||
"theme_label": "主題",
|
"theme_label": "主題",
|
||||||
"override_theme_fonts_label": "覆寫主題字型",
|
"override_theme_fonts_label": "更改主題字型",
|
||||||
"layout": "佈局",
|
"layout": "佈局",
|
||||||
"layout-vertical-title": "垂直",
|
"layout-vertical-title": "垂直",
|
||||||
"layout-horizontal-title": "水平",
|
"layout-horizontal-title": "水平",
|
||||||
"layout-vertical-description": "啟動列位於左側(預設)",
|
"layout-vertical-description": "啟動欄位於左側(預設)",
|
||||||
"layout-horizontal-description": "啟動列位於分頁欄下方,分頁欄現在是全寬的。",
|
"layout-horizontal-description": "啟動欄位於分頁欄下方,分頁欄現在是全寬的。",
|
||||||
"auto_theme": "傳統(遵循系統配色方案)",
|
"auto_theme": "傳統(遵循系統配色方案)",
|
||||||
"light_theme": "傳統(淺色)",
|
"light_theme": "傳統(淺色)",
|
||||||
"dark_theme": "傳統(深色)",
|
"dark_theme": "傳統(深色)",
|
||||||
@@ -1374,7 +1374,7 @@
|
|||||||
"hoist-note": "聚焦筆記",
|
"hoist-note": "聚焦筆記",
|
||||||
"unhoist-note": "取消聚焦筆記",
|
"unhoist-note": "取消聚焦筆記",
|
||||||
"edit-branch-prefix": "編輯分支前綴",
|
"edit-branch-prefix": "編輯分支前綴",
|
||||||
"advanced": "高級",
|
"advanced": "進階",
|
||||||
"expand-subtree": "展開子階層",
|
"expand-subtree": "展開子階層",
|
||||||
"collapse-subtree": "收摺子階層",
|
"collapse-subtree": "收摺子階層",
|
||||||
"sort-by": "排序方式…",
|
"sort-by": "排序方式…",
|
||||||
@@ -1494,7 +1494,7 @@
|
|||||||
"search_not_executed": "尚未執行搜尋。請點擊上方的「搜尋」按鈕查看結果。"
|
"search_not_executed": "尚未執行搜尋。請點擊上方的「搜尋」按鈕查看結果。"
|
||||||
},
|
},
|
||||||
"spacer": {
|
"spacer": {
|
||||||
"configure_launchbar": "設定啟動列"
|
"configure_launchbar": "設定啟動欄"
|
||||||
},
|
},
|
||||||
"sql_result": {
|
"sql_result": {
|
||||||
"no_rows": "此次查詢沒有返回任何數據"
|
"no_rows": "此次查詢沒有返回任何數據"
|
||||||
@@ -1778,12 +1778,12 @@
|
|||||||
},
|
},
|
||||||
"revisions_snapshot_interval": {
|
"revisions_snapshot_interval": {
|
||||||
"note_revisions_snapshot_interval_title": "筆記歷史快照間隔",
|
"note_revisions_snapshot_interval_title": "筆記歷史快照間隔",
|
||||||
"note_revisions_snapshot_description": "筆記歷史快照間隔是建立新筆記修訂的時間。如需詳細資訊,請參閱 <a href=\"https://triliumnext.github.io/Docs/Wiki/note-revisions.html\" class=\"external\">wiki</a>。",
|
"note_revisions_snapshot_description": "筆記歷史快照間隔是建立新筆記修訂的時間。如需詳細資訊,請參閱 <doc>wiki</doc>。",
|
||||||
"snapshot_time_interval_label": "筆記歷史快照時間間隔:"
|
"snapshot_time_interval_label": "筆記歷史快照時間間隔:"
|
||||||
},
|
},
|
||||||
"custom_date_time_format": {
|
"custom_date_time_format": {
|
||||||
"title": "自訂日期 / 時間格式",
|
"title": "自訂日期 / 時間格式",
|
||||||
"description": "透過 <kbd></kbd> 或工具列自訂插入日期和時間的格式。有關可用的格式及符號,請參閱 <a href=\"https://day.js.org/docs/en/display/format\" target=\"_blank\" rel=\"noopener noreferrer\">Day.js docs</a>。",
|
"description": "透過 <shortcut /> 或工具列自訂插入日期和時間的格式。有關可用的格式及符號,請參閱 <doc>Day.js docs</doc>。",
|
||||||
"format_string": "格式化字串:",
|
"format_string": "格式化字串:",
|
||||||
"formatted_time": "格式化日期 / 時間:"
|
"formatted_time": "格式化日期 / 時間:"
|
||||||
},
|
},
|
||||||
@@ -1981,8 +1981,8 @@
|
|||||||
"search_subtree_description": "在目前子階層中搜尋",
|
"search_subtree_description": "在目前子階層中搜尋",
|
||||||
"search_history_title": "顯示搜尋歷史",
|
"search_history_title": "顯示搜尋歷史",
|
||||||
"search_history_description": "查看搜尋記錄",
|
"search_history_description": "查看搜尋記錄",
|
||||||
"configure_launch_bar_title": "設定啟動列",
|
"configure_launch_bar_title": "設定啟動欄",
|
||||||
"configure_launch_bar_description": "打開啟動列設定以新增或移除項目。"
|
"configure_launch_bar_description": "打開啟動欄設定以新增或移除項目。"
|
||||||
},
|
},
|
||||||
"content_renderer": {
|
"content_renderer": {
|
||||||
"open_externally": "以外部程式打開"
|
"open_externally": "以外部程式打開"
|
||||||
@@ -1994,6 +1994,10 @@
|
|||||||
"call_to_action": {
|
"call_to_action": {
|
||||||
"background_effects_title": "背景特效已推出穩定版本",
|
"background_effects_title": "背景特效已推出穩定版本",
|
||||||
"background_effects_message": "在 Windows 裝置上,背景特效現在已完全穩定。背景特效透過模糊背後的背景,為使用者介面增添一抹色彩。此技術也用於其他應用程式,例如 Windows 檔案總管。",
|
"background_effects_message": "在 Windows 裝置上,背景特效現在已完全穩定。背景特效透過模糊背後的背景,為使用者介面增添一抹色彩。此技術也用於其他應用程式,例如 Windows 檔案總管。",
|
||||||
"background_effects_button": "啟用背景特效"
|
"background_effects_button": "啟用背景特效",
|
||||||
|
"next_theme_title": "試用新 Trilium 主題",
|
||||||
|
"next_theme_message": "您正在使用舊版主題,要試用新主題嗎?",
|
||||||
|
"next_theme_button": "試用新主題",
|
||||||
|
"dismiss": "關閉"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"add_link": "Додати посилання",
|
"add_link": "Додати посилання",
|
||||||
"help_on_links": "Довідка щодо посилань",
|
"help_on_links": "Довідка щодо посилань",
|
||||||
"note": "Нотатка",
|
"note": "Нотатка",
|
||||||
"search_note": "Знайти нотатку за ім'ям",
|
"search_note": "пошук нотатки за її назвою",
|
||||||
"link_title_mirrors": "заголовок посилання відображає назву нотатки",
|
"link_title_mirrors": "заголовок посилання відображає поточний заголовок нотатки",
|
||||||
"link_title_arbitrary": "свій заголовок посилання",
|
"link_title_arbitrary": "заголовок посилання можна змінювати довільно",
|
||||||
"link_title": "Заголовок посилання",
|
"link_title": "Заголовок посилання",
|
||||||
"button_add_link": "Додати посилання"
|
"button_add_link": "Додати посилання"
|
||||||
},
|
},
|
||||||
@@ -21,9 +21,10 @@
|
|||||||
"db_version": "Версія БД:",
|
"db_version": "Версія БД:",
|
||||||
"build_date": "Дата збірки:",
|
"build_date": "Дата збірки:",
|
||||||
"build_revision": "Ревізія збірки:",
|
"build_revision": "Ревізія збірки:",
|
||||||
"data_directory": "Директорія даних:",
|
"data_directory": "Каталог даних:",
|
||||||
"homepage": "Домашня сторінка:",
|
"homepage": "Домашня сторінка:",
|
||||||
"title": "Про Trilium Notes"
|
"title": "Про Trilium Notes",
|
||||||
|
"sync_version": "Версія синхронізації:"
|
||||||
},
|
},
|
||||||
"global_menu": {
|
"global_menu": {
|
||||||
"about": "Про Trilium Notes"
|
"about": "Про Trilium Notes"
|
||||||
@@ -33,22 +34,44 @@
|
|||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"critical-error": {
|
"critical-error": {
|
||||||
"title": "Критична помилка"
|
"title": "Критична помилка",
|
||||||
|
"message": "Сталася критична помилка, яка перешкоджає запуску клієнтської програми:\n\n{{message}}\n\nНайімовірніше, це спричинено несподіваною помилкою скрипту. Спробуйте запустити програму в безпечному режимі та вирішити проблему."
|
||||||
|
},
|
||||||
|
"widget-error": {
|
||||||
|
"title": "Помилка ініціалізації віджета",
|
||||||
|
"message-custom": "Не вдалося ініціалізувати користувацький віджет із нотатки з ID \"{{id}}\" під назвою \"{{title}}\" через:\n\n{{message}}",
|
||||||
|
"message-unknown": "Невідомий віджет не вдалося ініціалізувати через:\n\n{{message}}"
|
||||||
|
},
|
||||||
|
"bundle-error": {
|
||||||
|
"title": "Не вдалося завантажити власний скрипт",
|
||||||
|
"message": "Скрипт із нотатки з ID \"{{id}}\" під назвою \"{{title}}\" не вдалося виконати через:\n\n{{message}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulk_actions": {
|
"bulk_actions": {
|
||||||
"bulk_actions": "Масові дії",
|
"bulk_actions": "Масові дії",
|
||||||
"affected_notes": "Зачеплені нотатки",
|
"affected_notes": "Застосовані нотатки",
|
||||||
"available_actions": "Доступні дії",
|
"available_actions": "Доступні дії",
|
||||||
"chosen_actions": "Обрані дії",
|
"chosen_actions": "Обрані дії",
|
||||||
"execute_bulk_actions": "Виконати масові дії",
|
"execute_bulk_actions": "Виконання масових дій",
|
||||||
"bulk_actions_executed": "Масові дії успішно виконано.",
|
"bulk_actions_executed": "Масові дії успішно виконано.",
|
||||||
"none_yet": "Поки ніяких.. Додайте дію натиснувши на одну з наданих вище."
|
"none_yet": "Поки що немає... додайте дію, натиснувши одну з доступних вище.",
|
||||||
|
"include_descendants": "Включити нащадків вибраних нотаток",
|
||||||
|
"labels": "Мітки",
|
||||||
|
"relations": "Відносини",
|
||||||
|
"notes": "Нотатки",
|
||||||
|
"other": "Інше"
|
||||||
},
|
},
|
||||||
"clone_to": {
|
"clone_to": {
|
||||||
"clone_notes_to": "Клонувати нотатки до...",
|
"clone_notes_to": "Клонувати нотатки до...",
|
||||||
"target_parent_note": "Цільова батьківська нотатка",
|
"target_parent_note": "Цільова батьківська нотатка",
|
||||||
"search_for_note_by_its_name": "Знайти нотатку за ім'ям"
|
"search_for_note_by_its_name": "Знайти нотатку за назвою",
|
||||||
|
"help_on_links": "Допомога з посиланнями",
|
||||||
|
"notes_to_clone": "Нотатки для клонування",
|
||||||
|
"cloned_note_prefix_title": "Клонована нотатка буде відображатися в дереві нотаток із заданим префіксом",
|
||||||
|
"prefix_optional": "Префікс (необов'язково)",
|
||||||
|
"clone_to_selected_note": "Клонувати до вибраної нотатки",
|
||||||
|
"no_path_to_clone_to": "Немає шляху для клонування.",
|
||||||
|
"note_cloned": "Нотатку \"{{clonedTitle}}\" було клоновано в \"{{targetTitle}}\""
|
||||||
},
|
},
|
||||||
"clipboard": {
|
"clipboard": {
|
||||||
"copied": "Нотатку(-и) було скопійовано в буфер.",
|
"copied": "Нотатку(-и) було скопійовано в буфер.",
|
||||||
@@ -87,5 +110,139 @@
|
|||||||
"editor_type": {
|
"editor_type": {
|
||||||
"label": "Панель інструментів форматування"
|
"label": "Панель інструментів форматування"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"confirmation": "Підтвердження",
|
||||||
|
"cancel": "Скасувати",
|
||||||
|
"ok": "ОК",
|
||||||
|
"are_you_sure_remove_note": "Ви впевнені, що хочете видалити нотатку \"{{title}}\" з карти зв'язків? ",
|
||||||
|
"if_you_dont_check": "Якщо ви не позначите цей пункт, нотатку буде видалено лише з карти зв'язків.",
|
||||||
|
"also_delete_note": "Також видалити нотатку"
|
||||||
|
},
|
||||||
|
"delete_notes": {
|
||||||
|
"delete_notes_preview": "Видалити попередній перегляд нотаток",
|
||||||
|
"close": "Закрити",
|
||||||
|
"delete_all_clones_description": "Видалити також усі клони (можна скасувати в останніх змінах)",
|
||||||
|
"erase_notes_description": "Звичайне (м’яке) видалення лише позначає нотатки як видалені, і їх можна відновити (у діалоговому вікні останніх змін) протягом певного періоду часу. Якщо позначити цю опцію, нотатки будуть видалені негайно, і їх неможливо буде відновити.",
|
||||||
|
"erase_notes_warning": "Стерти нотатки назавжди (скасувати не можна), включаючи всі клони. Це призведе до перезавантаження програми.",
|
||||||
|
"notes_to_be_deleted": "Наступні нотатки будуть видалені ({{notesCount}})",
|
||||||
|
"no_note_to_delete": "Жодну нотатку не буде видалено (лише клони).",
|
||||||
|
"broken_relations_to_be_deleted": "Наступні зв'язки будуть розірвані та видалені ({{ relationCount}})",
|
||||||
|
"cancel": "Скасувати",
|
||||||
|
"ok": "ОК",
|
||||||
|
"deleted_relation_text": "Нотатка {{- note}} (буде видалена) посилається на зв'язок {{- relation}}, що походить з {{- source}}."
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"export_note_title": "Експорт нотатки",
|
||||||
|
"close": "Закрити",
|
||||||
|
"export_type_subtree": "Ця нотатка та всі її нащадки",
|
||||||
|
"format_html": "HTML – рекомендовано, оскільки він зберігає всі формати",
|
||||||
|
"format_html_zip": "HTML у ZIP-архіві – це рекомендовано, оскільки це зберігає все форматування.",
|
||||||
|
"format_markdown": "Markdown – це зберігає більшу частину форматування.",
|
||||||
|
"format_opml": "OPML – формат обміну структурами лише для тексту. Форматування, зображення та файли не включено.",
|
||||||
|
"opml_version_1": "OPML версії 1.0 – лише звичайний текст",
|
||||||
|
"opml_version_2": "OPML v2.0 - також дозволяє HTML",
|
||||||
|
"export_type_single": "Тільки ця нотатка без її нащадків",
|
||||||
|
"export": "Експорт",
|
||||||
|
"choose_export_type": "Спочатку виберіть тип експорту",
|
||||||
|
"export_status": "Стан експорту",
|
||||||
|
"export_in_progress": "Триває експорт: {{progressCount}}",
|
||||||
|
"export_finished_successfully": "Експорт успішно завершено.",
|
||||||
|
"format_pdf": "PDF – для друку або спільного використання."
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"title": "Шпаргалка",
|
||||||
|
"noteNavigation": "Навігація по нотатках",
|
||||||
|
"goUpDown": "переміститись вгору/вниз у списку нотаток",
|
||||||
|
"collapseExpand": "згорнути/розгорнути вузол",
|
||||||
|
"notSet": "не встановлено",
|
||||||
|
"goBackForwards": "повернутися назад / вперед в історії",
|
||||||
|
"showJumpToNoteDialog": "показати <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">діалогове вікно \"Перейти до\"</a>",
|
||||||
|
"scrollToActiveNote": "прокрутити до активної нотатки",
|
||||||
|
"jumpToParentNote": "перейти до батьківської нотатки",
|
||||||
|
"collapseWholeTree": "згорнути все дерево нотаток",
|
||||||
|
"collapseSubTree": "згорнути піддерево",
|
||||||
|
"tabShortcuts": "Швидкі клавіші вкладки",
|
||||||
|
"newTabNoteLink": "посилання на нотатку відкриває нотатку в новій вкладці",
|
||||||
|
"newTabWithActivationNoteLink": "посилання на нотатку відкривається та активує нотатку в новій вкладці",
|
||||||
|
"onlyInDesktop": "Тільки для ПК (збірка Electron)",
|
||||||
|
"openEmptyTab": "відкрити порожню вкладку",
|
||||||
|
"closeActiveTab": "закрити активну вкладку",
|
||||||
|
"activateNextTab": "активувати наступну вкладку",
|
||||||
|
"activatePreviousTab": "активувати попередню вкладку",
|
||||||
|
"creatingNotes": "Створення нотаток",
|
||||||
|
"createNoteAfter": "створити нову нотатку після активної нотатки",
|
||||||
|
"createNoteInto": "створити нову піднотатку в активній нотатці",
|
||||||
|
"editBranchPrefix": "редагувати <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/tree-concepts.html#prefix\">префікс</a> активного клону нотатки",
|
||||||
|
"movingCloningNotes": "Переміщення / клонування нотаток",
|
||||||
|
"moveNoteUpDown": "переміщення нотатки вгору/вниз у списку нотаток",
|
||||||
|
"moveNoteUpHierarchy": "перемістити нотатку вище в ієрархії",
|
||||||
|
"multiSelectNote": "множинний вибір нотатки вище/нижче",
|
||||||
|
"selectAllNotes": "вибрати всі нотатки на поточному рівні",
|
||||||
|
"selectNote": "вибрати нотатку",
|
||||||
|
"copyNotes": "копіювати активну нотатку (або поточний вибір) у буфер обміну (використовується для <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/cloning-notes.html#cloning-notes\">клонування</a>)",
|
||||||
|
"cutNotes": "вирізати поточну нотатку (або поточний вибір) у буфер обміну (використовується для переміщення нотаток)",
|
||||||
|
"pasteNotes": "вставити нотатку(и) як піднотатку в активну нотатку (яка або переміщується, або клонується залежно від того, чи була вона скопійована, чи вирізана в буфер обміну)",
|
||||||
|
"deleteNotes": "видалити нотатку / піддерево",
|
||||||
|
"editingNotes": "Редагування нотаток",
|
||||||
|
"editNoteTitle": "На панелі дерева перемкнеться з панелі дерева на назву нотатки. Введення з назви нотатки перемкне фокус на текстовий редактор. <kbd>Ctrl+.</kbd> перемкнеться назад з редактора на панель дерева.",
|
||||||
|
"createEditLink": "створити / редагувати зовнішнє посилання",
|
||||||
|
"createInternalLink": "створити внутрішнє посилання",
|
||||||
|
"followLink": "перейти за посиланням під курсором",
|
||||||
|
"insertDateTime": "вставити поточну дату та час у позицію курсору",
|
||||||
|
"jumpToTreePane": "перейти до панелі дерева та прокрутити до активної нотатки",
|
||||||
|
"markdownAutoformat": "Автоформатування, подібне до Markdown",
|
||||||
|
"headings": "<code>##</code>, <code>###</code>, <code>####</code> тощо, а потім пробіл для заголовків",
|
||||||
|
"bulletList": "<code>*</code> або <code>-</code> з пробілом для маркованого списку",
|
||||||
|
"numberedList": "<code>1.</code> або <code>1)</code>, а потім пробіл для нумерованого списку",
|
||||||
|
"blockQuote": "починайте рядок з <code>></code>, а потім пробіл для цитування блоку",
|
||||||
|
"troubleshooting": "Усунення несправностей",
|
||||||
|
"reloadFrontend": "перезавантажити інтерфейс Trilium",
|
||||||
|
"showDevTools": "показати інструменти розробника",
|
||||||
|
"showSQLConsole": "показати консоль SQL",
|
||||||
|
"other": "Інше",
|
||||||
|
"quickSearch": "зосередження на швидкому введенні пошукового запиту",
|
||||||
|
"inPageSearch": "пошук на сторінці"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"importIntoNote": "Імпортувати в нотатку",
|
||||||
|
"chooseImportFile": "Вибрати файл імпорту",
|
||||||
|
"importDescription": "Вміст вибраного(их) файлу(ів) буде імпортовано як дочірню(і) нотатку(и) до",
|
||||||
|
"options": "Опції",
|
||||||
|
"safeImportTooltip": "Експортовані файли Trilium <code>.zip</code> можуть містити виконувані скрипти, які можуть мати шкідливу поведінку. Безпечний імпорт деактивує автоматичне виконання всіх імпортованих скриптів. Зніміть позначку \"Безпечний імпорт\", лише якщо імпортований архів має містити виконувані скрипти, і ви повністю довіряєте вмісту файлу імпорту.",
|
||||||
|
"safeImport": "Безпечний імпорт",
|
||||||
|
"explodeArchivesTooltip": "Якщо цей прапорець позначено, Trilium читатиме файли <code>.zip</code>, <code>.enex</code> та <code>.opml</code> і створюватиме нотатки з файлів усередині цих архівів. Якщо прапорець знято, Trilium додаватиме самі архіви до нотатки.",
|
||||||
|
"explodeArchives": "Зчитати вміст архівів <code>.zip</code>, <code>.enex</code> та <code>.opml</code>.",
|
||||||
|
"shrinkImagesTooltip": "<p>Якщо ви позначите цей параметр, Trilium спробує зменшити імпортовані зображення шляхом масштабування та оптимізації, що може вплинути на сприйняту якість зображення. Якщо не позначити, зображення будуть імпортовані без змін.</p><p>Це не стосується імпорту <code>.zip</code> з метаданими, оскільки передбачається, що ці файли вже оптимізовані.</p>",
|
||||||
|
"shrinkImages": "Зменшити зображення",
|
||||||
|
"textImportedAsText": "Імпортувати HTML, Markdown та TXT як текстові нотатки, якщо це незрозуміло з метаданих",
|
||||||
|
"codeImportedAsCode": "Імпортувати розпізнані файли коду (наприклад, <code>.json</code>) як нотатки до коду, якщо це незрозуміло з метаданих",
|
||||||
|
"replaceUnderscoresWithSpaces": "Замінити підкреслення пробілами в назвах імпортованих нотаток",
|
||||||
|
"import": "Імпорт",
|
||||||
|
"failed": "Помилка імпорту: {{message}}."
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"title": "Підказка",
|
||||||
|
"ok": "ОК",
|
||||||
|
"defaultTitle": "Підказка"
|
||||||
|
},
|
||||||
|
"protected_session_password": {
|
||||||
|
"modal_title": "Захищений сеанс",
|
||||||
|
"help_title": "Довідка щодо захищених нотаток",
|
||||||
|
"close_label": "Закрити",
|
||||||
|
"form_label": "Щоб продовжити запитувану дію, вам потрібно розпочати захищений сеанс, ввівши пароль:",
|
||||||
|
"start_button": "Розпочати захищений сеанс"
|
||||||
|
},
|
||||||
|
"recent_changes": {
|
||||||
|
"title": "Останні зміни",
|
||||||
|
"erase_notes_button": "Стерти видалені нотатки зараз",
|
||||||
|
"deleted_notes_message": "Видалені нотатки стерто.",
|
||||||
|
"no_changes_message": "Поки що жодних змін...",
|
||||||
|
"undelete_link": "відновити",
|
||||||
|
"confirm_undelete": "Ви хочете відновити цю нотатку та її піднотатки?"
|
||||||
|
},
|
||||||
|
"revisions": {
|
||||||
|
"note_revisions": "Зміни нотаток",
|
||||||
|
"delete_all_revisions": "Видалити всі редакції цієї нотатки"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,5 +73,8 @@
|
|||||||
},
|
},
|
||||||
"rename_label": {
|
"rename_label": {
|
||||||
"rename_label": "Đặt lại tên nhãn"
|
"rename_label": "Đặt lại tên nhãn"
|
||||||
|
},
|
||||||
|
"call_to_action": {
|
||||||
|
"dismiss": "Bỏ qua"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
|||||||
this.$inputName = this.$widget.find(".attr-input-name");
|
this.$inputName = this.$widget.find(".attr-input-name");
|
||||||
this.$inputName.on("input", (ev) => {
|
this.$inputName.on("input", (ev) => {
|
||||||
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
|
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
|
||||||
// https://github.com/zadam/trilium/pull/3812
|
// https://github.com/TriliumNext/Trilium/pull/3812
|
||||||
this.userEditedAttribute();
|
this.userEditedAttribute();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -383,7 +383,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
|||||||
this.$inputValue = this.$widget.find(".attr-input-value");
|
this.$inputValue = this.$widget.find(".attr-input-value");
|
||||||
this.$inputValue.on("input", (ev) => {
|
this.$inputValue.on("input", (ev) => {
|
||||||
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
|
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
|
||||||
// https://github.com/zadam/trilium/pull/3812
|
// https://github.com/TriliumNext/Trilium/pull/3812
|
||||||
this.userEditedAttribute();
|
this.userEditedAttribute();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -421,7 +421,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
|||||||
this.$inputInverseRelation = this.$widget.find(".attr-input-inverse-relation");
|
this.$inputInverseRelation = this.$widget.find(".attr-input-inverse-relation");
|
||||||
this.$inputInverseRelation.on("input", (ev) => {
|
this.$inputInverseRelation.on("input", (ev) => {
|
||||||
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
|
if (!(ev.originalEvent as KeyboardEvent)?.isComposing) {
|
||||||
// https://github.com/zadam/trilium/pull/3812
|
// https://github.com/TriliumNext/Trilium/pull/3812
|
||||||
this.userEditedAttribute();
|
this.userEditedAttribute();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
|
|||||||
this.attributeDetailWidget.hide();
|
this.attributeDetailWidget.hide();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.$editor.on("blur", () => setTimeout(() => this.save(), 100)); // Timeout to fix https://github.com/zadam/trilium/issues/4160
|
this.$editor.on("blur", () => setTimeout(() => this.save(), 100)); // Timeout to fix https://github.com/TriliumNext/Trilium/issues/4160
|
||||||
|
|
||||||
this.$addNewAttributeButton = this.$widget.find(".add-new-attribute-button");
|
this.$addNewAttributeButton = this.$widget.find(".add-new-attribute-button");
|
||||||
this.$addNewAttributeButton.on("click", (e) => this.addNewAttribute(e));
|
this.$addNewAttributeButton.on("click", (e) => this.addNewAttribute(e));
|
||||||
@@ -282,7 +282,7 @@ export default class AttributeEditorWidget extends NoteContextAwareWidget implem
|
|||||||
|
|
||||||
async save() {
|
async save() {
|
||||||
if (this.lastUpdatedNoteId !== this.noteId) {
|
if (this.lastUpdatedNoteId !== this.noteId) {
|
||||||
// https://github.com/zadam/trilium/issues/3090
|
// https://github.com/TriliumNext/Trilium/issues/3090
|
||||||
console.warn("Ignoring blur event because a different note is loaded.");
|
console.warn("Ignoring blur event because a different note is loaded.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import FlexContainer from "./flex_container.js";
|
|||||||
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
|
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
|
||||||
import type BasicWidget from "../basic_widget.js";
|
import type BasicWidget from "../basic_widget.js";
|
||||||
import type NoteContext from "../../components/note_context.js";
|
import type NoteContext from "../../components/note_context.js";
|
||||||
|
import Component from "../../components/component.js";
|
||||||
|
|
||||||
interface NoteContextEvent {
|
interface NoteContextEvent {
|
||||||
noteContext: NoteContext;
|
noteContext: NoteContext;
|
||||||
@@ -152,6 +153,8 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
|||||||
for (const ntxId of ntxIds) {
|
for (const ntxId of ntxIds) {
|
||||||
this.$widget.find(`[data-ntx-id="${ntxId}"]`).remove();
|
this.$widget.find(`[data-ntx-id="${ntxId}"]`).remove();
|
||||||
|
|
||||||
|
const widget = this.widgets[ntxId];
|
||||||
|
recursiveCleanup(widget);
|
||||||
delete this.widgets[ntxId];
|
delete this.widgets[ntxId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,3 +240,12 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
|||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recursiveCleanup(widget: Component) {
|
||||||
|
for (const child of widget.children) {
|
||||||
|
recursiveCleanup(child);
|
||||||
|
}
|
||||||
|
if ("cleanup" in widget && typeof widget.cleanup === "function") {
|
||||||
|
widget.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function AddLinkDialogComponent() {
|
|||||||
}}
|
}}
|
||||||
show={shown}
|
show={shown}
|
||||||
>
|
>
|
||||||
<FormGroup label={t("add_link.note")}>
|
<FormGroup label={t("add_link.note")} name="note">
|
||||||
<NoteAutocomplete
|
<NoteAutocomplete
|
||||||
inputRef={autocompleteRef}
|
inputRef={autocompleteRef}
|
||||||
onChange={setSuggestion}
|
onChange={setSuggestion}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function BranchPrefixDialogComponent() {
|
|||||||
footer={<Button text={t("branch_prefix.save")} />}
|
footer={<Button text={t("branch_prefix.save")} />}
|
||||||
show={shown}
|
show={shown}
|
||||||
>
|
>
|
||||||
<FormGroup label={t("branch_prefix.prefix")}>
|
<FormGroup label={t("branch_prefix.prefix")} name="prefix">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
|
<input class="branch-prefix-input form-control" value={prefix} ref={branchInput}
|
||||||
onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />
|
onChange={(e) => setPrefix((e.target as HTMLInputElement).value)} />
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ function AvailableActionsList() {
|
|||||||
<td>{ actionGroup.title }:</td>
|
<td>{ actionGroup.title }:</td>
|
||||||
{actionGroup.actions.map(({ actionName, actionTitle }) =>
|
{actionGroup.actions.map(({ actionName, actionTitle }) =>
|
||||||
<Button
|
<Button
|
||||||
small text={actionTitle}
|
size="small"
|
||||||
|
text={actionTitle}
|
||||||
onClick={() => bulk_action.addAction("_bulkAction", actionName)}
|
onClick={() => bulk_action.addAction("_bulkAction", actionName)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Button from "../react/Button";
|
|||||||
import Modal from "../react/Modal";
|
import Modal from "../react/Modal";
|
||||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||||
import { CallToAction, dismissCallToAction, getCallToActions } from "./call_to_action_definitions";
|
import { CallToAction, dismissCallToAction, getCallToActions } from "./call_to_action_definitions";
|
||||||
|
import { t } from "../../services/i18n";
|
||||||
|
|
||||||
function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActions: CallToAction[] }) {
|
function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActions: CallToAction[] }) {
|
||||||
if (!activeCallToActions.length) {
|
if (!activeCallToActions.length) {
|
||||||
@@ -30,7 +31,7 @@ function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActi
|
|||||||
onHidden={() => setShown(false)}
|
onHidden={() => setShown(false)}
|
||||||
footerAlignment="between"
|
footerAlignment="between"
|
||||||
footer={<>
|
footer={<>
|
||||||
<Button text="Dismiss" onClick={async () => {
|
<Button text={t("call_to_action.dismiss")} onClick={async () => {
|
||||||
await dismissCallToAction(activeItem.id);
|
await dismissCallToAction(activeItem.id);
|
||||||
goToNext();
|
goToNext();
|
||||||
}} />
|
}} />
|
||||||
|
|||||||
@@ -69,15 +69,15 @@ function CloneToDialogComponent() {
|
|||||||
>
|
>
|
||||||
<h5>{t("clone_to.notes_to_clone")}</h5>
|
<h5>{t("clone_to.notes_to_clone")}</h5>
|
||||||
<NoteList style={{ maxHeight: "200px", overflow: "auto" }} noteIds={clonedNoteIds} />
|
<NoteList style={{ maxHeight: "200px", overflow: "auto" }} noteIds={clonedNoteIds} />
|
||||||
<FormGroup label={t("clone_to.target_parent_note")}>
|
<FormGroup name="target-parent-note" label={t("clone_to.target_parent_note")}>
|
||||||
<NoteAutocomplete
|
<NoteAutocomplete
|
||||||
placeholder={t("clone_to.search_for_note_by_its_name")}
|
placeholder={t("clone_to.search_for_note_by_its_name")}
|
||||||
onChange={setSuggestion}
|
onChange={setSuggestion}
|
||||||
inputRef={autoCompleteRef}
|
inputRef={autoCompleteRef}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup label={t("clone_to.prefix_optional")} title={t("clone_to.cloned_note_prefix_title")}>
|
<FormGroup name="clone-prefix" label={t("clone_to.prefix_optional")} title={t("clone_to.cloned_note_prefix_title")}>
|
||||||
<FormTextBox name="clone-prefix" onChange={setPrefix} />
|
<FormTextBox onChange={setPrefix} />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import tree from "../../services/tree";
|
|||||||
import Button from "../react/Button";
|
import Button from "../react/Button";
|
||||||
import FormCheckbox from "../react/FormCheckbox";
|
import FormCheckbox from "../react/FormCheckbox";
|
||||||
import FormFileUpload from "../react/FormFileUpload";
|
import FormFileUpload from "../react/FormFileUpload";
|
||||||
import FormGroup from "../react/FormGroup";
|
import FormGroup, { FormMultiGroup } from "../react/FormGroup";
|
||||||
import Modal from "../react/Modal";
|
import Modal from "../react/Modal";
|
||||||
import RawHtml from "../react/RawHtml";
|
import RawHtml from "../react/RawHtml";
|
||||||
import ReactBasicWidget from "../react/ReactBasicWidget";
|
import ReactBasicWidget from "../react/ReactBasicWidget";
|
||||||
@@ -55,11 +55,11 @@ function ImportDialogComponent() {
|
|||||||
footer={<Button text={t("import.import")} primary disabled={!files} />}
|
footer={<Button text={t("import.import")} primary disabled={!files} />}
|
||||||
show={shown}
|
show={shown}
|
||||||
>
|
>
|
||||||
<FormGroup label={t("import.chooseImportFile")} description={<>{t("import.importDescription")} <strong>{ noteTitle }</strong></>}>
|
<FormGroup name="files" label={t("import.chooseImportFile")} description={<>{t("import.importDescription")} <strong>{ noteTitle }</strong></>}>
|
||||||
<FormFileUpload multiple onChange={setFiles} />
|
<FormFileUpload multiple onChange={setFiles} />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup label={t("import.options")}>
|
<FormMultiGroup label={t("import.options")}>
|
||||||
<FormCheckbox
|
<FormCheckbox
|
||||||
name="safe-import" hint={t("import.safeImportTooltip")} label={t("import.safeImport")}
|
name="safe-import" hint={t("import.safeImportTooltip")} label={t("import.safeImport")}
|
||||||
currentValue={safeImport} onChange={setSafeImport}
|
currentValue={safeImport} onChange={setSafeImport}
|
||||||
@@ -84,7 +84,7 @@ function ImportDialogComponent() {
|
|||||||
name="replace-underscores-with-spaces" label={t("import.replaceUnderscoresWithSpaces")}
|
name="replace-underscores-with-spaces" label={t("import.replaceUnderscoresWithSpaces")}
|
||||||
currentValue={replaceUnderscoresWithSpaces} onChange={setReplaceUnderscoresWithSpaces}
|
currentValue={replaceUnderscoresWithSpaces} onChange={setReplaceUnderscoresWithSpaces}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormMultiGroup>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function IncludeNoteDialogComponent() {
|
|||||||
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
|
||||||
show={shown}
|
show={shown}
|
||||||
>
|
>
|
||||||
<FormGroup label={t("include_note.label_note")}>
|
<FormGroup name="note" label={t("include_note.label_note")}>
|
||||||
<NoteAutocomplete
|
<NoteAutocomplete
|
||||||
placeholder={t("include_note.placeholder_search")}
|
placeholder={t("include_note.placeholder_search")}
|
||||||
onChange={setSuggestion}
|
onChange={setSuggestion}
|
||||||
@@ -55,8 +55,9 @@ function IncludeNoteDialogComponent() {
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup label={t("include_note.box_size_prompt")}>
|
<FormGroup name="include-note-box-size" label={t("include_note.box_size_prompt")}>
|
||||||
<FormRadioGroup name="include-note-box-size"
|
<FormRadioGroup
|
||||||
|
name="include-note-box-size"
|
||||||
currentValue={boxSize} onChange={setBoxSize}
|
currentValue={boxSize} onChange={setBoxSize}
|
||||||
values={[
|
values={[
|
||||||
{ label: t("include_note.box_size_small"), value: "small" },
|
{ label: t("include_note.box_size_small"), value: "small" },
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function MoveToDialogComponent() {
|
|||||||
<h5>{t("move_to.notes_to_move")}</h5>
|
<h5>{t("move_to.notes_to_move")}</h5>
|
||||||
<NoteList branchIds={movedBranchIds} />
|
<NoteList branchIds={movedBranchIds} />
|
||||||
|
|
||||||
<FormGroup label={t("move_to.target_parent_note")}>
|
<FormGroup name="parent-note" label={t("move_to.target_parent_note")}>
|
||||||
<NoteAutocomplete
|
<NoteAutocomplete
|
||||||
onChange={setSuggestion}
|
onChange={setSuggestion}
|
||||||
inputRef={autoCompleteRef}
|
inputRef={autoCompleteRef}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function NoteTypeChooserDialogComponent() {
|
|||||||
show={shown}
|
show={shown}
|
||||||
stackable
|
stackable
|
||||||
>
|
>
|
||||||
<FormGroup label={t("note_type_chooser.change_path_prompt")}>
|
<FormGroup name="parent-note" label={t("note_type_chooser.change_path_prompt")}>
|
||||||
<NoteAutocomplete
|
<NoteAutocomplete
|
||||||
onChange={setParentNote}
|
onChange={setParentNote}
|
||||||
placeholder={t("note_type_chooser.search_placeholder")}
|
placeholder={t("note_type_chooser.search_placeholder")}
|
||||||
@@ -95,7 +95,7 @@ function NoteTypeChooserDialogComponent() {
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup label={t("note_type_chooser.modal_body")}>
|
<FormGroup name="note-type" label={t("note_type_chooser.modal_body")}>
|
||||||
<FormList onSelect={onNoteTypeSelected}>
|
<FormList onSelect={onNoteTypeSelected}>
|
||||||
{noteTypes.map((_item) => {
|
{noteTypes.map((_item) => {
|
||||||
if (_item.title === "----") {
|
if (_item.title === "----") {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface PromptDialogOptions {
|
|||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
shown?: PromptShownDialogCallback;
|
shown?: PromptShownDialogCallback;
|
||||||
callback?: (value: string | null) => void;
|
callback?: (value: string | null) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PromptDialogComponent() {
|
function PromptDialogComponent() {
|
||||||
@@ -32,24 +33,26 @@ function PromptDialogComponent() {
|
|||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const labelRef = useRef<HTMLLabelElement>(null);
|
const labelRef = useRef<HTMLLabelElement>(null);
|
||||||
const answerRef = useRef<HTMLInputElement>(null);
|
const answerRef = useRef<HTMLInputElement>(null);
|
||||||
const [ opts, setOpts ] = useState<PromptDialogOptions>();
|
const opts = useRef<PromptDialogOptions>();
|
||||||
const [ value, setValue ] = useState("");
|
const [ value, setValue ] = useState("");
|
||||||
const [ shown, setShown ] = useState(false);
|
const [ shown, setShown ] = useState(false);
|
||||||
|
const submitValue = useRef<string>(null);
|
||||||
|
|
||||||
useTriliumEvent("showPromptDialog", (opts) => {
|
useTriliumEvent("showPromptDialog", (newOpts) => {
|
||||||
setOpts(opts);
|
opts.current = newOpts;
|
||||||
|
setValue(newOpts.defaultValue ?? "");
|
||||||
setShown(true);
|
setShown(true);
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className="prompt-dialog"
|
className="prompt-dialog"
|
||||||
title={opts?.title ?? t("prompt.title")}
|
title={opts.current?.title ?? t("prompt.title")}
|
||||||
size="lg"
|
size="lg"
|
||||||
zIndex={2000}
|
zIndex={2000}
|
||||||
modalRef={modalRef} formRef={formRef}
|
modalRef={modalRef} formRef={formRef}
|
||||||
onShown={() => {
|
onShown={() => {
|
||||||
opts?.shown?.({
|
opts.current?.shown?.({
|
||||||
$dialog: refToJQuerySelector(modalRef),
|
$dialog: refToJQuerySelector(modalRef),
|
||||||
$question: refToJQuerySelector(labelRef),
|
$question: refToJQuerySelector(labelRef),
|
||||||
$answer: refToJQuerySelector(answerRef),
|
$answer: refToJQuerySelector(answerRef),
|
||||||
@@ -58,24 +61,25 @@ function PromptDialogComponent() {
|
|||||||
answerRef.current?.focus();
|
answerRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
const modal = BootstrapModal.getOrCreateInstance(modalRef.current!);
|
submitValue.current = value;
|
||||||
modal.hide();
|
setShown(false);
|
||||||
|
|
||||||
opts?.callback?.(value);
|
|
||||||
}}
|
}}
|
||||||
onHidden={() => {
|
onHidden={() => {
|
||||||
opts?.callback?.(null);
|
|
||||||
setShown(false);
|
setShown(false);
|
||||||
|
opts.current?.callback?.(submitValue.current);
|
||||||
|
submitValue.current = null;
|
||||||
|
opts.current = undefined;
|
||||||
}}
|
}}
|
||||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
|
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
|
||||||
show={shown}
|
show={shown}
|
||||||
stackable
|
stackable
|
||||||
>
|
>
|
||||||
<FormGroup label={opts?.message} labelRef={labelRef}>
|
<FormGroup name="prompt-dialog-answer" label={opts.current?.message} labelRef={labelRef}>
|
||||||
<FormTextBox
|
<FormTextBox
|
||||||
name="prompt-dialog-answer"
|
|
||||||
inputRef={answerRef}
|
inputRef={answerRef}
|
||||||
currentValue={value} onChange={setValue} />
|
currentValue={value} onChange={setValue}
|
||||||
|
readOnly={opts.current?.readOnly}
|
||||||
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ function RecentChangesDialogComponent() {
|
|||||||
header={
|
header={
|
||||||
<Button
|
<Button
|
||||||
text={t("recent_changes.erase_notes_button")}
|
text={t("recent_changes.erase_notes_button")}
|
||||||
small style={{ padding: "0 10px" }}
|
size="small"
|
||||||
|
style={{ padding: "0 10px" }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
server.post("notes/erase-deleted-notes-now").then(() => {
|
server.post("notes/erase-deleted-notes-now").then(() => {
|
||||||
setNeedsRefresh(true);
|
setNeedsRefresh(true);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function RevisionsDialogComponent() {
|
|||||||
helpPageId="vZWERwf8U3nx"
|
helpPageId="vZWERwf8U3nx"
|
||||||
bodyStyle={{ display: "flex", height: "80vh" }}
|
bodyStyle={{ display: "flex", height: "80vh" }}
|
||||||
header={
|
header={
|
||||||
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} small style={{ padding: "0 10px" }}
|
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} size="small" style={{ padding: "0 10px" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const text = t("revisions.confirm_delete_all");
|
const text = t("revisions.confirm_delete_all");
|
||||||
|
|
||||||
|
|||||||
@@ -83,11 +83,8 @@ function SortChildNotesDialogComponent() {
|
|||||||
label={t("sort_child_notes.sort_with_respect_to_different_character_sorting")}
|
label={t("sort_child_notes.sort_with_respect_to_different_character_sorting")}
|
||||||
currentValue={sortNatural} onChange={setSortNatural}
|
currentValue={sortNatural} onChange={setSortNatural}
|
||||||
/>
|
/>
|
||||||
<FormGroup className="form-check" label={t("sort_child_notes.natural_sort_language")} description={t("sort_child_notes.the_language_code_for_natural_sort")}>
|
<FormGroup name="sort-locale" className="form-check" label={t("sort_child_notes.natural_sort_language")} description={t("sort_child_notes.the_language_code_for_natural_sort")}>
|
||||||
<FormTextBox
|
<FormTextBox currentValue={sortLocale} onChange={setSortLocale} />
|
||||||
name="sort-locale"
|
|
||||||
currentValue={sortLocale} onChange={setSortLocale}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,13 +51,12 @@ function UploadAttachmentsDialogComponent() {
|
|||||||
onHidden={() => setShown(false)}
|
onHidden={() => setShown(false)}
|
||||||
show={shown}
|
show={shown}
|
||||||
>
|
>
|
||||||
<FormGroup label={t("upload_attachments.choose_files")} description={description}>
|
<FormGroup name="files" label={t("upload_attachments.choose_files")} description={description}>
|
||||||
<FormFileUpload onChange={setFiles} multiple />
|
<FormFileUpload onChange={setFiles} multiple />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup label={t("upload_attachments.options")}>
|
<FormGroup name="shrink-images" label={t("upload_attachments.options")}>
|
||||||
<FormCheckbox
|
<FormCheckbox
|
||||||
name="shrink-images"
|
|
||||||
hint={t("upload_attachments.tooltip")} label={t("upload_attachments.shrink_images")}
|
hint={t("upload_attachments.tooltip")} label={t("upload_attachments.shrink_images")}
|
||||||
currentValue={shrinkImages} onChange={setShrinkImages}
|
currentValue={shrinkImages} onChange={setShrinkImages}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
|
|||||||
* sets full height of container that contains note content for a subset of note-types
|
* sets full height of container that contains note content for a subset of note-types
|
||||||
*/
|
*/
|
||||||
checkFullHeight() {
|
checkFullHeight() {
|
||||||
// https://github.com/zadam/trilium/issues/2522
|
// https://github.com/TriliumNext/Trilium/issues/2522
|
||||||
const isBackendNote = this.noteContext?.noteId === "_backendLog";
|
const isBackendNote = this.noteContext?.noteId === "_backendLog";
|
||||||
const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
|
const isSqlNote = this.mime === "text/x-sqlite;schema=trilium";
|
||||||
const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid", "file"].includes(this.type ?? "");
|
const isFullHeightNoteType = ["canvas", "webView", "noteMap", "mindMap", "mermaid", "file"].includes(this.type ?? "");
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default class NoteListWidget extends NoteContextAwareWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// there seems to be a race condition on Firefox which triggers the observer only before the widget is visible
|
// there seems to be a race condition on Firefox which triggers the observer only before the widget is visible
|
||||||
// (intersection is false). https://github.com/zadam/trilium/issues/4165
|
// (intersection is false). https://github.com/TriliumNext/Trilium/issues/4165
|
||||||
setTimeout(() => observer.observe(this.$widget[0]), 10);
|
setTimeout(() => observer.observe(this.$widget[0]), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
|||||||
setupNoteTitleTooltip() {
|
setupNoteTitleTooltip() {
|
||||||
// the following will dynamically set tree item's tooltip if the whole item's text is not currently visible
|
// the following will dynamically set tree item's tooltip if the whole item's text is not currently visible
|
||||||
// if the whole text is visible then no tooltip is show since that's unnecessarily distracting
|
// if the whole text is visible then no tooltip is show since that's unnecessarily distracting
|
||||||
// see https://github.com/zadam/trilium/pull/1120 for discussion
|
// see https://github.com/TriliumNext/Trilium/pull/1120 for discussion
|
||||||
|
|
||||||
// code inspired by https://gist.github.com/jtsternberg/c272d7de5b967cec2d3d
|
// code inspired by https://gist.github.com/jtsternberg/c272d7de5b967cec2d3d
|
||||||
const isEnclosing = ($container: JQuery<HTMLElement>, $sub: JQuery<HTMLElement>) => {
|
const isEnclosing = ($container: JQuery<HTMLElement>, $sub: JQuery<HTMLElement>) => {
|
||||||
@@ -952,7 +952,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
|||||||
|
|
||||||
await this.filterHoistedBranch(true);
|
await this.filterHoistedBranch(true);
|
||||||
|
|
||||||
// don't activate the active note, see discussion in https://github.com/zadam/trilium/issues/3664
|
// don't activate the active note, see discussion in https://github.com/TriliumNext/Trilium/issues/3664
|
||||||
}
|
}
|
||||||
|
|
||||||
async expandTree(node: Fancytree.FancytreeNode | null = null) {
|
async expandTree(node: Fancytree.FancytreeNode | null = null) {
|
||||||
@@ -1181,7 +1181,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
|||||||
/*
|
/*
|
||||||
* We're collapsing notes after a period of inactivity to "cleanup" the tree - users rarely
|
* We're collapsing notes after a period of inactivity to "cleanup" the tree - users rarely
|
||||||
* collapse the notes and the tree becomes unusuably large.
|
* collapse the notes and the tree becomes unusuably large.
|
||||||
* Some context: https://github.com/zadam/trilium/issues/1192
|
* Some context: https://github.com/TriliumNext/Trilium/issues/1192
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const noteIdsToKeepExpanded = new Set(
|
const noteIdsToKeepExpanded = new Set(
|
||||||
@@ -1429,7 +1429,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeNodeFocused) {
|
if (activeNodeFocused) {
|
||||||
// needed by Firefox: https://github.com/zadam/trilium/issues/1865
|
// needed by Firefox: https://github.com/TriliumNext/Trilium/issues/1865
|
||||||
this.tree.$container.focus();
|
this.tree.$container.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ interface QuickSearchResponse {
|
|||||||
highlightedNotePathTitle: string;
|
highlightedNotePathTitle: string;
|
||||||
contentSnippet?: string;
|
contentSnippet?: string;
|
||||||
highlightedContentSnippet?: string;
|
highlightedContentSnippet?: string;
|
||||||
|
attributeSnippet?: string;
|
||||||
|
highlightedAttributeSnippet?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
}>;
|
}>;
|
||||||
error: string;
|
error: string;
|
||||||
@@ -241,7 +243,12 @@ export default class QuickSearchWidget extends BasicWidget {
|
|||||||
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
|
<span style="flex: 1;" class="search-result-title">${result.highlightedNotePathTitle}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Add content snippet below the title if available
|
// Add attribute snippet (tags/attributes) below the title if available
|
||||||
|
if (result.highlightedAttributeSnippet) {
|
||||||
|
itemHtml += `<div style="font-size: 0.75em; color: var(--muted-text-color); opacity: 0.5; margin-left: 20px; margin-top: 2px; line-height: 1.2;" class="search-result-attributes">${result.highlightedAttributeSnippet}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add content snippet below the attributes if available
|
||||||
if (result.highlightedContentSnippet) {
|
if (result.highlightedContentSnippet) {
|
||||||
itemHtml += `<div style="font-size: 0.85em; color: var(--main-text-color); opacity: 0.7; margin-left: 20px; margin-top: 4px; line-height: 1.3;" class="search-result-content">${result.highlightedContentSnippet}</div>`;
|
itemHtml += `<div style="font-size: 0.85em; color: var(--main-text-color); opacity: 0.7; margin-left: 20px; margin-top: 4px; line-height: 1.3;" class="search-result-content">${result.highlightedContentSnippet}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/client/src/widgets/react/Admonition.tsx
Normal file
14
apps/client/src/widgets/react/Admonition.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ComponentChildren } from "preact";
|
||||||
|
|
||||||
|
interface AdmonitionProps {
|
||||||
|
type: "warning" | "note" | "caution";
|
||||||
|
children: ComponentChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Admonition({ type, children }: AdmonitionProps) {
|
||||||
|
return (
|
||||||
|
<div className={`admonition ${type}`} role="alert">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ComponentChildren } from "preact";
|
import { ComponentChildren } from "preact";
|
||||||
|
|
||||||
interface AlertProps {
|
interface AlertProps {
|
||||||
type: "info" | "danger";
|
type: "info" | "danger" | "warning";
|
||||||
title?: string;
|
title?: string;
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRef, useMemo } from "preact/hooks";
|
|||||||
import { memo } from "preact/compat";
|
import { memo } from "preact/compat";
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
|
name?: string;
|
||||||
/** Reference to the button element. Mostly useful for requesting focus. */
|
/** Reference to the button element. Mostly useful for requesting focus. */
|
||||||
buttonRef?: RefObject<HTMLButtonElement>;
|
buttonRef?: RefObject<HTMLButtonElement>;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -14,11 +15,11 @@ interface ButtonProps {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
primary?: boolean;
|
primary?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
small?: boolean;
|
size?: "normal" | "small" | "micro";
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, small, style }: ButtonProps) => {
|
const Button = memo(({ name, buttonRef: _buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style }: ButtonProps) => {
|
||||||
// Memoize classes array to prevent recreation
|
// Memoize classes array to prevent recreation
|
||||||
const classes = useMemo(() => {
|
const classes = useMemo(() => {
|
||||||
const classList: string[] = ["btn"];
|
const classList: string[] = ["btn"];
|
||||||
@@ -30,11 +31,13 @@ const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboard
|
|||||||
if (className) {
|
if (className) {
|
||||||
classList.push(className);
|
classList.push(className);
|
||||||
}
|
}
|
||||||
if (small) {
|
if (size === "small") {
|
||||||
classList.push("btn-sm");
|
classList.push("btn-sm");
|
||||||
|
} else if (size === "micro") {
|
||||||
|
classList.push("btn-micro");
|
||||||
}
|
}
|
||||||
return classList.join(" ");
|
return classList.join(" ");
|
||||||
}, [primary, className, small]);
|
}, [primary, className, size]);
|
||||||
|
|
||||||
const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null);
|
const buttonRef = _buttonRef ?? useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ const Button = memo(({ buttonRef: _buttonRef, className, text, onClick, keyboard
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
name={name}
|
||||||
className={classes}
|
className={classes}
|
||||||
type={onClick ? "button" : "submit"}
|
type={onClick ? "button" : "submit"}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|||||||
17
apps/client/src/widgets/react/Column.tsx
Normal file
17
apps/client/src/widgets/react/Column.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ComponentChildren } from "preact";
|
||||||
|
import { CSSProperties } from "preact/compat";
|
||||||
|
|
||||||
|
interface ColumnProps {
|
||||||
|
md?: number;
|
||||||
|
children: ComponentChildren;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Column({ md, children, className, style }: ColumnProps) {
|
||||||
|
return (
|
||||||
|
<div className={`col-md-${md ?? 6} ${className ?? ""}`} style={style}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import { Tooltip } from "bootstrap";
|
|||||||
import { useEffect, useRef, useMemo, useCallback } from "preact/hooks";
|
import { useEffect, useRef, useMemo, useCallback } from "preact/hooks";
|
||||||
import { escapeQuotes } from "../../services/utils";
|
import { escapeQuotes } from "../../services/utils";
|
||||||
import { ComponentChildren } from "preact";
|
import { ComponentChildren } from "preact";
|
||||||
import { memo } from "preact/compat";
|
import { CSSProperties, memo } from "preact/compat";
|
||||||
|
import { useUniqueName } from "./hooks";
|
||||||
|
|
||||||
interface FormCheckboxProps {
|
interface FormCheckboxProps {
|
||||||
name: string;
|
id?: string;
|
||||||
|
name?: string;
|
||||||
label: string | ComponentChildren;
|
label: string | ComponentChildren;
|
||||||
/**
|
/**
|
||||||
* If set, the checkbox label will be underlined and dotted, indicating a hint. When hovered, it will show the hint text.
|
* If set, the checkbox label will be underlined and dotted, indicating a hint. When hovered, it will show the hint text.
|
||||||
@@ -14,9 +16,11 @@ interface FormCheckboxProps {
|
|||||||
currentValue: boolean;
|
currentValue: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange(newValue: boolean): void;
|
onChange(newValue: boolean): void;
|
||||||
|
containerStyle?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint }: FormCheckboxProps) => {
|
const FormCheckbox = memo(({ name, id: _id, disabled, label, currentValue, onChange, hint, containerStyle }: FormCheckboxProps) => {
|
||||||
|
const id = _id ?? useUniqueName(name);
|
||||||
const labelRef = useRef<HTMLLabelElement>(null);
|
const labelRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
// Fix: Move useEffect outside conditional
|
// Fix: Move useEffect outside conditional
|
||||||
@@ -46,7 +50,7 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
|
|||||||
const titleText = useMemo(() => hint ? escapeQuotes(hint) : undefined, [hint]);
|
const titleText = useMemo(() => hint ? escapeQuotes(hint) : undefined, [hint]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-checkbox">
|
<div className="form-checkbox" style={containerStyle}>
|
||||||
<label
|
<label
|
||||||
className="form-check-label tn-checkbox"
|
className="form-check-label tn-checkbox"
|
||||||
style={labelStyle}
|
style={labelStyle}
|
||||||
@@ -54,9 +58,10 @@ const FormCheckbox = memo(({ name, disabled, label, currentValue, onChange, hint
|
|||||||
ref={labelRef}
|
ref={labelRef}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id={id}
|
||||||
className="form-check-input"
|
className="form-check-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={name}
|
name={id}
|
||||||
checked={currentValue || false}
|
checked={currentValue || false}
|
||||||
value="1"
|
value="1"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,24 +1,43 @@
|
|||||||
import { ComponentChildren, RefObject } from "preact";
|
import { cloneElement, ComponentChildren, RefObject, VNode } from "preact";
|
||||||
|
import { CSSProperties } from "preact/compat";
|
||||||
|
import { useUniqueName } from "./hooks";
|
||||||
|
|
||||||
interface FormGroupProps {
|
interface FormGroupProps {
|
||||||
|
name: string;
|
||||||
labelRef?: RefObject<HTMLLabelElement>;
|
labelRef?: RefObject<HTMLLabelElement>;
|
||||||
label?: string;
|
label?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ComponentChildren;
|
children: VNode<any>;
|
||||||
description?: string | ComponentChildren;
|
description?: string | ComponentChildren;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormGroup({ label, title, className, children, description, labelRef }: FormGroupProps) {
|
export default function FormGroup({ name, label, title, className, children, description, labelRef, disabled, style }: FormGroupProps) {
|
||||||
|
const id = useUniqueName(name);
|
||||||
|
const childWithId = cloneElement(children, { id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`form-group ${className}`} title={title}
|
<div className={`form-group ${className} ${disabled ? "disabled" : ""}`} title={title} style={style}>
|
||||||
style={{ "margin-bottom": "15px" }}>
|
{ label &&
|
||||||
<label style={{ width: "100%" }} ref={labelRef}>
|
<label style={{ width: "100%" }} ref={labelRef} htmlFor={id}>{label}</label>}
|
||||||
{label && <div style={{ "margin-bottom": "10px" }}>{label}</div> }
|
|
||||||
{children}
|
{childWithId}
|
||||||
</label>
|
|
||||||
|
|
||||||
{description && <small className="form-text">{description}</small>}
|
{description && <small className="form-text">{description}</small>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to {@link FormGroup} but allows more than one child. Due to this behaviour, there is no automatic ID assignment.
|
||||||
|
*/
|
||||||
|
export function FormMultiGroup({ label, children }: { label: string, children: ComponentChildren }) {
|
||||||
|
return (
|
||||||
|
<div className={`form-group`}>
|
||||||
|
{label && <label>{label}</label>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,56 @@
|
|||||||
|
import type { ComponentChildren } from "preact";
|
||||||
|
import { useUniqueName } from "./hooks";
|
||||||
|
|
||||||
interface FormRadioProps {
|
interface FormRadioProps {
|
||||||
name: string;
|
name: string;
|
||||||
currentValue?: string;
|
currentValue?: string;
|
||||||
values: {
|
values: {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string | ComponentChildren;
|
||||||
|
inlineDescription?: string | ComponentChildren;
|
||||||
}[];
|
}[];
|
||||||
onChange(newValue: string): void;
|
onChange(newValue: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormRadioGroup({ name, values, currentValue, onChange }: FormRadioProps) {
|
export default function FormRadioGroup({ values, ...restProps }: FormRadioProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div role="group">
|
||||||
{(values || []).map(({ value, label }) => (
|
{(values || []).map(({ value, label, inlineDescription }) => (
|
||||||
<div className="form-check">
|
<div className="form-checkbox">
|
||||||
<label className="form-check-label tn-radio">
|
<FormRadio
|
||||||
<input
|
value={value}
|
||||||
className="form-check-input"
|
label={label} inlineDescription={inlineDescription}
|
||||||
type="radio"
|
labelClassName="form-check-label"
|
||||||
name={name}
|
{...restProps}
|
||||||
value={value}
|
/>
|
||||||
checked={value === currentValue}
|
|
||||||
onChange={e => onChange((e.target as HTMLInputElement).value)} />
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormInlineRadioGroup({ values, ...restProps }: FormRadioProps) {
|
||||||
|
return (
|
||||||
|
<div role="group">
|
||||||
|
{values.map(({ value, label }) => (<FormRadio value={value} label={label} {...restProps} />))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormRadio({ name, value, label, currentValue, onChange, labelClassName, inlineDescription }: Omit<FormRadioProps, "values"> & { value: string, label: ComponentChildren, inlineDescription?: ComponentChildren, labelClassName?: string }) {
|
||||||
|
return (
|
||||||
|
<label className={`tn-radio ${labelClassName ?? ""}`}>
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="radio"
|
||||||
|
name={useUniqueName(name)}
|
||||||
|
value={value}
|
||||||
|
checked={value === currentValue}
|
||||||
|
onChange={e => onChange((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{inlineDescription ?
|
||||||
|
<><strong>{label}</strong> - {inlineDescription}</>
|
||||||
|
: label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
79
apps/client/src/widgets/react/FormSelect.tsx
Normal file
79
apps/client/src/widgets/react/FormSelect.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ComponentChildren } from "preact";
|
||||||
|
import { CSSProperties } from "preact/compat";
|
||||||
|
|
||||||
|
type OnChangeListener = (newValue: string) => void;
|
||||||
|
|
||||||
|
export interface FormSelectGroup<T> {
|
||||||
|
title: string;
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValueConfig<T, Q> {
|
||||||
|
values: Q[];
|
||||||
|
/** The property of an item of {@link values} to be used as the key, uniquely identifying it. The key will be passed to the change listener. */
|
||||||
|
keyProperty: keyof T;
|
||||||
|
/** The property of an item of {@link values} to be used as the label, representing a human-readable version of the key. If missing, {@link keyProperty} will be used instead. */
|
||||||
|
titleProperty?: keyof T;
|
||||||
|
/** The current value of the combobox. The value will be looked up by going through {@link values} and looking an item whose {@link #keyProperty} value matches this one */
|
||||||
|
currentValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormSelectProps<T, Q> extends ValueConfig<T, Q> {
|
||||||
|
id?: string;
|
||||||
|
onChange: OnChangeListener;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combobox component that takes in any object array as data. Each item of the array is rendered as an item, and the key and values are obtained by looking into the object by a specified key.
|
||||||
|
*/
|
||||||
|
export default function FormSelect<T>({ id, onChange, style, ...restProps }: FormSelectProps<T, T>) {
|
||||||
|
return (
|
||||||
|
<FormSelectBody id={id} onChange={onChange} style={style}>
|
||||||
|
<FormSelectGroup {...restProps} />
|
||||||
|
</FormSelectBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to {@link FormSelect}, but the top-level elements are actually groups.
|
||||||
|
*/
|
||||||
|
export function FormSelectWithGroups<T>({ id, values, keyProperty, titleProperty, currentValue, onChange }: FormSelectProps<T, FormSelectGroup<T>>) {
|
||||||
|
return (
|
||||||
|
<FormSelectBody id={id} onChange={onChange}>
|
||||||
|
{values.map(({ title, items }) => {
|
||||||
|
return (
|
||||||
|
<optgroup label={title}>
|
||||||
|
<FormSelectGroup values={items} keyProperty={keyProperty} titleProperty={titleProperty} currentValue={currentValue} />
|
||||||
|
</optgroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FormSelectBody>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormSelectBody({ id, children, onChange, style }: { id?: string, children: ComponentChildren, onChange: OnChangeListener, style?: CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
class="form-select"
|
||||||
|
onChange={e => onChange((e.target as HTMLInputElement).value)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormSelectGroup<T>({ values, keyProperty, titleProperty, currentValue }: ValueConfig<T, T>) {
|
||||||
|
return values.map(item => {
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
value={item[keyProperty] as any}
|
||||||
|
selected={item[keyProperty] === currentValue}
|
||||||
|
>
|
||||||
|
{item[titleProperty ?? keyProperty] ?? item[keyProperty] as any}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
5
apps/client/src/widgets/react/FormText.tsx
Normal file
5
apps/client/src/widgets/react/FormText.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ComponentChildren } from "preact";
|
||||||
|
|
||||||
|
export default function FormText({ children }: { children: ComponentChildren }) {
|
||||||
|
return <p className="form-text use-tn-links">{children}</p>
|
||||||
|
}
|
||||||
18
apps/client/src/widgets/react/FormTextArea.tsx
Normal file
18
apps/client/src/widgets/react/FormTextArea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
interface FormTextAreaProps {
|
||||||
|
id?: string;
|
||||||
|
currentValue: string;
|
||||||
|
onBlur?(newValue: string): void;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
export default function FormTextArea({ id, onBlur, rows, currentValue }: FormTextAreaProps) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
rows={rows}
|
||||||
|
onBlur={(e) => {
|
||||||
|
onBlur?.(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>{currentValue}</textarea>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +1,48 @@
|
|||||||
import type { InputHTMLAttributes, RefObject } from "preact/compat";
|
import type { InputHTMLAttributes, RefObject } from "preact/compat";
|
||||||
|
|
||||||
interface FormTextBoxProps extends Pick<InputHTMLAttributes<HTMLInputElement>, "placeholder" | "autoComplete" | "className" | "type" | "name" | "pattern" | "title" | "style"> {
|
interface FormTextBoxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "onBlur" | "value"> {
|
||||||
id?: string;
|
id?: string;
|
||||||
currentValue?: string;
|
currentValue?: string;
|
||||||
onChange?(newValue: string): void;
|
onChange?(newValue: string, validity: ValidityState): void;
|
||||||
|
onBlur?(newValue: string): void;
|
||||||
inputRef?: RefObject<HTMLInputElement>;
|
inputRef?: RefObject<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormTextBox({ id, type, name, className, currentValue, onChange, autoComplete, inputRef, placeholder, title, pattern, style }: FormTextBoxProps) {
|
export default function FormTextBox({ inputRef, className, type, currentValue, onChange, onBlur,...rest}: FormTextBoxProps) {
|
||||||
|
if (type === "number" && currentValue) {
|
||||||
|
const { min, max } = rest;
|
||||||
|
const currentValueNum = parseInt(currentValue, 10);
|
||||||
|
if (min && currentValueNum < parseInt(String(min), 10)) {
|
||||||
|
currentValue = String(min);
|
||||||
|
} else if (max && currentValueNum > parseInt(String(max), 10)) {
|
||||||
|
currentValue = String(max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type={type ?? "text"}
|
|
||||||
className={`form-control ${className ?? ""}`}
|
className={`form-control ${className ?? ""}`}
|
||||||
id={id}
|
type={type ?? "text"}
|
||||||
name={name}
|
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
autoComplete={autoComplete}
|
onInput={onChange && (e => {
|
||||||
placeholder={placeholder}
|
const target = e.currentTarget;
|
||||||
title={title}
|
onChange?.(target.value, target.validity);
|
||||||
pattern={pattern}
|
})}
|
||||||
onInput={e => onChange?.(e.currentTarget.value)}
|
onBlur={onBlur && (e => {
|
||||||
style={style}
|
const target = e.currentTarget;
|
||||||
|
onBlur(target.value);
|
||||||
|
})}
|
||||||
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormTextBoxWithUnit(props: FormTextBoxProps & { unit: string }) {
|
||||||
|
return (
|
||||||
|
<label class="input-group tn-number-unit-pair">
|
||||||
|
<FormTextBox {...props} />
|
||||||
|
<span class="input-group-text">{props.unit}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
33
apps/client/src/widgets/react/KeyboardShortcut.tsx
Normal file
33
apps/client/src/widgets/react/KeyboardShortcut.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/commons";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import keyboard_actions from "../../services/keyboard_actions";
|
||||||
|
|
||||||
|
interface KeyboardShortcutProps {
|
||||||
|
actionName: KeyboardActionNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) {
|
||||||
|
|
||||||
|
const [ action, setAction ] = useState<ActionKeyboardShortcut>();
|
||||||
|
useEffect(() => {
|
||||||
|
keyboard_actions.getAction(actionName).then(setAction);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{action.effectiveShortcuts?.map((shortcut, i) => {
|
||||||
|
const keys = shortcut.split("+");
|
||||||
|
return keys
|
||||||
|
.map((key, i) => (
|
||||||
|
<>
|
||||||
|
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
}).reduce<any>((acc, item) => (acc.length ? [...acc, ", ", item] : [item]), [])}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/client/src/widgets/react/LinkButton.tsx
Normal file
17
apps/client/src/widgets/react/LinkButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ComponentChild } from "preact";
|
||||||
|
|
||||||
|
interface LinkButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
text: ComponentChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LinkButton({ onClick, text }: LinkButtonProps) {
|
||||||
|
return (
|
||||||
|
<a class="tn-link" href="javascript:" onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}}>
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { RefObject } from "preact";
|
|||||||
import type { CSSProperties } from "preact/compat";
|
import type { CSSProperties } from "preact/compat";
|
||||||
|
|
||||||
interface NoteAutocompleteProps {
|
interface NoteAutocompleteProps {
|
||||||
|
id?: string;
|
||||||
inputRef?: RefObject<HTMLInputElement>;
|
inputRef?: RefObject<HTMLInputElement>;
|
||||||
text?: string;
|
text?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -18,7 +19,7 @@ interface NoteAutocompleteProps {
|
|||||||
noteId?: string;
|
noteId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoteAutocomplete({ inputRef: _ref, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
|
export default function NoteAutocomplete({ id, inputRef: _ref, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged }: NoteAutocompleteProps) {
|
||||||
const ref = _ref ?? useRef<HTMLInputElement>(null);
|
const ref = _ref ?? useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,6 +75,7 @@ export default function NoteAutocomplete({ inputRef: _ref, text, placeholder, on
|
|||||||
return (
|
return (
|
||||||
<div className="input-group" style={containerStyle}>
|
<div className="input-group" style={containerStyle}>
|
||||||
<input
|
<input
|
||||||
|
id={id}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="note-autocomplete form-control"
|
className="note-autocomplete form-control"
|
||||||
placeholder={placeholder ?? t("add_link.search_note")} />
|
placeholder={placeholder ?? t("add_link.search_note")} />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function getProps({ className, html, style }: RawHtmlProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||||
if (typeof html === "object" && "length" in html) {
|
if (typeof html === "object" && "length" in html) {
|
||||||
html = html[0];
|
html = html[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,18 @@ export default abstract class ReactBasicWidget extends BasicWidget {
|
|||||||
* @returns the rendered wrapped DOM element.
|
* @returns the rendered wrapped DOM element.
|
||||||
*/
|
*/
|
||||||
export function renderReactWidget(parentComponent: Component, el: JSX.Element) {
|
export function renderReactWidget(parentComponent: Component, el: JSX.Element) {
|
||||||
const renderContainer = new DocumentFragment();
|
return renderReactWidgetAtElement(parentComponent, el, new DocumentFragment()).children();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderReactWidgetAtElement(parentComponent: Component, el: JSX.Element, container: Element | DocumentFragment) {
|
||||||
render((
|
render((
|
||||||
<ParentComponent.Provider value={parentComponent}>
|
<ParentComponent.Provider value={parentComponent}>
|
||||||
{el}
|
{el}
|
||||||
</ParentComponent.Provider>
|
</ParentComponent.Provider>
|
||||||
), renderContainer);
|
), container);
|
||||||
return $(renderContainer.firstChild as HTMLElement);
|
return $(container) as JQuery<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeReactWidget(container: Element) {
|
||||||
|
render(null, container);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useContext, useEffect, useRef } from "preact/hooks";
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import { EventData, EventNames } from "../../components/app_context";
|
import { EventData, EventNames } from "../../components/app_context";
|
||||||
import { ParentComponent } from "./ReactBasicWidget";
|
import { ParentComponent } from "./ReactBasicWidget";
|
||||||
import SpacedUpdate from "../../services/spaced_update";
|
import SpacedUpdate from "../../services/spaced_update";
|
||||||
|
import { OptionNames } from "@triliumnext/commons";
|
||||||
|
import options, { type OptionValue } from "../../services/options";
|
||||||
|
import utils, { reloadFrontendApp } from "../../services/utils";
|
||||||
|
import Component from "../../components/component";
|
||||||
|
import server from "../../services/server";
|
||||||
|
|
||||||
|
type TriliumEventHandler<T extends EventNames> = (data: EventData<T>) => void;
|
||||||
|
const registeredHandlers: Map<Component, Map<EventNames, TriliumEventHandler<any>[]>> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows a React component to react to Trilium events (e.g. `entitiesReloaded`). When the desired event is triggered, the handler is invoked with the event parameters.
|
* Allows a React component to react to Trilium events (e.g. `entitiesReloaded`). When the desired event is triggered, the handler is invoked with the event parameters.
|
||||||
@@ -12,32 +20,67 @@ import SpacedUpdate from "../../services/spaced_update";
|
|||||||
* @param handler the handler to be invoked when the event is triggered.
|
* @param handler the handler to be invoked when the event is triggered.
|
||||||
* @param enabled determines whether the event should be listened to or not. Useful to conditionally limit the listener based on a state (e.g. a modal being displayed).
|
* @param enabled determines whether the event should be listened to or not. Useful to conditionally limit the listener based on a state (e.g. a modal being displayed).
|
||||||
*/
|
*/
|
||||||
export default function useTriliumEvent<T extends EventNames>(eventName: T, handler: (data: EventData<T>) => void, enabled = true) {
|
export default function useTriliumEvent<T extends EventNames>(eventName: T, handler: TriliumEventHandler<T>, enabled = true) {
|
||||||
const parentWidget = useContext(ParentComponent);
|
const parentWidget = useContext(ParentComponent);
|
||||||
useEffect(() => {
|
if (!parentWidget) {
|
||||||
if (!parentWidget || !enabled) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
const handlerName = `${eventName}Event`;
|
||||||
// Create a unique handler name for this specific event listener
|
const customHandler = useMemo(() => {
|
||||||
const handlerName = `${eventName}Event`;
|
return async (data: EventData<T>) => {
|
||||||
const originalHandler = parentWidget[handlerName];
|
// Inform the attached event listeners.
|
||||||
|
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName) ?? [];
|
||||||
// Override the event handler to call our handler
|
for (const eventHandler of eventHandlers) {
|
||||||
parentWidget[handlerName] = async function(data: EventData<T>) {
|
eventHandler(data);
|
||||||
// Call original handler if it exists
|
|
||||||
if (originalHandler) {
|
|
||||||
await originalHandler.call(parentWidget, data);
|
|
||||||
}
|
}
|
||||||
// Call our React component's handler
|
}
|
||||||
handler(data);
|
}, [ eventName, parentWidget ]);
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup: restore original handler on unmount or when disabled
|
useEffect(() => {
|
||||||
|
// Attach to the list of handlers.
|
||||||
|
let handlersByWidget = registeredHandlers.get(parentWidget);
|
||||||
|
if (!handlersByWidget) {
|
||||||
|
handlersByWidget = new Map();
|
||||||
|
registeredHandlers.set(parentWidget, handlersByWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
let handlersByWidgetAndEventName = handlersByWidget.get(eventName);
|
||||||
|
if (!handlersByWidgetAndEventName) {
|
||||||
|
handlersByWidgetAndEventName = [];
|
||||||
|
handlersByWidget.set(eventName, handlersByWidgetAndEventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handlersByWidgetAndEventName.includes(handler)) {
|
||||||
|
handlersByWidgetAndEventName.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the custom event handler.
|
||||||
|
if (parentWidget[handlerName] && parentWidget[handlerName] !== customHandler) {
|
||||||
|
console.warn(`Widget ${parentWidget.componentId} already had an event listener and it was replaced by the React one.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parentWidget[handlerName] = customHandler;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
parentWidget[handlerName] = originalHandler;
|
const eventHandlers = registeredHandlers.get(parentWidget)?.get(eventName);
|
||||||
|
if (!eventHandlers || !eventHandlers.includes(handler)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the event handler from the array.
|
||||||
|
const newEventHandlers = eventHandlers.filter(e => e !== handler);
|
||||||
|
if (newEventHandlers.length) {
|
||||||
|
registeredHandlers.get(parentWidget)?.set(eventName, newEventHandlers);
|
||||||
|
} else {
|
||||||
|
registeredHandlers.get(parentWidget)?.delete(eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registeredHandlers.get(parentWidget)?.size) {
|
||||||
|
registeredHandlers.delete(parentWidget);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [parentWidget, enabled, eventName, handler]);
|
}, [ eventName, parentWidget, handler ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000) {
|
export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000) {
|
||||||
@@ -63,4 +106,116 @@ export function useSpacedUpdate(callback: () => Promise<void>, interval = 1000)
|
|||||||
}, [interval]);
|
}, [interval]);
|
||||||
|
|
||||||
return spacedUpdateRef.current;
|
return spacedUpdateRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows a React component to read and write a Trilium option, while also watching for external changes.
|
||||||
|
*
|
||||||
|
* Conceptually, `useTriliumOption` works just like `useState`, but the value is also automatically updated if
|
||||||
|
* the option is changed somewhere else in the client.
|
||||||
|
*
|
||||||
|
* @param name the name of the option to listen for.
|
||||||
|
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||||
|
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||||
|
*/
|
||||||
|
export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [string, (newValue: OptionValue) => Promise<void>] {
|
||||||
|
const initialValue = options.get(name);
|
||||||
|
const [ value, setValue ] = useState(initialValue);
|
||||||
|
|
||||||
|
const wrappedSetValue = useMemo(() => {
|
||||||
|
return async (newValue: OptionValue) => {
|
||||||
|
await options.save(name, newValue);
|
||||||
|
|
||||||
|
if (needsRefresh) {
|
||||||
|
reloadFrontendApp(`option change: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ name, needsRefresh ]);
|
||||||
|
|
||||||
|
useTriliumEvent("entitiesReloaded", useCallback(({ loadResults }) => {
|
||||||
|
if (loadResults.getOptionNames().includes(name)) {
|
||||||
|
const newValue = options.get(name);
|
||||||
|
setValue(newValue);
|
||||||
|
}
|
||||||
|
}, [ name ]));
|
||||||
|
|
||||||
|
return [
|
||||||
|
value,
|
||||||
|
wrappedSetValue
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to {@link useTriliumOption}, but the value is converted to and from a boolean instead of a string.
|
||||||
|
*
|
||||||
|
* @param name the name of the option to listen for.
|
||||||
|
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||||
|
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||||
|
*/
|
||||||
|
export function useTriliumOptionBool(name: OptionNames, needsRefresh?: boolean): [boolean, (newValue: boolean) => Promise<void>] {
|
||||||
|
const [ value, setValue ] = useTriliumOption(name, needsRefresh);
|
||||||
|
return [
|
||||||
|
(value === "true"),
|
||||||
|
(newValue) => setValue(newValue ? "true" : "false")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to {@link useTriliumOption}, but the value is converted to and from a int instead of a string.
|
||||||
|
*
|
||||||
|
* @param name the name of the option to listen for.
|
||||||
|
* @param needsRefresh whether to reload the frontend whenever the value is changed.
|
||||||
|
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||||
|
*/
|
||||||
|
export function useTriliumOptionInt(name: OptionNames): [number, (newValue: number) => Promise<void>] {
|
||||||
|
const [ value, setValue ] = useTriliumOption(name);
|
||||||
|
return [
|
||||||
|
(parseInt(value, 10)),
|
||||||
|
(newValue) => setValue(newValue)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to {@link useTriliumOption}, but the object value is parsed to and from a JSON instead of a string.
|
||||||
|
*
|
||||||
|
* @param name the name of the option to listen for.
|
||||||
|
* @returns an array where the first value is the current option value and the second value is the setter.
|
||||||
|
*/
|
||||||
|
export function useTriliumOptionJson<T>(name: OptionNames): [ T, (newValue: T) => Promise<void> ] {
|
||||||
|
const [ value, setValue ] = useTriliumOption(name);
|
||||||
|
return [
|
||||||
|
(JSON.parse(value) as T),
|
||||||
|
(newValue => setValue(JSON.stringify(newValue)))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to {@link useTriliumOption}, but operates with multiple options at once.
|
||||||
|
*
|
||||||
|
* @param names the name of the option to listen for.
|
||||||
|
* @returns an array where the first value is a map where the keys are the option names and the values, and the second value is the setter which takes in the same type of map and saves them all at once.
|
||||||
|
*/
|
||||||
|
export function useTriliumOptions<T extends OptionNames>(...names: T[]) {
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const name of names) {
|
||||||
|
values[name] = options.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
values as Record<T, string>,
|
||||||
|
options.saveMany
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a unique name via a random alphanumeric string of a fixed length.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Generally used to assign names to inputs that are unique, especially useful for widgets inside tabs.
|
||||||
|
*
|
||||||
|
* @param prefix a prefix to add to the unique name.
|
||||||
|
* @returns a name with the given prefix and a random alpanumeric string appended to it.
|
||||||
|
*/
|
||||||
|
export function useUniqueName(prefix?: string) {
|
||||||
|
return useMemo(() => (prefix ? prefix + "-" : "") + utils.randomString(10), [ prefix ]);
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ export default class InheritedAttributesWidget extends NoteContextAwareWidget {
|
|||||||
if (a.noteId === b.noteId) {
|
if (a.noteId === b.noteId) {
|
||||||
return a.position - b.position;
|
return a.position - b.position;
|
||||||
} else {
|
} else {
|
||||||
// inherited attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
|
// inherited attributes should stay grouped: https://github.com/TriliumNext/Trilium/issues/3761
|
||||||
return a.noteId < b.noteId ? -1 : 1;
|
return a.noteId < b.noteId ? -1 : 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const TPL = /*html*/`
|
|||||||
<div class="promoted-attributes-widget">
|
<div class="promoted-attributes-widget">
|
||||||
<style>
|
<style>
|
||||||
body.mobile .promoted-attributes-widget {
|
body.mobile .promoted-attributes-widget {
|
||||||
/* https://github.com/zadam/trilium/issues/4468 */
|
/* https://github.com/TriliumNext/Trilium/issues/4468 */
|
||||||
flex-shrink: 0.4;
|
flex-shrink: 0.4;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default class SharedInfoWidget extends NoteContextAwareWidget {
|
|||||||
let host = location.host;
|
let host = location.host;
|
||||||
if (host.endsWith("/")) {
|
if (host.endsWith("/")) {
|
||||||
// seems like IE has trailing slash
|
// seems like IE has trailing slash
|
||||||
// https://github.com/zadam/trilium/issues/3782
|
// https://github.com/TriliumNext/Trilium/issues/3782
|
||||||
host = host.substr(0, host.length - 1);
|
host = host.substr(0, host.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export default class TocWidget extends RightPanelWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reduce indent if a larger headings are not being used: https://github.com/zadam/trilium/issues/4363
|
* Reduce indent if a larger headings are not being used: https://github.com/TriliumNext/Trilium/issues/4363
|
||||||
*/
|
*/
|
||||||
pullLeft($toc: JQuery<HTMLElement>) {
|
pullLeft($toc: JQuery<HTMLElement>) {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -390,7 +390,7 @@ export default class TocWidget extends RightPanelWidget {
|
|||||||
// temporarily" (ie "edit this note" button) without any
|
// temporarily" (ie "edit this note" button) without any
|
||||||
// intervening events, do the readonly calculation at navigation
|
// intervening events, do the readonly calculation at navigation
|
||||||
// time and not at outline creation time
|
// time and not at outline creation time
|
||||||
// See https://github.com/zadam/trilium/issues/2828
|
// See https://github.com/TriliumNext/Trilium/issues/2828
|
||||||
const isDocNote = this.note.type === "doc";
|
const isDocNote = this.note.type === "doc";
|
||||||
const isReadOnly = await this.noteContext.isReadOnly();
|
const isReadOnly = await this.noteContext.isReadOnly();
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const TPL = /*html*/`
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Conflict between excalidraw and bootstrap classes keeps the menu hidden */
|
/* Conflict between excalidraw and bootstrap classes keeps the menu hidden */
|
||||||
/* https://github.com/zadam/trilium/issues/3780 */
|
/* https://github.com/TriliumNext/Trilium/issues/3780 */
|
||||||
/* https://github.com/excalidraw/excalidraw/issues/6567 */
|
/* https://github.com/excalidraw/excalidraw/issues/6567 */
|
||||||
.excalidraw .dropdown-menu {
|
.excalidraw .dropdown-menu {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import TypeWidget from "./type_widget.js";
|
|
||||||
import ElectronIntegrationOptions from "./options/appearance/electron_integration.js";
|
|
||||||
import ThemeOptions from "./options/appearance/theme.js";
|
|
||||||
import FontsOptions from "./options/appearance/fonts.js";
|
|
||||||
import MaxContentWidthOptions from "./options/appearance/max_content_width.js";
|
|
||||||
import KeyboardShortcutsOptions from "./options/shortcuts.js";
|
|
||||||
import HeadingStyleOptions from "./options/text_notes/heading_style.js";
|
|
||||||
import TableOfContentsOptions from "./options/text_notes/table_of_contents.js";
|
|
||||||
import HighlightsListOptions from "./options/text_notes/highlights_list.js";
|
|
||||||
import TextAutoReadOnlySizeOptions from "./options/text_notes/text_auto_read_only_size.js";
|
|
||||||
import DateTimeFormatOptions from "./options/text_notes/date_time_format.js";
|
|
||||||
import CodeEditorOptions from "./options/code_notes/code_editor.js";
|
|
||||||
import CodeAutoReadOnlySizeOptions from "./options/code_notes/code_auto_read_only_size.js";
|
|
||||||
import CodeMimeTypesOptions from "./options/code_notes/code_mime_types.js";
|
|
||||||
import ImageOptions from "./options/images/images.js";
|
|
||||||
import SpellcheckOptions from "./options/spellcheck.js";
|
|
||||||
import PasswordOptions from "./options/password/password.js";
|
|
||||||
import ProtectedSessionTimeoutOptions from "./options/password/protected_session_timeout.js";
|
|
||||||
import EtapiOptions from "./options/etapi.js";
|
|
||||||
import BackupOptions from "./options/backup.js";
|
|
||||||
import SyncOptions from "./options/sync.js";
|
|
||||||
import SearchEngineOptions from "./options/other/search_engine.js";
|
|
||||||
import TrayOptions from "./options/other/tray.js";
|
|
||||||
import NoteErasureTimeoutOptions from "./options/other/note_erasure_timeout.js";
|
|
||||||
import RevisionsSnapshotIntervalOptions from "./options/other/revisions_snapshot_interval.js";
|
|
||||||
import RevisionSnapshotsLimitOptions from "./options/other/revision_snapshots_limit.js";
|
|
||||||
import NetworkConnectionsOptions from "./options/other/network_connections.js";
|
|
||||||
import HtmlImportTagsOptions from "./options/other/html_import_tags.js";
|
|
||||||
import AdvancedSyncOptions from "./options/advanced/sync.js";
|
|
||||||
import DatabaseIntegrityCheckOptions from "./options/advanced/database_integrity_check.js";
|
|
||||||
import VacuumDatabaseOptions from "./options/advanced/vacuum_database.js";
|
|
||||||
import DatabaseAnonymizationOptions from "./options/advanced/database_anonymization.js";
|
|
||||||
import BackendLogWidget from "./content/backend_log.js";
|
|
||||||
import AttachmentErasureTimeoutOptions from "./options/other/attachment_erasure_timeout.js";
|
|
||||||
import RibbonOptions from "./options/appearance/ribbon.js";
|
|
||||||
import MultiFactorAuthenticationOptions from './options/multi_factor_authentication.js';
|
|
||||||
import LocalizationOptions from "./options/i18n/i18n.js";
|
|
||||||
import CodeBlockOptions from "./options/text_notes/code_block.js";
|
|
||||||
import EditorOptions from "./options/text_notes/editor.js";
|
|
||||||
import ShareSettingsOptions from "./options/other/share_settings.js";
|
|
||||||
import AiSettingsOptions from "./options/ai_settings.js";
|
|
||||||
import type FNote from "../../entities/fnote.js";
|
|
||||||
import type NoteContextAwareWidget from "../note_context_aware_widget.js";
|
|
||||||
import { t } from "../../services/i18n.js";
|
|
||||||
import LanguageOptions from "./options/i18n/language.js";
|
|
||||||
import type BasicWidget from "../basic_widget.js";
|
|
||||||
import CodeTheme from "./options/code_notes/code_theme.js";
|
|
||||||
import RelatedSettings from "./options/appearance/related_settings.js";
|
|
||||||
import EditorFeaturesOptions from "./options/text_notes/features.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
|
|
||||||
<style>
|
|
||||||
.type-contentWidget .note-detail {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-detail-content-widget {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-detail-content-widget-content {
|
|
||||||
padding: 15px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-detail.full-height .note-detail-content-widget-content {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="note-detail-content-widget-content"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
|
|
||||||
|
|
||||||
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (typeof NoteContextAwareWidget)[]> = {
|
|
||||||
_optionsAppearance: [
|
|
||||||
ThemeOptions,
|
|
||||||
FontsOptions,
|
|
||||||
ElectronIntegrationOptions,
|
|
||||||
MaxContentWidthOptions,
|
|
||||||
RibbonOptions
|
|
||||||
],
|
|
||||||
_optionsShortcuts: [
|
|
||||||
KeyboardShortcutsOptions
|
|
||||||
],
|
|
||||||
_optionsTextNotes: [
|
|
||||||
EditorOptions,
|
|
||||||
EditorFeaturesOptions,
|
|
||||||
HeadingStyleOptions,
|
|
||||||
CodeBlockOptions,
|
|
||||||
TableOfContentsOptions,
|
|
||||||
HighlightsListOptions,
|
|
||||||
TextAutoReadOnlySizeOptions,
|
|
||||||
DateTimeFormatOptions
|
|
||||||
],
|
|
||||||
_optionsCodeNotes: [
|
|
||||||
CodeEditorOptions,
|
|
||||||
CodeTheme,
|
|
||||||
CodeMimeTypesOptions,
|
|
||||||
CodeAutoReadOnlySizeOptions
|
|
||||||
],
|
|
||||||
_optionsImages: [
|
|
||||||
ImageOptions
|
|
||||||
],
|
|
||||||
_optionsSpellcheck: [
|
|
||||||
SpellcheckOptions
|
|
||||||
],
|
|
||||||
_optionsPassword: [
|
|
||||||
PasswordOptions,
|
|
||||||
ProtectedSessionTimeoutOptions
|
|
||||||
],
|
|
||||||
_optionsMFA: [MultiFactorAuthenticationOptions],
|
|
||||||
_optionsEtapi: [
|
|
||||||
EtapiOptions
|
|
||||||
],
|
|
||||||
_optionsBackup: [
|
|
||||||
BackupOptions
|
|
||||||
],
|
|
||||||
_optionsSync: [
|
|
||||||
SyncOptions
|
|
||||||
],
|
|
||||||
_optionsAi: [AiSettingsOptions],
|
|
||||||
_optionsOther: [
|
|
||||||
SearchEngineOptions,
|
|
||||||
TrayOptions,
|
|
||||||
NoteErasureTimeoutOptions,
|
|
||||||
AttachmentErasureTimeoutOptions,
|
|
||||||
RevisionsSnapshotIntervalOptions,
|
|
||||||
RevisionSnapshotsLimitOptions,
|
|
||||||
HtmlImportTagsOptions,
|
|
||||||
ShareSettingsOptions,
|
|
||||||
NetworkConnectionsOptions
|
|
||||||
],
|
|
||||||
_optionsLocalization: [
|
|
||||||
LocalizationOptions,
|
|
||||||
LanguageOptions
|
|
||||||
],
|
|
||||||
_optionsAdvanced: [
|
|
||||||
AdvancedSyncOptions,
|
|
||||||
DatabaseIntegrityCheckOptions,
|
|
||||||
DatabaseAnonymizationOptions,
|
|
||||||
VacuumDatabaseOptions
|
|
||||||
],
|
|
||||||
_backendLog: [
|
|
||||||
BackendLogWidget
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
|
|
||||||
*
|
|
||||||
* One important aspect is that, like its parent {@link TypeWidget}, the content widgets don't receive all events by default and they must be manually added
|
|
||||||
* to the propagation list in {@link TypeWidget.handleEventInChildren}.
|
|
||||||
*/
|
|
||||||
export default class ContentWidgetTypeWidget extends TypeWidget {
|
|
||||||
private $content!: JQuery<HTMLElement>;
|
|
||||||
private widget?: BasicWidget;
|
|
||||||
|
|
||||||
static getType() {
|
|
||||||
return "contentWidget";
|
|
||||||
}
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.$content = this.$widget.find(".note-detail-content-widget-content");
|
|
||||||
|
|
||||||
super.doRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
async doRefresh(note: FNote) {
|
|
||||||
this.$content.empty();
|
|
||||||
this.children = [];
|
|
||||||
|
|
||||||
const contentWidgets = [
|
|
||||||
...((CONTENT_WIDGETS as Record<string, typeof NoteContextAwareWidget[]>)[note.noteId]),
|
|
||||||
RelatedSettings
|
|
||||||
];
|
|
||||||
this.$content.toggleClass("options", note.noteId.startsWith("_options"));
|
|
||||||
|
|
||||||
if (contentWidgets) {
|
|
||||||
for (const clazz of contentWidgets) {
|
|
||||||
const widget = new clazz();
|
|
||||||
|
|
||||||
if (this.noteContext) {
|
|
||||||
await widget.handleEvent("setNoteContext", { noteContext: this.noteContext });
|
|
||||||
}
|
|
||||||
this.child(widget);
|
|
||||||
|
|
||||||
this.$content.append(widget.render());
|
|
||||||
this.widget = widget;
|
|
||||||
await widget.refresh();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.$content.append(t("content_widget.unknown_widget", { id: note.noteId }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
137
apps/client/src/widgets/type_widgets/content_widget.tsx
Normal file
137
apps/client/src/widgets/type_widgets/content_widget.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import TypeWidget from "./type_widget.js";
|
||||||
|
import type FNote from "../../entities/fnote.js";
|
||||||
|
import type NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||||
|
import { t } from "../../services/i18n.js";
|
||||||
|
import type BasicWidget from "../basic_widget.js";
|
||||||
|
import type { JSX } from "preact/jsx-runtime";
|
||||||
|
import AppearanceSettings from "./options/appearance.jsx";
|
||||||
|
import { disposeReactWidget, renderReactWidget, renderReactWidgetAtElement } from "../react/ReactBasicWidget.jsx";
|
||||||
|
import ImageSettings from "./options/images.jsx";
|
||||||
|
import AdvancedSettings from "./options/advanced.jsx";
|
||||||
|
import InternationalizationOptions from "./options/i18n.jsx";
|
||||||
|
import SyncOptions from "./options/sync.jsx";
|
||||||
|
import EtapiSettings from "./options/etapi.js";
|
||||||
|
import BackupSettings from "./options/backup.js";
|
||||||
|
import SpellcheckSettings from "./options/spellcheck.js";
|
||||||
|
import PasswordSettings from "./options/password.jsx";
|
||||||
|
import ShortcutSettings from "./options/shortcuts.js";
|
||||||
|
import TextNoteSettings from "./options/text_notes.jsx";
|
||||||
|
import CodeNoteSettings from "./options/code_notes.jsx";
|
||||||
|
import OtherSettings from "./options/other.jsx";
|
||||||
|
import BackendLogWidget from "./content/backend_log.js";
|
||||||
|
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js";
|
||||||
|
import AiSettings from "./options/ai_settings.jsx";
|
||||||
|
|
||||||
|
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
|
||||||
|
<style>
|
||||||
|
.type-contentWidget .note-detail {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-content-widget {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail-content-widget-content {
|
||||||
|
padding: 15px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail.full-height .note-detail-content-widget-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="note-detail-content-widget-content"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsAi" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
|
||||||
|
|
||||||
|
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextAwareWidget)[] | JSX.Element)> = {
|
||||||
|
_optionsAppearance: <AppearanceSettings />,
|
||||||
|
_optionsShortcuts: <ShortcutSettings />,
|
||||||
|
_optionsTextNotes: <TextNoteSettings />,
|
||||||
|
_optionsCodeNotes: <CodeNoteSettings />,
|
||||||
|
_optionsImages: <ImageSettings />,
|
||||||
|
_optionsSpellcheck: <SpellcheckSettings />,
|
||||||
|
_optionsPassword: <PasswordSettings />,
|
||||||
|
_optionsMFA: <MultiFactorAuthenticationSettings />,
|
||||||
|
_optionsEtapi: <EtapiSettings />,
|
||||||
|
_optionsBackup: <BackupSettings />,
|
||||||
|
_optionsSync: <SyncOptions />,
|
||||||
|
_optionsAi: <AiSettings />,
|
||||||
|
_optionsOther: <OtherSettings />,
|
||||||
|
_optionsLocalization: <InternationalizationOptions />,
|
||||||
|
_optionsAdvanced: <AdvancedSettings />,
|
||||||
|
_backendLog: [
|
||||||
|
BackendLogWidget
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type widget that displays one or more widgets based on the type of note, generally used for options and other interactive notes such as the backend log.
|
||||||
|
*
|
||||||
|
* One important aspect is that, like its parent {@link TypeWidget}, the content widgets don't receive all events by default and they must be manually added
|
||||||
|
* to the propagation list in {@link TypeWidget.handleEventInChildren}.
|
||||||
|
*/
|
||||||
|
export default class ContentWidgetTypeWidget extends TypeWidget {
|
||||||
|
private $content!: JQuery<HTMLElement>;
|
||||||
|
private widget?: BasicWidget;
|
||||||
|
|
||||||
|
static getType() {
|
||||||
|
return "contentWidget";
|
||||||
|
}
|
||||||
|
|
||||||
|
doRender() {
|
||||||
|
this.$widget = $(TPL);
|
||||||
|
this.$content = this.$widget.find(".note-detail-content-widget-content");
|
||||||
|
|
||||||
|
super.doRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
async doRefresh(note: FNote) {
|
||||||
|
this.$content.empty();
|
||||||
|
this.children = [];
|
||||||
|
|
||||||
|
const contentWidgets = (CONTENT_WIDGETS as Record<string, (typeof NoteContextAwareWidget[] | JSX.Element)>)[note.noteId];
|
||||||
|
this.$content.toggleClass("options", note.noteId.startsWith("_options"));
|
||||||
|
|
||||||
|
// Unknown widget.
|
||||||
|
if (!contentWidgets) {
|
||||||
|
this.$content.append(t("content_widget.unknown_widget", { id: note.noteId }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy widget.
|
||||||
|
if (Array.isArray(contentWidgets)) {
|
||||||
|
for (const clazz of contentWidgets) {
|
||||||
|
const widget = new clazz();
|
||||||
|
|
||||||
|
if (this.noteContext) {
|
||||||
|
await widget.handleEvent("setNoteContext", { noteContext: this.noteContext });
|
||||||
|
}
|
||||||
|
this.child(widget);
|
||||||
|
|
||||||
|
this.$content.append(widget.render());
|
||||||
|
this.widget = widget;
|
||||||
|
await widget.refresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// React widget.
|
||||||
|
renderReactWidgetAtElement(this, contentWidgets, this.$content[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(): void {
|
||||||
|
if (this.noteId) {
|
||||||
|
const contentWidgets = (CONTENT_WIDGETS as Record<string, (typeof NoteContextAwareWidget[] | JSX.Element)>)[this.noteId];
|
||||||
|
if (contentWidgets && !Array.isArray(contentWidgets)) {
|
||||||
|
disposeReactWidget(this.$content[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
175
apps/client/src/widgets/type_widgets/options/advanced.tsx
Normal file
175
apps/client/src/widgets/type_widgets/options/advanced.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { AnonymizedDbResponse, DatabaseAnonymizeResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons";
|
||||||
|
import { t } from "../../../services/i18n";
|
||||||
|
import server from "../../../services/server";
|
||||||
|
import toast from "../../../services/toast";
|
||||||
|
import Button from "../../react/Button";
|
||||||
|
import FormText from "../../react/FormText";
|
||||||
|
import OptionsSection from "./components/OptionsSection"
|
||||||
|
import Column from "../../react/Column";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
export default function AdvancedSettings() {
|
||||||
|
return <>
|
||||||
|
<AdvancedSyncOptions />
|
||||||
|
<DatabaseIntegrityOptions />
|
||||||
|
<DatabaseAnonymizationOptions />
|
||||||
|
<VacuumDatabaseOptions />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvancedSyncOptions() {
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("sync.title")}>
|
||||||
|
<Button
|
||||||
|
text={t("sync.force_full_sync_button")}
|
||||||
|
onClick={async () => {
|
||||||
|
await server.post("sync/force-full-sync");
|
||||||
|
toast.showMessage(t("sync.full_sync_triggered"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text={t("sync.fill_entity_changes_button")}
|
||||||
|
onClick={async () => {
|
||||||
|
toast.showMessage(t("sync.filling_entity_changes"));
|
||||||
|
await server.post("sync/fill-entity-changes");
|
||||||
|
toast.showMessage(t("sync.sync_rows_filled_successfully"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OptionsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatabaseIntegrityOptions() {
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("database_integrity_check.title")}>
|
||||||
|
<FormText>{t("database_integrity_check.description")}</FormText>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text={t("database_integrity_check.check_button")}
|
||||||
|
onClick={async () => {
|
||||||
|
toast.showMessage(t("database_integrity_check.checking_integrity"));
|
||||||
|
|
||||||
|
const { results } = await server.get<DatabaseCheckIntegrityResponse>("database/check-integrity");
|
||||||
|
|
||||||
|
if (results.length === 1 && results[0].integrity_check === "ok") {
|
||||||
|
toast.showMessage(t("database_integrity_check.integrity_check_succeeded"));
|
||||||
|
} else {
|
||||||
|
toast.showMessage(t("database_integrity_check.integrity_check_failed", { results: JSON.stringify(results, null, 2) }), 15000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text={t("consistency_checks.find_and_fix_button")}
|
||||||
|
onClick={async () => {
|
||||||
|
toast.showMessage(t("consistency_checks.finding_and_fixing_message"));
|
||||||
|
await server.post("database/find-and-fix-consistency-issues");
|
||||||
|
toast.showMessage(t("consistency_checks.issues_fixed_message"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatabaseAnonymizationOptions() {
|
||||||
|
const [ existingAnonymizedDatabases, setExistingAnonymizedDatabases ] = useState<AnonymizedDbResponse[]>([]);
|
||||||
|
|
||||||
|
function refreshAnonymizedDatabase() {
|
||||||
|
server.get<AnonymizedDbResponse[]>("database/anonymized-databases").then(setExistingAnonymizedDatabases);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refreshAnonymizedDatabase, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("database_anonymization.title")}>
|
||||||
|
<FormText>{t("database_anonymization.choose_anonymization")}</FormText>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<DatabaseAnonymizationOption
|
||||||
|
title={t("database_anonymization.full_anonymization")}
|
||||||
|
description={t("database_anonymization.full_anonymization_description")}
|
||||||
|
buttonText={t("database_anonymization.save_fully_anonymized_database")}
|
||||||
|
buttonClick={async () => {
|
||||||
|
toast.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
|
||||||
|
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/full");
|
||||||
|
|
||||||
|
if (!resp.success) {
|
||||||
|
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
|
||||||
|
} else {
|
||||||
|
toast.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
||||||
|
refreshAnonymizedDatabase();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DatabaseAnonymizationOption
|
||||||
|
title={t("database_anonymization.light_anonymization")}
|
||||||
|
description={t("database_anonymization.light_anonymization_description")}
|
||||||
|
buttonText={t("database_anonymization.save_lightly_anonymized_database")}
|
||||||
|
buttonClick={async () => {
|
||||||
|
toast.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
|
||||||
|
const resp = await server.post<DatabaseAnonymizeResponse>("database/anonymize/light");
|
||||||
|
|
||||||
|
if (!resp.success) {
|
||||||
|
toast.showError(t("database_anonymization.error_creating_anonymized_database"));
|
||||||
|
} else {
|
||||||
|
toast.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
||||||
|
refreshAnonymizedDatabase();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<ExistingAnonymizedDatabases databases={existingAnonymizedDatabases} />
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatabaseAnonymizationOption({ title, description, buttonText, buttonClick }: { title: string, description: string, buttonText: string, buttonClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<Column md={6} style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", marginTop: "1em" }}>
|
||||||
|
<h5>{title}</h5>
|
||||||
|
<FormText>{description}</FormText>
|
||||||
|
<Button text={buttonText} onClick={buttonClick} />
|
||||||
|
</Column>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExistingAnonymizedDatabases({ databases }: { databases: AnonymizedDbResponse[] }) {
|
||||||
|
if (!databases.length) {
|
||||||
|
return <FormText>{t("database_anonymization.no_anonymized_database_yet")}</FormText>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="table table-stripped">
|
||||||
|
<thead>
|
||||||
|
<th>{t("database_anonymization.existing_anonymized_databases")}</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{databases.map(({ filePath }) => (
|
||||||
|
<tr>
|
||||||
|
<td>{filePath}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VacuumDatabaseOptions() {
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("vacuum_database.title")}>
|
||||||
|
<FormText>{t("vacuum_database.description")}</FormText>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text={t("vacuum_database.button_text")}
|
||||||
|
onClick={async () => {
|
||||||
|
toast.showMessage(t("vacuum_database.vacuuming_database"));
|
||||||
|
await server.post("database/vacuum-database");
|
||||||
|
toast.showMessage(t("vacuum_database.database_vacuumed"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import toastService from "../../../../services/toast.js";
|
|
||||||
import server from "../../../../services/server.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import type { OptionMap } from "@triliumnext/commons";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<style>
|
|
||||||
.database-database-anonymization-option {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.database-database-anonymization-option p {
|
|
||||||
margin-top: .75em;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<h4>${t("database_anonymization.title")}</h4>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<p class="form-text">${t("database_anonymization.choose_anonymization")}</p>
|
|
||||||
|
|
||||||
<div class="col-md-6 database-database-anonymization-option">
|
|
||||||
<h5>${t("database_anonymization.full_anonymization")}</h5>
|
|
||||||
|
|
||||||
<p class="form-text">${t("database_anonymization.full_anonymization_description")}</p>
|
|
||||||
<button class="anonymize-full-button btn btn-secondary">${t("database_anonymization.save_fully_anonymized_database")}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 database-database-anonymization-option">
|
|
||||||
<h5>${t("database_anonymization.light_anonymization")}</h5>
|
|
||||||
|
|
||||||
<p class="form-text">${t("database_anonymization.light_anonymization_description")}</p>
|
|
||||||
|
|
||||||
<button class="anonymize-light-button btn btn-secondary">${t("database_anonymization.save_lightly_anonymized_database")}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<table class="existing-anonymized-databases-table table table-stripped">
|
|
||||||
<thead>
|
|
||||||
<th>${t("database_anonymization.existing_anonymized_databases")}</th>
|
|
||||||
</thead>
|
|
||||||
<tbody class="existing-anonymized-databases">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// TODO: Deduplicate with server
|
|
||||||
interface AnonymizeResponse {
|
|
||||||
success: boolean;
|
|
||||||
anonymizedFilePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnonymizedDbResponse {
|
|
||||||
filePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class DatabaseAnonymizationOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $anonymizeFullButton!: JQuery<HTMLElement>;
|
|
||||||
private $anonymizeLightButton!: JQuery<HTMLElement>;
|
|
||||||
private $existingAnonymizedDatabases!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.$anonymizeFullButton = this.$widget.find(".anonymize-full-button");
|
|
||||||
this.$anonymizeLightButton = this.$widget.find(".anonymize-light-button");
|
|
||||||
this.$anonymizeFullButton.on("click", async () => {
|
|
||||||
toastService.showMessage(t("database_anonymization.creating_fully_anonymized_database"));
|
|
||||||
|
|
||||||
const resp = await server.post<AnonymizeResponse>("database/anonymize/full");
|
|
||||||
|
|
||||||
if (!resp.success) {
|
|
||||||
toastService.showError(t("database_anonymization.error_creating_anonymized_database"));
|
|
||||||
} else {
|
|
||||||
toastService.showMessage(t("database_anonymization.successfully_created_fully_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$anonymizeLightButton.on("click", async () => {
|
|
||||||
toastService.showMessage(t("database_anonymization.creating_lightly_anonymized_database"));
|
|
||||||
|
|
||||||
const resp = await server.post<AnonymizeResponse>("database/anonymize/light");
|
|
||||||
|
|
||||||
if (!resp.success) {
|
|
||||||
toastService.showError(t("database_anonymization.error_creating_anonymized_database"));
|
|
||||||
} else {
|
|
||||||
toastService.showMessage(t("database_anonymization.successfully_created_lightly_anonymized_database", { anonymizedFilePath: resp.anonymizedFilePath }), 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$existingAnonymizedDatabases = this.$widget.find(".existing-anonymized-databases");
|
|
||||||
}
|
|
||||||
|
|
||||||
optionsLoaded(options: OptionMap) {
|
|
||||||
server.get<AnonymizedDbResponse[]>("database/anonymized-databases").then((anonymizedDatabases) => {
|
|
||||||
this.$existingAnonymizedDatabases.empty();
|
|
||||||
|
|
||||||
if (!anonymizedDatabases.length) {
|
|
||||||
anonymizedDatabases = [{ filePath: t("database_anonymization.no_anonymized_database_yet") }];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { filePath } of anonymizedDatabases) {
|
|
||||||
this.$existingAnonymizedDatabases.append($("<tr>").append($("<td>").text(filePath)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import toastService from "../../../../services/toast.js";
|
|
||||||
import server from "../../../../services/server.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("database_integrity_check.title")}</h4>
|
|
||||||
|
|
||||||
<p class="form-text">${t("database_integrity_check.description")}</p>
|
|
||||||
|
|
||||||
<button class="check-integrity-button btn btn-secondary">${t("database_integrity_check.check_button")}</button>
|
|
||||||
<button class="find-and-fix-consistency-issues-button btn btn-secondary">${t("consistency_checks.find_and_fix_button")}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// TODO: Deduplicate with server
|
|
||||||
interface Response {
|
|
||||||
results: {
|
|
||||||
integrity_check: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class DatabaseIntegrityCheckOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $checkIntegrityButton!: JQuery<HTMLElement>;
|
|
||||||
private $findAndFixConsistencyIssuesButton!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.$checkIntegrityButton = this.$widget.find(".check-integrity-button");
|
|
||||||
this.$checkIntegrityButton.on("click", async () => {
|
|
||||||
toastService.showMessage(t("database_integrity_check.checking_integrity"));
|
|
||||||
|
|
||||||
const { results } = await server.get<Response>("database/check-integrity");
|
|
||||||
|
|
||||||
if (results.length === 1 && results[0].integrity_check === "ok") {
|
|
||||||
toastService.showMessage(t("database_integrity_check.integrity_check_succeeded"));
|
|
||||||
} else {
|
|
||||||
toastService.showMessage(t("database_integrity_check.integrity_check_failed", { results: JSON.stringify(results, null, 2) }), 15000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$findAndFixConsistencyIssuesButton = this.$widget.find(".find-and-fix-consistency-issues-button");
|
|
||||||
this.$findAndFixConsistencyIssuesButton.on("click", async () => {
|
|
||||||
toastService.showMessage(t("consistency_checks.finding_and_fixing_message"));
|
|
||||||
|
|
||||||
await server.post("database/find-and-fix-consistency-issues");
|
|
||||||
|
|
||||||
toastService.showMessage(t("consistency_checks.issues_fixed_message"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import server from "../../../../services/server.js";
|
|
||||||
import toastService from "../../../../services/toast.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import type { OptionMap } from "@triliumnext/commons";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("sync.title")}</h4>
|
|
||||||
<button class="force-full-sync-button btn btn-secondary">${t("sync.force_full_sync_button")}</button>
|
|
||||||
|
|
||||||
<button class="fill-entity-changes-button btn btn-secondary">${t("sync.fill_entity_changes_button")}</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
export default class AdvancedSyncOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $forceFullSyncButton!: JQuery<HTMLElement>;
|
|
||||||
private $fillEntityChangesButton!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.$forceFullSyncButton = this.$widget.find(".force-full-sync-button");
|
|
||||||
this.$fillEntityChangesButton = this.$widget.find(".fill-entity-changes-button");
|
|
||||||
this.$forceFullSyncButton.on("click", async () => {
|
|
||||||
await server.post("sync/force-full-sync");
|
|
||||||
|
|
||||||
toastService.showMessage(t("sync.full_sync_triggered"));
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$fillEntityChangesButton.on("click", async () => {
|
|
||||||
toastService.showMessage(t("sync.filling_entity_changes"));
|
|
||||||
|
|
||||||
await server.post("sync/fill-entity-changes");
|
|
||||||
|
|
||||||
toastService.showMessage(t("sync.sync_rows_filled_successfully"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async optionsLoaded(options: OptionMap) {}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import toastService from "../../../../services/toast.js";
|
|
||||||
import server from "../../../../services/server.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("vacuum_database.title")}</h4>
|
|
||||||
|
|
||||||
<p class="form-text">${t("vacuum_database.description")}</p>
|
|
||||||
|
|
||||||
<button class="vacuum-database-button btn btn-secondary">${t("vacuum_database.button_text")}</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
export default class VacuumDatabaseOptions extends OptionsWidget {
|
|
||||||
private $vacuumDatabaseButton!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.$vacuumDatabaseButton = this.$widget.find(".vacuum-database-button");
|
|
||||||
this.$vacuumDatabaseButton.on("click", async () => {
|
|
||||||
toastService.showMessage(t("vacuum_database.vacuuming_database"));
|
|
||||||
|
|
||||||
await server.post("database/vacuum-database");
|
|
||||||
|
|
||||||
toastService.showMessage(t("vacuum_database.database_vacuumed"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import AiSettingsWidget from './ai_settings/index.js';
|
|
||||||
export default AiSettingsWidget;
|
|
||||||
236
apps/client/src/widgets/type_widgets/options/ai_settings.tsx
Normal file
236
apps/client/src/widgets/type_widgets/options/ai_settings.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||||
|
import { t } from "../../../services/i18n";
|
||||||
|
import toast from "../../../services/toast";
|
||||||
|
import FormCheckbox from "../../react/FormCheckbox";
|
||||||
|
import FormGroup from "../../react/FormGroup";
|
||||||
|
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||||
|
import OptionsSection from "./components/OptionsSection";
|
||||||
|
import Admonition from "../../react/Admonition";
|
||||||
|
import FormSelect from "../../react/FormSelect";
|
||||||
|
import FormTextBox from "../../react/FormTextBox";
|
||||||
|
import type { OllamaModelResponse, OpenAiOrAnthropicModelResponse, OptionNames } from "@triliumnext/commons";
|
||||||
|
import server from "../../../services/server";
|
||||||
|
import Button from "../../react/Button";
|
||||||
|
import FormTextArea from "../../react/FormTextArea";
|
||||||
|
|
||||||
|
export default function AiSettings() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EnableAiSettings />
|
||||||
|
<ProviderSettings />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnableAiSettings() {
|
||||||
|
const [ aiEnabled, setAiEnabled ] = useTriliumOptionBool("aiEnabled");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OptionsSection title={t("ai_llm.title")}>
|
||||||
|
<FormGroup name="ai-enabled" description={t("ai_llm.enable_ai_description")}>
|
||||||
|
<FormCheckbox
|
||||||
|
label={t("ai_llm.enable_ai_features")}
|
||||||
|
currentValue={aiEnabled} onChange={(isEnabled) => {
|
||||||
|
if (isEnabled) {
|
||||||
|
toast.showMessage(t("ai_llm.ai_enabled"));
|
||||||
|
} else {
|
||||||
|
toast.showMessage(t("ai_llm.ai_disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiEnabled(isEnabled);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{aiEnabled && <Admonition type="warning">{t("ai_llm.experimental_warning")}</Admonition>}
|
||||||
|
</OptionsSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderSettings() {
|
||||||
|
const [ aiSelectedProvider, setAiSelectedProvider ] = useTriliumOption("aiSelectedProvider");
|
||||||
|
const [ aiTemperature, setAiTemperature ] = useTriliumOption("aiTemperature");
|
||||||
|
const [ aiSystemPrompt, setAiSystemPrompt ] = useTriliumOption("aiSystemPrompt");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("ai_llm.provider_configuration")}>
|
||||||
|
<FormGroup name="selected-provider" label={t("ai_llm.selected_provider")} description={t("ai_llm.selected_provider_description")}>
|
||||||
|
<FormSelect
|
||||||
|
values={[
|
||||||
|
{ value: "", text: t("ai_llm.select_provider") },
|
||||||
|
{ value: "openai", text: "OpenAI" },
|
||||||
|
{ value: "anthropic", text: "Anthropic" },
|
||||||
|
{ value: "ollama", text: "Ollama" }
|
||||||
|
]}
|
||||||
|
currentValue={aiSelectedProvider} onChange={setAiSelectedProvider}
|
||||||
|
keyProperty="value" titleProperty="text"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{
|
||||||
|
aiSelectedProvider === "openai" ?
|
||||||
|
<SingleProviderSettings
|
||||||
|
title={t("ai_llm.openai_settings")}
|
||||||
|
apiKeyDescription={t("ai_llm.openai_api_key_description")}
|
||||||
|
baseUrlDescription={t("ai_llm.openai_url_description")}
|
||||||
|
modelDescription={t("ai_llm.openai_model_description")}
|
||||||
|
validationErrorMessage={t("ai_llm.empty_key_warning.openai")}
|
||||||
|
apiKeyOption="openaiApiKey" baseUrlOption="openaiBaseUrl" modelOption="openaiDefaultModel"
|
||||||
|
provider={aiSelectedProvider}
|
||||||
|
/>
|
||||||
|
: aiSelectedProvider === "anthropic" ?
|
||||||
|
<SingleProviderSettings
|
||||||
|
title={t("ai_llm.anthropic_settings")}
|
||||||
|
apiKeyDescription={t("ai_llm.anthropic_api_key_description")}
|
||||||
|
modelDescription={t("ai_llm.anthropic_model_description")}
|
||||||
|
baseUrlDescription={t("ai_llm.anthropic_url_description")}
|
||||||
|
validationErrorMessage={t("ai_llm.empty_key_warning.anthropic")}
|
||||||
|
apiKeyOption="anthropicApiKey" baseUrlOption="anthropicBaseUrl" modelOption="anthropicDefaultModel"
|
||||||
|
provider={aiSelectedProvider}
|
||||||
|
/>
|
||||||
|
: aiSelectedProvider === "ollama" ?
|
||||||
|
<SingleProviderSettings
|
||||||
|
title={t("ai_llm.ollama_settings")}
|
||||||
|
baseUrlDescription={t("ai_llm.ollama_url_description")}
|
||||||
|
modelDescription={t("ai_llm.ollama_model_description")}
|
||||||
|
validationErrorMessage={t("ai_llm.ollama_no_url")}
|
||||||
|
baseUrlOption="ollamaBaseUrl"
|
||||||
|
provider={aiSelectedProvider} modelOption="ollamaDefaultModel"
|
||||||
|
/>
|
||||||
|
:
|
||||||
|
<></>
|
||||||
|
}
|
||||||
|
|
||||||
|
<FormGroup name="ai-temperature" label={t("ai_llm.temperature")} description={t("ai_llm.temperature_description")}>
|
||||||
|
<FormTextBox
|
||||||
|
type="number" min="0" max="2" step="0.1"
|
||||||
|
currentValue={aiTemperature} onChange={setAiTemperature}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup name="system-prompt" label={t("ai_llm.system_prompt")} description={t("ai_llm.system_prompt_description")}>
|
||||||
|
<FormTextArea
|
||||||
|
rows={3}
|
||||||
|
currentValue={aiSystemPrompt} onBlur={setAiSystemPrompt}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleProviderSettingsProps {
|
||||||
|
provider: string;
|
||||||
|
title: string;
|
||||||
|
apiKeyDescription?: string;
|
||||||
|
baseUrlDescription: string;
|
||||||
|
modelDescription: string;
|
||||||
|
validationErrorMessage: string;
|
||||||
|
apiKeyOption?: OptionNames;
|
||||||
|
baseUrlOption: OptionNames;
|
||||||
|
modelOption: OptionNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDescription, modelDescription, validationErrorMessage, apiKeyOption, baseUrlOption, modelOption }: SingleProviderSettingsProps) {
|
||||||
|
const [ apiKey, setApiKey ] = apiKeyOption ? useTriliumOption(apiKeyOption) : [];
|
||||||
|
const [ baseUrl, setBaseUrl ] = useTriliumOption(baseUrlOption);
|
||||||
|
const isValid = (apiKeyOption ? !!apiKey : !!baseUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="provider-settings">
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>{title}</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{!isValid && <Admonition type="caution">{validationErrorMessage}</Admonition> }
|
||||||
|
|
||||||
|
{apiKeyOption && (
|
||||||
|
<FormGroup name="api-key" label={t("ai_llm.api_key")} description={apiKeyDescription}>
|
||||||
|
<FormTextBox
|
||||||
|
type="password" autoComplete="off"
|
||||||
|
currentValue={apiKey} onChange={setApiKey}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormGroup name="base-url" label={t("ai_llm.url")} description={baseUrlDescription}>
|
||||||
|
<FormTextBox
|
||||||
|
currentValue={baseUrl ?? "https://api.openai.com/v1"} onChange={setBaseUrl}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{isValid &&
|
||||||
|
<FormGroup name="model" label={t("ai_llm.model")} description={modelDescription}>
|
||||||
|
<ModelSelector provider={provider} baseUrl={baseUrl} modelOption={modelOption} />
|
||||||
|
</FormGroup>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelector({ provider, baseUrl, modelOption }: { provider: string; baseUrl: string, modelOption: OptionNames }) {
|
||||||
|
const [ model, setModel ] = useTriliumOption(modelOption);
|
||||||
|
const [ models, setModels ] = useState<{ name: string, id: string }[]>([]);
|
||||||
|
|
||||||
|
const loadProviders = useCallback(async () => {
|
||||||
|
switch (provider) {
|
||||||
|
case "openai":
|
||||||
|
case "anthropic": {
|
||||||
|
try {
|
||||||
|
const response = await server.get<OpenAiOrAnthropicModelResponse>(`llm/providers/${provider}/models?baseUrl=${encodeURIComponent(baseUrl)}`);
|
||||||
|
if (response.success) {
|
||||||
|
setModels(response.chatModels.toSorted((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
} else {
|
||||||
|
toast.showError(t("ai_llm.no_models_found_online"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.showError(t("ai_llm.error_fetching", { error: e }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ollama": {
|
||||||
|
try {
|
||||||
|
const response = await server.get<OllamaModelResponse>(`llm/providers/ollama/models?baseUrl=${encodeURIComponent(baseUrl)}`);
|
||||||
|
if (response.success) {
|
||||||
|
setModels(response.models
|
||||||
|
.map(model => ({
|
||||||
|
name: model.name,
|
||||||
|
id: model.model
|
||||||
|
}))
|
||||||
|
.toSorted((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
} else {
|
||||||
|
toast.showError(t("ai_llm.no_models_found_ollama"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.showError(t("ai_llm.error_fetching", { error: e }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [provider]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProviders();
|
||||||
|
}, [provider]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSelect
|
||||||
|
values={models}
|
||||||
|
keyProperty="id" titleProperty="name"
|
||||||
|
currentValue={model} onChange={setModel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text={t("ai_llm.refresh_models")}
|
||||||
|
onClick={loadProviders}
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: "0.5em" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import { TPL } from "./template.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import type { OptionDefinitions, OptionMap } from "@triliumnext/commons";
|
|
||||||
import server from "../../../../services/server.js";
|
|
||||||
import toastService from "../../../../services/toast.js";
|
|
||||||
import { ProviderService } from "./providers.js";
|
|
||||||
|
|
||||||
export default class AiSettingsWidget extends OptionsWidget {
|
|
||||||
private ollamaModelsRefreshed = false;
|
|
||||||
private openaiModelsRefreshed = false;
|
|
||||||
private anthropicModelsRefreshed = false;
|
|
||||||
private providerService: ProviderService | null = null;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.providerService = new ProviderService(this.$widget);
|
|
||||||
|
|
||||||
// Setup event handlers for options
|
|
||||||
this.setupEventHandlers();
|
|
||||||
|
|
||||||
return this.$widget;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper method to set up a change event handler for an option
|
|
||||||
* @param selector The jQuery selector for the element
|
|
||||||
* @param optionName The name of the option to update
|
|
||||||
* @param validateAfter Whether to run validation after the update
|
|
||||||
* @param isCheckbox Whether the element is a checkbox
|
|
||||||
*/
|
|
||||||
setupChangeHandler(selector: string, optionName: keyof OptionDefinitions, validateAfter: boolean = false, isCheckbox: boolean = false) {
|
|
||||||
if (!this.$widget) return;
|
|
||||||
|
|
||||||
const $element = this.$widget.find(selector);
|
|
||||||
$element.on('change', async () => {
|
|
||||||
let value: string;
|
|
||||||
|
|
||||||
if (isCheckbox) {
|
|
||||||
value = $element.prop('checked') ? 'true' : 'false';
|
|
||||||
} else {
|
|
||||||
value = $element.val() as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.updateOption(optionName, value);
|
|
||||||
|
|
||||||
// Special handling for aiEnabled option
|
|
||||||
if (optionName === 'aiEnabled') {
|
|
||||||
try {
|
|
||||||
const isEnabled = value === 'true';
|
|
||||||
|
|
||||||
if (isEnabled) {
|
|
||||||
toastService.showMessage(t("ai_llm.ai_enabled") || "AI features enabled");
|
|
||||||
} else {
|
|
||||||
toastService.showMessage(t("ai_llm.ai_disabled") || "AI features disabled");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling AI:', error);
|
|
||||||
toastService.showError(t("ai_llm.ai_toggle_error") || "Error toggling AI features");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validateAfter) {
|
|
||||||
await this.displayValidationWarnings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up all event handlers for options
|
|
||||||
*/
|
|
||||||
setupEventHandlers() {
|
|
||||||
if (!this.$widget) return;
|
|
||||||
|
|
||||||
// Core AI options
|
|
||||||
this.setupChangeHandler('.ai-enabled', 'aiEnabled', true, true);
|
|
||||||
this.setupChangeHandler('.ai-selected-provider', 'aiSelectedProvider', true);
|
|
||||||
this.setupChangeHandler('.ai-temperature', 'aiTemperature');
|
|
||||||
this.setupChangeHandler('.ai-system-prompt', 'aiSystemPrompt');
|
|
||||||
|
|
||||||
// OpenAI options
|
|
||||||
this.setupChangeHandler('.openai-api-key', 'openaiApiKey', true);
|
|
||||||
this.setupChangeHandler('.openai-base-url', 'openaiBaseUrl', true);
|
|
||||||
this.setupChangeHandler('.openai-default-model', 'openaiDefaultModel');
|
|
||||||
|
|
||||||
// Anthropic options
|
|
||||||
this.setupChangeHandler('.anthropic-api-key', 'anthropicApiKey', true);
|
|
||||||
this.setupChangeHandler('.anthropic-default-model', 'anthropicDefaultModel');
|
|
||||||
this.setupChangeHandler('.anthropic-base-url', 'anthropicBaseUrl');
|
|
||||||
|
|
||||||
// Voyage options
|
|
||||||
this.setupChangeHandler('.voyage-api-key', 'voyageApiKey');
|
|
||||||
|
|
||||||
// Ollama options
|
|
||||||
this.setupChangeHandler('.ollama-base-url', 'ollamaBaseUrl');
|
|
||||||
this.setupChangeHandler('.ollama-default-model', 'ollamaDefaultModel');
|
|
||||||
|
|
||||||
const $refreshModels = this.$widget.find('.refresh-models');
|
|
||||||
$refreshModels.on('click', async () => {
|
|
||||||
this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(true, this.ollamaModelsRefreshed) || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add tab change handler for Ollama tab
|
|
||||||
const $ollamaTab = this.$widget.find('#nav-ollama-tab');
|
|
||||||
$ollamaTab.on('shown.bs.tab', async () => {
|
|
||||||
// Only refresh the models if we haven't done it before
|
|
||||||
this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(false, this.ollamaModelsRefreshed) || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// OpenAI models refresh button
|
|
||||||
const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
|
|
||||||
$refreshOpenAIModels.on('click', async () => {
|
|
||||||
this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(true, this.openaiModelsRefreshed) || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add tab change handler for OpenAI tab
|
|
||||||
const $openaiTab = this.$widget.find('#nav-openai-tab');
|
|
||||||
$openaiTab.on('shown.bs.tab', async () => {
|
|
||||||
// Only refresh the models if we haven't done it before
|
|
||||||
this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(false, this.openaiModelsRefreshed) || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Anthropic models refresh button
|
|
||||||
const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
|
|
||||||
$refreshAnthropicModels.on('click', async () => {
|
|
||||||
this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(true, this.anthropicModelsRefreshed) || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add tab change handler for Anthropic tab
|
|
||||||
const $anthropicTab = this.$widget.find('#nav-anthropic-tab');
|
|
||||||
$anthropicTab.on('shown.bs.tab', async () => {
|
|
||||||
// Only refresh the models if we haven't done it before
|
|
||||||
this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(false, this.anthropicModelsRefreshed) || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Add provider selection change handlers for dynamic settings visibility
|
|
||||||
this.$widget.find('.ai-selected-provider').on('change', async () => {
|
|
||||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
this.$widget.find('.provider-settings').hide();
|
|
||||||
if (selectedProvider) {
|
|
||||||
this.$widget.find(`.${selectedProvider}-provider-settings`).show();
|
|
||||||
// Automatically fetch models for the newly selected provider
|
|
||||||
await this.fetchModelsForProvider(selectedProvider, 'chat');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Add base URL change handlers to trigger model fetching
|
|
||||||
this.$widget.find('.openai-base-url').on('change', async () => {
|
|
||||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
if (selectedProvider === 'openai') {
|
|
||||||
await this.fetchModelsForProvider('openai', 'chat');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$widget.find('.anthropic-base-url').on('change', async () => {
|
|
||||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
if (selectedProvider === 'anthropic') {
|
|
||||||
await this.fetchModelsForProvider('anthropic', 'chat');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$widget.find('.ollama-base-url').on('change', async () => {
|
|
||||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
if (selectedProvider === 'ollama') {
|
|
||||||
await this.fetchModelsForProvider('ollama', 'chat');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add API key change handlers to trigger model fetching
|
|
||||||
this.$widget.find('.openai-api-key').on('change', async () => {
|
|
||||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
if (selectedProvider === 'openai') {
|
|
||||||
await this.fetchModelsForProvider('openai', 'chat');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$widget.find('.anthropic-api-key').on('change', async () => {
|
|
||||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
if (selectedProvider === 'anthropic') {
|
|
||||||
await this.fetchModelsForProvider('anthropic', 'chat');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display warnings for validation issues with providers
|
|
||||||
*/
|
|
||||||
async displayValidationWarnings() {
|
|
||||||
if (!this.$widget) return;
|
|
||||||
|
|
||||||
const $warningDiv = this.$widget.find('.provider-validation-warning');
|
|
||||||
|
|
||||||
// Check if AI is enabled
|
|
||||||
const aiEnabled = this.$widget.find('.ai-enabled').prop('checked');
|
|
||||||
if (!aiEnabled) {
|
|
||||||
$warningDiv.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get selected provider
|
|
||||||
const selectedProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
|
|
||||||
// Start with experimental warning
|
|
||||||
const allWarnings = [
|
|
||||||
t("ai_llm.experimental_warning")
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check for selected provider configuration
|
|
||||||
const providerWarnings: string[] = [];
|
|
||||||
if (selectedProvider === 'openai') {
|
|
||||||
const openaiApiKey = this.$widget.find('.openai-api-key').val();
|
|
||||||
if (!openaiApiKey) {
|
|
||||||
providerWarnings.push(t("ai_llm.empty_key_warning.openai"));
|
|
||||||
}
|
|
||||||
} else if (selectedProvider === 'anthropic') {
|
|
||||||
const anthropicApiKey = this.$widget.find('.anthropic-api-key').val();
|
|
||||||
if (!anthropicApiKey) {
|
|
||||||
providerWarnings.push(t("ai_llm.empty_key_warning.anthropic"));
|
|
||||||
}
|
|
||||||
} else if (selectedProvider === 'ollama') {
|
|
||||||
const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val();
|
|
||||||
if (!ollamaBaseUrl) {
|
|
||||||
providerWarnings.push(t("ai_llm.ollama_no_url"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add provider warnings to all warnings
|
|
||||||
allWarnings.push(...providerWarnings);
|
|
||||||
|
|
||||||
// Show or hide warnings
|
|
||||||
if (allWarnings.length > 0) {
|
|
||||||
const warningHtml = '<strong>' + t("ai_llm.configuration_warnings") + '</strong><ul>' +
|
|
||||||
allWarnings.map(warning => `<li>${warning}</li>`).join('') + '</ul>';
|
|
||||||
$warningDiv.html(warningHtml).show();
|
|
||||||
} else {
|
|
||||||
$warningDiv.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to get display name for providers
|
|
||||||
*/
|
|
||||||
getProviderDisplayName(provider: string): string {
|
|
||||||
switch(provider) {
|
|
||||||
case 'openai': return 'OpenAI';
|
|
||||||
case 'anthropic': return 'Anthropic';
|
|
||||||
case 'ollama': return 'Ollama';
|
|
||||||
case 'voyage': return 'Voyage';
|
|
||||||
case 'local': return 'Local';
|
|
||||||
default: return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set model dropdown value, adding the option if it doesn't exist
|
|
||||||
*/
|
|
||||||
setModelDropdownValue(selector: string, value: string | undefined) {
|
|
||||||
if (!this.$widget || !value) return;
|
|
||||||
|
|
||||||
const $dropdown = this.$widget.find(selector);
|
|
||||||
|
|
||||||
// Check if the value already exists as an option
|
|
||||||
if ($dropdown.find(`option[value="${value}"]`).length === 0) {
|
|
||||||
// Add the custom value as an option
|
|
||||||
$dropdown.append(`<option value="${value}">${value} (current)</option>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the value
|
|
||||||
$dropdown.val(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch models for a specific provider and model type
|
|
||||||
*/
|
|
||||||
async fetchModelsForProvider(provider: string, modelType: 'chat') {
|
|
||||||
if (!this.providerService) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (provider) {
|
|
||||||
case 'openai':
|
|
||||||
this.openaiModelsRefreshed = await this.providerService.refreshOpenAIModels(false, this.openaiModelsRefreshed);
|
|
||||||
break;
|
|
||||||
case 'anthropic':
|
|
||||||
this.anthropicModelsRefreshed = await this.providerService.refreshAnthropicModels(false, this.anthropicModelsRefreshed);
|
|
||||||
break;
|
|
||||||
case 'ollama':
|
|
||||||
this.ollamaModelsRefreshed = await this.providerService.refreshOllamaModels(false, this.ollamaModelsRefreshed);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log(`Model fetching not implemented for provider: ${provider}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching models for ${provider}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update provider settings visibility based on selected providers
|
|
||||||
*/
|
|
||||||
updateProviderSettingsVisibility() {
|
|
||||||
if (!this.$widget) return;
|
|
||||||
|
|
||||||
// Update AI provider settings visibility
|
|
||||||
const selectedAiProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
this.$widget.find('.provider-settings').hide();
|
|
||||||
if (selectedAiProvider) {
|
|
||||||
this.$widget.find(`.${selectedAiProvider}-provider-settings`).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the options have been loaded from the server
|
|
||||||
*/
|
|
||||||
async optionsLoaded(options: OptionMap) {
|
|
||||||
if (!this.$widget) return;
|
|
||||||
|
|
||||||
// AI Options
|
|
||||||
this.$widget.find('.ai-enabled').prop('checked', options.aiEnabled !== 'false');
|
|
||||||
this.$widget.find('.ai-temperature').val(options.aiTemperature || '0.7');
|
|
||||||
this.$widget.find('.ai-system-prompt').val(options.aiSystemPrompt || '');
|
|
||||||
this.$widget.find('.ai-selected-provider').val(options.aiSelectedProvider || 'openai');
|
|
||||||
|
|
||||||
// OpenAI Section
|
|
||||||
this.$widget.find('.openai-api-key').val(options.openaiApiKey || '');
|
|
||||||
this.$widget.find('.openai-base-url').val(options.openaiBaseUrl || 'https://api.openai.com/v1');
|
|
||||||
this.setModelDropdownValue('.openai-default-model', options.openaiDefaultModel);
|
|
||||||
|
|
||||||
// Anthropic Section
|
|
||||||
this.$widget.find('.anthropic-api-key').val(options.anthropicApiKey || '');
|
|
||||||
this.$widget.find('.anthropic-base-url').val(options.anthropicBaseUrl || 'https://api.anthropic.com');
|
|
||||||
this.setModelDropdownValue('.anthropic-default-model', options.anthropicDefaultModel);
|
|
||||||
|
|
||||||
// Voyage Section
|
|
||||||
this.$widget.find('.voyage-api-key').val(options.voyageApiKey || '');
|
|
||||||
|
|
||||||
// Ollama Section
|
|
||||||
this.$widget.find('.ollama-base-url').val(options.ollamaBaseUrl || 'http://localhost:11434');
|
|
||||||
this.setModelDropdownValue('.ollama-default-model', options.ollamaDefaultModel);
|
|
||||||
|
|
||||||
// Show/hide provider settings based on selected providers
|
|
||||||
this.updateProviderSettingsVisibility();
|
|
||||||
|
|
||||||
// Automatically fetch models for currently selected providers
|
|
||||||
const selectedAiProvider = this.$widget.find('.ai-selected-provider').val() as string;
|
|
||||||
|
|
||||||
if (selectedAiProvider) {
|
|
||||||
await this.fetchModelsForProvider(selectedAiProvider, 'chat');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display validation warnings
|
|
||||||
this.displayValidationWarnings();
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
// Cleanup method for widget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
import AiSettingsWidget from './ai_settings_widget.js';
|
|
||||||
export default AiSettingsWidget;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Interface for the Ollama model response
|
|
||||||
export interface OllamaModelResponse {
|
|
||||||
success: boolean;
|
|
||||||
models: Array<{
|
|
||||||
name: string;
|
|
||||||
model: string;
|
|
||||||
details?: {
|
|
||||||
family?: string;
|
|
||||||
parameter_size?: string;
|
|
||||||
}
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface OpenAIModelResponse {
|
|
||||||
success: boolean;
|
|
||||||
chatModels: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnthropicModelResponse {
|
|
||||||
success: boolean;
|
|
||||||
chatModels: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import server from "../../../../services/server.js";
|
|
||||||
import toastService from "../../../../services/toast.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import options from "../../../../services/options.js";
|
|
||||||
import type { OpenAIModelResponse, AnthropicModelResponse, OllamaModelResponse } from "./interfaces.js";
|
|
||||||
|
|
||||||
export class ProviderService {
|
|
||||||
constructor(private $widget: JQuery<HTMLElement>) {
|
|
||||||
// AI provider settings
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures the dropdown has the correct value set, prioritizing:
|
|
||||||
* 1. Current UI value if present
|
|
||||||
* 2. Value from database options if available
|
|
||||||
* 3. Falling back to first option if neither is available
|
|
||||||
*/
|
|
||||||
private ensureSelectedValue($select: JQuery<HTMLElement>, currentValue: string | number | string[] | undefined | null, optionName: string) {
|
|
||||||
if (currentValue) {
|
|
||||||
$select.val(currentValue);
|
|
||||||
// If the value doesn't exist anymore, select the first option
|
|
||||||
if (!$select.val()) {
|
|
||||||
$select.prop('selectedIndex', 0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no current value exists in the dropdown but there's a default in the database
|
|
||||||
const savedModel = options.get(optionName);
|
|
||||||
if (savedModel) {
|
|
||||||
$select.val(savedModel);
|
|
||||||
// If the saved model isn't in the dropdown, select the first option
|
|
||||||
if (!$select.val()) {
|
|
||||||
$select.prop('selectedIndex', 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes the list of OpenAI models
|
|
||||||
* @param showLoading Whether to show loading indicators and toasts
|
|
||||||
* @param openaiModelsRefreshed Reference to track if models have been refreshed
|
|
||||||
* @returns Promise that resolves when the refresh is complete
|
|
||||||
*/
|
|
||||||
async refreshOpenAIModels(showLoading: boolean, openaiModelsRefreshed: boolean): Promise<boolean> {
|
|
||||||
if (!this.$widget) return false;
|
|
||||||
|
|
||||||
const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
|
|
||||||
|
|
||||||
// If we've already refreshed and we're not forcing a refresh, don't do it again
|
|
||||||
if (openaiModelsRefreshed && !showLoading) {
|
|
||||||
return openaiModelsRefreshed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
$refreshOpenAIModels.prop('disabled', true);
|
|
||||||
$refreshOpenAIModels.html(`<i class="spinner-border spinner-border-sm"></i>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const openaiBaseUrl = this.$widget.find('.openai-base-url').val() as string;
|
|
||||||
const response = await server.get<OpenAIModelResponse>(`llm/providers/openai/models?baseUrl=${encodeURIComponent(openaiBaseUrl)}`);
|
|
||||||
|
|
||||||
if (response && response.success) {
|
|
||||||
// Update the chat models dropdown
|
|
||||||
if (response.chatModels?.length > 0) {
|
|
||||||
const $chatModelSelect = this.$widget.find('.openai-default-model');
|
|
||||||
const currentChatValue = $chatModelSelect.val();
|
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
$chatModelSelect.empty();
|
|
||||||
|
|
||||||
// Sort models by name
|
|
||||||
const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
// Add models to the dropdown
|
|
||||||
sortedChatModels.forEach(model => {
|
|
||||||
$chatModelSelect.append(`<option value="${model.id}">${model.name}</option>`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to restore the previously selected value
|
|
||||||
this.ensureSelectedValue($chatModelSelect, currentChatValue, 'openaiDefaultModel');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
// Show success message
|
|
||||||
const totalModels = (response.chatModels?.length || 0);
|
|
||||||
toastService.showMessage(`${totalModels} OpenAI models found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} else if (showLoading) {
|
|
||||||
toastService.showError(`No OpenAI models found. Please check your API key and settings.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return openaiModelsRefreshed;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error fetching OpenAI models:`, e);
|
|
||||||
if (showLoading) {
|
|
||||||
toastService.showError(`Error fetching OpenAI models: ${e}`);
|
|
||||||
}
|
|
||||||
return openaiModelsRefreshed;
|
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
$refreshOpenAIModels.prop('disabled', false);
|
|
||||||
$refreshOpenAIModels.html(`<span class="bx bx-refresh"></span>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes the list of Anthropic models
|
|
||||||
* @param showLoading Whether to show loading indicators and toasts
|
|
||||||
* @param anthropicModelsRefreshed Reference to track if models have been refreshed
|
|
||||||
* @returns Promise that resolves when the refresh is complete
|
|
||||||
*/
|
|
||||||
async refreshAnthropicModels(showLoading: boolean, anthropicModelsRefreshed: boolean): Promise<boolean> {
|
|
||||||
if (!this.$widget) return false;
|
|
||||||
|
|
||||||
const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
|
|
||||||
|
|
||||||
// If we've already refreshed and we're not forcing a refresh, don't do it again
|
|
||||||
if (anthropicModelsRefreshed && !showLoading) {
|
|
||||||
return anthropicModelsRefreshed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
$refreshAnthropicModels.prop('disabled', true);
|
|
||||||
$refreshAnthropicModels.html(`<i class="spinner-border spinner-border-sm"></i>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const anthropicBaseUrl = this.$widget.find('.anthropic-base-url').val() as string;
|
|
||||||
const response = await server.get<AnthropicModelResponse>(`llm/providers/anthropic/models?baseUrl=${encodeURIComponent(anthropicBaseUrl)}`);
|
|
||||||
|
|
||||||
if (response && response.success) {
|
|
||||||
// Update the chat models dropdown
|
|
||||||
if (response.chatModels?.length > 0) {
|
|
||||||
const $chatModelSelect = this.$widget.find('.anthropic-default-model');
|
|
||||||
const currentChatValue = $chatModelSelect.val();
|
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
$chatModelSelect.empty();
|
|
||||||
|
|
||||||
// Sort models by name
|
|
||||||
const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
// Add models to the dropdown
|
|
||||||
sortedChatModels.forEach(model => {
|
|
||||||
$chatModelSelect.append(`<option value="${model.id}">${model.name}</option>`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to restore the previously selected value
|
|
||||||
this.ensureSelectedValue($chatModelSelect, currentChatValue, 'anthropicDefaultModel');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
// Show success message
|
|
||||||
const totalModels = (response.chatModels?.length || 0);
|
|
||||||
toastService.showMessage(`${totalModels} Anthropic models found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} else if (showLoading) {
|
|
||||||
toastService.showError(`No Anthropic models found. Please check your API key and settings.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return anthropicModelsRefreshed;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error fetching Anthropic models:`, e);
|
|
||||||
if (showLoading) {
|
|
||||||
toastService.showError(`Error fetching Anthropic models: ${e}`);
|
|
||||||
}
|
|
||||||
return anthropicModelsRefreshed;
|
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
$refreshAnthropicModels.prop('disabled', false);
|
|
||||||
$refreshAnthropicModels.html(`<span class="bx bx-refresh"></span>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes the list of Ollama models
|
|
||||||
* @param showLoading Whether to show loading indicators and toasts
|
|
||||||
* @param ollamaModelsRefreshed Reference to track if models have been refreshed
|
|
||||||
* @returns Promise that resolves when the refresh is complete
|
|
||||||
*/
|
|
||||||
async refreshOllamaModels(showLoading: boolean, ollamaModelsRefreshed: boolean): Promise<boolean> {
|
|
||||||
if (!this.$widget) return false;
|
|
||||||
|
|
||||||
const $refreshModels = this.$widget.find('.refresh-models');
|
|
||||||
|
|
||||||
// If we've already refreshed and we're not forcing a refresh, don't do it again
|
|
||||||
if (ollamaModelsRefreshed && !showLoading) {
|
|
||||||
return ollamaModelsRefreshed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
$refreshModels.prop('disabled', true);
|
|
||||||
$refreshModels.text(t("ai_llm.refreshing_models"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the general Ollama base URL
|
|
||||||
const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val() as string;
|
|
||||||
|
|
||||||
const response = await server.get<OllamaModelResponse>(`llm/providers/ollama/models?baseUrl=${encodeURIComponent(ollamaBaseUrl)}`);
|
|
||||||
|
|
||||||
if (response && response.success && response.models && response.models.length > 0) {
|
|
||||||
// Update the LLM model dropdown
|
|
||||||
const $modelSelect = this.$widget.find('.ollama-default-model');
|
|
||||||
const currentModelValue = $modelSelect.val();
|
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
$modelSelect.empty();
|
|
||||||
|
|
||||||
// Sort models by name to make them easier to find
|
|
||||||
const sortedModels = [...response.models].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
// Add all models to the dropdown
|
|
||||||
sortedModels.forEach(model => {
|
|
||||||
$modelSelect.append(`<option value="${model.name}">${model.name}</option>`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to restore the previously selected value
|
|
||||||
this.ensureSelectedValue($modelSelect, currentModelValue, 'ollamaDefaultModel');
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
toastService.showMessage(`${response.models.length} Ollama models found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} else if (showLoading) {
|
|
||||||
toastService.showError(`No Ollama models found. Please check if Ollama is running.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ollamaModelsRefreshed;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error fetching Ollama models:`, e);
|
|
||||||
if (showLoading) {
|
|
||||||
toastService.showError(`Error fetching Ollama models: ${e}`);
|
|
||||||
}
|
|
||||||
return ollamaModelsRefreshed;
|
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
$refreshModels.prop('disabled', false);
|
|
||||||
$refreshModels.html(`<span class="bx bx-refresh"></span>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
|
|
||||||
export const TPL = `
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("ai_llm.title")}</h4>
|
|
||||||
|
|
||||||
<!-- Add warning alert div -->
|
|
||||||
<div class="provider-validation-warning alert alert-warning" style="display: none;"></div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="tn-checkbox">
|
|
||||||
<input class="ai-enabled form-check-input" type="checkbox">
|
|
||||||
${t("ai_llm.enable_ai_features")}
|
|
||||||
</label>
|
|
||||||
<div class="form-text">${t("ai_llm.enable_ai_description")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI settings template -->
|
|
||||||
|
|
||||||
<div class="ai-providers-section options-section">
|
|
||||||
<h4>${t("ai_llm.provider_configuration")}</h4>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.selected_provider")}</label>
|
|
||||||
<select class="ai-selected-provider form-control">
|
|
||||||
<option value="">${t("ai_llm.select_provider")}</option>
|
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
<option value="anthropic">Anthropic</option>
|
|
||||||
<option value="ollama">Ollama</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-text">${t("ai_llm.selected_provider_description")}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- OpenAI Provider Settings -->
|
|
||||||
<div class="provider-settings openai-provider-settings" style="display: none;">
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>${t("ai_llm.openai_settings")}</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.api_key")}</label>
|
|
||||||
<input type="password" class="openai-api-key form-control" autocomplete="off" />
|
|
||||||
<div class="form-text">${t("ai_llm.openai_api_key_description")}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.url")}</label>
|
|
||||||
<input type="text" class="openai-base-url form-control" />
|
|
||||||
<div class="form-text">${t("ai_llm.openai_url_description")}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.model")}</label>
|
|
||||||
<select class="openai-default-model form-control">
|
|
||||||
<option value="">${t("ai_llm.select_model")}</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-text">${t("ai_llm.openai_model_description")}</div>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary refresh-openai-models">${t("ai_llm.refresh_models")}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Anthropic Provider Settings -->
|
|
||||||
<div class="provider-settings anthropic-provider-settings" style="display: none;">
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>${t("ai_llm.anthropic_settings")}</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.api_key")}</label>
|
|
||||||
<input type="password" class="anthropic-api-key form-control" autocomplete="off" />
|
|
||||||
<div class="form-text">${t("ai_llm.anthropic_api_key_description")}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.url")}</label>
|
|
||||||
<input type="text" class="anthropic-base-url form-control" />
|
|
||||||
<div class="form-text">${t("ai_llm.anthropic_url_description")}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.model")}</label>
|
|
||||||
<select class="anthropic-default-model form-control">
|
|
||||||
<option value="">${t("ai_llm.select_model")}</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-text">${t("ai_llm.anthropic_model_description")}</div>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary refresh-anthropic-models">${t("ai_llm.refresh_models")}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ollama Provider Settings -->
|
|
||||||
<div class="provider-settings ollama-provider-settings" style="display: none;">
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>${t("ai_llm.ollama_settings")}</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.url")}</label>
|
|
||||||
<input type="text" class="ollama-base-url form-control" />
|
|
||||||
<div class="form-text">${t("ai_llm.ollama_url_description")}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.model")}</label>
|
|
||||||
<select class="ollama-default-model form-control">
|
|
||||||
<option value="">${t("ai_llm.select_model")}</option>
|
|
||||||
</select>
|
|
||||||
<div class="form-text">${t("ai_llm.ollama_model_description")}</div>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary refresh-models"><span class="bx bx-refresh"></span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.temperature")}</label>
|
|
||||||
<input class="ai-temperature form-control" type="number" min="0" max="2" step="0.1">
|
|
||||||
<div class="form-text">${t("ai_llm.temperature_description")}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>${t("ai_llm.system_prompt")}</label>
|
|
||||||
<textarea class="ai-system-prompt form-control" rows="3"></textarea>
|
|
||||||
<div class="form-text">${t("ai_llm.system_prompt_description")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
`;
|
|
||||||
270
apps/client/src/widgets/type_widgets/options/appearance.tsx
Normal file
270
apps/client/src/widgets/type_widgets/options/appearance.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { t } from "../../../services/i18n";
|
||||||
|
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
|
||||||
|
import Column from "../../react/Column";
|
||||||
|
import FormRadioGroup from "../../react/FormRadioGroup";
|
||||||
|
import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
|
||||||
|
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||||
|
import OptionsSection from "./components/OptionsSection";
|
||||||
|
import server from "../../../services/server";
|
||||||
|
import FormCheckbox from "../../react/FormCheckbox";
|
||||||
|
import FormGroup from "../../react/FormGroup";
|
||||||
|
import { FontFamily, OptionNames } from "@triliumnext/commons";
|
||||||
|
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
|
||||||
|
import FormText from "../../react/FormText";
|
||||||
|
import Button from "../../react/Button";
|
||||||
|
import RelatedSettings from "./components/RelatedSettings";
|
||||||
|
|
||||||
|
const MIN_CONTENT_WIDTH = 640;
|
||||||
|
|
||||||
|
interface Theme {
|
||||||
|
val: string;
|
||||||
|
title: string;
|
||||||
|
noteId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_THEMES: Theme[] = [
|
||||||
|
{ val: "next", title: t("theme.triliumnext") },
|
||||||
|
{ val: "next-light", title: t("theme.triliumnext-light") },
|
||||||
|
{ val: "next-dark", title: t("theme.triliumnext-dark") },
|
||||||
|
{ val: "auto", title: t("theme.auto_theme") },
|
||||||
|
{ val: "light", title: t("theme.light_theme") },
|
||||||
|
{ val: "dark", title: t("theme.dark_theme") }
|
||||||
|
]
|
||||||
|
|
||||||
|
interface FontFamilyEntry {
|
||||||
|
value: FontFamily;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FontGroup {
|
||||||
|
title: string;
|
||||||
|
items: FontFamilyEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_FAMILIES: FontGroup[] = [
|
||||||
|
{
|
||||||
|
title: t("fonts.generic-fonts"),
|
||||||
|
items: [
|
||||||
|
{ value: "theme", label: t("fonts.theme_defined") },
|
||||||
|
{ value: "system", label: t("fonts.system-default") },
|
||||||
|
{ value: "serif", label: t("fonts.serif") },
|
||||||
|
{ value: "sans-serif", label: t("fonts.sans-serif") },
|
||||||
|
{ value: "monospace", label: t("fonts.monospace") }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("fonts.sans-serif-system-fonts"),
|
||||||
|
items: [{ value: "Arial" }, { value: "Verdana" }, { value: "Helvetica" }, { value: "Tahoma" }, { value: "Trebuchet MS" }, { value: "Microsoft YaHei" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("fonts.serif-system-fonts"),
|
||||||
|
items: [{ value: "Times New Roman" }, { value: "Georgia" }, { value: "Garamond" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("fonts.monospace-system-fonts"),
|
||||||
|
items: [
|
||||||
|
{ value: "Courier New" },
|
||||||
|
{ value: "Brush Script MT" },
|
||||||
|
{ value: "Impact" },
|
||||||
|
{ value: "American Typewriter" },
|
||||||
|
{ value: "Andalé Mono" },
|
||||||
|
{ value: "Lucida Console" },
|
||||||
|
{ value: "Monaco" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("fonts.handwriting-system-fonts"),
|
||||||
|
items: [{ value: "Bradley Hand" }, { value: "Luminari" }, { value: "Comic Sans MS" }]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AppearanceSettings() {
|
||||||
|
const [ overrideThemeFonts ] = useTriliumOption("overrideThemeFonts");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!isMobile() && <LayoutOrientation />}
|
||||||
|
<ApplicationTheme />
|
||||||
|
{overrideThemeFonts === "true" && <Fonts />}
|
||||||
|
{isElectron() && <ElectronIntegration /> }
|
||||||
|
<MaxContentWidth />
|
||||||
|
<RelatedSettings items={[
|
||||||
|
{
|
||||||
|
title: t("settings_appearance.related_code_blocks"),
|
||||||
|
targetPage: "_optionsTextNotes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("settings_appearance.related_code_notes"),
|
||||||
|
targetPage: "_optionsCodeNotes"
|
||||||
|
}
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayoutOrientation() {
|
||||||
|
const [ layoutOrientation, setLayoutOrientation ] = useTriliumOption("layoutOrientation", true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("theme.layout")}>
|
||||||
|
<FormRadioGroup
|
||||||
|
name="layout-orientation"
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
label: t("theme.layout-vertical-title"),
|
||||||
|
inlineDescription: t("theme.layout-vertical-description"),
|
||||||
|
value: "vertical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("theme.layout-horizontal-title"),
|
||||||
|
inlineDescription: t("theme.layout-horizontal-description"),
|
||||||
|
value: "horizontal"
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
currentValue={layoutOrientation} onChange={setLayoutOrientation}
|
||||||
|
/>
|
||||||
|
</OptionsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplicationTheme() {
|
||||||
|
const [ theme, setTheme ] = useTriliumOption("theme", true);
|
||||||
|
const [ overrideThemeFonts, setOverrideThemeFonts ] = useTriliumOptionBool("overrideThemeFonts");
|
||||||
|
|
||||||
|
const [ themes, setThemes ] = useState<Theme[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
server.get<Theme[]>("options/user-themes").then((userThemes) => {
|
||||||
|
setThemes([
|
||||||
|
...BUILTIN_THEMES,
|
||||||
|
...userThemes
|
||||||
|
])
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("theme.title")}>
|
||||||
|
<div className="row">
|
||||||
|
<FormGroup name="theme" label={t("theme.theme_label")} className="col-md-6" style={{ marginBottom: 0 }}>
|
||||||
|
<FormSelect
|
||||||
|
values={themes} currentValue={theme} onChange={setTheme}
|
||||||
|
keyProperty="val" titleProperty="title"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup className="side-checkbox col-md-6" name="override-theme-fonts">
|
||||||
|
<FormCheckbox
|
||||||
|
label={t("theme.override_theme_fonts_label")}
|
||||||
|
currentValue={overrideThemeFonts} onChange={setOverrideThemeFonts} />
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fonts() {
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("fonts.fonts")}>
|
||||||
|
<Font title={t("fonts.main_font")} fontFamilyOption="mainFontFamily" fontSizeOption="mainFontSize" />
|
||||||
|
<Font title={t("fonts.note_tree_font")} fontFamilyOption="treeFontFamily" fontSizeOption="treeFontSize" />
|
||||||
|
<Font title={t("fonts.note_detail_font")} fontFamilyOption="detailFontFamily" fontSizeOption="detailFontSize" />
|
||||||
|
<Font title={t("fonts.monospace_font")} fontFamilyOption="monospaceFontFamily" fontSizeOption="monospaceFontSize" />
|
||||||
|
|
||||||
|
<FormText>{t("fonts.note_tree_and_detail_font_sizing")}</FormText>
|
||||||
|
<FormText>{t("fonts.not_all_fonts_available")}</FormText>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{t("fonts.apply_font_changes")} <Button text={t("fonts.reload_frontend")} size="micro" onClick={reloadFrontendApp} />
|
||||||
|
</p>
|
||||||
|
</OptionsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, fontFamilyOption: OptionNames, fontSizeOption: OptionNames }) {
|
||||||
|
const [ fontFamily, setFontFamily ] = useTriliumOption(fontFamilyOption);
|
||||||
|
const [ fontSize, setFontSize ] = useTriliumOption(fontSizeOption);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h5>{title}</h5>
|
||||||
|
<div className="row">
|
||||||
|
<FormGroup name="font-family" className="col-md-4" label={t("fonts.font_family")}>
|
||||||
|
<FormSelectWithGroups
|
||||||
|
values={FONT_FAMILIES}
|
||||||
|
currentValue={fontFamily} onChange={setFontFamily}
|
||||||
|
keyProperty="value" titleProperty="label"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup name="font-size" className="col-md-6" label={t("fonts.size")}>
|
||||||
|
<FormTextBoxWithUnit
|
||||||
|
name="tree-font-size"
|
||||||
|
type="number" min={50} max={200} step={10}
|
||||||
|
currentValue={fontSize} onChange={setFontSize}
|
||||||
|
unit={t("units.percentage")}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElectronIntegration() {
|
||||||
|
const [ zoomFactor, setZoomFactor ] = useTriliumOption("zoomFactor");
|
||||||
|
const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible");
|
||||||
|
const [ backgroundEffects, setBackgroundEffects ] = useTriliumOptionBool("backgroundEffects");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("electron_integration.desktop-application")}>
|
||||||
|
<FormGroup name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
|
||||||
|
<FormTextBox
|
||||||
|
type="number"
|
||||||
|
min="0.3" max="2.0" step="0.1"
|
||||||
|
currentValue={zoomFactor} onChange={setZoomFactor}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<hr/>
|
||||||
|
|
||||||
|
<FormGroup name="native-title-bar" description={t("electron_integration.native-title-bar-description")}>
|
||||||
|
<FormCheckbox
|
||||||
|
label={t("electron_integration.native-title-bar")}
|
||||||
|
currentValue={nativeTitleBarVisible} onChange={setNativeTitleBarVisible}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup name="background-effects" description={t("electron_integration.background-effects-description")}>
|
||||||
|
<FormCheckbox
|
||||||
|
label={t("electron_integration.background-effects")}
|
||||||
|
currentValue={backgroundEffects} onChange={setBackgroundEffects}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<Button text={t("electron_integration.restart-app-button")} onClick={restartDesktopApp} />
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaxContentWidth() {
|
||||||
|
const [ maxContentWidth, setMaxContentWidth ] = useTriliumOption("maxContentWidth");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("max_content_width.title")}>
|
||||||
|
<FormText>{t("max_content_width.default_description")}</FormText>
|
||||||
|
|
||||||
|
<Column md={6}>
|
||||||
|
<FormGroup name="max-content-width" label={t("max_content_width.max_width_label")}>
|
||||||
|
<FormTextBoxWithUnit
|
||||||
|
type="number" min={MIN_CONTENT_WIDTH} step="10"
|
||||||
|
currentValue={maxContentWidth} onChange={setMaxContentWidth}
|
||||||
|
unit={t("max_content_width.max_width_unit")}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{t("max_content_width.apply_changes_description")} <Button text={t("max_content_width.reload_button")} size="micro" onClick={reloadFrontendApp} />
|
||||||
|
</p>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import utils from "../../../../services/utils.js";
|
|
||||||
import type { OptionMap } from "@triliumnext/commons";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("electron_integration.desktop-application")}</h4>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-12">
|
|
||||||
<label for="zoom-factor-select">${t("electron_integration.zoom-factor")}</label>
|
|
||||||
<input id="zoom-factor-select" type="number" class="zoom-factor-select form-control options-number-input" min="0.3" max="2.0" step="0.1"/>
|
|
||||||
<p class="form-text">${t("zoom_factor.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="form-check tn-checkbox">
|
|
||||||
<input type="checkbox" class="native-title-bar form-check-input" />
|
|
||||||
${t("electron_integration.native-title-bar")}
|
|
||||||
</label>
|
|
||||||
<p class="form-text">
|
|
||||||
${t("electron_integration.native-title-bar-description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="form-check tn-checkbox">
|
|
||||||
<input type="checkbox" class="background-effects form-check-input" />
|
|
||||||
${t("electron_integration.background-effects")}
|
|
||||||
</label>
|
|
||||||
<p class="form-text">
|
|
||||||
${t("electron_integration.background-effects-description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-secondary btn-micro restart-app-button">${t("electron_integration.restart-app-button")}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default class ElectronIntegrationOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $zoomFactorSelect!: JQuery<HTMLElement>;
|
|
||||||
private $nativeTitleBar!: JQuery<HTMLElement>;
|
|
||||||
private $backgroundEffects!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
|
|
||||||
this.$zoomFactorSelect = this.$widget.find(".zoom-factor-select");
|
|
||||||
this.$zoomFactorSelect.on("change", () => {
|
|
||||||
this.triggerCommand("setZoomFactorAndSave", { zoomFactor: String(this.$zoomFactorSelect.val()) });
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$nativeTitleBar = this.$widget.find("input.native-title-bar");
|
|
||||||
this.$nativeTitleBar.on("change", () => this.updateCheckboxOption("nativeTitleBarVisible", this.$nativeTitleBar));
|
|
||||||
|
|
||||||
this.$backgroundEffects = this.$widget.find("input.background-effects");
|
|
||||||
this.$backgroundEffects.on("change", () => this.updateCheckboxOption("backgroundEffects", this.$backgroundEffects));
|
|
||||||
|
|
||||||
const restartAppButton = this.$widget.find(".restart-app-button");
|
|
||||||
restartAppButton.on("click", utils.restartDesktopApp);
|
|
||||||
}
|
|
||||||
|
|
||||||
isEnabled() {
|
|
||||||
return utils.isElectron();
|
|
||||||
}
|
|
||||||
|
|
||||||
async optionsLoaded(options: OptionMap) {
|
|
||||||
this.$zoomFactorSelect.val(options.zoomFactor);
|
|
||||||
this.setCheckboxState(this.$nativeTitleBar, options.nativeTitleBarVisible);
|
|
||||||
this.setCheckboxState(this.$backgroundEffects, options.backgroundEffects);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import utils from "../../../../services/utils.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import type { FontFamily, OptionMap, OptionNames } from "@triliumnext/commons";
|
|
||||||
|
|
||||||
interface FontFamilyEntry {
|
|
||||||
value: FontFamily;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FontGroup {
|
|
||||||
title: string;
|
|
||||||
items: FontFamilyEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const FONT_FAMILIES: FontGroup[] = [
|
|
||||||
{
|
|
||||||
title: t("fonts.generic-fonts"),
|
|
||||||
items: [
|
|
||||||
{ value: "theme", label: t("fonts.theme_defined") },
|
|
||||||
{ value: "system", label: t("fonts.system-default") },
|
|
||||||
{ value: "serif", label: t("fonts.serif") },
|
|
||||||
{ value: "sans-serif", label: t("fonts.sans-serif") },
|
|
||||||
{ value: "monospace", label: t("fonts.monospace") }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("fonts.sans-serif-system-fonts"),
|
|
||||||
items: [{ value: "Arial" }, { value: "Verdana" }, { value: "Helvetica" }, { value: "Tahoma" }, { value: "Trebuchet MS" }, { value: "Microsoft YaHei" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("fonts.serif-system-fonts"),
|
|
||||||
items: [{ value: "Times New Roman" }, { value: "Georgia" }, { value: "Garamond" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("fonts.monospace-system-fonts"),
|
|
||||||
items: [
|
|
||||||
{ value: "Courier New" },
|
|
||||||
{ value: "Brush Script MT" },
|
|
||||||
{ value: "Impact" },
|
|
||||||
{ value: "American Typewriter" },
|
|
||||||
{ value: "Andalé Mono" },
|
|
||||||
{ value: "Lucida Console" },
|
|
||||||
{ value: "Monaco" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("fonts.handwriting-system-fonts"),
|
|
||||||
items: [{ value: "Bradley Hand" }, { value: "Luminari" }, { value: "Comic Sans MS" }]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("fonts.fonts")}</h4>
|
|
||||||
|
|
||||||
<h5>${t("fonts.main_font")}</h5>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-4">
|
|
||||||
<label for="main-font-family">${t("fonts.font_family")}</label>
|
|
||||||
<select id="main-font-family" class="main-font-family form-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="main-font-size">${t("fonts.size")}</label>
|
|
||||||
|
|
||||||
<label class="input-group tn-number-unit-pair main-font-size-input-group">
|
|
||||||
<input id="main-font-size" type="number" class="main-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5>${t("fonts.note_tree_font")}</h5>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-4">
|
|
||||||
<label for="tree-font-family">${t("fonts.font_family")}</label>
|
|
||||||
<select id="tree-font-family" class="tree-font-family form-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="tree-font-size">${t("fonts.size")}</label>
|
|
||||||
|
|
||||||
<label class="input-group tn-number-unit-pair tree-font-size-input-group">
|
|
||||||
<input id="tree-font-size" type="number" class="tree-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5>${t("fonts.note_detail_font")}</h5>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-4">
|
|
||||||
<label for="detail-font-family">${t("fonts.font_family")}</label>
|
|
||||||
<select id="detail-font-family" class="detail-font-family form-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="detail-font-size">${t("fonts.size")}</label>
|
|
||||||
|
|
||||||
<label class="input-group tn-number-unit-pair detail-font-size-input-group">
|
|
||||||
<input id="detail-font-size" type="number" class="detail-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5>${t("fonts.monospace_font")}</h5>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-4">
|
|
||||||
<label for="monospace-font-family">${t("fonts.font_family")}</label>
|
|
||||||
<select id="monospace-font-family" class="monospace-font-family form-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="monospace-font-size">${t("fonts.size")}</label>
|
|
||||||
|
|
||||||
<label class="input-group tn-number-unit-pair monospace-font-size-input-group">
|
|
||||||
<input id="monospace-font-size" type="number" class="monospace-font-size form-control options-number-input" min="50" max="200" step="10"/>
|
|
||||||
<span class="input-group-text">%</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="form-text">${t("fonts.note_tree_and_detail_font_sizing")}</p>
|
|
||||||
|
|
||||||
<p class="form-text">${t("fonts.not_all_fonts_available")}</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
${t("fonts.apply_font_changes")}
|
|
||||||
<button class="btn btn-secondary btn-micro reload-frontend-button">${t("fonts.reload_frontend")}</button>
|
|
||||||
</p>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
export default class FontsOptions extends OptionsWidget {
|
|
||||||
private $mainFontSize!: JQuery<HTMLElement>;
|
|
||||||
private $mainFontFamily!: JQuery<HTMLElement>;
|
|
||||||
private $treeFontSize!: JQuery<HTMLElement>;
|
|
||||||
private $treeFontFamily!: JQuery<HTMLElement>;
|
|
||||||
private $detailFontSize!: JQuery<HTMLElement>;
|
|
||||||
private $detailFontFamily!: JQuery<HTMLElement>;
|
|
||||||
private $monospaceFontSize!: JQuery<HTMLElement>;
|
|
||||||
private $monospaceFontFamily!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
private _isEnabled?: boolean;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
|
|
||||||
this.$mainFontSize = this.$widget.find(".main-font-size");
|
|
||||||
this.$mainFontFamily = this.$widget.find(".main-font-family");
|
|
||||||
|
|
||||||
this.$treeFontSize = this.$widget.find(".tree-font-size");
|
|
||||||
this.$treeFontFamily = this.$widget.find(".tree-font-family");
|
|
||||||
|
|
||||||
this.$detailFontSize = this.$widget.find(".detail-font-size");
|
|
||||||
this.$detailFontFamily = this.$widget.find(".detail-font-family");
|
|
||||||
|
|
||||||
this.$monospaceFontSize = this.$widget.find(".monospace-font-size");
|
|
||||||
this.$monospaceFontFamily = this.$widget.find(".monospace-font-family");
|
|
||||||
|
|
||||||
this.$widget.find(".reload-frontend-button").on("click", () => utils.reloadFrontendApp("changes from appearance options"));
|
|
||||||
}
|
|
||||||
|
|
||||||
isEnabled() {
|
|
||||||
return !!this._isEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
async optionsLoaded(options: OptionMap) {
|
|
||||||
this._isEnabled = options.overrideThemeFonts === "true";
|
|
||||||
this.toggleInt(this._isEnabled);
|
|
||||||
if (!this._isEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$mainFontSize.val(options.mainFontSize);
|
|
||||||
this.fillFontFamilyOptions(this.$mainFontFamily, options.mainFontFamily);
|
|
||||||
|
|
||||||
this.$treeFontSize.val(options.treeFontSize);
|
|
||||||
this.fillFontFamilyOptions(this.$treeFontFamily, options.treeFontFamily);
|
|
||||||
|
|
||||||
this.$detailFontSize.val(options.detailFontSize);
|
|
||||||
this.fillFontFamilyOptions(this.$detailFontFamily, options.detailFontFamily);
|
|
||||||
|
|
||||||
this.$monospaceFontSize.val(options.monospaceFontSize);
|
|
||||||
this.fillFontFamilyOptions(this.$monospaceFontFamily, options.monospaceFontFamily);
|
|
||||||
|
|
||||||
const optionsToSave: OptionNames[] = ["mainFontFamily", "mainFontSize", "treeFontFamily", "treeFontSize", "detailFontFamily", "detailFontSize", "monospaceFontFamily", "monospaceFontSize"];
|
|
||||||
|
|
||||||
for (const optionName of optionsToSave) {
|
|
||||||
const $el = (this as any)[`$${optionName}`];
|
|
||||||
$el.on("change", () => this.updateOption(optionName, $el.val()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fillFontFamilyOptions($select: JQuery<HTMLElement>, currentValue: string) {
|
|
||||||
$select.empty();
|
|
||||||
|
|
||||||
for (const { title, items } of Object.values(FONT_FAMILIES)) {
|
|
||||||
const $group = $("<optgroup>").attr("label", title);
|
|
||||||
|
|
||||||
for (const { value, label } of items) {
|
|
||||||
$group.append(
|
|
||||||
$("<option>")
|
|
||||||
.attr("value", value)
|
|
||||||
.prop("selected", value === currentValue)
|
|
||||||
.text(label ?? value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$select.append($group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import utils from "../../../../services/utils.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import type { OptionMap } from "@triliumnext/commons";
|
|
||||||
|
|
||||||
const MIN_VALUE = 640;
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("max_content_width.title")}</h4>
|
|
||||||
|
|
||||||
<p class="form-text">${t("max_content_width.default_description")}</p>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="max-content-width">${t("max_content_width.max_width_label")}</label>
|
|
||||||
<label class="input-group tn-number-unit-pair">
|
|
||||||
<input id="max-content-width" type="number" min="${MIN_VALUE}" step="10" class="max-content-width form-control options-number-input">
|
|
||||||
<span class="input-group-text">${t("max_content_width.max_width_unit")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
${t("max_content_width.apply_changes_description")}
|
|
||||||
<button class="btn btn-secondary btn-micro reload-frontend-button">${t("max_content_width.reload_button")}</button>
|
|
||||||
</p>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
export default class MaxContentWidthOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $maxContentWidth!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
|
|
||||||
this.$maxContentWidth = this.$widget.find(".max-content-width");
|
|
||||||
|
|
||||||
this.$maxContentWidth.on("change", async () => this.updateOption("maxContentWidth", String(this.$maxContentWidth.val())));
|
|
||||||
|
|
||||||
this.$widget.find(".reload-frontend-button").on("click", () => utils.reloadFrontendApp(t("max_content_width.reload_description")));
|
|
||||||
}
|
|
||||||
|
|
||||||
async optionsLoaded(options: OptionMap) {
|
|
||||||
this.$maxContentWidth.val(Math.max(MIN_VALUE, parseInt(options.maxContentWidth, 10)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { OptionPages } from "../../content_widget";
|
|
||||||
import OptionsWidget from "../options_widget";
|
|
||||||
|
|
||||||
const TPL = `\
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>Related settings</h4>
|
|
||||||
|
|
||||||
<nav class="related-settings use-tn-links">
|
|
||||||
<li>Color scheme for code blocks in text notes</li>
|
|
||||||
<li>Color scheme for code notes</li>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.related-settings {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface RelatedSettingsConfig {
|
|
||||||
items: {
|
|
||||||
title: string;
|
|
||||||
targetPage: OptionPages;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const RELATED_SETTINGS: Record<string, RelatedSettingsConfig> = {
|
|
||||||
"_optionsAppearance": {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Color scheme for code blocks in text notes",
|
|
||||||
targetPage: "_optionsTextNotes"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Color scheme for code notes",
|
|
||||||
targetPage: "_optionsCodeNotes"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class RelatedSettings extends OptionsWidget {
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
|
|
||||||
const config = this.noteId && RELATED_SETTINGS[this.noteId];
|
|
||||||
if (!config) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $relatedSettings = this.$widget.find(".related-settings");
|
|
||||||
$relatedSettings.empty();
|
|
||||||
for (const item of config.items) {
|
|
||||||
const $item = $("<li>");
|
|
||||||
const $link = $("<a>").text(item.title);
|
|
||||||
|
|
||||||
$item.append($link);
|
|
||||||
$link.attr("href", `#root/_hidden/_options/${item.targetPage}`);
|
|
||||||
$relatedSettings.append($item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isEnabled() {
|
|
||||||
return (!!this.noteId && this.noteId in RELATED_SETTINGS);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import type { OptionMap } from "@triliumnext/commons";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("ribbon.widgets")}</h4>
|
|
||||||
<div>
|
|
||||||
<label class="tn-checkbox">
|
|
||||||
<input type="checkbox" class="promoted-attributes-open-in-ribbon form-check-input">
|
|
||||||
${t("ribbon.promoted_attributes_message")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="tn-checkbox">
|
|
||||||
<input type="checkbox" class="edited-notes-open-in-ribbon form-check-input">
|
|
||||||
${t("ribbon.edited_notes_message")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
export default class RibbonOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $promotedAttributesOpenInRibbon!: JQuery<HTMLElement>;
|
|
||||||
private $editedNotesOpenInRibbon!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
|
|
||||||
this.$promotedAttributesOpenInRibbon = this.$widget.find(".promoted-attributes-open-in-ribbon");
|
|
||||||
this.$promotedAttributesOpenInRibbon.on("change", () => this.updateCheckboxOption("promotedAttributesOpenInRibbon", this.$promotedAttributesOpenInRibbon));
|
|
||||||
|
|
||||||
this.$editedNotesOpenInRibbon = this.$widget.find(".edited-notes-open-in-ribbon");
|
|
||||||
this.$editedNotesOpenInRibbon.on("change", () => this.updateCheckboxOption("editedNotesOpenInRibbon", this.$editedNotesOpenInRibbon));
|
|
||||||
}
|
|
||||||
|
|
||||||
async optionsLoaded(options: OptionMap) {
|
|
||||||
this.setCheckboxState(this.$promotedAttributesOpenInRibbon, options.promotedAttributesOpenInRibbon);
|
|
||||||
this.setCheckboxState(this.$editedNotesOpenInRibbon, options.editedNotesOpenInRibbon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import OptionsWidget from "../options_widget.js";
|
|
||||||
import server from "../../../../services/server.js";
|
|
||||||
import utils from "../../../../services/utils.js";
|
|
||||||
import { t } from "../../../../services/i18n.js";
|
|
||||||
import type { OptionMap } from "@triliumnext/commons";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("theme.layout")}</h4>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div>
|
|
||||||
<label class="tn-radio">
|
|
||||||
<input type="radio" name="layout-orientation" value="vertical" />
|
|
||||||
<strong>${t("theme.layout-vertical-title")}</strong>
|
|
||||||
- ${t("theme.layout-vertical-description")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="tn-radio">
|
|
||||||
<input type="radio" name="layout-orientation" value="horizontal" />
|
|
||||||
<strong>${t("theme.layout-horizontal-title")}</strong>
|
|
||||||
- ${t("theme.layout-horizontal-description")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("theme.title")}</h4>
|
|
||||||
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="theme-select">${t("theme.theme_label")}</label>
|
|
||||||
<select id="theme-select" class="theme-select form-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 side-checkbox">
|
|
||||||
<label class="form-check tn-checkbox">
|
|
||||||
<input type="checkbox" class="override-theme-fonts form-check-input">
|
|
||||||
${t("theme.override_theme_fonts_label")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface Theme {
|
|
||||||
val: string;
|
|
||||||
title: string;
|
|
||||||
noteId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class ThemeOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $themeSelect!: JQuery<HTMLElement>;
|
|
||||||
private $overrideThemeFonts!: JQuery<HTMLElement>;
|
|
||||||
private $layoutOrientation!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.$themeSelect = this.$widget.find(".theme-select");
|
|
||||||
this.$overrideThemeFonts = this.$widget.find(".override-theme-fonts");
|
|
||||||
this.$layoutOrientation = this.$widget.find(`input[name="layout-orientation"]`).on("change", async () => {
|
|
||||||
const newLayoutOrientation = String(this.$widget.find(`input[name="layout-orientation"]:checked`).val());
|
|
||||||
await this.updateOption("layoutOrientation", newLayoutOrientation);
|
|
||||||
utils.reloadFrontendApp("layout orientation change");
|
|
||||||
});
|
|
||||||
|
|
||||||
const $layoutOrientationSection = $(this.$widget[0]);
|
|
||||||
$layoutOrientationSection.toggleClass("hidden-ext", utils.isMobile());
|
|
||||||
|
|
||||||
this.$themeSelect.on("change", async () => {
|
|
||||||
const newTheme = this.$themeSelect.val();
|
|
||||||
|
|
||||||
await server.put(`options/theme/${newTheme}`);
|
|
||||||
|
|
||||||
utils.reloadFrontendApp("theme change");
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$overrideThemeFonts.on("change", () => this.updateCheckboxOption("overrideThemeFonts", this.$overrideThemeFonts));
|
|
||||||
}
|
|
||||||
|
|
||||||
async optionsLoaded(options: OptionMap) {
|
|
||||||
const themes: Theme[] = [
|
|
||||||
{ val: "next", title: t("theme.triliumnext") },
|
|
||||||
{ val: "next-light", title: t("theme.triliumnext-light") },
|
|
||||||
{ val: "next-dark", title: t("theme.triliumnext-dark") },
|
|
||||||
{ val: "auto", title: t("theme.auto_theme") },
|
|
||||||
{ val: "light", title: t("theme.light_theme") },
|
|
||||||
{ val: "dark", title: t("theme.dark_theme") }
|
|
||||||
].concat(await server.get<Theme[]>("options/user-themes"));
|
|
||||||
|
|
||||||
this.$themeSelect.empty();
|
|
||||||
|
|
||||||
for (const theme of themes) {
|
|
||||||
this.$themeSelect.append(
|
|
||||||
$("<option>")
|
|
||||||
.attr("value", theme.val)
|
|
||||||
.attr("data-note-id", theme.noteId || "")
|
|
||||||
.text(theme.title)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$themeSelect.val(options.theme);
|
|
||||||
|
|
||||||
this.setCheckboxState(this.$overrideThemeFonts, options.overrideThemeFonts);
|
|
||||||
|
|
||||||
this.$widget.find(`input[name="layout-orientation"][value="${options.layoutOrientation}"]`).prop("checked", "true");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { formatDateTime } from "../../../utils/formatters.js";
|
|
||||||
import { t } from "../../../services/i18n.js";
|
|
||||||
import OptionsWidget from "./options_widget.js";
|
|
||||||
import server from "../../../services/server.js";
|
|
||||||
import toastService from "../../../services/toast.js";
|
|
||||||
import type { OptionMap } from "@triliumnext/commons";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("backup.automatic_backup")}</h4>
|
|
||||||
|
|
||||||
<p>${t("backup.automatic_backup_description")}</p>
|
|
||||||
|
|
||||||
<ul style="list-style: none">
|
|
||||||
<li>
|
|
||||||
<label class="tn-checkbox">
|
|
||||||
<input type="checkbox" class="daily-backup-enabled form-check-input">
|
|
||||||
${t("backup.enable_daily_backup")}
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label class="tn-checkbox">
|
|
||||||
<input type="checkbox" class="weekly-backup-enabled form-check-input">
|
|
||||||
${t("backup.enable_weekly_backup")}
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<label class="tn-checkbox">
|
|
||||||
<input type="checkbox" class="monthly-backup-enabled form-check-input">
|
|
||||||
${t("backup.enable_monthly_backup")}
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p class="form-text">${t("backup.backup_recommendation")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("backup.backup_now")}</h4>
|
|
||||||
|
|
||||||
<button class="backup-database-button btn btn-secondary">${t("backup.backup_database_now")}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>${t("backup.existing_backups")}</h4>
|
|
||||||
|
|
||||||
<table class="table table-stripped">
|
|
||||||
<colgroup>
|
|
||||||
<col width="33%" />
|
|
||||||
<col />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>${t("backup.date-and-time")}</th>
|
|
||||||
<th>${t("backup.path")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="existing-backup-list-items">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// TODO: Deduplicate.
|
|
||||||
interface PostDatabaseResponse {
|
|
||||||
backupFile: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Deduplicate
|
|
||||||
interface Backup {
|
|
||||||
filePath: string;
|
|
||||||
mtime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class BackupOptions extends OptionsWidget {
|
|
||||||
|
|
||||||
private $backupDatabaseButton!: JQuery<HTMLElement>;
|
|
||||||
private $dailyBackupEnabled!: JQuery<HTMLElement>;
|
|
||||||
private $weeklyBackupEnabled!: JQuery<HTMLElement>;
|
|
||||||
private $monthlyBackupEnabled!: JQuery<HTMLElement>;
|
|
||||||
private $existingBackupList!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
|
|
||||||
this.$backupDatabaseButton = this.$widget.find(".backup-database-button");
|
|
||||||
|
|
||||||
this.$backupDatabaseButton.on("click", async () => {
|
|
||||||
const { backupFile } = await server.post<PostDatabaseResponse>("database/backup-database");
|
|
||||||
|
|
||||||
toastService.showMessage(t("backup.database_backed_up_to", { backupFilePath: backupFile }), 10000);
|
|
||||||
|
|
||||||
this.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$dailyBackupEnabled = this.$widget.find(".daily-backup-enabled");
|
|
||||||
this.$weeklyBackupEnabled = this.$widget.find(".weekly-backup-enabled");
|
|
||||||
this.$monthlyBackupEnabled = this.$widget.find(".monthly-backup-enabled");
|
|
||||||
|
|
||||||
this.$dailyBackupEnabled.on("change", () => this.updateCheckboxOption("dailyBackupEnabled", this.$dailyBackupEnabled));
|
|
||||||
|
|
||||||
this.$weeklyBackupEnabled.on("change", () => this.updateCheckboxOption("weeklyBackupEnabled", this.$weeklyBackupEnabled));
|
|
||||||
|
|
||||||
this.$monthlyBackupEnabled.on("change", () => this.updateCheckboxOption("monthlyBackupEnabled", this.$monthlyBackupEnabled));
|
|
||||||
|
|
||||||
this.$existingBackupList = this.$widget.find(".existing-backup-list-items");
|
|
||||||
}
|
|
||||||
|
|
||||||
optionsLoaded(options: OptionMap) {
|
|
||||||
this.setCheckboxState(this.$dailyBackupEnabled, options.dailyBackupEnabled);
|
|
||||||
this.setCheckboxState(this.$weeklyBackupEnabled, options.weeklyBackupEnabled);
|
|
||||||
this.setCheckboxState(this.$monthlyBackupEnabled, options.monthlyBackupEnabled);
|
|
||||||
|
|
||||||
server.get<Backup[]>("database/backups").then((backupFiles) => {
|
|
||||||
this.$existingBackupList.empty();
|
|
||||||
|
|
||||||
if (!backupFiles.length) {
|
|
||||||
this.$existingBackupList.append(
|
|
||||||
$(`
|
|
||||||
<tr>
|
|
||||||
<td class="empty-table-placeholder" colspan="2">${t("backup.no_backup_yet")}</td>
|
|
||||||
</tr>
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort the backup files by modification date & time in a desceding order
|
|
||||||
backupFiles.sort((a, b) => {
|
|
||||||
if (a.mtime < b.mtime) return 1;
|
|
||||||
if (a.mtime > b.mtime) return -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const { filePath, mtime } of backupFiles) {
|
|
||||||
this.$existingBackupList.append(
|
|
||||||
$(`
|
|
||||||
<tr>
|
|
||||||
<td>${mtime ? formatDateTime(mtime) : "-"}</td>
|
|
||||||
<td>${filePath}</td>
|
|
||||||
</tr>
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
apps/client/src/widgets/type_widgets/options/backup.tsx
Normal file
119
apps/client/src/widgets/type_widgets/options/backup.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { BackupDatabaseNowResponse, DatabaseBackup } from "@triliumnext/commons";
|
||||||
|
import { t } from "../../../services/i18n";
|
||||||
|
import server from "../../../services/server";
|
||||||
|
import toast from "../../../services/toast";
|
||||||
|
import Button from "../../react/Button";
|
||||||
|
import FormCheckbox from "../../react/FormCheckbox";
|
||||||
|
import FormGroup, { FormMultiGroup } from "../../react/FormGroup";
|
||||||
|
import FormText from "../../react/FormText";
|
||||||
|
import { useTriliumOptionBool } from "../../react/hooks";
|
||||||
|
import OptionsSection from "./components/OptionsSection";
|
||||||
|
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||||
|
import { formatDateTime } from "../../../utils/formatters";
|
||||||
|
|
||||||
|
export default function BackupSettings() {
|
||||||
|
const [ backups, setBackups ] = useState<DatabaseBackup[]>([]);
|
||||||
|
|
||||||
|
const refreshBackups = useCallback(() => {
|
||||||
|
server.get<DatabaseBackup[]>("database/backups").then((backupFiles) => {
|
||||||
|
// Sort the backup files by modification date & time in a desceding order
|
||||||
|
backupFiles.sort((a, b) => {
|
||||||
|
if (a.mtime < b.mtime) return 1;
|
||||||
|
if (a.mtime > b.mtime) return -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
setBackups(backupFiles);
|
||||||
|
});
|
||||||
|
}, [ setBackups ]);
|
||||||
|
|
||||||
|
useEffect(refreshBackups, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AutomaticBackup />
|
||||||
|
<BackupNow refreshCallback={refreshBackups} />
|
||||||
|
<BackupList backups={backups} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomaticBackup() {
|
||||||
|
const [ dailyBackupEnabled, setDailyBackupEnabled ] = useTriliumOptionBool("dailyBackupEnabled");
|
||||||
|
const [ weeklyBackupEnabled, setWeeklyBackupEnabled ] = useTriliumOptionBool("weeklyBackupEnabled");
|
||||||
|
const [ monthlyBackupEnabled, setMonthlyBackupEnabled ] = useTriliumOptionBool("monthlyBackupEnabled");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("backup.automatic_backup")}>
|
||||||
|
<FormMultiGroup label={t("backup.automatic_backup_description")}>
|
||||||
|
<FormCheckbox
|
||||||
|
name="daily-backup-enabled"
|
||||||
|
label={t("backup.enable_daily_backup")}
|
||||||
|
currentValue={dailyBackupEnabled} onChange={setDailyBackupEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormCheckbox
|
||||||
|
name="weekly-backup-enabled"
|
||||||
|
label={t("backup.enable_weekly_backup")}
|
||||||
|
currentValue={weeklyBackupEnabled} onChange={setWeeklyBackupEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormCheckbox
|
||||||
|
name="monthly-backup-enabled"
|
||||||
|
label={t("backup.enable_monthly_backup")}
|
||||||
|
currentValue={monthlyBackupEnabled} onChange={setMonthlyBackupEnabled}
|
||||||
|
/>
|
||||||
|
</FormMultiGroup>
|
||||||
|
|
||||||
|
<FormText>{t("backup.backup_recommendation")}</FormText>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupNow({ refreshCallback }: { refreshCallback: () => void }) {
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("backup.backup_now")}>
|
||||||
|
<Button
|
||||||
|
text={t("backup.backup_database_now")}
|
||||||
|
onClick={async () => {
|
||||||
|
const { backupFile } = await server.post<BackupDatabaseNowResponse>("database/backup-database");
|
||||||
|
toast.showMessage(t("backup.database_backed_up_to", { backupFilePath: backupFile }), 10000);
|
||||||
|
refreshCallback();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("backup.existing_backups")}>
|
||||||
|
<table class="table table-stripped">
|
||||||
|
<colgroup>
|
||||||
|
<col width="33%" />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("backup.date-and-time")}</th>
|
||||||
|
<th>{t("backup.path")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ backups.length > 0 ? (
|
||||||
|
backups.map(({ mtime, filePath }) => (
|
||||||
|
<tr>
|
||||||
|
<td>{mtime ? formatDateTime(mtime) : "-"}</td>
|
||||||
|
<td>{filePath}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="empty-table-placeholder" colspan={2}>{t("backup.no_backup_yet")}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</OptionsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
apps/client/src/widgets/type_widgets/options/code_notes.tsx
Normal file
164
apps/client/src/widgets/type_widgets/options/code_notes.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import CodeMirror, { ColorThemes, getThemeById } from "@triliumnext/codemirror";
|
||||||
|
import { t } from "../../../services/i18n";
|
||||||
|
import Column from "../../react/Column";
|
||||||
|
import FormCheckbox from "../../react/FormCheckbox";
|
||||||
|
import FormGroup from "../../react/FormGroup";
|
||||||
|
import FormSelect from "../../react/FormSelect";
|
||||||
|
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
|
||||||
|
import OptionsSection from "./components/OptionsSection";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
import codeNoteSample from "./samples/code_note.txt?raw";
|
||||||
|
import { DEFAULT_PREFIX } from "../abstract_code_type_widget";
|
||||||
|
import { MimeType } from "@triliumnext/commons";
|
||||||
|
import mime_types from "../../../services/mime_types";
|
||||||
|
import CheckboxList from "./components/CheckboxList";
|
||||||
|
import AutoReadOnlySize from "./components/AutoReadOnlySize";
|
||||||
|
|
||||||
|
const SAMPLE_MIME = "application/typescript";
|
||||||
|
|
||||||
|
export default function CodeNoteSettings() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Editor />
|
||||||
|
<Appearance />
|
||||||
|
<CodeMimeTypes />
|
||||||
|
<AutoReadOnlySize option="autoReadonlySizeCode" label={t("code_auto_read_only_size.label")} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor() {
|
||||||
|
const [ vimKeymapEnabled, setVimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("code-editor-options.title")}>
|
||||||
|
<FormGroup name="vim-keymap-enabled" description={t("vim_key_bindings.enable_vim_keybindings")}>
|
||||||
|
<FormCheckbox
|
||||||
|
label={t("vim_key_bindings.use_vim_keybindings_in_code_notes")}
|
||||||
|
currentValue={vimKeymapEnabled} onChange={setVimKeymapEnabled}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Appearance() {
|
||||||
|
const [ codeNoteTheme, setCodeNoteTheme ] = useTriliumOption("codeNoteTheme");
|
||||||
|
const [ codeLineWrapEnabled, setCodeLineWrapEnabled ] = useTriliumOptionBool("codeLineWrapEnabled");
|
||||||
|
|
||||||
|
const themes = useMemo(() => {
|
||||||
|
return ColorThemes.map(({ id, name }) => ({
|
||||||
|
id: "default:" + id,
|
||||||
|
name
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("code_theme.title")}>
|
||||||
|
<div className="row" style={{ marginBottom: "15px" }}>
|
||||||
|
<FormGroup name="color-scheme" label={t("code_theme.color-scheme")} className="col-md-6" style={{ marginBottom: 0 }}>
|
||||||
|
<FormSelect
|
||||||
|
values={themes}
|
||||||
|
keyProperty="id" titleProperty="name"
|
||||||
|
currentValue={codeNoteTheme} onChange={setCodeNoteTheme}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<Column className="side-checkbox">
|
||||||
|
<FormCheckbox
|
||||||
|
name="word-wrap"
|
||||||
|
label={t("code_theme.word_wrapping")}
|
||||||
|
currentValue={codeLineWrapEnabled} onChange={setCodeLineWrapEnabled}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeNotePreview wordWrapping={codeLineWrapEnabled} themeName={codeNoteTheme} />
|
||||||
|
</OptionsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeNotePreview({ themeName, wordWrapping }: { themeName: string, wordWrapping: boolean }) {
|
||||||
|
const editorRef = useRef<CodeMirror>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up previous instance.
|
||||||
|
editorRef.current?.destroy();
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
|
||||||
|
// Set up a new instance.
|
||||||
|
const editor = new CodeMirror({
|
||||||
|
parent: containerRef.current
|
||||||
|
});
|
||||||
|
editor.setText(codeNoteSample);
|
||||||
|
editor.setMimeType(SAMPLE_MIME);
|
||||||
|
editorRef.current = editor;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editorRef.current?.setLineWrapping(wordWrapping);
|
||||||
|
}, [ wordWrapping ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (themeName?.startsWith(DEFAULT_PREFIX)) {
|
||||||
|
const theme = getThemeById(themeName.substring(DEFAULT_PREFIX.length));
|
||||||
|
if (theme) {
|
||||||
|
editorRef.current?.setTheme(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ themeName ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class="note-detail-readonly-code-content"
|
||||||
|
style={{ margin: 0, height: "200px" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeMimeTypes() {
|
||||||
|
const [ codeNotesMimeTypes, setCodeNotesMimeTypes ] = useTriliumOptionJson<string[]>("codeNotesMimeTypes");
|
||||||
|
const sectionStyle = useMemo(() => ({ marginBottom: "1em", breakInside: "avoid-column" }), []);
|
||||||
|
const groupedMimeTypes: Record<string, MimeType[]> = useMemo(() => {
|
||||||
|
mime_types.loadMimeTypes();
|
||||||
|
|
||||||
|
const ungroupedMimeTypes = Array.from(mime_types.getMimeTypes());
|
||||||
|
const plainTextMimeType = ungroupedMimeTypes.shift();
|
||||||
|
const result: Record<string, MimeType[]> = {};
|
||||||
|
ungroupedMimeTypes.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
|
result[""] = [ plainTextMimeType! ];
|
||||||
|
for (const mimeType of ungroupedMimeTypes) {
|
||||||
|
const initial = mimeType.title.charAt(0).toUpperCase();
|
||||||
|
if (!result[initial]) {
|
||||||
|
result[initial] = [];
|
||||||
|
}
|
||||||
|
result[initial].push(mimeType);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionsSection title={t("code_mime_types.title")}>
|
||||||
|
<ul class="options-mime-types" style={{ listStyleType: "none", columnWidth: "250px" }}>
|
||||||
|
{Object.entries(groupedMimeTypes).map(([ initial, mimeTypes ]) => (
|
||||||
|
<section style={sectionStyle}>
|
||||||
|
{ initial && <h5>{initial}</h5> }
|
||||||
|
<CheckboxList
|
||||||
|
values={mimeTypes}
|
||||||
|
keyProperty="mime" titleProperty="title"
|
||||||
|
currentValue={codeNotesMimeTypes} onChange={setCodeNotesMimeTypes}
|
||||||
|
columnWidth="inherit"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</OptionsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user