mirror of
https://github.com/zadam/trilium.git
synced 2026-04-07 12:38:58 +02:00
Compare commits
22 Commits
standalone
...
feature/cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd1491e6e5 | ||
|
|
ac35730e3b | ||
|
|
00023adbc0 | ||
|
|
a70142a4dc | ||
|
|
7b056fe1af | ||
|
|
467be38bd1 | ||
|
|
f56482157c | ||
|
|
5d0c91d91d | ||
|
|
03136611a1 | ||
|
|
3e7488e4f3 | ||
|
|
3ed7d48d42 | ||
|
|
ef72d89172 | ||
|
|
ad97071862 | ||
|
|
2291892946 | ||
|
|
bf8cfa1421 | ||
|
|
bdd806efff | ||
|
|
c912c4af7b | ||
|
|
fc7f359f28 | ||
|
|
21598f6189 | ||
|
|
15505ffcd8 | ||
|
|
96cef35f09 | ||
|
|
ac24c69858 |
20
CLAUDE.md
20
CLAUDE.md
@@ -122,6 +122,12 @@ Trilium provides powerful user scripting capabilities:
|
||||
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
|
||||
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
|
||||
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
|
||||
- **Server-side translations** (e.g. hidden subtree titles) go in `apps/server/src/assets/translations/en/server.json`, not in the client `translation.json`
|
||||
|
||||
### Electron Desktop App
|
||||
- Desktop entry point: `apps/desktop/src/main.ts`, window management: `apps/server/src/services/window.ts`
|
||||
- IPC communication: use `electron.ipcMain.on(channel, handler)` on server side, `electron.ipcRenderer.send(channel, data)` on client side
|
||||
- Electron-only features should check `isElectron()` from `apps/client/src/services/utils.ts` (client) or `utils.isElectron` (server)
|
||||
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
@@ -153,6 +159,20 @@ Trilium provides powerful user scripting capabilities:
|
||||
- Create new package in `packages/` following existing plugin structure
|
||||
- Register in `packages/ckeditor5/src/plugins.ts`
|
||||
|
||||
### Adding Hidden System Notes
|
||||
The hidden subtree (`_hidden`) contains system notes with predictable IDs (prefixed with `_`). Defined in `apps/server/src/services/hidden_subtree.ts` via the `HiddenSubtreeItem` interface from `@triliumnext/commons`.
|
||||
|
||||
1. Add the note definition to `buildHiddenSubtreeDefinition()` in `apps/server/src/services/hidden_subtree.ts`
|
||||
2. Add a translation key for the title in `apps/server/src/assets/translations/en/server.json` under `"hidden-subtree"`
|
||||
3. The note is auto-created on startup by `checkHiddenSubtree()` — uses deterministic IDs so all sync cluster instances generate the same structure
|
||||
4. Key properties: `id` (must start with `_`), `title`, `type`, `icon` (format: `bx-icon-name` without `bx ` prefix), `attributes`, `children`, `content`
|
||||
5. Use `enforceAttributes: true` to keep attributes in sync, `enforceBranches: true` for correct placement, `enforceDeleted: true` to remove deprecated notes
|
||||
6. For launcher bar entries, see `hidden_subtree_launcherbar.ts`; for templates, see `hidden_subtree_templates.ts`
|
||||
|
||||
### Writing to Notes from Server Services
|
||||
- `note.setContent()` requires a CLS (Continuation Local Storage) context — wrap calls in `cls.init(() => { ... })` (from `apps/server/src/services/cls.ts`)
|
||||
- Operations called from Express routes already have CLS context; standalone services (schedulers, Electron IPC handlers) do not
|
||||
|
||||
### Adding New LLM Tools
|
||||
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ function initOnElectron() {
|
||||
const currentWindow = electronRemote.getCurrentWindow();
|
||||
const style = window.getComputedStyle(document.body);
|
||||
|
||||
initDarkOrLightMode(style);
|
||||
initDarkOrLightMode();
|
||||
initTransparencyEffects(style, currentWindow);
|
||||
initFullScreenDetection(currentWindow);
|
||||
|
||||
@@ -119,11 +119,11 @@ function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Elec
|
||||
*
|
||||
* @param style the root CSS element to read variables from.
|
||||
*/
|
||||
function initDarkOrLightMode(style: CSSStyleDeclaration) {
|
||||
function initDarkOrLightMode() {
|
||||
let themeSource: typeof nativeTheme.themeSource = "system";
|
||||
|
||||
const themeStyle = style.getPropertyValue("--theme-style");
|
||||
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
|
||||
const themeStyle = window.glob.getThemeStyle();
|
||||
if (themeStyle !== "auto") {
|
||||
themeSource = themeStyle;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getThemeStyle } from "./services/theme";
|
||||
|
||||
async function bootstrap() {
|
||||
showSplash();
|
||||
await setupGlob();
|
||||
@@ -38,6 +40,7 @@ async function setupGlob() {
|
||||
...json,
|
||||
activeDialog: null
|
||||
};
|
||||
window.glob.getThemeStyle = getThemeStyle;
|
||||
}
|
||||
|
||||
async function loadBootstrapCss() {
|
||||
@@ -49,31 +52,65 @@ async function loadBootstrapCss() {
|
||||
}
|
||||
}
|
||||
|
||||
function loadStylesheets() {
|
||||
const { device, assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
|
||||
type StylesheetRef = {
|
||||
href: string;
|
||||
media?: string;
|
||||
};
|
||||
|
||||
const cssToLoad: string[] = [];
|
||||
if (device !== "print") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/ckeditor-theme.css`);
|
||||
cssToLoad.push(`api/fonts`);
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
|
||||
if (themeCssUrl) {
|
||||
cssToLoad.push(themeCssUrl);
|
||||
}
|
||||
if (themeUseNextAsBase === "next") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`);
|
||||
} else if (themeUseNextAsBase === "next-dark") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`);
|
||||
} else if (themeUseNextAsBase === "next-light") {
|
||||
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`);
|
||||
}
|
||||
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
|
||||
function getConfiguredThemeStylesheets(stylesheetsPath: string, theme: string, customThemeCssUrl?: string) {
|
||||
if (theme === "auto") {
|
||||
return [{ href: `${stylesheetsPath}/theme-dark.css`, media: "(prefers-color-scheme: dark)" }];
|
||||
}
|
||||
|
||||
for (const href of cssToLoad) {
|
||||
if (theme === "dark") {
|
||||
return [{ href: `${stylesheetsPath}/theme-dark.css` }];
|
||||
}
|
||||
|
||||
if (theme === "next") {
|
||||
return [
|
||||
{ href: `${stylesheetsPath}/theme-next-light.css` },
|
||||
{ href: `${stylesheetsPath}/theme-next-dark.css`, media: "(prefers-color-scheme: dark)" }
|
||||
];
|
||||
}
|
||||
|
||||
if (theme === "next-light") {
|
||||
return [{ href: `${stylesheetsPath}/theme-next-light.css` }];
|
||||
}
|
||||
|
||||
if (theme === "next-dark") {
|
||||
return [{ href: `${stylesheetsPath}/theme-next-dark.css` }];
|
||||
}
|
||||
|
||||
if (theme !== "light" && customThemeCssUrl) {
|
||||
return [{ href: customThemeCssUrl }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function loadStylesheets() {
|
||||
const { device, assetPath, theme, themeBase, customThemeCssUrl } = window.glob;
|
||||
const stylesheetsPath = `${assetPath}/stylesheets`;
|
||||
|
||||
const cssToLoad: StylesheetRef[] = [];
|
||||
if (device !== "print") {
|
||||
cssToLoad.push({ href: `${stylesheetsPath}/ckeditor-theme.css` });
|
||||
cssToLoad.push({ href: `api/fonts` });
|
||||
cssToLoad.push({ href: `${stylesheetsPath}/theme-light.css` });
|
||||
cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, theme, customThemeCssUrl));
|
||||
if (themeBase) {
|
||||
cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, themeBase));
|
||||
}
|
||||
cssToLoad.push({ href: `${stylesheetsPath}/style.css` });
|
||||
}
|
||||
|
||||
for (const { href, media } of cssToLoad) {
|
||||
const linkEl = document.createElement("link");
|
||||
linkEl.href = href;
|
||||
linkEl.rel = "stylesheet";
|
||||
if (media) {
|
||||
linkEl.media = media;
|
||||
}
|
||||
document.head.appendChild(linkEl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function setupContextMenu() {
|
||||
items.push({
|
||||
title: t("electron_context_menu.add-term-to-dictionary", { term: params.misspelledWord }),
|
||||
uiIcon: "bx bx-plus",
|
||||
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
handler: () => electron.ipcRenderer.send("add-word-to-dictionary", params.misspelledWord)
|
||||
});
|
||||
|
||||
items.push({ kind: "separator" });
|
||||
|
||||
35
apps/client/src/services/theme.ts
Normal file
35
apps/client/src/services/theme.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export function getThemeStyle(): "auto" | "light" | "dark" {
|
||||
const configuredTheme = window.glob?.theme;
|
||||
if (configuredTheme === "auto" || configuredTheme === "next") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
if (configuredTheme === "light" || configuredTheme === "dark") {
|
||||
return configuredTheme;
|
||||
}
|
||||
|
||||
if (configuredTheme === "next-light") {
|
||||
return "light";
|
||||
}
|
||||
|
||||
if (configuredTheme === "next-dark") {
|
||||
return "dark";
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(document.body);
|
||||
const themeStyle = style.getPropertyValue("--theme-style");
|
||||
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
|
||||
return themeStyle as "light" | "dark";
|
||||
}
|
||||
|
||||
return "auto";
|
||||
}
|
||||
|
||||
export function getEffectiveThemeStyle(): "light" | "dark" {
|
||||
const themeStyle = getThemeStyle();
|
||||
if (themeStyle === "auto") {
|
||||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
return themeStyle === "dark" ? "dark" : "light";
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/* Import the light color scheme.
|
||||
* This is the base color scheme, always active and overridden by the dark
|
||||
* color scheme stylesheet when necessary. */
|
||||
@import url(./theme-next-light.css);
|
||||
|
||||
/* Import the dark color scheme when the system preference is set to dark mode */
|
||||
@import url(./theme-next-dark.css) (prefers-color-scheme: dark);
|
||||
|
||||
:root {
|
||||
--theme-style-auto: true;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/* Import the light color scheme.
|
||||
* This is the base color scheme, always active and overridden by the dark
|
||||
* color scheme stylesheet when necessary. */
|
||||
@import url(./theme-light.css);
|
||||
|
||||
/* Import the dark color scheme when the system preference is set to dark mode */
|
||||
@import url(./theme-dark.css) (prefers-color-scheme: dark);
|
||||
|
||||
:root {
|
||||
--theme-style-auto: true;
|
||||
}
|
||||
@@ -1498,12 +1498,15 @@
|
||||
"spellcheck": {
|
||||
"title": "Spell Check",
|
||||
"description": "These options apply only for desktop builds, browsers will use their own native spell check.",
|
||||
"enable": "Enable spellcheck",
|
||||
"language_code_label": "Language code(s)",
|
||||
"language_code_placeholder": "for example \"en-US\", \"de-AT\"",
|
||||
"multiple_languages_info": "Multiple languages can be separated by comma, e.g. \"en-US, de-DE, cs\". ",
|
||||
"available_language_codes_label": "Available language codes:",
|
||||
"restart-required": "Changes to the spell check options will take effect after application restart."
|
||||
"enable": "Check spelling",
|
||||
"language_code_label": "Spell Check Languages",
|
||||
"restart-required": "Changes to the spell check options will take effect after application restart.",
|
||||
"custom_dictionary_title": "Custom Dictionary",
|
||||
"custom_dictionary_description": "Words added to the dictionary are synced across all your devices.",
|
||||
"custom_dictionary_edit": "Custom words",
|
||||
"custom_dictionary_edit_description": "Edit the list of words that should not be flagged by the spell checker. Changes will be visible after a restart.",
|
||||
"custom_dictionary_open": "Edit dictionary",
|
||||
"related_description": "Configure spell check languages and custom dictionary."
|
||||
},
|
||||
"sync_2": {
|
||||
"config_title": "Sync Configuration",
|
||||
|
||||
1
apps/client/src/types-lib.d.ts
vendored
1
apps/client/src/types-lib.d.ts
vendored
@@ -66,6 +66,7 @@ declare module "preact" {
|
||||
interface ElectronWebViewElement extends JSX.HTMLAttributes<HTMLElement> {
|
||||
src: string;
|
||||
class: string;
|
||||
key?: string | number;
|
||||
}
|
||||
|
||||
interface IntrinsicElements {
|
||||
|
||||
6
apps/client/src/types.d.ts
vendored
6
apps/client/src/types.d.ts
vendored
@@ -24,6 +24,7 @@ interface CustomGlobals {
|
||||
getReferenceLinkTitle: (href: string) => Promise<string>;
|
||||
getReferenceLinkTitleSync: (href: string) => string;
|
||||
getActiveContextNote: () => FNote | null;
|
||||
getThemeStyle: () => "auto" | "light" | "dark";
|
||||
ESLINT: Library;
|
||||
appContext: AppContext;
|
||||
froca: Froca;
|
||||
@@ -51,8 +52,9 @@ interface CustomGlobals {
|
||||
isElectron: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
themeCssUrl: string;
|
||||
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
|
||||
theme: string;
|
||||
themeBase?: "next" | "next-light" | "next-dark";
|
||||
customThemeCssUrl?: string;
|
||||
iconPackCss: string;
|
||||
headingStyle: "plain" | "underline" | "markdown";
|
||||
layoutOrientation: "vertical" | "horizontal";
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import "./NoteMap.css";
|
||||
import { getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
|
||||
import { RefObject } from "preact";
|
||||
import FNote from "../../entities/fnote";
|
||||
import { useElementSize, useNoteLabel } from "../react/hooks";
|
||||
|
||||
import ForceGraph from "force-graph";
|
||||
import { RefObject } from "preact";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import link_context_menu from "../../menus/link_context_menu";
|
||||
import hoisted_note from "../../services/hoisted_note";
|
||||
import { t } from "../../services/i18n";
|
||||
import { getEffectiveThemeStyle } from "../../services/theme";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { useElementSize, useNoteLabel } from "../react/hooks";
|
||||
import Slider from "../react/Slider";
|
||||
import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
|
||||
import { CssData, setupRendering } from "./rendering";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { t } from "../../services/i18n";
|
||||
import link_context_menu from "../../menus/link_context_menu";
|
||||
import appContext from "../../components/app_context";
|
||||
import Slider from "../react/Slider";
|
||||
import hoisted_note from "../../services/hoisted_note";
|
||||
import { MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
|
||||
|
||||
interface NoteMapProps {
|
||||
note: FNote;
|
||||
@@ -40,9 +43,9 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
|
||||
return hoisted_note.getHoistedNoteId();
|
||||
} else if (mapRootIdLabel) {
|
||||
return mapRootIdLabel;
|
||||
} else {
|
||||
return appContext.tabManager.getActiveContext()?.parentNoteId ?? null;
|
||||
}
|
||||
return appContext.tabManager.getActiveContext()?.parentNoteId ?? null;
|
||||
|
||||
}, [ note ]);
|
||||
|
||||
// Build the note graph instance.
|
||||
@@ -67,7 +70,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
|
||||
noteIdToSizeMap: notesAndRelations.noteIdToSizeMap,
|
||||
cssData,
|
||||
notesAndRelations,
|
||||
themeStyle: getThemeStyle(),
|
||||
themeStyle: getEffectiveThemeStyle(),
|
||||
widgetMode,
|
||||
mapType
|
||||
});
|
||||
@@ -113,7 +116,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
|
||||
node.fx = undefined;
|
||||
node.fy = undefined;
|
||||
}
|
||||
})
|
||||
});
|
||||
}, [ fixNodes, mapType ]);
|
||||
|
||||
return (
|
||||
@@ -159,7 +162,7 @@ function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: {
|
||||
onClick={() => setMapType(type)}
|
||||
frame
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData {
|
||||
@@ -170,5 +173,5 @@ function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData
|
||||
fontFamily: containerStyle.fontFamily,
|
||||
textColor: rgb2hex(containerStyle.color),
|
||||
mutedTextColor: rgb2hex(styleResolverStyle.color)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,3 @@ export function generateColorFromString(str: string, themeStyle: "light" | "dark
|
||||
return color;
|
||||
}
|
||||
|
||||
export function getThemeStyle() {
|
||||
const documentStyle = window.getComputedStyle(document.documentElement);
|
||||
return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark";
|
||||
}
|
||||
|
||||
@@ -1385,7 +1385,7 @@ export function useGetContextDataFrom<K extends keyof NoteContextDataMap>(
|
||||
}
|
||||
|
||||
export function useColorScheme() {
|
||||
const themeStyle = getThemeStyle();
|
||||
const themeStyle = window.glob.getThemeStyle();
|
||||
const defaultValue = themeStyle === "auto" ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) : themeStyle === "dark";
|
||||
const [ prefersDark, setPrefersDark ] = useState(defaultValue);
|
||||
|
||||
@@ -1400,12 +1400,3 @@ export function useColorScheme() {
|
||||
|
||||
return prefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
function getThemeStyle() {
|
||||
const style = window.getComputedStyle(document.body);
|
||||
const themeStyle = style.getPropertyValue("--theme-style");
|
||||
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
|
||||
return themeStyle as "light" | "dark";
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ function DesktopWebView({ src, ntxId }: { src: string, ntxId: string | null | un
|
||||
return <webview
|
||||
ref={webviewRef}
|
||||
src={src}
|
||||
key={src}
|
||||
class="note-detail-web-view-content"
|
||||
/>;
|
||||
}
|
||||
@@ -80,6 +81,7 @@ function BrowserWebView({ src, ntxId }: { src: string, ntxId: string | null | un
|
||||
return <iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
key={src}
|
||||
class="note-detail-web-view-content"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups" />;
|
||||
}
|
||||
|
||||
@@ -45,3 +45,15 @@
|
||||
.option-row.centered {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.option-row-link.use-tn-links {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
margin-inline: calc(-1 * var(--options-card-padding, 15px));
|
||||
padding-inline: var(--options-card-padding, 15px);
|
||||
transition: background-color 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.option-row-link:hover {
|
||||
background: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { cloneElement, VNode } from "preact";
|
||||
import "./OptionsRow.css";
|
||||
|
||||
import { cloneElement, VNode } from "preact";
|
||||
|
||||
import { useUniqueName } from "../../../react/hooks";
|
||||
|
||||
interface OptionsRowProps {
|
||||
@@ -25,4 +27,24 @@ export default function OptionsRow({ name, label, description, children, centere
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface OptionsRowLinkProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function OptionsRowLink({ label, description, href }: OptionsRowLinkProps) {
|
||||
return (
|
||||
<a href={href} className="option-row option-row-link use-tn-links no-tooltip-preview">
|
||||
<div className="option-row-label">
|
||||
<label style={{ cursor: "pointer" }}>{label}</label>
|
||||
{description && <small className="option-row-description">{description}</small>}
|
||||
</div>
|
||||
<div className="option-row-input">
|
||||
<span className="bx bx-chevron-right" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import OptionsSection from "./OptionsSection";
|
||||
import type { OptionPages } from "../../ContentWidget";
|
||||
import { t } from "../../../../services/i18n";
|
||||
import type { OptionPages } from "../../ContentWidget";
|
||||
import { OptionsRowLink } from "./OptionsRow";
|
||||
import OptionsSection from "./OptionsSection";
|
||||
|
||||
interface RelatedSettingsItem {
|
||||
title: string;
|
||||
description?: string;
|
||||
targetPage: OptionPages;
|
||||
}
|
||||
|
||||
interface RelatedSettingsProps {
|
||||
items: {
|
||||
title: string;
|
||||
targetPage: OptionPages;
|
||||
}[];
|
||||
items: RelatedSettingsItem[];
|
||||
}
|
||||
|
||||
export default function RelatedSettings({ items }: RelatedSettingsProps) {
|
||||
return (
|
||||
<OptionsSection title={t("settings.related_settings")}>
|
||||
<nav className="use-tn-links" style={{ padding: 0, margin: 0, listStyleType: "none" }}>
|
||||
{items.map(item => (
|
||||
<li>
|
||||
<a href={`#root/_hidden/_options/${item.targetPage}`}>{item.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</nav>
|
||||
{items.map((item) => (
|
||||
<OptionsRowLink
|
||||
key={item.targetPage}
|
||||
label={item.title}
|
||||
description={item.description}
|
||||
href={`#root/_hidden/_options/${item.targetPage}`}
|
||||
/>
|
||||
))}
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import { useTriliumOption, useTriliumOptionJson } from "../../react/hooks";
|
||||
import type { Locale } from "@triliumnext/commons";
|
||||
import { restartDesktopApp } from "../../../services/utils";
|
||||
import { isElectron, restartDesktopApp } from "../../../services/utils";
|
||||
import FormRadioGroup from "../../react/FormRadioGroup";
|
||||
import FormText from "../../react/FormText";
|
||||
import RawHtml from "../../react/RawHtml";
|
||||
import Admonition from "../../react/Admonition";
|
||||
import Button from "../../react/Button";
|
||||
import CheckboxList from "./components/CheckboxList";
|
||||
import RelatedSettings from "./components/RelatedSettings";
|
||||
import { LocaleSelector } from "./components/LocaleSelector";
|
||||
|
||||
export default function InternationalizationOptions() {
|
||||
@@ -19,8 +20,17 @@ export default function InternationalizationOptions() {
|
||||
<>
|
||||
<LocalizationOptions />
|
||||
<ContentLanguages />
|
||||
{isElectron() && (
|
||||
<RelatedSettings items={[
|
||||
{
|
||||
title: t("spellcheck.title"),
|
||||
description: t("spellcheck.related_description"),
|
||||
targetPage: "_optionsSpellcheck"
|
||||
}
|
||||
]} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function LocalizationOptions() {
|
||||
|
||||
@@ -1,57 +1,122 @@
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { useCallback, useMemo } from "preact/hooks";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import { t } from "../../../services/i18n";
|
||||
import FormCheckbox from "../../react/FormCheckbox";
|
||||
import FormGroup from "../../react/FormGroup";
|
||||
import { dynamicRequire, isElectron, restartDesktopApp } from "../../../services/utils";
|
||||
import Button from "../../react/Button";
|
||||
import FormText from "../../react/FormText";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import FormToggle from "../../react/FormToggle";
|
||||
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import CheckboxList from "./components/CheckboxList";
|
||||
import OptionsRow from "./components/OptionsRow";
|
||||
import OptionsSection from "./components/OptionsSection";
|
||||
import { dynamicRequire, isElectron } from "../../../services/utils";
|
||||
|
||||
export default function SpellcheckSettings() {
|
||||
if (isElectron()) {
|
||||
return <ElectronSpellcheckSettings />
|
||||
} else {
|
||||
return <WebSpellcheckSettings />
|
||||
return <ElectronSpellcheckSettings />;
|
||||
}
|
||||
return <WebSpellcheckSettings />;
|
||||
}
|
||||
|
||||
interface SpellcheckLanguage {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function ElectronSpellcheckSettings() {
|
||||
const [ spellCheckEnabled, setSpellCheckEnabled ] = useTriliumOptionBool("spellCheckEnabled");
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptionsSection title={t("spellcheck.title")}>
|
||||
<FormText>{t("spellcheck.restart-required")}</FormText>
|
||||
|
||||
<OptionsRow name="spell-check-enabled" label={t("spellcheck.enable")}>
|
||||
<FormToggle
|
||||
switchOnName="" switchOffName=""
|
||||
currentValue={spellCheckEnabled}
|
||||
onChange={setSpellCheckEnabled}
|
||||
/>
|
||||
</OptionsRow>
|
||||
|
||||
<OptionsRow name="restart" centered>
|
||||
<Button
|
||||
name="restart-app-button"
|
||||
text={t("electron_integration.restart-app-button")}
|
||||
size="micro"
|
||||
onClick={restartDesktopApp}
|
||||
/>
|
||||
</OptionsRow>
|
||||
</OptionsSection>
|
||||
|
||||
{spellCheckEnabled && <SpellcheckLanguages />}
|
||||
{spellCheckEnabled && <CustomDictionary />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SpellcheckLanguages() {
|
||||
const [ spellCheckLanguageCode, setSpellCheckLanguageCode ] = useTriliumOption("spellCheckLanguageCode");
|
||||
|
||||
const availableLanguageCodes = useMemo(() => {
|
||||
const selectedCodes = useMemo(() =>
|
||||
(spellCheckLanguageCode ?? "")
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0),
|
||||
[spellCheckLanguageCode]
|
||||
);
|
||||
|
||||
const setSelectedCodes = useCallback((codes: string[]) => {
|
||||
setSpellCheckLanguageCode(codes.join(", "));
|
||||
}, [setSpellCheckLanguageCode]);
|
||||
|
||||
const availableLanguages = useMemo<SpellcheckLanguage[]>(() => {
|
||||
if (!isElectron()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { webContents } = dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
return webContents.session.availableSpellCheckerLanguages as string[];
|
||||
}, [])
|
||||
const { webContents } = dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
const codes = webContents.session.availableSpellCheckerLanguages as string[];
|
||||
const displayNames = new Intl.DisplayNames([navigator.language], { type: "language" });
|
||||
|
||||
return codes.map((code) => ({
|
||||
code,
|
||||
name: displayNames.of(code) ?? code
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("spellcheck.title")}>
|
||||
<FormText>{t("spellcheck.restart-required")}</FormText>
|
||||
|
||||
<FormCheckbox
|
||||
name="spell-check-enabled"
|
||||
label={t("spellcheck.enable")}
|
||||
currentValue={spellCheckEnabled} onChange={setSpellCheckEnabled}
|
||||
<OptionsSection title={t("spellcheck.language_code_label")}>
|
||||
<CheckboxList
|
||||
values={availableLanguages}
|
||||
keyProperty="code" titleProperty="name"
|
||||
currentValue={selectedCodes}
|
||||
onChange={setSelectedCodes}
|
||||
columnWidth="200px"
|
||||
/>
|
||||
|
||||
<FormGroup name="spell-check-languages" label={t("spellcheck.language_code_label")} description={t("spellcheck.multiple_languages_info")}>
|
||||
<FormTextBox
|
||||
placeholder={t("spellcheck.language_code_placeholder")}
|
||||
currentValue={spellCheckLanguageCode} onChange={setSpellCheckLanguageCode}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormText>
|
||||
<strong>{t("spellcheck.available_language_codes_label")} </strong>
|
||||
{availableLanguageCodes.join(", ")}
|
||||
</FormText>
|
||||
</OptionsSection>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CustomDictionary() {
|
||||
function openDictionary() {
|
||||
appContext.triggerCommand("openInPopup", { noteIdOrPath: "_customDictionary" });
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionsSection title={t("spellcheck.custom_dictionary_title")}>
|
||||
<FormText>{t("spellcheck.custom_dictionary_description")}</FormText>
|
||||
|
||||
<OptionsRow name="custom-dictionary" label={t("spellcheck.custom_dictionary_edit")} description={t("spellcheck.custom_dictionary_edit_description")}>
|
||||
<Button
|
||||
name="open-custom-dictionary"
|
||||
text={t("spellcheck.custom_dictionary_open")}
|
||||
icon="bx bx-edit"
|
||||
onClick={openDictionary}
|
||||
/>
|
||||
</OptionsRow>
|
||||
</OptionsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSpellcheckSettings() {
|
||||
@@ -59,5 +124,5 @@ function WebSpellcheckSettings() {
|
||||
<OptionsSection title={t("spellcheck.title")}>
|
||||
<p>{t("spellcheck.description")}</p>
|
||||
</OptionsSection>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,9 @@ const config: ForgeConfig = {
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
[FuseV1Options.GrantFileProtocolExtraPrivileges]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -313,6 +313,7 @@
|
||||
"shared-notes-title": "Shared Notes",
|
||||
"bulk-action-title": "Bulk Action",
|
||||
"backend-log-title": "Backend Log",
|
||||
"custom-dictionary-title": "Custom Dictionary",
|
||||
"user-hidden-title": "User Hidden",
|
||||
"launch-bar-templates-title": "Launch Bar Templates",
|
||||
"base-abstract-launcher-title": "Base Abstract Launcher",
|
||||
|
||||
@@ -11,9 +11,9 @@ import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPP
|
||||
import log from "../services/log.js";
|
||||
import optionService from "../services/options.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import { generateCsrfToken } from "./csrf_protection.js";
|
||||
import sql from "../services/sql.js";
|
||||
import { isDev, isElectron, isMac, isWindows11 } from "../services/utils.js";
|
||||
import { generateCsrfToken } from "./csrf_protection.js";
|
||||
|
||||
type View = "desktop" | "mobile" | "print";
|
||||
|
||||
@@ -38,6 +38,7 @@ export function bootstrap(req: Request, res: Response) {
|
||||
const view = getView(req);
|
||||
const theme = options.theme;
|
||||
const themeNote = attributeService.getNoteWithLabel("appTheme", theme);
|
||||
const themeUseNextAsBase = themeNote?.getAttributeValue("label", "appThemeBase") ?? undefined;
|
||||
const nativeTitleBarVisible = options.nativeTitleBarVisible === "true";
|
||||
const iconPacks = getIconPacks();
|
||||
const currentLocale = getCurrentLocale();
|
||||
@@ -45,8 +46,9 @@ export function bootstrap(req: Request, res: Response) {
|
||||
res.send({
|
||||
device: view,
|
||||
csrfToken,
|
||||
themeCssUrl: getThemeCssUrl(theme, themeNote),
|
||||
themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase"),
|
||||
theme,
|
||||
themeBase: themeUseNextAsBase,
|
||||
customThemeCssUrl: getCustomThemeCssUrl(theme, themeNote),
|
||||
headingStyle: options.headingStyle,
|
||||
layoutOrientation: options.layoutOrientation,
|
||||
platform: process.platform,
|
||||
@@ -117,25 +119,16 @@ function getView(req: Request): View {
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
function getThemeCssUrl(theme: string, themeNote: BNote | null) {
|
||||
if (theme === "auto") {
|
||||
return `${assetPath}/stylesheets/theme.css`;
|
||||
} else if (theme === "light") {
|
||||
// light theme is always loaded as baseline
|
||||
return false;
|
||||
} else if (theme === "dark") {
|
||||
return `${assetPath}/stylesheets/theme-dark.css`;
|
||||
} else if (theme === "next") {
|
||||
return `${assetPath}/stylesheets/theme-next.css`;
|
||||
} else if (theme === "next-light") {
|
||||
return `${assetPath}/stylesheets/theme-next-light.css`;
|
||||
} else if (theme === "next-dark") {
|
||||
return `${assetPath}/stylesheets/theme-next-dark.css`;
|
||||
} else if (!process.env.TRILIUM_SAFE_MODE && themeNote) {
|
||||
function getCustomThemeCssUrl(theme: string, themeNote: BNote | null) {
|
||||
if (["auto", "light", "dark", "next", "next-light", "next-dark"].includes(theme)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!process.env.TRILIUM_SAFE_MODE && themeNote) {
|
||||
return `api/notes/download/${themeNote.noteId}`;
|
||||
}
|
||||
// baseline light theme
|
||||
return false;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getAppCssNoteIds() {
|
||||
|
||||
171
apps/server/src/services/custom_dictionary.spec.ts
Normal file
171
apps/server/src/services/custom_dictionary.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import becca from "../becca/becca.js";
|
||||
import { buildNote } from "../test/becca_easy_mocking.js";
|
||||
import customDictionary from "./custom_dictionary.js";
|
||||
|
||||
vi.mock("./log.js", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("./sql.js", () => ({
|
||||
default: {
|
||||
transactional: (cb: Function) => cb(),
|
||||
execute: () => {},
|
||||
replace: () => {},
|
||||
getMap: () => {},
|
||||
getValue: () => null,
|
||||
upsert: () => {}
|
||||
}
|
||||
}));
|
||||
|
||||
function mockSession(localWords: string[] = []) {
|
||||
return {
|
||||
listWordsInSpellCheckerDictionary: vi.fn().mockResolvedValue(localWords),
|
||||
addWordToSpellCheckerDictionary: vi.fn(),
|
||||
removeWordFromSpellCheckerDictionary: vi.fn()
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("custom_dictionary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
becca.reset();
|
||||
buildNote({
|
||||
id: "_customDictionary",
|
||||
title: "Custom Dictionary",
|
||||
type: "code",
|
||||
content: ""
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadForSession", () => {
|
||||
it("does nothing when note is empty and no local words", async () => {
|
||||
const session = mockSession();
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
expect(session.addWordToSpellCheckerDictionary).not.toHaveBeenCalled();
|
||||
expect(session.removeWordFromSpellCheckerDictionary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports local words when note is empty (one-time import)", async () => {
|
||||
const session = mockSession(["hello", "world"]);
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
// Words are saved to the note; they're already in the local dictionary so no re-add needed.
|
||||
expect(session.addWordToSpellCheckerDictionary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not remove or re-add local words after one-time import", async () => {
|
||||
const session = mockSession(["hello", "world"]);
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
// Words were imported from local, so they already exist — no remove, no re-add.
|
||||
expect(session.removeWordFromSpellCheckerDictionary).not.toHaveBeenCalled();
|
||||
expect(session.addWordToSpellCheckerDictionary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads note words into session when no local words exist", async () => {
|
||||
becca.reset();
|
||||
buildNote({
|
||||
id: "_customDictionary",
|
||||
title: "Custom Dictionary",
|
||||
type: "code",
|
||||
content: "apple\nbanana"
|
||||
});
|
||||
const session = mockSession();
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledTimes(2);
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledWith("apple");
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledWith("banana");
|
||||
});
|
||||
|
||||
it("only adds note words not already in local dictionary", async () => {
|
||||
becca.reset();
|
||||
buildNote({
|
||||
id: "_customDictionary",
|
||||
title: "Custom Dictionary",
|
||||
type: "code",
|
||||
content: "apple\nbanana"
|
||||
});
|
||||
// "banana" is already local, so only "apple" needs adding.
|
||||
const session = mockSession(["banana", "cherry"]);
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledTimes(1);
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledWith("apple");
|
||||
});
|
||||
|
||||
it("only removes local words not in the note", async () => {
|
||||
becca.reset();
|
||||
buildNote({
|
||||
id: "_customDictionary",
|
||||
title: "Custom Dictionary",
|
||||
type: "code",
|
||||
content: "apple\nbanana"
|
||||
});
|
||||
// "cherry" is not in the note, so it should be removed. "banana" should stay.
|
||||
const session = mockSession(["banana", "cherry"]);
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
expect(session.removeWordFromSpellCheckerDictionary).toHaveBeenCalledTimes(1);
|
||||
expect(session.removeWordFromSpellCheckerDictionary).toHaveBeenCalledWith("cherry");
|
||||
});
|
||||
|
||||
it("handles note with whitespace and blank lines", async () => {
|
||||
becca.reset();
|
||||
buildNote({
|
||||
id: "_customDictionary",
|
||||
title: "Custom Dictionary",
|
||||
type: "code",
|
||||
content: " apple \n\n banana \n\n"
|
||||
});
|
||||
const session = mockSession();
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledTimes(2);
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledWith("apple");
|
||||
expect(session.addWordToSpellCheckerDictionary).toHaveBeenCalledWith("banana");
|
||||
});
|
||||
|
||||
it("does not re-add words removed from the note but present locally", async () => {
|
||||
becca.reset();
|
||||
buildNote({
|
||||
id: "_customDictionary",
|
||||
title: "Custom Dictionary",
|
||||
type: "code",
|
||||
content: "apple\nbanana"
|
||||
});
|
||||
// "cherry" was previously in the note but user removed it;
|
||||
// it still lingers in Electron's local dictionary.
|
||||
const session = mockSession(["apple", "banana", "cherry"]);
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
// "apple" and "banana" are already local — no re-add needed.
|
||||
expect(session.addWordToSpellCheckerDictionary).not.toHaveBeenCalled();
|
||||
// "cherry" should be removed from local dictionary.
|
||||
expect(session.removeWordFromSpellCheckerDictionary).toHaveBeenCalledTimes(1);
|
||||
expect(session.removeWordFromSpellCheckerDictionary).toHaveBeenCalledWith("cherry");
|
||||
});
|
||||
|
||||
it("handles missing dictionary note gracefully", async () => {
|
||||
becca.reset(); // no note created
|
||||
const session = mockSession(["hello"]);
|
||||
|
||||
await customDictionary.loadForSession(session);
|
||||
|
||||
expect(session.addWordToSpellCheckerDictionary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
113
apps/server/src/services/custom_dictionary.ts
Normal file
113
apps/server/src/services/custom_dictionary.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Session } from "electron";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import cls from "./cls.js";
|
||||
import log from "./log.js";
|
||||
|
||||
const DICTIONARY_NOTE_ID = "_customDictionary";
|
||||
|
||||
/**
|
||||
* Reads the custom dictionary words from the hidden note.
|
||||
*/
|
||||
function getWords(): Set<string> {
|
||||
const note = becca.getNote(DICTIONARY_NOTE_ID);
|
||||
if (!note) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const content = note.getContent();
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
content.split("\n")
|
||||
.map((w) => w.trim())
|
||||
.filter((w) => w.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the given words to the custom dictionary note, one per line.
|
||||
*/
|
||||
function saveWords(words: Set<string>) {
|
||||
cls.init(() => {
|
||||
const note = becca.getNote(DICTIONARY_NOTE_ID);
|
||||
if (!note) {
|
||||
log.error("Custom dictionary note not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = [...words].sort((a, b) => a.localeCompare(b));
|
||||
note.setContent(sorted.join("\n"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single word to the custom dictionary note.
|
||||
*/
|
||||
function addWord(word: string) {
|
||||
const words = getWords();
|
||||
words.add(word);
|
||||
saveWords(words);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all words from Electron's local spellchecker dictionary
|
||||
* so they are not re-imported on subsequent startups.
|
||||
*/
|
||||
function clearFromLocalDictionary(session: Session, localWords: string[]) {
|
||||
for (const word of localWords) {
|
||||
session.removeWordFromSpellCheckerDictionary(word);
|
||||
}
|
||||
log.info(`Cleared ${localWords.length} words from local spellchecker dictionary.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the custom dictionary into Electron's spellchecker session,
|
||||
* performing a one-time import of locally stored words on first use.
|
||||
*/
|
||||
async function loadForSession(session: Session) {
|
||||
const note = becca.getNote(DICTIONARY_NOTE_ID);
|
||||
if (!note) {
|
||||
log.error("Custom dictionary note not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const noteWords = getWords();
|
||||
const localWords = await session.listWordsInSpellCheckerDictionary();
|
||||
|
||||
let merged = noteWords;
|
||||
|
||||
// One-time import: if the note is empty but there are local words, import them.
|
||||
if (noteWords.size === 0 && localWords.length > 0) {
|
||||
log.info(`Importing ${localWords.length} words from local spellchecker dictionary.`);
|
||||
merged = new Set(localWords);
|
||||
saveWords(merged);
|
||||
}
|
||||
|
||||
// Remove local words that are not in the note (e.g. user removed them manually).
|
||||
const staleWords = localWords.filter((w) => !merged.has(w));
|
||||
if (staleWords.length > 0) {
|
||||
clearFromLocalDictionary(session, staleWords);
|
||||
}
|
||||
|
||||
// Add note words that aren't already in the local dictionary.
|
||||
const localWordsSet = new Set(localWords);
|
||||
for (const word of merged) {
|
||||
if (!localWordsSet.has(word)) {
|
||||
session.addWordToSpellCheckerDictionary(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.size > 0) {
|
||||
log.info(`Loaded ${merged.size} custom dictionary words into spellchecker.`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getWords,
|
||||
saveWords,
|
||||
addWord,
|
||||
loadForSession
|
||||
};
|
||||
@@ -93,6 +93,12 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
|
||||
{ type: "label", name: "fullContentWidth" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_customDictionary",
|
||||
title: t("hidden-subtree.custom-dictionary-title"),
|
||||
type: "code",
|
||||
icon: "bx-book"
|
||||
},
|
||||
{
|
||||
// place for user scripts hidden stuff (scripts should not create notes directly under hidden root)
|
||||
id: "_userHidden",
|
||||
|
||||
@@ -6,6 +6,7 @@ import url from "url";
|
||||
|
||||
import app_info from "./app_info.js";
|
||||
import cls from "./cls.js";
|
||||
import customDictionary from "./custom_dictionary.js";
|
||||
import keyboardActionsService from "./keyboard_actions.js";
|
||||
import log from "./log.js";
|
||||
import optionService from "./options.js";
|
||||
@@ -381,6 +382,12 @@ async function configureWebContents(webContents: WebContents, spellcheckEnabled:
|
||||
.map((code) => code.trim());
|
||||
|
||||
webContents.session.setSpellCheckerLanguages(languageCodes);
|
||||
customDictionary.loadForSession(webContents.session);
|
||||
|
||||
ipcMain.on("add-word-to-dictionary", (_event, word: string) => {
|
||||
webContents.session.addWordToSpellCheckerDictionary(word);
|
||||
customDictionary.addWord(word);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* There are three themes embedded in the application:
|
||||
* `light`, located in `src\public\stylesheets\theme-light.css`
|
||||
* `dark`, located in `src\public\stylesheets\theme-dark.css`
|
||||
* `next`, located in `src\public\stylesheets\theme-next.css`.
|
||||
* `next`, composed from `src\public\stylesheets\theme-next-light.css` and `src\public\stylesheets\theme-next-dark.css`.
|
||||
* The default theme is set only once, when the database is created and is managed by `options_init#initNotSyncedOptions`.
|
||||
* In the original implementation: On Electron, the choice between `light` and `dark` is done based on the OS preference. Otherwise, the theme is always `dark`.
|
||||
* Now, we always choose `next` as the default theme.
|
||||
|
||||
@@ -12,10 +12,10 @@ Trilium Web Clipper officially supports the following web browsers:
|
||||
|
||||
## Obtaining the extension
|
||||
|
||||
> [!WARNING]
|
||||
> The extension is currently under development. A preview with unsigned extensions is available on [GitHub Actions](https://github.com/TriliumNext/Trilium/actions/runs/21318809414).
|
||||
>
|
||||
> We have already submitted the extension to both Chrome and Firefox web stores, but they are pending validation.
|
||||
The extension is available from the official browser web stores:
|
||||
|
||||
* **Firefox**: [Trilium Web Clipper on Firefox Add-ons](https://addons.mozilla.org/firefox/addon/trilium-notes-web-clipper/)
|
||||
* **Chrome**: [Trilium Web Clipper on Chrome Web Store](https://chromewebstore.google.com/detail/trilium-web-clipper/ofoiklieachadcaeffficgjaajojpkpi)
|
||||
|
||||
## Functionality
|
||||
|
||||
@@ -87,4 +87,4 @@ Development versions are version pre-release versions, generally meant for testi
|
||||
|
||||
## Credits
|
||||
|
||||
Some parts of the code are based on the [Joplin Notes browser extension](https://github.com/laurent22/joplin/tree/master/Clipper).
|
||||
Some parts of the code are based on the [Joplin Notes browser extension](https://github.com/laurent22/joplin/tree/master/Clipper).
|
||||
|
||||
Reference in New Issue
Block a user