Merge remote-tracking branch 'origin/develop' into feature/in_app_help

This commit is contained in:
Elian Doran
2025-02-05 19:56:03 +02:00
40 changed files with 1559 additions and 616 deletions

View File

@@ -4,7 +4,6 @@ import noteTooltipService from "./services/note_tooltip.js";
import bundleService from "./services/bundle.js";
import toastService from "./services/toast.js";
import noteAutocompleteService from "./services/note_autocomplete.js";
import macInit from "./services/mac_init.js";
import electronContextMenu from "./menus/electron_context_menu.js";
import glob from "./services/glob.js";
import { t } from "./services/i18n.js";
@@ -35,8 +34,6 @@ if (utils.isElectron()) {
initOnElectron();
}
macInit.init();
noteTooltipService.setupGlobalTooltip();
noteAutocompleteService.init();

View File

@@ -1,26 +0,0 @@
/**
* Mac specific initialization
*/
import utils from "./utils.js";
import shortcutService from "./shortcuts.js";
function init() {
if (utils.isElectron() && utils.isMac()) {
shortcutService.bindGlobalShortcut("meta+c", () => exec("copy"));
shortcutService.bindGlobalShortcut("meta+v", () => exec("paste"));
shortcutService.bindGlobalShortcut("meta+x", () => exec("cut"));
shortcutService.bindGlobalShortcut("meta+a", () => exec("selectAll"));
shortcutService.bindGlobalShortcut("meta+z", () => exec("undo"));
shortcutService.bindGlobalShortcut("meta+y", () => exec("redo"));
}
}
function exec(cmd: string) {
document.execCommand(cmd);
return false;
}
export default {
init
};

View File

@@ -1,9 +1,6 @@
import utils from "./services/utils.js";
import macInit from "./services/mac_init.js";
import ko from "knockout";
macInit.init();
// TriliumNextTODO: properly make use of below types
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";

View File

@@ -67,6 +67,10 @@ const TPL = `
.attr-detail input[readonly] {
background-color: var(--accented-background-color) !important;
}
.attr-edit-table td {
padding: 4px 0;
}
</style>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
@@ -97,8 +101,13 @@ const TPL = `
</tr>
<tr class="attr-row-promoted"
title="${t("attribute_detail.promoted_title")}">
<th>${t("attribute_detail.promoted")}</th>
<td><input type="checkbox" class="attr-input-promoted form-check" /></td>
<th></th>
<td>
<label class="tn-checkbox">
<input type="checkbox" class="attr-input-promoted form-check" />
${t("attribute_detail.promoted")}
</label>
</td>
</tr>
<tr class="attr-row-promoted-alias">
<th title="${t("attribute_detail.promoted_alias_title")}">${t("attribute_detail.promoted_alias")}</th>
@@ -149,8 +158,13 @@ const TPL = `
</td>
</tr>
<tr title="${t("attribute_detail.inheritable_title")}">
<th>${t("attribute_detail.inheritable")}</th>
<td><input type="checkbox" class="attr-input-inheritable form-check" /></td>
<th></th>
<td>
<label class="tn-checkbox">
<input type="checkbox" class="attr-input-inheritable form-check" />
${t("attribute_detail.inheritable")}
</label>
</td>
</tr>
</table>

View File

@@ -77,8 +77,8 @@ const TPL = `
<div class="attribute-list-editor" tabindex="200"></div>
<div class="bx bx-save save-attributes-button" title="${escapeQuotes(t("attribute_editor.save_attributes"))}"></div>
<div class="bx bx-plus add-new-attribute-button" title="${escapeQuotes(t("attribute_editor.add_a_new_attribute"))}"></div>
<div class="bx bx-save save-attributes-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.save_attributes"))}"></div>
<div class="bx bx-plus add-new-attribute-button tn-tool-button" title="${escapeQuotes(t("attribute_editor.add_a_new_attribute"))}"></div>
<div class="attribute-errors" style="display: none;"></div>
</div>

View File

@@ -25,16 +25,22 @@ const TPL = `
${t("include_note.box_size_prompt")}
<div class="form-check">
<input class="form-check-input" type="radio" name="include-note-box-size" value="small">
<label class="form-check-label">${t("include_note.box_size_small")}</label>
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="include-note-box-size" value="small">
${t("include_note.box_size_small")}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="include-note-box-size" value="medium" checked>
<label class="form-check-label">${t("include_note.box_size_medium")}</label>
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="include-note-box-size" value="medium" checked>
${t("include_note.box_size_medium")}
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="include-note-box-size" value="full">
<label class="form-check-label">${t("include_note.box_size_full")}</label>
<label class="form-check-label tn-radio">
<input class="form-check-input" type="radio" name="include-note-box-size" value="full">
${t("include_note.box_size_full")}
</label>
</div>
</div>
<div class="modal-footer">

View File

@@ -27,7 +27,7 @@ const TPL = `
<span class="editability-active-desc">${t("editability_select.auto")}</span>
<span class="caret"></span>
</button>
<div class="editability-dropdown dropdown-menu dropdown-menu-right">
<div class="editability-dropdown dropdown-menu dropdown-menu-right tn-dropdown-list">
<a class="dropdown-item" href="#" data-editability="auto">
<span class="check">&check;</span>
${t("editability_select.auto")}

View File

