Compare commits

..

3 Commits

Author SHA1 Message Date
perf3ct
72dbf7f31d feat(docs): try a "smarter" way of fetching the assets path
debug(docs): try something new for swagger ui

debug(docs): try something else for swagger ui

debug(docs): try something else for swagger ui, again

debug(docs): try something else for swagger ui, again again

Revert "debug(docs): try something else for swagger ui, again again"

This reverts commit 0f17a076282611c1305dc073c6fd513b6a0acbcc.

Revert "debug(docs): try something else for swagger ui, again"

This reverts commit dd9970b0b013ad940b9041979ea97a0a330aa500.

Revert "debug(docs): try something else for swagger ui"

This reverts commit ffbedbb60b80458fb094a7545c69a7b2c6691b35.

Revert "debug(docs): try something new for swagger ui"

This reverts commit 944f1dad2e1322a991563d1085ca6fb86b098da6.

asdf

asdfasdf

asdfasdfasdf
2025-07-11 20:29:05 +00:00
perf3ct
755254d037 feat(tests): create tests for swagger ui 2025-07-09 17:50:43 +00:00
perf3ct
7963f03e71 fix(docs): resolve incorrect dir path for swaggerui 2025-07-09 17:29:34 +00:00
23 changed files with 727 additions and 663 deletions

View File

@@ -35,7 +35,7 @@
"draggabilly": "3.0.0",
"force-graph": "1.50.1",
"globals": "16.3.0",
"i18next": "25.3.2",
"i18next": "25.3.1",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery-hotkeys": "0.2.2",

View File

@@ -81,7 +81,7 @@ let rootCreationDate: Date | undefined;
async function getNoteTypeItems(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [
...getBlankNoteTypes(command),
...await getBuiltInTemplates(t("note_types.collections"), command, true),
...await getBuiltInTemplates("Collections", command, true),
...await getBuiltInTemplates(null, command, false),
...await getUserTemplates(command)
];
@@ -89,28 +89,26 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
return items;
}
function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandNames>[] {
return NOTE_TYPES
.filter((nt) => !nt.reserved && nt.type !== "book")
.map((nt) => {
const menuItem: MenuCommandItem<TreeCommandNames> = {
title: nt.title,
command,
type: nt.type,
uiIcon: "bx " + nt.icon,
badges: []
}
function getBlankNoteTypes(command): MenuItem<TreeCommandNames>[] {
return NOTE_TYPES.filter((nt) => !nt.reserved).map((nt) => {
const menuItem: MenuCommandItem<TreeCommandNames> = {
title: nt.title,
command,
type: nt.type,
uiIcon: "bx " + nt.icon,
badges: []
}
if (nt.isNew) {
menuItem.badges?.push(NEW_BADGE);
}
if (nt.isNew) {
menuItem.badges?.push(NEW_BADGE);
}
if (nt.isBeta) {
menuItem.badges?.push(BETA_BADGE);
}
if (nt.isBeta) {
menuItem.badges?.push(BETA_BADGE);
}
return menuItem;
});
return menuItem;
});
}
async function getUserTemplates(command?: TreeCommandNames) {
@@ -154,15 +152,15 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
return [];
}
const items: MenuItem<TreeCommandNames>[] = [];
const items: MenuItem<TreeCommandNames>[] = [
SEPARATOR
];
if (title) {
items.push({
title: title,
enabled: false,
uiIcon: "bx bx-empty"
enabled: false
});
} else {
items.push(SEPARATOR);
}
for (const templateNote of childNotes) {

View File

@@ -337,11 +337,6 @@ button kbd {
--bs-dropdown-link-active-bg: var(--active-item-background-color) !important;
}
.dropdown-menu .dropdown-divider {
break-before: avoid;
break-after: avoid;
}
body.desktop .dropdown-menu {
border: 1px solid var(--dropdown-border-color);
box-shadow: 0px 10px 20px rgba(0, 0, 0, var(--dropdown-shadow-opacity));

View File

@@ -754,7 +754,7 @@
"expand_all_children": "展开所有子项",
"collapse": "折叠",
"expand": "展开",
"book_properties": "",
"book_properties": "书籍属性",
"invalid_view_type": "无效的查看类型 '{{type}}'",
"calendar": "日历"
},

View File

@@ -750,7 +750,7 @@
"expand_all_children": "Unternotizen ausklappen",
"collapse": "Einklappen",
"expand": "Ausklappen",
"book_properties": "",
"book_properties": "Bucheigenschaften",
"invalid_view_type": "Ungültiger Ansichtstyp „{{type}}“",
"calendar": "Kalender"
},

View File

@@ -758,7 +758,7 @@
"expand_all_children": "Expand all children",
"collapse": "Collapse",
"expand": "Expand",
"book_properties": "Collection Properties",
"book_properties": "Book Properties",
"invalid_view_type": "Invalid view type '{{type}}'",
"calendar": "Calendar",
"table": "Table",
@@ -962,7 +962,7 @@
"no_attachments": "This note has no attachments."
},
"book": {
"no_children_help": "This collection doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details."
"no_children_help": "This note of type Book doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details."
},
"editable_code": {
"placeholder": "Type the content of your code note here..."
@@ -1629,8 +1629,7 @@
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"task-list": "Task List",
"new-feature": "New",
"collections": "Collections"
"new-feature": "New"
},
"protect_note": {
"toggle-on": "Protect the note",
@@ -1942,9 +1941,5 @@
"table_view": {
"new-row": "New row",
"new-column": "New column"
},
"book_properties_config": {
"hide-weekends": "Hide weekends",
"display-week-numbers": "Display week numbers"
}
}

