【WSH】設定ファイル(xml,ini)の編集ツール

# はじめに
会社では運用チームに属していて、各県にある30近い工場の運用作業を行っています。
システムの導入作業があると工場ごとに設定ファイルを変更して導入しており、現在は設定ファイルの編集は手作業で行っている。
導入作業の中で面倒なのが、あるClickOnceアプリケーションで、設定ファイル内でIPアドレスを変更する箇所が2ファイルある上に、ClickOnceのインストールのURLのIPアドレスも変更するので計3箇所あります。
IPアドレスを1回入力したら3箇所変更するように出来れば、ミスも無くなり作業も簡単になるわけです。
今回、一念発起してツールを作成しました。また、夏休み期間を利用して多くの人にも使えるようにブラッシュアップしてみました。

※ClickOnceのインストールのURLの変更は「mage.exe」を使用するので今回の編集ツールとは関係ないです。

【2024/10/04追記】
近頃、設定はJSONファイルが主流になってきています。
筆者はJSONの場合には下記ツールを使って置換しています。

https://qiita.com/yaju/items/6de6fc778e8bf8ce3327

【2019/10/21追記】
サーバーリプレース作業(Windows Server 2008R2 + Oracle11g -> Windows Server 2016 + PostgreSQL9.6)で使用アプリケーションの各設定ファイルの確認一覧用に「GETVALUE」を使用して値を取得するバッチを作成した際に気がついたことを追記しました。

【2020/08/26追記】
空要素(例 < DRIVE />)に対応しました。
設定変更を依頼された項目が空要素になっており、GETVALUEで次のタグを取得してしまったり、設定変更が正しく出来なかったので修正しました。

# 開発にあたり
xmlファイルの編集する上で最初に「MSXML2.DOMDocument」を使用したのですが、保存後に下記の問題が発生したので使用をやめました。

* 開始タグの末尾「 /＞」などの空白が消えてしまう。
* 空行が削除されてしまう
* 特殊文字がエスケープ文字に変換されてしまう

他にも問題があるかも知れません。やはり、指定した箇所以外の変更は何もして欲しくないわけです。
「MSXML2.DOMDocument」を使うことをやめたことで、ほぼ同じ方法でiniファイルの編集も出来るようになったので、これで良かったです。

2016/11/16追記
置換対象の値を取得できるといいなと思ったので、機能を追加しました。

# 使用方法
「ReplaceConfig.vbs」は引数を４つ指定できます。

```
ReplaceConfig FileName FindKey Value [CharSet]
```

* FileName
設定ファイル(xml,ini)を指定します。
* FindKey
変更するキーを指定します。
属性をキーにする場合は、「[]=角括弧」で囲んだ上で属性名の手前に「@」を付けてください。
例 configuration/appSettings/add[@key='url']

* Value
変更する値を指定します。
空白を含む値は2重引用符で囲んでください。
特殊文字「"」や「%」などを値として使う場合は、「#(16進数)」としてください。2重引用符の「"」は「#22」となります。
属性の値を変更する場合は、「/@」を先頭に付けてください。
固定文字列「GETVALUE」にすると、置換ではなく置換対象の値を取得します。

* CharSet
BOM無しの場合の文字コードをセットします。不要な場合は指定する必要はありません。指定しない場合、iniファイルの場合は「Shift-JIS」でそれ以外は「UTF-8」となります。xmlファイルの場合は「encoding」を優先します。

※設定ファイルを対象としているので、複数同一キー見つかった場合でも最初のみ変更して終了する仕様としています。

# 使用例
## xmlファイルの変更

```xml:system-config.xml
<?xml version="1.0" encoding="utf-8"?>
<system-config>
  <system-name>ConfigSetValue</system-name>
  <version>1.1</version>
  <users>
    <user id="0001" expired="2015/12/31">
      <name>foo</name>
    </user>
    <user id="0002" expired="2015/09/30">
      <name>bar</name>
    </user>
  </users>
</system-config>
```

バージョン番号を1.1から1.2に変更する。

```
ReplaceConfig system-config.xml system-config/version 1.2
```

ユーザー Id=0002の名前をbarからhogeに変更する。

```
ReplaceConfig system-config.xml system-config/users/user[@id='0002']/name hoge
```

ユーザー Id=0001の日付を2015/12/31から2016/08/17に変更する。