@@ -33,11 +33,15 @@ const TPL = `
}
.find-widget-found-wrapper {
font-weight: bold;
justify-content: center;
min-width: 60px;
padding: 0 4px;
font-size: .85em;
text-align: center;
}
.find-widget-search-term-input-group, .replace-widget-replacetext-input {
max-width: 300px;
max-width: 350px;
}
.find-widget-spacer {
@@ -49,6 +53,13 @@ const TPL = `
<div class="input-group find-widget-search-term-input-group">
<input type="text" class="form-control find-widget-search-term-input" placeholder="${t("find.find_placeholder")}">
<button class="btn btn-outline-secondary bx bxs-chevron-up find-widget-previous-button" type="button"></button>
<div class="find-widget-found-wrapper input-group-text">
<span>
<span class="find-widget-current-found">0</span>
/
<span class="find-widget-total-found">0</span>
<span>
</div>
<button class="btn btn-outline-secondary bx bxs-chevron-down find-widget-next-button" type="button"></button>
</div>
@@ -66,11 +77,7 @@ const TPL = `
</label>
</div>
<div class="find-widget-found-wrapper">
<span class="find-widget-current-found">0</span>
/
<span class="find-widget-total-found">0</span>
</div>
<div class="find-widget-spacer"></div>

View File

@@ -40,7 +40,7 @@ const TPL = `
<span class="note-type-desc"></span>
<span class="caret"></span>
</button>
<div class="note-type-dropdown dropdown-menu dropdown-menu-left"></div>
<div class="note-type-dropdown dropdown-menu dropdown-menu-left tn-dropdown-list"></div>
</div>
`;

View File

@@ -15,18 +15,18 @@ const TPL = `
display: flex;
flex-direction: column;
}
.attachment-detail .links-wrapper {
font-size: larger;
padding: 0 0 16px 0;
}
.attachment-detail .attachment-wrapper {
flex-grow: 1;
}
</style>
<div class="links-wrapper"></div>
<div class="links-wrapper use-tn-links"></div>
<div class="attachment-wrapper"></div>
</div>`;
@@ -57,7 +57,7 @@ export default class AttachmentDetailTypeWidget extends TypeWidget {
this.$linksWrapper.empty().append(
t("attachment_detail.owning_note"),
await linkService.createLink(this.noteId),
(await linkService.createLink(this.noteId)),
t("attachment_detail.you_can_also_open"),
await linkService.createLink(this.noteId, {
title: t("attachment_detail.list_of_all_attachments"),

View File

@@ -17,7 +17,7 @@ const TPL = `
<hr />
<div class="side-checkbox">
<label class="form-check">
<label class="form-check tn-checkbox">
<input type="checkbox" class="native-title-bar form-check-input" />
<strong>${t("electron_integration.native-title-bar")}</strong>
<p>${t("electron_integration.native-title-bar-description")}</p>
@@ -25,7 +25,7 @@ const TPL = `
</div>
<div class="side-checkbox">
<label class="form-check">
<label class="form-check tn-checkbox">
<input type="checkbox" class="background-effects form-check-input" />
<strong>${t("electron_integration.background-effects")}</strong>
<p>${t("electron_integration.background-effects-description")}</p>

View File

@@ -7,7 +7,7 @@ const TPL = `
<div class="options-section">
<h4>${t("tray.title")}</h4>
<label>
<label class="tn-checkbox">
<input type="checkbox" class="tray-enabled">
${t("tray.enable_tray")}
</label>

View File

@@ -17,7 +17,7 @@ const TPL_ELECTRON = `
<p>${t("spellcheck.restart-required")}</p>
<label>
<label class="tn-checkbox">
<input type="checkbox" class="spell-check-enabled">
${t("spellcheck.enable")}
</label>

View File

@@ -9,6 +9,12 @@ const TPL = `
width: 300px;
margin: 30px auto auto;
}
.protected-session-password-component input,
.protected-session-password-component button {
margin-top: 12px;
}
</style>
<form class="protected-session-password-form">

View File

@@ -1,6 +1,8 @@
@import url(./forms.css);
@import url(./shell.css);
@import url(./ribbon.css);
@import url(./settings.css);
@import url(./notes/empty.css);
@import url(./notes/text.css);
@font-face {
@@ -64,3 +66,29 @@
/* Theme capabilities */
--tab-note-icons: true;
}
/*
* Note search suggestions
*/
/* List body */
.jump-to-note-dialog .jump-to-note-results .aa-suggestions,
.note-detail-empty .aa-suggestions {
padding: 0;
}
/* List item */
.jump-to-note-dialog .aa-suggestions div,
.note-detail-empty .aa-suggestions div {
border-radius: 6px;
padding: 6px 12px;
color: var(--menu-text-color);
cursor: default;
}
/* Selected list item */
.jump-to-note-dialog .aa-suggestions div.aa-cursor,
.note-detail-empty .aa-suggestions div.aa-cursor {
background: var(--hover-item-background-color);
color: var(--hover-item-text-color);
}

View File