View File

@@ -758,7 +758,7 @@
"expand_all_children": "Ampliar todas las subnotas",
"collapse": "Colapsar",
"expand": "Expandir",
"book_properties": "",
"book_properties": "Propiedades del libro",
"invalid_view_type": "Tipo de vista inválida '{{type}}'",
"calendar": "Calendario"
},

View File

@@ -753,7 +753,7 @@
"expand_all_children": "Développer tous les enfants",
"collapse": "Réduire",
"expand": "Développer",
"book_properties": "",
"book_properties": "Propriétés du livre",
"invalid_view_type": "Type de vue non valide '{{type}}'",
"calendar": "Calendrier"
},

View File

@@ -274,7 +274,7 @@
"no_children_help": "Această notiță de tip Carte nu are nicio subnotiță așadar nu este nimic de afișat. Vedeți <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> pentru detalii."
},
"book_properties": {
"book_properties": "",
"book_properties": "Proprietăți carte",
"collapse": "Minimizează",
"collapse_all_notes": "Minimizează toate notițele",
"expand": "Expandează",

View File

@@ -718,7 +718,7 @@
"expand_all_children": "展開所有子項",
"collapse": "折疊",
"expand": "展開",
"book_properties": "",
"book_properties": "書籍屬性",
"invalid_view_type": "無效的查看類型 '{{type}}'"
},
"edited_notes": {

View File

@@ -3,8 +3,6 @@ import attributeService from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
import { bookPropertiesConfig, BookProperty } from "./book_properties_config.js";
import attributes from "../../services/attributes.js";
const TPL = /*html*/`
<div class="book-properties-widget">
@@ -17,19 +15,6 @@ const TPL = /*html*/`
.book-properties-widget > * {
margin-right: 15px;
}
.book-properties-container {
display: flex;
align-items: center;
}
.book-properties-container > * {
margin-right: 15px;
}
.book-properties-container input[type="checkbox"] {
margin-right: 5px;
}
</style>
<div style="display: flex; align-items: baseline">
@@ -44,16 +29,30 @@ const TPL = /*html*/`
</select>
</div>
<div class="book-properties-container">
</div>
<button type="button"
class="collapse-all-button btn btn-sm"
title="${t("book_properties.collapse_all_notes")}">
<span class="bx bx-layer-minus"></span>
${t("book_properties.collapse")}
</button>
<button type="button"
class="expand-children-button btn btn-sm"
title="${t("book_properties.expand_all_children")}">
<span class="bx bx-move-vertical"></span>
${t("book_properties.expand")}
</button>
</div>
`;
export default class BookPropertiesWidget extends NoteContextAwareWidget {
private $viewTypeSelect!: JQuery<HTMLElement>;
private $propertiesContainer!: JQuery<HTMLElement>;
private labelsToWatch: string[] = [];
private $expandChildrenButton!: JQuery<HTMLElement>;
private $collapseAllButton!: JQuery<HTMLElement>;
get name() {
return "bookProperties";
@@ -82,7 +81,32 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
this.$viewTypeSelect = this.$widget.find(".view-type-select");
this.$viewTypeSelect.on("change", () => this.toggleViewType(String(this.$viewTypeSelect.val())));
this.$propertiesContainer = this.$widget.find(".book-properties-container");
this.$expandChildrenButton = this.$widget.find(".expand-children-button");
this.$expandChildrenButton.on("click", async () => {
if (!this.noteId || !this.note) {
return;
}
if (!this.note?.isLabelTruthy("expanded")) {
await attributeService.addLabel(this.noteId, "expanded");
}
this.triggerCommand("refreshNoteList", { noteId: this.noteId });
});
this.$collapseAllButton = this.$widget.find(".collapse-all-button");
this.$collapseAllButton.on("click", async () => {
if (!this.noteId || !this.note) {
return;
}
// owned is important - we shouldn't remove inherited expanded labels
for (const expandedAttr of this.note.getOwnedLabels("expanded")) {
await attributeService.removeAttributeById(this.noteId, expandedAttr.attributeId);
}
this.triggerCommand("refreshNoteList", { noteId: this.noteId });
});
}
async refreshWithNote(note: FNote) {
@@ -94,15 +118,8 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
this.$viewTypeSelect.val(viewType);
this.$propertiesContainer.empty();
const bookPropertiesData = bookPropertiesConfig[viewType];
if (bookPropertiesData) {
for (const property of bookPropertiesData.properties) {
this.$propertiesContainer.append(this.renderBookProperty(property));
this.labelsToWatch.push(property.bindToLabel);
}
}
this.$expandChildrenButton.toggle(viewType === "list");
this.$collapseAllButton.toggle(viewType === "list");
}
async toggleViewType(type: string) {
@@ -118,60 +135,8 @@ export default class BookPropertiesWidget extends NoteContextAwareWidget {
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.getAttributeRows().find((attr) =>
attr.noteId === this.noteId
&& (attr.name === "viewType" || this.labelsToWatch.includes(attr.name ?? "")))) {
if (loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
this.refresh();
}
}
renderBookProperty(property: BookProperty) {
const $container = $("<div>");
const note = this.note;
if (!note) {
return $container;
}
switch (property.type) {
case "checkbox":
const $label = $("<label>").text(property.label);
const $checkbox = $("<input>", {
type: "checkbox",
class: "form-check-input",
});
$checkbox.on("change", () => {
if ($checkbox.prop("checked")) {
attributes.setLabel(note.noteId, property.bindToLabel);
} else {
attributes.removeOwnedLabelByName(note, property.bindToLabel);
}
});
$checkbox.prop("checked", note.hasOwnedLabel(property.bindToLabel));
$label.prepend($checkbox);
$container.append($label);
break;
case "button":
const $button = $("<button>", {
type: "button",
class: "btn btn-sm"
}).text(property.label);
if (property.title) {
$button.attr("title", property.title);
}
if (property.icon) {
$button.prepend($("<span>", { class: property.icon }));
}
$button.on("click", () => {
property.onClick({
note,
triggerCommand: this.triggerCommand.bind(this)
});
});
$container.append($button);
break;
}
return $container;
}
}

View File

@@ -1,90 +0,0 @@
import { t } from "i18next";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { ViewTypeOptions } from "../../services/note_list_renderer"
import NoteContextAwareWidget from "../note_context_aware_widget";
export type BookProperty = CheckBoxProperty | ButtonProperty;
interface BookConfig {
properties: BookProperty[];
}
interface CheckBoxProperty {
type: "checkbox",
label: string;
bindToLabel: string
}
interface ButtonProperty {
type: "button",
label: string;
title?: string;
icon?: string;
onClick: (context: BookContext) => void;
}
interface BookContext {
note: FNote;
triggerCommand: NoteContextAwareWidget["triggerCommand"];
}
export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
grid: {
properties: []
},
list: {
properties: [
{
label: t("book_properties.collapse"),
title: t("book_properties.collapse_all_notes"),
type: "button",
icon: "bx bx-layer-minus",
async onClick({ note, triggerCommand }) {
const { noteId } = note;
// owned is important - we shouldn't remove inherited expanded labels
for (const expandedAttr of note.getOwnedLabels("expanded")) {
await attributes.removeAttributeById(noteId, expandedAttr.attributeId);
}
triggerCommand("refreshNoteList", { noteId: noteId });
},
},
{
label: t("book_properties.expand"),
title: t("book_properties.expand_all_children"),
type: "button",
icon: "bx bx-move-vertical",
async onClick({ note, triggerCommand }) {
const { noteId } = note;
if (!note.isLabelTruthy("expanded")) {
await attributes.addLabel(noteId, "expanded");
}
triggerCommand("refreshNoteList", { noteId });
},
}
]
},
calendar: {
properties: [
{
label: t("book_properties_config.hide-weekends"),
type: "checkbox",
bindToLabel: "calendar:hideWeekends"
},
{
label: t("book_properties_config.display-week-numbers"),
type: "checkbox",
bindToLabel: "calendar:weekNumbers"
}
]
},
geoMap: {
properties: []
},
table: {
properties: []
}
};

View File

@@ -37,10 +37,7 @@ const config: ForgeConfig = {
executableName: EXECUTABLE_NAME,
name: PRODUCT_NAME,
overwrite: true,
asar: {
unpack: "node_modules/swagger-ui-dist/**",
unpackDir: "assets"
},
asar: true,
icon: path.join(APP_ICON_PATH, "icon"),
...macosSignConfiguration,
windowsSign: windowsSignConfiguration,

View File

@@ -74,7 +74,7 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"i18next": "25.3.2",
"i18next": "25.3.1",
"i18next-fs-backend": "2.6.0",
"image-type": "6.0.0",
"ini": "5.0.0",

View File

@@ -99,7 +99,7 @@
"reset-zoom-level": "Reset zoom level",
"copy-without-formatting": "Copy selected text without formatting",
"force-save-revision": "Force creating / saving new note revision of the active note",
"toggle-book-properties": "Toggle Collection Properties",
"toggle-book-properties": "Toggle Book Properties",
"toggle-classic-editor-toolbar": "Toggle the Formatting tab for the editor with fixed toolbar",
"export-as-pdf": "Export the current note as a PDF",
"toggle-zen-mode": "Enables/disables the zen mode (minimal UI for more focused editing)"
@@ -303,20 +303,5 @@
"subpages": "Subpages:",
"on-this-page": "On This Page",
"expand": "Expand"
},
"hidden_subtree_templates": {
"text-snippet": "Text Snippet",
"description": "Description",
"list-view": "List View",
"grid-view": "Grid View",
"calendar": "Calendar",
"table": "Table",
"geo-map": "Geo Map",
"start-date": "Start Date",
"end-date": "End Date",
"start-time": "Start Time",
"end-time": "End Time",
"geolocation": "Geolocation",
"built-in-templates": "Built-in templates"
}
}

