diff --git a/src/components/Dialog/Dialog.ts b/src/components/Dialog/Dialog.ts index c29b0cd5cb..02dfadc77f 100644 --- a/src/components/Dialog/Dialog.ts +++ b/src/components/Dialog/Dialog.ts @@ -12,6 +12,7 @@ import { } from "@/store/type"; import { DotNotationDispatch } from "@/store/vuex"; import { withProgress } from "@/store/ui"; +import { errorToMessage } from "@/helpers/errorHelper"; type MediaType = "audio" | "text" | "project" | "label"; @@ -86,6 +87,14 @@ export const showAlertDialog = async ( }); }; +/** 例外からエラーダイアログを表示する便利関数 */ +export const showErrorDialog = async (title: string, e: unknown) => { + return showAlertDialog({ + title, + message: errorToMessage(e), + }); +}; + /** 続行することが望まれそうな場合の質問ダイアログ */ export const showConfirmDialog = async (options: ConfirmDialogOptions) => { options.cancel ??= "キャンセル"; diff --git a/src/components/Menu/MenuBar/MenuBar.vue b/src/components/Menu/MenuBar/MenuBar.vue index 969ea5780b..c6d83496a9 100644 --- a/src/components/Menu/MenuBar/MenuBar.vue +++ b/src/components/Menu/MenuBar/MenuBar.vue @@ -142,19 +142,19 @@ const createNewProject = async () => { const saveProject = async () => { if (!uiLocked.value) { - await store.actions.SAVE_PROJECT_FILE({ overwrite: true }); + await store.actions.SAVE_PROJECT_FILE_OVERWRITE(); } }; const saveProjectAs = async () => { if (!uiLocked.value) { - await store.actions.SAVE_PROJECT_FILE({}); + await store.actions.SAVE_PROJECT_FILE_AS(); } }; const saveProjectCopy = async () => { if (!uiLocked.value) { - await store.actions.SAVE_PROJECT_FILE_AS_COPY({}); + await store.actions.SAVE_PROJECT_FILE_AS_COPY(); } }; diff --git a/src/components/Talk/ToolBar.vue b/src/components/Talk/ToolBar.vue index 12b1bbbc86..0ecca782e2 100644 --- a/src/components/Talk/ToolBar.vue +++ b/src/components/Talk/ToolBar.vue @@ -142,7 +142,7 @@ const generateAndConnectAndSaveAudio = async () => { }); }; const saveProject = async () => { - await store.actions.SAVE_PROJECT_FILE({ overwrite: true }); + await store.actions.SAVE_PROJECT_FILE_OVERWRITE(); }; const importTextFile = () => { void store.actions.COMMAND_IMPORT_FROM_FILE({ type: "dialog" }); diff --git a/src/store/project.ts b/src/store/project/index.ts similarity index 72% rename from src/store/project.ts rename to src/store/project/index.ts index 1fad123e61..ae7e903004 100755 --- a/src/store/project.ts +++ b/src/store/project/index.ts @@ -1,4 +1,10 @@ -import { createPartialStore, DotNotationDispatch } from "./vuex"; +import { createPartialStore, DotNotationDispatch } from "../vuex"; +import { + executeWritePromiseOrDialog, + promptProjectSaveFilePath, + markCurrentProjectAsSaved, + writeProjectFile, +} from "./saveProjectHelper"; import { createUILockAction } from "@/store/ui"; import { AllActions, @@ -28,8 +34,6 @@ import { showQuestionDialog, } from "@/components/Dialog/Dialog"; import { uuid4 } from "@/helpers/random"; -import { getAppInfos } from "@/domain/appInfo"; -import { errorToMessage } from "@/helpers/errorHelper"; export const projectStoreState: ProjectStoreState = { savedLastCommandIds: { talk: null, song: null }, @@ -247,143 +251,73 @@ export const projectStore = createPartialStore({ ), }, - SAVE_PROJECT_FILE_AS_COPY: { + SAVE_PROJECT_FILE_OVERWRITE: { /** - * プロジェクトファイルを複製として保存する。保存の成否が返る。 + * プロジェクトファイルを上書き保存する。 + * 現在のプロジェクトファイルが未設定の場合は名前をつけて保存する。 + * ファイルを保存できた場合はtrueが、キャンセルしたか例外が発生した場合はfalseが返る。 * エラー発生時はダイアログが表示される。 */ - action: createUILockAction(async (context, { filePath: filePathArg }) => { - let filePath = filePathArg; - try { - if (!filePath) { - filePath = await context.actions.PROMPT_PROJECT_SAVE_FILE_PATH({}); - if (!filePath) { - return false; - } - } - await context.actions.WRITE_PROJECT_FILE({ filePath }); - - return true; - } catch (err) { - window.backend.logError(err); - await showAlertDialog({ - title: "エラー", - message: `プロジェクトファイルの保存に失敗しました。\n${errorToMessage(err)}`, - }); - return false; + action: createUILockAction(async (context) => { + const filePath = context.state.projectFilePath; + if (!filePath) { + return await context.actions.SAVE_PROJECT_FILE_AS(); } + + const result = await executeWritePromiseOrDialog( + writeProjectFile(context, filePath), + ); + if (!result) return false; + + await markCurrentProjectAsSaved(context, filePath); + return true; }), }, - SAVE_PROJECT_FILE: { + SAVE_PROJECT_FILE_AS: { /** - * プロジェクトファイルを保存し、現在のプロジェクトファイルのパスを更新する。保存の成否が返る。 + * プロジェクトファイルを名前をつけて保存し、現在のプロジェクトファイルのパスを更新する。 + * ファイルを保存できた場合はtrueが、キャンセルしたか例外が発生した場合はfalseが返る。 + * エラー発生時はダイアログが表示される。 */ - action: createUILockAction( - async (context, { overwrite }: { overwrite?: boolean }) => { - let filePath = context.state.projectFilePath; - - try { - if (!overwrite || !filePath) { - filePath = await context.actions.PROMPT_PROJECT_SAVE_FILE_PATH({ - defaultFilePath: filePath, - }); - if (!filePath) { - return false; - } - } - - if ( - context.state.projectFilePath && - context.state.projectFilePath != filePath - ) { - await showMessageDialog({ - type: "info", - title: "保存", - message: `編集中のプロジェクトが ${filePath} に切り替わりました。`, - }); - } - - await context.actions.APPEND_RECENTLY_USED_PROJECT({ - filePath, - }); - await context.actions.WRITE_PROJECT_FILE({ filePath }); - - context.mutations.SET_PROJECT_FILEPATH({ filePath }); - context.mutations.SET_SAVED_LAST_COMMAND_IDS( - context.getters.LAST_COMMAND_IDS, - ); - - return true; - } catch (err) { - window.backend.logError(err); - await showAlertDialog({ - title: "エラー", - message: `プロジェクトファイルの保存に失敗しました。\n${errorToMessage(err)}`, - }); - return false; - } - }, - ), - }, - - PROMPT_PROJECT_SAVE_FILE_PATH: { - async action(context, { defaultFilePath }) { - let defaultPath: string; - - if (!defaultFilePath) { - // if new project: use generated name - defaultPath = `${context.getters.DEFAULT_PROJECT_FILE_BASE_NAME}.vvproj`; - } else { - // if saveAs for existing project: use current project path - defaultPath = defaultFilePath; + action: createUILockAction(async (context) => { + const filePath = await promptProjectSaveFilePath(context); + if (!filePath) return false; + + const result = await executeWritePromiseOrDialog( + writeProjectFile(context, filePath), + ); + if (!result) return false; + + if (context.state.projectFilePath !== filePath) { + context.mutations.SET_PROJECT_FILEPATH({ filePath }); + await showMessageDialog({ + type: "info", + title: "保存", + message: `編集中のプロジェクトが ${filePath} に切り替わりました。`, + }); } - // Write the current status to a project file. - return await window.backend.showSaveFileDialog({ - title: "プロジェクトファイルの保存", - name: "VOICEVOX Project file", - extensions: ["vvproj"], - defaultPath, - }); - }, + await markCurrentProjectAsSaved(context, filePath); + return true; + }), }, - WRITE_PROJECT_FILE: { - action: async (context, { filePath }) => { - const appVersion = getAppInfos().version; - const { - audioItems, - audioKeys, - tpqn, - tempos, - timeSignatures, - tracks, - trackOrder, - } = context.state; - const projectData: LatestProjectType = { - appVersion, - talk: { - audioKeys, - audioItems, - }, - song: { - tpqn, - tempos, - timeSignatures, - tracks: Object.fromEntries(tracks), - trackOrder, - }, - }; - - const buf = new TextEncoder().encode(JSON.stringify(projectData)).buffer; - await window.backend - .writeFile({ - filePath, - buffer: new Uint8Array(buf), - }) - .then(getValueOrThrow); - }, + SAVE_PROJECT_FILE_AS_COPY: { + /** + * プロジェクトファイルを複製として保存する。 + * ファイルを保存できた場合はtrueが、キャンセルしたか例外が発生した場合はfalseが返る。 + * エラー発生時はダイアログが表示される。 + */ + action: createUILockAction(async (context) => { + const filePath = await promptProjectSaveFilePath(context); + if (!filePath) return false; + + const result = await executeWritePromiseOrDialog( + writeProjectFile(context, filePath), + ); + return result; + }), }, /** @@ -410,9 +344,7 @@ export const projectStore = createPartialStore({ cancel: 0, }); if (result == 2) { - const saved = await actions.SAVE_PROJECT_FILE({ - overwrite: true, - }); + const saved = await actions.SAVE_PROJECT_FILE_OVERWRITE(); return saved ? "saved" : "canceled"; } else if (result == 1) { return "discarded"; diff --git a/src/store/project/saveProjectHelper.ts b/src/store/project/saveProjectHelper.ts new file mode 100644 index 0000000000..8039efa148 --- /dev/null +++ b/src/store/project/saveProjectHelper.ts @@ -0,0 +1,88 @@ +import { ActionContext } from "../type"; +import { showErrorDialog } from "@/components/Dialog/Dialog"; +import { getAppInfos } from "@/domain/appInfo"; +import { LatestProjectType } from "@/domain/project/schema"; +import { DisplayableError } from "@/helpers/errorHelper"; +import { ResultError } from "@/type/result"; + +export async function promptProjectSaveFilePath( + context: ActionContext, +): Promise { + const defaultPath = `${context.getters.DEFAULT_PROJECT_FILE_BASE_NAME}.vvproj`; + + return await window.backend.showSaveFileDialog({ + title: "プロジェクトファイルの保存", + name: "VOICEVOX Project file", + extensions: ["vvproj"], + defaultPath, + }); +} + +/** + * @throws ファイルの保存に失敗した場合 + */ +export async function writeProjectFile( + context: ActionContext, + filePath: string, +) { + const appVersion = getAppInfos().version; + const { + audioItems, + audioKeys, + tpqn, + tempos, + timeSignatures, + tracks, + trackOrder, + } = context.state; + const projectData: LatestProjectType = { + appVersion, + talk: { + audioKeys, + audioItems, + }, + song: { + tpqn, + tempos, + timeSignatures, + tracks: Object.fromEntries(tracks), + trackOrder, + }, + }; + + const buf = new TextEncoder().encode(JSON.stringify(projectData)).buffer; + const result = await window.backend.writeFile({ + filePath, + buffer: new Uint8Array(buf), + }); + if (!result.ok) { + throw new DisplayableError("ファイルの保存に失敗しました。", { + cause: new ResultError(result), + }); + } +} + +export async function executeWritePromiseOrDialog( + savePromise: Promise, +): Promise { + try { + await savePromise; + return true; + } catch (e) { + window.backend.logError(e); + await showErrorDialog("プロジェクトファイルの保存に失敗しました", e); + return false; + } +} + +export async function markCurrentProjectAsSaved( + context: ActionContext, + filePath: string, +) { + await context.actions.APPEND_RECENTLY_USED_PROJECT({ + filePath, + }); + context.mutations.SET_SAVED_LAST_COMMAND_IDS( + context.getters.LAST_COMMAND_IDS, + ); +} diff --git a/src/store/type.ts b/src/store/type.ts index 88c4682444..c928b66607 100644 --- a/src/store/type.ts +++ b/src/store/type.ts @@ -9,6 +9,7 @@ import { StoreOptions, PayloadFunction, Store, + DotNotationActionContext, } from "./vuex"; import { createCommandMutationTree, PayloadRecipeTree } from "./command"; import { @@ -1851,24 +1852,20 @@ export type ProjectStoreTypes = { ): boolean; }; - SAVE_PROJECT_FILE: { - action(payload: { overwrite?: boolean }): boolean; + SAVE_PROJECT_FILE_OVERWRITE: { + action(): Promise; }; - SAVE_PROJECT_FILE_AS_COPY: { - action(payload: { filePath?: string }): boolean; - }; - - PROMPT_PROJECT_SAVE_FILE_PATH: { - action(payload: { defaultFilePath?: string }): Promise; + SAVE_PROJECT_FILE_AS: { + action(): Promise; }; - WRITE_PROJECT_FILE: { - action(payload: { filePath: string }): Promise; + SAVE_PROJECT_FILE_AS_COPY: { + action(): Promise; }; SAVE_OR_DISCARD_PROJECT_FILE: { - action(palyoad: { + action(payload: { additionalMessage?: string; }): "saved" | "discarded" | "canceled"; }; @@ -2386,6 +2383,14 @@ export type AllGetters = StoreType; export type AllMutations = StoreType; export type AllActions = StoreType; +export type ActionContext = DotNotationActionContext< + State, + State, + AllGetters, + AllActions, + AllMutations +>; + export const commandMutationsCreator = ( arg: PayloadRecipeTree, editor: EditorType, diff --git a/src/type/result.ts b/src/type/result.ts index c7f3a4effb..1fee31abb7 100644 --- a/src/type/result.ts +++ b/src/type/result.ts @@ -93,7 +93,8 @@ export class ResultError< public code: E; constructor(public readonly result: FailureResult) { - super(`${result.code}: ${result.error.message}`, { cause: result.error }); + super(`${result.code}`, { cause: result.error }); + this.name = "ResultError"; this.code = result.code; } }