From eaf54924532bef17751d5eb6cc963d7d3bb679b4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 14 Nov 2025 15:28:23 +0100 Subject: [PATCH] feat: add update modal settings for major releases only - Replace boolean announceUpdates setting with dropdown (all/major/none) - Add robust semver parsing utility with comprehensive test coverage - Migrate legacy boolean values to new string preference - Show update modal only on major version bumps when 'major' option selected Closes #447 --- src/engine/CaptureChoiceEngine.notice.test.ts | 2 +- ...oiceEngine.template-property-types.test.ts | 2 +- src/engine/MacroChoiceEngine.notice.test.ts | 2 +- .../TemplateChoiceEngine.notice.test.ts | 2 +- src/main.ts | 28 +++++- src/quickAddSettingsTab.ts | 40 +++++--- src/utils/semver.test.ts | 75 +++++++++++++++ src/utils/semver.ts | 91 +++++++++++++++++++ 8 files changed, 221 insertions(+), 21 deletions(-) create mode 100644 src/utils/semver.test.ts create mode 100644 src/utils/semver.ts diff --git a/src/engine/CaptureChoiceEngine.notice.test.ts b/src/engine/CaptureChoiceEngine.notice.test.ts index dda4b769..2d4b66d3 100644 --- a/src/engine/CaptureChoiceEngine.notice.test.ts +++ b/src/engine/CaptureChoiceEngine.notice.test.ts @@ -6,7 +6,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", - announceUpdates: true, + announceUpdates: "all", version: "0.0.0", globalVariables: {}, onePageInputEnabled: false, diff --git a/src/engine/CaptureChoiceEngine.template-property-types.test.ts b/src/engine/CaptureChoiceEngine.template-property-types.test.ts index cf11dc03..0bffc466 100644 --- a/src/engine/CaptureChoiceEngine.template-property-types.test.ts +++ b/src/engine/CaptureChoiceEngine.template-property-types.test.ts @@ -11,7 +11,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", - announceUpdates: true, + announceUpdates: "all", version: "0.0.0", globalVariables: {}, onePageInputEnabled: false, diff --git a/src/engine/MacroChoiceEngine.notice.test.ts b/src/engine/MacroChoiceEngine.notice.test.ts index 7c111cbb..dcb284ee 100644 --- a/src/engine/MacroChoiceEngine.notice.test.ts +++ b/src/engine/MacroChoiceEngine.notice.test.ts @@ -6,7 +6,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", - announceUpdates: true, + announceUpdates: "all", version: "0.0.0", globalVariables: {}, onePageInputEnabled: false, diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 6a9732e7..bde1e27b 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -6,7 +6,7 @@ vi.mock("../quickAddSettingsTab", () => { inputPrompt: "single-line", devMode: false, templateFolderPath: "", - announceUpdates: true, + announceUpdates: "all", version: "0.0.0", globalVariables: {}, onePageInputEnabled: false, diff --git a/src/main.ts b/src/main.ts index b94f40ee..572c9446 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ import { UpdateModal } from "./gui/UpdateModal/UpdateModal"; import { CommandType } from "./types/macros/CommandType"; import { InfiniteAIAssistantCommandSettingsModal } from "./gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal"; import { FieldSuggestionCache } from "./utils/FieldSuggestionCache"; +import { isMajorUpdate } from "./utils/semver"; // Parameters prefixed with `value-` get used as named values for the executed choice type CaptureValueParameters = { [key in `value-${string}`]?: string }; @@ -193,7 +194,20 @@ export default class QuickAdd extends Plugin { } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + const loadedData = await this.loadData(); + const settings = Object.assign( + {}, + DEFAULT_SETTINGS, + loadedData, + ) as QuickAddSettings & { + announceUpdates: QuickAddSettings["announceUpdates"] | boolean; + }; + + if (typeof settings.announceUpdates === "boolean") { + settings.announceUpdates = settings.announceUpdates ? "all" : "none"; + } + + this.settings = settings; } async saveSettings() { @@ -291,12 +305,22 @@ export default class QuickAdd extends Plugin { if (currentVersion === knownVersion) return; + const preference = this.settings.announceUpdates; + let shouldAnnounce = true; + + if (preference === "none") { + shouldAnnounce = false; + } else if (preference === "major" && !isMajorUpdate(currentVersion, knownVersion)) { + shouldAnnounce = false; + } + this.settings.version = currentVersion; void this.saveSettings(); - if (this.settings.announceUpdates === false) return; + if (!shouldAnnounce) return; const updateModal = new UpdateModal(this.app, knownVersion); updateModal.open(); } } + diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index 14474ec9..7d5cf1e2 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -16,18 +16,18 @@ export interface QuickAddSettings { inputPrompt: "multi-line" | "single-line"; devMode: boolean; templateFolderPath: string; - announceUpdates: boolean; + announceUpdates: "all" | "major" | "none"; version: string; globalVariables: Record; /** - * Enables the one-page input flow that pre-collects variables - * and renders a single dynamic GUI before executing a choice. - */ + * Enables the one-page input flow that pre-collects variables + * and renders a single dynamic GUI before executing a choice. + */ onePageInputEnabled: boolean; /** - * If this is true, then the plugin is not to contact external services (e.g. OpenAI, etc.) via plugin features. - * Users _can_ still use User Scripts to do so by executing arbitrary JavaScript, but that is not something the plugin controls. - */ + * If this is true, then the plugin is not to contact external services (e.g. OpenAI, etc.) via plugin features. + * Users _can_ still use User Scripts to do so by executing arbitrary JavaScript, but that is not something the plugin controls. + */ disableOnlineFeatures: boolean; enableRibbonIcon: boolean; showCaptureNotification: boolean; @@ -58,7 +58,7 @@ export const DEFAULT_SETTINGS: QuickAddSettings = { inputPrompt: "single-line", devMode: false, templateFolderPath: "", - announceUpdates: true, + announceUpdates: "all", version: "0.0.0", globalVariables: {}, onePageInputEnabled: false, @@ -76,8 +76,8 @@ export const DEFAULT_SETTINGS: QuickAddSettings = { }, migrations: { /** - * @deprecated kept for backward compatibility; always true, ignored. - */ + * @deprecated kept for backward compatibility; always true, ignored. + */ migrateToMacroIDFromEmbeddedMacro: true, useQuickAddTemplateFolder: false, incrementFileNameSettingMoveToDefaultBehavior: false, @@ -171,11 +171,21 @@ export class QuickAddSettingsTab extends PluginSettingTab { setting.setDesc( "Display release notes when a new version is installed. This includes new features, demo videos, and bug fixes." ); - setting.addToggle((toggle) => { - toggle.setValue(settingsStore.getState().announceUpdates); - toggle.onChange((value) => { - settingsStore.setState({ announceUpdates: value }); - }); + setting.addDropdown((dropdown) => { + const currentValue = settingsStore.getState().announceUpdates; + dropdown + .addOption("all", "Show updates on each new release") + .addOption( + "major", + "Show updates only on major releases (new features, breaking changes)" + ) + .addOption("none", "Don't show") + .setValue(currentValue) + .onChange((value) => { + settingsStore.setState({ + announceUpdates: value as QuickAddSettings["announceUpdates"], + }); + }); }); } diff --git a/src/utils/semver.test.ts b/src/utils/semver.test.ts new file mode 100644 index 00000000..914cfff9 --- /dev/null +++ b/src/utils/semver.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { parseSemver, isMajorUpdate } from "./semver"; + +describe("parseSemver", () => { + it("parses standard semantic versions", () => { + expect(parseSemver("2.7.0")).toEqual({ major: 2, minor: 7, patch: 0 }); + expect(parseSemver("1.0.0")).toEqual({ major: 1, minor: 0, patch: 0 }); + expect(parseSemver("10.20.30")).toEqual({ major: 10, minor: 20, patch: 30 }); + }); + + it("handles pre-release versions", () => { + expect(parseSemver("2.7.0-beta.1")).toEqual({ major: 2, minor: 7, patch: 0 }); + expect(parseSemver("2.7.0-alpha")).toEqual({ major: 2, minor: 7, patch: 0 }); + expect(parseSemver("2.7.0-rc.2")).toEqual({ major: 2, minor: 7, patch: 0 }); + }); + + it("handles build metadata", () => { + expect(parseSemver("2.7.0+123")).toEqual({ major: 2, minor: 7, patch: 0 }); + expect(parseSemver("2.7.0+20240101")).toEqual({ major: 2, minor: 7, patch: 0 }); + }); + + it("handles both pre-release and build metadata", () => { + expect(parseSemver("2.7.0-beta.1+123")).toEqual({ major: 2, minor: 7, patch: 0 }); + }); + + it("returns null for invalid versions", () => { + expect(parseSemver("")).toBeNull(); + expect(parseSemver("invalid")).toBeNull(); + expect(parseSemver("2.7")).toBeNull(); + expect(parseSemver("2")).toBeNull(); + expect(parseSemver("2.7.0.1")).toBeNull(); + expect(parseSemver("2.7.x")).toBeNull(); + expect(parseSemver("v2.7.0")).toBeNull(); + }); + + it("handles null and undefined", () => { + expect(parseSemver(null as unknown as string)).toBeNull(); + expect(parseSemver(undefined as unknown as string)).toBeNull(); + }); + + it("handles negative numbers", () => { + expect(parseSemver("-1.0.0")).toBeNull(); + }); +}); + +describe("isMajorUpdate", () => { + it("detects major version bumps", () => { + expect(isMajorUpdate("3.0.0", "2.7.0")).toBe(true); + expect(isMajorUpdate("2.0.0", "1.9.9")).toBe(true); + expect(isMajorUpdate("10.0.0", "9.99.99")).toBe(true); + }); + + it("returns false for minor and patch updates", () => { + expect(isMajorUpdate("2.8.0", "2.7.0")).toBe(false); + expect(isMajorUpdate("2.7.1", "2.7.0")).toBe(false); + expect(isMajorUpdate("2.7.0", "2.7.0")).toBe(false); + }); + + it("handles pre-release versions", () => { + expect(isMajorUpdate("3.0.0-beta.1", "2.7.0")).toBe(true); + expect(isMajorUpdate("2.8.0-alpha", "2.7.0")).toBe(false); + }); + + it("returns true when versions cannot be parsed (err on side of caution)", () => { + expect(isMajorUpdate("invalid", "2.7.0")).toBe(true); + expect(isMajorUpdate("2.7.0", "invalid")).toBe(true); + expect(isMajorUpdate("invalid", "invalid")).toBe(true); + }); + + it("handles downgrades (should not show as major update)", () => { + expect(isMajorUpdate("1.0.0", "2.7.0")).toBe(false); + expect(isMajorUpdate("2.6.0", "2.7.0")).toBe(false); + }); +}); + diff --git a/src/utils/semver.ts b/src/utils/semver.ts new file mode 100644 index 00000000..76526f5b --- /dev/null +++ b/src/utils/semver.ts @@ -0,0 +1,91 @@ +/** + * Semantic version parsing and comparison utilities. + * Handles standard semver format (major.minor.patch) with optional pre-release + * and build metadata suffixes (e.g., "2.7.0-beta.1", "2.7.0+123"). + */ + +export interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +/** + * Parses a semantic version string into its components. + * Handles versions with pre-release and build metadata suffixes by ignoring them. + * + * @param version - Version string (e.g., "2.7.0", "2.7.0-beta.1", "2.7.0+123") + * @returns Parsed version object or null if the version string is invalid + * + * @example + * parseSemver("2.7.0") // { major: 2, minor: 7, patch: 0 } + * parseSemver("2.7.0-beta.1") // { major: 2, minor: 7, patch: 0 } + * parseSemver("invalid") // null + */ +export function parseSemver(version: string): ParsedVersion | null { + if (!version || typeof version !== "string") { + return null; + } + + // Remove pre-release and build metadata suffixes + // e.g., "2.7.0-beta.1" -> "2.7.0", "2.7.0+123" -> "2.7.0" + const baseVersion = version.split("-")[0]?.split("+")[0]?.trim(); + if (!baseVersion) { + return null; + } + + // Split into parts and parse numeric components + const parts = baseVersion.split("."); + if (parts.length !== 3) { + return null; + } + + const major = Number.parseInt(parts[0] ?? "", 10); + const minor = Number.parseInt(parts[1] ?? "", 10); + const patch = Number.parseInt(parts[2] ?? "", 10); + + // Validate that all parts are valid numbers + if ( + Number.isNaN(major) || + Number.isNaN(minor) || + Number.isNaN(patch) || + major < 0 || + minor < 0 || + patch < 0 + ) { + return null; + } + + return { major, minor, patch }; +} + +/** + * Determines if an update from previousVersion to currentVersion is a major version bump. + * A major update occurs when the major version number increases. + * + * @param currentVersion - The new version string + * @param previousVersion - The previous version string + * @returns true if it's a major update, false otherwise. Returns true if either version + * cannot be parsed (to err on the side of showing updates when uncertain). + * + * @example + * isMajorUpdate("3.0.0", "2.7.0") // true + * isMajorUpdate("2.8.0", "2.7.0") // false + * isMajorUpdate("2.7.0", "invalid") // true (shows update when uncertain) + */ +export function isMajorUpdate( + currentVersion: string, + previousVersion: string, +): boolean { + const current = parseSemver(currentVersion); + const previous = parseSemver(previousVersion); + + // If either version is invalid, default to showing the update + // This ensures users don't miss important updates due to parsing issues + if (!current || !previous) { + return true; + } + + return current.major > previous.major; +} +