intra-martのxmlをXSLT1.0でなんとかしてExcel管理したい話[ジョブネット定義編]

Excelで管理台帳を作る文化がどれくらい生き残っているのか、
寡聞にして存じませんが、日本においてはいかにも根強く残っていそうです。

#### 書くこと
* intra-martのエクスポートファイル(本稿ではジョブネット)をExcelファイルに取り込む要領

#### 書かないこと
* PowerShellの詳細
* XSLTの詳細
* パワークエリの詳細

#### 想定環境
* Windows
* Excel(パワークエリが利用可能なもの)
* 上記以外のツール・ライブラリをインストール・セットアップせずに済ませたい

## XSLTプロセッサ
前述のとおり、セットアップを省略したいので、PowerShellで[XslCompiledTransformクラス](https://learn.microsoft.com/ja-jp/dotnet/api/system.xml.xsl.xslcompiledtransform?view=net-8.0)を使います。
XslCompiledTransformクラスはXSLT1.0のみに対応とのことで、早速XSLT2.0以上は使えないという制約が発生しております!:laughing:
```powershell:transform.ps1
Param(
    [Parameter(Mandatory=$true,HelpMessage="Enter xsl styleSheet file name.")]
    [String]$Xsl,
    [Parameter(Mandatory=$true,HelpMessage="Enter xml file name.")]
    [String]$Xml,
    [Parameter(Mandatory=$true,HelpMessage="Enter output file name.")]
    [String]$Result
)

&{
    $xslt = [System.Xml.Xsl.XslCompiledTransform]::new($true)
    $xslt.Load($Xsl)
    $xslt.Transform($Xml, $Result)
}
```
## 変換処理
XSLファイル、XMLファイル、XSLT変換後のファイルを指定して実行します。
各ファイルの内容は後述します。

```powershell
.\transform.ps1 -Xsl .\job-scheduler.xsl -Xml .\job-scheduler.xml -Result .\job-scheduler.html
```
以下のエラーが出る場合はExecutionPolicyを指定して再度、実行します。

:::note alert
このシステムではスクリプトの実行が無効になっているため、ファイル {ファイルパス} を読み込むことができません。詳細については、「about_Execution_Policies」(https://go.microsoft.com/fwlink/?LinkID=135170) を参
照してください。
:::

```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Force
```

## XMLファイル
intra-martのジョブネット・ジョブ定義のXMLファイルです。[公式ドキュメントのサンプルファイル](https://document.intra-mart.jp/library/iap/public/im_job_scheduler/im_job_scheduler_import_export_specification/texts/file_format/index.html#xml%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB)を少々改変しております。
```xml:job-scheduler.xml
<?xml version="1.0" encoding="UTF-8"?>
<root xmlns="http://www.intra-mart.jp/job-scheduler/data">
    <im-job-scheduler-data>
        <job-category id="grand-parent-job-category">
            <localize locale="ja">
                <name>ルートジョブカテゴリ</name>
            </localize>
        </job-category>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <job-category id="parent-job-category">
            <parent-id>grand-parent-job-category</parent-id>
            <localize locale="ja">
                <name>ジョブカテゴリ</name>
            </localize>
        </job-category>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <job-category id="sample-job-category">
            <parent-id>parent-job-category</parent-id>
            <localize locale="ja">
                <name>サンプルジョブカテゴリ</name>
            </localize>
        </job-category>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <jobnet-category id="grand-parent-jobnet-category">
            <localize locale="ja">
                <name>ルートジョブネットカテゴリ</name>
            </localize>
        </jobnet-category>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <jobnet-category id="parent-jobnet-category">
            <parent-id>grand-parent-jobnet-category</parent-id>
            <localize locale="ja">
                <name>ジョブネットカテゴリ</name>
            </localize>
        </jobnet-category>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <jobnet-category id="sample-jobnet-category">
            <parent-id>parent-jobnet-category</parent-id>
            <localize locale="ja">
                <name>サンプルジョブネットカテゴリ</name>
            </localize>
        </jobnet-category>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <job-detail id="sample-job">
            <category-id>sample-job-category</category-id>
            <job-type>JAVA</job-type>
            <job-path>jp.co.intra_mart.sample.SampleJob</job-path>
            <parameter key="param1">value1</parameter>
            <localize locale="ja">
                <name>サンプルジョブ</name>
                <description>サンプルジョブです</description>
            </localize>
        </job-detail>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <job-detail id="sample-job2">
            <category-id>parent-job-category</category-id>
            <job-type>JAVA</job-type>
            <job-path>jp.co.intra_mart.sample.SampleJob2</job-path>
            <parameter key="param2">value2</parameter>
            <parameter key="param3">value3</parameter>
            <localize locale="ja">
                <name>サンプルジョブ2</name>
                <description>サンプルジョブ2です</description>
            </localize>
        </job-detail>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <job-detail id="sample-job3">
            <job-type>JAVA</job-type>
            <job-path>jp.co.intra_mart.sample.SampleJob3</job-path>
            <localize locale="ja">
                <name>サンプルジョブ3</name>
                <description>サンプルジョブ3です</description>
            </localize>
        </job-detail>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <jobnet id="sample-jobnet">
            <category-id>sample-jobnet-category</category-id>
            <parameter key="param1">value1</parameter>
            <localize locale="ja">
                <name>サンプルジョブネット</name>
                <description>サンプルジョブネットです</description>
            </localize>
            <disallowConcurrent>true</disallowConcurrent>
            <serialize>
                <job-id>sample-job</job-id>
            </serialize>
        </jobnet>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <jobnet id="sample-jobnet2">
            <category-id>parent-jobnet-category</category-id>
            <parameter key="param1">value1</parameter>
            <parameter key="param3">value3</parameter>
            <localize locale="ja">
                <name>サンプルジョブネット2</name>
                <description>サンプルジョブネット2です</description>
            </localize>
            <disallowConcurrent>true</disallowConcurrent>
            <serialize>
                <job-id>sample-job</job-id>
                <job-id>sample-job2</job-id>
            </serialize>
        </jobnet>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <jobnet id="sample-jobnet3">
            <localize locale="ja">
                <name>サンプルジョブネット3</name>
                <description>サンプルジョブネット3です</description>
            </localize>
            <disallowConcurrent>true</disallowConcurrent>
            <serialize>
                <job-id>sample-job2</job-id>
            </serialize>
        </jobnet>
    </im-job-scheduler-data>
    <im-job-scheduler-data>
        <trigger id="sample-trigger">
            <jobnet-id>sample-jobnet</jobnet-id>
            <description>サンプルジョブネットのトリガです</description>
            <enable>false</enable>
            <start-date>2014-03-24T17:21:57.000+09:00</start-date>
            <repeat>
                <count>1</count>
            </repeat>
        </trigger>
    </im-job-scheduler-data>
</root>
```
## XSLファイル
ジョブネットカテゴリ・ジョブカテゴリの階層の深さはまちまちですので、ここでは割り切って階層を「/」区切りで連結し単一の文字列にしています。後でExcelファイルにするので、パワークエリで分割します。(ジョブネットカテゴリID等に「/」を利用している場合はxslファイル内の区切り文字```separator```を「|」に変更する等、調整してください。)
```xml:job-scheduler.xsl
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:im="http://www.intra-mart.jp/job-scheduler/data" exclude-result-prefixes="im">
  <xsl:output method="html" encoding="UTF-8" />

  <xsl:variable name="jobnetCategories" select="//im:jobnet-category" />
  <xsl:variable name="jobCategories" select="//im:job-category" />
  <xsl:variable name="separator" select="'/'" />

  <xsl:template match="/">
  <html lang="ja">
    <head>
      <title>jobnet-category</title>
    </head>
    <body>
      <h2>jobnet</h2>
      <table border="1">
        <tr>
          <th>full-path</th>
          <th>jobnet-id</th>
          <th>name(ja)</th>
          <th>description(ja)</th>
          <th>parameters</th>
          <th>disallowConcurrent</th>
          <th>jobs</th>
          <th>triggers</th>
        </tr>
        <xsl:apply-templates select="//im:jobnet" />
      </table>

      <h2>job</h2>
      <table border="1">
        <tr>
          <th>full-path</th>
          <th>job-id</th>
          <th>type</th>
          <th>path</th>
          <th>parameters</th>
          <th>name(ja)</th>
          <th>description(ja)</th>
        </tr>
        <xsl:apply-templates select="//im:job-detail" />
      </table>

      <h2>trigger</h2>
      <table border="1">
        <tr>
          <th>jobnet</th>
          <th>id</th>
          <th>description</th>
          <th>enable</th>
          <th>start-date</th>
          <th>repeat</th>
        </tr>
        <xsl:apply-templates select="//im:trigger" />
      </table>
    </body>
  </html>
  </xsl:template>

  <xsl:template match="im:jobnet">
    <tr>
      <td>
        <xsl:call-template name="get_fullpath_jobnet_categories">
          <xsl:with-param name="category_id">
            <xsl:value-of select="im:category-id" />
          </xsl:with-param>
        </xsl:call-template>
      </td>
      <td><xsl:value-of select="@id" /></td>
      <td><xsl:value-of select="im:localize[@locale='ja']/im:name" /></td>
      <td><xsl:value-of select="im:localize[@locale='ja']/im:description" /></td>
      <td>
        <xsl:for-each select="im:parameter">
          <xsl:if test="position() &gt; 1">|</xsl:if>
          <xsl:value-of select="@key" />=<xsl:value-of select="text()" />
        </xsl:for-each>
      </td>
      <td><xsl:value-of select="im:disallowConcurrent" /></td>
      <xsl:apply-templates select="im:serialize" />
      <td>
        <xsl:call-template name="triggers">
          <xsl:with-param name="jobnet_id">
            <xsl:value-of select="@id" />
          </xsl:with-param>
        </xsl:call-template>
      </td>
    </tr>
  </xsl:template>

  <xsl:template match="im:job-detail">
    <tr>
      <td>
        <xsl:call-template name="get_fullpath_job_categories">
          <xsl:with-param name="category_id">
            <xsl:value-of select="im:category-id" />
          </xsl:with-param>
        </xsl:call-template>
      </td>
      <td><xsl:value-of select="@id" /></td>
      <td><xsl:value-of select="im:job-type" /></td>
      <td><xsl:value-of select="im:job-path" /></td>
      <td>
        <xsl:for-each select="im:parameter">
          <xsl:if test="position() &gt; 1">|</xsl:if>
          <xsl:value-of select="@key" />=<xsl:value-of select="text()" />
        </xsl:for-each>
      </td>
      <td><xsl:value-of select="im:localize[@locale='ja']/im:name" /></td>
      <td><xsl:value-of select="im:localize[@locale='ja']/im:description" /></td>
    </tr>
  </xsl:template>

  <xsl:template match="im:serialize">
      <td>
      <xsl:for-each select="im:job-id">
        <xsl:if test="position() &gt; 1">|</xsl:if>
        <xsl:value-of select="." />
      </xsl:for-each>
      </td>
  </xsl:template>

  <xsl:template match="im:trigger">
      <td><xsl:value-of select="im:jobnet-id" /></td>
      <td><xsl:value-of select="@id" /></td>
      <td><xsl:value-of select="im:description" /></td>
      <td><xsl:value-of select="im:enable" /></td>
      <td><xsl:value-of select="im:start-date" /></td>
      <td><xsl:value-of select="im:repeat/im:count" /></td>
  </xsl:template>

  <xsl:template name="get_fullpath_job_categories">
  	<xsl:param name="category_id" />

    <xsl:for-each select="$jobCategories">
      <xsl:if test="@id = $category_id">
        <xsl:call-template name="get_fullpath_job_categories">
          <xsl:with-param name="category_id">
            <xsl:value-of select="im:parent-id" />
          </xsl:with-param>
        </xsl:call-template>
        <xsl:value-of select="concat(@id, $separator)" />
      </xsl:if>
    </xsl:for-each>
  </xsl:template>
  
  <xsl:template name="get_fullpath_jobnet_categories">
  	<xsl:param name="category_id" />

    <xsl:for-each select="$jobnetCategories">
      <xsl:if test="@id = $category_id">
        <xsl:call-template name="get_fullpath_jobnet_categories">
          <xsl:with-param name="category_id">
            <xsl:value-of select="im:parent-id" />
          </xsl:with-param>
        </xsl:call-template>
        <xsl:value-of select="concat(@id, $separator)" />
      </xsl:if>
    </xsl:for-each>
  </xsl:template>

  <xsl:template name="triggers">
	<xsl:param name="jobnet_id" />

    <xsl:for-each select="//im:trigger">
      <xsl:if test="im:jobnet-id = $jobnet_id">
        <xsl:value-of select="@id" /><br/>
      </xsl:if>
    </xsl:for-each>
  </xsl:template>

</xsl:stylesheet>
```

テンプレート「get_fullpath_job_categories」と「get_fullpath_jobnet_categories」がほぼ同じ内容でDRY原則に反しておりそうです。```xsl:for-each```に指定している変数を```xsl:param```としてtemplateに渡そうとすると、以下のエラーになるため断念しました。
XSLT1.0縛りの中で回避できる方法をご存じの方はご指摘いただけると幸いです。

:::note alert
パスの式の中で 'Result tree fragment' を使用するには、最初に msxsl:node-set() 関数を使用してノードセットに変換してください。
:::

## XSLT変換結果
```html:job-scheduler.html
<html lang="ja">
  <head>
    <META http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>jobnet-category</title>
  </head>
  <body>
    <h2>jobnet</h2>
    <table border="1">
      <tr>
        <th>full-path</th>
        <th>jobnet-id</th>
        <th>name(ja)</th>
        <th>description(ja)</th>
        <th>parameters</th>
        <th>disallowConcurrent</th>
        <th>jobs</th>
        <th>triggers</th>
      </tr>
      <tr>
        <td>grand-parent-jobnet-category/parent-jobnet-category/sample-jobnet-category/</td>
        <td>sample-jobnet</td>
        <td>サンプルジョブネット</td>
        <td>サンプルジョブネットです</td>
        <td>param1=value1</td>
        <td>true</td>
        <td>sample-job</td>
        <td>sample-trigger<br></td>
      </tr>
      <tr>
        <td>grand-parent-jobnet-category/parent-jobnet-category/</td>
        <td>sample-jobnet2</td>
        <td>サンプルジョブネット2</td>
        <td>サンプルジョブネット2です</td>
        <td>param1=value1|param3=value3</td>
        <td>true</td>
        <td>sample-job|sample-job2</td>
        <td></td>
      </tr>
      <tr>
        <td></td>
        <td>sample-jobnet3</td>
        <td>サンプルジョブネット3</td>
        <td>サンプルジョブネット3です</td>
        <td></td>
        <td>true</td>
        <td>sample-job2</td>
        <td></td>
      </tr>
    </table>
    <h2>job</h2>
    <table border="1">
      <tr>
        <th>full-path</th>
        <th>job-id</th>
        <th>type</th>
        <th>path</th>
        <th>parameters</th>
        <th>name(ja)</th>
        <th>description(ja)</th>
      </tr>
      <tr>
        <td>grand-parent-job-category/parent-job-category/sample-job-category/</td>
        <td>sample-job</td>
        <td>JAVA</td>
        <td>jp.co.intra_mart.sample.SampleJob</td>
        <td>param1=value1</td>
        <td>サンプルジョブ</td>
        <td>サンプルジョブです</td>
      </tr>
      <tr>
        <td>grand-parent-job-category/parent-job-category/</td>
        <td>sample-job2</td>
        <td>JAVA</td>
        <td>jp.co.intra_mart.sample.SampleJob2</td>
        <td>param2=value2|param3=value3</td>
        <td>サンプルジョブ2</td>
        <td>サンプルジョブ2です</td>
      </tr>
      <tr>
        <td></td>
        <td>sample-job3</td>
        <td>JAVA</td>
        <td>jp.co.intra_mart.sample.SampleJob3</td>
        <td></td>
        <td>サンプルジョブ3</td>
        <td>サンプルジョブ3です</td>
      </tr>
    </table>
    <h2>trigger</h2>
    <table border="1">
      <tr>
        <th>jobnet</th>
        <th>id</th>
        <th>description</th>
        <th>enable</th>
        <th>start-date</th>
        <th>repeat</th>
      </tr>
      <td>sample-jobnet</td>
      <td>sample-trigger</td>
      <td>サンプルジョブネットのトリガです</td>
      <td>false</td>
      <td>2014-03-24T17:21:57.000+09:00</td>
      <td>1</td>
    </table>
  </body>
</html>
```
Google Chromeで表示すると、以下のようになりました。
![image1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/170999/d754a1a9-fce6-f061-29ee-542fa2c03448.jpeg)

## Excelファイルへの取込み
Excelで「データ > データの取得と変換 > Webから」先のhtmlファイルを取込み、区切り文字で分割する等調整するとジョブネットの表は以下のようになります。ジョブ、トリガの表も同じ要領で取り込めます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/170999/26a28d36-9c12-cc5f-8a38-e852e44b2228.png)

あとはExcel側でいかようにも調理できるかと思います。
このExcelファイルからxmlを生成できるようにすれば、完全循環のエコシステムが出現する、かもしれません。

なお、今回は複雑なxmlでしたのでXSLTを利用しましたが、単純なxmlであれば、そのままパワークエリで取り扱えるケースもあると思います。ご留意くださいませ。

ではまた。