コピーボタンと参照コピーボタン
編集画面にはレコードを複製するボタンが 2 種類あります。このページでは両者の内部動作の違いと、複製時に引き継がれる値・リセットされる値をまとめます。
- コピーはダイアログを経由して
POST items/{id}/copyを送り、サーバー側ですぐに複製レコードを作成する - 参照コピーは
?CopyFrom={id}付きの新規作成画面に遷移するだけ。コピー元の値が入力済みの状態で、ユーザーが手を加えてから「作成」で確定する - どちらも添付ファイル・コピー時の初期値を設定した列・読み取り権限のない列はリセットされ、リンクデータは
CopyWithLinksで引き継がれる
2 つのボタンの違い
| 比較項目 | コピー | 参照コピー |
|---|---|---|
| ショートカットキー | ALT + c | ALT + k |
| 操作後の流れ | コピーダイアログが開き、「コピー」でそのまま複製が作成される | 新規作成画面へ遷移し、コピー元の値が入力済みで表示される |
| 有効にする設定 | AllowCopy(コピーを許可する) | AllowReferenceCopy(参照コピーを許可する) |
CopyFrom の受け渡し | サーバー側で直接コピー元の ID を渡して複製 | URL パラメータ → 隠しフィールド → フォームの 2 段階 |
| タイトルへの接尾辞 | CharToAddWhenCopying を自動で付ける | 付けない(ユーザーが編集する) |
| コメント | ダイアログの「コメントと共にコピー」で選べる(既定はオン) | クリアされる |
| リンクデータ | CopyWithLinks で引き継ぐ | 同様に CopyWithLinks で引き継ぐ |
| 添付ファイル | SetCopyDefault() でリセット(引き継がない) | 同様にリセット |
どちらのボタンも編集画面にだけ表示され、ログインユーザーに作成権限(CanCreate)が必要です。

設定画面の場所
設定は「テーブルの管理」の「エディタ」タブにある「コピーを許可」「参照コピーを許可」です。同じタブに、コピー時にタイトルへ追加する文字(CharToAddWhenCopying)の入力欄もあります(1.5.8.1 の SiteUtilities.cs の EditorSettingsEditor)。

