Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/engine/CaptureChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
announceUpdates: true,
announceUpdates: "all",
version: "0.0.0",
globalVariables: {},
onePageInputEnabled: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
announceUpdates: true,
announceUpdates: "all",
version: "0.0.0",
globalVariables: {},
onePageInputEnabled: false,
Expand Down
2 changes: 1 addition & 1 deletion src/engine/MacroChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
announceUpdates: true,
announceUpdates: "all",
version: "0.0.0",
globalVariables: {},
onePageInputEnabled: false,
Expand Down
2 changes: 1 addition & 1 deletion src/engine/TemplateChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ vi.mock("../quickAddSettingsTab", () => {
inputPrompt: "single-line",
devMode: false,
templateFolderPath: "",
announceUpdates: true,
announceUpdates: "all",
version: "0.0.0",
globalVariables: {},
onePageInputEnabled: false,
Expand Down
28 changes: 26 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}
}

40 changes: 25 additions & 15 deletions src/quickAddSettingsTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/**
* 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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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"],
});
});
});
}

Expand Down
75 changes: 75 additions & 0 deletions src/utils/semver.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

91 changes: 91 additions & 0 deletions src/utils/semver.ts
Original file line number Diff line number Diff line change
@@ -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;
}