addashboard-cli 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,7 +104,22 @@
104
104
  .chip .chipin{border:none; background:transparent; font-weight:700; color:var(--fg); font-family:inherit; font-size:11.5px; padding:0 1px; border-bottom:1px dashed var(--line); border-radius:0; min-width:0;}
105
105
  .chip .chipin:hover{border-bottom-color:var(--muted);}
106
106
  .chip .chipin:focus{outline:none; border-bottom:1px solid var(--accent); background:var(--accent-soft);}
107
+ /* C5: テキストチップは既定幅が狭く(記事URL=16ch 等)、長いURLの末尾が見えず
108
+ 「文字数制限で入らない」と誤認される。編集中だけ広げる。
109
+ 幅は renderTplChips が style 属性で直に書くのでカスケードでは勝てず !important が必要。
110
+ type 無しの <input>(= txt())だけが対象。number/checkbox/select には効かない。 */
111
+ .chip input.chipin:not([type]):focus{width:44ch !important; max-width:min(44ch, 60vw);}
107
112
  .chip select.chipin{-webkit-appearance:none; appearance:none; cursor:pointer;}
113
+ /* kind:'datetime' = 日付ピッカー + 時刻ピッカーの2分割。
114
+ ネイティブの date/time を使うのでカレンダーと時計はブラウザ標準UIが出る。
115
+ 時刻は空欄可(= 日付のみの指定)にしたいので datetime-local ではなく2つに分ける。 */
116
+ .chip input.chipin[type=date]{width:12ch;}
117
+ .chip input.chipin[type=time]{width:8ch; margin-left:2px;}
118
+ .chip input.chipin[type=time]:disabled{opacity:.35; cursor:not-allowed;}
119
+ .chip input.chipin[type=date]::-webkit-calendar-picker-indicator,
120
+ .chip input.chipin[type=time]::-webkit-calendar-picker-indicator{cursor:pointer; opacity:.55; padding:0;}
121
+ .chip input.chipin[type=date]:hover::-webkit-calendar-picker-indicator,
122
+ .chip input.chipin[type=time]:hover::-webkit-calendar-picker-indicator{opacity:1;}
108
123
  /* 開閉式 戦略メモ */
109
124
  .stratbox{border:1px solid var(--line); border-radius:9px; background:#fff; margin:0 0 12px; overflow:hidden;}
110
125
  .stratbox>summary{list-style:none; cursor:pointer; padding:9px 14px; font-size:12.5px; font-weight:700; color:var(--fg); background:var(--softer); user-select:none; display:flex; align-items:center; gap:8px;}
@@ -242,6 +257,40 @@
242
257
  .tgpick .tgcaret{ flex:0 0 auto; color:var(--muted); font-size:10px;}
243
258
  .tgpick.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line); border-color:var(--accent);}
244
259
  .tgpick.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
260
+ /* ── S14: フリークエンシー (kind:'freq')。モード + 3プルダウンを1行に並べる ── */
261
+ .freqtd{ vertical-align:middle;}
262
+ .freqbox{ display:flex; align-items:center; gap:4px; flex-wrap:nowrap; min-height:26px;
263
+ border:1px solid var(--line); border-radius:6px; padding:3px 6px; background:var(--soft); line-height:1.3;}
264
+ .freqbox.on{ background:#fff;}
265
+ .freqbox:hover{ border-color:var(--accent);}
266
+ .freqbox.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line); border-color:var(--accent);}
267
+ .freqbox.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
268
+ .freqin{ flex:0 0 auto; font-size:12px; padding:1px 4px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--fg);}
269
+ .freqmode{ min-width:70px;}
270
+ .freqnum{ min-width:58px;}
271
+ /* 「既定値」のとき、案件共通設定の中身を薄く出す (読み取り専用) */
272
+ .freqinherit{ flex:1 1 auto; min-width:0; font-size:11px; color:var(--faint); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
273
+ .freqoff{ font-size:12px; color:var(--faint);}
274
+ /* 案件設定チップ内も同じ1行 */
275
+ .chip .freqin{ margin-left:2px;}
276
+ /* ── S15: セルの日時 (kind:'datetime')。日付ピッカー + 時刻ピッカーの2分割 ──
277
+ チップ側 (S12) と同じ考え方。時刻を空欄にできるようにしたいので datetime-local は使わない。
278
+ data-sel は外側の .dtcell に付き、コピペ・範囲選択は従来どおり1本の文字列で往復する。 */
279
+ .dttd{ vertical-align:middle;}
280
+ .dtcell{ display:flex; align-items:center; gap:3px; flex-wrap:nowrap; min-height:26px;
281
+ border:1px solid var(--line); border-radius:6px; padding:2px 4px; background:#fff;}
282
+ .dtcell:hover{ border-color:var(--accent);}
283
+ .dtcell.err{ border-color:var(--danger);}
284
+ .dtcell.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line); border-color:var(--accent);}
285
+ .dtcell.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
286
+ /* 幅はネイティブ表示 (mm/dd/yyyy + カレンダーアイコン / --:-- + 時計アイコン) が
287
+ 切れない実測値。狭いと "09/01/20…" のように末尾が欠けて年が読めなくなる。 */
288
+ .dtin{ flex:0 0 auto; font-size:12px; padding:2px 4px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--fg);}
289
+ .dtin[type=date]{ width:15ch;}
290
+ .dtin[type=time]{ width:11ch;}
291
+ .dtin:disabled{ opacity:.35; cursor:not-allowed;}
292
+ .dtin::-webkit-calendar-picker-indicator{ cursor:pointer; opacity:.55; padding:0;}
293
+ .dtin:hover::-webkit-calendar-picker-indicator{ opacity:1;}
245
294
  body.noselect{ user-select:none; -webkit-user-select:none;}
246
295
  .hinttxt{ font-size:11px; color:var(--faint);}
247
296
  /* ── ref data view ── */
@@ -408,7 +457,8 @@
408
457
  <div class="body">
409
458
  <p class="lead">全広告に共通する固定値は「案件テンプレ」で1箇所定義し全広告へ一括適用する(現行の全行ハードコードを廃止)。</p>
410
459
  <table class="g"><thead><tr><th>項目</th><th>既定値</th><th>備考</th></tr></thead><tbody>
411
- <tr><td>アトリビューション計測期間</td><td><b>1日</b></td><td>媒体既定7日を毎回1日に変更する慣習</td></tr>
460
+ <tr><td>アトリビューション計測期間</td><td><b>1日</b></td><td>媒体既定7日を毎回1日に変更する慣習。<b>入稿APIの対象外</b>なので管理画面側で設定する(プランナーの案件設定チップからは撤去済み)</td></tr>
461
+ <tr><td>フリークエンシー</td><td><b>未設定</b></td><td>ONにすると <code>viewableFrequencyCap</code>(適用レベル / 期間単位 / 上限回数)をキャンペーンに付ける。案件設定チップが新規キャンペーンの既定になり、キャンペーン行で個別に変更できる</td></tr>
412
462
  <tr><td>CTAボタン</td><td><b>もっとみる</b></td><td>画像入稿の実データ既定。動画入稿は「詳細を見る」。<span class="pill amber">要確定</span></td></tr>
413
463
  <tr><td>入札戦略</td><td><b>tCPA 8,500円</b></td><td>API値は <code>MAXIMIZE_CONVERSIONS + targetCpa:8500</code>。もう一択は「最大配信」</td></tr>
414
464
  <tr><td>予算</td><td>新規 12,000 / ASC 50,000〜</td><td>モードで切替</td></tr>
@@ -433,7 +483,8 @@ mediaId = media[ 画像番号 ][ 形式 ] <span class="c">→ 2166762
433
483
  <span class="c">// タイトル/説明文 … 番号で文言ライブラリを引く</span>
434
484
  title = titles[ タイトル番号 ] desc = titles[ 説明文番号 ]
435
485
  <span class="c">// 最終リンク先URL(入稿JSONの finalUrl)</span>
436
- finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
486
+ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
487
+ <span class="c">// 案件設定の「URLパラメータ」を OFF にすると finalUrl = baseURL のみ</span></div>
437
488
  <div class="grid2" style="margin-top:12px">
438
489
  <div class="kv"><h4>キャンペーン名</h4><p class="mono" style="font-size:12px">{空き番号}_{CR名}_{YYYY-MM-DD}</p><p>空き番号は使用済みから未使用を割当(連番でなくてよい・被らないことだけ守る)。</p></div>
439
490
  <div class="kv"><h4>入稿リンク末尾番号</h4><p>ベースURLは案件固定、末尾パラメータ番号のみ可変。既存の使用済み番号から空きを自動割当。</p></div>
@@ -457,10 +508,10 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
457
508
  <details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
458
509
  <div class="body">
459
510
  <table class="g"><thead><tr><th>場所</th><th>入力項目</th><th>入稿JSONへの反映</th></tr></thead><tbody>
460
- <tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b>配信開始日</b> / CTA / 配信 / タイプ / 入札既定(tCPA・目標CPA) / 計測 / 主体者 / 表示URL / 記事URL / 予算(新規・ASC)</td><td>accountId・goal・startDate・入札既定・予算既定・finalUrlベース</td></tr>
461
- <tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 検証変数 / 入稿リンク番号 / 戦略メモ</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
511
+ <tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b>配信開始日時</b> / CTA / 配信 / タイプ / 入札既定(tCPA・目標CPA) / フリークエンシー / 主体者 / 表示URL / 記事URL / 予算(新規・ASC)</td><td>accountId・goal・startDate・入札既定・予算既定・finalUrlベース</td></tr>
512
+ <tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 入稿リンク番号</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
462
513
  <tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / スマートTG / <b>ターゲティング列</b>(デバイス・性別・年齢・地域・曜日時間帯・<b>オーディエンス(含む/除外)</b>・<b>配置(含む/除外)</b>・サーチKW)</td><td>name・status・smartTargeting・devices/ages/genders/days(+startHour/endHour)・<b>audiencesInclude/Exclude・placementsInclude/Exclude(実IDで送信)</b>。<b>未反映は 地域・サーチKW のみ</b></td></tr>
463
- <tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 記事slug / 記事URL(上書き) / 最終リンク先URL / タイトル本文 / 説明文本文 / 画像パス / 広告名 / 手入稿フラグ</td><td>ads[].adName・mediaIds・imagePaths・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
514
+ <tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 最終リンク先URL(自動) / タイトル本文 / 説明文本文 / 画像パス / 広告名 / 手入稿フラグ</td><td>ads[].adName・mediaIds・imagePaths・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
464
515
  </tbody></table>
465
516
  </div></details>
466
517
  <details class="sec" open><summary><span class="num">2</span><span class="stitle">入稿JSON — 構造と入力項目・制約(パラメータ)</span><span class="chev"></span></summary>
@@ -476,7 +527,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
476
527
  <table class="g"><thead><tr><th>項目</th><th>画面上の出どころ</th><th>変換・制約</th></tr></thead><tbody>
477
528
  <tr><td class="mono">accountId</td><td><b>案件設定「アカウントID」</b></td><td>必須。未入力だと書き出し中止</td></tr>
478
529
  <tr><td class="mono">goal</td><td><b>案件設定「目的」</b></td><td><code>CONVERSION</code> / <code>CLICK</code> / <code>BRAND_AWARENESS</code> / <code>VIDEO_VIEW</code> / <code>VIEWABLE_IMPRESSION</code></td></tr>
479
- <tr><td class="mono">startDate</td><td><b>案件設定「配信開始日」</b></td><td>YYYY-MM-DD。空欄=書き出し日</td></tr>
530
+ <tr><td class="mono">startDate</td><td><b>案件設定「配信開始日時」</b>(カレンダー+時計から選択)</td><td>YYYY-MM-DD。空欄=書き出し日。<b>時刻を入れても入稿ボディには日付部分だけが載る</b>(Yahoo Ads API の startDate は日付のみのISO。時刻は計画上のメモとして保存JSONに残る)</td></tr>
480
531
  <tr><td class="mono">status</td><td>キャンペーン行/広告グループ行の「配信設定」</td><td><code>オン→ACTIVE</code> / <code>オフ→PAUSED</code>(既定オフ=誤配信防止)</td></tr>
481
532
  <tr><td class="mono">budget</td><td>キャンペーン行「1日の予算」</td><td>未入力時はモード別既定(新規12,000/ASC50,000)</td></tr>
482
533
  <tr><td class="mono">biddingStrategy</td><td>キャンペーン行「入札戦略」+ 目標CV単価</td><td><code>tCPA → MAXIMIZE_CONVERSIONS + targetCpa</code>(tCPA値はキャンペーン行→案件既定8,500の順)/ <code>最大配信 → MAXIMIZE_CONVERSIONS</code>(目標なし <span class="pill amber">要実機確認</span>)</td></tr>
@@ -489,7 +540,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
489
540
  <tr><td class="mono">ads[].imagePaths</td><td>広告行「画像パス」</td><td><b>参照表に無い画像番号(未登録)のときだけ</b>送る。CLI が入稿前にアップロードして mediaId を解決する。画像番号が解決済みの行に入れても<b>送らない</b>(CLI は追加アップロードするが広告には既存 mediaId が使われるため。差し替えたいときは画像番号を空にする)</td></tr>
490
541
  <tr><td class="mono">ads[].headlines</td><td><span class="pill purple">可変</span></td><td>タイトル番号 → 文言。<b>35字以内</b>(API上のアセット制約はタイトル≤20字・説明文≤90字)</td></tr>
491
542
  <tr><td class="mono">ads[].descriptions</td><td><span class="pill purple">可変</span></td><td>説明文番号 → 文言</td></tr>
492
- <tr><td class="mono">ads[].finalUrl</td><td>広告行「記事slug / 記事URL(上書き) / 最終リンク先URL」</td><td>生成式 <code>(記事URL(上書き) または baseURL + slug) + "?ad=" + paramCode + "&creative=..."</code>(実データ準拠)。案件設定の <b>URLパラメータ を OFF</b> にするとパラメータを付けない。記事URLに既にクエリがある場合は <code>&amp;</code> で連結する</td></tr>
543
+ <tr><td class="mono">ads[].finalUrl</td><td>自動生成(広告行の入力項目ではない)</td><td>YDA は <code>baseURL + paramCode + "?ad=" + paramCode + "&creative=" + paramCode + "-" + 広告名</code> / Meta は <code>baseURL + "?ad=" + paramCode</code>。案件設定の <b>URLパラメータ を OFF</b> にすると <code>baseURL</code> のみ(paramCode 列は非表示になり未入力でもエラーにならない)。記事URLに既にクエリがある場合は <code>&amp;</code> で連結する</td></tr>
493
544
  </tbody></table>
