Skip to content

Commit

Permalink
feat: プロジェクトのエクスポート機能を追加 (#2428)
Browse files Browse the repository at this point in the history
Co-authored-by: Hiroshiba <[email protected]>
Co-authored-by: Hiroshiba Kazuyuki <[email protected]>
  • Loading branch information
3 people authored Dec 31, 2024
1 parent 9fd356b commit bd33fea
Show file tree
Hide file tree
Showing 13 changed files with 404 additions and 124 deletions.
16 changes: 16 additions & 0 deletions src/backend/browser/fakePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { z } from "zod";
import { uuid4 } from "@/helpers/random";

const fakePathSchema = z
.string()
.regex(/^<browser-dummy-[0-9a-f]+>-.+$/)
.brand("FakePath");
export type FakePath = z.infer<typeof fakePathSchema>;

export const isFakePath = (path: string): path is FakePath => {
return fakePathSchema.safeParse(path).success;
};

export const createFakePath = (name: string): FakePath => {
return fakePathSchema.parse(`<browser-dummy-${uuid4()}>-${name}`);
};
118 changes: 92 additions & 26 deletions src/backend/browser/fileImpl.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { directoryHandleStoreKey } from "./contract";
import { openDB } from "./browserConfig";
import { createFakePath, FakePath, isFakePath } from "./fakePath";
import { SandboxKey } from "@/type/preload";
import { failure, success } from "@/type/result";
import { createLogger } from "@/domain/frontend/log";
import { uuid4 } from "@/helpers/random";
import { normalizeError } from "@/helpers/normalizeError";
import path from "@/helpers/path";
import { ExhaustiveError } from "@/type/utility";

const log = createLogger("fileImpl");

Expand Down Expand Up @@ -113,45 +114,81 @@ const getDirectoryHandleFromDirectoryPath = async (
}
};

export type WritableFilePath =
| {
// ファイル名のみ。ダウンロードとして扱われます。
type: "nameOnly";
path: string;
}
| {
// ディレクトリ内への書き込み。
type: "child";
path: string;
}
| {
// 疑似パス。
type: "fake";
path: FakePath;
};

