JScript on WSHでファイル選択ダイアログを表示する方法のまとめ

少し前までJScript on WSH 5.6でちょっとしたツールを書いていて*1、その中でファイル選択ダイアログを表示させようと調べてみたら、幾つか方法があったけどどれも決定打に欠けるというか一長一短というか、正直微妙だった。

取り敢えず判明した方法をまとめてみた。

実験環境はWindows XP Pro SP2のWindows Scripting Host 5.6で、Internet Explorer 6 SP3。

「UserAccounts.CommonDialog」というCOMコンポーネントを使う方法

お手軽に使用できる方法。但しWindows XPでしか使えない。XPより古くても新しくてもダメ。Windows Serverなシリーズとかもダメらしい。どうやらこのコンポーネント、XPで導入されたけどVistaで削除されたらしい。

var dialog = WScript.CreateObject("UserAccounts.CommonDialog");

dialog.Filter = "Text Files|*.txt|Csv Files|*.csv|All Files|*.*";
dialog.InitialDir = ".";

if (!dialog.ShowOpen()) {
	WScript.Echo("ファイルは選択されなかったみたいだ……。");
	WScript.Quit();
}

WScript.Echo("選択したのはこれか?\n\"" + dialog.FileName + "\"");

コモンダイアログコントロール (Comdlg32.ocx)を使う方法

Comdlg32.ocxが必要なのがネック。Visual BasicないしVB 6.0のランタイムをインストール必要がある模様。ライセンス的な問題もあるかもしれない。

var dialog = WScript.CreateObject("MSComDlg.CommonDialog");

dialog.MaxFileSize = 256;
dialog.Filter = "Text Files|*.txt|Csv Files|*.csv|All Files|*.*";
dialog.InitDir = ".";

dialog.CancelError = true;
try {
	dialog.ShowOpen();
} catch (e) {
	WScript.Echo("ファイルは選択されなかったみたいだ……。");
	WScript.Quit();
}

WScript.Echo("選択したのはこれか?\n\"" + dialog.FileName + "\"");

COM経由でInternet Explorerを使う方法

マシンによっては少し重いかもしれない。何しろIE使ってるから。

個人的には、IEとかのバージョンの違いに苦しめられる可能性がありそうだと感じているけど、その辺りは未確認。

// FIXME 2011/02/19 現在はこのコードでは動作しない?
// 行儀が悪いけどコメントアウト。
/*
var ie = WScript.CreateObject("InternetExplorer.Application");
ie.Navigate2("about:<html><body><input type='file' id='fileDialog'></body></html>");
while (ie.Busy || ie.ReadyState != 4) {
    WScript.sleep(10);
}
ie.Document.all.fileDialog.click();
var fname = ie.Document.all.fileDialog.value;
ie.Quit();

if (fname == "") {
	WScript.Echo("ファイルは選択されなかったみたいだ……。");
} else {
	WScript.Echo("選択したのはこれか?\n\"" + fname + "\"");
}
*/
2011/02/19 追記

上のコードだが、いつの間にか手元の環境(Windows XP SP3 + InternetExplorer 6)で動作しなくなっていた。

どうもInternetExplorer.Application::Navigate2でタグ付きでごにょごにょしている部分で失敗しているようなので、そこを手直ししてみた。以下のコードはWindows XP SP3上のInternetExplorer 6〜7で動作する。

var ie, body, dialog, fname;

ie = WScript.CreateObject("InternetExplorer.Application");
ie.Navigate("about:blank");
body = ie.document.getElementsByTagName("body")[0];
body.innerHTML = "<input type=\'file\' id=\'fileDialog\'>";
while (ie.Busy || ie.ReadyState != 4) {
	WScript.sleep(10);
}

dialog = ie.document.all.fileDialog;
dialog.click();
fname = dialog.value;
ie.Quit();

if (fname == "") {
	WScript.Echo("ファイルは選択されなかったみたいだ……。");
} else {
	WScript.Echo("選択したのはこれか?\n\"" + fname + "\"");
}

InternetExplorer 8の場合は更に手直しが必要だ。というのもダイアログから直接取得できるファイルパスは、セキュリティ対策の為に偽のパスになっているのだ*2。

この挙動への対策として、例えばtextarea要素を用意しておき、ダイアログで取得したファイルパスを内部でこっそりtextareaにコピペする――といった方法がある。