```
ReplaceConfig system-config.xml system-config/users/user[@id='0001']/@expired 2016/08/17
```
【結果イメージ】
![diff.png](https://qiita-image-store.s3.amazonaws.com/0/14053/2c8a4e0a-909f-f4fd-fa2e-03b1e7faec54.png)

## 値の取得
バージョン番号の1.1を取得する。固定文字列「GETVALUE」を指定している。

```bat
for /f "usebackq tokens=*" %%i IN (`call cscript //nologo ReplaceConfig.vbs system-config.xml system-config/version GETVALUE`) DO @set result=%%i

echo %result%
```

【2019/10/21追記】
バッチで属性のキーを指定する際の「=」にはエスケープ処理が必要で、エスケープ文字の「^」を使用します。(例 id^='0001')

```bat
for /f "usebackq tokens=*" %%i IN (`call CScript //nologo ReplaceConfig.vbs system-config.xml system-config/users/user[@id^='0001']/@expired GETVALUE`) DO @set result=%%i

echo %result%
```

バッチでIF文を使用して括弧で囲んだ場合、遅延環境変数として「%」でなく「!」で囲まない(例 echo !result!)と値がセットされません。

```bat
setlocal enabledelayedexpansion

if exist system-config.xml (
for /f "usebackq tokens=*" %%i IN (`call CScript //nologo ReplaceConfig.vbs system-config.xml system-config/users/user[@id^='0001']/@expired GETVALUE`) DO @set result=%%i

echo !result!
)

endlocal
```

## iniファイルの変更

```ini:db.ini
[database]
host = 192.168.0.1
USER = username
PASS = password

[encoding]
enc = SJIS
```

hostを192.168.0.1からlocalhostに変更する。

```
ReplaceConfig db.ini database/host 127.0.0.1
```

encをSJISからUTF-8に変更する。

```
ReplaceConfig db.ini encoding/enc UTF-8
```

【2023/08/03追記】
仕事で使用してもらっていたところ、違うセクションの同じキーの値が更新されるとの報告がありました。
調査したところ、先頭の方にあったキーの値にセクション名が使われていたため、最初に見つかった違うセクションの同じキーの値を更新していました。
対応としてセクション名とキー名の検索に正規表現を使用しているため、セクション名部分を"\\[" と "\\]"で括ってもらうことで解決しました。

```
ReplaceConfig db.ini \[database\]/host 127.0.0.1
```

【結果イメージ】
![diffini.png](https://qiita-image-store.s3.amazonaws.com/0/14053/4d7eaee4-02c1-3334-e474-2b201dfc79e4.png)

## 値の取得
hostの192.168.0.1を取得する。固定文字列「GETVALUE」を指定している。

```bat
for /f "usebackq tokens=*" %%i IN (`call cscript //nologo ReplaceConfig.vbs db.ini database/host GETVALUE`) DO @set result=%%i

echo %result%
```

【2019/10/21追記】
バッチでIF文を使用して括弧で囲んだ場合、遅延環境変数として「%」でなく「!」で囲まない(例 echo !result!)と値がセットされません。

```bat
setlocal enabledelayedexpansion

if exist db.ini (
for /f "usebackq tokens=*" %%i IN (`call cscript //nologo ReplaceConfig.vbs db.ini database/host GETVALUE`) DO @set result=%%i

echo !result!
)

endlocal
```

# ソースリスト
```vb:ReplaceConfig.vbs
'--------------------------------------
'  引数1: 変更するファイル
'  引数2: 検索文字列
'  引数3: 変更文字列
'  引数4: 文字コード(BOM以外)
'--------------------------------------
Option Explicit
'On Error Resume Next

Const ForReading = 1, ForWriting = 2, ForAppEnding = 8
Const adTypeBinary = 1, adTypeText = 2
Const adReadAll = -1, adReadLine = -2

Const typeXML = 1, typeINI = 2

Const GET_CONFIG = "GETVALUE"

Dim objArgs
Dim pathName, keyName, valueString, charSet, strLine

'コマンドライン引数取得
Set objArgs = WScript.Arguments

'対象パス名取得
If objArgs.Count >= 1 Then
    pathName = objArgs(0)
Else
    WScript.Echo "対象パス名が見つかりません"
    WScript.Quit(1)
End If

'検索キー取得
If objArgs.Count >= 2 Then
    keyName = objArgs(1)
Else
    WScript.Echo "検索キーが見つかりません"
    WScript.Quit(2)
End If

'変更対象値取得
If objArgs.count >= 3 then
    valueString = objArgs(2)
Else
    WScript.Echo "変更対象値が見つかりません"
    WScript.Quit(3)
End If

'文字コード(BOM以外)
If objArgs.count >= 4 then
    charSet = UCase(objArgs(3))
End If

'ファイル情報を取得する
Dim param, key, str, result
Set param = CreateObject("Scripting.Dictionary")
Call GetInfo(pathName, charSet)

'設定値変更処理
result = Main(pathName, keyName, valueString)
Set param = Nothing

WScript.Quit(result)

'------------------------------------------------------------------------
'  設定値変更処理
'  pathName:    対象パス名
'  keyName:     検索キー
'  valueString: 変更対象値
'------------------------------------------------------------------------
Function Main(pathName, keyName, valueString)

    Dim dic, reg, matches, match, hexvalue, stream_r, stream_pre, stream_w
    Dim bin, newtxt, line, nextline, aryKey, i, idx, curKey, count, key
    Dim attrKey, aryAttr, node, exitFlag, lfType, lfType2, allLine, aryLine
    Dim result

    Main = 0

    Set stream_r = CreateObject("ADODB.Stream")
    Set stream_pre = CreateObject("ADODB.Stream")
    Set stream_w = CreateObject("ADODB.Stream")
    Set dic = CreateObject("Scripting.Dictionary")
    Set reg = CreateObject("VBScript.RegExp")
    reg.Global = True
    reg.IgnoreCase = True

    '検索キー分割し、キー単位で状態を保持する
    aryKey = Split(keyName, "/")
    For i=0 To UBound(aryKey)
        dic.Add aryKey(i), 0
    Next

    '現行の文字コードで読み込む
    stream_r.Open
    stream_r.CharSet = param("CharSet")
    stream_r.LoadFromFile pathName
    allLine = stream_r.ReadText(adReadAll)
    stream_r.Close

    '改行コード
    lfType = ""
    Select Case param("LfType")
        Case "CrLf": lfType = vbCrLf
        Case "Lf": lfType = vbLf
        Case "Cr": lfType = vbCr
    End Select

    'XMLで改行無しの場合
    lfType2 = lfType
    If param("FileType") = typeXML And param("LfType") = "" Then
        '一時的に改行を付ける
        reg.Pattern =  "(.*?>)(<.*?)"
        allLine = reg.Replace(allLine, "$1" & vbCrLf & "$2")
        lfType2 = vbCrLf
    End If

    '改行コードで分割
    aryLine = Split(allLine, lfType2)

    '設定値の#記号の数値変換
    reg.Pattern =  "#(\d\d)"
    Set matches = reg.Execute(valueString)
    For Each match In matches
        hexvalue = match.SubMatches(0)
        valueString = Replace(valueString, "#" & hexvalue, Chr(CInt("&H" & hexvalue)))
    Next
    Set matches = Nothing

    i = 0
    count = 0
    exitFlag = False

    For idx = 0 To UBound(aryLine)
        line = aryLine(idx)
        IF exitFlag = False Then
            key = dic.Keys
            node = key(i)

            'キーの一致確認
            If param("FileType") = typeXML Then
                If Instr(node, "[") > 0 Then
                   node = Replace(node,"[@",".*?")
                   node = Replace(node,"='","=.*?")
                   node = Replace(node,"']","")
                   node = Replace(node,"]","")
                End If
                reg.Pattern = "<\s*?" & node & ".*?>"
            Else
                reg.Pattern = "^[^;]*?" & node & ".*?"
            End If
            If reg.Test(line) Then
                'キー状態有りに設定
                dic.Item(key(i)) = 1

                '次のキーへ
                i = i + 1

                If i <= UBound(key) Then
                    '属性値の変更かチェック(先頭に"@"記号)
                    attrKey = ""
                    If Instr(key(i), "@") = 1 Then
                        attrKey = Mid(key(i), 2, Len(key(i)))
                        reg.Pattern =  ".*?" & attrKey & ".*?="
                        If reg.Test(line) Then
                            'キー状態有りに設定
                            dic.Item(key(i)) = 1
                        End If
                    End If
                End If

                'キー有無状態の確認
                count = 0
                For Each curKey In dic
                    count = count + dic.Item(curKey)
                Next
            End If

            '最終キーに到達
            If count = UBound(aryKey) + 1 Then
                '設定値の変更
                If param("FileType") = typeXML Then
                    'XMLファイル側の変更
                    If attrKey <> "" Then
                        '属性値の変更
                        reg.Pattern = attrKey & "(.*?=.*?['""])(.*?)(['""])"
                        If reg.Test(line) Then
                            result = GetValue(reg, valueString, line)
                            line = reg.Replace(line, attrKey & "$1" & valueString & "$3")
                        End If
                    Else
                        '値の変更
                        reg.Pattern = "(>)(.*?)(<)"
                        If reg.Test(line) Then
                            result = GetValue(reg, valueString, line)
                            line = reg.Replace(line, "$1" & valueString & "$3")
                        Else
                            '終了タグ
                            reg.Pattern = "(?:<)([^\s]+)(.*)(?:\/>)"
                            If reg.Test(line) Then
                                result = ""
                                If valueString <> "" Then
                                    line = reg.Replace(line, "<$1$2>" & valueString & "</" & "$1" & ">")
                                    line = Replace(line, " >", ">")
                                End If
                            Else
                                '次の行に値がある
                                nextline = aryLine(idx + 1)
                                reg.Pattern = "(.*?)(\S.*)"
                                If reg.Test(nextline) Then
                                    result = GetValue(reg, valueString, nextline)
                                    '読み込んだ次の行を再セット
                                    aryLine(idx + 1) = reg.Replace(nextline, "$1" & valueString)
                                End If
                            End If
                        End If
                    End If
                Else
                    'Iniファイル側の値変更
                    reg.Pattern = "(=\s*)(.*)"
                    If reg.Test(line) Then
                        result = GetValue(reg, valueString, line)
                        line = reg.Replace(line, "$1" & valueString)
                    End If
                End If
                dic.Item(aryKey(count-1)) = 0

                '変更対象値に変更したら処理終了
                exitFlag = True
            End If
        End If

        '読み込んだ行を再セット
        aryLine(idx) = line
    Next

    If exitFlag = True Then
        If UCase(valueString) = GET_CONFIG Then
            '値を返す
            WScript.Echo result
        Else
            '改行を付加
            newtxt = Join(aryLine, lfType)

            'ファイル保存
            If param("CharSet") = "UTF-8" And param("IsBOM") = False Then 
                'BOM無し
                stream_pre.Type = adTypeText
                stream_pre.CharSet = param("CharSet")
                stream_pre.Open
                stream_pre.WriteText newtxt

                stream_pre.Position = 0
                stream_pre.Type = adTypeBinary
                stream_pre.Position = 3
                bin = stream_pre.Read()
                stream_pre.Close()

                stream_w.Type = adTypeBinary
                stream_w.Open()
                stream_w.Write(bin)
                stream_w.SaveToFile pathName, ForWriting
                stream_w.Close()
            Else
                stream_w.Open()
                stream_w.Charset = param("CharSet")
                stream_w.WriteText newtxt
                stream_w.SaveToFile pathName, ForWriting
                stream_w.Close()
            End If
        End If
    Else
        WScript.Echo "キーが見つかりませんでした。"
        Main = 9
    End If

    Set dic = Nothing
    Set reg = Nothing

End Function

'------------------------------------------------------------------------
'ファイルの文字コード等の情報を取得する
'------------------------------------------------------------------------
Function GetInfo(pathName, charSet)

    Dim stream, reg, match, matches, bytCode, allLine, line, endLine, lfType, aryLine
    Dim fso, ext

    GetInfo = False

    '初期値セット
    param.Add "CharSet", "UTF-8"        '文字コード
    param.Add "IsBOM", False            'BOM有無
    param.Add "FileType", typeINI       'ファイルタイプ
    param.Add "LfType", "CrLf"          '改行コード
    param.Add "IsEndLf", False          '末尾改行有無

    '拡張子を取得する
    Set fso = CreateObject("Scripting.FileSystemObject")
    ext = fso.GetExtensionName(pathName)
    Set fso = Nothing

    'ファイルをオープン
    Set stream = CreateObject("ADODB.Stream")

    'BOMチェック用に先頭行を読み込む
    stream.Type = adTypeBinary
    stream.Open
    stream.LoadFromFile pathName
    bytCode = stream.Read
    stream.Close
    Call CheckBOM(bytCode)

    If param("IsBOM") = False Then
        'IniファイルはSJISとする
        If charSet = "" And (LCase(ext) = "ini" Or Instr(LCase(pathName),".ini") > 1) Then
            charSet = "Shift-JIS"
        End If
        param("CharSet") = charSet
    End If

    '文字コードを自動判定で読み込み
    stream.Type = adTypeText
    stream.CharSet = "_autodetect_all"
    If charSet <> "" Then stream.CharSet = charSet
    stream.Open
    stream.LoadFromFile pathName
    allLine = stream.ReadText(adReadAll)
    stream.Close

    '改行コード判定(100文字で判断)
    line = Left(allLine, 100)
    If Instr(line, vbCrLf) <> 0 Then
        lfType = "CrLf"
    ElseIf Instr(line, vbLf) <> 0 Then
        lfType = "Lf"
    ElseIf Instr(line, vbCr) <> 0 Then
        lfType = "Cr"
    End If
    param("LfType") = lfType

    '改行コードで分割
    Select Case lfType
        Case "CrLf": lfType = vbCrLf
        Case "Lf": lfType = vbLf
        Case "Cr": lfType = vbCr
    End Select
    aryLine = Split(allLine, lfType)

    '先頭行に"< >"があれば、XMLとして扱う
    Set reg = CreateObject("VBScript.RegExp")
    reg.Pattern = "<.*?>"
    If reg.Test(aryLine(0)) Then
        param("FileType") = typeXML
        If param("IsBOM") = False Then
            'encodingの値を文字コードにセット
            reg.Pattern = "encoding.*?=['""](.*?)['""]"
            Set matches = reg.Execute(aryLine(0))
            For Each match In matches
                param("CharSet") = UCase(match.SubMatches(0))
            Next
            Set matches = Nothing
        End If
    End If
    Set reg = Nothing

    '最終行の改行コード有無
    If aryLine(UBound(aryLine)) = "" Then
        param("IsEndLf") = True
    End If

    GetInfo = True

End Function

'------------------------------------------------------------------------
'BOMチェックの情報を取得する
'------------------------------------------------------------------------
Function CheckBOM(str)
    Dim bytes(2), result

    bytes(0) = AscB(MidB(str, 1, 1))
    bytes(1) = AscB(MidB(str, 2, 1))
    bytes(2) = AscB(MidB(str, 3, 1))

    If LenB(str) > 1 Then
        If bytes(0) >= &HFE And bytes(1) >= &HFE Then
            result = "unicode"
            If bytes(0) = &HFF And bytes(1) = &HFE Then
                result = "unicodeFFFE"
            End If
        Else If bytes(0) = &HEF And bytes(1) = &HBB And bytes(2) = &HBF Then
                result = "UTF-8"
            End If
        End If
    End If

    If result <> "" Then
        param("CharSet") = result
        param("IsBOM") = True
    End If

    CheckBOM = result

End Function

'------------------------------------------------------------------------
'現在の設定値を取得する
'------------------------------------------------------------------------
Function GetValue(reg, valueString, target)
    Dim matches, match

    GetValue = ""
    If UCase(valueString) = GET_CONFIG Then
        Set matches = reg.Execute(target)
        For Each match In matches
            GetValue = match.SubMatches(1)
            Exit For
        Next
    End If

End Function
```

# ライセンスっぽいこと
* コード改変や配布は自由です。
* このツールによる義務／責任を何ら負いません。

# 最後に
仕組みとしては、変更キーを"/"区切りで分割して連想配列のキーとし値を「0」でセット、キーが見つかれば「1」に変更して、全てのキーの値が「1」になれば値を変更するようにしています。

今回ブラッシュアップした内容としては、下記となります。

* 特殊文字「"」や「%」などの対応
* 文字コード関連
* 改行無しXMLファイルの対応
* キーと値の間の空白やタグ間の改行対応

設定ファイルも手作業で修正してもそんな手間ではないわけですが、こういうツールがあれば、バッチを使って値を変更できるようになるので作業も簡単になりミスもなくなります。