addashboard-cli 0.6.2 → 0.7.0

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>翌日 05:00</b>。<b>時刻は別フィールド <code>startTime</code>(HHmm)として送られる</b>(公式: ディスプレイ広告API v18 CampaignService。startTime を指定するときは startDate が必須)。日付だけを入れた場合は startTime を送らない</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>creative= もパス連結もしない</b>。paramCode は<b>広告行 &gt; 広告セット</b>の実効値)。案件設定の <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>
@@ -587,7 +638,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
587
638
 
588
639
  <details class="sec" open><summary><span class="num">5</span><span class="stitle">未確定事項・未対応</span><span class="chev"></span></summary>
589
640
  <div class="body">
590
- <p class="lead">命名規則は <b>YDA 踏襲で確定</b>(<code>{番号}{i|v}-{見出し}L{本文}-{paramCode}</code> / URL は <code>?ad=&amp;creative=</code>)。以下が残っている。</p>
641
+ <p class="lead">命名規則は <b>YDA 踏襲で確定</b>(<code>{番号}{i|v}-{見出し}L{本文}-{paramCode}</code>)。<b>最終URLは <code>{記事URL}?ad={paramCode}</code> のみ</b>(YDA と違い <code>creative=</code> もパス連結もしない)。<b>paramCode は広告セット行が既定値・広告行が上書き</b>(広告行が空欄なら広告セットの値を継承する)。以下が残っている。</p>
591
642
  <ul>
592
643
  <li>Meta広告フィードバックの所在と最終確認者(タスク確認事項より・未回答)。</li>
593
644
  <li>クリエイティブ検証の単位(YDAの「1キャンペーン=1変数」に相当する実験設計)。</li>
@@ -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,31 @@ 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
+ /* 配信開始日時の既定 = 翌日 05:00 (JST)。当日配信は審査が間に合わず意図しない時間に出るため。
787
+ YDA も Meta も 05:00 が効く: YDA は startDate(yyyyMMdd) + **startTime(HHmm)** の2フィールドに分けて送る
788
+ (公式: ディスプレイ広告API v18 CampaignService。startTime を送るときは startDate が必須)。 */
789
+ const DEFAULT_START_TIME = '05:00';
790
+ const defaultStart = () => { const d=new Date(); d.setDate(d.getDate()+1); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
791
+ const defaultStartDateTime = () => defaultStart()+' '+DEFAULT_START_TIME;
792
+ const defaultStartIso = () => defaultStart()+'T'+DEFAULT_START_TIME+':00+0900';
793
+ /* S12: 日時文字列 "YYYY-MM-DD[ HH:mm[:ss]]" ⇄ {date, time} の相互変換。
794
+ kind:'datetime' チップ (日付ピッカー + 時刻ピッカー) と state の1本文字列を橋渡しする。
795
+ ・秒は input[type=time] の既定表示に合わせて分までに丸める (秒付きの旧データも壊さず読める)
796
+ ・書式が読めない値は date にも time にも入れない (= 空のピッカー) が、state は触らない */
797
+ const splitDateTime = v => {
798
+ const m=String(v==null?'':v).trim().match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/);
799
+ return m ? {date:m[1], time:m[2]||''} : {date:'', time:''};
800
+ };
801
+ /* 日付が空なら時刻だけ残しても意味がないので全体を空にする (= 未指定) */
802
+ const joinDateTime = (date, time) => {
803
+ const d=String(date||'').trim(); if(!d) return '';
804
+ const t=String(time||'').trim(); return t ? d+' '+t : d;
805
+ };
806
+ /* 入稿ボディ用: 日時文字列から日付部分だけを取り出す (YDA の startDate は yyyyMMdd 相当の日付のみ) */
807
+ const dateOnly = v => splitDateTime(v).date;
808
+ /* 入稿ボディ用: 時刻部分を YDA の startTime フォーマット **HHmm** にする。
809
+ 時刻が無ければ '' を返し、呼び出し側で startTime キー自体を出さない (媒体既定のまま)。 */
810
+ const timeHHmm = v => { const t=splitDateTime(v).time; return t ? t.replace(':','') : ''; };
735
811
  /* 引数を左から順に見て「trim して空文字でない最初の値」を返す。優先順位を1行で書くための道具。 */
736
812
  const firstNonEmpty = (...vs) => { for(const v of vs){ const s=String(v==null?'':v).trim(); if(s!=='') return s; } return ''; };
737
813
  /* URLのクエリ値に埋める文字列のエスケープ (媒体非依存のURLユーティリティ)。
@@ -1059,10 +1135,20 @@ PLATFORMS.yda = {
1059
1135
  {key:'goal', label:'目的', kind:'selectv', fallback:'CONVERSION',
1060
1136
  options:[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},
1061
1137
  {v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}]},
1062
- {key:'startDate', label:'配信開始日', kind:'text', width:'10ch', fallback:''},
1138
+ /* S12: カレンダー + 時刻ピッカー。Yahoo Ads API の startDate は日付のみの ISO (YYYY-MM-DD) なので、
1139
+ 時刻は運用メモとして state / 保存JSON にだけ残り、入稿ボディへは日付部分だけを送る (buildPlan.campaign)。 */
1140
+ {key:'startDate', label:'配信開始日時', kind:'datetime', fallback:'',
1141
+ title:'配信開始日時(JST)。日付はカレンダー、時刻は時計から選べます。\n'
1142
+ +'空欄なら翌日 05:00 が既定です。日付は startDate、時刻は startTime(HHmm)として入稿されます。\n'
1143
+ +'日付だけを入れた場合は時刻を送らず、媒体側の既定(0時)から配信されます。'},
1063
1144
  {key:'cta', label:'CTA', kind:'text', width:'7ch'},
1064
1145
  {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
1065
- {key:'attributionDays', label:'計測', kind:'number', width:'3ch', suffix:'日'},
1146
+ /* S14: フリークエンシー(viewableFrequencyCap)の案件既定。
1147
+ チェックONのときだけ 適用/期間/回数 を出し、新規キャンペーン作成時の初期値になる。
1148
+ ※旧「計測」(attributionDays) チップは撤去。入稿計画JSONにも入稿ボディにも一切載らない
1149
+ デッドフィールドだったため(アトリビューション計測期間はYDA APIの入稿対象外)。 */
1150
+ {key:'freq', label:'フリークエンシー', kind:'freq',
1151
+ title:'ONにすると、以後の新規キャンペーンにこの設定が初期値として入ります(キャンペーン行で個別に変更できます)'},
1066
1152
  {key:'displayUrlFormat',label:'表示URL', kind:'text', width:'13ch'},
1067
1153
  {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'},
1068
1154
  /* C3: 案件単位の URLパラメータ ON/OFF。**defaults.project には入れない**
@@ -1100,6 +1186,9 @@ PLATFORMS.yda = {
1100
1186
  未知ラベルは MORE_DETAIL(=「詳しくはこちら」) にフォールバック(実データ既定)。 */
1101
1187
  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
1188
  ctaFallback:'MORE_DETAIL',
1189
+ /* S14: viewableFrequencyCap の enum (正: frontend/src/pages/submission/types.ts の FREQ_*_OPTIONS) */
1190
+ freqLevel:{'広告グループ':'AD_GROUP','キャンペーン':'CAMPAIGN','広告':'AD'},
1191
+ freqUnit:{'週':'WEEK','日':'DAY','月':'MONTH'},
1103
1192
  statusOn:'ACTIVE', statusOff:'PAUSED'
1104
1193
  },
1105
1194
 
@@ -1135,12 +1224,16 @@ PLATFORMS.yda = {
1135
1224
  project:{
1136
1225
  cta:'もっとみる', adType:'レスポンシブ', mainMediaFormat:'画像',
1137
1226
  deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
1138
- attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
1227
+ biddingStrategyDefault:'tCPA', tcpaDefault:8500,
1228
+ /* S14: フリークエンシーの案件既定。OFF が既定(明示的にONにしたときだけ入稿ボディに載る) */
1229
+ freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3,
1139
1230
  budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
1140
1231
  },
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:''}
1232
+ /* C6: adName は空 = 自動生成 (naming.adName)。C1: imagePath は空 = 従来挙動 */
1233
+ ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', imagePath:'', adName:'', desc2:''},
1234
+ /* S14: freq* は「案件設定チップの既定を新規CP作成時にコピーする」ので factories.newCampaign 側で入れる */
1235
+ campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:'',
1236
+ freqMode:'default', freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3}
1144
1237
  },
1145
1238
 
1146
1239
  /* ---- factories: 行の生成 (リテラル生成をコアから撤去) ---- */
@@ -1152,11 +1245,15 @@ PLATFORMS.yda = {
1152
1245
  newCampaign(seed){ const p=st.project, D=PLATFORMS.yda.defaults, F=PLATFORMS.yda.factories;
1153
1246
  return Object.assign({uid:uid('c')}, D.campaign, {
1154
1247
  budget:D.newCampaignBudget, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault,
1248
+ /* S14: 既定は 'default' (案件共通設定に追従)。個別指定したい行だけ「オン」にする。
1249
+ 3値は「オンへ切り替えた瞬間の初期値」として案件既定を持たせておく。 */
1250
+ freqMode:'default', freqOn:false,
1251
+ freqLevel:p.freqLevel||'AD_GROUP', freqUnit:p.freqUnit||'WEEK', freqCount:freqNum(p.freqCount)||3,
1155
1252
  adGroups:[F.newAdGroup(null,0)]
1156
1253
  }, seed||{}); },
1157
1254
  /* E-3/F-2: 既存キャンペーン呼び出し (campaignIdを持つ広告追加専用の器)。isExisting:true = 基本パラメータ編集不可 */
1158
1255
  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:[]}; },
1256
+ isExisting:true, urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; },
1160
1257
  /* F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める */