(function() {
	var READYSTATE_COMPLETE = 4;
	var OLECMDID_COPY = 12;
	var OLECMDID_PASTE = 13;
	var OLECMDID_SELECTALL = 0x11;
	var OLECMDEXECOPT_DODEFAULT = 0;

	var ie, body, dialog, fname, text;

	ie = WScript.CreateObject("InternetExplorer.Application");
	ie.Navigate("about:blank");
	body = ie.document.getElementsByTagName("body")[0];
	body.innerHTML = "<input type=\'file\' id=\'fileDialog\'><textarea id=\'text\'></textarea>";
	while (ie.Busy || ie.ReadyState !== READYSTATE_COMPLETE) {
		WScript.sleep(10);
	}

	dialog = ie.document.all.fileDialog;
	dialog.click();
	fname = dialog.value;

	if (fname === "") {
		WScript.Echo("ファイルは選択されなかったみたいだ……。");
	} else {
		dialog.focus();
		ie.ExecWB(OLECMDID_SELECTALL, OLECMDEXECOPT_DODEFAULT);
		ie.ExecWB(OLECMDID_COPY, OLECMDEXECOPT_DODEFAULT);

		text = ie.document.all.text;
		text.focus();
		ie.ExecWB(OLECMDID_PASTE, OLECMDEXECOPT_DODEFAULT);
		fname = text.value;
		WScript.Echo("選択したのはこれか?\n\"" + fname + "\"");
	}

	text = undefined;
	dialog = undefined;
	body = undefined;
	ie.Quit();
	ie = undefined;
})();

このコードはWindows XP SP3上のInternetExplorer 8で動作した。InternetExplorer 6〜7でも問題なく動作するようなので、複数のバージョンにて使う必要のあるスクリプトを書く場合はこのコードをベースにしてもよいかもしれない。

以上、追記終わり。

ファイル選択ダイアログ表示用のアプリを別途用意する方法

  1. ファイル選択ダイアログを表示して、選択されたファイルのパスを標準出力に書き出すだけのアプリを用意しておく。
  2. スクリプトからそのアプリを起動し、標準出力経由でファイルパスを取得して色々と活用する。

別途アプリを用意しないといけない所がスマートじゃなくて且つ面倒。だけどネイティブコードなアプリを用意できたら比較的広い範囲で使えるかもしれない所が魅力的かも。

まずはアプリのソース。C89相当のC言語+Windows APIで書いてある。

/**
 * select_file3\.exe -(o|s) "[:FILTER:]" [:DEFEXT:]
 * $ select_file3.exe -o "Csv file|*.csv|All files|*.*" csv
 *
 * MinGWでビルドする場合は -lshell32 -lcomdlg32 が必要
 */


#include <assert.h>
#include <stdlib.h>
#include <string.h>

#include <tchar.h>
#include <windows.h>
#include <shellapi.h>
#include <winnls.h>


#ifndef UNICODE

/** char文字列をWCHARに変換する */
static LPWSTR atow(LPCSTR src)
{
	LPWSTR buf;
	int dst_size, rc;

	rc = MultiByteToWideChar(CP_ACP, 0, src, -1, NULL, 0);
	if (rc == 0) {
		return NULL;
	}

	dst_size = rc + 1;
	buf = (LPWSTR) malloc(sizeof(WCHAR) * dst_size);
	if (buf == NULL) {
		return NULL;
	}

	rc = MultiByteToWideChar(CP_ACP, 0, src, -1, buf, dst_size);
	if (rc == 0) {
		free(buf);
		return NULL;
	}
	buf[rc] = L'\0';

	return buf;
}
#endif /* ndef UNICODE */

/** WCHAR文字列をcharに変換する */
static LPSTR wtoa(LPCWSTR src)
{
	LPSTR buf;
	int dst_size, rc;

	rc = WideCharToMultiByte(CP_ACP, 0, src, -1, NULL, 0, NULL, NULL);
	if (rc == 0) {
		return NULL;
	}

	dst_size = rc + 1;
	buf = (LPSTR) malloc(dst_size);
	if (buf == NULL) {
		return NULL;
	}

	rc = WideCharToMultiByte(CP_ACP, 0, src, -1, buf, dst_size, NULL, NULL);
	if (rc == 0) {
		free(buf);
		return NULL;
	}
	buf[rc] = '\0';

	return buf;
}