@@ -78,10 +78,11 @@ button.btn.btn-success kbd {
* Icon buttons
*/
:root .icon-action:not(.global-menu-button) {
:root .icon-action:not(.global-menu-button),
:root .tn-tool-button {
width: var(--icon-button-size);
height: var(--icon-button-size);
border: unset;
border: unset !important;
border-radius: 8px;
padding: 0;
text-align: center;
@@ -89,7 +90,8 @@ button.btn.btn-success kbd {
}
/* The "x" icon button */
:root .icon-action.bx-x {
:root .icon-action.bx-x,
:root .tn-tool-button.bx-x {
--icon-button-hover-background: var(--tab-close-button-hover-background);
--icon-button-hover-color: var(--tab-close-button-hover-color);
--icon-button-size: 24px;
@@ -99,23 +101,28 @@ button.btn.btn-success kbd {
}
/* The icon */
:root .icon-action:not(.global-menu-button)::before {
:root .icon-action:not(.global-menu-button)::before,
:root .tn-tool-button::before {
display: block;
line-height: var(--icon-button-size);
font-size: calc(var(--icon-button-size) * var(--icon-button-icon-ratio));
}
:root .icon-action:not(.global-menu-button):hover,
:root .icon-action:not(.global-menu-button).show {
:root .icon-action:not(.global-menu-button).show,
:root .tn-tool-button:hover,
:root .tn-tool-button.show {
background: var(--icon-button-hover-background);
color: var(--icon-button-hover-color);
}
:root .icon-action:not(.global-menu-button):active::before {
:root .icon-action:not(.global-menu-button):active::before,
:root .tn-tool-button:active::before {
transform: scale(.85);
}
:root .icon-action:not(.global-menu-button):focus-visible {
:root .icon-action:not(.global-menu-button):focus-visible,
:root .tn-tool-button:focus-visible {
outline: 2px solid var(--input-focus-outline-color);
}
@@ -216,6 +223,7 @@ input::selection,
.input-group button,
.input-group a {
display: flex;
align-items: center;
--bs-border-width: 0;
--accented-background-color: transparent;
background: transparent;
@@ -228,19 +236,26 @@ input::selection,
.input-group button:hover,
.input-group a:hover {
--muted-text-color: var(--input-action-button-hover);
color: var(--input-action-button-hover);
}
.input-group button:focus-visible,
.input-group a:focus-visible {
box-shadow: unset;
outline: transparent;
border: transparent;
text-shadow: 0 0 3px var(--input-action-button-hover);
}
.input-group button:active {
background: transparent !important;
}
.input-group a.disabled {
opacity: .5;
/* Workaround to set the "background" property. */
--button-disabled-background-color: transparent;
--button-disabled-text-color: var(--input-action-button-color);
}
.input-group .input-clearer-button {
@@ -273,6 +288,7 @@ input::selection,
select,
select.form-select,
select.form-control,
.select-button.dropdown-toggle.btn {
outline: 3px solid transparent;
outline-offset: 6px;
@@ -285,6 +301,7 @@ select.form-select,
select:hover,
select.form-select:hover,
select.form-control:hover,
.select-button.dropdown-toggle.btn:hover {
background-color: var(--input-hover-background);
color: var(--input-hover-color);
@@ -297,6 +314,7 @@ button.select-button.dropdown-toggle.btn:active {
select:focus,
select.form-select:focus,
select.form-control:focus,
.select-button.dropdown-toggle.btn:focus {
box-shadow: unset;
outline: 3px solid var(--input-focus-outline-color);

View File

@@ -0,0 +1,11 @@
/* The container */
div.note-detail-empty {
max-width: 70%;
margin: 50px auto;
}
/* The search results list */
.note-detail-empty span.aa-dropdown-menu {
margin-top: 1em;
border: unset;
}

View File

@@ -126,3 +126,41 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
border: 0 !important;
border-top: 1px solid var(--main-border-color) !important;
}
/* Table caption */
.ck-content .table > figcaption {
background: var(--accented-background-color);
color: var(--main-text-color);
}
/*
* Search in text panel
*/
.find-replace-widget > div {
padding: 8px;
}
.find-replace-widget > div + div {
padding-top: 0;
}
div.find-replace-widget div.find-widget-found-wrapper {
min-width: 50px;
font-style: normal;
font-weight: normal;
}
/* The up / down buttons of the "Find in text" input */
.find-replace-widget .input-group button {
font-size: 1.3em;
}
.find-replace-widget .form-check {
padding-left: 0;
}
.find-replace-widget .form-check .form-check-input {
margin-left: 0;
}

View File

@@ -0,0 +1,41 @@
/*
* Owned attributes
*/
.attribute-list .add-new-attribute-button,
.attribute-list .save-attributes-button {
bottom: .3em;
}
.attribute-list .save-attributes-button {
right: 30px;
}
/*
* Similar notes
*/
:root .similar-notes-widget a {
color: var(--cmd-button-text-color);
background: var(--cmd-button-background-color);
}
:root .similar-notes-widget a:hover {
color: var(--cmd-button-hover-text-color);
background: var(--cmd-button-hover-background-color);
}
/*
* Note info
*/
.note-info-widget-table th {
opacity: .65;
font-weight: 500;
}
:root .note-info-widget-table button.calculate-button {
min-width: 0;
padding: 4px 10px !important;
font-size: .8em;
}

View File

@@ -227,6 +227,12 @@ body.layout-horizontal > .horizontal {
--hover-item-background-color: transparent;
}
#launcher-pane.horizontal .global-menu-button .global-menu-button-update-available {
right: -23px;
bottom: -22px;
transform: scale(0.85);
}
.tooltip .tooltip-arrow {
display: none;
}
@@ -945,6 +951,9 @@ body.mobile .note-title {
/*
* Menus
*
* Note: apply the "tn-dropdown-list" class for scrollable dropdown menus. Submenus are not
* supported when this class is used.
*/
.dropdown-menu {
@@ -953,6 +962,10 @@ body.mobile .note-title {
font-size: 0.9rem !important;
}
.dropdown-menu::-webkit-scrollbar-track {
background: var(--menu-background-color);
}
body.mobile .dropdown-menu {
backdrop-filter: var(--dropdown-backdrop-filter);
border-radius: var(--dropdown-border-radius);
@@ -976,6 +989,14 @@ body.desktop .dropdown-menu::before {
z-index: -1;
}
body.desktop .dropdown-menu.tn-dropdown-list {
backdrop-filter: var(--dropdown-backdrop-filter);
}
body.desktop .dropdown-menu.tn-dropdown-list::before {
display: none;
}
body.desktop .dropdown-submenu .dropdown-menu::before {
content: unset;
}
@@ -1237,25 +1258,6 @@ body .calendar-dropdown-widget .calendar-body a:hover {
background: transparent !important;
}
/* List body */
.jump-to-note-dialog .jump-to-note-results .aa-suggestions {
padding: 0;
}
/* List item */
.jump-to-note-dialog .aa-suggestions div {
border-radius: 6px;
padding: 6px 12px;
color: var(--menu-text-color);
cursor: default;
}
/* Selected list item */
.jump-to-note-dialog .aa-suggestions div.aa-cursor {
background: var(--hover-item-background-color);
color: var(--hover-item-text-color);
}
/*
* Recent changes list
*/
@@ -1657,7 +1659,17 @@ body .calendar-dropdown-widget .calendar-body a:hover {
padding-left: 12px;
}
.note-actions {
--menu-item-icon-vert-offset: -2.5px;
}
/* Promoted attributes */
.promoted-attribute-cell div.input-group {
margin: 1px 0;
}
/* Delete notes preview dialog */
.delete-notes-list .note-path {
padding-left: 8px;
}

View File

@@ -216,6 +216,7 @@ function deleteBranch(req: Request) {
function setPrefix(req: Request) {
const branchId = req.params.branchId;
//TriliumNextTODO: req.body arrives as string, so req.body.prefix will be undefined did the code below ever even work?
const prefix = utils.isEmptyOrWhitespace(req.body.prefix) ? null : req.body.prefix;
const branch = becca.getBranchOrThrow(branchId);

View File

@@ -6,7 +6,7 @@ import eventService from "./events.js";
import cls from "../services/cls.js";
import protectedSessionService from "../services/protected_session.js";
import log from "../services/log.js";
import { newEntityId, isString, unescapeHtml, quoteRegex, toMap } from "../services/utils.js";
import { newEntityId, unescapeHtml, quoteRegex, toMap } from "../services/utils.js";
import revisionService from "./revisions.js";
import request from "./request.js";
import path from "path";
@@ -734,13 +734,13 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment
note.setContent(newContent, { forceFrontendReload });
if (attachments?.length > 0) {
const existingAttachmentsByTitle = toMap(note.getAttachments({ includeContentLength: false }), "title");
const existingAttachmentsByTitle = toMap(note.getAttachments({ includeContentLength: false }), "title");
for (const { attachmentId, role, mime, title, position, content } of attachments) {
if (attachmentId || !(title in existingAttachmentsByTitle)) {
const existingAttachment = existingAttachmentsByTitle.get(title);
if (attachmentId || !existingAttachment) {
note.saveAttachment({ attachmentId, role, mime, title, content, position });
} else {
const existingAttachment = existingAttachmentsByTitle[title];
existingAttachment.role = role;
existingAttachment.mime = mime;
existingAttachment.position = position;
@@ -887,7 +887,7 @@ async function asyncPostProcessContent(note: BNote, content: string | Buffer) {
return;
}
if (note.hasStringContent() && !isString(content)) {
if (note.hasStringContent() && typeof content !== "string") {
content = content.toString();
}

View File

@@ -0,0 +1,13 @@
import { describe, it, expect } from "vitest";
import { processMindmapContent } from "./note_content_fulltext.js";
describe("processMindmapContent", () => {
it("supports empty JSON", () => {
expect(processMindmapContent("{}")).toEqual("");
});
it("supports blank text / invalid JSON", () => {
expect(processMindmapContent("")).toEqual("");
expect(processMindmapContent(`{ "node": " }`)).toEqual("");
});
});

View File

@@ -131,52 +131,7 @@ class NoteContentFulltextExp extends Expression {
content = content.replace(/&nbsp;/g, " ");
} else if (type === "mindMap" && mime === "application/json") {
let mindMapcontent = JSON.parse(content);
// Define interfaces for the JSON structure
interface MindmapNode {
id: string;
topic: string;
children: MindmapNode[]; // Recursive structure
direction?: number;
expanded?: boolean;
}
interface MindmapData {
nodedata: MindmapNode;
arrows: any[]; // If you know the structure, replace `any` with the correct type
summaries: any[];
direction: number;
theme: {
name: string;
type: string;
palette: string[];
cssvar: Record<string, string>; // Object with string keys and string values
};
}
// Recursive function to collect all topics
function collectTopics(node: MindmapNode): string[] {
// Collect the current node's topic
let topics = [node.topic];
// If the node has children, collect topics recursively
if (node.children && node.children.length > 0) {
for (const child of node.children) {
topics = topics.concat(collectTopics(child));
}
}
return topics;
}
// Start extracting from the root node
const topicsArray = collectTopics(mindMapcontent.nodedata);
// Combine topics into a single string
const topicsString = topicsArray.join(", ");
content = normalize(topicsString.toString());
content = processMindmapContent(content);
} else if (type === "canvas" && mime === "application/json") {
interface Element {
type: string;
@@ -215,4 +170,63 @@ class NoteContentFulltextExp extends Expression {
}
}
export function processMindmapContent(content: string) {
let mindMapcontent;
try {
mindMapcontent = JSON.parse(content);
} catch (e) {
return "";
}
// Define interfaces for the JSON structure
interface MindmapNode {
id: string;
topic: string;
children: MindmapNode[]; // Recursive structure
direction?: number;
expanded?: boolean;
}
interface MindmapData {
nodedata: MindmapNode;
arrows: any[]; // If you know the structure, replace `any` with the correct type
summaries: any[];
direction: number;
theme: {
name: string;
type: string;
palette: string[];
cssvar: Record<string, string>; // Object with string keys and string values
};
}
// Recursive function to collect all topics
function collectTopics(node?: MindmapNode): string[] {
if (!node) {
return [];
}
// Collect the current node's topic
let topics = [node.topic];
// If the node has children, collect topics recursively
if (node.children && node.children.length > 0) {
for (const child of node.children) {
topics = topics.concat(collectTopics(child));
}
}
return topics;
}
// Start extracting from the root node
const topicsArray = collectTopics(mindMapcontent.nodedata);
// Combine topics into a single string
const topicsString = topicsArray.join(", ");
return normalize(topicsString.toString());
}
export default NoteContentFulltextExp;

View File

@@ -1,61 +0,0 @@
import { expect, describe, it } from "vitest";
import { formatDownloadTitle } from "./utils.js";
const testCases: [fnValue: Parameters<typeof formatDownloadTitle>, expectedValue: ReturnType<typeof formatDownloadTitle>][] = [
// empty fileName tests
[["", "text", ""], "untitled.html"],
[["", "canvas", ""], "untitled.json"],
[["", null, ""], "untitled"],
// json extension from type tests
[["test_file", "canvas", ""], "test_file.json"],
[["test_file", "relationMap", ""], "test_file.json"],
[["test_file", "search", ""], "test_file.json"],
// extension based on mime type
[["test_file", null, "text/csv"], "test_file.csv"],
[["test_file_wo_ext", "image", "image/svg+xml"], "test_file_wo_ext.svg"],
[["test_file_wo_ext", "file", "application/json"], "test_file_wo_ext.json"],
[["test_file_w_fake_ext.ext", "image", "image/svg+xml"], "test_file_w_fake_ext.ext.svg"],
[["test_file_w_correct_ext.svg", "image", "image/svg+xml"], "test_file_w_correct_ext.svg"],
[["test_file_w_correct_ext.svgz", "image", "image/svg+xml"], "test_file_w_correct_ext.svgz"],
[["test_file.zip", "file", "application/zip"], "test_file.zip"],
[["test_file", "file", "application/zip"], "test_file.zip"],
// application/octet-stream tests
[["test_file", "file", "application/octet-stream"], "test_file"],
[["test_file.zip", "file", "application/octet-stream"], "test_file.zip"],
[["test_file.unknown", null, "application/octet-stream"], "test_file.unknown"],
// sanitized filename tests
[["test/file", null, "application/octet-stream"], "testfile"],
[["test:file.zip", "file", "application/zip"], "testfile.zip"],
[[":::", "file", "application/zip"], ".zip"],
[[":::a", "file", "application/zip"], "a.zip"]
];
describe("utils/formatDownloadTitle unit tests", () => {
testCases.forEach((testCase) => {
return it(`With args '${JSON.stringify(testCase[0])}' it should return '${testCase[1]}'`, () => {
const [value, expected] = testCase;
const actual = formatDownloadTitle(...value);
expect(actual).toStrictEqual(expected);
});
});
});

630
src/services/utils.spec.ts Normal file
View File

@@ -0,0 +1,630 @@
import { describe, it, expect } from "vitest";
import utils from "./utils.js";
type TestCase<T extends (...args: any) => any> = [desc: string, fnParams: Parameters<T>, expected: ReturnType<T>];
describe("#newEntityId", () => {
it("should return a string with a length of 12", () => {
const result = utils.newEntityId();
expect(result).toBeTypeOf("string");
expect(result).toHaveLength(12);
});
});
describe("#randomString", () => {
it("should return a string with a length as per argument", () => {
const stringLength = 5;
const result = utils.randomString(stringLength);
expect(result).toBeTypeOf("string");
expect(result).toHaveLength(stringLength);
});
});
// TriliumNextTODO: should use mocks and assert that functions get called
describe("#randomSecureToken", () => {
// base64 -> 4 * (bytes/3) length -> if padding and rounding up is ignored for simplicity
// https://stackoverflow.com/a/13378842
const byteToBase64Length = (bytes: number) => 4 * (bytes / 3);
it("should return a string and use 32 bytes by default", () => {
const result = utils.randomSecureToken();
expect(result).toBeTypeOf("string");
expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(32));
});
it("should return a string and use passed byte length", () => {
const bytes = 16;
const result = utils.randomSecureToken(bytes);
expect(result).toBeTypeOf("string");
expect(result.length).toBeGreaterThanOrEqual(byteToBase64Length(bytes));
expect(result.length).toBeLessThan(44); // default argument uses 32 bytes -> which translates to 44 base64 legal chars
});
});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#md5", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hashedBlobId", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#toBase64", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#fromBase64", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hmac", () => {});
// TriliumNextTODO: should use mocks and assert that functions get called
describe.todo("#hash", () => {});
describe("#isEmptyOrWhitespace", () => {
const testCases: TestCase<typeof utils.isEmptyOrWhitespace>[] = [
["w/ 'null' it should return true", [null], true],
["w/ 'null' it should return true", [null], true],
["w/ undefined it should return true", [undefined], true],
["w/ empty string '' it should return true", [""], true],
["w/ single whitespace string ' ' it should return true", [" "], true],
["w/ multiple whitespace string ' ' it should return true", [" "], true],
["w/ non-empty string ' t ' it should return false", [" t "], false],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.isEmptyOrWhitespace(...fnParams);
expect(result).toStrictEqual(expected);
})
})
});
describe("#sanitizeSqlIdentifier", () => {
const testCases: TestCase<typeof utils.sanitizeSqlIdentifier>[] = [
["w/ 'test' it should not strip anything", ["test"], "test"],
["w/ 'test123' it should not strip anything", ["test123"], "test123"],
["w/ 'tEst_TeSt' it should not strip anything", ["tEst_TeSt"], "tEst_TeSt"],
["w/ 'test_test' it should not strip '_'", ["test_test"], "test_test"],
["w/ 'test-' it should strip the '-'", ["test-"], "test"],
["w/ 'test-test' it should strip the '-'", ["test-test"], "testtest"],
["w/ 'test; --test' it should strip the '; --'", ["test; --test"], "testtest"],
["w/ 'test test' it should strip the ' '", ["test test"], "testtest"],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.sanitizeSqlIdentifier(...fnParams);
expect(result).toStrictEqual(expected);
})
});
});
describe("#escapeHtml", () => {
it("should re-export 'escape-html' npm module as escapeHtml", () => {
expect(utils.escapeHtml).toBeTypeOf("function");
});
});
describe("#unescapeHtml", () => {
it("should re-export 'unescape' npm module as unescapeHtml", () => {
expect(utils.unescapeHtml).toBeTypeOf("function");
});
});
describe("#toObject", () => {
it("should return an object with keys and value being set from the supplied Function", () => {
type TestListEntry = { testPropA: string, testPropB: string };
type TestListFn = (testListEntry: TestListEntry) => [string, string];
const testList: [TestListEntry, TestListEntry] = [{ testPropA: "keyA", testPropB: "valueA" }, { testPropA: "keyB", testPropB: "valueB" }];
const fn: TestListFn = (testListEntry: TestListEntry) => [testListEntry.testPropA + "_fn", testListEntry.testPropB + "_fn"];
const result = utils.toObject(testList, fn);
expect(result).toStrictEqual({
"keyA_fn": "valueA_fn",
"keyB_fn": "valueB_fn"
});
});
});
describe("#stripTags", () => {
//prettier-ignore
const htmlWithNewlines =
`<p>abc
def</p>
<p>ghi</p>`;
const testCases: TestCase<typeof utils.stripTags>[] = [
["should strip all tags and only return the content, leaving new lines and spaces in tact", [htmlWithNewlines], "abc\ndef\nghi"],
//TriliumNextTODO: should this actually insert a space between content to prevent concatenated text?
["should strip all tags and only return the content", ["<h1>abc</h1><p>def</p>"], "abcdef"],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.stripTags(...fnParams);
expect(result).toStrictEqual(expected);
})
});
});
describe.todo("#escapeRegExp", () => {});
describe.todo("#crash", () => {});
describe("#getContentDisposition", () => {
const defaultFallBackDisposition = `file; filename="file"; filename*=UTF-8''file`;
const testCases: TestCase<typeof utils.getContentDisposition>[] = [
[
"when passed filename is empty, it should fallback to default value 'file'",
[" "],
defaultFallBackDisposition
],
[
"when passed filename '..' would cause sanitized filename to be empty, it should fallback to default value 'file'",
[".."],
defaultFallBackDisposition
],
// COM1 is a Windows specific "illegal filename" that sanitize filename strips away
[
"when passed filename 'COM1' would cause sanitized filename to be empty, it should fallback to default value 'file'",
["COM1"],
defaultFallBackDisposition
],
[
"sanitized passed filename should be returned URIEncoded",
["test file.csv"],
`file; filename="test%20file.csv"; filename*=UTF-8''test%20file.csv`
]
]
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.getContentDisposition(...fnParams);
expect(result).toStrictEqual(expected);
})
});
});
describe("#isStringNote", () => {
const testCases: TestCase<typeof utils.isStringNote>[] = [
[
"w/ 'undefined' note type, but a string mime type, it should return true",
[undefined, "application/javascript"],
true
],
[
"w/ non-string note type, it should return false",
["image", "image/jpeg"],
false
],
[
"w/ string note type (text), it should return true",
["text", "text/html"],
true
],
[
"w/ string note type (code), it should return true",
["code", "application/json"],
true
],
[
"w/ non-string note type (file), but string mime type, it should return true",
["file", "application/json"],
true
],
[
"w/ non-string note type (file), but mime type starting with 'text/', it should return true",
["file", "text/html"],
true
],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.isStringNote(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#quoteRegex", () => {});
describe.todo("#replaceAll", () => {});
describe("#removeTextFileExtension", () => {
const testCases: TestCase<typeof utils.removeTextFileExtension>[] = [
["w/ 'test.md' it should strip '.md'", ["test.md"], "test"],
["w/ 'test.markdown' it should strip '.markdown'", ["test.markdown"], "test"],
["w/ 'test.html' it should strip '.html'", ["test.html"], "test"],
["w/ 'test.htm' it should strip '.htm'", ["test.htm"], "test"],
["w/ 'test.zip' it should NOT strip '.zip'", ["test.zip"], "test.zip"],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.removeTextFileExtension(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#getNoteTitle", () => {
const testCases: TestCase<typeof utils.getNoteTitle>[] = [
[
"when file has no spaces, and no special file extension, it should return the filename unaltered",
["test.json", true, undefined],
"test.json"
],
[
"when replaceUnderscoresWithSpaces is false, it should keep the underscores in the title",
["test_file.json", false, undefined],
"test_file.json"
],
[
"when replaceUnderscoresWithSpaces is true, it should replace the underscores in the title",
["test_file.json", true, undefined],
"test file.json"
],
[
"when filePath ends with one of the extra handled endings (.md), it should strip the file extension from the title",
["test_file.md", false, undefined],
"test_file"
],
[
"when filePath ends with one of the extra handled endings (.md) and replaceUnderscoresWithSpaces is true, it should strip the file extension from the title and replace underscores",
["test_file.md", true, undefined],
"test file"
],
[
"when filepath contains a full path, it should only return the basename of the file",
["Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.zip", true, undefined],
"template.zip"
],
[
"when filepath contains a full path and has extra handled ending (.html), it should only return the basename of the file and strip the file extension",
["Trilium Demo/Scripting examples/Statistics/Most cloned notes/template.html", true, undefined],
"template"
],
[
"when a noteMeta object is passed, it should use the title from the noteMeta, if present",
//@ts-expect-error - passing in incomplete noteMeta - but we only care about the title prop here
["test_file.md", true, { title: "some other title"}],
"some other title"
],
[
"when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed",
//@ts-expect-error - passing in incomplete noteMeta - but we only care about the title prop here
["test_file.md", true, { title: ""}],
"test file"
],
[
"when a noteMeta object is passed, but the title prop is empty, it should try to handle the filename as if no noteMeta was passed",
//@ts-expect-error - passing in incomplete noteMeta - but we only care about the title prop here
["test_file.json", false, { title: " "}],
"test_file.json"
]
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.getNoteTitle(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#timeLimit", () => {
it("when promise execution does NOT exceed timeout, it should resolve with promises' value", async () => {
const resolvedValue = `resolved: ${new Date().toISOString()}`;
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res(resolvedValue);
}, 200);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 1_000)).resolves.toBe(resolvedValue);
});
it("when promise execution rejects within timeout, it should return the original promises' rejected value, not the custom set one", async () => {
const rejectedValue = `rejected: ${new Date().toISOString()}`;
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
//return res("resolved");
rej(rejectedValue);
}, 100);
});
await expect(utils.timeLimit(testPromise, 200, "Custom Error")).rejects.toThrow(rejectedValue)
});
it("when promise execution exceeds the set timeout, and 'errorMessage' is NOT set, it should reject the promise and display default error message", async () => {
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res("resolved");
}, 500);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 200)).rejects.toThrow(`Process exceeded time limit 200`)
});
it("when promise execution exceeds the set timeout, and 'errorMessage' is set, it should reject the promise and display set error message", async () => {
const customErrorMsg = "Custom Error";
const testPromise = new Promise((res, rej) => {
setTimeout(() => {
return res("resolved");
}, 500);
//rej("rejected!");
});
await expect(utils.timeLimit(testPromise, 200, customErrorMsg)).rejects.toThrow(customErrorMsg)
});
// TriliumNextTODO: since TS avoids this from ever happening do we need this check?
it("when the passed promise is not a promise but 'undefined', it should return 'undefined'", async () => {
//@ts-expect-error - passing in illegal type 'undefined'
expect(utils.timeLimit(undefined, 200)).toBe(undefined)
});
// TriliumNextTODO: since TS avoids this from ever happening do we need this check?
it("when the passed promise is not a promise, it should return the passed value", async () => {
//@ts-expect-error - passing in illegal type 'object'
expect(utils.timeLimit({test: 1}, 200)).toStrictEqual({test: 1})
});
});
describe("#deferred", () => {
it("should return a promise", () => {
const result = utils.deferred();
expect(result).toBeInstanceOf(Promise)
})
// TriliumNextTODO: Add further tests!
});
describe("#removeDiacritic", () => {
const testCases: TestCase<typeof utils.removeDiacritic>[] = [
["w/ 'Äpfel' it should replace the 'Ä'", ["Äpfel"], "Apfel"],
["w/ 'Été' it should replace the 'É' and 'é'", ["Été"], "Ete"],
["w/ 'Fête' it should replace the 'ê'", ["Fête"], "Fete"],
["w/ 'Αλφαβήτα' it should replace the 'ή'", ["Αλφαβήτα"], "Αλφαβητα"],
["w/ '' (empty string) it should return empty string", [""], ""],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.removeDiacritic(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#normalize", () => {
const testCases: TestCase<typeof utils.normalize>[] = [
["w/ 'Äpfel' it should replace the 'Ä' and return lowercased", ["Äpfel"], "apfel"],
["w/ 'Été' it should replace the 'É' and 'é' and return lowercased", ["Été"], "ete"],
["w/ 'FêTe' it should replace the 'ê' and return lowercased", ["FêTe"], "fete"],
["w/ 'ΑλΦαβήΤα' it should replace the 'ή' and return lowercased", ["ΑλΦαβήΤα"], "αλφαβητα"],
["w/ '' (empty string) it should return empty string", [""], ""],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.normalize(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe("#toMap", () => {
it("should return an instace of Map, with the correct size and keys, when supplied with a list and existing keys", () => {
const testList = [{title: "test", propA: "text", propB: 123 }, {title: "test2", propA: "prop2", propB: 456 }];
const result = utils.toMap(testList, "title");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(Array.from(result.keys())).toStrictEqual(["test", "test2"]);
});
it("should return an instace of Map, with an empty size, when the supplied list does not contain the supplied key", () => {
const testList = [{title: "test", propA: "text", propB: 123 }, {title: "test2", propA: "prop2", propB: 456 }];
//@ts-expect-error - key is non-existing on supplied list type
const result = utils.toMap(testList, "nonExistingKey");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it.fails("should correctly handle duplicate keys? (currently it will overwrite the entry, so returned size will be 1 instead of 2)", () => {
const testList = [{title: "testDupeTitle", propA: "text", propB: 123 }, {title: "testDupeTitle", propA: "prop2", propB: 456 }];
const result = utils.toMap(testList, "title");
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
});
});
describe("#envToBoolean", () => {
const testCases: TestCase<typeof utils.envToBoolean>[] = [
["w/ 'true' it should return boolean 'true'", ["true"], true],
["w/ 'True' it should return boolean 'true'", ["True"], true],
["w/ 'TRUE' it should return boolean 'true'", ["TRUE"], true],
["w/ 'true ' it should return boolean 'true'", ["true "], true],
["w/ 'false' it should return boolean 'false'", ["false"], false],
["w/ 'False' it should return boolean 'false'", ["False"], false],
["w/ 'FALSE' it should return boolean 'false'", ["FALSE"], false],
["w/ 'false ' it should return boolean 'false'", ["false "], false],
["w/ 'whatever' (non-boolean string) it should return undefined", ["whatever"], undefined],
["w/ '-' (non-boolean string) it should return undefined", ["-"], undefined],
["w/ '' (empty string) it should return undefined", [""], undefined],
["w/ ' ' (white space string) it should return undefined", [" "], undefined],
["w/ undefined it should return undefined", [undefined], undefined],
//@ts-expect-error - pass wrong type as param
["w/ number 1 it should return undefined", [1], undefined],
];
testCases.forEach(testCase => {
const [desc, fnParams, expected] = testCase;
it(desc, () => {
const result = utils.envToBoolean(...fnParams);
expect(result).toStrictEqual(expected);
});
});
});
describe.todo("#getResourceDir", () => {});
describe("#isElectron", () => {
it("should export a boolean", () => {
expect(utils.isElectron).toBeTypeOf("boolean");
});
});
describe("#isMac", () => {
it("should export a boolean", () => {
expect(utils.isMac).toBeTypeOf("boolean");
});
});
describe("#isWindows", () => {
it("should export a boolean", () => {
expect(utils.isWindows).toBeTypeOf("boolean");
});
});
describe("#isDev", () => {
it("should export a boolean", () => {
expect(utils.isDev).toBeTypeOf("boolean");
});
});
describe("#formatDownloadTitle", () => {
//prettier-ignore
const testCases: [fnValue: Parameters<typeof utils.formatDownloadTitle>, expectedValue: ReturnType<typeof utils.formatDownloadTitle>][] = [
// empty fileName tests
[
["", "text", ""],
"untitled.html"
],
[
["", "canvas", ""],
"untitled.json"
],
[
["", null, ""],
"untitled"
],
// json extension from type tests
[
["test_file", "canvas", ""],
"test_file.json"
],
[
["test_file", "relationMap", ""],
"test_file.json"
],
[
["test_file", "search", ""],
"test_file.json"
],
// extension based on mime type
[
["test_file", null, "text/csv"],
"test_file.csv"
],
[
["test_file_wo_ext", "image", "image/svg+xml"],
"test_file_wo_ext.svg"
],
[
["test_file_wo_ext", "file", "application/json"],
"test_file_wo_ext.json"
],
[
["test_file_w_fake_ext.ext", "image", "image/svg+xml"],
"test_file_w_fake_ext.ext.svg"
],
[
["test_file_w_correct_ext.svg", "image", "image/svg+xml"],
"test_file_w_correct_ext.svg"
],
[
["test_file_w_correct_ext.svgz", "image", "image/svg+xml"],
"test_file_w_correct_ext.svgz"
],
[
["test_file.zip", "file", "application/zip"],
"test_file.zip"
],
[
["test_file", "file", "application/zip"],
"test_file.zip"
],
// application/octet-stream tests
[
["test_file", "file", "application/octet-stream"],
"test_file"
],
[
["test_file.zip", "file", "application/octet-stream"],
"test_file.zip"
],
[
["test_file.unknown", null, "application/octet-stream"],
"test_file.unknown"
],
// sanitized filename tests
[
["test/file", null, "application/octet-stream"],
"testfile"
],
[
["test:file.zip", "file", "application/zip"],
"testfile.zip"
],
[
[":::", "file", "application/zip"],
".zip"
],
[
[":::a", "file", "application/zip"],
"a.zip"
]
];
testCases.forEach((testCase) => {
const [fnParams, expected] = testCase;
return it(`With args '${JSON.stringify(fnParams)}', it should return '${expected}'`, () => {
const actual = utils.formatDownloadTitle(...fnParams);
expect(actual).toStrictEqual(expected);
});
});
});

View File

@@ -9,6 +9,7 @@ import mimeTypes from "mime-types";
import path from "path";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import type NoteMeta from "./meta/note_meta.js";
const randtoken = generator({ source: "crypto" });
@@ -71,21 +72,18 @@ export function hash(text: string) {
return crypto.createHash("sha1").update(text).digest("base64");
}
export function isEmptyOrWhitespace(str: string) {
return str === null || str.match(/^ *$/) !== null;
export function isEmptyOrWhitespace(str: string | null | undefined) {
if (!str) return true;
return str.match(/^ *$/) !== null;
}
export function sanitizeSqlIdentifier(str: string) {
return str.replace(/[^A-Za-z0-9_]/g, "");
}
export function escapeHtml(str: string) {
return escape(str);
}
export const escapeHtml = escape;
export function unescapeHtml(str: string) {
return unescape(str);
}
export const unescapeHtml = unescape;
export function toObject<T, K extends string | number | symbol, V>(array: T[], fn: (item: T) => [K, V]): Record<K, V> {
const obj: Record<K, V> = {} as Record<K, V>; // TODO: unsafe?
@@ -103,29 +101,6 @@ export function stripTags(text: string) {
return text.replace(/<(?:.|\n)*?>/gm, "");
}
export function union<T extends string | number | symbol>(a: T[], b: T[]): T[] {
const obj: Record<T, T> = {} as Record<T, T>; // TODO: unsafe?
for (let i = a.length - 1; i >= 0; i--) {
obj[a[i]] = a[i];
}
for (let i = b.length - 1; i >= 0; i--) {
obj[b[i]] = b[i];
}
const res: T[] = [];
for (const k in obj) {
if (obj.hasOwnProperty(k)) {
// <-- optional
res.push(obj[k]);
}
}
return res;
}
export function escapeRegExp(str: string) {
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
}
@@ -138,27 +113,18 @@ export async function crash() {
}
}
export function sanitizeFilenameForHeader(filename: string) {
let sanitizedFilename = sanitize(filename);
if (sanitizedFilename.trim().length === 0) {
sanitizedFilename = "file";
}
return encodeURIComponent(sanitizedFilename);
}
export function getContentDisposition(filename: string) {
const sanitizedFilename = sanitizeFilenameForHeader(filename);
return `file; filename="${sanitizedFilename}"; filename*=UTF-8''${sanitizedFilename}`;
const sanitizedFilename = sanitize(filename).trim() || "file";
const uriEncodedFilename = encodeURIComponent(sanitizedFilename);
return `file; filename="${uriEncodedFilename}"; filename*=UTF-8''${uriEncodedFilename}`;
}
// render and book are string note in the sense that they are expected to contain empty string
const STRING_NOTE_TYPES = new Set(["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas"]);
const STRING_MIME_TYPES = new Set(["application/javascript", "application/x-javascript", "application/json", "application/x-sql", "image/svg+xml"]);
export function isStringNote(type: string | undefined, mime: string) {
// render and book are string note in the sense that they are expected to contain empty string
return (type && ["text", "code", "relationMap", "search", "render", "book", "mermaid", "canvas"].includes(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime);
return (type && STRING_NOTE_TYPES.has(type)) || mime.startsWith("text/") || STRING_MIME_TYPES.has(mime);
}
export function quoteRegex(url: string) {
@@ -211,26 +177,23 @@ export function removeTextFileExtension(filePath: string) {
}
}
export function getNoteTitle(filePath: string, replaceUnderscoresWithSpaces: boolean, noteMeta?: { title?: string }) {
if (noteMeta?.title) {
return noteMeta.title;
} else {
const basename = path.basename(removeTextFileExtension(filePath));
if (replaceUnderscoresWithSpaces) {
return basename.replace(/_/g, " ").trim();
}
return basename;
}
export function getNoteTitle(filePath: string, replaceUnderscoresWithSpaces: boolean, noteMeta?: NoteMeta) {
const trimmedNoteMeta = noteMeta?.title?.trim();
if (trimmedNoteMeta) return trimmedNoteMeta;
const basename = path.basename(removeTextFileExtension(filePath));
return replaceUnderscoresWithSpaces ? basename.replace(/_/g, " ").trim() : basename;
}
export function timeLimit<T>(promise: Promise<T>, limitMs: number, errorMessage?: string): Promise<T> {
// TriliumNextTODO: since TS avoids this from ever happening do we need this check?
if (!promise || !promise.then) {
// it's not actually a promise
return promise;
}
// better stack trace if created outside of promise
const error = new Error(errorMessage || `Process exceeded time limit ${limitMs}`);
const errorTimeLimit = new Error(errorMessage || `Process exceeded time limit ${limitMs}`);
return new Promise((res, rej) => {
let resolved = false;
@@ -245,7 +208,7 @@ export function timeLimit<T>(promise: Promise<T>, limitMs: number, errorMessage?
setTimeout(() => {
if (!resolved) {
rej(error);
rej(errorTimeLimit);
}
}, limitMs);
});
@@ -284,20 +247,18 @@ export function normalize(str: string) {
return removeDiacritic(str).toLowerCase();
}
export function toMap<T extends Record<string, any>>(list: T[], key: keyof T): Record<string, T> {
const map: Record<string, T> = {};
export function toMap<T extends Record<string, any>>(list: T[], key: keyof T) {
const map = new Map<string, T>();
for (const el of list) {
map[el[key]] = el;
const keyForMap = el[key];
if (!keyForMap) continue;
// TriliumNextTODO: do we need to handle the case when the same key is used?
// currently this will overwrite the existing entry in the map
map.set(keyForMap, el);
}
return map;
}
export function isString(x: any) {
return Object.prototype.toString.call(x) === "[object String]";
}
// try to turn 'true' and 'false' strings from process.env variables into boolean values or undefined
export function envToBoolean(val: string | undefined) {
if (val === undefined || typeof val !== "string") return undefined;
@@ -317,48 +278,87 @@ export function envToBoolean(val: string | undefined) {
* @returns the resource dir.
*/
export function getResourceDir() {
if (isElectron && !isDev) {
return process.resourcesPath;
} else {
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
if (isElectron && !isDev) return process.resourcesPath;
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
}
// TODO: Deduplicate with src/public/app/services/utils.ts
/**
* Compares two semantic version strings.
* Returns:
* 1 if v1 is greater than v2
* 0 if v1 is equal to v2
* -1 if v1 is less than v2
*
* @param v1 First version string
* @param v2 Second version string
* @returns
*/
function compareVersions(v1: string, v2: string): number {
// Remove 'v' prefix and everything after dash if present
v1 = v1.replace(/^v/, "").split("-")[0];
v2 = v2.replace(/^v/, "").split("-")[0];
const v1parts = v1.split(".").map(Number);
const v2parts = v2.split(".").map(Number);
// Pad shorter version with zeros
while (v1parts.length < 3) v1parts.push(0);
while (v2parts.length < 3) v2parts.push(0);
// Compare major version
if (v1parts[0] !== v2parts[0]) {
return v1parts[0] > v2parts[0] ? 1 : -1;
}
// Compare minor version
if (v1parts[1] !== v2parts[1]) {
return v1parts[1] > v2parts[1] ? 1 : -1;
}
// Compare patch version
if (v1parts[2] !== v2parts[2]) {
return v1parts[2] > v2parts[2] ? 1 : -1;
}
return 0;
}
export default {
randomSecureToken,
randomString,
compareVersions,
crash,
deferred,
envToBoolean,
escapeHtml,
escapeRegExp,
formatDownloadTitle,
fromBase64,
getContentDisposition,
getNoteTitle,
getResourceDir,
hash,
hashedBlobId,
hmac,
isDev,
isElectron,
isEmptyOrWhitespace,
isMac,
isStringNote,
isWindows,
md5,
newEntityId,
toBase64,
fromBase64,
hmac,
isElectron,
hash,
isEmptyOrWhitespace,
sanitizeSqlIdentifier,
escapeHtml,
unescapeHtml,
toObject,
stripTags,
union,
escapeRegExp,
crash,
getContentDisposition,
isStringNote,
quoteRegex,
replaceAll,
getNoteTitle,
removeTextFileExtension,
formatDownloadTitle,
timeLimit,
deferred,
removeDiacritic,
normalize,
hashedBlobId,
quoteRegex,
randomSecureToken,
randomString,
removeDiacritic,
removeTextFileExtension,
replaceAll,
sanitizeSqlIdentifier,
stripTags,
timeLimit,
toBase64,
toMap,
isString,
getResourceDir,
isMac,
isWindows,
envToBoolean
toObject,
unescapeHtml
};

View File

@@ -56,18 +56,26 @@
<div id="setup-type" data-bind="visible: step() == 'setup-type'" style="margin-top: 20px;">
<form data-bind="submit: selectSetupType">
<div class="radio" style="margin-bottom: 15px;">
<label><input type="radio" name="setup-type" value="new-document" data-bind="checked: setupType"">
<%= t("setup.new-document") %></label>
</div>
<div class="radio" style="margin-bottom: 15px;">
<label><input type="radio" name="setup-type" value="sync-from-desktop" data-bind="checked: setupType">
<%= t("setup.sync-from-desktop") %></label>
</div>
<div class="radio" style="margin-bottom: 15px;">
<label><input type="radio" name="setup-type" value="sync-from-server" data-bind="checked: setupType"">
<%= t("setup.sync-from-server") %></label>
</div>
<div class="radio" style="margin-bottom: 15px;">
<label class="tn-radio">
<input type="radio" name="setup-type" value="new-document" data-bind="checked: setupType">
<%= t("setup.new-document") %>
</label>
</div>
<div class="radio" style="margin-bottom: 15px;">
<label class="tn-radio">
<input type="radio" name="setup-type" value="sync-from-desktop" data-bind="checked: setupType">
<%= t("setup.sync-from-desktop") %>
</label>
</div>
<div class="radio" style="margin-bottom: 15px;">
<label class="tn-radio">
<input type="radio" name="setup-type" value="sync-from-server" data-bind="checked: setupType">
<%= t("setup.sync-from-server") %>
</label>
</div>
<button type="submit" data-bind="disable: !setupTypeSelected()" class="btn btn-primary"><%= t("setup.next") %></button>
</form>

View File

@@ -12,7 +12,8 @@ import ws from "./services/ws.js";
import utils from "./services/utils.js";
import port from "./services/port.js";
import host from "./services/host.js";
import semver from "semver";
const MINIMUM_NODE_VERSION = "20.0.0";
// setup basic error handling even before requiring dependencies, since those can produce errors as well
@@ -32,8 +33,12 @@ function exit() {
process.on("SIGINT", exit);
process.on("SIGTERM", exit);
if (!semver.satisfies(process.version, ">=10.5.0")) {
console.error("Trilium only supports node.js 10.5 and later");
if (utils.compareVersions(process.versions.node, MINIMUM_NODE_VERSION) < 0) {
console.error();
console.error(`The Trilium server requires Node.js ${MINIMUM_NODE_VERSION} and later in order to start.\n`);
console.error(`\tCurrent version:\t${process.versions.node}`);
console.error(`\tExpected version:\t${MINIMUM_NODE_VERSION}`);
console.error();
process.exit(1);
}