494
545
  <div class="callout">プランナーの保存JSON(バージョン保存)はこれとは別物で、<code>schemaVersion:2</code> の全state(meta / project / campaigns / ui / refs / submit)を保持する。<b>meta</b> = バージョンID・作成/更新日時・作成者・対象媒体・名称・メモ。旧v1 JSONも読込時に自動マイグレーションされる。</div>
495
546
  </div></details>
@@ -629,7 +680,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
629
680
  <div class="toolbar2">
630
681
  <button class="act ghost" id="btnDupSel" title="選択した行を複製">⧉ 選択を複製</button>
631
682
  <button class="act ghost" id="btnSeqParam" style="display:none" title="選択した列(paramCode / 画像番号 / タイトル番号 / 説明文番号)を先頭セル基点に連番採番">🔢 連番</button>
632
- <span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(スプシから一括)</span>
683
+ <span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(1セルのコピーは選択範囲全体へ充填)/範囲選択のままプルダウン・入力欄を確定すると同じ列へ一括、ターゲティング列はサイドバーで一括ON/OFF</span>
633
684
  <div class="spacer" style="flex:1"></div>
634
685
  <span class="filt">表示 <select id="sheetFilter">
635
686
  <option value="all">すべて</option>
@@ -732,6 +783,21 @@ const uid = p => p + '_' + (Date.now().toString(36)) + (_uc++).toString(36);
732
783
  const esc = s => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
733
784
  const glen = s => [...String(s==null?'':s)].length;
734
785
  const today = () => { const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
786
+ /* S12: 日時文字列 "YYYY-MM-DD[ HH:mm[:ss]]" ⇄ {date, time} の相互変換。
787
+ kind:'datetime' チップ (日付ピッカー + 時刻ピッカー) と state の1本文字列を橋渡しする。
788
+ ・秒は input[type=time] の既定表示に合わせて分までに丸める (秒付きの旧データも壊さず読める)
789
+ ・書式が読めない値は date にも time にも入れない (= 空のピッカー) が、state は触らない */
790
+ const splitDateTime = v => {
791
+ const m=String(v==null?'':v).trim().match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/);
792
+ return m ? {date:m[1], time:m[2]||''} : {date:'', time:''};
793
+ };
794
+ /* 日付が空なら時刻だけ残しても意味がないので全体を空にする (= 未指定) */
795
+ const joinDateTime = (date, time) => {
796
+ const d=String(date||'').trim(); if(!d) return '';
797
+ const t=String(time||'').trim(); return t ? d+' '+t : d;
798
+ };
799
+ /* 入稿ボディ用: 日時文字列から日付部分だけを取り出す (YDA の startDate は日付のみの ISO) */
800
+ const dateOnly = v => splitDateTime(v).date;
735
801
  /* 引数を左から順に見て「trim して空文字でない最初の値」を返す。優先順位を1行で書くための道具。 */
736
802
  const firstNonEmpty = (...vs) => { for(const v of vs){ const s=String(v==null?'':v).trim(); if(s!=='') return s; } return ''; };
737
803
  /* URLのクエリ値に埋める文字列のエスケープ (媒体非依存のURLユーティリティ)。
@@ -1059,10 +1125,19 @@ PLATFORMS.yda = {
1059
1125
  {key:'goal', label:'目的', kind:'selectv', fallback:'CONVERSION',
1060
1126
  options:[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},
1061
1127
  {v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}]},
1062
- {key:'startDate', label:'配信開始日', kind:'text', width:'10ch', fallback:''},
1128
+ /* S12: カレンダー + 時刻ピッカー。Yahoo Ads API の startDate は日付のみの ISO (YYYY-MM-DD) なので、
1129
+ 時刻は運用メモとして state / 保存JSON にだけ残り、入稿ボディへは日付部分だけを送る (buildPlan.campaign)。 */
1130
+ {key:'startDate', label:'配信開始日時', kind:'datetime', fallback:'',
1131
+ title:'配信開始日時(JST)。日付はカレンダー、時刻は時計から選べます。\n'
1132
+ +'空欄なら書き出し日が使われます。Yahoo Ads API へは日付(YYYY-MM-DD)のみ送られ、時刻は計画上のメモ扱いです。'},
1063
1133
  {key:'cta', label:'CTA', kind:'text', width:'7ch'},
1064
1134
  {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
1065
- {key:'attributionDays', label:'計測', kind:'number', width:'3ch', suffix:'日'},
1135
+ /* S14: フリークエンシー(viewableFrequencyCap)の案件既定。
1136
+ チェックONのときだけ 適用/期間/回数 を出し、新規キャンペーン作成時の初期値になる。
1137
+ ※旧「計測」(attributionDays) チップは撤去。入稿計画JSONにも入稿ボディにも一切載らない
1138
+ デッドフィールドだったため(アトリビューション計測期間はYDA APIの入稿対象外)。 */
1139
+ {key:'freq', label:'フリークエンシー', kind:'freq',
1140
+ title:'ONにすると、以後の新規キャンペーンにこの設定が初期値として入ります(キャンペーン行で個別に変更できます)'},
1066
1141
  {key:'displayUrlFormat',label:'表示URL', kind:'text', width:'13ch'},
1067
1142
  {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'},
1068
1143
  /* C3: 案件単位の URLパラメータ ON/OFF。**defaults.project には入れない**
@@ -1100,6 +1175,9 @@ PLATFORMS.yda = {
1100
1175
  未知ラベルは MORE_DETAIL(=「詳しくはこちら」) にフォールバック(実データ既定)。 */
1101
1176
  cta:{'もっとみる':'MORE_DETAIL','詳しくはこちら':'MORE_DETAIL','詳細を見る':'MORE_DETAIL','今すぐ確認':'CONFIRM_NOW','今すぐ申し込む':'APPLY_NOW','購入する':'PURCHASE','今すぐ購入':'PURCHASE_NOW','応募する':'APPLY_HERE','無料体験':'EXPERIENCE','資料請求':'REQUEST_INFO','ダウンロード':'DOWNLOAD','インストール':'INSTALL','予約する':'RESERVE_HERE','今すぐ予約':'RESERVE_NOW','登録する':'REGISTER','今すぐ登録':'REGISTER_NOW','お問い合わせ':'CONTACT_US'},
1102
1177
  ctaFallback:'MORE_DETAIL',
1178
+ /* S14: viewableFrequencyCap の enum (正: frontend/src/pages/submission/types.ts の FREQ_*_OPTIONS) */
1179
+ freqLevel:{'広告グループ':'AD_GROUP','キャンペーン':'CAMPAIGN','広告':'AD'},
1180
+ freqUnit:{'週':'WEEK','日':'DAY','月':'MONTH'},
1103
1181
  statusOn:'ACTIVE', statusOff:'PAUSED'
1104
1182
  },
1105
1183
 
@@ -1135,12 +1213,16 @@ PLATFORMS.yda = {
1135
1213
  project:{
1136
1214
  cta:'もっとみる', adType:'レスポンシブ', mainMediaFormat:'画像',
1137
1215
  deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
1138
- attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
1216
+ biddingStrategyDefault:'tCPA', tcpaDefault:8500,
1217
+ /* S14: フリークエンシーの案件既定。OFF が既定(明示的にONにしたときだけ入稿ボディに載る) */
1218
+ freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3,
1139
1219
  budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
1140
1220
  },
1141
- /* C6: adName は空 = 自動生成 (naming.adName)。C4/C1: articleUrl / imagePath は空 = 従来挙動 */
1142
- ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', articleUrl:'', imagePath:'', adName:'', desc2:''},
1143
- campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, variable:'', urlNumber:''}
1221
+ /* C6: adName は空 = 自動生成 (naming.adName)。C1: imagePath は空 = 従来挙動 */
1222
+ ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', imagePath:'', adName:'', desc2:''},
1223
+ /* S14: freq* は「案件設定チップの既定を新規CP作成時にコピーする」ので factories.newCampaign 側で入れる */
1224
+ campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:'',
1225
+ freqMode:'default', freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3}
1144
1226
  },
1145
1227
 
1146
1228
  /* ---- factories: 行の生成 (リテラル生成をコアから撤去) ---- */
@@ -1152,11 +1234,15 @@ PLATFORMS.yda = {
1152
1234
  newCampaign(seed){ const p=st.project, D=PLATFORMS.yda.defaults, F=PLATFORMS.yda.factories;
1153
1235
  return Object.assign({uid:uid('c')}, D.campaign, {
1154
1236
  budget:D.newCampaignBudget, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault,
1237
+ /* S14: 既定は 'default' (案件共通設定に追従)。個別指定したい行だけ「オン」にする。
1238
+ 3値は「オンへ切り替えた瞬間の初期値」として案件既定を持たせておく。 */
1239
+ freqMode:'default', freqOn:false,
1240
+ freqLevel:p.freqLevel||'AD_GROUP', freqUnit:p.freqUnit||'WEEK', freqCount:freqNum(p.freqCount)||3,
1155
1241
  adGroups:[F.newAdGroup(null,0)]
1156
1242
  }, seed||{}); },
1157
1243
  /* E-3/F-2: 既存キャンペーン呼び出し (campaignIdを持つ広告追加専用の器)。isExisting:true = 基本パラメータ編集不可 */
1158
1244
  existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
1159
- isExisting:true, variable:'', urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; },
1245
+ isExisting:true, urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; },
1160
1246
  /* F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める */