/** オプション引数を解析する */
static LPTSTR *parse_args(LPCTSTR args_t, int *argc)
{
	LPCWSTR args_w;
	LPWSTR *argv_w;

	if (args_t[0] == _T('\0')) {
		*argc = 0;
		return NULL;
	}

#ifdef UNICODE
	args_w = args_t;
#else
	/* 引数文字列をWCHARに変換 */
	args_w = (LPCWSTR) atow(args_t);
	if (args_w == NULL) {
		return NULL;
	}
#endif

	/* パースする(WCHAR用のAPIしか用意されていない模様) */
	argv_w = CommandLineToArgvW(args_w, argc);

#ifdef UNICODE
	return argv_w;
#else
	free((void *) args_w);
	if (argv_w == NULL) {
		return NULL;
	}

	{
		LPSTR *argv_c = NULL;
		int i, j;

		/* パース結果はWCHARなので、char型に変換する必要がある */
		argv_c = (LPSTR *) malloc(sizeof(argv_c[0]) * (*argc + 1));
		if (argv_c == NULL) {
			goto DONE;
		}
		for (i = 0; i < *argc; ++i) {
			argv_c[i] = wtoa(argv_w[i]);
			if (argv_c[i] == NULL) {
				for (j = 0; j < i; ++j) {
					free(argv_c[j]);
				}
				free(argv_c);
				argv_c = NULL;
				goto DONE;
			}
		}
		argv_c[i] = NULL;

DONE:
		(void) LocalFree((HLOCAL) argv_w);
		return argv_c;
	}
#endif
}

/** オプション引数部分の領域を開放する */
static void free_args(int argc, LPTSTR *argv)
{
#ifdef UNICODE
	(void) argc;
	(void) LocalFree((HLOCAL) argv);
#else
	int i;

	for (i = 0; i < argc; ++i) {
		free(argv[i]);
	}
	free(argv);
#endif
}


/** 文字列を標準出力に書き出す */
static BOOL print_str(LPCTSTR str)
{
	HANDLE hstdout;
	LPSTR str_c;
	DWORD size, written_byte;
	BOOL rc;

	assert(str != NULL);

	hstdout = GetStdHandle(STD_OUTPUT_HANDLE);
	if (hstdout == INVALID_HANDLE_VALUE) {
		return FALSE;
	}

#ifdef UNICODE
	str_c = wtoa(str);
	if (str_c == NULL) {
		return FALSE;
	}
#else
	str_c = (LPSTR) str;
#endif
	size = (DWORD) strlen(str_c);

	written_byte = 0;
	rc = WriteFile(hstdout, (LPCVOID) str_c, size, &written_byte, NULL);
	if (rc == FALSE) {
		goto DONE;
	}
	assert(size == written_byte);

	rc = FlushFileBuffers(hstdout);

DONE:
#ifdef UNICODE
	free(str_c);
#endif
	return rc;
}

/* ファイル選択ダイアログを表示する
 * mode=0 open
 * mode=1 save
 */
static BOOL show_dialog(HWND hwnd, LPCTSTR filter, LPTSTR file, DWORD maxfile, LPCTSTR defext, int mode)
{
	OPENFILENAME ofn;
	BOOL rc;

	memset(&ofn, 0, sizeof(ofn));
	ofn.lStructSize = sizeof(ofn);
	ofn.hwndOwner = hwnd;
	ofn.lpstrFilter = filter;
	ofn.lpstrFile = file;
	ofn.nMaxFile = maxfile;
	ofn.lpstrDefExt = defext;

	if (mode == 0)  {
		ofn.lpstrTitle = _T("ファイルを開く");
		ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST;
		rc = GetOpenFileName(&ofn);
	} else if (mode == 1) {
		ofn.lpstrTitle = _T("名前を付けて保存");
		ofn.Flags = OFN_NOREADONLYRETURN | OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST;
		rc = GetSaveFileName(&ofn);
	} else {
		return FALSE;
	}

	return rc;
}

#define NELEMS(ary) (sizeof(ary) / sizeof((ary)[0]))

#define  EXIT_ERROR  (EXIT_FAILURE + 1)


/** メインルーチン */
int WINAPI _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
                     LPTSTR lpCmdLine, int nCmdShow)
{
	static TCHAR buf[512];
	int argc, mode;
	LPTSTR *argv, filter;
	LPCTSTR defext;
	size_t filter_size, i;
	BOOL rc;

	(void) hInstance, (void) hPrevInstance, (void) lpCmdLine, (void) nCmdShow;

	/* Win32アプリでは、引数文字列はパースされていないので、パースする */
	argc = ~0;
	argv = parse_args(GetCommandLine(), &argc);
	if (argv == NULL) {
		return EXIT_ERROR;
	}
	if (argc < 3 || argc > 4) {
		free_args(argc, argv);
		return EXIT_ERROR;
	}

	/* 第3引数が無い場合は空文字で補完する */
	defext = (argc == 3) ? _T("") : argv[3];

	/* 第1引数の形式をチェック */
	if (_tcscmp(argv[1], _T("-o")) == 0) {
		mode = 0;
	} else if (_tcscmp(argv[1], _T("-s")) == 0) {
		mode = 1;
	} else {
		free_args(argc, argv);
		return EXIT_ERROR;
	}

	/* 第2引数はそのままでは使えないので変換する */
	filter_size = _tcslen(argv[2]) + 2;
	filter = (LPTSTR) malloc(sizeof(TCHAR) * filter_size);
	if (filter == NULL) {
		free_args(argc, argv);
		return EXIT_ERROR;
	}
	for (i = 0; i < filter_size; ++i) {
		filter[i] = (argv[2][i] == _T('|')) ? _T('\0') : argv[2][i];
		if (argv[2][i] == _T('\0')) {
			filter[i+1] = _T('\0');
			break;
		}
	}

	/* ダイアログを表示し、選択されたファイルのパスを標準出力に書き出す */
	rc = show_dialog(NULL, filter, buf, (DWORD) NELEMS(buf), defext, mode);
	if (rc != FALSE) {
		(void) print_str(buf);
	}
	free(filter);
	free_args(argc, argv);

	return (rc != FALSE) ? EXIT_SUCCESS : EXIT_FAILURE;
}