// NOTE: fixedExportEnabled が有効になっている GENERATE_AND_SAVE_AUDIO action では、ファイル名に加えディレクトリ名も指定された状態でfilePathが渡ってくる
// また GENERATE_AND_SAVE_ALL_AUDIO action では fixedExportEnabled の有効の有無に関わらず、ディレクトリ名も指定された状態でfilePathが渡ってくる
export const writeFileImpl: (typeof window)[typeof SandboxKey]["writeFile"] =
async (obj: { filePath: string; buffer: ArrayBuffer }) => {
const filePath = obj.filePath;
// showExportFilePicker での疑似パスが渡ってくる可能性もある。
export const writeFileImpl = async (obj: {
filePath: WritableFilePath;
buffer: ArrayBuffer;
}) => {
const filePath = obj.filePath;

if (!filePath.includes(path.SEPARATOR)) {
switch (filePath.type) {
case "fake": {
const fileHandle = fileHandleMap.get(filePath.path);
if (fileHandle == undefined) {
return failure(new Error(`ファイルが見つかりません: ${filePath.path}`));
}
const writable = await fileHandle.createWritable();
await writable.write(obj.buffer);
return writable.close().then(() => success(undefined));
}

case "nameOnly": {
const aTag = document.createElement("a");
const blob = URL.createObjectURL(new Blob([obj.buffer]));
aTag.href = blob;
aTag.download = filePath;
aTag.download = filePath.path;
document.body.appendChild(aTag);
aTag.click();
document.body.removeChild(aTag);
URL.revokeObjectURL(blob);
return success(undefined);
}

const fileName = resolveFileName(filePath);
const maybeDirectoryHandleName = resolveDirectoryName(filePath);
case "child": {
const fileName = resolveFileName(filePath.path);
const maybeDirectoryHandleName = resolveDirectoryName(filePath.path);

const directoryHandle = await getDirectoryHandleFromDirectoryPath(
maybeDirectoryHandleName,
);
const directoryHandle = await getDirectoryHandleFromDirectoryPath(
maybeDirectoryHandleName,
);

directoryHandleMap.set(maybeDirectoryHandleName, directoryHandle);
directoryHandleMap.set(maybeDirectoryHandleName, directoryHandle);

return directoryHandle
.getFileHandle(fileName, { create: true })
.then(async (fileHandle) => {
const writable = await fileHandle.createWritable();
await writable.write(obj.buffer);
return writable.close();
})
.then(() => success(undefined))
.catch((e) => {
return failure(normalizeError(e));
});
};
return directoryHandle
.getFileHandle(fileName, { create: true })
.then(async (fileHandle) => {
const writable = await fileHandle.createWritable();
await writable.write(obj.buffer);
return writable.close();
})
.then(() => success(undefined))
.catch((e) => {
return failure(normalizeError(e));
});
}
default:
throw new ExhaustiveError(filePath);
}
};

export const checkFileExistsImpl: (typeof window)[typeof SandboxKey]["checkFileExists"] =
async (filePath) => {
Expand Down Expand Up @@ -182,7 +219,7 @@ export const checkFileExistsImpl: (typeof window)[typeof SandboxKey]["checkFileE
};

// FileSystemFileHandleを保持するMap。キーは生成した疑似パス。
const fileHandleMap: Map<string, FileSystemFileHandle> = new Map();
const fileHandleMap: Map<FakePath, FileSystemFileHandle> = new Map();

// ファイル選択ダイアログを開く
// 返り値はファイルパスではなく、疑似パスを返す
Expand All @@ -201,7 +238,7 @@ export const showOpenFilePickerImpl = async (options: {
});
const paths = [];
for (const handle of handles) {
const fakePath = `<browser-dummy-${uuid4()}>-${handle.name}`;
const fakePath = createFakePath(handle.name);
fileHandleMap.set(fakePath, handle);
paths.push(fakePath);
}
Expand All @@ -214,6 +251,9 @@ export const showOpenFilePickerImpl = async (options: {

// 指定した疑似パスのファイルを読み込む
export const readFileImpl = async (filePath: string) => {
if (!isFakePath(filePath)) {
return failure(new Error(`疑似パスではありません: ${filePath}`));
}
const fileHandle = fileHandleMap.get(filePath);
if (fileHandle == undefined) {
return failure(new Error(`ファイルが見つかりません: ${filePath}`));
Expand All @@ -222,3 +262,29 @@ export const readFileImpl = async (filePath: string) => {
const buffer = await file.arrayBuffer();
return success(buffer);
};

// ファイル選択ダイアログを開く
// 返り値はファイルパスではなく、疑似パスを返す
export const showExportFilePickerImpl: (typeof window)[typeof SandboxKey]["showExportFileDialog"] =
async (obj: {
defaultPath?: string;
extensionName: string;
extensions: string[];
title: string;
}) => {
const handle = await showSaveFilePicker({
suggestedName: obj.defaultPath,
types: [
{
description: obj.extensions.join("、"),
accept: {
"application/octet-stream": obj.extensions.map((ext) => `.${ext}`),
},
},
],
});
const fakePath = createFakePath(handle.name);
fileHandleMap.set(fakePath, handle);

return fakePath;
};
52 changes: 23 additions & 29 deletions src/backend/browser/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { defaultEngine } from "./contract";
import {
checkFileExistsImpl,
readFileImpl,
showExportFilePickerImpl,
showOpenDirectoryDialogImpl,
showOpenFilePickerImpl,
WritableFilePath,
writeFileImpl,
} from "./fileImpl";
import { getConfigManager } from "./browserConfig";
import { isFakePath } from "./fakePath";
import { IpcSOData } from "@/type/ipc";
import {
defaultToolbarButtonSetting,
Expand All @@ -17,6 +20,7 @@ import {
} from "@/type/preload";
import { AssetTextFileNames } from "@/type/staticResources";
import { HotkeySettingType } from "@/domain/hotkeyAction";
import path from "@/helpers/path";

const toStaticPath = (fileName: string) =>
`${import.meta.env.BASE_URL}/${fileName}`.replaceAll(/\/\/+/g, "/");
Expand Down Expand Up @@ -72,34 +76,6 @@ export const api: Sandbox = {
// NOTE: ブラウザ版ではサポートされていません
return Promise.resolve({});
},
showAudioSaveDialog(obj: { title: string; defaultPath?: string }) {
return new Promise((resolve, reject) => {
if (obj.defaultPath == undefined) {
reject(
// storeやvue componentからdefaultPathを設定していなかったらrejectされる
new Error(
"ブラウザ版ではファイルの保存機能が一部サポートされていません。",
),
);
} else {
resolve(obj.defaultPath);
}
});
},
showTextSaveDialog(obj: { title: string; defaultPath?: string }) {
return new Promise((resolve, reject) => {
if (obj.defaultPath == undefined) {
reject(
// storeやvue componentからdefaultPathを設定していなかったらrejectされる
new Error(
"ブラウザ版ではファイルの保存機能が一部サポートされていません。",
),
);
} else {
resolve(obj.defaultPath);
}
});
},
showSaveDirectoryDialog(obj: { title: string }) {
return showOpenDirectoryDialogImpl(obj);
},
Expand Down Expand Up @@ -163,8 +139,26 @@ export const api: Sandbox = {
});
return fileHandle?.[0];
},
async showExportFileDialog(obj: {
defaultPath?: string;
extensionName: string;
extensions: string[];
title: string;
}) {
const fileHandle = await showExportFilePickerImpl(obj);
return fileHandle;
},
writeFile(obj: { filePath: string; buffer: ArrayBuffer }) {
return writeFileImpl(obj);
let filePath: WritableFilePath;
if (isFakePath(obj.filePath)) {
filePath = { type: "fake", path: obj.filePath };
} else if (obj.filePath.includes(path.SEPARATOR)) {
filePath = { type: "child", path: obj.filePath };
} else {
filePath = { type: "nameOnly", path: obj.filePath };
}

return writeFileImpl({ filePath, buffer: obj.buffer });
},
readFile(obj: { filePath: string }) {
return readFileImpl(obj.filePath);
Expand Down
44 changes: 15 additions & 29 deletions src/backend/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,35 +469,6 @@ registerIpcMainHandle<IpcMainHandle>({
return engineInfoManager.altPortInfos;
},

SHOW_AUDIO_SAVE_DIALOG: async (_, { title, defaultPath }) => {
const result = await retryShowSaveDialogWhileSafeDir(() =>
dialog.showSaveDialog(win, {
title,
defaultPath,
filters: [
{
name: "WAVファイル",
extensions: ["wav"],
},
],
properties: ["createDirectory"],
}),
);
return result.filePath;
},

SHOW_TEXT_SAVE_DIALOG: async (_, { title, defaultPath }) => {
const result = await retryShowSaveDialogWhileSafeDir(() =>
dialog.showSaveDialog(win, {
title,
defaultPath,
filters: [{ name: "Text File", extensions: ["txt"] }],
properties: ["createDirectory"],
}),
);
return result.filePath;
},

/**
* 保存先になるディレクトリを選ぶダイアログを表示する。
*/
Expand Down Expand Up @@ -600,6 +571,21 @@ registerIpcMainHandle<IpcMainHandle>({
})?.[0];
},

SHOW_EXPORT_FILE_DIALOG: async (
_,
{ title, defaultPath, extensionName, extensions },
) => {
const result = await retryShowSaveDialogWhileSafeDir(() =>
dialog.showSaveDialog(win, {
title,
defaultPath,
filters: [{ name: extensionName, extensions: extensions }],
properties: ["createDirectory"],
}),
);
return result.filePath;
},

IS_AVAILABLE_GPU_MODE: () => {
return hasSupportedGpu(process.platform);
},
Expand Down
20 changes: 9 additions & 11 deletions src/backend/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,6 @@ const api: Sandbox = {
return await ipcRendererInvokeProxy.GET_ALT_PORT_INFOS();
},

showAudioSaveDialog: ({ title, defaultPath }) => {
return ipcRendererInvokeProxy.SHOW_AUDIO_SAVE_DIALOG({
title,
defaultPath,
});
},

showTextSaveDialog: ({ title, defaultPath }) => {
return ipcRendererInvokeProxy.SHOW_TEXT_SAVE_DIALOG({ title, defaultPath });
},

showSaveDirectoryDialog: ({ title }) => {
return ipcRendererInvokeProxy.SHOW_SAVE_DIRECTORY_DIALOG({ title });
},
Expand Down Expand Up @@ -75,6 +64,15 @@ const api: Sandbox = {
});
},

showExportFileDialog: ({ title, defaultPath, extensionName, extensions }) => {
return ipcRendererInvokeProxy.SHOW_EXPORT_FILE_DIALOG({
title,
defaultPath,
extensionName,
extensions,
});
},

writeFile: async ({ filePath, buffer }) => {
return await ipcRendererInvokeProxy.WRITE_FILE({ filePath, buffer });
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/Dialog/Dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { DotNotationDispatch } from "@/store/vuex";
import { withProgress } from "@/store/ui";

type MediaType = "audio" | "text" | "label";
type MediaType = "audio" | "text" | "project" | "label";

export type TextDialogResult = "OK" | "CANCEL";
export type MessageDialogOptions = {
Expand Down Expand Up @@ -302,6 +302,7 @@ const showWriteSuccessNotify = ({
const mediaTypeNames: Record<MediaType, string> = {
audio: "音声",
text: "テキスト",
project: "プロジェクト",
label: "labファイル",
};
void actions.SHOW_NOTIFY_AND_NOT_SHOW_AGAIN_BUTTON({
Expand Down
Loading

0 comments on commit bd33fea

Please sign in to comment.