コピーボタンの仕組み
ボタンとダイアログ
「コピー」ボタンは次のように定義されています。ss.IsSite(context) が true の場合は AllowCopy に関係なく表示されます。
.Button(
controlId: "OpenCopyDialogCommand",
text: Displays.Copy(context: context),
controlCss: "button-icon open-dialog button-positive",
accessKey: "c",
onClick: "$p.openDialog($(this));",
icon: "ui-icon-copy",
selector: "#CopyDialog",
_using: copyButton
&& context.CanCreate(ss: ss)
&& (ss.IsSite(context: context) || ss.AllowCopy == true))クリックすると $p.openDialog($(this)) で #CopyDialog が開きます。ダイアログの構成は次のとおりです。
<div id="CopyDialog" class="dialog" title="コピー設定">
<form id="CopyDialogForm" action="{ApplicationPath}items/{id}/_action_">
<!-- チェックボックス: コメントと共にコピー(デフォルト: チェック) -->
<input id="CopyWithComments" type="checkbox" class="always-send" checked>
<!-- チェックボックス: 通知と共にコピー(設定によって表示) -->
<input id="CopyWithNotifications" type="checkbox" class="always-send">
<!-- チェックボックス: リマインダーと共にコピー(設定によって表示) -->
<input id="CopyWithReminders" type="checkbox" class="always-send">
<!-- コピー実行ボタン -->
<button
id="CopyCommand"
onclick="$p.copy($(this));"
data-action="Copy"
data-method="post"
>コピー</button>
<!-- キャンセルボタン -->
<button onclick="$p.closeDialog($(this));">キャンセル</button>
</form>
</div>
フォームの action の _action_ は送信時に data-action の値(Copy)で置き換えられ、送信先は {ApplicationPath}items/{id}/copy になります。ダイアログは HtmlCopies.cs で生成されています(HtmlCopies.cs#L10-L77)。
$p.copy()
ダイアログの「コピー」で呼ばれる $p.copy() は、フォームを同期送信し、成功したらブラウザの URL を更新するだけです。
$p.copy = function ($control) {
var error = $p.syncSend($control);
if (error === 0) {
history.pushState(null, null, $('#BaseUrl').val() + $('#Id').val());
}
};送信データには次の項目が含まれます。
| フィールド | 説明 |
|---|---|
ControlId | "CopyCommand"(押したボタンの ID) |
CopyWithComments | チェック状態(true / false) |
CopyWithNotifications | チェック状態(設定によって送信) |
CopyWithReminders | チェック状態(設定によって送信) |
Token | CSRF トークン |
サーバー側の処理
POST は ItemsController.Copy で受け取り、テーブル種別ごとの処理に委譲されます。
[HttpPost]
public string Copy(long id)
{
var context = new Context();
var log = new SysLogModel(context: context);
var json = new ItemModel(context: context, referenceId: id).Copy(context: context);
log.Finish(context: context, responseSize: json.Length);
return json;
}期限付きテーブル(Issues)の場合は次のとおりです。
public static string Copy(Context context, SiteSettings ss, long issueId)
{
// ...(権限チェックなど)...
var issueModel = new IssueModel(
context: context,
ss: ss,
issueId: issueId,
formData: context.Forms); // フォームデータ(チェックボックスの値など)を読み込む
issueModel.IssueId = 0; // 新規レコードとして扱う
issueModel.Ver = 1;
// タイトルにコピー時の追記文字を付加
if (ss.GetEditorColumnNames().Contains("Title"))
{
issueModel.Title.Value += ss.CharToAddWhenCopying;
}
// CopyWithComments が false ならコメントをクリア
if (!context.Forms.Bool("CopyWithComments"))
{
issueModel.Comments.Clear();
}
// サイト管理で設定したカラムのコピー時初期値を適用
issueModel.SetCopyDefault(context: context, ss: ss);
var errorData = issueModel.Create(
context: context,
ss: ss,
copyFrom: issueId, ...);
// ...(レスポンス生成)...
}| 処理 | 内容 |
|---|---|
| タイトル末尾への追記 | CharToAddWhenCopying(サイトで未設定なら General.json の値。1.5.8.1 の既定値は " - コピー"、General.json)を Title に追加。エディタにタイトル列がある場合のみ |
| コメントのクリア | CopyWithComments が false のときコメントを空にする |
| コピー時初期値の適用 | SetCopyDefault()(後述) |
| 作成 | Create() に copyFrom を渡し、リンクデータも複製する |
コピー後の画面(SwitchRecordWithAjax)
コピー成功後の画面の切り替わり方は、サイト管理の「Ajax でレコードを切り替える」(SwitchRecordWithAjax)で変わります。
図を読み込み中…
| 設定 | 動作 |
|---|---|
SwitchRecordWithAjax = true | 編集画面を AJAX で再描画し、ページ遷移なしでコピー後のレコードを表示 |
SwitchRecordWithAjax = false(既定値) | コピー後のレコードの URL へ遷移 |
参照コピーボタンの仕組み
ボタンは新規作成画面へ遷移するだけ
参照コピーボタンの定義は次のとおりです。
.Button(
serverScriptModelRow: serverScriptModelRow,
commandDisplayTypes: view?.ReferenceCopyCommand,
controlId: "ReferenceCopyCommand",
text: Displays.ReferenceCopy(context: context),
controlCss: "button-icon button-positive",
accessKey: "k",
onClick: $"location.href='{Locations.ItemNew(context: context, id: ss.SiteId)}?CopyFrom={context.Id}'",
icon: "ui-icon-copy",
_using: copyButton
&& context.CanCreate(ss: ss)
&& !ss.IsSite(context: context)
&& ss.AllowReferenceCopy == true)onClick は次の画面遷移です。ダイアログも API 呼び出しもありません。これが「参照コピーは新規作成とセットになる」理由です。
location.href = '/items/{siteId}/new?CopyFrom={currentItemId}'表示されるのは次の条件をすべて満たすときです。
| 条件 | 説明 |
|---|---|
copyButton | 呼び出し元で true が渡されている(編集画面が対象) |
context.CanCreate(ss: ss) | ログインユーザーが作成権限を持っている |
!ss.IsSite(context: context) | サイト設定画面ではない(通常のレコード編集画面である) |
ss.AllowReferenceCopy | 参照コピーが有効になっている |
CopyFrom は GET と POST の 2 回使われる
CopyFrom は、新規作成画面の表示(GET)と「作成」の保存(POST)の 2 回参照されます。
図を読み込み中…
GET:新規作成画面の初期値を用意する
新規作成画面(EditorNew)は、CopyFrom があればコピー元レコードを DB から取得し、CopyAndInit() で新規作成用に初期化します。
public static string EditorNew(Context context, SiteSettings ss)
{
// アイテム数上限チェック(超えていたらエラー画面を返す)
if (context.ContractSettings.ItemsLimit(context: context, siteId: ss.SiteId))
{
return HtmlTemplates.Error(...);
}
IssueModel issueModel = null;
var copyFrom = context.QueryStrings.Long("CopyFrom");
if (ss.AllowReferenceCopy == true && copyFrom > 0)
{
// コピー元レコードを issueId = copyFrom で DB から SELECT
issueModel = new IssueModel(
context: context,
ss: ss,
issueId: copyFrom,
methodType: BaseModel.MethodTypes.New);
if (issueModel.AccessStatus == Databases.AccessStatuses.Selected
&& Permissions.CanRead(context: context, siteId: ss.SiteId, id: issueModel.IssueId))
{
// 読み取りOK → 値を複写して新規作成用に初期化
issueModel = issueModel.CopyAndInit(context: context, ss: ss);
}
else
{
// 存在しない or 権限なし → NotFound エラー
return HtmlTemplates.Error(context: context, errorData: new ErrorData(type: Error.Types.NotFound));
}
}
// CopyFrom なし → 空の IssueModel で通常の新規作成画面
return Editor(context: context, ss: ss,
issueModel: issueModel ?? new IssueModel(context: context, ss: ss,
methodType: BaseModel.MethodTypes.New, formData: context.Forms));
}コピー元の読み取り権限が必要
コピー元レコードが存在しない、または読み取り権限がない場合は NotFound エラーになります。参照コピーは「コピー元を見る権限があること」が前提です。
CopyAndInit() は次の順に処理します。
public IssueModel CopyAndInit(Context context, SiteSettings ss)
{
// 空の新規モデルを作成
var issueModel = new IssueModel(context: context, ss: ss, methodType: MethodTypes.New);
// ① コピー元の全フィールドを新モデルに複写
issueModel.SetByModel(this);
// ② 新規作成に必要な値をリセット
issueModel.IssueId = 0; // ID なし(INSERT 後に採番される)
issueModel.Ver = 1; // バージョンは 1 に戻す
issueModel.Comments = new Comments(); // コメントはクリア
// ③ コピー時のデフォルト値を適用(CopyByDefault 列 / 添付 / 読み取り不可列)
issueModel.SetCopyDefault(context: context, ss: ss);
// ④ フォームデータを上書き(画面で変更済みの値があれば反映)
issueModel.SetByForm(context: context, ss: ss, formData: context.Forms);
// ⑤ サイト設定由来の値を適用(計算式・デフォルト値など)
issueModel.SetBySettings(context: context, ss: ss, formData: context.Forms);
// ⑥ ステータス制御(ステータス連動の項目を強制更新)
issueModel.SetByStatusControls(context: context, ss: ss, force: true);
return issueModel;
}①の SetByModel() では、標準フィールド(タイトル・本文・開始日・完了日・進捗率・ステータス・担当者など)と拡張フィールド(ClassHash・NumHash・DateHash・DescriptionHash・CheckHash・AttachmentsHash)がすべてコピーされ、その後③でリセット対象の列が初期化されます。
GET 後:CopyFrom を隠しフィールドで引き継ぐ
新規作成画面には、CopyFrom の値が隠しフィールドとして埋め込まれます。always-send クラスが付いているため、「作成」を押したときの POST に自動で含まれます。
.Hidden(
controlId: "CopyFrom",
css: "control-hidden always-send",
value: context.QueryStrings.Long("CopyFrom").ToString(),
_using: context.QueryStrings.Long("CopyFrom") > 0)POST:権限を再確認し、リンクデータを引き継ぐ
「作成」を押すと Create が呼ばれ、ここでも CopyFrom が使われます。
public static string Create(Context context, SiteSettings ss)
{
var copyFrom = context.Forms.Int("CopyFrom");
// コピー元への読み取り権限チェック(POST でも再検証)
if (copyFrom > 0 && !Permissions.CanRead(context, siteId: context.SiteId, id: copyFrom))
{
return Error.Types.HasNotPermission.MessageJson(context: context);
}
var issueModel = new IssueModel(
context, ss, issueId: copyFrom,
setCopyDefault: copyFrom > 0, // コピー元がある場合は CopyByDefault を適用
formData: context.Forms);
issueModel.IssueId = 0;
issueModel.Ver = 1;
// ... バリデーション・プロセス処理 ...
if (copyFrom > 0)
{
// コピー元のコメントは引き継がない(新規作成中に追加したコメントのみ保持)
issueModel.Comments.RemoveAll(o => !o.Created);
}
var errorData = issueModel.Create(
context, ss,
copyFrom: context.Forms.Long("CopyFrom"), // IssueModel.Create に渡す
otherInitValue: copyFrom > 0);
// ...
}IssueModel.Create() では、INSERT の完了後にリンクデータを複製します。
if (copyFrom > 0)
{
// リンク設定の "CopyWithLinks" アクションを実行
// コピー元(From)のリンクデータを新レコード(To)へ複製する
ss.LinkActions(
context: context,
type: "CopyWithLinks",
data: new Dictionary<string, string>()
{
{ "From", copyFrom.ToString() },
{ "To", IssueId.ToString() }
});
}コピー時にリセットされる値(SetCopyDefault)
コピー・参照コピーのどちらでも、SetCopyDefault() で次の列がデフォルト値に戻されます。
| 対象の列 | 理由 |
|---|---|
CopyByDefault == true の列 | サイト設定でコピー時に初期化するよう設定されている(カラム設定の「コピー時の初期値」) |
Attachments 型の列 | 添付ファイルはコピーしない(別レコードに同じファイルを参照させない) |
| 読み取り権限のない列 | ユーザーが参照できない値を引き継がせない |
public void SetCopyDefault(Context context, SiteSettings ss)
{
ss.Columns
.Where(column => column.CopyByDefault == true
|| column.TypeCs == "Attachments"
|| !column.CanRead(context: context, ss: ss, mine: Mine(context: context)))
.ForEach(column => SetDefault(context: context, ss: ss, column: column));
}「読み取り権限のない列」の判定には項目のアクセス制御が使われます。判定の仕組みは アクセス権限の実装 を参照してください。
応用:プログラムからコピーする $p.itemCopy
コピーボタンの仕組みを応用して、任意のレコードをスクリプトからコピーする $p.itemCopy を拡張スクリプトとして作れます。コピーボタンと同じく items/{id}/copy に POST します。
引数(args のプロパティ) | 型 | 説明 |
|---|---|---|
id | number | コピー元のアイテム ID(省略時は現在のアイテム) |
withComments | boolean | コメントと共にコピーするか(既定: true) |
done | function(referenceId, json) | 成功時のコールバック。コピーされたアイテムの ReferenceId とレスポンス JSON を受け取る |
fail | function(jqXHR) | 失敗時のコールバック |
always | function() | 成否に関わらず実行されるコールバック |
App_Data/Parameters/ExtendedScripts/ に ItemCopy.js として配置します。
/**
* $p.itemCopy
* 任意のアイテムをプログラムからコピーする拡張関数
*
* 使用例:
* $p.itemCopy({ done: function(referenceId) { console.log(referenceId); } });
* $p.itemCopy({ id: 12345, withComments: false, done: function(referenceId) { ... } });
*/
$p.itemCopy = function (args) {
args = args || {};
var id = args.id || $p.id();
var data = {
ControlId: 'CopyCommand',
CopyWithComments: args.withComments !== false
};
if ($('#Token').length) {
data.Token = $('#Token').val();
}
return $.ajax({
type: 'post',
url: $('#ApplicationPath').val() + 'items/' + id + '/copy',
cache: false,
data: data,
dataType: 'json'
}).done(function (json) {
var resp = json.find(function (r) {
return r.Method === 'Response' && r.Target === 'id';
});
var referenceId = resp ? Number(resp.Value) : null;
if (typeof args.done === 'function') {
args.done(referenceId, json);
}
}).fail(function (jqXHR) {
if (typeof args.fail === 'function') {
args.fail(jqXHR);
}
}).always(function () {
if (typeof args.always === 'function') {
args.always();
}
});
};実装のポイントは次のとおりです。
| ポイント | 内容 |
|---|---|
$p.apiExec と同じ構造 | $p.apiGet / $p.apiCreate などが内部で使う $p.apiExec と同じく、$.ajax の .done() / .fail() / .always() に args のコールバックを委ねる。書き方が $p.apiCreate などと揃う |
| 新しい ID の取得 | コピー成功時のレスポンス JSON には { "Method": "Response", "Target": "id", "Value": "新しいID" } が含まれるので、これを find で取り出して数値にする |
| CSRF トークン | $p.apiExec と同様に $('#Token').val() を送信データに加える |
使い方の例
// 現在のアイテムをコピーして新しい ID をコンソールに表示する
$p.itemCopy({
done: function (referenceId) {
console.log('コピー成功。新しいアイテム ID: ' + referenceId);
},
fail: function () {
console.error('コピーに失敗しました。');
}
});// コメントなしでコピーし、新しいアイテムへ遷移する
$p.itemCopy({
withComments: false,
done: function (referenceId) {
$p.transition($('#BaseUrl').val() + referenceId);
}
});// 特定のアイテムをコピーして新しい ID を取得する
$p.itemCopy({
id: 12345,
done: function (referenceId) {
console.log('ID 12345 をコピー → 新 ID: ' + referenceId);
},
fail: function (jqXHR) {
console.error('コピー失敗: ' + jqXHR.status);
}
});