ちなみにC/C++で書く場合は以下に注意。

  • コンソールアプリだとダイアログ表示中にコマンドプロンプトが見えたままになる。それが嫌ならWin32アプリにすること。
  • パスに日本語が含まれている場合は、標準出力への出力はマルチバイト文字列じゃないとNGな模様。Visual C++を使う場合は、プロジェクトの設定でマルチバイト文字列を使うようにする方が無難。

上のソースからselect_file.exeという名前でアプリをビルドした場合、JScriot側のコードはこんな感じになる。

var shell = WScript.CreateObject("WScript.Shell");

try {
	var proc = shell.Exec("select_file.exe -o \"Text Files|*.txt|Csv Files|*.csv|All Files|*.*\" txt");
} catch (e) {
	eprintln("error: " + e.message);
	WScript.Quit();
}

var fname = proc.StdOut.ReadAll();

while (proc.Status == 0) {
	WScript.Sleep(10);
}

if (proc.ExitCode == 0) {
	WScript.Echo("選択したのはこれか?\n\"" + fname + "\"");
} else if (proc.ExitCode == 1) {
	WScript.Echo("ファイルは選択されなかったみたいだ……。");
} else {
	WScript.Echo("エラー発生!");
}

ファイル選択ダイアログ表示用のCOMコンポーネントを自作する方法

  1. ファイル選択ダイアログを表示して、選択されたファイルのパスを取得するCOMコンポーネントを自作する。
  2. 自作したCOMコンポーネントをシステムに登録する。
  3. スクリプトからそのコンポーネントを叩き、ファイルパスを取得して色々と活用する。

やったことがないし、やる気もないのでサンプルコートは無し。

COMコンポーネントを作るのが面倒かも*3。システムに登録する必要がある点は、場合によってはマイナスかも。

ファイル選択ダイアログ表示用にPowerShellのスクリプトを使用する方法(2012/11/25)

「ファイル選択ダイアログ表示用のアプリを別途用意する方法」の変形版として、そのアプリをPowerShellのスクリプトで書いてしまう方法もある。

たとえばこんなスクリプト。

Set-StrictMode -version Latest

Add-Type -assemblyName System.Windows.Forms

$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = 'Text Files|*.txt|Csv Files|*.csv|All Files|*.*'
$dialog.InitialDirectory = '.'

[void] $dialog.ShowDialog()

$dialog.FileName

ダイアログで何かしらファイルを選択したらそのフルパスを出力し、選択しなかった場合は空白行を出力する。

上記スクリプトを実行するWSH側はこんな感じ。

var fname;

fname = WScript.CreateObject('WScript.Shell').
                Exec('powershell -Sta -File select_file.ps1').
                StdOut.ReadLine();

if (fname === '') {
	WScript.Echo('ファイルは選択されなかったみたいだ……。');
} else {
	WScript.Echo('選択したのはこれか?\n"' + fname + '"');
}

PowerShellはWindows 7以降ならデフォルトでインストールされているし、ちょっと手間だけどWindows XPやVistaにインストールできる。.NET Framework経由でファイル選択ダイアログを表示するのも容易い。古い環境をあまり考慮する必要がなければ、今後はPowerShellを積極的に使用してもよいだろう。

ただ、JScriptやVBScriptで書かれた既存のスクリプトを全てPowerShellで書き直すのは現実的ではないことも多いので、その場合はこの例のように一部をPowerShellで補完するスタイルが有効だと思う。

*1:id:eel3:20090203:1233588775 のCSVパーサを書く発端となったやつ。

*2:例えばhoge.txtというファイルの場合、そのファイルが何処にあったとしてもC:\fakepath\hoge.txtというパスが返ってくる。

*3:でもやったことがないので分からない