View File

@@ -0,0 +1,148 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import supertest from "supertest";
import type { Application } from "express";
import fs from "fs";
import path from "path";
import { RESOURCE_DIR } from "../services/resource_dir.js";
let app: Application;
describe("API Documentation Routes", () => {
beforeAll(async () => {
const buildApp = (await import("../app.js")).default;
app = await buildApp();
});
describe("ETAPI Documentation", () => {
it("should serve ETAPI Swagger UI at /etapi/docs/", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toContain("TriliumNext ETAPI Documentation");
expect(response.text).toContain("swagger-ui");
});
it("should have OpenAPI spec accessible through Swagger UI", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
expect(response.text).toContain("swagger-ui");
expect(response.text).toContain("TriliumNext ETAPI Documentation");
});
it("should serve ETAPI static assets", async () => {
const response = await supertest(app)
.get("/etapi/docs/swagger-ui-bundle.js")
.expect(200);
expect(response.headers["content-type"]).toMatch(/javascript/);
});
it("should load ETAPI OpenAPI spec from correct resource path", () => {
const etapiSpecPath = path.join(RESOURCE_DIR, "etapi.openapi.yaml");
expect(fs.existsSync(etapiSpecPath)).toBe(true);
});
});
describe("Internal API Documentation", () => {
it("should serve Internal API Swagger UI at /api/docs/", async () => {
const response = await supertest(app)
.get("/api/docs/")
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toContain("TriliumNext Internal API Documentation");
expect(response.text).toContain("swagger-ui");
});
it("should have OpenAPI spec accessible through Swagger UI", async () => {
const response = await supertest(app)
.get("/api/docs/")
.expect(200);
expect(response.text).toContain("swagger-ui");
expect(response.text).toContain("TriliumNext Internal API Documentation");
});
it("should serve Internal API static assets", async () => {
const response = await supertest(app)
.get("/api/docs/swagger-ui-bundle.js")
.expect(200);
expect(response.headers["content-type"]).toMatch(/javascript/);
});
it("should load Internal API OpenAPI spec from correct resource path", () => {
const apiSpecPath = path.join(RESOURCE_DIR, "openapi.json");
expect(fs.existsSync(apiSpecPath)).toBe(true);
});
});
describe("Resource Directory Resolution", () => {
it("should resolve RESOURCE_DIR to a valid directory", () => {
expect(fs.existsSync(RESOURCE_DIR)).toBe(true);
expect(fs.statSync(RESOURCE_DIR).isDirectory()).toBe(true);
});
it("should find assets directory in RESOURCE_DIR", () => {
const assetsPath = path.join(RESOURCE_DIR, "assets");
// The assets directory should exist at the resource root, not inside another assets folder
expect(fs.existsSync(RESOURCE_DIR)).toBe(true);
});
it("should have required OpenAPI files in RESOURCE_DIR", () => {
const etapiPath = path.join(RESOURCE_DIR, "etapi.openapi.yaml");
const openApiPath = path.join(RESOURCE_DIR, "openapi.json");
expect(fs.existsSync(etapiPath)).toBe(true);
expect(fs.existsSync(openApiPath)).toBe(true);
});
});
describe("Error Handling", () => {
it("should handle missing OpenAPI files gracefully", async () => {
// Mock fs.readFileSync to throw an error
const originalReadFileSync = fs.readFileSync;
vi.spyOn(fs, "readFileSync").mockImplementation((path, options) => {
if (typeof path === "string" && path.includes("etapi.openapi.yaml")) {
throw new Error("File not found");
}
return originalReadFileSync(path, options);
});
try {
await supertest(app)
.get("/etapi/docs/")
.expect(500);
} catch (error) {
// Expected to fail
}
vi.restoreAllMocks();
});
it("should handle invalid OpenAPI files gracefully", async () => {
// Mock fs.readFileSync to return invalid YAML
const originalReadFileSync = fs.readFileSync;
vi.spyOn(fs, "readFileSync").mockImplementation((path, options) => {
if (typeof path === "string" && path.includes("etapi.openapi.yaml")) {
return "invalid: yaml: content: [" as any;
}
return originalReadFileSync(path, options);
});
try {
await supertest(app)
.get("/etapi/docs/")
.expect(500);
} catch (error) {
// Expected to fail
}
vi.restoreAllMocks();
});
});
});

