diff --git a/src/sing/fileUtils.ts b/src/sing/fileUtils.ts deleted file mode 100644 index 504708d69f..0000000000 --- a/src/sing/fileUtils.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 指定されたファイルパスに対応するファイルが既に存在する場合、 - * ファイル名に連番のサフィックスを追加してユニークなファイルパスを生成する。 - * TODO: src/store/audio.tsのchangeFileTailToNonExistent関数と統合する - */ -export async function generateUniqueFilePath( - filePathWithoutExtension: string, - extension: string, -) { - let filePath = `${filePathWithoutExtension}.${extension}`; - let tail = 1; - while (await window.backend.checkFileExists(filePath)) { - filePath = `${filePathWithoutExtension}[${tail}].${extension}`; - tail += 1; - } - return filePath; -} diff --git a/src/store/audio.ts b/src/store/audio.ts index e5749eb54a..77ed0777e8 100644 --- a/src/store/audio.ts +++ b/src/store/audio.ts @@ -24,6 +24,7 @@ import { TuningTranscription, filterCharacterInfosByStyleType, DEFAULT_PROJECT_NAME, + generateUniqueFilePath, } from "./utility"; import { createPartialStore } from "./vuex"; import { determineNextPresetKey } from "./preset"; @@ -130,20 +131,6 @@ function parseTextFile( return audioItems; } -// TODO: src/sing/fileUtils.tsのgenerateUniqueFilePathと統合する -async function changeFileTailToNonExistent( - filePath: string, - extension: string, -) { - let tail = 1; - const name = filePath.slice(0, filePath.length - 1 - extension.length); - while (await window.backend.checkFileExists(filePath)) { - filePath = `${name}[${tail}].${extension}`; - tail += 1; - } - return filePath; -} - export async function writeTextFile(obj: { filePath: string; text: string; @@ -1396,7 +1383,7 @@ export const audioStore = createPartialStore({ } if (state.savingSetting.avoidOverwrite) { - filePath = await changeFileTailToNonExistent(filePath, "wav"); + filePath = await generateUniqueFilePath(filePath, "wav"); } let fetchAudioResult: FetchAudioResult; @@ -1546,7 +1533,7 @@ export const audioStore = createPartialStore({ } if (state.savingSetting.avoidOverwrite) { - filePath = await changeFileTailToNonExistent(filePath, "wav"); + filePath = await generateUniqueFilePath(filePath, "wav"); } const encodedBlobs: string[] = []; @@ -1679,7 +1666,7 @@ export const audioStore = createPartialStore({ } if (state.savingSetting.avoidOverwrite) { - filePath = await changeFileTailToNonExistent(filePath, "txt"); + filePath = await generateUniqueFilePath(filePath, "txt"); } const characters = new Map(); diff --git a/src/store/singing.ts b/src/store/singing.ts index 3a9a89862d..c85b3195d0 100644 --- a/src/store/singing.ts +++ b/src/store/singing.ts @@ -30,6 +30,7 @@ import { DEFAULT_STYLE_NAME, generateLabelFileData, PhonemeTimingLabel, + generateUniqueFilePath, sanitizeFileName, } from "./utility"; import { @@ -105,7 +106,6 @@ import { generateWavFileData } from "@/helpers/fileDataGenerator"; import path from "@/helpers/path"; import { showAlertDialog } from "@/components/Dialog/Dialog"; import { ufProjectFromVoicevox } from "@/sing/utaformatixProject/fromVoicevox"; -import { generateUniqueFilePath } from "@/sing/fileUtils"; import { isMultiFileProjectFormat, isSingleFileProjectFormat, @@ -3263,11 +3263,7 @@ export const singingStore = createPartialStore({ if (!filePath) { return { result: "CANCELED", path: "" }; } - filePath = await generateUniqueFilePath( - // 拡張子を除いたファイル名を取得 - filePath.slice(0, -(extension.length + 1)), - extension, - ); + filePath = await generateUniqueFilePath(filePath, extension); return await actions.EXPORT_FILE({ filePath, diff --git a/src/store/utility.ts b/src/store/utility.ts index dd170cd882..3adde06cc0 100644 --- a/src/store/utility.ts +++ b/src/store/utility.ts @@ -541,3 +541,38 @@ export async function generateLabelFileData(labels: PhonemeTimingLabel[]) { return await generateTextFileData({ text: labString }); } + +/** + * 指定されたファイルパスに対応するファイルが既に存在する場合、 + * ファイル名に連番のサフィックスを追加してユニークなファイルパスを生成する。 + * + * NOTE: extension はドットなし(例: "wav")を想定する。 + */ +export async function generateUniqueFilePath( + filePath: string, + extension: string, +): Promise { + let currentPath = filePath; + let baseName: string; + + if (extension && filePath.endsWith(`.${extension}`)) { + // 拡張子あり → 拡張子を除いたベース名 + baseName = filePath.slice(0, -(extension.length + 1)); + } else { + // 拡張子なし → 全体をベース名として扱う + baseName = filePath; + + if (extension) { + currentPath = `${filePath}.${extension}`; + } + } + + let tail = 1; + while (await window.backend.checkFileExists(currentPath)) { + currentPath = extension + ? `${baseName}[${tail}].${extension}` + : `${baseName}[${tail}]`; + tail += 1; + } + return currentPath; +} diff --git a/tests/unit/store/utility.spec.ts b/tests/unit/store/utility.spec.ts index fc1f1ca66c..86ac86c449 100644 --- a/tests/unit/store/utility.spec.ts +++ b/tests/unit/store/utility.spec.ts @@ -1,4 +1,4 @@ -import { it, expect, describe, test } from "vitest"; +import { it, expect, describe, test, vi } from "vitest"; import { AccentPhrase, Mora } from "@/openapi"; import { CharacterInfo, @@ -20,6 +20,7 @@ import { getToolbarButtonName, isOnCommandOrCtrlKeyDown, filterCharacterInfosByStyleType, + generateUniqueFilePath, } from "@/store/utility"; import { uuid4 } from "@/helpers/random"; import { isMac } from "@/helpers/platform"; @@ -362,3 +363,82 @@ describe("filterCharacterInfosByStyleType", () => { expect(filtered[2].metas.styles.length).toBe(1); }); }); + +describe("generateUniqueFilePath", () => { + it("ファイルが存在しない場合は元のパスを返す", async () => { + const checkFileExists = vi.fn().mockResolvedValue(false); + vi.stubGlobal("window", { + backend: { + checkFileExists, + }, + }); + + const result = await generateUniqueFilePath("test.wav", "wav"); + expect(result).toBe("test.wav"); + expect(checkFileExists).toHaveBeenCalledWith("test.wav"); + }); + + it("拡張子なしのパスが渡された場合、拡張子を付与してチェックする", async () => { + const checkFileExists = vi.fn().mockResolvedValue(false); + vi.stubGlobal("window", { + backend: { + checkFileExists, + }, + }); + + const result = await generateUniqueFilePath("test", "wav"); + expect(result).toBe("test.wav"); + expect(checkFileExists).toHaveBeenCalledWith("test.wav"); + }); + + it("ファイルが存在する場合は連番を付与する", async () => { + const checkFileExists = vi + .fn() + .mockResolvedValueOnce(true) // test.wav exists + .mockResolvedValueOnce(false); // test[1].wav does not exist + + vi.stubGlobal("window", { + backend: { + checkFileExists, + }, + }); + + const result = await generateUniqueFilePath("test.wav", "wav"); + expect(result).toBe("test[1].wav"); + expect(checkFileExists).toHaveBeenCalledWith("test.wav"); + expect(checkFileExists).toHaveBeenCalledWith("test[1].wav"); + }); + + it("連番ファイルも存在する場合は次の連番を付与する", async () => { + const checkFileExists = vi + .fn() + .mockResolvedValueOnce(true) // test.wav exists + .mockResolvedValueOnce(true) // test[1].wav exists + .mockResolvedValueOnce(false); // test[2].wav does not exist + + vi.stubGlobal("window", { + backend: { + checkFileExists, + }, + }); + + const result = await generateUniqueFilePath("test.wav", "wav"); + expect(result).toBe("test[2].wav"); + }); + + it("拡張子が空の場合は連番のみ付与する", async () => { + const checkFileExists = vi + .fn() + .mockResolvedValueOnce(true) // test exists + .mockResolvedValueOnce(false); // test[1] does not exist + + vi.stubGlobal("window", { + backend: { + checkFileExists, + }, + }); + + const result = await generateUniqueFilePath("test", ""); + expect(result).toBe("test[1]"); + }); +});