diff --git a/src/gui/AIAssistantProvidersModal.ts b/src/gui/AIAssistantProvidersModal.ts index 76db346f..82767c2c 100644 --- a/src/gui/AIAssistantProvidersModal.ts +++ b/src/gui/AIAssistantProvidersModal.ts @@ -6,6 +6,7 @@ import { dedupeModels } from "src/ai/modelsDirectory"; import { discoverProviderModels } from "src/ai/modelDiscoveryService"; import { ModelDirectoryModal } from "./ModelDirectoryModal"; import { setPasswordOnBlur } from "src/utils/setPasswordOnBlur"; +import { deepClone } from "src/utils/deepClone"; import GenericInputPrompt from "./GenericInputPrompt/GenericInputPrompt"; import { ProviderPickerModal } from "./ProviderPickerModal"; import GenericYesNoPrompt from "./GenericYesNoPrompt/GenericYesNoPrompt"; @@ -101,14 +102,14 @@ export class AIAssistantProvidersModal extends Modal { button.setWarning(); button.setIcon("trash" as IconType); }) - .addButton((button) => { - button.setButtonText("Edit").onClick(() => { - this.selectedProvider = provider; - this._selectedProviderClone = structuredClone(provider); + .addButton((button) => { + button.setButtonText("Edit").onClick(() => { + this.selectedProvider = provider; + this._selectedProviderClone = deepClone(provider); - this.reload(); + this.reload(); + }); }); - }); }); } diff --git a/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts b/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts index c120c911..921e44f1 100644 --- a/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts +++ b/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts @@ -3,19 +3,12 @@ import type { App } from "obsidian"; import type QuickAdd from "../../main"; import type IChoice from "../../types/choices/IChoice"; import type { ICommand } from "../../types/macros/ICommand"; +import { deepClone } from "../../utils/deepClone"; import { CommandSequenceEditor, type CommandSequenceEditorConditionalHandlers, } from "./CommandSequenceEditor"; -function cloneCommands(commands: ICommand[]): ICommand[] { - if (typeof structuredClone === "function") { - return structuredClone(commands); - } - - return JSON.parse(JSON.stringify(commands)) as ICommand[]; -} - interface ConditionalBranchEditorModalOptions { app: App; plugin: QuickAdd; @@ -40,7 +33,7 @@ export class ConditionalBranchEditorModal extends Modal { this.plugin = options.plugin; this.choices = options.choices; this.conditionalHandlers = options.conditionalHandlers; - this.workingCommands = cloneCommands(options.commands); + this.workingCommands = deepClone(options.commands); this.waitForClose = new Promise((resolve) => { this.resolvePromise = resolve; diff --git a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts index 788959bd..13c2d0db 100644 --- a/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts +++ b/src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts @@ -1,6 +1,7 @@ import type QuickAdd from "src/main"; import type IChoice from "src/types/choices/IChoice"; import type { IMacro } from "src/types/macros/IMacro"; +import { deepClone } from "src/utils/deepClone"; import { isMultiChoice } from "./helpers/isMultiChoice"; import { isNestedChoiceCommand } from "./helpers/isNestedChoiceCommand"; import { isOldTemplateChoice } from "./helpers/isOldTemplateChoice"; @@ -48,13 +49,13 @@ const incrementFileNameSettingMoveToDefaultBehavior: Migration = { "'Increment file name' setting moved to 'Set default behavior if file already exists' setting", migrate: async (plugin: QuickAdd): Promise => { - const choicesCopy = structuredClone(plugin.settings.choices); + const choicesCopy = deepClone(plugin.settings.choices); const choices = recursiveRemoveIncrementFileName(choicesCopy); - const macrosCopy = structuredClone((plugin.settings as any).macros || []); + const macrosCopy = deepClone((plugin.settings as any).macros || []); const macros = removeIncrementFileName(macrosCopy); - plugin.settings.choices = structuredClone(choices); + plugin.settings.choices = deepClone(choices); // Save the migrated macros back to settings - later migrations still need it (plugin.settings as any).macros = macros; diff --git a/src/migrations/migrate.ts b/src/migrations/migrate.ts index bed44f28..b6ffccab 100644 --- a/src/migrations/migrate.ts +++ b/src/migrations/migrate.ts @@ -9,6 +9,7 @@ import addDefaultAIProviders from "./addDefaultAIProviders"; import removeMacroIndirection from "./removeMacroIndirection"; import migrateFileOpeningSettings from "./migrateFileOpeningSettings"; import setProviderModelDiscoveryMode from "./setProviderModelDiscoveryMode"; +import { deepClone } from "src/utils/deepClone"; const migrations: Migrations = { useQuickAddTemplateFolder, @@ -38,7 +39,7 @@ async function migrate(plugin: QuickAdd): Promise { `Running migration ${migration}: ${migrations[migration].description}` ); - const backup = structuredClone(plugin.settings); + const backup = deepClone(plugin.settings); try { await migrations[migration].migrate(plugin); diff --git a/src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts b/src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts index 8a81fd2c..54c63867 100644 --- a/src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts +++ b/src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts @@ -4,6 +4,7 @@ import { isCaptureChoice } from "./helpers/isCaptureChoice"; import { isMultiChoice } from "./helpers/isMultiChoice"; import { isNestedChoiceCommand } from "./helpers/isNestedChoiceCommand"; import type { Migration } from "./Migrations"; +import { deepClone } from "src/utils/deepClone"; function recursiveMigrateSettingInChoices(choices: IChoice[]): IChoice[] { for (const choice of choices) { @@ -48,10 +49,10 @@ const mutualExclusionInsertAfterAndWriteToBottomOfFile: Migration = { "Mutual exclusion of insertAfter and writeToBottomOfFile settings. If insertAfter is enabled, writeToBottomOfFile is disabled. To support changes in settings UI.", migrate: async (plugin) => { - const choicesCopy = structuredClone(plugin.settings.choices); + const choicesCopy = deepClone(plugin.settings.choices); const choices = recursiveMigrateSettingInChoices(choicesCopy); - const macrosCopy = structuredClone((plugin.settings as any).macros || []); + const macrosCopy = deepClone((plugin.settings as any).macros || []); const macros = migrateSettingsInMacros(macrosCopy); plugin.settings.choices = choices; diff --git a/src/migrations/setProviderModelDiscoveryMode.ts b/src/migrations/setProviderModelDiscoveryMode.ts index 70142630..571f3449 100644 --- a/src/migrations/setProviderModelDiscoveryMode.ts +++ b/src/migrations/setProviderModelDiscoveryMode.ts @@ -1,6 +1,7 @@ import type QuickAdd from "src/main"; import { settingsStore } from "src/settingsStore"; import type { Migration } from "./Migrations"; +import { deepClone } from "src/utils/deepClone"; const setProviderModelDiscoveryMode: Migration = { description: @@ -25,7 +26,7 @@ const setProviderModelDiscoveryMode: Migration = { ...state, ai: { ...state.ai, - providers: structuredClone(providers), + providers: deepClone(providers), }, })); }, diff --git a/src/services/choiceService.ts b/src/services/choiceService.ts index b0f6208c..6d7a0868 100644 --- a/src/services/choiceService.ts +++ b/src/services/choiceService.ts @@ -18,6 +18,7 @@ import { MultiChoice } from "../types/choices/MultiChoice"; import { TemplateChoice } from "../types/choices/TemplateChoice"; import { excludeKeys } from "../utilityObsidian"; import { regenerateIds } from "../utils/macroUtils"; +import { deepClone } from "../utils/deepClone"; const choiceConstructors: Record IChoice> = { Template: TemplateChoice, @@ -49,9 +50,7 @@ export function duplicateChoice(choice: IChoice): IChoice { Object.assign(newChoice, excludeKeys(choice, ["id", "name"])); if (choice.type === "Macro") { - (newChoice as IMacroChoice).macro = structuredClone( - (choice as IMacroChoice).macro, - ); + (newChoice as IMacroChoice).macro = deepClone((choice as IMacroChoice).macro); regenerateIds((newChoice as IMacroChoice).macro); } diff --git a/src/services/packageExportService.ts b/src/services/packageExportService.ts index 822168e5..c2dcd983 100644 --- a/src/services/packageExportService.ts +++ b/src/services/packageExportService.ts @@ -10,12 +10,13 @@ import type { } from "../types/packages/QuickAddPackage"; import { QUICKADD_PACKAGE_SCHEMA_VERSION } from "../types/packages/QuickAddPackage"; import { - collectChoiceClosure, - collectScriptDependencies, - collectFileDependencies, + collectChoiceClosure, + collectScriptDependencies, + collectFileDependencies, } from "../utils/packageTraversal"; import { log } from "../logger/logManager"; import { encodeToBase64 } from "../utils/base64"; +import { deepClone } from "../utils/deepClone"; export interface BuildPackageOptions { choices: IChoice[]; @@ -61,15 +62,15 @@ export async function buildPackage( const assets = await encodeAssets(app, assetDescriptors); const packageChoices: QuickAddPackageChoice[] = closure.choiceIds.map( - (choiceId) => { - const entry = closure.catalog.get(choiceId); - if (!entry) throw new Error(`Choice '${choiceId}' missing from catalog.`); - const clonedChoice = structuredClone(entry.choice); - pruneChoiceTree(clonedChoice, includedChoiceIds); - return { - choice: clonedChoice, - pathHint: [...entry.path], - parentChoiceId: entry.parentId, + (choiceId) => { + const entry = closure.catalog.get(choiceId); + if (!entry) throw new Error(`Choice '${choiceId}' missing from catalog.`); + const clonedChoice = deepClone(entry.choice); + pruneChoiceTree(clonedChoice, includedChoiceIds); + return { + choice: clonedChoice, + pathHint: [...entry.path], + parentChoiceId: entry.parentId, }; }, ); diff --git a/src/services/packageImportService.ts b/src/services/packageImportService.ts index a45df0d8..9ff6162f 100644 --- a/src/services/packageImportService.ts +++ b/src/services/packageImportService.ts @@ -20,6 +20,7 @@ import type { IUserScript } from "../types/macros/IUserScript"; import { CommandType } from "../types/macros/CommandType"; import { log } from "../logger/logManager"; import { decodeFromBase64 } from "../utils/base64"; +import { deepClone } from "../utils/deepClone"; export interface LoadedQuickAddPackage { pkg: QuickAddPackage; @@ -259,7 +260,7 @@ export async function applyPackageImport( idMap.set(entry.choice.id, newId); } - const updatedChoices = structuredClone(existingChoices); + const updatedChoices = deepClone(existingChoices); const addedChoiceIds: string[] = []; const overwrittenChoiceIds: string[] = []; const skippedChoiceIds: string[] = []; @@ -272,10 +273,10 @@ export async function applyPackageImport( continue; } - const clone = structuredClone(entry.choice); - const remapped = remapChoiceTree(clone, idMap, importableChoiceIds); - preparedChoices.set(entry.choice.id, remapped); - } + const clone = deepClone(entry.choice); + const remapped = remapChoiceTree(clone, idMap, importableChoiceIds); + preparedChoices.set(entry.choice.id, remapped); + } const handledChoices = new Set(); diff --git a/src/settingsStore.ts b/src/settingsStore.ts index ea7fbd06..217f661f 100644 --- a/src/settingsStore.ts +++ b/src/settingsStore.ts @@ -1,12 +1,13 @@ import { createStore } from "zustand/vanilla"; import type { QuickAddSettings } from "./quickAddSettingsTab"; import { DEFAULT_SETTINGS } from "./quickAddSettingsTab"; +import { deepClone } from "./utils/deepClone"; type SettingsState = QuickAddSettings; export const settingsStore = (() => { const useSettingsStore = createStore((set, _get) => ({ - ...structuredClone(DEFAULT_SETTINGS), + ...deepClone(DEFAULT_SETTINGS), })); const { getState, setState, subscribe } = useSettingsStore; diff --git a/src/utilityObsidian.ts b/src/utilityObsidian.ts index 2d9316e3..472be16b 100644 --- a/src/utilityObsidian.ts +++ b/src/utilityObsidian.ts @@ -21,6 +21,7 @@ import type { AppendLinkOptions, LinkPlacement } from "./types/linkPlacement"; import { placementSupportsEmbed } from "./types/linkPlacement"; import type { IUserScript } from "./types/macros/IUserScript"; import { reportError } from "./utils/errorUtils"; +import { deepClone } from "./utils/deepClone"; export type TemplaterPluginLike = { settings?: { @@ -945,7 +946,7 @@ export function excludeKeys( sourceObj: T, except: K[], ): Omit { - const obj = structuredClone(sourceObj); + const obj = deepClone(sourceObj); for (const key of except) { delete obj[key]; diff --git a/src/utils/deepClone.test.ts b/src/utils/deepClone.test.ts new file mode 100644 index 00000000..0b3b2dd9 --- /dev/null +++ b/src/utils/deepClone.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { deepClone } from "./deepClone"; + +describe("deepClone", () => { + const originalStructuredClone = (globalThis as any).structuredClone; + + afterEach(() => { + (globalThis as any).structuredClone = originalStructuredClone; + }); + + it("deep clones plain objects when structuredClone is missing", () => { + (globalThis as any).structuredClone = undefined; + + const value = { a: 1, b: { c: 2 }, d: [1, { e: 3 }] }; + const cloned = deepClone(value); + + expect(cloned).toEqual(value); + expect(cloned).not.toBe(value); + expect(cloned.b).not.toBe(value.b); + expect(cloned.d).not.toBe(value.d); + expect(cloned.d[1]).not.toBe(value.d[1]); + }); + + it("handles circular references in the fallback clone", () => { + (globalThis as any).structuredClone = undefined; + + const value: { self?: unknown } = {}; + value.self = value; + + const cloned = deepClone(value) as typeof value; + expect(cloned).not.toBe(value); + expect(cloned.self).toBe(cloned); + }); + + it("falls back if structuredClone throws", () => { + (globalThis as any).structuredClone = () => { + throw new Error("boom"); + }; + + const value = { a: 1, b: { c: 2 } }; + const cloned = deepClone(value); + + expect(cloned).toEqual(value); + expect(cloned).not.toBe(value); + expect(cloned.b).not.toBe(value.b); + }); + + it("clones class instances without mutating the original", () => { + (globalThis as any).structuredClone = undefined; + + class Example { + public nested: { value: number }; + + constructor(value: number) { + this.nested = { value }; + } + } + + const instance = new Example(123); + const cloned = deepClone(instance); + + expect(cloned).toBeInstanceOf(Example); + expect(cloned).not.toBe(instance); + expect(cloned.nested).toEqual(instance.nested); + expect(cloned.nested).not.toBe(instance.nested); + }); +}); diff --git a/src/utils/deepClone.ts b/src/utils/deepClone.ts new file mode 100644 index 00000000..1b895bc0 --- /dev/null +++ b/src/utils/deepClone.ts @@ -0,0 +1,118 @@ +export function deepClone(value: T): T { + const structuredCloneFn = (globalThis as any).structuredClone as + | ((value: unknown) => unknown) + | undefined; + + if (typeof structuredCloneFn === "function") { + try { + return structuredCloneFn(value) as T; + } catch { + // Fall back to a JS implementation below. + } + } + + return deepCloneFallback(value, new Map()) as T; +} + +function deepCloneFallback(value: unknown, seen: Map): unknown { + if (value === null || typeof value !== "object") { + return value; + } + + if (seen.has(value)) { + return seen.get(value); + } + + if (value instanceof Date) { + const cloned = new Date(value.getTime()); + seen.set(value, cloned); + return cloned; + } + + if (Array.isArray(value)) { + const cloned: unknown[] = []; + seen.set(value, cloned); + for (const item of value) { + cloned.push(deepCloneFallback(item, seen)); + } + return cloned; + } + + if (value instanceof Map) { + const cloned = new Map(); + seen.set(value, cloned); + for (const [key, nestedValue] of value.entries()) { + cloned.set( + deepCloneFallback(key, seen), + deepCloneFallback(nestedValue, seen), + ); + } + return cloned; + } + + if (value instanceof Set) { + const cloned = new Set(); + seen.set(value, cloned); + for (const item of value.values()) { + cloned.add(deepCloneFallback(item, seen)); + } + return cloned; + } + + if (value instanceof RegExp) { + const cloned = new RegExp(value.source, value.flags); + cloned.lastIndex = value.lastIndex; + seen.set(value, cloned); + return cloned; + } + + if (value instanceof ArrayBuffer) { + const cloned = value.slice(0); + seen.set(value, cloned); + return cloned; + } + + if (ArrayBuffer.isView(value)) { + if (value instanceof DataView) { + const cloned = new DataView( + deepCloneFallback(value.buffer, seen) as ArrayBuffer, + value.byteOffset, + value.byteLength, + ); + seen.set(value, cloned); + return cloned; + } + + const typedArray = value as unknown as { + constructor: new ( + buffer: ArrayBuffer, + byteOffset: number, + length: number, + ) => unknown; + buffer: ArrayBuffer; + byteOffset: number; + length: number; + }; + const clonedBuffer = deepCloneFallback(typedArray.buffer, seen) as ArrayBuffer; + const cloned = new typedArray.constructor( + clonedBuffer, + typedArray.byteOffset, + typedArray.length, + ); + seen.set(value, cloned); + return cloned; + } + + const prototype = Object.getPrototypeOf(value); + const cloned: Record = Object.create(prototype); + seen.set(value, cloned); + for (const [key, nestedValue] of Object.entries(value)) { + Object.defineProperty(cloned, key, { + value: deepCloneFallback(nestedValue, seen), + writable: true, + enumerable: true, + configurable: true, + }); + } + return cloned; +}