View File

@@ -3,102 +3,19 @@ import swaggerUi from "swagger-ui-express";
import { join } from "path";
import yaml from "js-yaml";
import type { JsonObject } from "swagger-ui-express";
import { readFileSync, existsSync, readdirSync } from "fs";
import { readFileSync } from "fs";
import { RESOURCE_DIR } from "../services/resource_dir";
import log from "../services/log";
// Monkey patch fs.lstat to debug which files are causing the issue
const originalFs = require('fs');
const originalLstat = originalFs.lstat;
originalFs.lstat = function(path: string, callback: any) {
log.info(`[FS DEBUG] lstat called on: ${path}`);
return originalLstat.call(this, path, (err: any, stats: any) => {
if (err) {
log.error(`[FS DEBUG] lstat error on ${path}: ${err.message}`);
}
callback(err, stats);
});
};
export default function register(app: Application) {
log.info(`[DEBUG] Starting API docs registration`);
log.info(`[DEBUG] RESOURCE_DIR: ${RESOURCE_DIR}`);
// Clean trailing slashes from RESOURCE_DIR to prevent path resolution issues in packaged Electron apps
const cleanResourceDir = RESOURCE_DIR.replace(/[\\\/]+$/, '');
log.info(`[DEBUG] cleanResourceDir: ${cleanResourceDir}`);
// Check what's in the resource directory
try {
if (existsSync(cleanResourceDir)) {
const contents = readdirSync(cleanResourceDir);
log.info(`[DEBUG] Contents of ${cleanResourceDir}: ${contents.join(', ')}`);
} else {
log.info(`[DEBUG] Resource directory doesn't exist: ${cleanResourceDir}`);
}
} catch (e) {
log.error(`[DEBUG] Error reading resource directory: ${e}`);
}
// In packaged Electron apps, check if we need to read from the unpacked directory
let resourceDir = cleanResourceDir;
if (resourceDir.includes('app.asar')) {
log.info(`[DEBUG] Detected ASAR packaging`);
const unpackedDir = cleanResourceDir.replace('app.asar', 'app.asar.unpacked');
log.info(`[DEBUG] Checking unpacked dir: ${unpackedDir}`);
// Check what's in the unpacked directory
try {
if (existsSync(unpackedDir)) {
const unpackedContents = readdirSync(unpackedDir);
log.info(`[DEBUG] Contents of unpacked dir: ${unpackedContents.join(', ')}`);
if (existsSync(join(unpackedDir, "etapi.openapi.yaml"))) {
resourceDir = unpackedDir;
log.info(`[DEBUG] Using unpacked directory: ${resourceDir}`);
}
} else {
log.info(`[DEBUG] Unpacked directory doesn't exist: ${unpackedDir}`);
}
} catch (e) {
log.error(`[DEBUG] Error checking unpacked directory: ${e}`);
}
}
log.info(`[DEBUG] Final resourceDir: ${resourceDir}`);
log.info(`[DEBUG] About to load OpenAPI specs...`);
const etapiDocument = yaml.load(readFileSync(join(resourceDir, "etapi.openapi.yaml"), "utf8")) as JsonObject;
const apiDocument = JSON.parse(readFileSync(join(resourceDir, "openapi.json"), "utf-8"));
log.info(`[DEBUG] Successfully loaded OpenAPI documents`);
log.info(`[DEBUG] About to register swagger-ui endpoints...`);
// Check swagger-ui-dist location
try {
const swaggerPath = require.resolve('swagger-ui-dist/package.json');
log.info(`[DEBUG] swagger-ui-dist package.json found at: ${swaggerPath}`);
const swaggerDistPath = require.resolve('swagger-ui-dist');
log.info(`[DEBUG] swagger-ui-dist main module at: ${swaggerDistPath}`);
} catch (e) {
log.error(`[DEBUG] Error finding swagger-ui-dist: ${e}`);
}
const etapiDocument = yaml.load(readFileSync(join(cleanResourceDir, "etapi.openapi.yaml"), "utf8")) as JsonObject;
const apiDocument = JSON.parse(readFileSync(join(cleanResourceDir, "openapi.json"), "utf-8"));
app.use(
"/etapi/docs/",
(req, res, next) => {
log.info(`[DEBUG] Request to /etapi/docs/: ${req.method} ${req.url}`);
// Temporarily disable ASAR for swagger-ui file access
const originalNoAsar = process.noAsar;
process.noAsar = true;
// Restore ASAR setting after response
res.on('finish', () => {
process.noAsar = originalNoAsar;
});
next();
},
swaggerUi.serve,
swaggerUi.serveFiles(etapiDocument),
swaggerUi.setup(etapiDocument, {
explorer: true,
customSiteTitle: "TriliumNext ETAPI Documentation"
@@ -107,19 +24,7 @@ export default function register(app: Application) {
app.use(
"/api/docs/",
(req, res, next) => {
// Temporarily disable ASAR for swagger-ui file access
const originalNoAsar = process.noAsar;
process.noAsar = true;
// Restore ASAR setting after response
res.on('finish', () => {
process.noAsar = originalNoAsar;
});
next();
},
swaggerUi.serve,
swaggerUi.serveFiles(apiDocument),
swaggerUi.setup(apiDocument, {
explorer: true,
customSiteTitle: "TriliumNext Internal API Documentation"

View File

@@ -0,0 +1,139 @@
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import type { Application } from "express";
import { note } from "../test/becca_mocking.js";
import BNote from "../becca/entities/bnote.js";
let app: Application;
describe("API Reference Help Note", () => {
beforeAll(async () => {
const buildApp = (await import("../app.js")).default;
app = await buildApp();
});
describe("Help Note Structure", () => {
it("should have correct help note metadata in the system", () => {
// Test that the help note IDs are defined in the system
expect("_help_9qPsTWBorUhQ").toBe("_help_9qPsTWBorUhQ");
expect("_help_z8O2VG4ZZJD7").toBe("_help_z8O2VG4ZZJD7");
});
});
describe("WebView Source URLs", () => {
it("should serve content at ETAPI docs URL", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toContain("swagger-ui");
});
it("should serve content at Internal API docs URL", async () => {
const response = await supertest(app)
.get("/api/docs/")
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toContain("swagger-ui");
});
it("should handle trailing slash in ETAPI docs URL", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toContain("swagger-ui");
});
it("should handle trailing slash in Internal API docs URL", async () => {
const response = await supertest(app)
.get("/api/docs/")
.expect(200);
expect(response.headers["content-type"]).toMatch(/text\/html/);
expect(response.text).toContain("swagger-ui");
});
});
describe("Help Note Integration", () => {
it("should be accessible via help note ID in the application", async () => {
// Test that the help note endpoint would work
// Note: This would typically be handled by the client-side application
// but we can test that the webViewSrc URL is accessible
const etapiResponse = await supertest(app)
.get("/etapi/docs/")
.expect(200);
expect(etapiResponse.text).toContain("TriliumNext ETAPI Documentation");
});
it("should not return 'Invalid package' error for ETAPI docs", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
expect(response.text).not.toContain("Invalid package");
expect(response.text).not.toContain("C:\\\\Users\\\\perf3ct\\\\AppData\\\\Local\\\\trilium\\\\app-0.96.0\\\\resources\\\\app.asar");
});
it("should not return 'Invalid package' error for Internal API docs", async () => {
const response = await supertest(app)
.get("/api/docs/")
.expect(200);
expect(response.text).not.toContain("Invalid package");
expect(response.text).not.toContain("C:\\\\Users\\\\perf3ct\\\\AppData\\\\Local\\\\trilium\\\\app-0.96.0\\\\resources\\\\app.asar");
});
});
describe("Swagger UI Content Validation", () => {
it("should serve valid Swagger UI page with expected elements", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
// Check for essential Swagger UI elements
expect(response.text).toContain("swagger-ui");
expect(response.text).toContain("TriliumNext ETAPI Documentation");
expect(response.text).toMatch(/swagger-ui.*css/);
expect(response.text).toMatch(/swagger-ui.*js/);
});
it("should serve valid Swagger UI with OpenAPI content", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
expect(response.text).toContain("swagger-ui");
expect(response.text).toContain("TriliumNext ETAPI Documentation");
});
});
describe("Client-Side WebView Integration", () => {
it("should serve content that can be loaded in a webview", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
// Check that the response is proper HTML that can be loaded in a webview
expect(response.text).toMatch(/<!DOCTYPE html>/i);
expect(response.text).toMatch(/<html/i);
expect(response.text).toMatch(/<head>/i);
expect(response.text).toMatch(/<body>/i);
});
it("should have appropriate headers for webview loading", async () => {
const response = await supertest(app)
.get("/etapi/docs/")
.expect(200);
// Check that the response is successful and has content
expect(response.status).toBe(200);
expect(response.text).toContain("swagger-ui");
});
});
});

View File

@@ -1,16 +1,15 @@
import { HiddenSubtreeItem } from "@triliumnext/commons";
import { t } from "i18next";
export default function buildHiddenSubtreeTemplates() {
const templates: HiddenSubtreeItem = {
id: "_templates",
title: t("hidden_subtree_templates.built-in-templates"),
title: "Built-in templates",
type: "book",
children: [
{
id: "_template_text_snippet",
type: "text",
title: t("hidden_subtree_templates.text-snippet"),
title: "Text Snippet",
icon: "bx-align-left",
attributes: [
{
@@ -24,105 +23,14 @@ export default function buildHiddenSubtreeTemplates() {
{
name: "label:textSnippetDescription",
type: "label",
value: `promoted,alias=${t("hidden_subtree_templates.description")},single,text`
}
]
},
{
id: "_template_list_view",
type: "book",
title: t("hidden_subtree_templates.list-view"),
icon: "bx bx-list-ul",
attributes: [
{
name: "template",
type: "label"
},
{
name: "collection",
type: "label"
},
{
name: "viewType",
type: "label",
value: "list"
}
]
},
{
id: "_template_grid_view",
type: "book",
title: t("hidden_subtree_templates.grid-view"),
icon: "bx bxs-grid",
attributes: [
{
name: "template",
type: "label"
},
{
name: "collection",
type: "label"
},
{
name: "viewType",
type: "label",
value: "grid"
}
]
},
{
id: "_template_calendar",
type: "book",
title: t("hidden_subtree_templates.calendar"),
icon: "bx bx-calendar",
attributes: [
{
name: "template",
type: "label",
},
{
name: "collection",
type: "label"
},
{
name: "viewType",
type: "label",
value: "calendar"
},
{
name: "hidePromotedAttributes",
type: "label"
},
{
name: "label:startDate",
type: "label",
value: `promoted,alias=${t("hidden_subtree_templates.start-date")},single,date`,
isInheritable: true
},
{
name: "label:endDate",
type: "label",
value: `promoted,alias=${t("hidden_subtree_templates.end-date")},single,date`,
isInheritable: true
},
{
name: "label:startTime",
type: "label",
value: `promoted,alias=${t("hidden_subtree_templates.start-time")},single,time`,
isInheritable: true
},
{
name: "label:endTime",
type: "label",
value: `promoted,alias=${t("hidden_subtree_templates.end-time")},single,time`,
isInheritable: true
value: "promoted,alias=Description,single,text"
}
]
},
{
id: "_template_table",
type: "book",
title: t("hidden_subtree_templates.table"),
title: "Table",
icon: "bx bx-table",
attributes: [
{
@@ -143,7 +51,7 @@ export default function buildHiddenSubtreeTemplates() {
{
id: "_template_geo_map",
type: "book",
title: t("hidden_subtree_templates.geo-map"),
title: "Geo Map",
icon: "bx bx-map-alt",
attributes: [
{
@@ -166,7 +74,7 @@ export default function buildHiddenSubtreeTemplates() {
{
name: "label:geolocation",
type: "label",
value: `promoted,alias=${t("hidden_subtree_templates.geolocation")},single,text`,
value: "promoted,alias=Geolocation,single,text",
isInheritable: true
}
]

View File

@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import utils from "./utils.js";
import fs from "fs";
import path from "path";
type TestCase<T extends (...args: any) => any> = [desc: string, fnParams: Parameters<T>, expected: ReturnType<T>];
@@ -474,7 +476,110 @@ describe("#envToBoolean", () => {
});
});
describe.todo("#getResourceDir", () => {});
describe("#getResourceDir", () => {
let originalEnv: typeof process.env;
let originalPlatform: typeof process.platform;
let originalVersions: typeof process.versions;
let originalArgv: typeof process.argv;
beforeEach(() => {
// Save original values
originalEnv = { ...process.env };
originalPlatform = process.platform;
originalVersions = { ...process.versions };
originalArgv = [...process.argv];
});
afterEach(() => {
// Restore original values
process.env = originalEnv;
Object.defineProperty(process, 'platform', { value: originalPlatform });
Object.defineProperty(process, 'versions', { value: originalVersions });
process.argv = originalArgv;
vi.clearAllMocks();
});
it("should return TRILIUM_RESOURCE_DIR environment variable when set", () => {
const testPath = "/custom/resource/dir";
process.env.TRILIUM_RESOURCE_DIR = testPath;
const result = utils.getResourceDir();
expect(result).toBe(testPath);
});
it("should dynamically find assets directory in Electron production mode", () => {
// Mock Electron production environment
delete process.env.TRILIUM_RESOURCE_DIR;
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, electron: '37.2.0' }
});
delete process.env.TRILIUM_ENV;
// Mock fs.existsSync to simulate finding assets directory
const mockExistsSync = vi.spyOn(fs, 'existsSync');
mockExistsSync.mockImplementation((pathToCheck: string) => {
// Simulate finding assets directory at the app root level
return pathToCheck.includes("assets") &&
pathToCheck === path.join(path.dirname(path.dirname(path.dirname(path.dirname(__dirname)))), "assets");
});
const result = utils.getResourceDir();
expect(result).toBe(path.dirname(path.dirname(path.dirname(path.dirname(__dirname)))));
expect(mockExistsSync).toHaveBeenCalled();
});
it("should fall back to hardcoded path when assets directory not found dynamically", () => {
// Mock Electron production environment
delete process.env.TRILIUM_RESOURCE_DIR;
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, electron: '37.2.0' }
});
delete process.env.TRILIUM_ENV;
// Mock fs.existsSync to always return false (assets not found)
const mockExistsSync = vi.spyOn(fs, 'existsSync');
mockExistsSync.mockReturnValue(false);
const result = utils.getResourceDir();
expect(result).toBe(path.join(__dirname, "../../../.."));
expect(mockExistsSync).toHaveBeenCalled();
});
it("should return process.argv[1] directory in non-Electron production mode", () => {
delete process.env.TRILIUM_RESOURCE_DIR;
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, electron: undefined }
});
delete process.env.TRILIUM_ENV;
process.argv[1] = "/app/server/main.js";
const result = utils.getResourceDir();
expect(result).toBe(path.dirname(process.argv[1]));
});
it("should return parent directory in development mode", () => {
delete process.env.TRILIUM_RESOURCE_DIR;
process.env.TRILIUM_ENV = "dev";
const result = utils.getResourceDir();
expect(result).toBe(path.join(__dirname, ".."));
});
it("should handle edge case when reaching filesystem root", () => {
delete process.env.TRILIUM_RESOURCE_DIR;
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, electron: '37.2.0' }
});
delete process.env.TRILIUM_ENV;
const mockExistsSync = vi.spyOn(fs, 'existsSync');
mockExistsSync.mockReturnValue(false);
const result = utils.getResourceDir();
expect(result).toBe(path.join(__dirname, "../../../.."));
});
});
describe("#isElectron", () => {
it("should export a boolean", () => {

View File

@@ -9,6 +9,7 @@ import escape from "escape-html";
import sanitize from "sanitize-filename";
import mimeTypes from "mime-types";
import path from "path";
import fs from "fs";
import type NoteMeta from "./meta/note_meta.js";
import log from "./log.js";
import { t } from "i18next";
@@ -292,7 +293,33 @@ export function getResourceDir() {
return process.env.TRILIUM_RESOURCE_DIR;
}
if (isElectron && !isDev) return __dirname;
if (isElectron && !isDev) {
// Dynamically find the correct resource directory by traversing upward
// until we find the assets directory or reach the root
let currentPath = __dirname;
let maxDepth = 10; // Safety limit to prevent infinite loops
while (maxDepth > 0) {
const assetsPath = path.join(currentPath, "assets");
if (fs.existsSync(assetsPath)) {
return currentPath;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
// Reached root directory
break;
}
currentPath = parentPath;
maxDepth--;
}
// Fallback to the old hardcoded path if dynamic search fails
log.info("Could not dynamically find assets directory, falling back to hardcoded path");
return path.join(__dirname, "../../../..");
}
if (!isDev) {
return path.dirname(process.argv[1]);
}

View File

@@ -80,7 +80,7 @@
"url": "https://github.com/TriliumNext/Notes/issues"
},
"homepage": "https://github.com/TriliumNext/Notes#readme",
"packageManager": "pnpm@10.13.1",
"packageManager": "pnpm@10.12.4",
"pnpm": {
"patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",

433
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff