From f43768d4de425cedfde776865c2316577ffd1a0d Mon Sep 17 00:00:00 2001 From: ShivamChaudhary Date: Sat, 21 Feb 2026 03:37:56 +0530 Subject: [PATCH 1/2] fix: validate default template still has template tag before using it --- src/index.ts | 34 +++++++++++++++++++++++++++------- src/utils/templates.ts | 33 ++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index fbfdb73..8297df9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ import { CommandsPanel } from "./views/commandsPanel"; const DOCUMENTATION_URL = "https://github.com/joplin/plugin-templates#readme"; joplin.plugins.register({ - onStart: async function() { + onStart: async function () { // Register setting section await PluginSettingsRegistry.registerSettings(); @@ -199,22 +199,32 @@ joplin.plugins.register({ label: "Create note from default template", execute: async () => { let defaultTemplate: Note | null = null; + let defaultTemplateIdWasSet = false; const defaultTemplatesConfig = await DefaultTemplatesConfigSetting.get(); const currentFolderId = await getSelectedFolder(); if (currentFolderId in defaultTemplatesConfig) { - defaultTemplate = await getTemplateFromId(defaultTemplatesConfig[currentFolderId].defaultNoteTemplateId); + const notebookDefaultId = defaultTemplatesConfig[currentFolderId].defaultNoteTemplateId; + if (notebookDefaultId) defaultTemplateIdWasSet = true; + defaultTemplate = await getTemplateFromId(notebookDefaultId); } if (defaultTemplate === null) { - defaultTemplate = await getTemplateFromId(await DefaultNoteTemplateIdSetting.get()); + const globalDefaultId = await DefaultNoteTemplateIdSetting.get(); + if (globalDefaultId) defaultTemplateIdWasSet = true; + defaultTemplate = await getTemplateFromId(globalDefaultId); } if (defaultTemplate) { return await performActionWithParsedTemplate(TemplateAction.NewNote, defaultTemplate); } - await joplin.views.dialogs.showMessageBox("No default note template is set."); + + if (defaultTemplateIdWasSet) { + await joplin.views.dialogs.showMessageBox("The note set as the default template is no longer a template. Please set a new default template via Tools \u2192 Templates \u2192 Default templates."); + } else { + await joplin.views.dialogs.showMessageBox("No default note template is set."); + } } })); @@ -223,22 +233,32 @@ joplin.plugins.register({ label: "Create to-do from default template", execute: async () => { let defaultTemplate: Note | null = null; + let defaultTemplateIdWasSet = false; const defaultTemplatesConfig = await DefaultTemplatesConfigSetting.get(); const currentFolderId = await getSelectedFolder(); if (currentFolderId in defaultTemplatesConfig) { - defaultTemplate = await getTemplateFromId(defaultTemplatesConfig[currentFolderId].defaultTodoTemplateId); + const notebookDefaultId = defaultTemplatesConfig[currentFolderId].defaultTodoTemplateId; + if (notebookDefaultId) defaultTemplateIdWasSet = true; + defaultTemplate = await getTemplateFromId(notebookDefaultId); } if (defaultTemplate === null) { - defaultTemplate = await getTemplateFromId(await DefaultTodoTemplateIdSetting.get()); + const globalDefaultId = await DefaultTodoTemplateIdSetting.get(); + if (globalDefaultId) defaultTemplateIdWasSet = true; + defaultTemplate = await getTemplateFromId(globalDefaultId); } if (defaultTemplate) { return await performActionWithParsedTemplate(TemplateAction.NewTodo, defaultTemplate); } - await joplin.views.dialogs.showMessageBox("No default to-do template is set."); + + if (defaultTemplateIdWasSet) { + await joplin.views.dialogs.showMessageBox("The note set as the default template is no longer a template. Please set a new default template via Tools \u2192 Templates \u2192 Default templates."); + } else { + await joplin.views.dialogs.showMessageBox("No default to-do template is set."); + } } })); diff --git a/src/utils/templates.ts b/src/utils/templates.ts index 54e6429..2c1b92c 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -5,6 +5,7 @@ import { TemplatesSourceSetting, TemplatesSource } from "../settings/templatesSo import { LocaleGlobalSetting } from "../settings/global"; import { encode, decode } from "html-entities"; import { AUTO_FOCUS_SCRIPT } from "./dialogHelpers"; +import { fetchAllItems } from "./dataApi"; export interface Note { id: string; @@ -58,7 +59,7 @@ const getAllTemplates = async () => { export async function getUserTemplateSelection(dialogHandle: string, property?: NoteProperty, promptLabel = "Template:"): Promise { try { const templates = await getAllTemplates(); - + if (templates.length === 0) { await joplin.views.dialogs.showMessageBox("No templates found! Please create a template and try again."); return null; @@ -97,15 +98,15 @@ export async function getUserTemplateSelection(dialogHandle: string, property?: await joplin.views.dialogs.setFitToContent(dialogHandle, true); const result = await joplin.views.dialogs.open(dialogHandle); - + if (result.id === "cancel") { return null; } - + // Get the template value and decode HTML entities const templateValue = result.formData?.["templates-form"]?.template; const decodedValue = templateValue ? decode(templateValue) : null; - + return decodedValue; } catch (error) { console.error("Error in getUserTemplateSelection:", error); @@ -113,13 +114,35 @@ export async function getUserTemplateSelection(dialogHandle: string, property?: } } +/** + * Checks whether a given note still has the 'template' tag. + * Returns false if the note has no template tag or if the check fails. + */ +export const isNoteATemplate = async (noteId: string): Promise => { + try { + const tags = await fetchAllItems(["notes", noteId, "tags"], { fields: ["id", "title"] }); + return tags.some((tag: { title: string }) => tag.title === "template"); + } catch (error) { + console.error("Error checking if note is a template", error); + return false; + } +}; + export const getTemplateFromId = async (templateId: string | null): Promise => { if (!templateId) { return null; } try { - return await joplin.data.get([ "notes", templateId ], { fields: ["id", "title", "body"] }); + const note = await joplin.data.get(["notes", templateId], { fields: ["id", "title", "body"] }); + + const stillATemplate = await isNoteATemplate(templateId); + if (!stillATemplate) { + console.warn(`Note "${note.title}" (id: ${templateId}) is set as a default template but no longer has the 'template' tag.`); + return null; + } + + return note; } catch (error) { console.error("There was an error loading a template from id", error); return null; From 5c495049c5f27585b77e123a12620cae7182617d Mon Sep 17 00:00:00 2001 From: Shivam Chaudhary Date: Thu, 17 Sep 2026 15:03:13 +0530 Subject: [PATCH 2/2] fix: validate default template is a template based on template source --- package.json | 2 +- src/index.ts | 18 +++++-- src/utils/templates.ts | 41 ++++++++++------ tests/utils/templates.spec.ts | 90 +++++++++++++++++++++++++++++++++++ webpack.config.js | 48 ++++++++++++------- 5 files changed, 164 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index a135cfc..8133127 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "joplin-plugin-templates", "version": "3.0.0", "scripts": { - "dist": "NODE_OPTIONS=--openssl-legacy-provider webpack --joplin-plugin-config buildMain && NODE_OPTIONS=--openssl-legacy-provider webpack --joplin-plugin-config buildExtraScripts && NODE_OPTIONS=--openssl-legacy-provider webpack --joplin-plugin-config createArchive", + "dist": "NODE_OPTIONS=--openssl-legacy-provider webpack --env joplin-plugin-config=buildMain && NODE_OPTIONS=--openssl-legacy-provider webpack --env joplin-plugin-config=buildExtraScripts && NODE_OPTIONS=--openssl-legacy-provider webpack --env joplin-plugin-config=createArchive", "prepare": "npm run dist", "update": "npm install -g generator-joplin && yo joplin --update", "lint": "eslint '{src,tests}/**/*.{ts,json}'", diff --git a/src/index.ts b/src/index.ts index 8297df9..fafa7fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { Parser } from "./parser"; import { DateAndTimeUtils } from "./utils/dateAndTime"; import { getFolderFromId, getSelectedFolder, getUserFolderSelection, Folder } from "./utils/folders"; import { getUserDefaultTemplateTypeSelection, setDefaultTemplate } from "./utils/defaultTemplates"; -import { getTemplateFromId, getUserTemplateSelection, Note } from "./utils/templates"; +import { getTemplateFromId, getUserTemplateSelection, isNoteATemplate, Note } from "./utils/templates"; import { setDefaultTemplatesView, DefaultTemplatesDisplayData, NotebookDefaultTemplatesDisplayData } from "./views/defaultTemplates"; import { TemplateAction, performAction } from "./actions"; import { loadLegacyTemplates } from "./legacyTemplates"; @@ -217,7 +217,13 @@ joplin.plugins.register({ } if (defaultTemplate) { - return await performActionWithParsedTemplate(TemplateAction.NewNote, defaultTemplate); + const validTemplate = await isNoteATemplate(defaultTemplate); + if (validTemplate) { + return await performActionWithParsedTemplate(TemplateAction.NewNote, defaultTemplate); + } else { + await joplin.views.dialogs.showMessageBox("The note set as the default template is no longer a template. Please set a new default template via Tools \u2192 Templates \u2192 Default templates."); + return; + } } if (defaultTemplateIdWasSet) { @@ -251,7 +257,13 @@ joplin.plugins.register({ } if (defaultTemplate) { - return await performActionWithParsedTemplate(TemplateAction.NewTodo, defaultTemplate); + const validTemplate = await isNoteATemplate(defaultTemplate); + if (validTemplate) { + return await performActionWithParsedTemplate(TemplateAction.NewTodo, defaultTemplate); + } else { + await joplin.views.dialogs.showMessageBox("The note set as the default template is no longer a template. Please set a new default template via Tools \u2192 Templates \u2192 Default templates."); + return; + } } if (defaultTemplateIdWasSet) { diff --git a/src/utils/templates.ts b/src/utils/templates.ts index 2c1b92c..2e8e51d 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -1,5 +1,5 @@ import joplin from "api"; -import { getAllNotesInFolder } from "./folders"; +import { getAllNotesInFolder, getFolderFromId } from "./folders"; import { getAllNotesWithTag, getAllTagsWithTitle } from "./tags"; import { TemplatesSourceSetting, TemplatesSource } from "../settings/templatesSource"; import { LocaleGlobalSetting } from "../settings/global"; @@ -11,6 +11,7 @@ export interface Note { id: string; title: string; body: string; + parent_id?: string; } type NoteProperty = "body" | "id" | "title"; @@ -115,13 +116,30 @@ export async function getUserTemplateSelection(dialogHandle: string, property?: } /** - * Checks whether a given note still has the 'template' tag. - * Returns false if the note has no template tag or if the check fails. + * Checks whether a given note is still considered a template based on the user's TemplatesSource setting. + * - For Tag source: checks if the note has the 'template' tag. + * - For Notebook source: checks if the note's parent folder title is 'Templates'. */ -export const isNoteATemplate = async (noteId: string): Promise => { +export const isNoteATemplate = async (note: Note): Promise => { try { - const tags = await fetchAllItems(["notes", noteId, "tags"], { fields: ["id", "title"] }); - return tags.some((tag: { title: string }) => tag.title === "template"); + const templatesSource = await TemplatesSourceSetting.get(); + + if (templatesSource === TemplatesSource.Tag) { + const templateTags = await getAllTagsWithTitle("template"); + for (const tag of templateTags) { + const notesWithTag = await getAllNotesWithTag(tag.id); + if (notesWithTag.some(n => n.id === note.id)) { + return true; + } + } + return false; + } else { + if (!note.parent_id) { + return false; + } + const folder = await getFolderFromId(note.parent_id); + return folder !== null && folder.title === "Templates"; + } } catch (error) { console.error("Error checking if note is a template", error); return false; @@ -134,17 +152,10 @@ export const getTemplateFromId = async (templateId: string | null): Promise { expect(res).toEqual(null); }); }); + +describe("isNoteATemplate", () => { + const setTemplateTagsAndNotes = (data: TagData[]) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + jest.spyOn(tagUtils, "getAllTagsWithTitle").mockImplementation(async (title: string) => { + return data.map(tag => { + return { + id: tag.id, + title: tag.title + } + }); + }); + + jest.spyOn(tagUtils, "getAllNotesWithTag").mockImplementation(async (id: string) => { + for (const tag of data) { + if (tag.id === id) { + return tag.notes; + } + } + return []; + }); + } + + test("should return true for tag source if note has template tag", async () => { + jest.spyOn(joplin.settings, "value").mockImplementation(async (setting: string) => { + if (setting === "templatesSource") return "tag"; + return ""; + }); + setTemplateTagsAndNotes([ + { + id: "tag-1", + title: "template", + notes: [{ id: "note-1", title: "Test", body: "" }] + } + ]); + + const { isNoteATemplate } = await import("@templates/utils/templates"); + const res = await isNoteATemplate({ id: "note-1", title: "Test", body: "" }); + expect(res).toBe(true); + }); + + test("should return false for tag source if note lacks template tag", async () => { + jest.spyOn(joplin.settings, "value").mockImplementation(async (setting: string) => { + if (setting === "templatesSource") return "tag"; + return ""; + }); + setTemplateTagsAndNotes([ + { + id: "tag-1", + title: "template", + notes: [{ id: "note-2", title: "Other Note", body: "" }] + } + ]); + + const { isNoteATemplate } = await import("@templates/utils/templates"); + const res = await isNoteATemplate({ id: "note-1", title: "Test", body: "" }); + expect(res).toBe(false); + }); + + test("should return true for notebook source if parent folder is Templates", async () => { + jest.spyOn(joplin.settings, "value").mockImplementation(async (setting: string) => { + if (setting === "templatesSource") return "notebook"; + return ""; + }); + jest.spyOn(folderUtils, "getFolderFromId").mockImplementation(async (id: string) => { + if (id === "folder-1") return { id: "folder-1", title: "Templates" }; + return null; + }); + + const { isNoteATemplate } = await import("@templates/utils/templates"); + const res = await isNoteATemplate({ id: "note-1", title: "Test", body: "", parent_id: "folder-1" }); + expect(res).toBe(true); + }); + + test("should return false for notebook source if parent folder is not Templates", async () => { + jest.spyOn(joplin.settings, "value").mockImplementation(async (setting: string) => { + if (setting === "templatesSource") return "notebook"; + return ""; + }); + jest.spyOn(folderUtils, "getFolderFromId").mockImplementation(async (id: string) => { + if (id === "folder-2") return { id: "folder-2", title: "Personal" }; + return null; + }); + + const { isNoteATemplate } = await import("@templates/utils/templates"); + const res = await isNoteATemplate({ id: "note-1", title: "Test", body: "", parent_id: "folder-2" }); + expect(res).toBe(false); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 2d8e3f1..2c33b11 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -32,6 +32,11 @@ const packageJsonPath = `${rootDir}/package.json`; const manifest = readManifest(manifestPath); const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`); const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`); +const { builtinModules } = require('module'); +const moduleFallback = {}; +for (const moduleName of builtinModules) { + moduleFallback[moduleName] = false; +} function validatePackageJson() { const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); @@ -142,6 +147,7 @@ const pluginConfig = Object.assign({}, baseConfig, { alias: { api: path.resolve(__dirname, 'api'), }, + fallback: moduleFallback, // JSON files can also be required from scripts so we include this. // https://github.com/joplin/plugin-bibtex/pull/2 extensions: ['.tsx', '.ts', '.js', '.json'], @@ -176,6 +182,7 @@ const extraScriptConfig = Object.assign({}, baseConfig, { alias: { api: path.resolve(__dirname, 'api'), }, + fallback: moduleFallback, extensions: ['.tsx', '.ts', '.js', '.json'], }, }); @@ -183,11 +190,18 @@ const extraScriptConfig = Object.assign({}, baseConfig, { const createArchiveConfig = { stats: 'errors-only', entry: './dist/index.js', + resolve: { + fallback: moduleFallback, + }, output: { filename: 'index.js', path: publishDir, }, - plugins: [new WebpackOnBuildPlugin(onBuildCompleted)], + plugins: [{ + apply(compiler) { + compiler.hooks.done.tap('archiveOnBuildListener', onBuildCompleted); + }, + }], }; function resolveExtraScriptPath(name) { @@ -228,11 +242,11 @@ function buildExtraScriptConfigs(userConfig) { return output; } -function main(processArgv) { +function main(processArgv, env) { const yargs = require('yargs/yargs'); const argv = yargs(processArgv).argv; - const configName = argv['joplin-plugin-config']; + const configName = (env && env['joplin-plugin-config']) || argv['joplin-plugin-config']; if (!configName) throw new Error('A config file must be specified via the --joplin-plugin-config flag'); // Webpack configurations run in parallel, while we need them to run in @@ -270,19 +284,21 @@ function main(processArgv) { return configs[configName]; } -let exportedConfigs = []; +module.exports = (env = {}) => { + let exportedConfigs = []; -try { - exportedConfigs = main(process.argv); -} catch (error) { - console.error(chalk.red(error.message)); - process.exit(1); -} + try { + exportedConfigs = main(process.argv, env); + } catch (error) { + console.error(chalk.red(error.message)); + process.exit(1); + } -if (!exportedConfigs.length) { - // Nothing to do - for example where there are no external scripts to - // compile. - process.exit(0); -} + if (!exportedConfigs.length) { + // Nothing to do - for example where there are no external scripts to + // compile. + process.exit(0); + } -module.exports = exportedConfigs; + return exportedConfigs; +};