1161
1247
  existingAdGroup(agId, meta){ const F=PLATFORMS.yda.factories; const m=meta||{};
1162
1248
  return {uid:uid('g'), name:m.name||('AG '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
@@ -1166,52 +1252,27 @@ PLATFORMS.yda = {
1166
1252
  /* ---- naming: クリエイティブ名 / 広告名 / 最終URL / キャンペーン名 ---- */
1167
1253
  naming:{
1168
1254
  crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
1169
- adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
1170
- /* C4: 記事URLの base は ad.articleUrl (完全URL上書き) → 無ければ baseArticleUrl + articleSlug。
1171
- C3: project.urlParams===false なら ?ad= / &creative= を付けない (欠落 undefined は ON = 従来挙動)。
1255
+ /* C8: URLパラメータ OFF の案件は paramCode を入力しない (列も隠す) ので、
1256
+ 広告名の末尾 "-{paramCode}" も付けない (末尾が "-" だけ残るのを避ける)。 */
1257
+ adName(ad, group, crName, project){
1258
+ const head = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'');
1259
+ if(project && project.urlParams===false) return head;
1260
+ return head + '-' + (group.paramCode||'');
1261
+ },
1262
+ /* C8: 最終URL = 案件設定の記事URL(base) + paramCode + ?ad=&creative=。
1263
+ 広告ごとのURL指定 (記事slug / 記事URL(上書き)) は廃止し、URLの可変部は paramCode だけ。
1264
+ ・project.urlParams===false なら paramCode も ?ad= / &creative= も付けず base のみ
1265
+ (欠落 undefined は ON = 従来挙動)
1172
1266
  ⚠ base が既にクエリを持つ (utm_source 等) 場合は '?' を2つ並べない = 繋ぎ文字を選ぶ。
1173
1267
  並べると parse 時に ad= が消えて utm_source の値に食われる。
1174
1268
  ⚠ paramCode / adName は自由入力なので urlq() でURL構造を壊す文字だけ落とす。 */
1175
1269
  finalUrl(ad, group, adName, project){
1176
- const base = String(ad.articleUrl||'').trim() || ((project.baseArticleUrl||'') + (ad.articleSlug||''));
1177
- if(project.urlParams===false) return base;
1178
- const sep = base.indexOf('?')>=0 ? '&' : '?';
1270
+ if(project.urlParams===false) return (project.baseArticleUrl||'');
1179
1271
  const code = urlq(group.paramCode||'');
1272
+ const base = (project.baseArticleUrl||'') + code;
1273
+ const sep = base.indexOf('?')>=0 ? '&' : '?';
1180
1274
  return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
1181
1275
  },
1182
- /* C4: 記事URL入力の正規化。「完全URLを slug 欄に入れると壊れる」の直接修正。
1183
- articleSlug / articleUrl / finalUrl の **3列すべての set フック**がこれを通る。
1184
- ① ?ad= / &creative= を除去
1185
- ② baseArticleUrl 配下の完全URLは slug に吸収
1186
- ③ 配下外の完全URLは articleUrl へ
1187
- ④ それ以外 (相対断片) は slug
1188
- ⚠ 空入力は呼び出し元 (applyArticleInput) が扱う。ここで両フィールドを空で返すのは
1189
- 「明示的に両方を消す」意味なので、列ごとの空クリアには使わない。 */
1190
- normalizeArticleInput(v, project){
1191
- let s=String(v==null?'':v).trim();
1192
- if(s==='') return {articleSlug:'', articleUrl:''};
1193
- const stripped=s.replace(/[?&]ad=[^&#]*/g,'').replace(/[?&]creative=[^&#]*/g,'');
1194
- const removed = stripped!==s;
1195
- s=stripped.replace(/\?&/,'?').replace(/[?&]+$/,'');
1196
- /* 先頭の "?ad=" を消すと '?' ごと失われ、後続クエリ (utm=1 等) が '&' のまま
1197
- パスに連結されてしまう (…/x&utm=1)。除去が起きたときだけ先頭の & を ? に昇格する。 */
1198
- if(removed && s.indexOf('?')<0) s=s.replace(/&/,'?');
1199
- const base=String((project&&project.baseArticleUrl)||'');
1200
- if(base && s.indexOf(base)===0) return {articleSlug:s.slice(base.length), articleUrl:''};
1201
- if(/^https?:\/\//i.test(s)) return {articleSlug:'', articleUrl:s};
1202
- return {articleSlug:s, articleUrl:''};
1203
- },
1204
- /* C4: 記事URL3列 (記事slug / 記事URL(上書き) / 最終リンク先URL) の set フック共通処理。
1205
- **空入力で他の列の値を道連れにしない**のが要点:
1206
- ・空 → 自分の列が持つフィールドだけを消す (finalUrl 列は自分のフィールドを持たないので no-op)
1207
- ・非空 → normalizeArticleInput の振り分け結果 (slug / url の組) をそのまま反映
1208
- pasteGrid は範囲内の空セルも左→右に setVar するため、ここで分けないと
1209
- 「slug を書いた直後に隣の空セルが slug を消す」というサイレントなデータ消失になる。 */
1210
- applyArticleInput(row, val, project, ownField){
1211
- const s=String(val==null?'':val).trim();
1212
- if(s===''){ if(ownField) row[ownField]=''; return; }
1213
- Object.assign(row, PLATFORMS.yda.naming.normalizeArticleInput(s, project));
1214
- },
1215
1276
  /* キャンペーン名 自動採番: {空き番号2桁}_{CR名}_{今日} */
1216
1277
  campaignName(urlNumber, firstAd, P){
1217
1278
  const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
@@ -1244,7 +1305,7 @@ PLATFORMS.yda = {
1244
1305
  const crName=P.naming.crName(ad, P);
1245
1306
  /* C6: 広告名は自由入力。空 = 自動生成 (naming.adName) にフォールバックする。
1246
1307
  ⚠ finalUrl の creative= は adName を使うので、手入力するとURLも変わる (仕様) */
1247
- const autoAdName=P.naming.adName(ad, group, crName);
1308
+ const autoAdName=P.naming.adName(ad, group, crName, p);
1248
1309
  const adName=String(ad.adName||'').trim() || autoAdName;
1249
1310
  const me = refs.media[String(ad.imageNo)];
1250
1311
  const mediaId = me ? me[fmt.mediaKey] : undefined;
@@ -1268,7 +1329,8 @@ PLATFORMS.yda = {
1268
1329
  if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
1269
1330
  else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
1270
1331
  // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
1271
- if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
1332
+ /* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
1333
+ if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
1272
1334
  if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
1273
1335
  // C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
1274
1336
  // ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
@@ -1287,6 +1349,17 @@ PLATFORMS.yda = {
1287
1349
  },
1288
1350
 
1289
1351
  /* ---- S8: 入稿計画JSON (SubmitPlanFile) の3階層ボディ生成 ---- */
1352
+ /* S13: 案件設定の書き出し前ガード。
1353
+ startDate は空欄なら書き出し日にフォールバックする仕様だが、「読めない書式」まで空と同じ扱いにすると
1354
+ 入力した日付が黙って当日にすり替わる。GUI (日付/時刻ピッカー) からは不正値は入らないので、
1355
+ ここに来るのは旧保存JSONの読み込みか手編集だけ。その場合は書き出しを止めて気づけるようにする。 */
1356
+ validateProject(p){
1357
+ const s=String((p&&p.startDate)||'').trim();
1358
+ if(s && !dateOnly(s)) return [{code:EC.START_TIME_INVALID,
1359
+ msg:'案件設定「配信開始日時」の書式が不正です(例 2026-08-01 / 2026-08-01 10:30): '+s}];
1360
+ return [];
1361
+ },
1362
+
1290
1363
  buildPlan:{
1291
1364
  campaign(c, ctx){
1292
1365
  const P=PLATFORMS.yda, p=ctx.project;
@@ -1296,11 +1369,23 @@ PLATFORMS.yda = {
1296
1369
  name:c.name,
1297
1370
  goal:p.goal||'CONVERSION',
1298
1371
  status:P.mappers.status(p.deliveryStatus, P), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
1299
- startDate:p.startDate||today(),
1372
+ /* S12: 案件設定は時刻付きで持てるが、Yahoo Ads API の startDate は日付のみの ISO。日付部分だけを送る */
1373
+ startDate:dateOnly(p.startDate)||today(),
1300
1374
  budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
1301
1375
  biddingStrategy,
1302
1376
  adGroups:[]
1303
1377
  };
1378
+ /* S14: フリークエンシー (viewableFrequencyCap)。OFF / 回数未入力なら **キー自体を出さない**
1379
+ (backend の campaignSchema では全て optional。付けない = 媒体既定のまま) */
1380
+ /* S14: 'default' は案件共通設定を引き、'off' は付けない。小数はここまでに落ちている
1381
+ (backend の vImps は int 制約が無く dry-run も素通りするため GUI 側で担保する) */
1382
+ const fe=freqEffective(c);
1383
+ if(fe){
1384
+ cp.useFrequencyCap=true;
1385
+ cp.frequencyLevel=fe.level;
1386
+ cp.frequencyTimeUnit=fe.unit;
1387
+ cp.vImps=fe.count;
1388
+ }
1304
1389
  if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
1305
1390
  else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
1306
1391
  const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
@@ -1584,6 +1669,18 @@ function cpDefaults(project, mode){ const p=project||st.project; return {budget:
1584
1669
  /* S7: 新規/既存キャンペーンの生成も pf().factories へ委譲 */
1585
1670
  function makeNewCampaign(seed){ return pf().factories.newCampaign(seed); }
1586
1671
  function makeExistingCampaign(cpId, name){ return pf().factories.existingCampaign(cpId, name); }
1672
+ /* ---- C8: 記事slug / 記事URL(上書き) 廃止に伴う片方向マイグレーション ----
1673
+ 旧プランが持つ広告単位のURL指定は捨てる。新モデルの最終URLは
1674
+ 「案件設定の記事URL + paramCode」だけで決まり、広告ごとの可変部は無い。
1675
+ (ここで base+slug の完全URLに固定すると、後から案件設定の記事URLを変えても
1676
+ その行だけ旧baseのまま残る、というサイレントな不整合になる) */
1677
+ function migrateArticleSlug(campaigns){
1678
+ (campaigns||[]).forEach(c=>(c.adGroups||[]).forEach(g=>(g.ads||[]).forEach(a=>{
1679
+ if(!a) return;
1680
+ delete a.articleSlug; delete a.articleUrl;
1681
+ })));
1682
+ return campaigns;
1683
+ }
1587
1684
  function initialState(){
1588
1685
  const refs=buildRefs();
1589
1686
  // 共通設定(project=案件名/主体者/アカウント等)はアカウントJSON由来。サンプルプランには campaigns と accountId のみ。
@@ -1591,13 +1688,19 @@ function initialState(){
1591
1688
  let campaigns=[];
1592
1689
  planMismatchMsg='';
1593
1690
  if(samplePlan && Array.isArray(samplePlan.campaigns)){
1594
- // アカウントIDとサンプルプランのaccountIdが異なる場合はエラー(不一致プランは読み込まない)
1595
- if(samplePlan.accountId && String(samplePlan.accountId)!==String(project.accountId)){
1596
- planMismatchMsg='⚠ サンプルプランのアカウントID('+samplePlan.accountId+')が現在のアカウント('+project.accountId+(project.accountName?' '+project.accountName:'')+')と一致しません。プランは読み込みません。正しいアカウントに切り替えるか、サンプルプランの accountId を合わせてください。';
1691
+ /* アカウントIDとサンプルプランのaccountIdが異なる場合はエラー(不一致プランは読み込まない)
1692
+ S13: accountId 欠落も「不一致」として扱う fail-safe。旧実装は truthy 判定で短絡していたため、
1693
+ accountId の無いプラン (= 誰のものか分からないプラン) がどのアカウントでも読み込まれていた。 */
1694
+ const planAcc=String(samplePlan.accountId||''), curAcc=String(project.accountId||'');
1695
+ if(planAcc!==curAcc){
1696
+ planMismatchMsg = planAcc
1697
+ ? '⚠ サンプルプランのアカウントID('+planAcc+')が現在のアカウント('+curAcc+(project.accountName?' '+project.accountName:'')+')と一致しません。プランは読み込みません。正しいアカウントに切り替えるか、サンプルプランの accountId を合わせてください。'
1698
+ : '⚠ サンプルプランに accountId がありません。どのアカウントのプランか判別できないため読み込みません。サンプルプランに accountId を追加してください。';
1597
1699
  if(typeof toast==='function') toast(planMismatchMsg);
1598
1700
  } else {
1599
1701
  campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
1600
1702
  campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
1703
+ migrateArticleSlug(campaigns); // C8: 旧 articleSlug / articleUrl を除去
1601
1704
  }
1602
1705
  }
1603
1706
  return {schemaVersion:SCHEMA_VERSION, meta:defaultMeta(), project, refs, campaigns, ui:{expanded:{}}};
@@ -1702,13 +1805,42 @@ function renderTplChips(){
1702
1805
  const ph = typeof f.placeholder==='function' ? f.placeholder(p) : f.placeholder;
1703
1806
  return (ph? ' placeholder="'+esc(ph)+'"' : '') + (f.title? ' title="'+esc(f.title)+'"' : '');
1704
1807
  };
1705
- const txt=(f,v,w,d)=>'<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'"'+hint(d)+'>';
1808
+ /* C5: 記述子に title が無いテキストチップは、値そのものを title にする。
1809
+ 幅が狭くて末尾が切れていてもホバーで全文を確認できる(記事URL・表示URL 等)。
1810
+ 記述子側の title が有るときは hint(d) が出すので二重に付けない。 */
1811
+ const txt=(f,v,w,d)=>{
1812
+ const full = (d && d.title) ? '' : (String(v||'') ? ' title="'+esc(String(v))+'"' : '');
1813
+ return '<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'"'+hint(d)+full+'>';
1814
+ };
1706
1815
  const num=(f,v,w,d)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'"'+hint(d)+'>';
1707
1816
  const sel=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option'+(o===v?' selected':'')+'>'+esc(o)+'</option>').join('')+'</select>';
1708
1817
  const selv=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option value="'+esc(o.v)+'"'+(o.v===v?' selected':'')+'>'+esc(o.t)+'</option>').join('')+'</select>';
1709
1818
  /* C1: kind:'toggle' = チェックボックス。値は真偽で持ち、**未定義は fallback**(欠落=従来挙動)。
1710
1819
  `p[key]||fallback` では false が fallback(true) に化けるので、toggle だけは別扱いにする。 */
1711
1820
  const tgl=(f,v,d)=>'<input class="chipin" type="checkbox" data-proj="'+f+'"'+(v?' checked':'')+hint(d)+'>';
1821
+ /* S12: kind:'datetime' = <input type="date"> + <input type="time"> の2分割。
1822
+ state には従来どおり 1本の文字列で持つ ("YYYY-MM-DD HH:mm"、時刻が空なら "YYYY-MM-DD")。
1823
+ data-proj は **日付側だけ** に付ける(時刻側は data-projtime)。
1824
+ - 既存の [data-proj="startDate"] を掴むコード/テストがそのまま日付入力に当たる
1825
+ - 汎用の change ハンドラが日付だけで上書きしてしまう事故も防げる(data-dt で先に分岐する)
1826
+ ネイティブ input は不正文字列を弾いて空を返すので、書式異常は state に入らない。 */
1827
+ const dt=(f,v,d)=>{
1828
+ const p=splitDateTime(v);
1829
+ /* S13: 日付が空のうちは時刻を disabled にする。state は1本の文字列で「時刻のみ」を表現できず、
1830
+ joinDateTime が空へ潰して再描画が入力を消すため、打てるのに消える見た目になっていた。 */
1831
+ const noDate=!p.date;
1832
+ return '<input class="chipin" type="date" data-proj="'+f+'" data-dt="'+f+'" value="'+esc(p.date)+'"'+hint(d)+'>'
1833
+ + '<input class="chipin" type="time" data-projtime="'+f+'" data-dt="'+f+'" value="'+esc(p.time)+'"'
1834
+ + (noDate?' disabled':'')
1835
+ + ' title="'+(noDate?'先に日付を選んでください(時刻だけの指定はできません)':'時刻(任意)。空欄なら日付のみの指定になります')+'">';
1836
+ };
1837
+ /* S14: kind:'freq' = チェックでONにしたときだけ 適用/期間/回数 を出す案件既定。
1838
+ キャンペーン行と同じ freqSectionsHtml を使い、data-proj で汎用チップハンドラに乗せる。 */
1839
+ const freq=(d)=>{
1840
+ const on=!!p.freqOn;
1841
+ return '<input class="chipin" type="checkbox" data-proj="freqOn"'+(on?' checked':'')+hint(d)+'>'
1842
+ + (on? freqSectionsHtml(p, f=>'data-proj="'+f+'"') : '<span class="freqoff">未設定</span>');
1843
+ };
1712
1844
  const chip=(label,ctrl)=>'<span class="chip">'+esc(label)+' '+ctrl+'</span>';
1713
1845
  // E-1: 案件名 / アカウントID / 主体者 / タイプ チップは撤去。
1714
1846
  // - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
@@ -1719,7 +1851,9 @@ function renderTplChips(){
1719
1851
  const v = f.kind==='toggle'
1720
1852
  ? (p[f.key]===undefined ? (f.fallback!==undefined?f.fallback:false) : !!p[f.key])
1721
1853
  : ((f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key]);
1722
- const ctrl = f.kind==='toggle' ? tgl(f.key, v, f)
1854
+ const ctrl = f.kind==='freq' ? freq(f)
1855
+ : f.kind==='toggle' ? tgl(f.key, v, f)
1856
+ : f.kind==='datetime' ? dt(f.key, v, f)
1723
1857
  : f.kind==='selectv' ? selv(f.key, v, f.options)
1724
1858
  : f.kind==='select' ? sel(f.key, v, f.options)
1725
1859
  : f.kind==='number' ? num(f.key, v, f.width, f)
@@ -1822,6 +1956,23 @@ function renderCell(col, item, ri, ci, ctx){
1822
1956
  return '<td><span class="robadge'+(col.kind==='mono'?' mono':'')+'"'+(col.roTitle?(' title="'+esc(col.roTitle)+'"'):'')+'>'+esc(val)+'</span></td>';
1823
1957
  }
1824
1958
 
1959
+ /* S14: kind:'freq' = セル内に「大きめの入力ボックス」を直接置く。
1960
+ チェックOFF は 1行 (未設定) / ON のときだけ 適用・期間・回数 の3セクションを展開する。
1961
+ data-sel は外側のボックスに付ける (コピー&ペースト・範囲選択は col.get/col.set 経由で往復する)。
1962
+ 内側のコントロールは data-cu + data-freqf を持ち、専用の change ハンドラが拾う。 */
1963
+ if(col.kind==='freq'){
1964
+ const m=freqMode(row);
1965
+ const at=f=>'data-'+ui.uAttr+'="'+row.uid+'" data-freqf="'+f+'"';
1966
+ const mode='<select class="freqin freqmode" '+at('freqMode')+'>'
1967
+ + FREQ_MODES.map(o=>'<option value="'+o.v+'"'+(o.v===m?' selected':'')+'>'+esc(o.t)+'</option>').join('')
1968
+ + '</select>';
1969
+ const rest = m==='on' ? freqSectionsHtml(row, at)
1970
+ : m==='default' ? '<span class="freqinherit" title="案件共通設定に追従します(案件設定チップで変更)">'+esc(freqInheritText())+'</span>'
1971
+ : '';
1972
+ return '<td class="freqtd"><div class="freqbox'+(m==='on'?' on':'')+'" '+sel+' title="'+esc(freqCellText(row)||'オフ(フリークエンシーを付けない)')+'">'
1973
+ + mode + rest + '</div></td>';
1974
+ }
1975
+
1825
1976
  if(col.kind==='select'){
1826
1977
  /* '' は「(既定)」。emptyNoSelected は「(既定)に selected を付けない」現行DOMの踏襲 */
1827
1978
  const opts=col.options.map(o=> o===''
@@ -1849,6 +2000,29 @@ function renderCell(col, item, ri, ci, ctx){
1849
2000
  return '<td class="tgtd"><div class="tgpick'+(shown?'':' tgempty')+'" '+sel+' title="'+esc(shown||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
1850
2001
  }
1851
2002
 
2003
+ /* S15: kind:'datetime' = セル内を <input type=date> + <input type=time> の2分割にする。
2004
+ state は従来どおり1本の文字列 ("YYYY-MM-DD HH:mm" / 時刻が空なら "YYYY-MM-DD") なので、
2005
+ col.get/col.set は不要でコピペ・一括入力・連番は既存経路のまま動く。
2006
+ ・data-sel は外側の .dtcell に付ける (範囲選択・貼付の対象は従来どおりセル単位)
2007
+ ・内側は data-<uAttr> + data-dtf(列名) + data-dtpart(date|time) を持ち、専用 change ハンドラが拾う
2008
+ ・focusRestoreG が再描画後にフォーカスを戻せるよう data-<fAttr> も一意に振る (時刻側は末尾 '#t')
2009
+ ・日付が空のうちは時刻を disabled にする (joinDateTime が時刻だけの値を空へ潰すため。チップと同じ) */
2010
+ if(col.kind==='datetime'){
2011
+ const p=splitDateTime(val), noDate=!p.date;
2012
+ const ph=col.placeholder?col.placeholder(ctx,row):'';
2013
+ const bad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
2014
+ const tip=(col.title||'')+(ph?('\n空欄なら「'+ph+'」が使われます'):'');
2015
+ const inp=(type,v,part,extra)=>'<input class="dtin" type="'+type+'" '
2016
+ +'data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+(part==='time'?'#t':'')+'" '
2017
+ +'data-dtf="'+col.field+'" data-dtpart="'+part+'" value="'+esc(v)+'"'+(extra||'')+'>';
2018
+ return '<td class="dttd"><div class="dtcell'+(bad?' err':'')+'" '+sel
2019
+ +(tip?(' title="'+esc(tip)+'"'):'')+'>'
2020
+ + inp('date', p.date, 'date')
2021
+ + inp('time', p.time, 'time', (noDate?' disabled':'')
2022
+ +' title="'+(noDate?'先に日付を選んでください(時刻だけの指定はできません)':'時刻(任意)。空欄なら日付のみの指定になります')+'"')
2023
+ + '</div></td>';
2024
+ }
2025
+
1852
2026
  /* text / mono / number */
1853
2027
  const rowBad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
1854
2028
  const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'')+(rowBad?' err':'');
@@ -1880,7 +2054,6 @@ function renderTree(){
1880
2054
  html+='<span class="metae"><span class="ratio">'+esc(ratio)+'</span>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c));
1881
2055
  // 予算/入札は編集可(新規扱い)のCPのみ表示 (既存呼び出しCPは広告追加専用で予算等は無視)
1882
2056
  if(cpEd) html+=pf().tree.campaignBadges(c, pf(), st.project); // S7: 予算/入札の見せ方は媒体スキーマ
1883
- if(c.variable) html+='<span class="pill purple">変数:'+esc(c.variable)+'</span>';
1884
2057
  if(cerr) html+='<span class="pill danger">要修正</span>';
1885
2058
  html+='</span>';
1886
2059
  html+='<span class="nodeact">'+(cpEd?'<button class="iconbtn" data-act="cp-edit" data-u="'+c.uid+'" title="設定">⚙</button>':'')+'<button class="iconbtn" data-act="cp-dup" data-u="'+c.uid+'" title="複製/量産">⧉</button><button class="iconbtn danger" data-act="cp-del" data-u="'+c.uid+'" title="削除">✕</button></span>';
@@ -2035,6 +2208,100 @@ function refDel(tableKey,no){
2035
2208
  ・derive : kind:'derived' のセル内容 (resolveAd 由来の読取専用セル)
2036
2209
  ・tgKey : pf().targeting のキー
2037
2210
  ・sequenceable: 連番採番の対象列 / notSubmitted: 入稿JSONに載らない列 (書き出し時の警告対象) */
2211
+ /* ---- S14: フリークエンシー (viewableFrequencyCap) の共通部品 ----
2212
+ キャンペーン行の列 (kind:'freq') と 案件設定チップ (kind:'freq') の両方が使う。
2213
+ 値の持ち方は「4フィールドを行/案件にフラットに持つ」+「列としては1本の文字列に畳む」。
2214
+ 畳んだ文字列を get/set フックに載せることで、コピー&ペースト・一括入力が他の列と同じ経路で動く。 */
2215
+ const FREQ_FIELDS=['freqOn','freqLevel','freqUnit','freqCount'];
2216
+ /* enum値 → 日本語ラベル / 日本語ラベル (または enum値そのもの) → enum値 */
2217
+ function freqEnumLabel(kind, v){ const m=(pf().enums||{})[kind]||{}; const k=Object.keys(m).find(x=>m[x]===v); return k||''; }
2218
+ function freqEnumValue(kind, label, fb){
2219
+ const m=(pf().enums||{})[kind]||{}, s=String(label||'').trim();
2220
+ if(m[s]) return m[s];
2221
+ if(Object.keys(m).some(x=>m[x]===s)) return s; // "AD_GROUP" のような enum 値の直接指定も許す
2222
+ return fb;
2223
+ }
2224
+ function freqOptions(kind, cur){
2225
+ const m=(pf().enums||{})[kind]||{};
2226
+ return Object.keys(m).map(l=>'<option value="'+esc(m[l])+'"'+(m[l]===cur?' selected':'')+'>'+esc(l)+'</option>').join('');
2227
+ }
2228
+ /* 回数の正規化。**小数を必ず落とす**のが要点:
2229
+ backend の campaignSchema は vImps: z.number().optional() で int 制約が無く、dry-run も素通りするため、
2230
+ 2.5 のような値は本実行で初めて Yahoo API に弾かれる。GUI 側で整数化してしまう。
2231
+ "2.5"→2 / "3回"→3 / ""・0・負数→0 (呼び出し側が既定へフォールバックする) */
2232
+ function freqNum(v){
2233
+ const n=Math.floor(Number(String(v==null?'':v).replace(/[^0-9.]/g,'')));
2234
+ return n>0?n:0;
2235
+ }
2236
+ /* ONにする時の欠損補完。旧プラン (サンプルプラン.json 含む) のキャンペーン行は freq* を持たないので、
2237
+ ここで案件設定チップの既定 → 定数の順に埋める。埋めないと「ONにしたのに入稿ボディに載らない」になる。 */
2238
+ function freqEnsure(row){
2239
+ const p=st.project||{};
2240
+ if(!row.freqLevel) row.freqLevel=p.freqLevel||'AD_GROUP';
2241
+ if(!row.freqUnit) row.freqUnit =p.freqUnit ||'WEEK';
2242
+ if(!(freqNum(row.freqCount)>0)) row.freqCount=freqNum(p.freqCount)||3;
2243
+ else row.freqCount=freqNum(row.freqCount);
2244
+ }
2245
+ /* ---- モード (3状態) ----
2246
+ 'default' = 案件共通設定に追従 (既定) / 'on' = この行で個別指定 / 'off' = 付けない
2247
+ 旧データ (freqMode 無し) は freqOn:true のときだけ 'on'、それ以外は既定の 'default' に寄せる。 */
2248
+ const FREQ_MODES=[{v:'default',t:'既定値'},{v:'on',t:'オン'},{v:'off',t:'オフ'}];
2249
+ function freqMode(row){
2250
+ if(!row) return 'default';
2251
+ if(row.freqMode==='on'||row.freqMode==='off'||row.freqMode==='default') return row.freqMode;
2252
+ return row.freqOn===true?'on':'default';
2253
+ }
2254
+ /* 実際に入稿へ出す値。'default' は案件共通設定を引く。付けないときは null */
2255
+ function freqEffective(row){
2256
+ const m=freqMode(row), p=st.project||{};
2257
+ if(m==='off') return null;
2258
+ const src = m==='on' ? row : (p.freqOn ? p : null);
2259
+ if(!src) return null;
2260
+ const n=freqNum(src.freqCount)||3;
2261
+ return {level:src.freqLevel||'AD_GROUP', unit:src.freqUnit||'WEEK', count:n};
2262
+ }
2263
+ /* 表示・コピー用の1行テキスト。'off'=空文字 / 'default'=「既定値」 / 'on'=「3回/週/広告グループ」 */
2264
+ function freqCellText(row){
2265
+ const m=freqMode(row);
2266
+ if(m==='off') return '';
2267
+ if(m==='default') return '既定値';
2268
+ const n=freqNum(row.freqCount)||3;
2269
+ return n+'回/'+freqEnumLabel('freqUnit', row.freqUnit||'WEEK')+'/'+freqEnumLabel('freqLevel', row.freqLevel||'AD_GROUP');
2270
+ }
2271
+ /* 貼付/一括入力で入ってくる文字列をモード+3値へ戻す。欠けた要素は現在値を残す (完全上書きしない)。 */
2272
+ function freqCellApply(row, val){
2273
+ const s=String(val==null?'':val).trim();
2274
+ if(!s || /^(未設定|なし|off|オフ|-|—)$/i.test(s)){ row.freqMode='off'; row.freqOn=false; return; }
2275
+ if(/^(既定値|規定値|既定|default)$/i.test(s)){ row.freqMode='default'; row.freqOn=false; return; }
2276
+ const parts=s.split(/[\//]/).map(x=>x.trim());
2277
+ const n=freqNum(parts[0]);
2278
+ row.freqMode='on'; row.freqOn=true;
2279
+ if(n>0) row.freqCount=n;
2280
+ if(parts[1]) row.freqUnit =freqEnumValue('freqUnit', parts[1], row.freqUnit ||'WEEK');
2281
+ if(parts[2]) row.freqLevel=freqEnumValue('freqLevel', parts[2], row.freqLevel||'AD_GROUP');
2282
+ freqEnsure(row);
2283
+ }
2284
+ /* 回数の選択肢 (1〜10回)。実運用の範囲に絞る */
2285
+ function freqCountOptions(cur){
2286
+ const c=freqNum(cur)||3; let h='';
2287
+ for(let i=1;i<=10;i++) h+='<option value="'+i+'"'+(i===c?' selected':'')+'>'+i+'回</option>';
2288
+ return h;
2289
+ }
2290
+ /* ON のときに出す3つのプルダウン (適用 / 期間 / 回数)。**ラベルは出さない**(全部ワンライン)。
2291
+ attrs(field) が data-* を返す (キャンペーン行 = data-cu+data-freqf / 案件チップ = data-proj)。 */
2292
+ function freqSectionsHtml(o, attrs){
2293
+ const lv=o.freqLevel||'AD_GROUP', un=o.freqUnit||'WEEK';
2294
+ return '<select class="freqin" title="適用レベル" '+attrs('freqLevel')+'>'+freqOptions('freqLevel', lv)+'</select>'
2295
+ + '<select class="freqin" title="期間単位" ' +attrs('freqUnit') +'>'+freqOptions('freqUnit', un)+'</select>'
2296
+ + '<select class="freqin freqnum" title="上限回数" '+attrs('freqCount')+'>'+freqCountOptions(o.freqCount)+'</select>';
2297
+ }
2298
+ /* 'default' のときにセルへ薄く出す、案件共通設定の内容 */
2299
+ function freqInheritText(){
2300
+ const p=st.project||{};
2301
+ if(!p.freqOn) return '共通設定: なし';
2302
+ return freqEnumLabel('freqLevel', p.freqLevel||'AD_GROUP')+'/'+freqEnumLabel('freqUnit', p.freqUnit||'WEEK')+'/'+(freqNum(p.freqCount)||3)+'回';
2303
+ }
2304
+
2038
2305
  const COLS = {
2039
2306
  ads:[
2040
2307
  {field:'format', label:'形式', kind:'select', options:['Card','SQ'], width:'56px',
@@ -2056,22 +2323,16 @@ const COLS = {
2056
2323
  ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
2057
2324
  {field:'paramCode', label:'paramCode(グループ)', kind:'mono', width:'120px',
2058
2325
  scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
2059
- title:'グループ共通(変更で同グループ全広告に反映)',
2326
+ title:'グループ共通(変更で同グループ全広告に反映)。最終URLは 記事URL + paramCode',
2327
+ hidden:p=>urlParamsOff(p),
2060
2328
  roStyle:'input', roTitle:'既存広告グループのため編集不可',
2061
2329
  onSet:it=>autoResolveGroupIds(it.g,it.c)},
2062
- /* C4: 記事slug / 記事URL / 最終リンク先URL は同じ「記事URLの指定」の3つの見方。
2063
- どの列に何を貼っても naming.normalizeArticleInput が振り分ける (完全URLを slug 欄に貼っても壊れない)。
2064
- 空セルは **その列が持つフィールドだけ** を消す (applyArticleInput の第4引数)。
2065
- 3列とも一律に両フィールドを消すと、空セルを含む範囲を貼るたびに隣の列の値が飛ぶ。 */
2066
- {field:'articleSlug', label:'記事slug', kind:'mono', width:'90px',
2067
- set:(row,val,ctx)=>{ PLATFORMS.yda.naming.applyArticleInput(row, val, ctx.project, 'articleSlug'); }},
2068
- {field:'articleUrl', label:'記事URL(上書き)', kind:'mono', width:'170px',
2069
- title:'記事の完全URL。入れると 記事URL(案件設定)+slug より優先されます',
2070
- set:(row,val,ctx)=>{ PLATFORMS.yda.naming.applyArticleInput(row, val, ctx.project, 'articleUrl'); }},
2330
+ /* C8: 記事slug / 記事URL(上書き) 列は廃止。URLの可変部は paramCode だけになったので、
2331
+ 最終リンク先URL は **読み取り専用の導出セル** (貼り戻し先が無い)。 */
2071
2332
  {field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
2072
- title:'パラメータ込みの完成URL。貼り戻すと記事URL/slugへ逆写像されます(空にしても消えません。消すときは記事slug / 記事URL(上書き) を空にしてください)',
2073
- get:(row,ctx)=>ctx.r?ctx.r.finalUrl:'',
2074
- set:(row,val,ctx)=>{ PLATFORMS.yda.naming.applyArticleInput(row, val, ctx.project, null); }},
2333
+ alwaysReadonly:true, roStyle:'input',
2334
+ roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
2335
+ get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
2075
2336
  /* C5: 参照表(refs.titles / refs.descriptions)の本文を直接編集する列。
2076
2337
  番号未入力のまま本文を書くと空き番号を自動採番して参照表に登録する。 */
2077
2338
  makeRefTextCol({field:'titleText', label:'タイトル本文', table:'titles', noField:'titleNo', width:'180px'}),
@@ -2093,8 +2354,8 @@ const COLS = {
2093
2354
  {field:'name', label:'広告グループ名', kind:'text', width:'150px', required:true,
2094
2355
  readonlyOnExisting:true, roTitle:'既存広告グループのため編集不可'},
2095
2356
  {field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
2096
- sequenceable:true, readonlyOnExisting:true,
2097
- title:'URL生成用のパラメータ(グループ紐づけには不使用)',
2357
+ sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
2358
+ title:'URL生成用のパラメータ(グループ紐づけには不使用)。最終URLは 記事URL + paramCode',
2098
2359
  onSet:it=>autoResolveGroupIds(it.g,it.c)},
2099
2360
  {field:'bid', label:'入札', kind:'text', width:'56px', readonlyOnExisting:true, roEmptyDash:true},
2100
2361
  {field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
@@ -2133,7 +2394,13 @@ const COLS = {
2133
2394
  hideOnExisting:true, emptyNoSelected:true},
2134
2395
  {field:'tcpa', label:'tCPA', kind:'number', width:'70px',
2135
2396
  placeholder:()=>String(st.project.tcpaDefault), coerce:numOrNull, hideOnExisting:true},
2136
- {field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
2397
+ /* S14: フリークエンシー (viewableFrequencyCap)。YDA API はキャンペーン単位の設定なのでこの行に置く。
2398
+ 値は行に freqOn / freqLevel / freqUnit / freqCount の4フィールドで持ち、
2399
+ 列としては get/set フックで1本の文字列に畳む → コピー&ペーストがそのまま往復する。
2400
+ 文字列形式: OFF は空文字 / ON は "3回/週/広告グループ" (回数/期間/適用レベル) */
2401
+ {field:'freqCap', label:'フリークエンシー', kind:'freq', hideOnExisting:true,
2402
+ get:row=>freqCellText(row),
2403
+ set:(row,val)=>freqCellApply(row,val)},
2137
2404
  {field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
2138
2405
  ]
2139
2406
  };
@@ -2236,9 +2503,14 @@ const META_COLS = {
2236
2503
  ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
2237
2504
  {field:'paramCode', label:'paramCode(広告セット)', kind:'mono', width:'120px',
2238
2505
  scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
2239
- title:'広告セット共通(変更で同広告セット全広告に反映)',
2506
+ title:'広告セット共通(変更で同広告セット全広告に反映)。最終URLは 記事URL?ad={paramCode}',
2507
+ hidden:p=>urlParamsOff(p),
2240
2508
  roStyle:'input', roTitle:'既存広告セットのため編集不可'},
2241
- {field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
2509
+ /* C8: 記事slug / 記事URL(上書き) 列は廃止。最終URL = 記事URL + ?ad={paramCode} の導出セル */
2510
+ {field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
2511
+ alwaysReadonly:true, roStyle:'input',
2512
+ roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
2513
+ get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
2242
2514
  /* C6: 広告名は自由入力 (空 = 自動生成)。YDA と同じ規約 */
2243
2515
  {field:'adName', label:'広告名', kind:'mono', width:'170px',
2244
2516
  title:'空欄なら自動生成({番号}{i|v}-{見出し番号}L{本文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
@@ -2251,8 +2523,8 @@ const META_COLS = {
2251
2523
  {field:'name', label:'広告セット名', kind:'text', width:'150px', required:true,
2252
2524
  readonlyOnExisting:true, roTitle:'既存広告セットのため編集不可'},
2253
2525
  {field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
2254
- sequenceable:true, readonlyOnExisting:true,
2255
- title:'URL生成用のパラメータ(広告セット紐づけには不使用)'},
2526
+ sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
2527
+ title:'URL生成用のパラメータ(広告セット紐づけには不使用)。最終URLは 記事URL + paramCode'},
2256
2528
  /* budgetLevel='adGroup' → 予算は広告セット行が持つ (CBO は使わない) */
2257
2529
  {field:'dailyBudget', label:'日予算(円)', kind:'number', width:'84px', required:true,
2258
2530
  coerce:numOrNull, readonlyOnExisting:true, roEmptyDash:true, errCodes:[EC.BUDGET_MISSING],
@@ -2275,7 +2547,7 @@ const META_COLS = {
2275
2547
  {field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
2276
2548
  {field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
2277
2549
  /* 配信日時: 空欄 = 案件設定チップの値を継承(buildPlan 側で解決)。placeholder に継承先を出す */
2278
- {field:'startDate', label:'配信開始日時', kind:'text', width:'17ch',
2550
+ {field:'startDate', label:'配信開始日時', kind:'datetime', width:'17ch',
2279
2551
  readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
2280
2552
  errCodes:[EC.START_TIME_INVALID, EC.END_BEFORE_START],
2281
2553
  placeholder:()=>{ const v=String((st.project&&st.project.startDate)||'').trim();
@@ -2283,7 +2555,7 @@ const META_COLS = {
2283
2555
  title:'この広告セットだけの配信開始日時(JST)。空欄なら案件設定「配信開始日時」を継承し、'
2284
2556
  +'それも空欄なら当日 00:00。日付のみなら 00:00 開始(例 2026-08-01 10:30)。'
2285
2557
  +'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
2286
- {field:'endDate', label:'配信終了日時', kind:'text', width:'17ch',
2558
+ {field:'endDate', label:'配信終了日時', kind:'datetime', width:'17ch',
2287
2559
  readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
2288
2560
  errCodes:[EC.END_TIME_INVALID, EC.END_BEFORE_START],
2289
2561
  placeholder:()=>{ const v=String((st.project&&st.project.endDate)||'').trim();
@@ -2305,7 +2577,6 @@ const META_COLS = {
2305
2577
  /* 公式7値 + GUIセンチネル NONE (送信時 [] に変換) */
2306
2578
  options:['NONE','HOUSING','EMPLOYMENT','CREDIT','ISSUES_ELECTIONS_POLITICS',
2307
2579
  'ONLINE_GAMBLING_AND_GAMING','FINANCIAL_PRODUCTS_SERVICES']},
2308
- {field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
2309
2580
  {field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
2310
2581
  ]
2311
2582
  };
@@ -2381,13 +2652,11 @@ PLATFORMS.meta = {
2381
2652
  {key:'cta', label:'CTA', kind:'select',
2382
2653
  options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
2383
2654
  '予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
2384
- {key:'startDate', label:'配信開始日時', kind:'text', width:'17ch', fallback:'',
2385
- placeholder:'YYYY-MM-DD [HH:mm]',
2655
+ {key:'startDate', label:'配信開始日時', kind:'datetime', fallback:'',
2386
2656
  title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
2387
2657
  +'日付のみなら 00:00 開始。時刻を入れるとその時刻ちょうどから配信(例 2026-08-01 10:30)。'
2388
2658
  +'空欄なら当日 00:00 から。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
2389
- {key:'endDate', label:'配信終了日時', kind:'text', width:'17ch', fallback:'',
2390
- placeholder:'空欄=無期限',
2659
+ {key:'endDate', label:'配信終了日時', kind:'datetime', fallback:'',
2391
2660
  title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
2392
2661
  +'日付のみならその日の 23:59:59 まで。時刻を入れるとその時刻で終了(例 2026-08-31 18:00)。'
2393
2662
  +'空欄なら終了日時を送らず無期限配信。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
@@ -2415,7 +2684,7 @@ PLATFORMS.meta = {
2415
2684
  cta:{'詳しくはこちら':'LEARN_MORE','購入する':'SHOP_NOW','登録する':'SIGN_UP',
2416
2685
  'ダウンロード':'DOWNLOAD','お問い合わせ':'CONTACT_US','今すぐ申し込む':'APPLY_NOW',
2417
2686
  '予約する':'BOOK_NOW','見積もりを取得':'GET_QUOTE','購読する':'SUBSCRIBE',
2418
- '動画を見る':'WATCH_MORE','ボタンなし':'NO_BUTTON'},
2687
+ '動画を見る':'WATCH_MORE','詳細を見る':'SEE_DETAILS','ボタンなし':'NO_BUTTON'},
2419
2688
  ctaFallback:'LEARN_MORE',
2420
2689
  statusOn:'ACTIVE', statusOff:'PAUSED',
2421
2690
  /* 特別広告カテゴリでターゲティングが強制されるルール (公式) */
@@ -2540,12 +2809,12 @@ PLATFORMS.meta = {
2540
2809
  deliveryStatus:'オフ', startDate:'', endDate:''
2541
2810
  },
2542
2811
  /* C6: adName は空 = 自動生成 (naming.adName) */
2543
- ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adName:''},
2812
+ ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', adName:''},
2544
2813
  adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
2545
2814
  ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
2546
2815
  tg_placement:'自動配置', startDate:'', endDate:'', deliveryStatus:''},
2547
2816
  campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
2548
- objective:'OUTCOME_SALES', specialAdCategory:'NONE', variable:'', urlNumber:''}
2817
+ objective:'OUTCOME_SALES', specialAdCategory:'NONE', urlNumber:''}
2549
2818
  },
2550
2819
  factories:{
2551
2820
  newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.meta.defaults.ad, seed||{}); },
@@ -2556,7 +2825,7 @@ PLATFORMS.meta = {
2556
2825
  return Object.assign({uid:uid('c')}, P.defaults.campaign,
2557
2826
  {adGroups:[P.factories.newAdGroup(null,0)]}, seed||{}); },
2558
2827
  existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
2559
- isExisting:true, objective:'', specialAdCategory:'NONE', variable:'', urlNumber:'', adGroups:[]}; },
2828
+ isExisting:true, objective:'', specialAdCategory:'NONE', urlNumber:'', adGroups:[]}; },
2560
2829
  existingAdGroup(agId, meta){ const P=PLATFORMS.meta, m=meta||{};
2561
2830
  return Object.assign({uid:uid('g')}, P.defaults.adGroup,
2562
2831
  {name:m.name||('AS '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
@@ -2566,15 +2835,19 @@ PLATFORMS.meta = {
2566
2835
  /* 命名規則は YDA 踏襲 (形式記号は mediaFormats.crSuffix = i/v) */
2567
2836
  naming:{
2568
2837
  crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
2569
- adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
2570
- /* C3: project.urlParams===false なら ?ad= / &creative= を付けない (欠落 undefined は ON = 従来挙動)。
2571
- YDA と同じく base の既存クエリを壊さない (?の二重) / 自由入力の広告名は urlq でエスケープする。 */
2838
+ /* C8: URLパラメータ OFF なら末尾 "-{paramCode}" を付けない (YDA と同じ) */
2839
+ adName(ad, group, crName, project){
2840
+ const head = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'');
2841
+ if(project && project.urlParams===false) return head;
2842
+ return head + '-' + (group.paramCode||'');
2843
+ },
2844
+ /* C8: Meta の最終URL = {記事URL}?ad={paramCode} のみ (YDA と違い creative= もパス連結もしない)。
2845
+ urlParams===false なら記事URLのみ。base が既にクエリを持つときは '&' で繋ぐ。 */
2572
2846
  finalUrl(ad, group, adName, project){
2573
- const base=(project.baseArticleUrl||'') + (ad.articleSlug||'');
2847
+ const base = (project.baseArticleUrl||'');
2574
2848
  if(project.urlParams===false) return base;
2575
2849
  const sep = base.indexOf('?')>=0 ? '&' : '?';
2576
- const code = urlq(group.paramCode||'');
2577
- return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
2850
+ return base + sep + 'ad=' + urlq(group.paramCode||'');
2578
2851
  },
2579
2852
  campaignName(urlNumber, firstAd, P){
2580
2853
  const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
@@ -2603,7 +2876,7 @@ PLATFORMS.meta = {
2603
2876
  const fmt=P.fmtOf(ad);
2604
2877
  const crName=P.naming.crName(ad, P);
2605
2878
  /* C6: 広告名は自由入力。空 = 自動生成にフォールバック */
2606
- const autoAdName=P.naming.adName(ad, group, crName);
2879
+ const autoAdName=P.naming.adName(ad, group, crName, p);
2607
2880
  const adName=String(ad.adName||'').trim() || autoAdName;
2608
2881
  const me = refs.media[String(ad.imageNo)];
2609
2882
  const mediaId = me ? me[fmt.mediaKey] : undefined;
@@ -2626,7 +2899,8 @@ PLATFORMS.meta = {
2626
2899
  else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
2627
2900
  if(descText!==undefined && glen(descText)>D.err) pushErr('本文'+D.err+'字超', EC.DESC_TOO_LONG);
2628
2901
  else if(descText!==undefined && glen(descText)>D.warn) pushWarn('本文'+D.warn+'字超', EC.DESC_LONG_WARN);
2629
- if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
2902
+ /* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
2903
+ if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
2630
2904
  if(pendingImage) pushWarn(ad.format==='VIDEO'?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING);
2631
2905
  const aid=String(group.adGroupId||'').trim();
2632
2906
  return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
@@ -2862,7 +3136,18 @@ PLATFORMS.meta = {
2862
3136
  /* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
2863
3137
  function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
2864
3138
  /* 列記述子配列。引数省略時は現在のシートタブ */
2865
- function colDefs(tab){ const S=pf().sheets; return S[normTab(tab===undefined?sheetTab:tab)] || S.ads; }
3139
+ /* C8: 列記述子の hidden(project) が true の列は **描画も列index採番もしない**。
3140
+ colIndex / compatColsFor / getVar / setVar / コピペ / 連番 はすべて colDefs を基準に
3141
+ 列番号を決めるので、ここで落としておけば列番号のズレが起きない
3142
+ (逆に renderSheetGeneric 側だけで隠すと data-c と setVar の対象がズレる)。 */
3143
+ function colDefs(tab){
3144
+ const S=pf().sheets, list=S[normTab(tab===undefined?sheetTab:tab)] || S.ads;
3145
+ const p=(typeof st!=='undefined' && st) ? st.project : null;
3146
+ return list.filter(c=>!(typeof c.hidden==='function' && c.hidden(p||{})));
3147
+ }
3148
+ /* C8: URLパラメータ OFF の案件では paramCode 列を表に出さない。
3149
+ OFF = 「最終URL は 案件設定の記事URL のみ」なので、paramCode の入力自体が不要になる。 */
3150
+ function urlParamsOff(project){ return !!project && project.urlParams===false; }
2866
3151
  /* 列番号リテラル撤去用: field → 列index */
2867
3152
  function colIndex(field, tab){ return colDefs(tab).findIndex(c=>c.field===field); }
2868
3153
  /* ドロワーで拾った番号を流し込める列index群 (旧 compat=[1] / [2,3] の置換) */
@@ -2919,6 +3204,29 @@ function setVar(item,c,val,tab,ctx){
2919
3204
  else row[col.field] = v;
2920
3205
  if(col.onSet) col.onSet(item,row);
2921
3206
  }
3207
+ /* ---- 範囲選択中の1セル編集を、同じ列の選択行すべてへ一括反映する ----
3208
+ select / text / number など「▼ドロワーを持たない列」の一括入力口。
3209
+ 同種の列 = 同じ field(=同じ列)に限る。異なる列へ値を流し込むのは意味が壊れるのでしない。
3210
+ 編集したセルが選択範囲の外なら 0 を返し、従来どおり1セルだけの編集になる。 */
3211
+ function bulkFillColumn(tab, field, val, editedUid){
3212
+ if(activeView!=='sheet' || planMode!=='sheet') return 0;
3213
+ if(normTab(sheetTab)!==tab) return 0;
3214
+ const rg=selRange(); if(!rg) return 0;
3215
+ const ci=colIndex(field, tab); if(ci<0 || ci<rg.c0 || ci>rg.c1) return 0;
3216
+ const vis=flatVisible();
3217
+ const uidOf=it=>(it.kind==='a'?it.a.uid:(it.kind==='g'?it.g.uid:it.c.uid));
3218
+ let hit=false;
3219
+ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it && uidOf(it)===editedUid){ hit=true; break; } }
3220
+ if(!hit) return 0;
3221
+ let n=0;
3222
+ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||uidOf(it)===editedUid) continue; setVar(it, ci, val, tab); n++; }
3223
+ return n;
3224
+ }
3225
+ function bulkFillToast(tab, field, n){
3226
+ if(!n) return;
3227
+ const col=colDefs(tab).find(c=>c.field===field);
3228
+ toast(((col&&col.label)||field)+' を'+(n+1)+'セルに一括入力');
3229
+ }
2922
3230
  /* 列 field 指定で1セルを書く (change ハンドラを列記述子経由に載せるための入口)。
2923
3231
  列が見つからなければ false を返し、呼び出し側の旧経路にフォールバックさせる。 */
2924
3232
  function setVarByField(item, tab, field, val){
@@ -2939,6 +3247,15 @@ function pasteGrid(txt){
2939
3247
  const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
2940
3248
  const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
2941
3249
  const vis=flatVisible(); const maxc=tabCols().length-1; if(!vis[rg.r0]){ toast('貼付先がありません'); return; }
3250
+ /* 1セルのコピーを範囲へ貼ったら選択範囲全体へ充填する (スプシ同等のタイル展開)。
3251
+ 行を増やさないので既存行の同種セルを一括で埋める用途に限る。 */
3252
+ if(rows.length===1 && rows[0].length===1 && (rg.r1>rg.r0 || rg.c1>rg.c0)){
3253
+ const val=rows[0][0]; let n=0;
3254
+ mutate(()=>{ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue;
3255
+ for(let c=rg.c0;c<=Math.min(rg.c1,maxc);c++){ setVar(it,c,val); n++; } } });
3256
+ clearSel(); toast(n+'セルに貼り付け');
3257
+ return;
3258
+ }
2942
3259
  mutate(()=>{
2943
3260
  if(sheetTab==='ads'){
2944
3261
  // B8: 可視行基準に統一 (グループ境界・絞り込み表示を尊重)。不足分は末尾の可視グループに新規広告を追加。
@@ -2957,7 +3274,7 @@ function pasteGrid(txt){
2957
3274
  function dupSelected(){
2958
3275
  const rg=selRange(); if(!rg){ toast('複製する行を選択してください'); return; } const vis=flatVisible(); const items=[]; for(let r=rg.r0;r<=rg.r1;r++){ if(vis[r]) items.push(vis[r]); } if(!items.length){ toast('対象なし'); return; }
2959
3276
  mutate(()=>{ items.forEach(it=>{
2960
- if(it.kind==='g'){ const ng=JSON.parse(JSON.stringify(it.g)); ng.uid=uid('g'); ng.name=it.g.name+'_copy'; ng.adGroupId=''; ng.isExisting=false; ng.ads.forEach(a=>a.uid=uid('a')); const idx=it.c.adGroups.indexOf(it.g); it.c.adGroups.splice(idx+1,0,ng); }
3277
+ if(it.kind==='g'){ const ng=JSON.parse(JSON.stringify(it.g)); ng.uid=uid('g'); ng.adGroupId=''; ng.isExisting=false; ng.name=dupGroupName(it.g.name); ng.paramCode=nextParamCode(it.g.paramCode); ng.ads.forEach(a=>a.uid=uid('a')); const idx=it.c.adGroups.indexOf(it.g); it.c.adGroups.splice(idx+1,0,ng); autoResolveGroupIds(ng, it.c); }
2961
3278
  else if(it.kind==='c'){ const nc=cloneCampaign(it.c,false,false); const idx=st.campaigns.indexOf(it.c); st.campaigns.splice(idx+1,0,nc); }
2962
3279
  else { const idx=it.g.ads.indexOf(it.a); it.g.ads.splice(idx+1,0,cloneAd(it.a,null)); }
2963
3280
  }); }); clearSel(); toast(items.length+'件を複製');
@@ -3124,11 +3441,45 @@ function openTargetingDrawer(gUid, field){
3124
3441
  renderDrawer();
3125
3442
  document.getElementById('refDrawer').classList.add('open');
3126
3443
  }
3444
+ /* ---- ターゲティング列の一括トグル ----
3445
+ ドロワーで押した項目を「選択範囲 ∩ 同種の列」の全セルへ ON/OFF 一括適用する。
3446
+ 同種の判定は列記述子から引く (refpick は同じ refTable / tgpick は同じ tgKey)。
3447
+ 選択が無い・対象列に掛かっていない場合はアンカーの1セルだけを返し、従来の単体トグルと同じ挙動になる。 */
3448
+ function tgBulkTargets(gUid, field){
3449
+ const tab=normTab(sheetTab), cols=colDefs(tab);
3450
+ const ci=cols.findIndex(c=>c.field===field), base=cols[ci];
3451
+ const fg=findGroup(gUid);
3452
+ const anchor=(fg&&ci>=0)?[{item:{kind:'g', g:fg.g, c:fg.c}, ci}]:[];
3453
+ if(!base) return anchor;
3454
+ const compat=[]; cols.forEach((c,i)=>{
3455
+ if(base.kind==='refpick'){ if(c.kind==='refpick' && c.refTable===base.refTable) compat.push(i); }
3456
+ else if(c.kind==='tgpick' && c.tgKey===base.tgKey) compat.push(i);
3457
+ });
3458
+ const rg=selRange(); if(!rg) return anchor;
3459
+ const vis=flatVisible(), out=[];
3460
+ for(let r=rg.r0;r<=rg.r1;r++){
3461
+ const it=vis[r]; if(!it||it.kind!=='g') continue;
3462
+ for(let c=rg.c0;c<=rg.c1;c++){
3463
+ if(compat.indexOf(c)<0) continue;
3464
+ if(rowIsExisting(it, cols[c])) continue; /* F-2: 既存呼び出しAGは編集不可 */
3465
+ out.push({item:it, ci:c});
3466
+ }
3467
+ }
3468
+ return out.length?out:anchor;
3469
+ }
3470
+ function tgListOf(t){ return String(getVar(t.item, t.ci)||'').split(',').map(s=>s.trim()).filter(Boolean); }
3127
3471
  function drawerTgToggle(val){
3128
- if(!drawerTarget||drawerTarget.type!=='tg') return; const r=findGroup(drawerTarget.gUid); if(!r) return; const f=drawerTarget.field;
3129
- let cur=(r.g[f]?String(r.g[f]):'').split(',').map(s=>s.trim()).filter(Boolean);
3130
- const i=cur.indexOf(val); if(i>=0) cur.splice(i,1); else cur.push(val);
3131
- mutate(()=>{ r.g[f]=cur.join(', '); });
3472
+ if(!drawerTarget||drawerTarget.type!=='tg') return;
3473
+ const targets=tgBulkTargets(drawerTarget.gUid, drawerTarget.field);
3474
+ if(!targets.length) return;
3475
+ /* 全セルに入っていれば一括解除、そうでなければ一括追加 (ON/OFF 一括トグル) */
3476
+ const allOn=targets.every(t=>tgListOf(t).indexOf(val)>=0);
3477
+ mutate(()=>{ targets.forEach(t=>{
3478
+ const cur=tgListOf(t), i=cur.indexOf(val);
3479
+ if(allOn){ if(i>=0) cur.splice(i,1); } else if(i<0) cur.push(val);
3480
+ setVar(t.item, t.ci, cur.join(', '));
3481
+ }); });
3482
+ if(targets.length>1) toast((allOn?'解除':'追加')+':'+targets.length+'セルに一括適用');
3132
3483
  renderDrawer();
3133
3484
  }
3134
3485
  function closeDrawer(){ document.getElementById('refDrawer').classList.remove('open'); drawerTarget=null; }
@@ -3178,9 +3529,16 @@ function renderDrawer(){
3178
3529
  const cfg=isRef?null:pf().targeting[drawerTarget.field];
3179
3530
  const label=(cfg&&cfg.label)||(gcol&&gcol.label)||'';
3180
3531
  const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
3181
- const cur=(g&&g[drawerTarget.field]?String(g[drawerTarget.field]):'').split(',').map(s=>s.trim()).filter(Boolean);
3182
- titleEl.textContent=label+' を選択(複数可)';
3183
- tgtEl.textContent='入力先: '+(g&&g.name?g.name:agLabel())+' / '+label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)');
3532
+ /* 一括対象セル全体で ✓ を判定する: 全セルにあれば ✓ / 一部だけなら −(押すと全セルへ揃える) */
3533
+ const targets=tgBulkTargets(drawerTarget.gUid, drawerTarget.field);
3534
+ const lists=targets.map(tgListOf), N=targets.length;
3535
+ const cur=lists[0]||[];
3536
+ const mark=v=>{ const n=lists.reduce((a,l)=>a+(l.indexOf(v)>=0?1:0),0);
3537
+ return n===0?{cls:'', ic:''}:(n===N?{cls:' cur', ic:'✓'}:{cls:' cur', ic:'−'}); };
3538
+ titleEl.textContent=label+' を選択(複数可)'+(N>1?(' / '+N+'セル一括'):'');
3539
+ tgtEl.textContent = N>1
3540
+ ? ('入力先: 選択中の '+N+' セル / '+label+'(押した項目を全セルにON/OFF)')
3541
+ : ('入力先: '+(g&&g.name?g.name:agLabel())+' / '+label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)'));
3184
3542
  let h='';
3185
3543
  if(isRef){
3186
3544
  /* C1: 選択肢は st.refs[col.refTable] (ID→name)。data-tgval には **実ID** を入れる */
@@ -3188,12 +3546,12 @@ function renderDrawer(){
3188
3546
  Object.keys(store).forEach(id=>{
3189
3547
  const nm=refPickName(gcol,id);
3190
3548
  if(q && !String(id).includes(q) && !nm.toLowerCase().includes(q)) return;
3191
- const on=cur.indexOf(String(id))>=0;
3192
- h+='<div class="ddrow'+(on?' cur':'')+'" data-tgval="'+esc(id)+'"><span class="ddno">'+(on?'✓':'')+'</span><span class="ddtx">'+esc(nm||id)+' <span class="sm mono">'+esc(id)+'</span></span></div>';
3549
+ const mk=mark(String(id));
3550
+ h+='<div class="ddrow'+mk.cls+'" data-tgval="'+esc(id)+'"><span class="ddno">'+mk.ic+'</span><span class="ddtx">'+esc(nm||id)+' <span class="sm mono">'+esc(id)+'</span></span></div>';
3193
3551
  });
3194
3552
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">参照データがありません(アカウントJSONの refs.'+esc(gcol.refTable)+')</div>'; return;
3195
3553
  }
3196
- cfg.opts.forEach(o=>{ if(q&&!o.toLowerCase().includes(q)) return; const on=cur.indexOf(o)>=0; h+='<div class="ddrow'+(on?' cur':'')+'" data-tgval="'+esc(o)+'"><span class="ddno">'+(on?'✓':'')+'</span><span class="ddtx">'+esc(o)+'</span></div>'; });
3554
+ cfg.opts.forEach(o=>{ if(q&&!o.toLowerCase().includes(q)) return; const mk=mark(o); h+='<div class="ddrow'+mk.cls+'" data-tgval="'+esc(o)+'"><span class="ddno">'+mk.ic+'</span><span class="ddtx">'+esc(o)+'</span></div>'; });
3197
3555
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
3198
3556
  }
3199
3557
  /* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
@@ -3245,18 +3603,67 @@ function setField(u,f,val){
3245
3603
  document.addEventListener('change', e=>{
3246
3604
  const t=e.target; if(!t.dataset||!t.dataset.u||!t.dataset.f) return;
3247
3605
  const f=t.dataset.f; const val = t.type==='checkbox'? t.checked : t.value;
3606
+ let n=0;
3248
3607
  mutate(()=>{
3249
3608
  const found=findAd(t.dataset.u); if(!found) return;
3250
3609
  if(!setVarByField({kind:'a', a:found.a, g:found.g, c:found.c}, 'ads', f, val)) setField(t.dataset.u,f,val);
3610
+ n=bulkFillColumn('ads', f, val, t.dataset.u); /* 範囲選択中なら同じ列の選択行すべてへ */
3251
3611
  });
3612
+ bulkFillToast('ads', f, n);
3252
3613
  });
3253
3614
  /* 広告グループ/キャンペーン タブの一覧編集 */
3254
3615
  document.addEventListener('change', e=>{
3255
3616
  const t=e.target; if(!t.dataset) return;
3256
- if(t.dataset.gu && t.dataset.gf){ const gf=t.dataset.gf, val=t.value; mutate(()=>{ const r=findGroup(t.dataset.gu); if(!r) return;
3257
- if(!setVarByField({kind:'g', g:r.g, c:r.c}, 'adGroups', gf, val)){ if(!isExistingGroup(r.g)){ r.g[gf]=val; if(gf==='paramCode') autoResolveGroupIds(r.g,r.c); } } }); return; } // F-2: 既存呼び出しAGは基本パラメータ編集不可 (setVar 側の readonlyOnExisting ガードが担保)
3258
- if(t.dataset.cu && t.dataset.cf){ const cf=t.dataset.cf; const val=t.value; mutate(()=>{ const r=findCp(t.dataset.cu); if(!r) return;
3259
- if(!setVarByField({kind:'c', c:r.c}, 'campaigns', cf, val)){ if(!isExistingCampaign(r.c)) r.c[cf]=(cf==='budget'||cf==='tcpa')?numOrNull(val):val; } }); return; } // F-2: 既存呼び出しCPは基本パラメータ編集不可
3617
+ if(t.dataset.dtpart) return; /* S15: 日時セルは下の専用ハンドラが日付+時刻をまとめて確定する */
3618
+ if(t.dataset.gu && t.dataset.gf){ const gf=t.dataset.gf, val=t.value; let n=0; mutate(()=>{ const r=findGroup(t.dataset.gu); if(!r) return;
3619
+ if(!setVarByField({kind:'g', g:r.g, c:r.c}, 'adGroups', gf, val)){ if(!isExistingGroup(r.g)){ r.g[gf]=val; if(gf==='paramCode') autoResolveGroupIds(r.g,r.c); } }
3620
+ n=bulkFillColumn('adGroups', gf, val, t.dataset.gu); }); bulkFillToast('adGroups', gf, n); return; } // F-2: 既存呼び出しAGは基本パラメータ編集不可 (setVar 側の readonlyOnExisting ガードが担保)
3621
+ if(t.dataset.cu && t.dataset.cf){ const cf=t.dataset.cf; const val=t.value; let n=0; mutate(()=>{ const r=findCp(t.dataset.cu); if(!r) return;
3622
+ if(!setVarByField({kind:'c', c:r.c}, 'campaigns', cf, val)){ if(!isExistingCampaign(r.c)) r.c[cf]=(cf==='budget'||cf==='tcpa')?numOrNull(val):val; }
3623
+ n=bulkFillColumn('campaigns', cf, val, t.dataset.cu); }); bulkFillToast('campaigns', cf, n); return; } // F-2: 既存呼び出しCPは基本パラメータ編集不可
3624
+ });
3625
+
3626
+ /* S15: 日時セル(kind:'datetime')のセル内コントロール。
3627
+ 1セルに日付+時刻の2入力があり、汎用ハンドラに素通しすると時刻が落ちた値で上書きされるため
3628
+ ここで両方を読んで1本の文字列に畳み、setVar (= 列記述子の coerce/set) 経由で書き込む。
3629
+ 範囲選択中は同じ列の選択行すべてへ配る (他の列と同じ一括入力の挙動)。 */
3630
+ document.addEventListener('change', e=>{
3631
+ const t=e.target; if(!t.dataset||!t.dataset.dtpart||!t.dataset.dtf) return;
3632
+ const field=t.dataset.dtf, box=t.closest('.dtcell'); if(!box) return;
3633
+ const d=box.querySelector('[data-dtpart="date"]'), tm=box.querySelector('[data-dtpart="time"]');
3634
+ const val=joinDateTime(d&&d.value, tm&&tm.value);
3635
+ const tab = t.dataset.gu ? 'adGroups' : (t.dataset.cu ? 'campaigns' : null);
3636
+ if(!tab) return;
3637
+ let n=0;
3638
+ mutate(()=>{
3639
+ if(tab==='adGroups'){ const r=findGroup(t.dataset.gu); if(!r) return;
3640
+ setVarByField({kind:'g', g:r.g, c:r.c}, 'adGroups', field, val);
3641
+ n=bulkFillColumn('adGroups', field, val, t.dataset.gu); }
3642
+ else { const r=findCp(t.dataset.cu); if(!r) return;
3643
+ setVarByField({kind:'c', c:r.c}, 'campaigns', field, val);
3644
+ n=bulkFillColumn('campaigns', field, val, t.dataset.cu); }
3645
+ });
3646
+ bulkFillToast(tab, field, n);
3647
+ });
3648
+
3649
+ /* S14: フリークエンシー(kind:'freq')のセル内コントロール。
3650
+ 1セルに4フィールドあるため data-cf を持たず (=上の汎用ハンドラは素通しする)、ここで拾う。
3651
+ 既存呼び出しCPは列が hideOnExisting なのでそもそも描画されない。 */
3652
+ document.addEventListener('change', e=>{
3653
+ const t=e.target; if(!t.dataset||!t.dataset.cu||!t.dataset.freqf) return;
3654
+ const f=t.dataset.freqf;
3655
+ const val = t.type==='checkbox' ? t.checked : (f==='freqCount' ? freqNum(t.value) : t.value);
3656
+ let n=0;
3657
+ mutate(()=>{
3658
+ const r=findCp(t.dataset.cu); if(!r || isExistingCampaign(r.c)) return;
3659
+ if(f==='freqCount' && !(val>0)) return; /* 0/空/不正は無視 (既存の値をそのまま残す) */
3660
+ if(f==='freqMode'){ r.c.freqMode=val; r.c.freqOn=(val==='on'); }
3661
+ else r.c[f]=val;
3662
+ if(freqMode(r.c)==='on') freqEnsure(r.c); /* 旧プラン行は freq* を持たないのでここで埋める */
3663
+ /* 範囲選択中は同じ列 (freqCap) の選択行すべてへ。畳んだ文字列を setVar 経由で配る */
3664
+ n=bulkFillColumn('campaigns', 'freqCap', freqCellText(r.c), t.dataset.cu);
3665
+ });
3666
+ bulkFillToast('campaigns', 'freqCap', n);
3260
3667
  });
3261
3668
 
3262
3669
  /* rename via contenteditable (tree) */
@@ -3308,12 +3715,51 @@ function addNAds(u){
3308
3715
  const r=findGroup(u); if(!r) return;
3309
3716
  openModal({title:'N本まとめて追加', sub:'空の広告を指定本数追加します(1:1:N 量産)', fields:[{key:'n',label:'本数',type:'number',value:10}], onOk:v=>{ const raw=parseInt(v.n); if(!Number.isFinite(raw)||raw<1){ toast('本数は1以上を入力してください'); return; } const n=Math.min(50,raw); mutate(()=>{ for(let i=0;i<n;i++) r.g.ads.push(newAd()); }); toast(n+'本追加しました'); }});
3310
3717
  }
3718
+ /* プラン内の全広告グループ名。複製時の同名回避に使う。 */
3719
+ function allGroupNames(){
3720
+ const s=new Set(); st.campaigns.forEach(c=>c.adGroups.forEach(g=>s.add(String(g.name||''))));
3721
+ return s;
3722
+ }
3723
+ /* 複製した広告グループの名前。名前は引き継ぐが、同名が既にあるときだけ「連番の部分」をずらす。
3724
+ ・末尾に数字がある名前 (AG1 / 広告グループ_03) → その数字を +1 しながら空き番号を探す (ゼロ埋め幅は維持)
3725
+ ・末尾に数字が無い名前 → 末尾に 2,3,… を足して空きを探す
3726
+ 旧実装は無条件に '_copy' を付けていたため、複製を重ねると AG1_copy_copy になっていた。 */
3727
+ function dupGroupName(srcName){
3728
+ const s=String(srcName||'').trim(); if(!s) return '';
3729
+ const used=allGroupNames(); if(!used.has(s)) return s;
3730
+ const m=s.match(/^(.*?)(\d+)$/);
3731
+ const prefix = m ? m[1] : s;
3732
+ const width = m ? m[2].length : 0;
3733
+ let n = m ? parseInt(m[2],10)+1 : 2;
3734
+ for(let i=0;i<1000;i++,n++){
3735
+ const cand = prefix + (width ? String(n).padStart(width,'0') : String(n));
3736
+ if(!used.has(cand)) return cand;
3737
+ }
3738
+ return s;
3739
+ }
3740
+ /* 広告グループの複製はモーダルを介さず即時実行する (中身そのまま複製)。
3741
+ ・名前は引き継ぎ、同名のときだけ連番をずらす (dupGroupName)
3742
+ ・paramCode は dupCampaign と同じ規則で連番自動採番し、ID を解決し直す
3743
+ (コピーのままだと同じ広告グループを指してしまうため)
3744
+ ・adGroupId はクリアして新規扱いにする */
3311
3745
  function dupGroup(u){
3312
3746
  const r=findGroup(u); if(!r) return;
3313
- openModal({title:'広告グループを複製', sub:r.g.name+' を複製します', fields:[
3314
- {key:'param',label:'新しい paramCode(空ならコピー)',type:'text',value:''},
3315
- {key:'clear',label:'',type:'check',value:true,text:'広告の中身(画像/タイトル/説明文)をクリアして器だけ残す'}
3316
- ], onOk:v=>{ mutate(()=>{ const ng=JSON.parse(JSON.stringify(r.g)); ng.uid=uid('g'); ng.name=r.g.name+'_copy'; ng.paramCode=(v.param||r.g.paramCode); ng.adGroupId=''; ng.isExisting=false; ng.ads=r.g.ads.map(a=>cloneAd(a, v.clear?['creative']:null)); r.c.adGroups.splice(r.i+1,0,ng); }); toast('複製しました'); }});
3747
+ mutate(()=>{
3748
+ const ng=JSON.parse(JSON.stringify(r.g));
3749
+ ng.uid=uid('g'); ng.adGroupId=''; ng.isExisting=false;
3750
+ ng.name=dupGroupName(r.g.name);
3751
+ ng.paramCode=nextParamCode(r.g.paramCode);
3752
+ ng.ads=r.g.ads.map(a=>cloneAd(a, null));
3753
+ r.c.adGroups.splice(r.i+1,0,ng);
3754
+ autoResolveGroupIds(ng, r.c);
3755
+ });
3756
+ toast('複製しました');
3757
+ }
3758
+ /* paramCode の末尾数字を「プラン内 + 参照表の最大 +1」に振り直す。数字が無い / 空なら元の値のまま。 */
3759
+ function nextParamCode(code){
3760
+ const m=String(code||'').match(/^(.*?)(\d+)\s*$/); if(!m) return code||'';
3761
+ const prefix=m[1], width=m[2].length;
3762
+ return prefix + String(maxParamNum(prefix)+1).padStart(width,'0');
3317
3763
  }
3318
3764
  function editParam(u){
3319
3765
  const r=findGroup(u); if(!r) return; const g=r.g;
@@ -3339,14 +3785,13 @@ function editCampaign(u){
3339
3785
  // E-2: モード/campaignID/戦略メモ フィールドは撤去 (新規/既存はID解決で自動判定)
3340
3786
  openModal({title:'キャンペーン設定', sub:c.name, fields:[
3341
3787
  {key:'name',label:'キャンペーン名',type:'text',value:c.name},
3342
- {key:'variable',label:'検証する変数(1キャンペーン=1変数)',type:'text',value:c.variable},
3343
3788
  {key:'urlNumber',label:'入稿リンク末尾番号(空き: '+suggestUrlNumber()+')',type:'text',value:c.urlNumber},
3344
3789
  {key:'budget',label:'予算/日(空で既定 '+pf().defaults.newCampaignBudget+')',type:'number',value:c.budget??''},
3345
3790
  {key:'biddingStrategy',label:'配信戦略',type:'select',value:c.biddingStrategy||'tCPA',options:[{v:'tCPA',t:'tCPA'},{v:'最大配信',t:'最大配信'}]},
3346
3791
  {key:'tcpa',label:'tCPA(円)',type:'number',value:c.tcpa??''}
3347
3792
  ], extra:'<button class="act" id="genName" style="margin-right:auto">キャンペーン名を自動生成</button>', onMount:(root,getVals,setVal)=>{
3348
3793
  root.querySelector('#genName').addEventListener('click',()=>{ const num=getVals().urlNumber||suggestUrlNumber(); const first=c.adGroups[0]&&c.adGroups[0].ads[0]; setVal('name', pf().naming.campaignName(num, first, pf())); });
3349
- }, onOk:v=>{ mutate(()=>{ c.name=v.name; c.variable=v.variable; c.urlNumber=v.urlNumber; c.budget=numOrNull(v.budget); c.biddingStrategy=v.biddingStrategy; c.tcpa=numOrNull(v.tcpa); }); }});
3794
+ }, onOk:v=>{ mutate(()=>{ c.name=v.name; c.urlNumber=v.urlNumber; c.budget=numOrNull(v.budget); c.biddingStrategy=v.biddingStrategy; c.tcpa=numOrNull(v.tcpa); }); }});
3350
3795
  // B6解消: 配信戦略/tCPAはキャンペーン個別値のみを参照 (project既定を焼き付けない)
3351
3796
  }
3352
3797
  function cloneCampaign(c, clearCreative, clearParam){
@@ -3362,10 +3807,19 @@ function maxParamNum(prefix){
3362
3807
  Object.keys(st.refs.groups||{}).forEach(scan);
3363
3808
  return mx;
3364
3809
  }
3365
- /* E(スクショFB): 複製/量産は「中身そのまま複製 + paramCode連番自動採番 + campaignId/adGroupIdクリア(新規扱い) + キャンペーン名自動採番」で固定。モーダルは本数入力のみ */
3810
+ /* 複製元のキャンペーン名を引き継ぐ。先頭が「{2桁以上の番号}_」形式のときだけ新しい urlNumber に差し替える。
3811
+ 名前が空のときだけ従来どおり naming.campaignName で自動生成する。
3812
+ (旧実装は常に自動生成で上書きしていたため、手入力したキャンペーン名が複製で消えていた) */
3813
+ function dupCampaignName(srcName, num, firstAd){
3814
+ const s=String(srcName||'').trim();
3815
+ if(!s) return pf().naming.campaignName(num, firstAd, pf());
3816
+ const m=s.match(/^(\d{2,})(_.*)$/);
3817
+ return m ? num+m[2] : s;
3818
+ }
3819
+ /* E(スクショFB): 複製/量産は「中身そのまま複製 + paramCode連番自動採番 + campaignId/adGroupIdクリア(新規扱い) + キャンペーン名は引き継ぎ(先頭番号のみ差し替え)」で固定。モーダルは本数入力のみ */
3366
3820
  function dupCampaign(u){
3367
3821
  const r=findCp(u); if(!r) return; const c=r.c;
3368
- openModal({title:'キャンペーンを複製 / 量産', sub:'中身そのまま複製し、paramCodeは連番で自動採番します(campaignID/広告グループIDはクリアして新規扱い・キャンペーン名は自動採番)。'+c.name, fields:[
3822
+ openModal({title:'キャンペーンを複製 / 量産', sub:'中身そのまま複製し、paramCodeは連番で自動採番します(campaignID/広告グループIDはクリアして新規扱い・キャンペーン名は引き継ぎ、先頭の番号のみ新しい番号に差し替え)。'+c.name, fields:[
3369
3823
  {key:'count',label:'複製する本数',type:'number',value:1}
3370
3824
  ], onOk:v=>{ const raw=parseInt(v.count); if(!Number.isFinite(raw)||raw<1){ toast('本数は1以上を入力してください'); return; } const n=Math.min(50,raw);
3371
3825
  mutate(()=>{
@@ -3374,8 +3828,8 @@ function dupCampaign(u){
3374
3828
  const nc=cloneCampaign(c, false, false); // 中身そのまま (creative/paramCode保持) + campaignId/adGroupIdクリア
3375
3829
  // paramCode 連番自動採番 (prefixごとに現状の最大+1から連番)
3376
3830
  nc.adGroups.forEach(g=>{ const m=String(g.paramCode||'').match(/^(.*?)(\d+)\s*$/); if(m){ const prefix=m[1], width=m[2].length; if(nextByPrefix[prefix]==null) nextByPrefix[prefix]=maxParamNum(prefix)+1; g.paramCode=prefix+String(nextByPrefix[prefix]++).padStart(width,'0'); autoResolveGroupIds(g,nc); } });
3377
- // キャンペーン名 自動採番 ({空き番号}_{CR名}_{今日})
3378
- const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0]; nc.name=pf().naming.campaignName(num, f, pf());
3831
+ // キャンペーン名は複製元を引き継ぐ (先頭の番号のみ新しい urlNumber に差し替え / 空なら自動生成)
3832
+ const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0]; nc.name=dupCampaignName(c.name, num, f);
3379
3833
  st.campaigns.splice(base+1+i,0,nc);
3380
3834
  }
3381
3835
  });
@@ -3651,6 +4105,7 @@ window.applyAccountJson=function(obj){
3651
4105
  && samplePlan.campaigns.length && String(samplePlan.accountId||'')===String(st.project.accountId||'')){
3652
4106
  st.campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
3653
4107
  st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
4108
+ migrateArticleSlug(st.campaigns); // C8: 旧 articleSlug / articleUrl を除去
3654
4109
  planMismatchMsg='';
3655
4110
  if(typeof renderPlanWarn==='function') renderPlanWarn();
3656
4111
  }
@@ -4007,7 +4462,12 @@ function openHelpModal(){
4007
4462
  /* 「サンプルプラン更新」: 現在のプラン(共通設定+キャンペーン)を サンプルプラン.json に書き戻す。
4008
4463
  初回だけ File System Access でファイルを選び、以後は IndexedDB 保持ハンドルで Finder無し更新。 */
4009
4464
  async function updateSamplePlan(){
4010
- const data={schemaVersion:SCHEMA_VERSION, project:st.project, campaigns:st.campaigns};
4465
+ /* S13: 書き出しスキーマは同梱の サンプルプラン*.json と同形 {schemaVersion, platform, accountId, campaigns} に揃える。
4466
+ ・accountId が無いと initialState() のアカウント不一致ガードが素通りし、別アカウントのプランが
4467
+ 無警告で読み込まれる(誤入稿に直結)ので必ず出す
4468
+ ・project は読み戻し側が存在しない(案件設定はアカウントJSON由来で defaultProject() が作る)。
4469
+ 書いても次回起動で必ず捨てられるため出さない = 「保存したのに戻る」の誤解を断つ */
4470
+ const data={schemaVersion:SCHEMA_VERSION, platform:curPf, accountId:st.project.accountId||'', campaigns:st.campaigns};
4011
4471
  const json=JSON.stringify(data,null,1);
4012
4472
  if(!samplePlanFileHandle){ try{ const h=await idbGet(idbKeySamplePlan()); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted') samplePlanFileHandle=h; else samplePlanFileHandle=h; } }catch(e){} }
4013
4473
  if(samplePlanFileHandle){ try{ let perm=await samplePlanFileHandle.queryPermission({mode:'readwrite'}); if(perm!=='granted') perm=await samplePlanFileHandle.requestPermission({mode:'readwrite'}); if(perm!=='granted') samplePlanFileHandle=null; }catch(e){ samplePlanFileHandle=null; } }
@@ -4082,7 +4542,7 @@ function applyLoadedJson(j){
4082
4542
  }
4083
4543
  snapshot();
4084
4544
  st.project=Object.assign(defaultProject(),j.project);
4085
- st.campaigns=j.campaigns;
4545
+ st.campaigns=migrateArticleSlug(j.campaigns); // C8: 旧 articleSlug / articleUrl を除去
4086
4546
  st.ui=j.ui||{expanded:{}};
4087
4547
  if(validRefs(j.refs)){ st.refs=normalizeRefs(j.refs); }
4088
4548
  st.meta=Object.assign(defaultMeta(),j.meta||{});
@@ -4111,7 +4571,39 @@ document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.targe
4111
4571
  /* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
4112
4572
  document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
4113
4573
  /* 案件テンプレ: チップ直接編集 */
4114
- document.getElementById('tplChips').addEventListener('change',e=>{ const el=e.target.closest('[data-proj]'); if(!el) return; const f=el.dataset.proj; const v = el.type==='checkbox'? el.checked : (el.type==='number'? Number(el.value) : el.value); mutate(()=>{ st.project[f]=v; }); });
4574
+ document.getElementById('tplChips').addEventListener('change',e=>{
4575
+ /* S12: kind:'datetime' は日付/時刻の2入力で1つのキーを作るので、[data-proj] の汎用処理より先に分岐する
4576
+ (日付側にも data-proj が付いており、素通しすると時刻が落ちた値で上書きされる) */
4577
+ const dtEl=e.target.closest('[data-dt]');
4578
+ if(dtEl){
4579
+ const f=dtEl.dataset.dt, host=dtEl.closest('.chip')||document.getElementById('tplChips');
4580
+ const d=host.querySelector('[data-dt="'+f+'"][type=date]'), t=host.querySelector('[data-dt="'+f+'"][type=time]');
4581
+ mutate(()=>{ st.project[f]=joinDateTime(d&&d.value, t&&t.value); });
4582
+ return;
4583
+ }
4584
+ const el=e.target.closest('[data-proj]'); if(!el) return; const f=el.dataset.proj; let v = el.type==='checkbox'? el.checked : (el.type==='number'? Number(el.value) : el.value);
4585
+ mutate(()=>{
4586
+ /* S14: フリークエンシーの回数は 0/空を無視する (0回で入稿されるのを防ぐ) */
4587
+ if(f==='freqCount' && !(freqNum(v)>0)) return; /* 0/空/不正は無視 (既存の値をそのまま残す) */
4588
+ if(f==='freqCount') v=freqNum(v);
4589
+ st.project[f]=v;
4590
+ if(st.project.freqOn) freqEnsure(st.project); /* 旧プランの project は freq* を持たない */
4591
+ });
4592
+ });
4593
+ /* C5: テキストチップはクリックするとキャレットが先頭に入り、そのまま打つと
4594
+ 既存URLの「前」に挿入されて壊れる。フォーカス時に全選択して打ち直し/貼り付けを置換にする。
4595
+ focus はバブルしないので capture で拾う。部分編集したいときは選択後にクリック or 矢印キーで解除できる。 */
4596
+ (function(){
4597
+ const chipTextInput = el => el && el.tagName==='INPUT' && !el.hasAttribute('type') && !!el.dataset.proj;
4598
+ let selectedOnFocus = null;
4599
+ const chips = document.getElementById('tplChips');
4600
+ chips.addEventListener('focus',e=>{ if(!chipTextInput(e.target)) return; e.target.select(); selectedOnFocus=e.target; }, true);
4601
+ /* クリックで focus した場合、続く mouseup がキャレットを1点に畳んで select() を無効化する。
4602
+ フォーカス取得直後の1回だけ mouseup を止めて全選択を保たせる。
4603
+ 2回目以降のクリック(=部分編集したいとき)は素通しする。 */
4604
+ chips.addEventListener('mouseup',e=>{ if(e.target!==selectedOnFocus) return; e.preventDefault(); selectedOnFocus=null; });
4605
+ chips.addEventListener('blur',e=>{ if(e.target===selectedOnFocus) selectedOnFocus=null; }, true);
4606
+ })();
4115
4607
  document.getElementById('btnSubmitJson').addEventListener('click',exportSubmitJson);
4116
4608
  document.getElementById('btnHelp').addEventListener('click',openHelpModal);
4117
4609
  document.getElementById('btnConsole').addEventListener('click',()=>openConsole('campaigns'));
@@ -4155,11 +4647,26 @@ document.getElementById('btnSeqParam').addEventListener('click',applySequentialP
4155
4647
  /* ---- シート セル選択 (spreadsheet) ---- */
4156
4648
  const sheetHostEl=document.getElementById('sheetHost');
4157
4649
  function cellFromEvent(e){ let cell=e.target.closest('[data-sel]'); if(!cell){ const cc=e.target.closest('.cellcombo'); if(cc) cell=cc.querySelector('[data-sel]'); } if(!cell){ const td=e.target.closest('td'); if(td && !td.classList.contains('rownum')) cell=td.querySelector('[data-sel]'); } return cell; } // td全体を当たり判定に(広告タブに踏襲)
4158
- let pendingDrawer=null, dragMoved=false; // ドラッグ範囲選択中はドロワーを開かない(mouseup時に単一クリックのみ開く)
4650
+ let pendingDrawer=null, dragMoved=false; // ドラッグ範囲選択中もドロワーを開き、選択範囲へ一括適用する
4651
+ let deferredCollapse=null; // 選択範囲内の入力欄クリックで保留した「選択の畳み込み」。ドラッグ開始時に実行する
4159
4652
  sheetHostEl.addEventListener('mousedown',e=>{
4160
4653
  if(e.target.closest('.combodd')) return; // ▼ はドロップダウン専用
4161
4654
  const rn=e.target.closest('td.rownum'); if(rn){ const r=+rn.dataset.rownum; if(isNaN(r)) return; selA={r,c:0}; selB={r,c:tabCols().length-1}; dragging='row'; pendingDrawer=null; applySelection(); return; }
4162
4655
  const cell=cellFromEvent(e); if(!cell) return; const r=+cell.dataset.r, c=+cell.dataset.c;
4656
+ /* 範囲選択中に選択内の入力欄(select/text/number)をクリックしたら、選択を「すぐには」畳まない。
4657
+ そのまま値を確定すると同じ列の選択行すべてへ一括反映される (bulkFillColumn)。
4658
+ ただしドラッグを始めたら畳む (deferredCollapse) ので、選択範囲内から新しい矩形を引き直せる。 */
4659
+ /* S14: kind:'freq' のセルは <label><span>ラベル</span><input/select></label> 構造なので、
4660
+ label も「入力欄クリック」に含める。含めないとラベル文字クリックで選択が畳まれ、
4661
+ 一括入力が黙って1行だけになる。 */
4662
+ if(e.target.closest('input,select,textarea,label')){
4663
+ const rgNow=selRange();
4664
+ if(rgNow && (rgNow.r1>rgNow.r0 || rgNow.c1>rgNow.c0)
4665
+ && r>=rgNow.r0 && r<=rgNow.r1 && c>=rgNow.c0 && c<=rgNow.c1){
4666
+ pendingDrawer=null; dragMoved=false; dragging='cell'; deferredCollapse={r,c}; return;
4667
+ }
4668
+ }
4669
+ deferredCollapse=null;
4163
4670
  pendingDrawer=null; dragMoved=false;
4164
4671
  // S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
4165
4672
  const col=colDefs()[c];
@@ -4169,7 +4676,10 @@ sheetHostEl.addEventListener('mousedown',e=>{
4169
4676
  sheetHostEl.addEventListener('mouseover',e=>{
4170
4677
  if(!dragging) return;
4171
4678
  if(dragging==='row'){ const rn=e.target.closest('td.rownum'); if(rn){ selB={r:+rn.dataset.rownum,c:tabCols().length-1}; applySelection(); } return; }
4172
- const cell=cellFromEvent(e); if(cell){ const r=+cell.dataset.r,c=+cell.dataset.c; if(!selB||selB.r!==r||selB.c!==c){ dragMoved=true; if(pendingDrawer && pendingDrawer.kind!=='ad') pendingDrawer=null; /* 画像/タイトル/説明文(ad)は複数ドラッグ選択でもサイドバーを開く。ターゲティング(tg)は単体クリックのみ */ if(!selA||r!==selA.r||c!==selA.c) document.body.classList.add('noselect'); selB={r,c}; applySelection(); } }
4679
+ const cell=cellFromEvent(e); if(cell){ const r=+cell.dataset.r,c=+cell.dataset.c;
4680
+ /* 入力欄クリックで保留していた畳み込みをここで実行し、そのセルを起点に矩形を引き直す */
4681
+ if(deferredCollapse){ selA={r:deferredCollapse.r, c:deferredCollapse.c}; selB={r:deferredCollapse.r, c:deferredCollapse.c}; deferredCollapse=null; }
4682
+ if(!selB||selB.r!==r||selB.c!==c){ dragMoved=true; /* 画像/タイトル/説明文(ad)もターゲティング(tg)も、複数ドラッグ選択でサイドバーを開き選択範囲へ一括適用する */ if(!selA||r!==selA.r||c!==selA.c) document.body.classList.add('noselect'); selB={r,c}; applySelection(); } }
4173
4683
  });
4174
4684
  sheetHostEl.addEventListener('click',e=>{
4175
4685
  // F-6: 広告グループタブのキャンペーンセル → プラン内CP選択(親付け替え)
@@ -4190,7 +4700,8 @@ document.getElementById('drawerList').addEventListener('click',e=>{ const row=e.
4190
4700
  else if(drawerTarget.type==='agpick'){ if(row.dataset.agpick) moveAdToGroup(drawerTarget.adUid, row.dataset.agpick); }
4191
4701
  else { drawerPick(row.dataset.no); }
4192
4702
  });
4193
- document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); /* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */ else if(!dragMoved) openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
4703
+ /* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */
4704
+ document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
4194
4705
 
4195
4706
  /* ---- コピー / 貼付 ---- */
4196
4707
  document.addEventListener('copy',e=>{