1161
1258
  existingAdGroup(agId, meta){ const F=PLATFORMS.yda.factories; const m=meta||{};
1162
1259
  return {uid:uid('g'), name:m.name||('AG '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
@@ -1166,52 +1263,27 @@ PLATFORMS.yda = {
1166
1263
  /* ---- naming: クリエイティブ名 / 広告名 / 最終URL / キャンペーン名 ---- */
1167
1264
  naming:{
1168
1265
  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 = 従来挙動)。
1266
+ /* C8: URLパラメータ OFF の案件は paramCode を入力しない (列も隠す) ので、
1267
+ 広告名の末尾 "-{paramCode}" も付けない (末尾が "-" だけ残るのを避ける)。 */
1268
+ adName(ad, group, crName, project){
1269
+ const head = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'');
1270
+ if(project && project.urlParams===false) return head;
1271
+ return head + '-' + (group.paramCode||'');
1272
+ },
1273
+ /* C8: 最終URL = 案件設定の記事URL(base) + paramCode + ?ad=&creative=。
1274
+ 広告ごとのURL指定 (記事slug / 記事URL(上書き)) は廃止し、URLの可変部は paramCode だけ。
1275
+ ・project.urlParams===false なら paramCode も ?ad= / &creative= も付けず base のみ
1276
+ (欠落 undefined は ON = 従来挙動)
1172
1277
  ⚠ base が既にクエリを持つ (utm_source 等) 場合は '?' を2つ並べない = 繋ぎ文字を選ぶ。
1173
1278
  並べると parse 時に ad= が消えて utm_source の値に食われる。
1174
1279
  ⚠ paramCode / adName は自由入力なので urlq() でURL構造を壊す文字だけ落とす。 */
1175
1280
  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 ? '&' : '?';
1281
+ if(project.urlParams===false) return (project.baseArticleUrl||'');
1179
1282
  const code = urlq(group.paramCode||'');
1283
+ const base = (project.baseArticleUrl||'') + code;
1284
+ const sep = base.indexOf('?')>=0 ? '&' : '?';
1180
1285
  return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
1181
1286
  },
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
1287
  /* キャンペーン名 自動採番: {空き番号2桁}_{CR名}_{今日} */
1216
1288
  campaignName(urlNumber, firstAd, P){
1217
1289
  const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
@@ -1244,7 +1316,7 @@ PLATFORMS.yda = {
1244
1316
  const crName=P.naming.crName(ad, P);
1245
1317
  /* C6: 広告名は自由入力。空 = 自動生成 (naming.adName) にフォールバックする。
1246
1318
  ⚠ finalUrl の creative= は adName を使うので、手入力するとURLも変わる (仕様) */
1247
- const autoAdName=P.naming.adName(ad, group, crName);
1319
+ const autoAdName=P.naming.adName(ad, group, crName, p);
1248
1320
  const adName=String(ad.adName||'').trim() || autoAdName;
1249
1321
  const me = refs.media[String(ad.imageNo)];
1250
1322
  const mediaId = me ? me[fmt.mediaKey] : undefined;
@@ -1268,7 +1340,8 @@ PLATFORMS.yda = {
1268
1340
  if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
1269
1341
  else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
1270
1342
  // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
1271
- if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
1343
+ /* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
1344
+ if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
1272
1345
  if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
1273
1346
  // C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
1274
1347
  // ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
@@ -1287,6 +1360,17 @@ PLATFORMS.yda = {
1287
1360
  },
1288
1361
 
1289
1362
  /* ---- S8: 入稿計画JSON (SubmitPlanFile) の3階層ボディ生成 ---- */
1363
+ /* S13: 案件設定の書き出し前ガード。
1364
+ startDate は空欄なら翌日にフォールバックする仕様だが、「読めない書式」まで空と同じ扱いにすると
1365
+ 入力した日付が黙って翌日にすり替わる。GUI (日付/時刻ピッカー) からは不正値は入らないので、
1366
+ ここに来るのは旧保存JSONの読み込みか手編集だけ。その場合は書き出しを止めて気づけるようにする。 */
1367
+ validateProject(p){
1368
+ const s=String((p&&p.startDate)||'').trim();
1369
+ if(s && !dateOnly(s)) return [{code:EC.START_TIME_INVALID,
1370
+ msg:'案件設定「配信開始日時」の書式が不正です(例 2026-08-01 / 2026-08-01 10:30): '+s}];
1371
+ return [];
1372
+ },
1373
+
1290
1374
  buildPlan:{
1291
1375
  campaign(c, ctx){
1292
1376
  const P=PLATFORMS.yda, p=ctx.project;
@@ -1296,11 +1380,28 @@ PLATFORMS.yda = {
1296
1380
  name:c.name,
1297
1381
  goal:p.goal||'CONVERSION',
1298
1382
  status:P.mappers.status(p.deliveryStatus, P), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
1299
- startDate:p.startDate||today(),
1383
+ /* S12/I-YDA-TIME: Yahoo Ads API は startDate(yyyyMMdd 相当) と startTime(HHmm) の2フィールド。
1384
+ 日付と時刻を分けて送る (公式: startTime を指定するときは startDate が必須)。 */
1385
+ startDate:dateOnly(p.startDate)||defaultStart(), /* 既定は翌日 */
1300
1386
  budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
1301
1387
  biddingStrategy,
1302
1388
  adGroups:[]
1303
1389
  };
1390
+ /* 開始時刻 (HHmm)。案件設定が時刻を持たないときは既定 05:00 を使う。
1391
+ 空欄になるのは「日付だけを明示的に入れた」ケースなので、そのときは startTime を送らない。 */
1392
+ const stTime = p.startDate ? timeHHmm(p.startDate) : timeHHmm(defaultStartDateTime());
1393
+ if(stTime) cp.startTime=stTime;
1394
+ /* S14: フリークエンシー (viewableFrequencyCap)。OFF / 回数未入力なら **キー自体を出さない**
1395
+ (backend の campaignSchema では全て optional。付けない = 媒体既定のまま) */
1396
+ /* S14: 'default' は案件共通設定を引き、'off' は付けない。小数はここまでに落ちている
1397
+ (backend の vImps は int 制約が無く dry-run も素通りするため GUI 側で担保する) */
1398
+ const fe=freqEffective(c);
1399
+ if(fe){
1400
+ cp.useFrequencyCap=true;
1401
+ cp.frequencyLevel=fe.level;
1402
+ cp.frequencyTimeUnit=fe.unit;
1403
+ cp.vImps=fe.count;
1404
+ }
1304
1405
  if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
1305
1406
  else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
1306
1407
  const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
@@ -1584,6 +1685,18 @@ function cpDefaults(project, mode){ const p=project||st.project; return {budget:
1584
1685
  /* S7: 新規/既存キャンペーンの生成も pf().factories へ委譲 */
1585
1686
  function makeNewCampaign(seed){ return pf().factories.newCampaign(seed); }
1586
1687
  function makeExistingCampaign(cpId, name){ return pf().factories.existingCampaign(cpId, name); }
1688
+ /* ---- C8: 記事slug / 記事URL(上書き) 廃止に伴う片方向マイグレーション ----
1689
+ 旧プランが持つ広告単位のURL指定は捨てる。新モデルの最終URLは
1690
+ 「案件設定の記事URL + paramCode」だけで決まり、広告ごとの可変部は無い。
1691
+ (ここで base+slug の完全URLに固定すると、後から案件設定の記事URLを変えても
1692
+ その行だけ旧baseのまま残る、というサイレントな不整合になる) */
1693
+ function migrateArticleSlug(campaigns){
1694
+ (campaigns||[]).forEach(c=>(c.adGroups||[]).forEach(g=>(g.ads||[]).forEach(a=>{
1695
+ if(!a) return;
1696
+ delete a.articleSlug; delete a.articleUrl;
1697
+ })));
1698
+ return campaigns;
1699
+ }
1587
1700
  function initialState(){
1588
1701
  const refs=buildRefs();
1589
1702
  // 共通設定(project=案件名/主体者/アカウント等)はアカウントJSON由来。サンプルプランには campaigns と accountId のみ。
@@ -1591,13 +1704,19 @@ function initialState(){
1591
1704
  let campaigns=[];
1592
1705
  planMismatchMsg='';
1593
1706
  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 を合わせてください。';
1707
+ /* アカウントIDとサンプルプランのaccountIdが異なる場合はエラー(不一致プランは読み込まない)
1708
+ S13: accountId 欠落も「不一致」として扱う fail-safe。旧実装は truthy 判定で短絡していたため、
1709
+ accountId の無いプラン (= 誰のものか分からないプラン) がどのアカウントでも読み込まれていた。 */
1710
+ const planAcc=String(samplePlan.accountId||''), curAcc=String(project.accountId||'');
1711
+ if(planAcc!==curAcc){
1712
+ planMismatchMsg = planAcc
1713
+ ? '⚠ サンプルプランのアカウントID('+planAcc+')が現在のアカウント('+curAcc+(project.accountName?' '+project.accountName:'')+')と一致しません。プランは読み込みません。正しいアカウントに切り替えるか、サンプルプランの accountId を合わせてください。'
1714
+ : '⚠ サンプルプランに accountId がありません。どのアカウントのプランか判別できないため読み込みません。サンプルプランに accountId を追加してください。';
1597
1715
  if(typeof toast==='function') toast(planMismatchMsg);
1598
1716
  } else {
1599
1717
  campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
1600
1718
  campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
1719
+ migrateArticleSlug(campaigns); // C8: 旧 articleSlug / articleUrl を除去
1601
1720
  }
1602
1721
  }
1603
1722
  return {schemaVersion:SCHEMA_VERSION, meta:defaultMeta(), project, refs, campaigns, ui:{expanded:{}}};
@@ -1702,13 +1821,42 @@ function renderTplChips(){
1702
1821
  const ph = typeof f.placeholder==='function' ? f.placeholder(p) : f.placeholder;
1703
1822
  return (ph? ' placeholder="'+esc(ph)+'"' : '') + (f.title? ' title="'+esc(f.title)+'"' : '');
1704
1823
  };
1705
- const txt=(f,v,w,d)=>'<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'"'+hint(d)+'>';
1824
+ /* C5: 記述子に title が無いテキストチップは、値そのものを title にする。
1825
+ 幅が狭くて末尾が切れていてもホバーで全文を確認できる(記事URL・表示URL 等)。
1826
+ 記述子側の title が有るときは hint(d) が出すので二重に付けない。 */
1827
+ const txt=(f,v,w,d)=>{
1828
+ const full = (d && d.title) ? '' : (String(v||'') ? ' title="'+esc(String(v))+'"' : '');
1829
+ return '<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'"'+hint(d)+full+'>';
1830
+ };
1706
1831
  const num=(f,v,w,d)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'"'+hint(d)+'>';
1707
1832
  const sel=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option'+(o===v?' selected':'')+'>'+esc(o)+'</option>').join('')+'</select>';
1708
1833
  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
1834
  /* C1: kind:'toggle' = チェックボックス。値は真偽で持ち、**未定義は fallback**(欠落=従来挙動)。
1710
1835
  `p[key]||fallback` では false が fallback(true) に化けるので、toggle だけは別扱いにする。 */
1711
1836
  const tgl=(f,v,d)=>'<input class="chipin" type="checkbox" data-proj="'+f+'"'+(v?' checked':'')+hint(d)+'>';
1837
+ /* S12: kind:'datetime' = <input type="date"> + <input type="time"> の2分割。
1838
+ state には従来どおり 1本の文字列で持つ ("YYYY-MM-DD HH:mm"、時刻が空なら "YYYY-MM-DD")。
1839
+ data-proj は **日付側だけ** に付ける(時刻側は data-projtime)。
1840
+ - 既存の [data-proj="startDate"] を掴むコード/テストがそのまま日付入力に当たる
1841
+ - 汎用の change ハンドラが日付だけで上書きしてしまう事故も防げる(data-dt で先に分岐する)
1842
+ ネイティブ input は不正文字列を弾いて空を返すので、書式異常は state に入らない。 */
1843
+ const dt=(f,v,d)=>{
1844
+ const p=splitDateTime(v);
1845
+ /* S13: 日付が空のうちは時刻を disabled にする。state は1本の文字列で「時刻のみ」を表現できず、
1846
+ joinDateTime が空へ潰して再描画が入力を消すため、打てるのに消える見た目になっていた。 */
1847
+ const noDate=!p.date;
1848
+ return '<input class="chipin" type="date" data-proj="'+f+'" data-dt="'+f+'" value="'+esc(p.date)+'"'+hint(d)+'>'
1849
+ + '<input class="chipin" type="time" data-projtime="'+f+'" data-dt="'+f+'" value="'+esc(p.time)+'"'
1850
+ + (noDate?' disabled':'')
1851
+ + ' title="'+(noDate?'先に日付を選んでください(時刻だけの指定はできません)':'時刻(任意)。空欄なら日付のみの指定になります')+'">';
1852
+ };
1853
+ /* S14: kind:'freq' = チェックでONにしたときだけ 適用/期間/回数 を出す案件既定。
1854
+ キャンペーン行と同じ freqSectionsHtml を使い、data-proj で汎用チップハンドラに乗せる。 */
1855
+ const freq=(d)=>{
1856
+ const on=!!p.freqOn;
1857
+ return '<input class="chipin" type="checkbox" data-proj="freqOn"'+(on?' checked':'')+hint(d)+'>'
1858
+ + (on? freqSectionsHtml(p, f=>'data-proj="'+f+'"') : '<span class="freqoff">未設定</span>');
1859
+ };
1712
1860
  const chip=(label,ctrl)=>'<span class="chip">'+esc(label)+' '+ctrl+'</span>';
1713
1861
  // E-1: 案件名 / アカウントID / 主体者 / タイプ チップは撤去。
1714
1862
  // - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
@@ -1719,7 +1867,9 @@ function renderTplChips(){
1719
1867
  const v = f.kind==='toggle'
1720
1868
  ? (p[f.key]===undefined ? (f.fallback!==undefined?f.fallback:false) : !!p[f.key])
1721
1869
  : ((f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key]);
1722
- const ctrl = f.kind==='toggle' ? tgl(f.key, v, f)
1870
+ const ctrl = f.kind==='freq' ? freq(f)
1871
+ : f.kind==='toggle' ? tgl(f.key, v, f)
1872
+ : f.kind==='datetime' ? dt(f.key, v, f)
1723
1873
  : f.kind==='selectv' ? selv(f.key, v, f.options)
1724
1874
  : f.kind==='select' ? sel(f.key, v, f.options)
1725
1875
  : f.kind==='number' ? num(f.key, v, f.width, f)
@@ -1822,6 +1972,23 @@ function renderCell(col, item, ri, ci, ctx){
1822
1972
  return '<td><span class="robadge'+(col.kind==='mono'?' mono':'')+'"'+(col.roTitle?(' title="'+esc(col.roTitle)+'"'):'')+'>'+esc(val)+'</span></td>';
1823
1973
  }
1824
1974
 
1975
+ /* S14: kind:'freq' = セル内に「大きめの入力ボックス」を直接置く。
1976
+ チェックOFF は 1行 (未設定) / ON のときだけ 適用・期間・回数 の3セクションを展開する。
1977
+ data-sel は外側のボックスに付ける (コピー&ペースト・範囲選択は col.get/col.set 経由で往復する)。
1978
+ 内側のコントロールは data-cu + data-freqf を持ち、専用の change ハンドラが拾う。 */
1979
+ if(col.kind==='freq'){
1980
+ const m=freqMode(row);
1981
+ const at=f=>'data-'+ui.uAttr+'="'+row.uid+'" data-freqf="'+f+'"';
1982
+ const mode='<select class="freqin freqmode" '+at('freqMode')+'>'
1983
+ + FREQ_MODES.map(o=>'<option value="'+o.v+'"'+(o.v===m?' selected':'')+'>'+esc(o.t)+'</option>').join('')
1984
+ + '</select>';
1985
+ const rest = m==='on' ? freqSectionsHtml(row, at)
1986
+ : m==='default' ? '<span class="freqinherit" title="案件共通設定に追従します(案件設定チップで変更)">'+esc(freqInheritText())+'</span>'
1987
+ : '';
1988
+ return '<td class="freqtd"><div class="freqbox'+(m==='on'?' on':'')+'" '+sel+' title="'+esc(freqCellText(row)||'オフ(フリークエンシーを付けない)')+'">'
1989
+ + mode + rest + '</div></td>';
1990
+ }
1991
+
1825
1992
  if(col.kind==='select'){
1826
1993
  /* '' は「(既定)」。emptyNoSelected は「(既定)に selected を付けない」現行DOMの踏襲 */
1827
1994
  const opts=col.options.map(o=> o===''
@@ -1849,6 +2016,29 @@ function renderCell(col, item, ri, ci, ctx){
1849
2016
  return '<td class="tgtd"><div class="tgpick'+(shown?'':' tgempty')+'" '+sel+' title="'+esc(shown||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
1850
2017
  }
1851
2018
 
2019
+ /* S15: kind:'datetime' = セル内を <input type=date> + <input type=time> の2分割にする。
2020
+ state は従来どおり1本の文字列 ("YYYY-MM-DD HH:mm" / 時刻が空なら "YYYY-MM-DD") なので、
2021
+ col.get/col.set は不要でコピペ・一括入力・連番は既存経路のまま動く。
2022
+ ・data-sel は外側の .dtcell に付ける (範囲選択・貼付の対象は従来どおりセル単位)
2023
+ ・内側は data-<uAttr> + data-dtf(列名) + data-dtpart(date|time) を持ち、専用 change ハンドラが拾う
2024
+ ・focusRestoreG が再描画後にフォーカスを戻せるよう data-<fAttr> も一意に振る (時刻側は末尾 '#t')
2025
+ ・日付が空のうちは時刻を disabled にする (joinDateTime が時刻だけの値を空へ潰すため。チップと同じ) */
2026
+ if(col.kind==='datetime'){
2027
+ const p=splitDateTime(val), noDate=!p.date;
2028
+ const ph=col.placeholder?col.placeholder(ctx,row):'';
2029
+ const bad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
2030
+ const tip=(col.title||'')+(ph?('\n空欄なら「'+ph+'」が使われます'):'');
2031
+ const inp=(type,v,part,extra)=>'<input class="dtin" type="'+type+'" '
2032
+ +'data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+(part==='time'?'#t':'')+'" '
2033
+ +'data-dtf="'+col.field+'" data-dtpart="'+part+'" value="'+esc(v)+'"'+(extra||'')+'>';
2034
+ return '<td class="dttd"><div class="dtcell'+(bad?' err':'')+'" '+sel
2035
+ +(tip?(' title="'+esc(tip)+'"'):'')+'>'
2036
+ + inp('date', p.date, 'date')
2037
+ + inp('time', p.time, 'time', (noDate?' disabled':'')
2038
+ +' title="'+(noDate?'先に日付を選んでください(時刻だけの指定はできません)':'時刻(任意)。空欄なら日付のみの指定になります')+'"')
2039
+ + '</div></td>';
2040
+ }
2041
+
1852
2042
  /* text / mono / number */
1853
2043
  const rowBad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
1854
2044
  const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'')+(rowBad?' err':'');
@@ -1880,7 +2070,6 @@ function renderTree(){
1880
2070
  html+='<span class="metae"><span class="ratio">'+esc(ratio)+'</span>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c));
1881
2071
  // 予算/入札は編集可(新規扱い)のCPのみ表示 (既存呼び出しCPは広告追加専用で予算等は無視)
1882
2072
  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
2073
  if(cerr) html+='<span class="pill danger">要修正</span>';
1885
2074
  html+='</span>';
1886
2075
  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 +2224,100 @@ function refDel(tableKey,no){
2035
2224
  ・derive : kind:'derived' のセル内容 (resolveAd 由来の読取専用セル)
2036
2225
  ・tgKey : pf().targeting のキー
2037
2226
  ・sequenceable: 連番採番の対象列 / notSubmitted: 入稿JSONに載らない列 (書き出し時の警告対象) */
2227
+ /* ---- S14: フリークエンシー (viewableFrequencyCap) の共通部品 ----
2228
+ キャンペーン行の列 (kind:'freq') と 案件設定チップ (kind:'freq') の両方が使う。
2229
+ 値の持ち方は「4フィールドを行/案件にフラットに持つ」+「列としては1本の文字列に畳む」。
2230
+ 畳んだ文字列を get/set フックに載せることで、コピー&ペースト・一括入力が他の列と同じ経路で動く。 */
2231
+ const FREQ_FIELDS=['freqOn','freqLevel','freqUnit','freqCount'];
2232
+ /* enum値 → 日本語ラベル / 日本語ラベル (または enum値そのもの) → enum値 */
2233
+ function freqEnumLabel(kind, v){ const m=(pf().enums||{})[kind]||{}; const k=Object.keys(m).find(x=>m[x]===v); return k||''; }
2234
+ function freqEnumValue(kind, label, fb){
2235
+ const m=(pf().enums||{})[kind]||{}, s=String(label||'').trim();
2236
+ if(m[s]) return m[s];
2237
+ if(Object.keys(m).some(x=>m[x]===s)) return s; // "AD_GROUP" のような enum 値の直接指定も許す
2238
+ return fb;
2239
+ }
2240
+ function freqOptions(kind, cur){
2241
+ const m=(pf().enums||{})[kind]||{};
2242
+ return Object.keys(m).map(l=>'<option value="'+esc(m[l])+'"'+(m[l]===cur?' selected':'')+'>'+esc(l)+'</option>').join('');
2243
+ }
2244
+ /* 回数の正規化。**小数を必ず落とす**のが要点:
2245
+ backend の campaignSchema は vImps: z.number().optional() で int 制約が無く、dry-run も素通りするため、
2246
+ 2.5 のような値は本実行で初めて Yahoo API に弾かれる。GUI 側で整数化してしまう。
2247
+ "2.5"→2 / "3回"→3 / ""・0・負数→0 (呼び出し側が既定へフォールバックする) */
2248
+ function freqNum(v){
2249
+ const n=Math.floor(Number(String(v==null?'':v).replace(/[^0-9.]/g,'')));
2250
+ return n>0?n:0;
2251
+ }
2252
+ /* ONにする時の欠損補完。旧プラン (サンプルプラン.json 含む) のキャンペーン行は freq* を持たないので、
2253
+ ここで案件設定チップの既定 → 定数の順に埋める。埋めないと「ONにしたのに入稿ボディに載らない」になる。 */
2254
+ function freqEnsure(row){
2255
+ const p=st.project||{};
2256
+ if(!row.freqLevel) row.freqLevel=p.freqLevel||'AD_GROUP';
2257
+ if(!row.freqUnit) row.freqUnit =p.freqUnit ||'WEEK';
2258
+ if(!(freqNum(row.freqCount)>0)) row.freqCount=freqNum(p.freqCount)||3;
2259
+ else row.freqCount=freqNum(row.freqCount);
2260
+ }
2261
+ /* ---- モード (3状態) ----
2262
+ 'default' = 案件共通設定に追従 (既定) / 'on' = この行で個別指定 / 'off' = 付けない
2263
+ 旧データ (freqMode 無し) は freqOn:true のときだけ 'on'、それ以外は既定の 'default' に寄せる。 */
2264
+ const FREQ_MODES=[{v:'default',t:'既定値'},{v:'on',t:'オン'},{v:'off',t:'オフ'}];
2265
+ function freqMode(row){
2266
+ if(!row) return 'default';
2267
+ if(row.freqMode==='on'||row.freqMode==='off'||row.freqMode==='default') return row.freqMode;
2268
+ return row.freqOn===true?'on':'default';
2269
+ }
2270
+ /* 実際に入稿へ出す値。'default' は案件共通設定を引く。付けないときは null */
2271
+ function freqEffective(row){
2272
+ const m=freqMode(row), p=st.project||{};
2273
+ if(m==='off') return null;
2274
+ const src = m==='on' ? row : (p.freqOn ? p : null);
2275
+ if(!src) return null;
2276
+ const n=freqNum(src.freqCount)||3;
2277
+ return {level:src.freqLevel||'AD_GROUP', unit:src.freqUnit||'WEEK', count:n};
2278
+ }
2279
+ /* 表示・コピー用の1行テキスト。'off'=空文字 / 'default'=「既定値」 / 'on'=「3回/週/広告グループ」 */
2280
+ function freqCellText(row){
2281
+ const m=freqMode(row);
2282
+ if(m==='off') return '';
2283
+ if(m==='default') return '既定値';
2284
+ const n=freqNum(row.freqCount)||3;
2285
+ return n+'回/'+freqEnumLabel('freqUnit', row.freqUnit||'WEEK')+'/'+freqEnumLabel('freqLevel', row.freqLevel||'AD_GROUP');
2286
+ }
2287
+ /* 貼付/一括入力で入ってくる文字列をモード+3値へ戻す。欠けた要素は現在値を残す (完全上書きしない)。 */
2288
+ function freqCellApply(row, val){
2289
+ const s=String(val==null?'':val).trim();
2290
+ if(!s || /^(未設定|なし|off|オフ|-|—)$/i.test(s)){ row.freqMode='off'; row.freqOn=false; return; }
2291
+ if(/^(既定値|規定値|既定|default)$/i.test(s)){ row.freqMode='default'; row.freqOn=false; return; }
2292
+ const parts=s.split(/[\//]/).map(x=>x.trim());
2293
+ const n=freqNum(parts[0]);
2294
+ row.freqMode='on'; row.freqOn=true;
2295
+ if(n>0) row.freqCount=n;
2296
+ if(parts[1]) row.freqUnit =freqEnumValue('freqUnit', parts[1], row.freqUnit ||'WEEK');
2297
+ if(parts[2]) row.freqLevel=freqEnumValue('freqLevel', parts[2], row.freqLevel||'AD_GROUP');
2298
+ freqEnsure(row);
2299
+ }
2300
+ /* 回数の選択肢 (1〜10回)。実運用の範囲に絞る */
2301
+ function freqCountOptions(cur){
2302
+ const c=freqNum(cur)||3; let h='';
2303
+ for(let i=1;i<=10;i++) h+='<option value="'+i+'"'+(i===c?' selected':'')+'>'+i+'回</option>';
2304
+ return h;
2305
+ }
2306
+ /* ON のときに出す3つのプルダウン (適用 / 期間 / 回数)。**ラベルは出さない**(全部ワンライン)。
2307
+ attrs(field) が data-* を返す (キャンペーン行 = data-cu+data-freqf / 案件チップ = data-proj)。 */
2308
+ function freqSectionsHtml(o, attrs){
2309
+ const lv=o.freqLevel||'AD_GROUP', un=o.freqUnit||'WEEK';
2310
+ return '<select class="freqin" title="適用レベル" '+attrs('freqLevel')+'>'+freqOptions('freqLevel', lv)+'</select>'
2311
+ + '<select class="freqin" title="期間単位" ' +attrs('freqUnit') +'>'+freqOptions('freqUnit', un)+'</select>'
2312
+ + '<select class="freqin freqnum" title="上限回数" '+attrs('freqCount')+'>'+freqCountOptions(o.freqCount)+'</select>';
2313
+ }
2314
+ /* 'default' のときにセルへ薄く出す、案件共通設定の内容 */
2315
+ function freqInheritText(){
2316
+ const p=st.project||{};
2317
+ if(!p.freqOn) return '共通設定: なし';
2318
+ return freqEnumLabel('freqLevel', p.freqLevel||'AD_GROUP')+'/'+freqEnumLabel('freqUnit', p.freqUnit||'WEEK')+'/'+(freqNum(p.freqCount)||3)+'回';
2319
+ }
2320
+
2038
2321
  const COLS = {
2039
2322
  ads:[
2040
2323
  {field:'format', label:'形式', kind:'select', options:['Card','SQ'], width:'56px',
@@ -2056,22 +2339,16 @@ const COLS = {
2056
2339
  ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
2057
2340
  {field:'paramCode', label:'paramCode(グループ)', kind:'mono', width:'120px',
2058
2341
  scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
2059
- title:'グループ共通(変更で同グループ全広告に反映)',
2342
+ title:'グループ共通(変更で同グループ全広告に反映)。最終URLは 記事URL + paramCode',
2343
+ hidden:p=>urlParamsOff(p),
2060
2344
  roStyle:'input', roTitle:'既存広告グループのため編集不可',
2061
2345
  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'); }},
2346
+ /* C8: 記事slug / 記事URL(上書き) 列は廃止。URLの可変部は paramCode だけになったので、
2347
+ 最終リンク先URL は **読み取り専用の導出セル** (貼り戻し先が無い)。 */
2071
2348
  {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); }},
2349
+ alwaysReadonly:true, roStyle:'input',
2350
+ roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
2351
+ get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
2075
2352
  /* C5: 参照表(refs.titles / refs.descriptions)の本文を直接編集する列。
2076
2353
  番号未入力のまま本文を書くと空き番号を自動採番して参照表に登録する。 */
2077
2354
  makeRefTextCol({field:'titleText', label:'タイトル本文', table:'titles', noField:'titleNo', width:'180px'}),
@@ -2093,8 +2370,8 @@ const COLS = {
2093
2370
  {field:'name', label:'広告グループ名', kind:'text', width:'150px', required:true,
2094
2371
  readonlyOnExisting:true, roTitle:'既存広告グループのため編集不可'},
2095
2372
  {field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
2096
- sequenceable:true, readonlyOnExisting:true,
2097
- title:'URL生成用のパラメータ(グループ紐づけには不使用)',
2373
+ sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
2374
+ title:'URL生成用のパラメータ(グループ紐づけには不使用)。最終URLは 記事URL + paramCode',
2098
2375
  onSet:it=>autoResolveGroupIds(it.g,it.c)},
2099
2376
  {field:'bid', label:'入札', kind:'text', width:'56px', readonlyOnExisting:true, roEmptyDash:true},
2100
2377
  {field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
@@ -2133,7 +2410,13 @@ const COLS = {
2133
2410
  hideOnExisting:true, emptyNoSelected:true},
2134
2411
  {field:'tcpa', label:'tCPA', kind:'number', width:'70px',
2135
2412
  placeholder:()=>String(st.project.tcpaDefault), coerce:numOrNull, hideOnExisting:true},
2136
- {field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
2413
+ /* S14: フリークエンシー (viewableFrequencyCap)。YDA API はキャンペーン単位の設定なのでこの行に置く。
2414
+ 値は行に freqOn / freqLevel / freqUnit / freqCount の4フィールドで持ち、
2415
+ 列としては get/set フックで1本の文字列に畳む → コピー&ペーストがそのまま往復する。
2416
+ 文字列形式: OFF は空文字 / ON は "3回/週/広告グループ" (回数/期間/適用レベル) */
2417
+ {field:'freqCap', label:'フリークエンシー', kind:'freq', hideOnExisting:true,
2418
+ get:row=>freqCellText(row),
2419
+ set:(row,val)=>freqCellApply(row,val)},
2137
2420
  {field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
2138
2421
  ]
2139
2422
  };
@@ -2234,14 +2517,21 @@ const META_COLS = {
2234
2517
  width:'6ch', sequenceable:true, errCodes:[EC.DESC_UNRESOLVED, EC.DESC_TOO_LONG],
2235
2518
  ghostKey:'desc', ddTitle:'メインテキスト一覧から選択', ghostTitle:r=>r.descText,
2236
2519
  ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
2237
- {field:'paramCode', label:'paramCode(広告セット)', kind:'mono', width:'120px',
2238
- scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
2239
- title:'広告セット共通(変更で同広告セット全広告に反映)',
2240
- roStyle:'input', roTitle:'既存広告セットのため編集不可'},
2241
- {field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
2520
+ /* I-150: 広告ごとに個別設定できる。空欄なら広告セット行の値を継承 (薄字で継承元を表示)。
2521
+ ⚠ scope:'parentGroup' を付けない。付けると広告セット行の値を書き換えてしまう。 */
2522
+ {field:'paramCode', label:'paramCode', kind:'mono', width:'120px',
2523
+ sequenceable:true, errCodes:[EC.PARAM_CODE_EMPTY],
2524
+ title:'空欄なら広告セットの paramCode を継承。入力するとこの広告だけ上書きする。最終URLは 記事URL?ad={paramCode}',
2525
+ hidden:p=>urlParamsOff(p),
2526
+ placeholder:ctx=>(ctx.r?ctx.r.groupParamCode:'')},
2527
+ /* C8: 記事slug / 記事URL(上書き) 列は廃止。最終URL = 記事URL + ?ad={paramCode} の導出セル */
2528
+ {field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
2529
+ alwaysReadonly:true, roStyle:'input',
2530
+ roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
2531
+ get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
2242
2532
  /* C6: 広告名は自由入力 (空 = 自動生成)。YDA と同じ規約 */
2243
2533
  {field:'adName', label:'広告名', kind:'mono', width:'170px',
2244
- title:'空欄なら自動生成({番号}{i|v}-{見出し番号}L{本文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
2534
+ title:'空欄なら自動生成({番号}{i|v}-{見出し番号}L{本文番号}-{paramCode})。Meta の最終リンク先URLは 記事URL?ad={paramCode} だけで決まるので、広告名を手入力してもURLは変わりません',
2245
2535
  placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
2246
2536
  {field:'adGroupId', label:'CP / AdSet ID', kind:'derived',
2247
2537
  derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AS '+ctx.r.adGroupId):'新規')+'</span>'}
@@ -2251,8 +2541,8 @@ const META_COLS = {
2251
2541
  {field:'name', label:'広告セット名', kind:'text', width:'150px', required:true,
2252
2542
  readonlyOnExisting:true, roTitle:'既存広告セットのため編集不可'},
2253
2543
  {field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
2254
- sequenceable:true, readonlyOnExisting:true,
2255
- title:'URL生成用のパラメータ(広告セット紐づけには不使用)'},
2544
+ sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
2545
+ title:'URL生成用のパラメータ(広告セット紐づけには不使用)。この広告セット配下の広告の既定値で、広告行に入力があればそちらが優先される。最終URLは 記事URL?ad={paramCode}'},
2256
2546
  /* budgetLevel='adGroup' → 予算は広告セット行が持つ (CBO は使わない) */
2257
2547
  {field:'dailyBudget', label:'日予算(円)', kind:'number', width:'84px', required:true,
2258
2548
  coerce:numOrNull, readonlyOnExisting:true, roEmptyDash:true, errCodes:[EC.BUDGET_MISSING],
@@ -2275,15 +2565,15 @@ const META_COLS = {
2275
2565
  {field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
2276
2566
  {field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
2277
2567
  /* 配信日時: 空欄 = 案件設定チップの値を継承(buildPlan 側で解決)。placeholder に継承先を出す */
2278
- {field:'startDate', label:'配信開始日時', kind:'text', width:'17ch',
2568
+ {field:'startDate', label:'配信開始日時', kind:'datetime', width:'17ch',
2279
2569
  readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
2280
2570
  errCodes:[EC.START_TIME_INVALID, EC.END_BEFORE_START],
2281
2571
  placeholder:()=>{ const v=String((st.project&&st.project.startDate)||'').trim();
2282
- return v!=='' ? v : '案件設定 / 今日'; },
2572
+ return v!=='' ? v : '案件設定 / 翌日05:00'; },
2283
2573
  title:'この広告セットだけの配信開始日時(JST)。空欄なら案件設定「配信開始日時」を継承し、'
2284
- +'それも空欄なら当日 00:00。日付のみなら 00:00 開始(例 2026-08-01 10:30)。'
2574
+ +'それも空欄なら翌日 05:00。日付のみなら 00:00 開始(例 2026-08-01 10:30)。'
2285
2575
  +'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
2286
- {field:'endDate', label:'配信終了日時', kind:'text', width:'17ch',
2576
+ {field:'endDate', label:'配信終了日時', kind:'datetime', width:'17ch',
2287
2577
  readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
2288
2578
  errCodes:[EC.END_TIME_INVALID, EC.END_BEFORE_START],
2289
2579
  placeholder:()=>{ const v=String((st.project&&st.project.endDate)||'').trim();
@@ -2305,7 +2595,6 @@ const META_COLS = {
2305
2595
  /* 公式7値 + GUIセンチネル NONE (送信時 [] に変換) */
2306
2596
  options:['NONE','HOUSING','EMPLOYMENT','CREDIT','ISSUES_ELECTIONS_POLITICS',
2307
2597
  'ONLINE_GAMBLING_AND_GAMING','FINANCIAL_PRODUCTS_SERVICES']},
2308
- {field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
2309
2598
  {field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
2310
2599
  ]
2311
2600
  };
@@ -2381,13 +2670,11 @@ PLATFORMS.meta = {
2381
2670
  {key:'cta', label:'CTA', kind:'select',
2382
2671
  options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
2383
2672
  '予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
2384
- {key:'startDate', label:'配信開始日時', kind:'text', width:'17ch', fallback:'',
2385
- placeholder:'YYYY-MM-DD [HH:mm]',
2673
+ {key:'startDate', label:'配信開始日時', kind:'datetime', fallback:'',
2386
2674
  title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
2387
2675
  +'日付のみなら 00:00 開始。時刻を入れるとその時刻ちょうどから配信(例 2026-08-01 10:30)。'
2388
- +'空欄なら当日 00:00 から。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
2389
- {key:'endDate', label:'配信終了日時', kind:'text', width:'17ch', fallback:'',
2390
- placeholder:'空欄=無期限',
2676
+ +'空欄なら翌日 05:00 から。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
2677
+ {key:'endDate', label:'配信終了日時', kind:'datetime', fallback:'',
2391
2678
  title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
2392
2679
  +'日付のみならその日の 23:59:59 まで。時刻を入れるとその時刻で終了(例 2026-08-31 18:00)。'
2393
2680
  +'空欄なら終了日時を送らず無期限配信。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
@@ -2415,7 +2702,7 @@ PLATFORMS.meta = {
2415
2702
  cta:{'詳しくはこちら':'LEARN_MORE','購入する':'SHOP_NOW','登録する':'SIGN_UP',
2416
2703
  'ダウンロード':'DOWNLOAD','お問い合わせ':'CONTACT_US','今すぐ申し込む':'APPLY_NOW',
2417
2704
  '予約する':'BOOK_NOW','見積もりを取得':'GET_QUOTE','購読する':'SUBSCRIBE',
2418
- '動画を見る':'WATCH_MORE','ボタンなし':'NO_BUTTON'},
2705
+ '動画を見る':'WATCH_MORE','詳細を見る':'SEE_DETAILS','ボタンなし':'NO_BUTTON'},
2419
2706
  ctaFallback:'LEARN_MORE',
2420
2707
  statusOn:'ACTIVE', statusOff:'PAUSED',
2421
2708
  /* 特別広告カテゴリでターゲティングが強制されるルール (公式) */
@@ -2520,7 +2807,7 @@ PLATFORMS.meta = {
2520
2807
  startTime(dateStr){
2521
2808
  const r=PLATFORMS.meta.mappers.normDateTime(dateStr);
2522
2809
  if(r && typeof r==='object') return String(r.input); /* 不正はそのまま返し検証側で弾く */
2523
- return r || (today()+'T00:00:00+0900');
2810
+ return r || defaultStartIso();
2524
2811
  },
2525
2812
  /* 終了日は「その日の終わりまで配信」の解釈。日付のみの入力を JST 23:59:59 に寄せる。
2526
2813
  空欄なら null を返し、呼び出し側で endTime キー自体を出さない (無期限配信)。 */
@@ -2540,12 +2827,13 @@ PLATFORMS.meta = {
2540
2827
  deliveryStatus:'オフ', startDate:'', endDate:''
2541
2828
  },
2542
2829
  /* C6: adName は空 = 自動生成 (naming.adName) */
2543
- ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adName:''},
2830
+ /* I-150: paramCode は空 = 広告セットの値を継承 (広告ごとに入力すると上書き) */
2831
+ ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', adName:'', paramCode:''},
2544
2832
  adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
2545
2833
  ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
2546
2834
  tg_placement:'自動配置', startDate:'', endDate:'', deliveryStatus:''},
2547
2835
  campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
2548
- objective:'OUTCOME_SALES', specialAdCategory:'NONE', variable:'', urlNumber:''}
2836
+ objective:'OUTCOME_SALES', specialAdCategory:'NONE', urlNumber:''}
2549
2837
  },
2550
2838
  factories:{
2551
2839
  newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.meta.defaults.ad, seed||{}); },
@@ -2556,25 +2844,38 @@ PLATFORMS.meta = {
2556
2844
  return Object.assign({uid:uid('c')}, P.defaults.campaign,
2557
2845
  {adGroups:[P.factories.newAdGroup(null,0)]}, seed||{}); },
2558
2846
  existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
2559
- isExisting:true, objective:'', specialAdCategory:'NONE', variable:'', urlNumber:'', adGroups:[]}; },
2847
+ isExisting:true, objective:'', specialAdCategory:'NONE', urlNumber:'', adGroups:[]}; },
2560
2848
  existingAdGroup(agId, meta){ const P=PLATFORMS.meta, m=meta||{};
2561
2849
  return Object.assign({uid:uid('g')}, P.defaults.adGroup,
2562
2850
  {name:m.name||('AS '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
2563
2851
  isExisting:true, ads:[P.factories.newAd()]}); }
2564
2852
  },
2565
2853
 
2854
+ /* I-150: Meta の paramCode は「広告セットが既定値・広告行が上書き」の2層。
2855
+ 広告行の paramCode が空 (=未入力) のときだけ広告セットの値を継承する。
2856
+ ⚠ 広告名・最終URL・検証はすべてこの実効値を見る。group.paramCode を直接読まない。 */
2857
+ paramCodeOf(ad, group){
2858
+ const own=String((ad&&ad.paramCode)||'').trim();
2859
+ return own || String((group&&group.paramCode)||'');
2860
+ },
2861
+
2566
2862
  /* 命名規則は YDA 踏襲 (形式記号は mediaFormats.crSuffix = i/v) */
2567
2863
  naming:{
2568
2864
  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 でエスケープする。 */
2865
+ /* C8: URLパラメータ OFF なら末尾 "-{paramCode}" を付けない (YDA と同じ) */
2866
+ adName(ad, group, crName, project){
2867
+ const head = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'');
2868
+ if(project && project.urlParams===false) return head;
2869
+ return head + '-' + PLATFORMS.meta.paramCodeOf(ad, group);
2870
+ },
2871
+ /* C8: Meta の最終URL = {記事URL}?ad={paramCode} のみ (YDA と違い creative= もパス連結もしない)。
2872
+ urlParams===false なら記事URLのみ。base が既にクエリを持つときは '&' で繋ぐ。
2873
+ I-150: paramCode は広告行 > 広告セット の実効値。 */
2572
2874
  finalUrl(ad, group, adName, project){
2573
- const base=(project.baseArticleUrl||'') + (ad.articleSlug||'');
2875
+ const base = (project.baseArticleUrl||'');
2574
2876
  if(project.urlParams===false) return base;
2575
2877
  const sep = base.indexOf('?')>=0 ? '&' : '?';
2576
- const code = urlq(group.paramCode||'');
2577
- return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
2878
+ return base + sep + 'ad=' + urlq(PLATFORMS.meta.paramCodeOf(ad, group));
2578
2879
  },
2579
2880
  campaignName(urlNumber, firstAd, P){
2580
2881
  const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
@@ -2602,8 +2903,10 @@ PLATFORMS.meta = {
2602
2903
  const P=PLATFORMS.meta, p=ctx.project, refs=ctx.refs;
2603
2904
  const fmt=P.fmtOf(ad);
2604
2905
  const crName=P.naming.crName(ad, P);
2906
+ /* I-150: 実効 paramCode (広告行 > 広告セット)。表示・検証はこれを見る */
2907
+ const paramCode=P.paramCodeOf(ad, group).trim();
2605
2908
  /* C6: 広告名は自由入力。空 = 自動生成にフォールバック */
2606
- const autoAdName=P.naming.adName(ad, group, crName);
2909
+ const autoAdName=P.naming.adName(ad, group, crName, p);
2607
2910
  const adName=String(ad.adName||'').trim() || autoAdName;
2608
2911
  const me = refs.media[String(ad.imageNo)];
2609
2912
  const mediaId = me ? me[fmt.mediaKey] : undefined;
@@ -2626,10 +2929,12 @@ PLATFORMS.meta = {
2626
2929
  else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
2627
2930
  if(descText!==undefined && glen(descText)>D.err) pushErr('本文'+D.err+'字超', EC.DESC_TOO_LONG);
2628
2931
  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);
2932
+ /* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない
2933
+ I-150: 広告行・広告セットの **どちらにも無い** ときだけエラー */
2934
+ if(p.urlParams!==false && paramCode==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
2630
2935
  if(pendingImage) pushWarn(ad.format==='VIDEO'?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING);
2631
2936
  const aid=String(group.adGroupId||'').trim();
2632
- return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
2937
+ return {crName, adName, autoAdName, paramCode, groupParamCode:String(group.paramCode||''), campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
2633
2938
  mediaId:mediaId||'', thumbHash:thumbHash||'', pendingImage,
2634
2939
  titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText,
2635
2940
  finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
@@ -2650,7 +2955,7 @@ PLATFORMS.meta = {
2650
2955
  if(sBad) errCodes.push(EC.START_TIME_INVALID);
2651
2956
  if(eBad) errCodes.push(EC.END_TIME_INVALID);
2652
2957
  if(!sBad && !eBad){
2653
- const s=M.epochOf(firstNonEmpty(sRaw, p.startDate) || today());
2958
+ const s=M.epochOf(firstNonEmpty(sRaw, p.startDate) || defaultStart());
2654
2959
  const e=M.epochOf(firstNonEmpty(eRaw, p.endDate), {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
2655
2960
  if(!isNaN(s) && !isNaN(e) && e<s) errCodes.push(EC.END_BEFORE_START);
2656
2961
  }
@@ -2671,7 +2976,7 @@ PLATFORMS.meta = {
2671
2976
  if(psBad) errs.push({code:EC.START_TIME_INVALID, msg:'案件設定「配信開始日時」の書式が不正です(例 2026-08-01 10:30): '+ps});
2672
2977
  if(peBad) errs.push({code:EC.END_TIME_INVALID, msg:'案件設定「配信終了日時」の書式が不正です(例 2026-08-31 23:59): '+pe});
2673
2978
  if(!psBad && !peBad){
2674
- const s=M.epochOf(ps||today()), e=M.epochOf(pe, {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
2979
+ const s=M.epochOf(ps||defaultStart()), e=M.epochOf(pe, {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
2675
2980
  if(!isNaN(s) && !isNaN(e) && e<s) errs.push({code:EC.END_BEFORE_START, msg:'案件設定の配信終了日時が配信開始日時より前です'});
2676
2981
  }
2677
2982
  /* 広告セット行の配信日時 (日予算・入札の未入力は従来どおり行除外で扱い、ここでは見ない) */
@@ -2722,7 +3027,7 @@ PLATFORMS.meta = {
2722
3027
  billingEvent:p.billingEvent||'IMPRESSIONS',
2723
3028
  bidStrategy:p.bidStrategy||'LOWEST_COST_WITHOUT_CAP',
2724
3029
  dailyBudget:Number(g.dailyBudget)||0, /* JPY はそのまま (currency_offset=1) */
2725
- startTime:P.mappers.startTime(startSrc), /* 常に送る (空なら today())。JST 明示 ISO8601 */
3030
+ startTime:P.mappers.startTime(startSrc), /* 常に送る (空なら翌日05:00)。JST 明示 ISO8601 */
2726
3031
  /* 特別カテゴリ時は 18-65 固定・性別指定不可 (公式制約) */
2727
3032
  ageMin: constrained?K.forcedAgeMin:(Number(g.ageMin)||18),
2728
3033
  /* 年齢上限は空欄 = 上限なし → ageMax キー自体を出さない (age_max 省略 = Meta 側で上限なし)。
@@ -2830,6 +3135,7 @@ PLATFORMS.meta = {
2830
3135
  ' - 保存先: .claude/skills/ad-ops/サンプルプラン_meta.json',
2831
3136
  ' - フォーマット: {"schemaVersion":'+SCHEMA_VERSION+', "platform":"meta", "accountId":"act_<対象アカウントID>", "campaigns":[ ... ]}',
2832
3137
  ' - campaigns には キャンペーン → 広告セット(paramCode / dailyBudget / 年齢 / 性別 / 配置) → 広告(形式 IMAGE|VIDEO / クリエイティブ番号 / 見出し番号 / 本文番号) の構成を入れてください。',
3138
+ ' 広告セットの paramCode は配下の広告の既定値です。広告ごとに変えたいときだけ広告側に paramCode を書いてください(空欄なら継承)。',
2833
3139
  '3. 書き出したら、プランナーを開き直す(またはリロードして媒体を Meta に切り替える)と内容が自動で表示されます。'].join('\n');
2834
3140
  },
2835
3141
  submitHelp(ctx){
@@ -2862,7 +3168,18 @@ PLATFORMS.meta = {
2862
3168
  /* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
2863
3169
  function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
2864
3170
  /* 列記述子配列。引数省略時は現在のシートタブ */
2865
- function colDefs(tab){ const S=pf().sheets; return S[normTab(tab===undefined?sheetTab:tab)] || S.ads; }
3171
+ /* C8: 列記述子の hidden(project) が true の列は **描画も列index採番もしない**。
3172
+ colIndex / compatColsFor / getVar / setVar / コピペ / 連番 はすべて colDefs を基準に
3173
+ 列番号を決めるので、ここで落としておけば列番号のズレが起きない
3174
+ (逆に renderSheetGeneric 側だけで隠すと data-c と setVar の対象がズレる)。 */
3175
+ function colDefs(tab){
3176
+ const S=pf().sheets, list=S[normTab(tab===undefined?sheetTab:tab)] || S.ads;
3177
+ const p=(typeof st!=='undefined' && st) ? st.project : null;
3178
+ return list.filter(c=>!(typeof c.hidden==='function' && c.hidden(p||{})));
3179
+ }
3180
+ /* C8: URLパラメータ OFF の案件では paramCode 列を表に出さない。
3181
+ OFF = 「最終URL は 案件設定の記事URL のみ」なので、paramCode の入力自体が不要になる。 */
3182
+ function urlParamsOff(project){ return !!project && project.urlParams===false; }
2866
3183
  /* 列番号リテラル撤去用: field → 列index */
2867
3184
  function colIndex(field, tab){ return colDefs(tab).findIndex(c=>c.field===field); }
2868
3185
  /* ドロワーで拾った番号を流し込める列index群 (旧 compat=[1] / [2,3] の置換) */
@@ -2919,6 +3236,29 @@ function setVar(item,c,val,tab,ctx){
2919
3236
  else row[col.field] = v;
2920
3237
  if(col.onSet) col.onSet(item,row);
2921
3238
  }
3239
+ /* ---- 範囲選択中の1セル編集を、同じ列の選択行すべてへ一括反映する ----
3240
+ select / text / number など「▼ドロワーを持たない列」の一括入力口。
3241
+ 同種の列 = 同じ field(=同じ列)に限る。異なる列へ値を流し込むのは意味が壊れるのでしない。
3242
+ 編集したセルが選択範囲の外なら 0 を返し、従来どおり1セルだけの編集になる。 */
3243
+ function bulkFillColumn(tab, field, val, editedUid){
3244
+ if(activeView!=='sheet' || planMode!=='sheet') return 0;
3245
+ if(normTab(sheetTab)!==tab) return 0;
3246
+ const rg=selRange(); if(!rg) return 0;
3247
+ const ci=colIndex(field, tab); if(ci<0 || ci<rg.c0 || ci>rg.c1) return 0;
3248
+ const vis=flatVisible();
3249
+ const uidOf=it=>(it.kind==='a'?it.a.uid:(it.kind==='g'?it.g.uid:it.c.uid));
3250
+ let hit=false;
3251
+ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it && uidOf(it)===editedUid){ hit=true; break; } }
3252
+ if(!hit) return 0;
3253
+ let n=0;
3254
+ 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++; }
3255
+ return n;
3256
+ }
3257
+ function bulkFillToast(tab, field, n){
3258
+ if(!n) return;
3259
+ const col=colDefs(tab).find(c=>c.field===field);
3260
+ toast(((col&&col.label)||field)+' を'+(n+1)+'セルに一括入力');
3261
+ }
2922
3262
  /* 列 field 指定で1セルを書く (change ハンドラを列記述子経由に載せるための入口)。
2923
3263
  列が見つからなければ false を返し、呼び出し側の旧経路にフォールバックさせる。 */
2924
3264
  function setVarByField(item, tab, field, val){
@@ -2939,6 +3279,15 @@ function pasteGrid(txt){
2939
3279
  const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
2940
3280
  const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
2941
3281
  const vis=flatVisible(); const maxc=tabCols().length-1; if(!vis[rg.r0]){ toast('貼付先がありません'); return; }
3282
+ /* 1セルのコピーを範囲へ貼ったら選択範囲全体へ充填する (スプシ同等のタイル展開)。
3283
+ 行を増やさないので既存行の同種セルを一括で埋める用途に限る。 */
3284
+ if(rows.length===1 && rows[0].length===1 && (rg.r1>rg.r0 || rg.c1>rg.c0)){
3285
+ const val=rows[0][0]; let n=0;
3286
+ mutate(()=>{ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue;
3287
+ for(let c=rg.c0;c<=Math.min(rg.c1,maxc);c++){ setVar(it,c,val); n++; } } });
3288
+ clearSel(); toast(n+'セルに貼り付け');
3289
+ return;
3290
+ }
2942
3291
  mutate(()=>{
2943
3292
  if(sheetTab==='ads'){
2944
3293
  // B8: 可視行基準に統一 (グループ境界・絞り込み表示を尊重)。不足分は末尾の可視グループに新規広告を追加。
@@ -2957,7 +3306,7 @@ function pasteGrid(txt){
2957
3306
  function dupSelected(){
2958
3307
  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
3308
  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); }
3309
+ 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); ng.ads.forEach(renumberAdParamCode); autoResolveGroupIds(ng, it.c); }
2961
3310
  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
3311
  else { const idx=it.g.ads.indexOf(it.a); it.g.ads.splice(idx+1,0,cloneAd(it.a,null)); }
2963
3312
  }); }); clearSel(); toast(items.length+'件を複製');
@@ -3124,11 +3473,45 @@ function openTargetingDrawer(gUid, field){
3124
3473
  renderDrawer();
3125
3474
  document.getElementById('refDrawer').classList.add('open');
3126
3475
  }
3476
+ /* ---- ターゲティング列の一括トグル ----
3477
+ ドロワーで押した項目を「選択範囲 ∩ 同種の列」の全セルへ ON/OFF 一括適用する。
3478
+ 同種の判定は列記述子から引く (refpick は同じ refTable / tgpick は同じ tgKey)。
3479
+ 選択が無い・対象列に掛かっていない場合はアンカーの1セルだけを返し、従来の単体トグルと同じ挙動になる。 */
3480
+ function tgBulkTargets(gUid, field){
3481
+ const tab=normTab(sheetTab), cols=colDefs(tab);
3482
+ const ci=cols.findIndex(c=>c.field===field), base=cols[ci];
3483
+ const fg=findGroup(gUid);
3484
+ const anchor=(fg&&ci>=0)?[{item:{kind:'g', g:fg.g, c:fg.c}, ci}]:[];
3485
+ if(!base) return anchor;
3486
+ const compat=[]; cols.forEach((c,i)=>{
3487
+ if(base.kind==='refpick'){ if(c.kind==='refpick' && c.refTable===base.refTable) compat.push(i); }
3488
+ else if(c.kind==='tgpick' && c.tgKey===base.tgKey) compat.push(i);
3489
+ });
3490
+ const rg=selRange(); if(!rg) return anchor;
3491
+ const vis=flatVisible(), out=[];
3492
+ for(let r=rg.r0;r<=rg.r1;r++){
3493
+ const it=vis[r]; if(!it||it.kind!=='g') continue;
3494
+ for(let c=rg.c0;c<=rg.c1;c++){
3495
+ if(compat.indexOf(c)<0) continue;
3496
+ if(rowIsExisting(it, cols[c])) continue; /* F-2: 既存呼び出しAGは編集不可 */
3497
+ out.push({item:it, ci:c});
3498
+ }
3499
+ }
3500
+ return out.length?out:anchor;
3501
+ }
3502
+ function tgListOf(t){ return String(getVar(t.item, t.ci)||'').split(',').map(s=>s.trim()).filter(Boolean); }
3127
3503
  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(', '); });
3504
+ if(!drawerTarget||drawerTarget.type!=='tg') return;
3505
+ const targets=tgBulkTargets(drawerTarget.gUid, drawerTarget.field);
3506
+ if(!targets.length) return;
3507
+ /* 全セルに入っていれば一括解除、そうでなければ一括追加 (ON/OFF 一括トグル) */
3508
+ const allOn=targets.every(t=>tgListOf(t).indexOf(val)>=0);
3509
+ mutate(()=>{ targets.forEach(t=>{
3510
+ const cur=tgListOf(t), i=cur.indexOf(val);
3511
+ if(allOn){ if(i>=0) cur.splice(i,1); } else if(i<0) cur.push(val);
3512
+ setVar(t.item, t.ci, cur.join(', '));
3513
+ }); });
3514
+ if(targets.length>1) toast((allOn?'解除':'追加')+':'+targets.length+'セルに一括適用');
3132
3515
  renderDrawer();
3133
3516
  }
3134
3517
  function closeDrawer(){ document.getElementById('refDrawer').classList.remove('open'); drawerTarget=null; }
@@ -3178,9 +3561,16 @@ function renderDrawer(){
3178
3561
  const cfg=isRef?null:pf().targeting[drawerTarget.field];
3179
3562
  const label=(cfg&&cfg.label)||(gcol&&gcol.label)||'';
3180
3563
  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+'件選択中)'):'(未設定)');
3564
+ /* 一括対象セル全体で ✓ を判定する: 全セルにあれば ✓ / 一部だけなら −(押すと全セルへ揃える) */
3565
+ const targets=tgBulkTargets(drawerTarget.gUid, drawerTarget.field);
3566
+ const lists=targets.map(tgListOf), N=targets.length;
3567
+ const cur=lists[0]||[];
3568
+ const mark=v=>{ const n=lists.reduce((a,l)=>a+(l.indexOf(v)>=0?1:0),0);
3569
+ return n===0?{cls:'', ic:''}:(n===N?{cls:' cur', ic:'✓'}:{cls:' cur', ic:'−'}); };
3570
+ titleEl.textContent=label+' を選択(複数可)'+(N>1?(' / '+N+'セル一括'):'');
3571
+ tgtEl.textContent = N>1
3572
+ ? ('入力先: 選択中の '+N+' セル / '+label+'(押した項目を全セルにON/OFF)')
3573
+ : ('入力先: '+(g&&g.name?g.name:agLabel())+' / '+label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)'));
3184
3574
  let h='';
3185
3575
  if(isRef){
3186
3576
  /* C1: 選択肢は st.refs[col.refTable] (ID→name)。data-tgval には **実ID** を入れる */
@@ -3188,12 +3578,12 @@ function renderDrawer(){
3188
3578
  Object.keys(store).forEach(id=>{
3189
3579
  const nm=refPickName(gcol,id);
3190
3580
  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>';
3581
+ const mk=mark(String(id));
3582
+ 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
3583
  });
3194
3584
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">参照データがありません(アカウントJSONの refs.'+esc(gcol.refTable)+')</div>'; return;
3195
3585
  }
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>'; });
3586
+ 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
3587
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
3198
3588
  }
3199
3589
  /* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
@@ -3245,18 +3635,67 @@ function setField(u,f,val){
3245
3635
  document.addEventListener('change', e=>{
3246
3636
  const t=e.target; if(!t.dataset||!t.dataset.u||!t.dataset.f) return;
3247
3637
  const f=t.dataset.f; const val = t.type==='checkbox'? t.checked : t.value;
3638
+ let n=0;
3248
3639
  mutate(()=>{
3249
3640
  const found=findAd(t.dataset.u); if(!found) return;
3250
3641
  if(!setVarByField({kind:'a', a:found.a, g:found.g, c:found.c}, 'ads', f, val)) setField(t.dataset.u,f,val);
3642
+ n=bulkFillColumn('ads', f, val, t.dataset.u); /* 範囲選択中なら同じ列の選択行すべてへ */
3251
3643
  });
3644
+ bulkFillToast('ads', f, n);
3252
3645
  });
3253
3646
  /* 広告グループ/キャンペーン タブの一覧編集 */
3254
3647
  document.addEventListener('change', e=>{
3255
3648
  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は基本パラメータ編集不可
3649
+ if(t.dataset.dtpart) return; /* S15: 日時セルは下の専用ハンドラが日付+時刻をまとめて確定する */
3650
+ 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;
3651
+ 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); } }
3652
+ n=bulkFillColumn('adGroups', gf, val, t.dataset.gu); }); bulkFillToast('adGroups', gf, n); return; } // F-2: 既存呼び出しAGは基本パラメータ編集不可 (setVar 側の readonlyOnExisting ガードが担保)
3653
+ 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;
3654
+ if(!setVarByField({kind:'c', c:r.c}, 'campaigns', cf, val)){ if(!isExistingCampaign(r.c)) r.c[cf]=(cf==='budget'||cf==='tcpa')?numOrNull(val):val; }
3655
+ n=bulkFillColumn('campaigns', cf, val, t.dataset.cu); }); bulkFillToast('campaigns', cf, n); return; } // F-2: 既存呼び出しCPは基本パラメータ編集不可
3656
+ });
3657
+
3658
+ /* S15: 日時セル(kind:'datetime')のセル内コントロール。
3659
+ 1セルに日付+時刻の2入力があり、汎用ハンドラに素通しすると時刻が落ちた値で上書きされるため
3660
+ ここで両方を読んで1本の文字列に畳み、setVar (= 列記述子の coerce/set) 経由で書き込む。
3661
+ 範囲選択中は同じ列の選択行すべてへ配る (他の列と同じ一括入力の挙動)。 */
3662
+ document.addEventListener('change', e=>{
3663
+ const t=e.target; if(!t.dataset||!t.dataset.dtpart||!t.dataset.dtf) return;
3664
+ const field=t.dataset.dtf, box=t.closest('.dtcell'); if(!box) return;
3665
+ const d=box.querySelector('[data-dtpart="date"]'), tm=box.querySelector('[data-dtpart="time"]');
3666
+ const val=joinDateTime(d&&d.value, tm&&tm.value);
3667
+ const tab = t.dataset.gu ? 'adGroups' : (t.dataset.cu ? 'campaigns' : null);
3668
+ if(!tab) return;
3669
+ let n=0;
3670
+ mutate(()=>{
3671
+ if(tab==='adGroups'){ const r=findGroup(t.dataset.gu); if(!r) return;
3672
+ setVarByField({kind:'g', g:r.g, c:r.c}, 'adGroups', field, val);
3673
+ n=bulkFillColumn('adGroups', field, val, t.dataset.gu); }
3674
+ else { const r=findCp(t.dataset.cu); if(!r) return;
3675
+ setVarByField({kind:'c', c:r.c}, 'campaigns', field, val);
3676
+ n=bulkFillColumn('campaigns', field, val, t.dataset.cu); }
3677
+ });
3678
+ bulkFillToast(tab, field, n);
3679
+ });
3680
+
3681
+ /* S14: フリークエンシー(kind:'freq')のセル内コントロール。
3682
+ 1セルに4フィールドあるため data-cf を持たず (=上の汎用ハンドラは素通しする)、ここで拾う。
3683
+ 既存呼び出しCPは列が hideOnExisting なのでそもそも描画されない。 */
3684
+ document.addEventListener('change', e=>{
3685
+ const t=e.target; if(!t.dataset||!t.dataset.cu||!t.dataset.freqf) return;
3686
+ const f=t.dataset.freqf;
3687
+ const val = t.type==='checkbox' ? t.checked : (f==='freqCount' ? freqNum(t.value) : t.value);
3688
+ let n=0;
3689
+ mutate(()=>{
3690
+ const r=findCp(t.dataset.cu); if(!r || isExistingCampaign(r.c)) return;
3691
+ if(f==='freqCount' && !(val>0)) return; /* 0/空/不正は無視 (既存の値をそのまま残す) */
3692
+ if(f==='freqMode'){ r.c.freqMode=val; r.c.freqOn=(val==='on'); }
3693
+ else r.c[f]=val;
3694
+ if(freqMode(r.c)==='on') freqEnsure(r.c); /* 旧プラン行は freq* を持たないのでここで埋める */
3695
+ /* 範囲選択中は同じ列 (freqCap) の選択行すべてへ。畳んだ文字列を setVar 経由で配る */
3696
+ n=bulkFillColumn('campaigns', 'freqCap', freqCellText(r.c), t.dataset.cu);
3697
+ });
3698
+ bulkFillToast('campaigns', 'freqCap', n);
3260
3699
  });
3261
3700
 
3262
3701
  /* rename via contenteditable (tree) */
@@ -3308,12 +3747,71 @@ function addNAds(u){
3308
3747
  const r=findGroup(u); if(!r) return;
3309
3748
  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
3749
  }
3750
+ /* プラン内の全広告グループ名。複製時の同名回避に使う。 */
3751
+ function allGroupNames(){
3752
+ const s=new Set(); st.campaigns.forEach(c=>c.adGroups.forEach(g=>s.add(String(g.name||''))));
3753
+ return s;
3754
+ }
3755
+ /* 複製した広告グループの名前。名前は引き継ぐが、同名が既にあるときだけ「連番の部分」をずらす。
3756
+ ・末尾に数字がある名前 (AG1 / 広告グループ_03) → その数字を +1 しながら空き番号を探す (ゼロ埋め幅は維持)
3757
+ ・末尾に数字が無い名前 → 末尾に 2,3,… を足して空きを探す
3758
+ 旧実装は無条件に '_copy' を付けていたため、複製を重ねると AG1_copy_copy になっていた。 */
3759
+ function dupGroupName(srcName){
3760
+ const s=String(srcName||'').trim(); if(!s) return '';
3761
+ const used=allGroupNames(); if(!used.has(s)) return s;
3762
+ const m=s.match(/^(.*?)(\d+)$/);
3763
+ const prefix = m ? m[1] : s;
3764
+ const width = m ? m[2].length : 0;
3765
+ let n = m ? parseInt(m[2],10)+1 : 2;
3766
+ for(let i=0;i<1000;i++,n++){
3767
+ const cand = prefix + (width ? String(n).padStart(width,'0') : String(n));
3768
+ if(!used.has(cand)) return cand;
3769
+ }
3770
+ return s;
3771
+ }
3772
+ /* 広告グループの複製はモーダルを介さず即時実行する (中身そのまま複製)。
3773
+ ・名前は引き継ぎ、同名のときだけ連番をずらす (dupGroupName)
3774
+ ・paramCode は dupCampaign と同じ規則で連番自動採番し、ID を解決し直す
3775
+ (コピーのままだと同じ広告グループを指してしまうため)
3776
+ ・adGroupId はクリアして新規扱いにする */
3311
3777
  function dupGroup(u){
3312
3778
  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('複製しました'); }});
3779
+ mutate(()=>{
3780
+ const ng=JSON.parse(JSON.stringify(r.g));
3781
+ ng.uid=uid('g'); ng.adGroupId=''; ng.isExisting=false;
3782
+ ng.name=dupGroupName(r.g.name);
3783
+ ng.paramCode=nextParamCode(r.g.paramCode);
3784
+ /* I-150: 広告行に明示指定された paramCode をそのままコピーすると、同じ最終URLの広告が
3785
+ 量産される。広告セットと同じ規則で振り直す (空欄=継承 の行は空欄のまま)。 */
3786
+ ng.ads=r.g.ads.map(a=>cloneAd(a, null));
3787
+ r.c.adGroups.splice(r.i+1,0,ng);
3788
+ /* 採番はプランへ挿入した後に行う。maxParamNum はプラン全体を走査するので、
3789
+ 先に振ると同じ広告セット内の広告が全部同じ番号になる。 */
3790
+ ng.ads.forEach(renumberAdParamCode);
3791
+ autoResolveGroupIds(ng, r.c);
3792
+ });
3793
+ toast('複製しました');
3794
+ }
3795
+ /* I-150: prefix ごとの連番プール (nextByPrefix) から次の paramCode を採る。
3796
+ 末尾数字が無い / 空欄 (=継承) のときは null を返し、呼び出し側で「触らない」を選ばせる。 */
3797
+ function seqParamCode(code, nextByPrefix){
3798
+ const m=String(code||'').match(/^(.*?)(\d+)\s*$/); if(!m) return null;
3799
+ const prefix=m[1], width=m[2].length;
3800
+ if(nextByPrefix[prefix]==null) nextByPrefix[prefix]=maxParamNum(prefix)+1;
3801
+ return prefix+String(nextByPrefix[prefix]++).padStart(width,'0');
3802
+ }
3803
+ /* I-150: 広告行が個別に持つ paramCode を複製時に振り直す。空欄 (=広告セット継承) は触らない。 */
3804
+ function renumberAdParamCode(ad){
3805
+ if(!ad) return;
3806
+ const own=String(ad.paramCode||'').trim();
3807
+ if(own==='') return;
3808
+ ad.paramCode=nextParamCode(own);
3809
+ }
3810
+ /* paramCode の末尾数字を「プラン内 + 参照表の最大 +1」に振り直す。数字が無い / 空なら元の値のまま。 */
3811
+ function nextParamCode(code){
3812
+ const m=String(code||'').match(/^(.*?)(\d+)\s*$/); if(!m) return code||'';
3813
+ const prefix=m[1], width=m[2].length;
3814
+ return prefix + String(maxParamNum(prefix)+1).padStart(width,'0');
3317
3815
  }
3318
3816
  function editParam(u){
3319
3817
  const r=findGroup(u); if(!r) return; const g=r.g;
@@ -3339,33 +3837,44 @@ function editCampaign(u){
3339
3837
  // E-2: モード/campaignID/戦略メモ フィールドは撤去 (新規/既存はID解決で自動判定)
3340
3838
  openModal({title:'キャンペーン設定', sub:c.name, fields:[
3341
3839
  {key:'name',label:'キャンペーン名',type:'text',value:c.name},
3342
- {key:'variable',label:'検証する変数(1キャンペーン=1変数)',type:'text',value:c.variable},
3343
3840
  {key:'urlNumber',label:'入稿リンク末尾番号(空き: '+suggestUrlNumber()+')',type:'text',value:c.urlNumber},
3344
3841
  {key:'budget',label:'予算/日(空で既定 '+pf().defaults.newCampaignBudget+')',type:'number',value:c.budget??''},
3345
3842
  {key:'biddingStrategy',label:'配信戦略',type:'select',value:c.biddingStrategy||'tCPA',options:[{v:'tCPA',t:'tCPA'},{v:'最大配信',t:'最大配信'}]},
3346
3843
  {key:'tcpa',label:'tCPA(円)',type:'number',value:c.tcpa??''}
3347
3844
  ], extra:'<button class="act" id="genName" style="margin-right:auto">キャンペーン名を自動生成</button>', onMount:(root,getVals,setVal)=>{
3348
3845
  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); }); }});
3846
+ }, 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
3847
  // B6解消: 配信戦略/tCPAはキャンペーン個別値のみを参照 (project既定を焼き付けない)
3351
3848
  }
3352
3849
  function cloneCampaign(c, clearCreative, clearParam){
3353
3850
  const nc=JSON.parse(JSON.stringify(c)); nc.uid=uid('c');
3354
3851
  nc.campaignId=''; nc.isExisting=false; // B7/F-2: 複製はIDを引き継がず必ず新規扱い(編集可)
3355
- nc.adGroups.forEach(g=>{ g.uid=uid('g'); g.adGroupId=''; g.isExisting=false; if(clearParam) g.paramCode=''; g.ads.forEach(a=>{ a.uid=uid('a'); if(clearCreative) pf().creativeFields.forEach(f=>{ a[f]=''; }); }); });
3852
+ /* I-150: 広告行も個別 paramCode を持つので clearParam の対象に含める */
3853
+ nc.adGroups.forEach(g=>{ g.uid=uid('g'); g.adGroupId=''; g.isExisting=false; if(clearParam) g.paramCode=''; g.ads.forEach(a=>{ a.uid=uid('a'); if(clearParam) a.paramCode=''; if(clearCreative) pf().creativeFields.forEach(f=>{ a[f]=''; }); }); });
3356
3854
  return nc;
3357
3855
  }
3358
3856
  /* 指定prefixを持つ paramCode の現在の最大末尾番号 (プラン内 + アカウントJSON refs.groups を走査) */
3359
3857
  function maxParamNum(prefix){
3360
3858
  let mx=0; const scan=code=>{ const m=String(code||'').match(/^(.*?)(\d+)\s*$/); if(m && m[1]===prefix){ const n=parseInt(m[2],10); if(n>mx) mx=n; } };
3361
- st.campaigns.forEach(c=>c.adGroups.forEach(g=>scan(g.paramCode)));
3859
+ /* I-150: Meta は広告行が個別の paramCode を持てるので、広告行も走査対象に含める
3860
+ (含めないと複製時の採番が広告行の番号とぶつかる) */
3861
+ st.campaigns.forEach(c=>c.adGroups.forEach(g=>{ scan(g.paramCode); (g.ads||[]).forEach(a=>scan(a.paramCode)); }));
3362
3862
  Object.keys(st.refs.groups||{}).forEach(scan);
3363
3863
  return mx;
3364
3864
  }
3365
- /* E(スクショFB): 複製/量産は「中身そのまま複製 + paramCode連番自動採番 + campaignId/adGroupIdクリア(新規扱い) + キャンペーン名自動採番」で固定。モーダルは本数入力のみ */
3865
+ /* 複製元のキャンペーン名を引き継ぐ。先頭が「{2桁以上の番号}_」形式のときだけ新しい urlNumber に差し替える。
3866
+ 名前が空のときだけ従来どおり naming.campaignName で自動生成する。
3867
+ (旧実装は常に自動生成で上書きしていたため、手入力したキャンペーン名が複製で消えていた) */
3868
+ function dupCampaignName(srcName, num, firstAd){
3869
+ const s=String(srcName||'').trim();
3870
+ if(!s) return pf().naming.campaignName(num, firstAd, pf());
3871
+ const m=s.match(/^(\d{2,})(_.*)$/);
3872
+ return m ? num+m[2] : s;
3873
+ }
3874
+ /* E(スクショFB): 複製/量産は「中身そのまま複製 + paramCode連番自動採番 + campaignId/adGroupIdクリア(新規扱い) + キャンペーン名は引き継ぎ(先頭番号のみ差し替え)」で固定。モーダルは本数入力のみ */
3366
3875
  function dupCampaign(u){
3367
3876
  const r=findCp(u); if(!r) return; const c=r.c;
3368
- openModal({title:'キャンペーンを複製 / 量産', sub:'中身そのまま複製し、paramCodeは連番で自動採番します(campaignID/広告グループIDはクリアして新規扱い・キャンペーン名は自動採番)。'+c.name, fields:[
3877
+ openModal({title:'キャンペーンを複製 / 量産', sub:'中身そのまま複製し、paramCodeは連番で自動採番します(campaignID/広告グループIDはクリアして新規扱い・キャンペーン名は引き継ぎ、先頭の番号のみ新しい番号に差し替え)。'+c.name, fields:[
3369
3878
  {key:'count',label:'複製する本数',type:'number',value:1}
3370
3879
  ], onOk:v=>{ const raw=parseInt(v.count); if(!Number.isFinite(raw)||raw<1){ toast('本数は1以上を入力してください'); return; } const n=Math.min(50,raw);
3371
3880
  mutate(()=>{
@@ -3373,9 +3882,11 @@ function dupCampaign(u){
3373
3882
  for(let i=0;i<n;i++){
3374
3883
  const nc=cloneCampaign(c, false, false); // 中身そのまま (creative/paramCode保持) + campaignId/adGroupIdクリア
3375
3884
  // paramCode 連番自動採番 (prefixごとに現状の最大+1から連番)
3376
- 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());
3885
+ /* I-150: 広告行が個別に持つ paramCode も同じ連番プールで採番する (空欄=継承 は触らない) */
3886
+ nc.adGroups.forEach(g=>{ const ng=seqParamCode(g.paramCode, nextByPrefix); if(ng!==null){ g.paramCode=ng; autoResolveGroupIds(g,nc); }
3887
+ g.ads.forEach(a=>{ const na=seqParamCode(a.paramCode, nextByPrefix); if(na!==null) a.paramCode=na; }); });
3888
+ // キャンペーン名は複製元を引き継ぐ (先頭の番号のみ新しい urlNumber に差し替え / 空なら自動生成)
3889
+ 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
3890
  st.campaigns.splice(base+1+i,0,nc);
3380
3891
  }
3381
3892
  });
@@ -3651,6 +4162,7 @@ window.applyAccountJson=function(obj){
3651
4162
  && samplePlan.campaigns.length && String(samplePlan.accountId||'')===String(st.project.accountId||'')){
3652
4163
  st.campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
3653
4164
  st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
4165
+ migrateArticleSlug(st.campaigns); // C8: 旧 articleSlug / articleUrl を除去
3654
4166
  planMismatchMsg='';
3655
4167
  if(typeof renderPlanWarn==='function') renderPlanWarn();
3656
4168
  }
@@ -4007,7 +4519,12 @@ function openHelpModal(){
4007
4519
  /* 「サンプルプラン更新」: 現在のプラン(共通設定+キャンペーン)を サンプルプラン.json に書き戻す。
4008
4520
  初回だけ File System Access でファイルを選び、以後は IndexedDB 保持ハンドルで Finder無し更新。 */
4009
4521
  async function updateSamplePlan(){
4010
- const data={schemaVersion:SCHEMA_VERSION, project:st.project, campaigns:st.campaigns};
4522
+ /* S13: 書き出しスキーマは同梱の サンプルプラン*.json と同形 {schemaVersion, platform, accountId, campaigns} に揃える。
4523
+ ・accountId が無いと initialState() のアカウント不一致ガードが素通りし、別アカウントのプランが
4524
+ 無警告で読み込まれる(誤入稿に直結)ので必ず出す
4525
+ ・project は読み戻し側が存在しない(案件設定はアカウントJSON由来で defaultProject() が作る)。
4526
+ 書いても次回起動で必ず捨てられるため出さない = 「保存したのに戻る」の誤解を断つ */
4527
+ const data={schemaVersion:SCHEMA_VERSION, platform:curPf, accountId:st.project.accountId||'', campaigns:st.campaigns};
4011
4528
  const json=JSON.stringify(data,null,1);
4012
4529
  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
4530
  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 +4599,7 @@ function applyLoadedJson(j){
4082
4599
  }
4083
4600
  snapshot();
4084
4601
  st.project=Object.assign(defaultProject(),j.project);
4085
- st.campaigns=j.campaigns;
4602
+ st.campaigns=migrateArticleSlug(j.campaigns); // C8: 旧 articleSlug / articleUrl を除去
4086
4603
  st.ui=j.ui||{expanded:{}};
4087
4604
  if(validRefs(j.refs)){ st.refs=normalizeRefs(j.refs); }
4088
4605
  st.meta=Object.assign(defaultMeta(),j.meta||{});
@@ -4111,7 +4628,39 @@ document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.targe
4111
4628
  /* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
4112
4629
  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
4630
  /* 案件テンプレ: チップ直接編集 */
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; }); });
4631
+ document.getElementById('tplChips').addEventListener('change',e=>{
4632
+ /* S12: kind:'datetime' は日付/時刻の2入力で1つのキーを作るので、[data-proj] の汎用処理より先に分岐する
4633
+ (日付側にも data-proj が付いており、素通しすると時刻が落ちた値で上書きされる) */
4634
+ const dtEl=e.target.closest('[data-dt]');
4635
+ if(dtEl){
4636
+ const f=dtEl.dataset.dt, host=dtEl.closest('.chip')||document.getElementById('tplChips');
4637
+ const d=host.querySelector('[data-dt="'+f+'"][type=date]'), t=host.querySelector('[data-dt="'+f+'"][type=time]');
4638
+ mutate(()=>{ st.project[f]=joinDateTime(d&&d.value, t&&t.value); });
4639
+ return;
4640
+ }
4641
+ 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);
4642
+ mutate(()=>{
4643
+ /* S14: フリークエンシーの回数は 0/空を無視する (0回で入稿されるのを防ぐ) */
4644
+ if(f==='freqCount' && !(freqNum(v)>0)) return; /* 0/空/不正は無視 (既存の値をそのまま残す) */
4645
+ if(f==='freqCount') v=freqNum(v);
4646
+ st.project[f]=v;
4647
+ if(st.project.freqOn) freqEnsure(st.project); /* 旧プランの project は freq* を持たない */
4648
+ });
4649
+ });
4650
+ /* C5: テキストチップはクリックするとキャレットが先頭に入り、そのまま打つと
4651
+ 既存URLの「前」に挿入されて壊れる。フォーカス時に全選択して打ち直し/貼り付けを置換にする。
4652
+ focus はバブルしないので capture で拾う。部分編集したいときは選択後にクリック or 矢印キーで解除できる。 */
4653
+ (function(){
4654
+ const chipTextInput = el => el && el.tagName==='INPUT' && !el.hasAttribute('type') && !!el.dataset.proj;
4655
+ let selectedOnFocus = null;
4656
+ const chips = document.getElementById('tplChips');
4657
+ chips.addEventListener('focus',e=>{ if(!chipTextInput(e.target)) return; e.target.select(); selectedOnFocus=e.target; }, true);
4658
+ /* クリックで focus した場合、続く mouseup がキャレットを1点に畳んで select() を無効化する。
4659
+ フォーカス取得直後の1回だけ mouseup を止めて全選択を保たせる。
4660
+ 2回目以降のクリック(=部分編集したいとき)は素通しする。 */
4661
+ chips.addEventListener('mouseup',e=>{ if(e.target!==selectedOnFocus) return; e.preventDefault(); selectedOnFocus=null; });
4662
+ chips.addEventListener('blur',e=>{ if(e.target===selectedOnFocus) selectedOnFocus=null; }, true);
4663
+ })();
4115
4664
  document.getElementById('btnSubmitJson').addEventListener('click',exportSubmitJson);
4116
4665
  document.getElementById('btnHelp').addEventListener('click',openHelpModal);
4117
4666
  document.getElementById('btnConsole').addEventListener('click',()=>openConsole('campaigns'));
@@ -4155,11 +4704,26 @@ document.getElementById('btnSeqParam').addEventListener('click',applySequentialP
4155
4704
  /* ---- シート セル選択 (spreadsheet) ---- */
4156
4705
  const sheetHostEl=document.getElementById('sheetHost');
4157
4706
  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時に単一クリックのみ開く)
4707
+ let pendingDrawer=null, dragMoved=false; // ドラッグ範囲選択中もドロワーを開き、選択範囲へ一括適用する
4708
+ let deferredCollapse=null; // 選択範囲内の入力欄クリックで保留した「選択の畳み込み」。ドラッグ開始時に実行する
4159
4709
  sheetHostEl.addEventListener('mousedown',e=>{
4160
4710
  if(e.target.closest('.combodd')) return; // ▼ はドロップダウン専用
4161
4711
  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
4712
  const cell=cellFromEvent(e); if(!cell) return; const r=+cell.dataset.r, c=+cell.dataset.c;
4713
+ /* 範囲選択中に選択内の入力欄(select/text/number)をクリックしたら、選択を「すぐには」畳まない。
4714
+ そのまま値を確定すると同じ列の選択行すべてへ一括反映される (bulkFillColumn)。
4715
+ ただしドラッグを始めたら畳む (deferredCollapse) ので、選択範囲内から新しい矩形を引き直せる。 */
4716
+ /* S14: kind:'freq' のセルは <label><span>ラベル</span><input/select></label> 構造なので、
4717
+ label も「入力欄クリック」に含める。含めないとラベル文字クリックで選択が畳まれ、
4718
+ 一括入力が黙って1行だけになる。 */
4719
+ if(e.target.closest('input,select,textarea,label')){
4720
+ const rgNow=selRange();
4721
+ if(rgNow && (rgNow.r1>rgNow.r0 || rgNow.c1>rgNow.c0)
4722
+ && r>=rgNow.r0 && r<=rgNow.r1 && c>=rgNow.c0 && c<=rgNow.c1){
4723
+ pendingDrawer=null; dragMoved=false; dragging='cell'; deferredCollapse={r,c}; return;
4724
+ }
4725
+ }
4726
+ deferredCollapse=null;
4163
4727
  pendingDrawer=null; dragMoved=false;
4164
4728
  // S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
4165
4729
  const col=colDefs()[c];
@@ -4169,7 +4733,10 @@ sheetHostEl.addEventListener('mousedown',e=>{
4169
4733
  sheetHostEl.addEventListener('mouseover',e=>{
4170
4734
  if(!dragging) return;
4171
4735
  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(); } }
4736
+ const cell=cellFromEvent(e); if(cell){ const r=+cell.dataset.r,c=+cell.dataset.c;
4737
+ /* 入力欄クリックで保留していた畳み込みをここで実行し、そのセルを起点に矩形を引き直す */
4738
+ if(deferredCollapse){ selA={r:deferredCollapse.r, c:deferredCollapse.c}; selB={r:deferredCollapse.r, c:deferredCollapse.c}; deferredCollapse=null; }
4739
+ 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
4740
  });
4174
4741
  sheetHostEl.addEventListener('click',e=>{
4175
4742
  // F-6: 広告グループタブのキャンペーンセル → プラン内CP選択(親付け替え)
@@ -4190,7 +4757,8 @@ document.getElementById('drawerList').addEventListener('click',e=>{ const row=e.
4190
4757
  else if(drawerTarget.type==='agpick'){ if(row.dataset.agpick) moveAdToGroup(drawerTarget.adUid, row.dataset.agpick); }
4191
4758
  else { drawerPick(row.dataset.no); }
4192
4759
  });
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'); } });
4760
+ /* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */
4761
+ 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
4762
 
4195
4763
  /* ---- コピー / 貼付 ---- */
4196
4764
  document.addEventListener('copy',e=>{