はじめに
プリザンターで一覧画面から複数のレコードを選択して、指定した添付ファイル項目の中身を一括で削除したいという場面はありませんか?1件ずつ編集画面を開いて削除するのは手間がかかります。今回は、$p.selectedIds、$p.apiGet、$p.apiUpdateを組み合わせて、選択したレコードの添付ファイルをまとめて削除するスクリプトを作ってみます。対象の添付ファイル項目はモーダルダイアログで選べるようにしているので、テーブルに複数の添付ファイル項目がある場合でも柔軟に対応できます。さらに、削除前にファイルをダウンロードする機能も付けているので、バックアップを取りつつ安全に削除できます。
今回は拡張スクリプトと拡張サーバスクリプトを使って実装するので、テーブルごとの管理画面で個別に設定する必要がありません。
処理の流れ
まずは処理の全体像を確認してみましょう。
選択したレコードIDのうち先頭のレコードから、テーブルに存在する添付ファイル項目を検出します。モーダルダイアログで削除対象の項目を選んだ後、各レコードを順番に処理します。レコードごとに$p.apiGetでデータを取得し、選択された添付ファイル項目ごとにダウンロード→Deleted: trueで削除を実行します。
Deletedプロパティの値について
プリザンターの本体コード(Attachment.cs)では、Deletedプロパティはbool?型(nullable boolean)として定義されています。
public bool? Deleted;
内部ではDeleted == trueで判定しているため、JSON上はtrue(boolean)が正しい値です。C#のNewtonsoft.Jsonは1(整数)からboolへの変換もサポートしているため、マニュアルに記載されている1でも動作しますが、型としてはtrueが適切です。
添付ファイルのデータ構造
$p.apiGetで取得したレコードデータのAttachmentsHashには、各添付ファイル項目をキーとしたオブジェクトが格納されています。ただし、App_Data/Parameters/Api.jsonのVersionが1.100未満(既定値1.1)の環境では、レスポンスのAttachmentsHashがnullになり、代わりにAttachmentsA〜AttachmentsZ、Attachments001〜Attachments100の個別プロパティにJSON文字列として格納されます。
$p.apiGetのリクエストでApiVersion: 1.1を明示的に指定すれば、Api.jsonの設定に関わらずAttachmentsHashにまとめて格納された形式で取得できます。ただし、Api.jsonでCompatibility_1_3_12がtrueに設定されている環境では、ApiKeyを含むリクエストでのみApiVersionが反映されます。$p.apiGetはセッション認証(Cookie + CSRFトークン)で動作するためApiKeyを送信せず、ApiVersionの指定が無視されます。この場合はデフォルト値(Api.jsonのVersion)が使われるため、個別プロパティ形式のレスポンスが返る可能性があります。
今回のスクリプトではApiVersion: 1.1を指定しつつ、da_getAttachmentsHash関数で両方の形式に対応しているため、どの環境でも動作します。
それぞれの値は以下のようなJSON配列です。
[
{
"Guid": "31DE9B93C26342D186646E723D7EB8E1",
"Name": "report_202503.xlsx",
"Size": 11904,
"HashCode": "xjugT/ALXg+G5dcjgs6CTPZ7DAeFDTXnZ8kavI8AQZY="
},
{
"Guid": "84EFA42CCDEB4BF099211D7DA6502ACB",
"Name": "data_202503.csv",
"Size": 2634,
"HashCode": "VJJDZPuajWvcZxHELY8PMXQimVffjWRcP0HN6IVnQWk="
}
]
このGuidを使って、$p.apiUpdateのリクエストでDeleted: trueを指定すると、そのファイルが削除されます。
ダウンロード時のファイル名
ダウンロードされるファイルは、どのレコードのどの添付ファイル項目のものかが分かるように、以下の命名規則でファイル名が付けられます。
{SiteId}_{レコードID}_{添付ファイル項目名}_{元のファイル名}
例えば、サイトID12345のレコードID67890の添付ファイルA(AttachmentsA)に添付されたreport_202503.xlsxであれば、以下のようになります。
12345_67890_AttachmentsA_report_202503.xlsx
元のファイル名にファイル名として使用できない文字(\ / : * ? " < > |)が含まれている場合は、自動的に_に置換されます。
スクリプトを作ってみる
それでは、実際にスクリプトを作ってみましょう。ボタンをクリックすると、選択したレコードの添付ファイル項目を自動検出し、モーダルダイアログで削除対象を選べるようになっています。
拡張スクリプトとして App_Data/Parameters/ExtendedScripts/ に配置します。
function da_sanitizeFileName(name) {
return name.replace(/[\\/:*?"<>|]/g, '_');
}
function da_downloadFile(guid, fileName) {
return fetch('/binaries/' + guid + '/download', {
credentials: 'include'
})
.then(function (response) {
return response.blob();
})
.then(function (blob) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
});
}
function da_downloadFieldFiles(siteId, recordId, fieldName, attachments) {
var chain = Promise.resolve();
attachments.forEach(function (file) {
chain = chain.then(function () {
var fileName = da_sanitizeFileName(
siteId + '_' + recordId + '_' + fieldName + '_' + file.Name
);
return da_downloadFile(file.Guid, fileName);
});
});
return chain;
}
function da_getAttachmentsHash(record) {
var hash = record.AttachmentsHash;
if (hash && Object.keys(hash).length > 0) return hash;
hash = {};
Object.keys(record).forEach(function (key) {
if (!/^Attachments([A-Z]|\d{3})$/.test(key)) return;
var val = record[key];
if (!val) return;
if (typeof val === 'string') {
try { val = JSON.parse(val); } catch (e) { return; }
}
if (Array.isArray(val) && val.length > 0) hash[key] = val;
});
return hash;
}
function da_discoverFields(firstId, callback) {
$p.apiGet({
id: firstId,
data: { ApiVersion: 1.1 },
done: function (data) {
var hash = da_getAttachmentsHash(data.Response.Data[0]);
callback(Object.keys(hash).sort());
},
fail: function () {
callback([]);
}
});
}
function da_showFieldSelectionDialog(fields, onConfirm) {
if (fields.length === 0) {
$p.clearMessage();
$p.setMessage('#Message', JSON.stringify({
Css: 'alert-warning',
Text: '選択したレコードに添付ファイル項目がありません'
}));
return;
}
var $dialog = $('<div title="削除対象の添付ファイル項目を選択"></div>');
var $list = $('<div style="margin: 10px 0;"></div>');
fields.forEach(function (field) {
$list.append(
'<label style="display: block; margin: 5px 0; cursor: pointer;">'
+ '<input type="checkbox" value="' + field + '" checked> '
+ field
+ '</label>'
);
});
$dialog.append($list);
$dialog.dialog({
modal: true,
width: 480,
open: function () {
$(this).css('margin-bottom', 0);
$(this).next('.ui-dialog-buttonpane').css('margin-top', 0);
},
buttons: {
'ダウンロードして削除': function () {
var selected = [];
$dialog.find('input:checked').each(function () {
selected.push($(this).val());
});
$(this).dialog('close');
$(this).remove();
if (selected.length > 0) onConfirm(selected);
},
'キャンセル': function () {
$(this).dialog('close');
$(this).remove();
}
}
});
}
function da_deleteSelectedAttachments() {
var ids = $p.selectedIds();
if (ids.length === 0) {
alert('レコードが選択されていません');
return;
}
da_discoverFields(ids[0], function (fields) {
da_showFieldSelectionDialog(fields, function (selectedFields) {
da_processRecords(ids, selectedFields);
});
});
}
function da_processRecords(ids, selectedFields) {
var siteId = $p.siteId();
var completed = 0;
var failed = 0;
var index = 0;
function processNext() {
var id = ids[index++];
$p.apiGet({
id: id,
data: { ApiVersion: 1.1 },
done: function (data) {
var hash = da_getAttachmentsHash(data.Response.Data[0]);
da_processFields(siteId, id, hash, selectedFields, 0, function (success) {
if (success) completed++; else failed++;
processNext();
});
},
fail: function () {
failed++;
processNext();
}
});
}
processNext();
}
function da_processFields(siteId, id, hash, selectedFields, fieldIndex, callback) {
if (fieldIndex >= selectedFields.length) {
callback(true);
return;
}
var fieldName = selectedFields[fieldIndex];
var attachments = hash[fieldName];
if (!attachments || attachments.length === 0) {
da_processFields(siteId, id, hash, selectedFields, fieldIndex + 1, callback);
return;
}
da_downloadFieldFiles(siteId, id, fieldName, attachments)
.then(function () {
var updateHash = {};
updateHash[fieldName] = attachments.map(function (file) {
return { Guid: file.Guid, Deleted: true };
});
$p.apiUpdate({
id: id,
data: { AttachmentsHash: updateHash },
done: function () {
da_processFields(siteId, id, hash, selectedFields, fieldIndex + 1, callback);
},
fail: function () {
callback(false);
}
});
});
}
スクリプトの解説
すべての関数名にda_(Delete Attachments)プレフィックスを付けています。拡張スクリプトはグローバルスコープで読み込まれるため、他のスクリプトと関数名が衝突しないようにプレフィックスで名前空間を分離しています。
-
$p.selectedIds()で一覧画面のチェックボックスで選択されたレコードIDの配列を取得します -
da_discoverFieldsで先頭レコードのデータを$p.apiGetで取得し、da_getAttachmentsHashでテーブルに存在する添付ファイル項目を検出します。ApiVersion: 1.1を指定していますが、Compatibility_1_3_12 = trueの環境では無視されるため、da_getAttachmentsHashで個別プロパティ形式のフォールバックも行っています -
da_showFieldSelectionDialogでjQuery UIのモーダルダイアログを表示し、検出された添付ファイル項目をチェックボックスで一覧表示します。ユーザが削除対象の項目を選択します - ユーザが「ダウンロードして削除」をクリックすると、
da_processRecordsで各レコードを順番に処理します - レコードごとに
$p.apiGetで最新データを取得し、da_processFieldsで選択された添付ファイル項目を1つずつ処理します - 各項目のファイルを
da_downloadFieldFilesでダウンロードした後、その項目だけを対象に$p.apiUpdateでDeleted: trueを送信して削除します - 全レコードの処理が完了したら結果メッセージを表示し、
$p.sendで一覧を再描画します
モーダルダイアログのポイント
プリザンターにはjQuery UIが組み込まれているため、.dialog()メソッドを使ってモーダルダイアログを作成できます。modal: trueを指定することで背景がオーバーレイされ、ダイアログ外の操作を防止しています。ダイアログの「キャンセル」ボタンや閉じるボタンをクリックすると、ダイアログ要素がremove()で完全にDOMから削除されるため、繰り返し実行しても問題ありません。
ダウンロード処理のポイント
da_downloadFile関数では、/binaries/{Guid}/downloadにアクセスしてファイルの実体を取得しています。ブラウザのセッション認証を使用するため、credentials: 'include'を指定しています。取得したデータをBlobに変換し、<a>タグのdownload属性を使ってカスタムファイル名でダウンロードを実行しています。
da_downloadFieldFiles関数では、Promiseチェーンを使って1件ずつ順番にダウンロードしています。これはブラウザが同時に多数のダウンロードを行うと一部がブロックされる場合があるためです。
ボタンの追加(拡張サーバスクリプト)
スクリプトを実行するにはボタンが必要です。拡張サーバスクリプトのcontext.AddResponseを使って、一覧画面にボタンを追加します。Appendメソッドを使うと、指定したセレクタの末尾にHTMLを追加できます。詳しい引数の指定方法はcontext.AddResponseの引数への指定例まとめを参照してください。
App_Data/Parameters/ExtendedServerScripts/ に以下の2ファイルを配置します。
{
"BeforeOpeningPage": true,
"Actions": ["index"],
"TryCatch": true,
"Body": "-- loaded from .json.js"
}
Actionsで実行対象のアクションをindex(一覧画面)に限定しています。SiteIdListを省略しているため、すべてのテーブルの一覧画面でボタンが表示されます。特定のテーブルだけに限定したい場合は、"SiteIdList": [12345, 67890]のようにサイトIDを配列で指定してください。また、"UserIdList": [1, 2]でユーザID、"GroupIdList": [10]でグループID、"DeptIdList": [100]で組織IDを指定すると、対象ユーザだけにボタンを表示できます。いずれも省略時は全ユーザが対象です。
context.AddResponse(
'Append',
'#MainCommands:has(#BulkDeleteCommand)',
'<button id="deleteAttachments" class="button button-icon" type="button" onclick="da_deleteSelectedAttachments()">'
+ '<span class="ui-icon ui-icon-trash"></span>'
+ '<span>添付ファイル一括削除</span>'
+ '</button>'
);
プリザンターの一括削除ボタン(#BulkDeleteCommand)は、ユーザに削除権限がある場合にのみ#MainCommands内に描画されます。CSSの:has()を使い、一括削除ボタンが存在しない場合は添付ファイル一括削除ボタンを非表示にしています。削除権限がないユーザに誤ってボタンを表示してしまうのを防げます。
拡張サーバスクリプトでは、JSON設定ファイル(.json)とスクリプト本文ファイル(.json.js)を分離して配置できます。Bodyプロパティにスクリプトを直接書くこともできますが、分離した方がエディタの補完やシンタックスハイライトが効くためオススメです。
拡張サーバスクリプトはエラーが発生すると全体の処理に影響します。try-catchで囲むか、JSON設定ファイルで"TryCatch": trueを指定しておくと安全です。
実際に動かしてみた
実際にテーブルを用意して動作を確認してみましょう。
1. 一覧画面にボタンが追加される
拡張サーバスクリプトを配置すると、一覧画面のコマンドバーに「添付ファイル一括削除」ボタンが追加されます。
2. レコードを選択して削除対象の項目を選ぶ
添付ファイルを削除したいレコードにチェックを入れて、「添付ファイル一括削除」ボタンをクリックすると、先頭レコードから検出された添付ファイル項目の一覧がモーダルダイアログに表示されます。削除したくない項目はチェックを外してください。
3. ダウンロードと削除の実行
「ダウンロードして削除」をクリックすると、選択したレコードの添付ファイルが順番にダウンロードされた後、削除されます。ダウンロードされたファイルはSiteId_レコードID_項目名_元のファイル名の形式で保存されます。
まとめ
$p.selectedIds、$p.apiGet、$p.apiUpdateを組み合わせることで、一覧画面から選択した複数レコードの添付ファイルを一括で削除するスクリプトを作成しました。
- 拡張スクリプト・拡張サーバスクリプトで実装することで、テーブルごとの管理画面で個別設定する必要がない
- CSSの
:has()で一括削除ボタンの有無を判定し、削除権限があるユーザにのみ表示する - 特定のテーブルに限定したい場合は
SiteIdListで対象を指定できる -
$p.selectedIds()で選択レコードIDを取得する - 先頭レコードの
AttachmentsHashから、テーブルに存在する添付ファイル項目を自動検出する - 検出された添付ファイル項目をjQuery UIのモーダルダイアログで表示し、ユーザが削除対象を選択できるようにする
- 各レコードを順番に処理し、添付ファイル項目ごとにダウンロード→削除を実行する
- 削除前に
/binaries/{Guid}/downloadで各ファイルをダウンロードする - ダウンロードファイル名は
SiteId_レコードID_項目名_元のファイル名の形式で保存される -
$p.apiUpdateのAttachmentsHashでDeleted: trueを指定してファイルを削除する(本体コードではbool?型のためtrueが正しい値) - 処理完了後に
$p.sendで一覧を再描画する
対象の添付ファイル項目をモーダルで動的に選べるので、テーブルの添付ファイル項目の構成が変わっても、スクリプトの修正は不要です。是非試してみてください。



