addashboard-cli 0.6.0 → 0.6.2

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.
@@ -308,6 +308,8 @@
308
308
  </div>
309
309
  <span class="vbadge ok" id="vbadge" title="バリデーション状況">✓ 0件</span>
310
310
  <div class="spacer"></div>
311
+ <!-- C9: 管理画面リンクは consoleLink() が媒体スキーマの consoleUrl から組み立てる -->
312
+ <button class="act ghost" id="btnConsole" title="選択中アカウントの管理画面(キャンペーン一覧)を開く">管理画面 ↗</button>
311
313
  <button class="act ghost" id="btnHelp" title="このプランナーの使い方">❓ 使い方</button>
312
314
  <button class="act ghost" id="btnUndo" title="元に戻す (Ctrl/Cmd+Z)">↶ 戻す</button>
313
315
  <button class="act primary" id="btnSubmitJson">✅ 完成 → 保存して入稿</button>
@@ -457,8 +459,8 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
457
459
  <table class="g"><thead><tr><th>場所</th><th>入力項目</th><th>入稿JSONへの反映</th></tr></thead><tbody>
458
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>
459
461
  <tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 検証変数 / 入稿リンク番号 / 戦略メモ</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
460
- <tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / <b>ターゲティング列</b>(デバイス・性別・年齢・地域・曜日時間帯・オーディエンス・サーチKW)</td><td>name・status・devices/ages/genders/days(+startHour/endHour)。地域・オーディエンス・サーチKWは未反映</td></tr>
461
- <tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 記事slug / 手入稿フラグ</td><td>ads[].adName・mediaIds・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
462
+ <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>
462
464
  </tbody></table>
463
465
  </div></details>
464
466
  <details class="sec" open><summary><span class="num">2</span><span class="stitle">入稿JSON — 構造と入力項目・制約(パラメータ)</span><span class="chev"></span></summary>
@@ -481,12 +483,13 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
481
483
  <tr><td class="mono">devices</td><td>広告グループ行「デバイス」</td><td><code>スマートフォン→SMARTPHONE</code> / <code>PC→DESKTOP</code> / <code>タブレット→TABLET</code>。全てのデバイス/空=全配信(<code>[]</code>)</td></tr>
482
484
  <tr><td class="mono">ages / genders</td><td>広告グループ行「年齢」「性別」</td><td><code>20-24歳→GT_RANGE20_24</code> 等 / <code>男性→ST_MALE</code>・<code>女性→ST_FEMALE</code>・<code>不明→ST_UNKNOWN</code>。空=全配信</td></tr>
483
485
  <tr><td class="mono">days / startHour / endHour</td><td>広告グループ行「曜日・時間帯」</td><td><code>月→MONDAY</code> 等。<code>平日</code>=月〜金、<code>土日祝</code>=土日 に展開。時間帯(午前(6-12時)等)は <code>startHour/endHour</code> に変換(複数選択時は最初の1つ)</td></tr>
484
- <tr><td class="mono">audiences* / placements*</td><td>—</td><td>常に空配列で送信(backendスキーマ必須項目)。<b>「地域」「オーディエンスリスト」「サーチキーワード」列はJSONに反映されない</b>(APIのID解決が必要なため。入稿後に管理画面で設定 or 将来対応)</td></tr>
485
- <tr><td class="mono">ads[].adName</td><td><span class="pill">自動</span></td><td>生成式 <code>{画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode}</code></td></tr>
486
+ <tr><td class="mono">audiences* / placements*</td><td>広告グループ行「オーディエンス(除外)」「配置(除外)」</td><td><b>参照表(refs.audiences / refs.placements)の実IDで送信される</b>(表示は name)。数値IDでない値(旧・日本語ラベルや手入力の名前)は落として書き出し時に警告。<b>JSONに反映されない列は「地域」「サーチキーワード」だけ</b>(入稿後に管理画面で設定)</td></tr>
487
+ <tr><td class="mono">ads[].adName</td><td>広告行「広告名」(空欄なら<span class="pill">自動</span>)</td><td>空欄時の生成式 <code>{画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode}</code>。手入力するとその名前が使われ、<code>finalUrl</code> の <code>creative=</code> も変わる</td></tr>
486
488
  <tr><td class="mono">ads[].mediaIds</td><td><span class="pill">自動</span></td><td>画像番号+形式 → 参照表の mediaId。未解決は入稿対象外</td></tr>
489
+ <tr><td class="mono">ads[].imagePaths</td><td>広告行「画像パス」</td><td><b>参照表に無い画像番号(未登録)のときだけ</b>送る。CLI が入稿前にアップロードして mediaId を解決する。画像番号が解決済みの行に入れても<b>送らない</b>(CLI は追加アップロードするが広告には既存 mediaId が使われるため。差し替えたいときは画像番号を空にする)</td></tr>
487
490
  <tr><td class="mono">ads[].headlines</td><td><span class="pill purple">可変</span></td><td>タイトル番号 → 文言。<b>35字以内</b>(API上のアセット制約はタイトル≤20字・説明文≤90字)</td></tr>
488
491
  <tr><td class="mono">ads[].descriptions</td><td><span class="pill purple">可変</span></td><td>説明文番号 → 文言</td></tr>
489
- <tr><td class="mono">ads[].finalUrl</td><td><span class="pill">自動</span></td><td>生成式 <code>baseURL + slug + "?ad=" + paramCode + "&creative=..."</code>(実データ準拠)</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>
490
493
  </tbody></table>
491
494
  <div class="callout">プランナーの保存JSON(バージョン保存)はこれとは別物で、<code>schemaVersion:2</code> の全state(meta / project / campaigns / ui / refs / submit)を保持する。<b>meta</b> = バージョンID・作成/更新日時・作成者・対象媒体・名称・メモ。旧v1 JSONも読込時に自動マイグレーションされる。</div>
492
495
  </div></details>
@@ -649,13 +652,8 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
649
652
  <!-- ============ アカウントアセットビュー ============ -->
650
653
  <section class="view" id="view-ref">
651
654
  <div class="toolbar2">
652
- <div class="seg" id="refTabs">
653
- <button data-rt="titles" class="active">タイトル</button>
654
- <button data-rt="descriptions">説明文</button>
655
- <button data-rt="media">登録画像</button>
656
- <button data-rt="adGroups">広告グループ</button>
657
- <button data-rt="campaigns">キャンペーン</button>
658
- </div>
655
+ <!-- C1: 参照データタブは pf().refTables から動的生成する (renderChrome)。静的ボタンは持たない -->
656
+ <div class="seg" id="refTabs"></div>
659
657
  <span class="filt">検索 <input id="refSearch" type="text" placeholder="番号・文言・IDで絞り込み" style="width:220px"></span>
660
658
  <div class="spacer" style="flex:1"></div>
661
659
  <span class="chip" id="refCount">—</span>
@@ -734,6 +732,14 @@ const uid = p => p + '_' + (Date.now().toString(36)) + (_uc++).toString(36);
734
732
  const esc = s => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
735
733
  const glen = s => [...String(s==null?'':s)].length;
736
734
  const today = () => { const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
735
+ /* 引数を左から順に見て「trim して空文字でない最初の値」を返す。優先順位を1行で書くための道具。 */
736
+ const firstNonEmpty = (...vs) => { for(const v of vs){ const s=String(v==null?'':v).trim(); if(s!=='') return s; } return ''; };
737
+ /* URLのクエリ値に埋める文字列のエスケープ (媒体非依存のURLユーティリティ)。
738
+ C6 で広告名が自由入力になったため、'&' '#' 空白などを生で連結すると
739
+ creative= の値がそこで切れて別パラメータが生える / リンクが壊れる。
740
+ **URL構造を壊す文字だけ**をパーセントエンコードし、日本語はそのまま残す
741
+ (計測ツール上での可読性を優先。1パスなので %→%25 の二重エンコードは起きない)。 */
742
+ const urlq = s => String(s==null?'':s).replace(/[%&#?=+\s]/g, c=>encodeURIComponent(c));
737
743
  function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove('show'),2200); }
738
744
 
739
745
  /* ---- エラー / 警告の安定コード (媒体非依存の語彙) ----
@@ -744,8 +750,13 @@ const EC = {
744
750
  DESC_UNRESOLVED:'DESC_UNRESOLVED', TITLE_TOO_LONG:'TITLE_TOO_LONG',
745
751
  DESC_TOO_LONG:'DESC_TOO_LONG', PARAM_CODE_EMPTY:'PARAM_CODE_EMPTY',
746
752
  MEDIA_MISSING:'MEDIA_MISSING' /* = pendingImage (warn) */,
753
+ IMAGE_PATH_MISSING:'IMAGE_PATH_MISSING' /* 未登録画像なのにローカル画像パス未入力 (warn) */,
754
+ IMAGE_PATH_IGNORED:'IMAGE_PATH_IGNORED' /* 画像番号が解決済みなのにローカル画像パスも入力 (warn) */,
747
755
  TITLE_LONG_WARN:'TITLE_LONG_WARN', DESC_LONG_WARN:'DESC_LONG_WARN',
748
- BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING'
756
+ BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING',
757
+ /* 配信日時 (媒体が日時列を持つ場合に使う。空欄はエラーにしない = 未指定は上位へフォールバック) */
758
+ START_TIME_INVALID:'START_TIME_INVALID', END_TIME_INVALID:'END_TIME_INVALID',
759
+ END_BEFORE_START:'END_BEFORE_START'
749
760
  };
750
761
 
751
762
  /* ---- refs (VLOOKUP代替) ----
@@ -775,6 +786,43 @@ function renderPlatformTabs(){
775
786
  }
776
787
  /* ---- S10: 媒体で変わる UI 文言 (タイトル / ブランド名 / シートタブ / 参照データタブ) ----
777
788
  媒体語彙 (「広告グループ」「登録画像」…) を静的HTMLに残さず、媒体スキーマから流し込む。 */
789
+ /* ---- C9: 管理画面URL は媒体スキーマの consoleUrl テンプレだけが持つ (手で組み立てない) ----
790
+ {mcc} … YDA の loginAccountId。st.project.consoleMcc → 無ければ accountId にフォールバック
791
+ {accountId} … アカウントID (Meta は act_ 付きのまま)
792
+ {accountIdNum} … act_ を剥いた数値部分 (Meta 用)
793
+ {resource} … campaigns | adGroups | ads (媒体別の語は consoleResourceMap が吸収する) */
794
+ function consoleLink(resource){
795
+ const P=pf(); if(!P||!P.consoleUrl) return '';
796
+ const p=st.project||{};
797
+ const accountId=String(p.accountId||'').trim();
798
+ if(!accountId) return '';
799
+ const mcc=String(p.consoleMcc||'').trim();
800
+ /* ⚠ mcc が要るテンプレ(YDA)で accountId にフォールバックしてはいけない。
801
+ YDA の管理画面は #/{MCCのID}/{アカウントID}/... で、MCC 位置にアカウントIDを入れると
802
+ エラーページ(errors/error404)に飛ぶ。しかも「リンクは開くが404」なので原因が分かりにくい。
803
+ mcc が無いときはリンクを出さず、アカウントJSONの mccId 未設定だと明示する。 */
804
+ if(P.consoleUrl.indexOf('{mcc}')>=0 && !mcc) return '';
805
+ const res=(P.consoleResourceMap&&P.consoleResourceMap[resource])||resource||'campaigns';
806
+ return P.consoleUrl
807
+ .replace('{mcc}', mcc)
808
+ .replace('{accountId}', accountId)
809
+ .replace('{accountIdNum}', accountId.replace(/^act_/,''))
810
+ .replace('{resource}', res);
811
+ }
812
+ window.consoleLink=consoleLink;
813
+ function openConsole(resource){
814
+ const url=consoleLink(resource||'campaigns');
815
+ if(!url){
816
+ const P=pf(), p=st.project||{};
817
+ if(P && P.consoleUrl && P.consoleUrl.indexOf('{mcc}')>=0 && String(p.accountId||'').trim() && !String(p.consoleMcc||'').trim()){
818
+ toast('アカウントJSONに mccId が未設定のため管理画面を開けません(mccId にMCCのアカウントIDを設定してください)');
819
+ } else {
820
+ toast('アカウントを選択してください(上部のアカウントセレクト)');
821
+ }
822
+ return;
823
+ }
824
+ try{ window.open(url,'_blank','noopener'); }catch(e){}
825
+ }
778
826
  /* 中間階層のラベル (YDA=広告グループ / Meta=広告セット)。UI 文言はここから供給する */
779
827
  function agLabel(){ const P=pf(); return (P&&P.levels&&P.levels.adGroup&&P.levels.adGroup.label)||'広告グループ'; }
780
828
  function renderChrome(){
@@ -785,9 +833,17 @@ function renderChrome(){
785
833
  document.querySelectorAll('#sheetTabs button[data-st]').forEach(b=>{
786
834
  const l=stLabel[b.dataset.st]; if(l) b.textContent=l;
787
835
  });
788
- document.querySelectorAll('#refTabs button[data-rt]').forEach(b=>{
789
- const T=P.refTables[b.dataset.rt]; if(T) b.textContent=(T.countLabel||T.label);
790
- });
836
+ /* C1: 参照データタブは refTables 宣言から生成 (参照テーブル追加でボタンも自動で増える)。
837
+ 現在の refTab が新媒体に無ければ先頭へフォールバックする。 */
838
+ const rt=document.getElementById('refTabs');
839
+ if(rt){
840
+ const keys=Object.keys(P.refTables);
841
+ if(keys.indexOf(refTab)<0) refTab=keys[0]||refTab;
842
+ rt.innerHTML=keys.map(k=>{
843
+ const T=P.refTables[k];
844
+ return '<button data-rt="'+esc(k)+'"'+(k===refTab?' class="active"':'')+'>'+esc(T.countLabel||T.label||k)+'</button>';
845
+ }).join('');
846
+ }
791
847
  renderFixedType();
792
848
  }
793
849
  /* 固定タイプチップ (変更不可) は媒体定義 pf().fixedType から描画する。
@@ -906,6 +962,27 @@ const REF_TABLES = {
906
962
  keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
907
963
  columns:[
908
964
  {label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
965
+ ] },
966
+ /* C7: オーディエンスリスト / 配置。ID キー + name の形は campaigns/adGroups と同じ。
967
+ required:false なので validRefs() には影響せず、normalizeRefsFor() が旧JSONに {} を補完する。
968
+ GUI は **実IDを入稿し、表示は name** (refpick 列)。 */
969
+ audiences:{ key:'audiences', label:'オーディエンス', countLabel:'オーディエンス', editable:false, required:false,
970
+ keyKind:'string', keyLabel:'オーディエンスID', keyWidth:'120px',
971
+ emptyMsg:'オーディエンスリストがありません(アカウントJSONの refs.audiences)',
972
+ valueFields:[{k:'name',label:'名'},{k:'kind',label:'種別'}],
973
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
974
+ columns:[
975
+ {label:'オーディエンス名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
976
+ {label:'種別', width:'140px', cls:'mono', cell:(no,v)=>esc(v&&v.kind||'')}
977
+ ] },
978
+ placements:{ key:'placements', label:'配置', countLabel:'配置', editable:false, required:false,
979
+ keyKind:'string', keyLabel:'配置ID', keyWidth:'120px',
980
+ emptyMsg:'配置リストがありません(アカウントJSONの refs.placements)',
981
+ valueFields:[{k:'name',label:'名'},{k:'kind',label:'種別'}],
982
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
983
+ columns:[
984
+ {label:'配置名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
985
+ {label:'種別', width:'140px', cls:'mono', cell:(no,v)=>esc(v&&v.kind||'')}
909
986
  ] }
910
987
  };
911
988
  /* ============================================================
@@ -926,6 +1003,15 @@ PLATFORMS.yda = {
926
1003
  project.accountName=String(obj.name||'');
927
1004
  /* E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持 */
928
1005
  if(obj.principal!=null) project.principal=String(obj.principal);
1006
+ /* C9: 管理画面URLの {mcc} (loginAccountId)。アカウントJSON v2 の mccId が唯一の供給元。
1007
+ 未設定なら consoleLink() が accountId にフォールバックする。
1008
+ ⚠ **キーが無いときは必ず既定へ戻す**。同一媒体内のアカウント切替では
1009
+ applyAccountJson が st.project を作り直さないため、「あるときだけ上書き」にすると
1010
+ 前のアカウントの mcc / URLパラメータ設定が残って別アカウントの画面を開く・
1011
+ 計測パラメータが黙って消える、という事故になる。 */
1012
+ project.consoleMcc = obj.mccId!=null ? String(obj.mccId) : '';
1013
+ /* C3: URLパラメータ ON/OFF の初期値。キー無し = undefined = true (従来挙動) */
1014
+ if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
929
1015
  },
930
1016
  /* AIプロンプトに埋め込む refs スキーマ例 (媒体固有。ここ1箇所だけで定義する) */
931
1017
  schemaLines(opts){
@@ -954,8 +1040,9 @@ PLATFORMS.yda = {
954
1040
  },
955
1041
  budgetLevel:'campaign',
956
1042
  /* 管理画面 (SPAハッシュルート)。{mcc} は YDA の loginAccountId */
1043
+ /* C9: {mcc} は st.project.consoleMcc (アカウントJSON mccId) 由来。ハードコードは持たない */
957
1044
  consoleUrl:'https://ads.yahoo.co.jp/manager/#/{mcc}/{accountId}/list/{resource}',
958
- consoleMcc:'1002385608',
1045
+ consoleResourceMap:{campaigns:'campaigns', adGroups:'adgroups', ads:'ads'},
959
1046
 
960
1047
  /* ---- 形式(Card/SQ) と クリエイティブ名サフィックス・media 値のキーの対応 ---- */
961
1048
  mediaFormats:[
@@ -977,7 +1064,11 @@ PLATFORMS.yda = {
977
1064
  {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
978
1065
  {key:'attributionDays', label:'計測', kind:'number', width:'3ch', suffix:'日'},
979
1066
  {key:'displayUrlFormat',label:'表示URL', kind:'text', width:'13ch'},
980
- {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'}
1067
+ {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'},
1068
+ /* C3: 案件単位の URLパラメータ ON/OFF。**defaults.project には入れない**
1069
+ (undefined = ON のフォールバック規約に一本化し、旧保存JSONと差を出さない) */
1070
+ {key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
1071
+ title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
981
1072
  ],
982
1073
  /* プランツールバーの固定プルダウン (変更不可) */
983
1074
  fixedType:[{label:'広告タイプ',value:'レスポンシブ'},{label:'メイン形式',value:'画像'}],
@@ -1031,7 +1122,9 @@ PLATFORMS.yda = {
1031
1122
  return {days, hours};
1032
1123
  },
1033
1124
  status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
1034
- cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; }
1125
+ cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
1126
+ /* C7: refpick セル (実IDのCSV) → 数値ID配列。落ちた分は ctx.stats.droppedRefIds に計上 */
1127
+ idList(s, ctx){ return refIdList(s, ctx); }
1035
1128
  },
1036
1129
 
1037
1130
  /* ---- 既定値 ---- */
@@ -1045,7 +1138,8 @@ PLATFORMS.yda = {
1045
1138
  attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
1046
1139
  budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
1047
1140
  },
1048
- ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adIdOverride:'', desc2:''},
1141
+ /* C6: adName は空 = 自動生成 (naming.adName)。C4/C1: articleUrl / imagePath は空 = 従来挙動 */
1142
+ ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', articleUrl:'', imagePath:'', adName:'', desc2:''},
1049
1143
  campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, variable:'', urlNumber:''}
1050
1144
  },
1051
1145
 
@@ -1053,7 +1147,7 @@ PLATFORMS.yda = {
1053
1147
  factories:{
1054
1148
  newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.yda.defaults.ad, seed||{}); },
1055
1149
  newAdGroup(c, i){ const F=PLATFORMS.yda.factories;
1056
- return {uid:uid('g'), name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), paramCode:'', isExisting:false, ads:[F.newAd()]}; },
1150
+ return {uid:uid('g'), name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), paramCode:'', isExisting:false, smartTargeting:'オン', ads:[F.newAd()]}; },
1057
1151
  /* E-2: 新規キャンペーン (空のAG1を1つ内包)。isExisting:false = 常に編集可 */
1058
1152
  newCampaign(seed){ const p=st.project, D=PLATFORMS.yda.defaults, F=PLATFORMS.yda.factories;
1059
1153
  return Object.assign({uid:uid('c')}, D.campaign, {
@@ -1073,10 +1167,50 @@ PLATFORMS.yda = {
1073
1167
  naming:{
1074
1168
  crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
1075
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 = 従来挙動)。
1172
+ ⚠ base が既にクエリを持つ (utm_source 等) 場合は '?' を2つ並べない = 繋ぎ文字を選ぶ。
1173
+ 並べると parse 時に ad= が消えて utm_source の値に食われる。
1174
+ ⚠ paramCode / adName は自由入力なので urlq() でURL構造を壊す文字だけ落とす。 */
1076
1175
  finalUrl(ad, group, adName, project){
1077
- return (project.baseArticleUrl||'') + (ad.articleSlug||'')
1078
- + '?ad=' + (group.paramCode||'')
1079
- + '&creative=' + (group.paramCode||'') + '-' + adName;
1176
+ const base = String(ad.articleUrl||'').trim() || ((project.baseArticleUrl||'') + (ad.articleSlug||''));
1177
+ if(project.urlParams===false) return base;
1178
+ const sep = base.indexOf('?')>=0 ? '&' : '?';
1179
+ const code = urlq(group.paramCode||'');
1180
+ return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
1181
+ },
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));
1080
1214
  },
1081
1215
  /* キャンペーン名 自動採番: {空き番号2桁}_{CR名}_{今日} */
1082
1216
  campaignName(urlNumber, firstAd, P){
@@ -1085,11 +1219,12 @@ PLATFORMS.yda = {
1085
1219
  }
1086
1220
  },
1087
1221
  /* 「クリエイティブをクリア」対象フィールド */
1088
- creativeFields:['imageNo','titleNo','descNo'],
1089
- /* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象) */
1090
- notSubmittedFields:['tg_region','tg_audience','tg_searchKw'],
1222
+ creativeFields:['imageNo','titleNo','descNo','imagePath'],
1223
+ /* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)。
1224
+ C7: オーディエンス/配置は実ID入稿に対応したので **未反映列から外した** */
1225
+ notSubmittedFields:['tg_region','tg_searchKw'],
1091
1226
  labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode',
1092
- tgDroppedWarn:'地域/オーディエンス/サーチKW'},
1227
+ tgDroppedWarn:'地域/サーチKW'},
1093
1228
 
1094
1229
  /* ---- tree ビューの媒体固有バッジ (DOM構造は renderTree 側と一致させる) ---- */
1095
1230
  tree:{
@@ -1107,7 +1242,10 @@ PLATFORMS.yda = {
1107
1242
  const P=PLATFORMS.yda, p=ctx.project, refs=ctx.refs;
1108
1243
  const fmt=P.fmtOf(ad);
1109
1244
  const crName=P.naming.crName(ad, P);
1110
- const adName=P.naming.adName(ad, group, crName);
1245
+ /* C6: 広告名は自由入力。空 = 自動生成 (naming.adName) にフォールバックする。
1246
+ ⚠ finalUrl の creative= は adName を使うので、手入力するとURLも変わる (仕様) */
1247
+ const autoAdName=P.naming.adName(ad, group, crName);
1248
+ const adName=String(ad.adName||'').trim() || autoAdName;
1111
1249
  const me = refs.media[String(ad.imageNo)];
1112
1250
  const mediaId = me ? me[fmt.mediaKey] : undefined;
1113
1251
  const titleText = refs.titles[ad.titleNo];
@@ -1132,9 +1270,18 @@ PLATFORMS.yda = {
1132
1270
  // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
1133
1271
  if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
1134
1272
  if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
1273
+ // C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
1274
+ // ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
1275
+ const imagePaths=String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
1276
+ if(pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
1277
+ /* 逆に mediaID が解決済みの広告に画像パスを足しても、その画像は広告には使われない
1278
+ (CLI はアップロードした mediaId を末尾に **追記** し、backend は mediaIds[0] しか見ない)。
1279
+ 黙って古い画像で入稿されるのが最悪なので、書き出しでは落とし、ここで警告する。
1280
+ 差し替えたいときは画像番号を空にする (= 未登録画像として新規アップロードさせる)。 */
1281
+ if(!pendingImage && imgNoStr!=='' && imagePaths.length>0) pushWarn('画像パス無視(画像番号が解決済み)', EC.IMAGE_PATH_IGNORED);
1135
1282
  // F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
1136
1283
  const aid=String(group.adGroupId||'').trim();
1137
- return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
1284
+ return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
1138
1285
  mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
1139
1286
  descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
1140
1287
  },
@@ -1165,13 +1312,15 @@ PLATFORMS.yda = {
1165
1312
  const ag={
1166
1313
  name:g.name,
1167
1314
  status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
1168
- smartTargeting:false, // GUI未対応 (将来ターゲティング列に追加)
1315
+ /* C8: 空欄・旧データ (undefined) はすべて true。明示的に「オフ」を選んだときだけ false */
1316
+ smartTargeting: g.smartTargeting!=='オフ',
1169
1317
  devices:P.mappers.devices(g.tg_device, P),
1170
1318
  ages:P.mappers.ages(g.tg_age, P),
1171
1319
  genders:P.mappers.genders(g.tg_gender, P),
1172
1320
  days:dh.days,
1173
- placementsInclude:[], placementsExclude:[],
1174
- audiencesInclude:[], audiencesExclude:[],
1321
+ /* C7: refpick 列は実IDを保持している。数値IDのみを送り、旧・日本語ラベルは落として警告に計上する */
1322
+ placementsInclude:P.mappers.idList(g.tg_placement, ctx), placementsExclude:P.mappers.idList(g.tg_placementEx, ctx),
1323
+ audiencesInclude:P.mappers.idList(g.tg_audience, ctx), audiencesExclude:P.mappers.idList(g.tg_audienceEx, ctx),
1175
1324
  ads:[]
1176
1325
  };
1177
1326
  if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
@@ -1192,9 +1341,15 @@ PLATFORMS.yda = {
1192
1341
  finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
1193
1342
  };
1194
1343
  if(r.pendingImage){
1195
- // 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する (imagePaths は agent/CLI が補完)
1196
- ad.mediaIds=[]; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; ad.imagePaths=[];
1344
+ // 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
1345
+ // C6-1: 画像パス列 (カンマ区切り) を imagePaths に展開する。空なら [] のまま (agent/CLI が補完)
1346
+ ad.mediaIds=[]; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; ad.imagePaths=r.imagePaths.slice();
1197
1347
  } else {
1348
+ /* 解決済み: mediaId だけを送る。imagePaths を **同時に載せない** のが重要。
1349
+ CLI (resolveYdaImages) は imagePaths があると必ずアップロードして
1350
+ mediaIds に追記し、backend (createImageAd) は mediaIds[0] しか使わないため、
1351
+ 両方載せると「新画像はライブラリに登録されるが広告は旧画像のまま」になる。
1352
+ 入力があった場合は resolveAd が IMAGE_PATH_IGNORED で警告している。 */
1198
1353
  ad.mediaIds=[String(r.mediaId)];
1199
1354
  }
1200
1355
  return ad;
@@ -1308,6 +1463,50 @@ function normalizeRefsFor(P,r){ const e=emptyRefsFor(P); if(!r) return e; Object
1308
1463
  function emptyRefs(){ return emptyRefsFor(pf()); }
1309
1464
  function validRefs(r){ if(!r) return false; return refTableKeys().filter(k=>refTableDef(k).required).concat(LEGACY_REF_KEYS).every(k=>!!r[k]); }
1310
1465
  function normalizeRefs(r){ return normalizeRefsFor(pf(), r); }
1466
+ /* ---- C1: refpick (参照テーブル ID → name の複数選択列) ----
1467
+ セルが持つ値は **カンマ区切りの実ID**。表示だけ name に置き換える (入稿は ID をそのまま送る)。
1468
+ 媒体語彙は持たない: 参照先テーブルは col.refTable が指す。 */
1469
+ function refPickStore(col){ return (st.refs && st.refs[col.refTable]) || {}; }
1470
+ function refPickName(col, id){ const v=refPickStore(col)[String(id)]; if(v==null) return ''; return String((typeof v==='object')?(v.name||''):v); }
1471
+ function refPickDisplay(col, val){
1472
+ return tgList(val).map(id=>{ const n=refPickName(col,id); return n? n : String(id); }).join(', ');
1473
+ }
1474
+ /* 実IDだけを残す (旧・日本語ラベルや手入力の名前は捨てる)。順序保持・重複除去。 */
1475
+ function refIdCoerce(v){
1476
+ const out=[]; tgList(v).forEach(x=>{ const s=String(x).trim(); if(/^\d+$/.test(s) && out.indexOf(s)<0) out.push(s); });
1477
+ return out.join(', ');
1478
+ }
1479
+ /* ---- C5: 参照表の本文を直接編集する列 (番号列と双方向) ----
1480
+ media 語彙は持たない: どのテーブル(table)のどの番号列(noField)を見るかは呼び出し側が渡す。
1481
+ ・get : refs[table][row[noField]] の文言
1482
+ ・set : 番号が空なら空き番号を自動採番して refs へ登録 → 番号列にも書き戻す
1483
+ 空文字の set は **参照表を消さない** (番号の解除は番号列で行う)。 */
1484
+ function nextRefNo(store){
1485
+ let m=0; Object.keys(store||{}).forEach(k=>{ const n=parseInt(k,10); if(Number.isFinite(n)&&n>m) m=n; });
1486
+ return m+1;
1487
+ }
1488
+ function makeRefTextCol(o){
1489
+ return {field:o.field, label:o.label, kind:'text', width:o.width||'180px',
1490
+ refTable:o.table, noField:o.noField,
1491
+ title:'参照表の本文を直接編集します(番号が空なら空き番号を自動採番)',
1492
+ get:(row,ctx)=>{ const t=((ctx&&ctx.refs)||st.refs)[o.table]||{}; const v=t[row[o.noField]]; return v==null?'':String(v); },
1493
+ set:(row,val,ctx)=>{
1494
+ const refs=(ctx&&ctx.refs)||st.refs;
1495
+ const t=refs[o.table]||(refs[o.table]={});
1496
+ const s=String(val==null?'':val);
1497
+ if(s==='') return;
1498
+ let no=String(row[o.noField]==null?'':row[o.noField]).trim();
1499
+ if(no===''){ no=String(nextRefNo(t)); row[o.noField]=no; }
1500
+ t[no]=s;
1501
+ }};
1502
+ }
1503
+ /* 入稿ボディ用: セル値 → 数値ID配列。落とした要素数を ctx.stats.droppedRefIds に計上する。 */
1504
+ function refIdList(v, ctx){
1505
+ const keep=[], drop=[];
1506
+ tgList(v).forEach(x=>{ const s=String(x).trim(); if(/^\d+$/.test(s)){ if(keep.indexOf(s)<0) keep.push(s); } else drop.push(s); });
1507
+ if(drop.length && ctx && ctx.stats) ctx.stats.droppedRefIds += drop.length;
1508
+ return keep;
1509
+ }
1311
1510
  /* 案A: 同梱デフォルトアカウントの refs を起動時に外部JSONから読み込む(固有名詞ハードコードなし)。
1312
1511
  descriptions は独立テーブル(titlesコピーの暫定を廃止)。 */
1313
1512
  function buildRefs(){ return bundledRefs ? normalizeRefs(bundledRefs) : emptyRefs(); }
@@ -1436,7 +1635,24 @@ function findCp(u){ const i=st.campaigns.findIndex(c=>c.uid===u); return i<0?nul
1436
1635
  /* S8: 解決本体は pf().resolve(ad, group, ctx) に移設。関数名は据え置き
1437
1636
  (E2E が page.evaluate から resolveAd(...) を直接呼ぶため)。 */
1438
1637
  function planCtx(){ const P=pf(); return {project:st.project, refs:st.refs, pf:P, resolve:resolveAd}; }
1439
- function resolveAd(ad, group){ return pf().resolve(ad, group, planCtx()); }
1638
+ /* C1: 行単位メモ化。get フックを持つ列が増えると 1行あたり列数ぶん resolveAd が走るため、
1639
+ 「1パス」(コピー範囲の走査など state を変えない読み取り) の中だけ結果を共有する。
1640
+ state を変える処理 (pasteGrid 等) は絶対に包まないこと (古い解決結果を掴む)。 */
1641
+ let _resolvePass=null;
1642
+ function withResolvePass(fn){
1643
+ const prev=_resolvePass; _resolvePass=new WeakMap();
1644
+ try{ return fn(); } finally { _resolvePass=prev; }
1645
+ }
1646
+ function resolveAd(ad, group){
1647
+ if(_resolvePass){
1648
+ const hit=_resolvePass.get(ad);
1649
+ if(hit && hit.g===group && hit.pf===curPf) return hit.r;
1650
+ const r=pf().resolve(ad, group, planCtx());
1651
+ _resolvePass.set(ad, {g:group, pf:curPf, r});
1652
+ return r;
1653
+ }
1654
+ return pf().resolve(ad, group, planCtx());
1655
+ }
1440
1656
  function validationCounts(){ let err=0,warn=0,ads=0; eachAd((a,g)=>{ ads++; const r=resolveAd(a,g); if(r.errs.length) err++; else if(r.warns.length) warn++; }); return {err,warn,ads}; }
1441
1657
 
1442
1658
  /* ============================================================
@@ -1476,10 +1692,23 @@ function renderValidationBadge(){
1476
1692
  }
1477
1693
  function renderTplChips(){
1478
1694
  const p=st.project;
1479
- const txt=(f,v,w)=>'<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'">';
1480
- const num=(f,v,w)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'">';
1695
+ /* S7b: 記述子の表現力拡張。placeholder は文字列 or (project)=>string、title は文字列。
1696
+ どちらも未指定なら空文字を返すので、無指定の媒体 (YDA) の出力HTMLは 1バイトも変わらない。
1697
+ coerce フックは足していない: 値の正規化 (全角空白・前後空白・区切り) は
1698
+ 媒体定義の mappers 側 (meta.mappers.normDateTime) が吸収する方が、
1699
+ 生の入力を state に残せて再編集時に壊れないため。 */
1700
+ const hint=(f)=>{
1701
+ if(!f) return '';
1702
+ const ph = typeof f.placeholder==='function' ? f.placeholder(p) : f.placeholder;
1703
+ return (ph? ' placeholder="'+esc(ph)+'"' : '') + (f.title? ' title="'+esc(f.title)+'"' : '');
1704
+ };
1705
+ const txt=(f,v,w,d)=>'<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'"'+hint(d)+'>';
1706
+ const num=(f,v,w,d)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'"'+hint(d)+'>';
1481
1707
  const sel=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option'+(o===v?' selected':'')+'>'+esc(o)+'</option>').join('')+'</select>';
1482
1708
  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
+ /* C1: kind:'toggle' = チェックボックス。値は真偽で持ち、**未定義は fallback**(欠落=従来挙動)。
1710
+ `p[key]||fallback` では false が fallback(true) に化けるので、toggle だけは別扱いにする。 */
1711
+ const tgl=(f,v,d)=>'<input class="chipin" type="checkbox" data-proj="'+f+'"'+(v?' checked':'')+hint(d)+'>';
1483
1712
  const chip=(label,ctrl)=>'<span class="chip">'+esc(label)+' '+ctrl+'</span>';
1484
1713
  // E-1: 案件名 / アカウントID / 主体者 / タイプ チップは撤去。
1485
1714
  // - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
@@ -1487,11 +1716,14 @@ function renderTplChips(){
1487
1716
  // - タイプ(レスポンシブ/画像)はプランツールバーの固定プルダウン (変更不可)
1488
1717
  // S7: チップの並び・種別・幅は pf().projectFields (媒体スキーマ) が持つ
1489
1718
  document.getElementById('tplChips').innerHTML = pf().projectFields.map(f=>{
1490
- const v = (f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key];
1491
- const ctrl = f.kind==='selectv' ? selv(f.key, v, f.options)
1719
+ const v = f.kind==='toggle'
1720
+ ? (p[f.key]===undefined ? (f.fallback!==undefined?f.fallback:false) : !!p[f.key])
1721
+ : ((f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key]);
1722
+ const ctrl = f.kind==='toggle' ? tgl(f.key, v, f)
1723
+ : f.kind==='selectv' ? selv(f.key, v, f.options)
1492
1724
  : f.kind==='select' ? sel(f.key, v, f.options)
1493
- : f.kind==='number' ? num(f.key, v, f.width)
1494
- : txt(f.key, v, f.width);
1725
+ : f.kind==='number' ? num(f.key, v, f.width, f)
1726
+ : txt(f.key, v, f.width, f);
1495
1727
  return chip(f.label, ctrl+(f.suffix||''));
1496
1728
  }).join('');
1497
1729
  // A-1: 入札(tcpaDefault) / 予算新規 / 予算ASC チップは撤去。project既定は新規CP作成時の初期値供給のみに使い、以後はキャンペーン行で個別編集する。
@@ -1557,6 +1789,9 @@ function renderRow(item, ri, cols, tab, ui){
1557
1789
  const ctx = {tab:tab, item:item, ri:ri, project:st.project, refs:st.refs};
1558
1790
  let cls='adrow';
1559
1791
  if(item.kind==='a'){ ctx.r=resolveAd(item.a,item.g); if(ctx.r.errs.length) cls+=' hasErr'; }
1792
+ /* 行レベル検証を持つ媒体だけ、行のエラーコードを ctx に載せる (列記述子 col.errCodes と突き合わせてセルを赤くする)。
1793
+ validateRow が無い媒体 (YDA) では ctx.rowErrCodes 自体が生えないので DOM は一切変わらない。 */
1794
+ if(item.kind==='g' && pf().validateRow) ctx.rowErrCodes=(pf().validateRow(item.g, 'adGroup', ctx)||{}).errCodes||[];
1560
1795
  let h='<tr class="'+cls+'" '+ui.rowAttr+'="'+row.uid+'">';
1561
1796
  h+='<td class="rownum" data-rownum="'+ri+'"'+(ui.rowNumTitle?ui.rowNumTitle(item):'')+'>'+(ri+1)+'</td>';
1562
1797
  if(ui.parentLabel!=null) h+=ui.parentCell(item);
@@ -1567,7 +1802,7 @@ function renderRow(item, ri, cols, tab, ui){
1567
1802
  /* 1セル。col.kind で分岐する唯一の場所。 */
1568
1803
  function renderCell(col, item, ri, ci, ctx){
1569
1804
  const ui=SHEET_UI[ctx.tab], row=rowOf(item);
1570
- const val=getVar(item, ci, ctx.tab);
1805
+ const val=getVar(item, ci, ctx.tab, ctx);
1571
1806
  const ro = col.alwaysReadonly || ((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col));
1572
1807
  const sel='data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+'"';
1573
1808
  const sty=col.width?(' style="width:'+col.width+'"'):'';
@@ -1602,14 +1837,21 @@ function renderCell(col, item, ri, ci, ctx){
1602
1837
  '<button class="combodd" data-dd="'+col.drawerType+'" data-u="'+row.uid+'" data-f="'+col.field+'" tabindex="-1" title="'+esc(col.ddTitle)+'">▾</button></div></td>';
1603
1838
  }
1604
1839
 
1605
- if(col.kind==='tgpick'){
1606
- const label=pf().targeting[col.tgKey].label;
1607
- const disp = val? '<span class="tgval">'+esc(val)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(label)+'</span></span>';
1608
- return '<td class="tgtd"><div class="tgpick'+(val?'':' tgempty')+'" '+sel+' title="'+esc(val||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
1840
+ /* tgpick = 固定ラベルの複数選択 / refpick = 参照テーブル(ID→name)の複数選択。
1841
+ DOM は同じ (.tgpick) に揃えて CSS・E2E セレクタ・ドラッグ判定を共通化する。
1842
+ ラベルは **col.label フォールバック必須**: refpick 列は pf().targeting にキーを持たないので
1843
+ targeting[col.tgKey].label を無条件参照すると TypeError になる。 */
1844
+ if(col.kind==='tgpick' || col.kind==='refpick'){
1845
+ const tg = col.tgKey ? pf().targeting[col.tgKey] : null;
1846
+ const label=(tg&&tg.label)||col.label||'';
1847
+ const shown = col.kind==='refpick' ? refPickDisplay(col, val) : val;
1848
+ const disp = shown? '<span class="tgval">'+esc(shown)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(label)+'</span></span>';
1849
+ return '<td class="tgtd"><div class="tgpick'+(shown?'':' tgempty')+'" '+sel+' title="'+esc(shown||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
1609
1850
  }
1610
1851
 
1611
1852
  /* text / mono / number */
1612
- const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'');
1853
+ const rowBad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
1854
+ const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'')+(rowBad?' err':'');
1613
1855
  return '<td><input class="'+cls+'"'+(col.kind==='number'?' type="number"':'')+' '+sel+' value="'+esc(val)+'"'+
1614
1856
  (col.placeholder?(' placeholder="'+esc(col.placeholder(ctx, row))+'"'):'')+
1615
1857
  (col.title?(' title="'+esc(col.title)+'"'):'')+sty+'></td>';
@@ -1817,10 +2059,31 @@ const COLS = {
1817
2059
  title:'グループ共通(変更で同グループ全広告に反映)',
1818
2060
  roStyle:'input', roTitle:'既存広告グループのため編集不可',
1819
2061
  onSet:it=>autoResolveGroupIds(it.g,it.c)},
1820
- {field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
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'); }},
2071
+ {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); }},
2075
+ /* C5: 参照表(refs.titles / refs.descriptions)の本文を直接編集する列。
2076
+ 番号未入力のまま本文を書くと空き番号を自動採番して参照表に登録する。 */
2077
+ makeRefTextCol({field:'titleText', label:'タイトル本文', table:'titles', noField:'titleNo', width:'180px'}),
2078
+ makeRefTextCol({field:'descText', label:'説明文本文', table:'descriptions', noField:'descNo', width:'180px'}),
2079
+ /* C6-1: 量産時のローカル画像パス担体 (カンマ区切りで複数可)。少数入稿は Skill の画像入稿フローが主導線 */
2080
+ {field:'imagePath', label:'画像パス', kind:'mono', width:'150px',
2081
+ title:'ローカル画像の絶対 / ~/… / 計画JSON基準の相対パス。カンマ区切りで複数可。※画像番号が参照表で解決できる行では入稿JSONに載りません(差し替えたいときは画像番号を空に)'},
2082
+ /* C6: 広告名は自由入力 (空 = 自動生成)。placeholder に自動生成値を出す */
2083
+ {field:'adName', label:'広告名', kind:'mono', width:'170px',
2084
+ title:'空欄なら自動生成({画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
2085
+ placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
1821
2086
  /* derived: resolveAd() 由来の読取専用セル。tabCols()/貼付/セル選択の対象外 (末尾固定) */
1822
- {field:'adName', label:'広告名(自動)', kind:'derived',
1823
- derive:(a,ctx)=>'<span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(ctx.r.finalUrl)+'">'+esc(ctx.r.adName)+'</span>'},
1824
2087
  {field:'adGroupId', label:'CP / AG ID', kind:'derived',
1825
2088
  derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AG '+ctx.r.adGroupId):'新規')+'</span>'}
1826
2089
  ],
@@ -1841,9 +2104,23 @@ const COLS = {
1841
2104
  {field:'tg_age', label:'年齢', kind:'tgpick', tgKey:'tg_age', readonlyOnExisting:true, roEmptyDash:true},
1842
2105
  {field:'tg_region', label:'地域', kind:'tgpick', tgKey:'tg_region', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
1843
2106
  {field:'tg_dayHour', label:'曜日時間', kind:'tgpick', tgKey:'tg_dayHour', readonlyOnExisting:true, roEmptyDash:true},
1844
- {field:'tg_audience', label:'オーディエンス', kind:'tgpick', tgKey:'tg_audience', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
2107
+ /* C7: オーディエンス/配置は refpick (参照テーブルの 実ID → name)。値は実IDのCSVで持ち、表示だけ name。
2108
+ coerce で数値ID以外 (旧・日本語ラベル / 手入力の名前) を落とす。 */
2109
+ {field:'tg_audience', label:'オーディエンス', kind:'refpick', refTable:'audiences',
2110
+ readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
2111
+ {field:'tg_audienceEx', label:'オーディエンス除外', kind:'refpick', refTable:'audiences',
2112
+ readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
2113
+ {field:'tg_placement', label:'配置', kind:'refpick', refTable:'placements',
2114
+ readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
2115
+ {field:'tg_placementEx',label:'配置除外', kind:'refpick', refTable:'placements',
2116
+ readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
1845
2117
  /* 暗黙 else を廃止: pf().targeting に無い列は kind:'text' で明示する */
1846
- {field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true}
2118
+ {field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
2119
+ /* C8: 空欄 = オン (未設定・旧データはすべて true)。明示的に「オフ」を選んだときだけ false */
2120
+ {field:'smartTargeting', label:'スマートTG', kind:'select', options:['','オン','オフ'], minWidth:'66px',
2121
+ readonlyOnExisting:true, roEmptyDash:true,
2122
+ title:'空欄は「オン」扱い(方針: スマートターゲティングは常時ON)',
2123
+ coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
1847
2124
  ],
1848
2125
  /* E-2: campaignId最左・read-only / mode/status/strategyMemo撤去 */
1849
2126
  campaigns:[
@@ -1962,8 +2239,10 @@ const META_COLS = {
1962
2239
  title:'広告セット共通(変更で同広告セット全広告に反映)',
1963
2240
  roStyle:'input', roTitle:'既存広告セットのため編集不可'},
1964
2241
  {field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
1965
- {field:'adName', label:'広告名(自動)', kind:'derived',
1966
- derive:(a,ctx)=>'<span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(ctx.r.finalUrl)+'">'+esc(ctx.r.adName)+'</span>'},
2242
+ /* C6: 広告名は自由入力 (空 = 自動生成)。YDA と同じ規約 */
2243
+ {field:'adName', label:'広告名', kind:'mono', width:'170px',
2244
+ title:'空欄なら自動生成({番号}{i|v}-{見出し番号}L{本文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
2245
+ placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
1967
2246
  {field:'adGroupId', label:'CP / AdSet ID', kind:'derived',
1968
2247
  derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AS '+ctx.r.adGroupId):'新規')+'</span>'}
1969
2248
  ],
@@ -1995,6 +2274,23 @@ const META_COLS = {
1995
2274
  {field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
1996
2275
  {field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
1997
2276
  {field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
2277
+ /* 配信日時: 空欄 = 案件設定チップの値を継承(buildPlan 側で解決)。placeholder に継承先を出す */
2278
+ {field:'startDate', label:'配信開始日時', kind:'text', width:'17ch',
2279
+ readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
2280
+ errCodes:[EC.START_TIME_INVALID, EC.END_BEFORE_START],
2281
+ placeholder:()=>{ const v=String((st.project&&st.project.startDate)||'').trim();
2282
+ return v!=='' ? v : '案件設定 / 今日'; },
2283
+ title:'この広告セットだけの配信開始日時(JST)。空欄なら案件設定「配信開始日時」を継承し、'
2284
+ +'それも空欄なら当日 00:00。日付のみなら 00:00 開始(例 2026-08-01 10:30)。'
2285
+ +'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
2286
+ {field:'endDate', label:'配信終了日時', kind:'text', width:'17ch',
2287
+ readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
2288
+ errCodes:[EC.END_TIME_INVALID, EC.END_BEFORE_START],
2289
+ placeholder:()=>{ const v=String((st.project&&st.project.endDate)||'').trim();
2290
+ return v!=='' ? v : '無期限'; },
2291
+ title:'この広告セットだけの配信終了日時(JST)。空欄なら案件設定「配信終了日時」を継承し、'
2292
+ +'それも空欄なら無期限配信。日付のみならその日の 23:59:59 まで。'
2293
+ +'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
1998
2294
  {field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
1999
2295
  readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
2000
2296
  ],
@@ -2027,6 +2323,10 @@ PLATFORMS.meta = {
2027
2323
  if(obj.pixelId!=null) project.pixelId=String(obj.pixelId);
2028
2324
  if(obj.projectName) project.name=obj.projectName;
2029
2325
  if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
2326
+ /* C3: YDA と同じ規約。**キー無しは必ず既定 (undefined = ON) に戻す** —
2327
+ 同一媒体内のアカウント切替では st.project が作り直されないため、
2328
+ 「あるときだけ上書き」だと前アカウントの OFF が残って計測パラメータが黙って消える */
2329
+ if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
2030
2330
  },
2031
2331
  schemaLines(opts){
2032
2332
  const o=opts||{};
@@ -2081,10 +2381,21 @@ PLATFORMS.meta = {
2081
2381
  {key:'cta', label:'CTA', kind:'select',
2082
2382
  options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
2083
2383
  '予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
2084
- {key:'startDate', label:'配信開始日', kind:'text', width:'10ch', fallback:''},
2085
- {key:'endDate', label:'配信終了日', kind:'text', width:'10ch', fallback:''},
2384
+ {key:'startDate', label:'配信開始日時', kind:'text', width:'17ch', fallback:'',
2385
+ placeholder:'YYYY-MM-DD [HH:mm]',
2386
+ title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
2387
+ +'日付のみなら 00:00 開始。時刻を入れるとその時刻ちょうどから配信(例 2026-08-01 10:30)。'
2388
+ +'空欄なら当日 00:00 から。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
2389
+ {key:'endDate', label:'配信終了日時', kind:'text', width:'17ch', fallback:'',
2390
+ placeholder:'空欄=無期限',
2391
+ title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
2392
+ +'日付のみならその日の 23:59:59 まで。時刻を入れるとその時刻で終了(例 2026-08-31 18:00)。'
2393
+ +'空欄なら終了日時を送らず無期限配信。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
2086
2394
  {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
2087
- {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''}
2395
+ {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''},
2396
+ /* C3: 案件単位の URLパラメータ ON/OFF (欠落 = ON = 従来挙動) */
2397
+ {key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
2398
+ title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
2088
2399
  ],
2089
2400
  fixedType:[{label:'広告タイプ',value:'単一画像 / 単一動画'}],
2090
2401
 
@@ -2129,11 +2440,95 @@ PLATFORMS.meta = {
2129
2440
  },
2130
2441
  status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
2131
2442
  cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
2443
+ /* 日時の正規化。日付のみ / 日付+時刻 の両方を受け、JST 明示 ISO8601 を返す。
2444
+ - 空 → null
2445
+ - 末尾にオフセット(+09:00 / +0900 / Z)を持つ完全 ISO → そのまま素通し (後方互換)
2446
+ - YYYY-MM-DD → opts.endOfDay ? T23:59:59 : T00:00:00 を補って +0900
2447
+ - YYYY-MM-DD[ T]HH:mm(:ss)? → 時刻が明示されているので endOfDay は適用しない
2448
+ - どれにも当たらない → {invalid:true, input} を返す (例外は投げない)
2449
+ 実在しない日付 (2026-02-30 / 2026-13-99) と範囲外の時刻 (24:00 / 10:75) は不正とする。
2450
+ Date は 2/30 を 3/2 へロールオーバーするので、生成した Date の年月日が入力と
2451
+ 一致するかを照合しないと非実在日付が素通りする。 */
2452
+ normDateTime(input, opts){
2453
+ const o=opts||{}, p2=n=>String(n).padStart(2,'0');
2454
+ /* 実在する暦日か (ロールオーバー検出) */
2455
+ const realDate=(y,mo,d)=>{
2456
+ const t=new Date(Date.UTC(y, mo-1, d));
2457
+ return t.getUTCFullYear()===y && t.getUTCMonth()===mo-1 && t.getUTCDate()===d;
2458
+ };
2459
+ let s=String(input==null?'':input).replace(/ /g,' ').trim();
2460
+ if(!s) return null;
2461
+ s=s.replace(/\//g,'-');
2462
+ /* 完全 ISO (末尾にオフセット or Z) は **原文のまま返す** (後方互換: 既存プランの
2463
+ '…T00:00:00.000Z' を書き換えない)。ただし素通し=無検証にはしない。
2464
+ 検証を飛ばすと 2026-02-30T10:00:00+0900 / 24:00 / 実在しない閏日が
2465
+ プランナーでは valid のまま書き出され、失敗が dry-run (backend 400) まで遅延する。 */
2466
+ const iso=s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:([+-])(\d{2}):?(\d{2})|Z)$/);
2467
+ if(iso){
2468
+ const hh=+iso[4], mi=+iso[5], ss=+(iso[6]||0);
2469
+ if(!realDate(+iso[1], +iso[2], +iso[3])) return {invalid:true, input:String(input)};
2470
+ if(hh>23 || mi>59 || ss>59) return {invalid:true, input:String(input)};
2471
+ /* UTC オフセットの実在範囲は -12:00〜+14:00 (公式の最大は +14:00)。
2472
+ +24:00 や +99:99 は Meta へ渡すと AdSet 作成時に落ち、キャンペーンだけ残る */
2473
+ if(iso[7]){
2474
+ const oh=+iso[8], om=+iso[9];
2475
+ if(om>59) return {invalid:true, input:String(input)};
2476
+ const mins=oh*60+om;
2477
+ if(iso[7]==='+' ? mins>14*60 : mins>12*60) return {invalid:true, input:String(input)};
2478
+ }
2479
+ return s;
2480
+ }
2481
+ if(/(?:[+-]\d{2}:?\d{2}|Z)$/.test(s)) return {invalid:true, input:String(input)}; /* オフセットらしき末尾だが形が不正 */
2482
+ let m=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
2483
+ if(m){
2484
+ if(!realDate(+m[1], +m[2], +m[3])) return {invalid:true, input:String(input)};
2485
+ const d=m[1]+'-'+p2(m[2])+'-'+p2(m[3]);
2486
+ return d + (o.endOfDay ? 'T23:59:59' : 'T00:00:00') + '+0900';
2487
+ }
2488
+ m=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?$/);
2489
+ if(m){
2490
+ const hh=+m[4], mi=+m[5], ss=+(m[6]||0);
2491
+ if(!realDate(+m[1], +m[2], +m[3])) return {invalid:true, input:String(input)};
2492
+ /* 24:00 は ISO8601 上は翌日 0:00 だが、意図と実挙動が食い違うので受け付けない */
2493
+ if(hh>23 || mi>59 || ss>59) return {invalid:true, input:String(input)};
2494
+ return m[1]+'-'+p2(m[2])+'-'+p2(m[3])+'T'
2495
+ + p2(hh)+':'+p2(mi)+':'+p2(ss)+'+0900';
2496
+ }
2497
+ return {invalid:true, input:String(input)};
2498
+ },
2499
+ /* 正規化後の日時を epoch(ms) にする。空 / 不正 / 解釈不能は NaN。
2500
+ normDateTime が返す +0900 (コロン無し) は Date.parse が拒む環境があるのでコロンを補う。 */
2501
+ epochOf(v, o){
2502
+ /* opts はそのまま normDateTime に渡す。終了日時の前後比較では **必ず {endOfDay:true} を渡すこと**。
2503
+ 渡さないと日付のみの終了日が 00:00 として比較され、
2504
+ 「start=2026-08-31 18:00 / end=2026-08-31」(実出力は 23:59:59 なので正当) を
2505
+ END_BEFORE_START と誤判定してブロックしてしまう。 */
2506
+ const r=PLATFORMS.meta.mappers.normDateTime(v, o);
2507
+ if(!r || typeof r==='object') return NaN;
2508
+ return Date.parse(String(r).replace(/([+-]\d{2})(\d{2})$/, '$1:$2'));
2509
+ },
2510
+ /* 空は valid 扱い (未指定 = 上位/既定へフォールバック)。
2511
+ 書式が合っていても実在しない日付 (2026-02-30 / 2026-13-99) や範囲外の時刻 (24:00)
2512
+ は normDateTime が {invalid:true} を返すので不正になる。 */
2513
+ isValidDateTime(v){
2514
+ const r=PLATFORMS.meta.mappers.normDateTime(v);
2515
+ if(r==null) return true; /* 空欄 */
2516
+ if(typeof r==='object') return false; /* 書式不一致 */
2517
+ return !isNaN(PLATFORMS.meta.mappers.epochOf(v));
2518
+ },
2132
2519
  /* B13: JST 00:00 を明示した ISO8601 (日付のみだと UTC 解釈で前日にずれる) */
2133
- startTime(dateStr){ return (dateStr||today())+'T00:00:00+0900'; },
2520
+ startTime(dateStr){
2521
+ const r=PLATFORMS.meta.mappers.normDateTime(dateStr);
2522
+ if(r && typeof r==='object') return String(r.input); /* 不正はそのまま返し検証側で弾く */
2523
+ return r || (today()+'T00:00:00+0900');
2524
+ },
2134
2525
  /* 終了日は「その日の終わりまで配信」の解釈。日付のみの入力を JST 23:59:59 に寄せる。
2135
2526
  空欄なら null を返し、呼び出し側で endTime キー自体を出さない (無期限配信)。 */
2136
- endTime(dateStr){ const s=String(dateStr||'').trim(); return s? s+'T23:59:59+0900' : null; }
2527
+ endTime(dateStr){
2528
+ const r=PLATFORMS.meta.mappers.normDateTime(dateStr, {endOfDay:true});
2529
+ if(r && typeof r==='object') return String(r.input);
2530
+ return r;
2531
+ }
2137
2532
  },
2138
2533
 
2139
2534
  defaults:{
@@ -2144,10 +2539,11 @@ PLATFORMS.meta = {
2144
2539
  bidStrategy:'LOWEST_COST_WITHOUT_CAP', buyingType:'AUCTION',
2145
2540
  deliveryStatus:'オフ', startDate:'', endDate:''
2146
2541
  },
2147
- ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:''},
2542
+ /* C6: adName は空 = 自動生成 (naming.adName) */
2543
+ ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adName:''},
2148
2544
  adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
2149
2545
  ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
2150
- tg_placement:'自動配置', deliveryStatus:''},
2546
+ tg_placement:'自動配置', startDate:'', endDate:'', deliveryStatus:''},
2151
2547
  campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
2152
2548
  objective:'OUTCOME_SALES', specialAdCategory:'NONE', variable:'', urlNumber:''}
2153
2549
  },
@@ -2171,10 +2567,14 @@ PLATFORMS.meta = {
2171
2567
  naming:{
2172
2568
  crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
2173
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 でエスケープする。 */
2174
2572
  finalUrl(ad, group, adName, project){
2175
- return (project.baseArticleUrl||'') + (ad.articleSlug||'')
2176
- + '?ad=' + (group.paramCode||'')
2177
- + '&creative=' + (group.paramCode||'') + '-' + adName;
2573
+ const base=(project.baseArticleUrl||'') + (ad.articleSlug||'');
2574
+ if(project.urlParams===false) return base;
2575
+ const sep = base.indexOf('?')>=0 ? '&' : '?';
2576
+ const code = urlq(group.paramCode||'');
2577
+ return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
2178
2578
  },
2179
2579
  campaignName(urlNumber, firstAd, P){
2180
2580
  const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
@@ -2202,7 +2602,9 @@ PLATFORMS.meta = {
2202
2602
  const P=PLATFORMS.meta, p=ctx.project, refs=ctx.refs;
2203
2603
  const fmt=P.fmtOf(ad);
2204
2604
  const crName=P.naming.crName(ad, P);
2205
- const adName=P.naming.adName(ad, group, crName);
2605
+ /* C6: 広告名は自由入力。空 = 自動生成にフォールバック */
2606
+ const autoAdName=P.naming.adName(ad, group, crName);
2607
+ const adName=String(ad.adName||'').trim() || autoAdName;
2206
2608
  const me = refs.media[String(ad.imageNo)];
2207
2609
  const mediaId = me ? me[fmt.mediaKey] : undefined;
2208
2610
  const thumbHash = me ? me.thumb : undefined;
@@ -2227,28 +2629,62 @@ PLATFORMS.meta = {
2227
2629
  if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
2228
2630
  if(pendingImage) pushWarn(ad.format==='VIDEO'?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING);
2229
2631
  const aid=String(group.adGroupId||'').trim();
2230
- return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
2632
+ return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
2231
2633
  mediaId:mediaId||'', thumbHash:thumbHash||'', pendingImage,
2232
2634
  titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText,
2233
2635
  finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
2234
2636
  },
2235
2637
 
2236
- /* ---- 広告セット行の追加検証 (予算・入札) ---- */
2638
+ /* ---- 広告セット行の追加検証 (予算・入札・配信日時) ---- */
2237
2639
  validateRow(row, level, ctx){
2238
- const P=PLATFORMS.meta, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
2640
+ const P=PLATFORMS.meta, M=P.mappers, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
2239
2641
  if(level==='adGroup' && !isExistingGroup(row)){
2240
2642
  if(!(Number(row.dailyBudget)>0)) errCodes.push(EC.BUDGET_MISSING);
2241
2643
  if(P.enums.bidAmountRequiredFor.indexOf(p.bidStrategy)>=0 && !(Number(row.bidAmount)>0))
2242
2644
  errCodes.push('BID_AMOUNT_REQUIRED');
2645
+ /* 配信日時。空欄は正常 (未指定 = 上位へフォールバック) なので isValidDateTime は空を valid とする。
2646
+ 前後関係は buildPlan.adGroup と同じ解決順 (行 → 案件設定 → 既定) で比較する。 */
2647
+ const sRaw=String(row.startDate==null?'':row.startDate).trim();
2648
+ const eRaw=String(row.endDate==null?'':row.endDate).trim();
2649
+ const sBad=!M.isValidDateTime(sRaw), eBad=!M.isValidDateTime(eRaw);
2650
+ if(sBad) errCodes.push(EC.START_TIME_INVALID);
2651
+ if(eBad) errCodes.push(EC.END_TIME_INVALID);
2652
+ if(!sBad && !eBad){
2653
+ const s=M.epochOf(firstNonEmpty(sRaw, p.startDate) || today());
2654
+ const e=M.epochOf(firstNonEmpty(eRaw, p.endDate), {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
2655
+ if(!isNaN(s) && !isNaN(e) && e<s) errCodes.push(EC.END_BEFORE_START);
2656
+ }
2243
2657
  }
2244
2658
  return {errCodes, warnCodes};
2245
2659
  },
2246
- /* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ---- */
2247
- validateProject(p){
2248
- const P=PLATFORMS.meta, errs=[];
2660
+ /* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ----
2661
+ 第2引数 state は任意。渡された場合は行の配信日時も走査して、不正日時のまま
2662
+ 書き出されるのを toast でブロックする (行だけを黙って除外すると気づけないため)。 */
2663
+ validateProject(p, state){
2664
+ const P=PLATFORMS.meta, M=P.mappers, errs=[];
2249
2665
  if(!String((p&&p.pageId)||'').trim()) errs.push({code:'PAGE_ID_MISSING', msg:'FBページIDを共通設定に入力してください'});
2250
2666
  if(P.enums.pixelRequiredFor.indexOf(p&&p.optimizationGoal)>=0 && !String((p&&p.pixelId)||'').trim())
2251
2667
  errs.push({code:EC.PIXEL_MISSING, msg:'最適化が'+p.optimizationGoal+'のためピクセルIDが必須です'});
2668
+ /* 案件設定チップの配信日時 */
2669
+ const ps=String((p&&p.startDate)||'').trim(), pe=String((p&&p.endDate)||'').trim();
2670
+ const psBad=!M.isValidDateTime(ps), peBad=!M.isValidDateTime(pe);
2671
+ if(psBad) errs.push({code:EC.START_TIME_INVALID, msg:'案件設定「配信開始日時」の書式が不正です(例 2026-08-01 10:30): '+ps});
2672
+ if(peBad) errs.push({code:EC.END_TIME_INVALID, msg:'案件設定「配信終了日時」の書式が不正です(例 2026-08-31 23:59): '+pe});
2673
+ if(!psBad && !peBad){
2674
+ const s=M.epochOf(ps||today()), e=M.epochOf(pe, {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
2675
+ if(!isNaN(s) && !isNaN(e) && e<s) errs.push({code:EC.END_BEFORE_START, msg:'案件設定の配信終了日時が配信開始日時より前です'});
2676
+ }
2677
+ /* 広告セット行の配信日時 (日予算・入札の未入力は従来どおり行除外で扱い、ここでは見ない) */
2678
+ const DATE_CODES=[EC.START_TIME_INVALID, EC.END_TIME_INVALID, EC.END_BEFORE_START];
2679
+ const MSG={};
2680
+ MSG[EC.START_TIME_INVALID]='の配信開始日時の書式が不正です(例 2026-08-01 10:30)';
2681
+ MSG[EC.END_TIME_INVALID] ='の配信終了日時の書式が不正です(例 2026-08-31 23:59)';
2682
+ MSG[EC.END_BEFORE_START] ='の配信終了日時が配信開始日時より前です';
2683
+ ((state&&state.campaigns)||[]).forEach(c=>(c.adGroups||[]).forEach(g=>{
2684
+ const codes=(P.validateRow(g, 'adGroup', {project:p})||{}).errCodes||[];
2685
+ DATE_CODES.forEach(code=>{ if(codes.indexOf(code)>=0)
2686
+ errs.push({code, msg:'広告セット「'+String(g.name||'')+'」'+MSG[code]}); });
2687
+ }));
2252
2688
  return errs;
2253
2689
  },
2254
2690
 
@@ -2273,6 +2709,12 @@ PLATFORMS.meta = {
2273
2709
  const cat=String((c&&c.specialAdCategory)||'NONE');
2274
2710
  const K=P.enums.specialAdCategoryConstraints;
2275
2711
  const constrained = K.appliesTo.indexOf(cat)>=0;
2712
+ /* 配信日時の解決順: 広告セット行 → 案件設定チップ → 既定 (開始のみ今日)。
2713
+ '' は「未指定 = 上位へフォールバック」であって「明示的な無期限」ではない。
2714
+ 既知の制約: 案件設定チップに終了日時があるとき、特定の広告セットだけ「終了なし」にはできない
2715
+ (無期限は行・チップの両方を空にして達成する)。 */
2716
+ const startSrc = firstNonEmpty(g.startDate, p.startDate);
2717
+ const endSrc = firstNonEmpty(g.endDate, p.endDate);
2276
2718
  const ag={
2277
2719
  name:g.name,
2278
2720
  status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
@@ -2280,7 +2722,7 @@ PLATFORMS.meta = {
2280
2722
  billingEvent:p.billingEvent||'IMPRESSIONS',
2281
2723
  bidStrategy:p.bidStrategy||'LOWEST_COST_WITHOUT_CAP',
2282
2724
  dailyBudget:Number(g.dailyBudget)||0, /* JPY はそのまま (currency_offset=1) */
2283
- startTime:P.mappers.startTime(p.startDate), /* JST 明示 ISO8601 */
2725
+ startTime:P.mappers.startTime(startSrc), /* 常に送る (空なら today())。JST 明示 ISO8601 */
2284
2726
  /* 特別カテゴリ時は 18-65 固定・性別指定不可 (公式制約) */
2285
2727
  ageMin: constrained?K.forcedAgeMin:(Number(g.ageMin)||18),
2286
2728
  /* 年齢上限は空欄 = 上限なし → ageMax キー自体を出さない (age_max 省略 = Meta 側で上限なし)。
@@ -2298,7 +2740,7 @@ PLATFORMS.meta = {
2298
2740
  customEventType:p.customEventType||'PURCHASE'
2299
2741
  };
2300
2742
  /* 終了日は任意。空欄なら endTime キー自体を出さない (= 無期限配信) */
2301
- const endT=P.mappers.endTime(p.endDate); if(endT) ag.endTime=endT;
2743
+ const endT=P.mappers.endTime(endSrc); if(endT) ag.endTime=endT;
2302
2744
  if(P.enums.bidAmountRequiredFor.indexOf(ag.bidStrategy)>=0 || Number(g.bidAmount)>0)
2303
2745
  ag.bidAmount=Number(g.bidAmount)||0;
2304
2746
  const aid=resolveAdGroupIdOf(g); if(aid) ag.adSetId=aid;
@@ -2444,26 +2886,55 @@ function rowIsExisting(item, col){
2444
2886
  if(item.kind==='c') return isExistingCampaign(item.c);
2445
2887
  return false;
2446
2888
  }
2447
- function getVar(item,c,tab){
2889
+ /* ---- C1: セル値アクセスの get/set フック ----
2890
+ 列記述子に col.get(row, ctx) / col.set(row, val, ctx, item) を足すと、
2891
+ 「派生値を持つ列」を derived ではなく **通常の入力列** として宣言できる。
2892
+ copySelText は getVar のみ・pasteGrid は setVar のみを使うので、
2893
+ フックを持つ列はそれだけで コピー&ペーストのラウンドトリップ対象になる。
2894
+ ctx は行コンテキスト {tab, item, project, refs, r(解決結果・遅延)}。
2895
+ renderCell は行で1回作った ctx をそのまま渡すので resolve が列数ぶん走らない。 */
2896
+ function cellCtx(item, tab, ctx){
2897
+ if(ctx) return ctx;
2898
+ const o={tab:normTab(tab===undefined?sheetTab:tab), item, project:st.project, refs:st.refs};
2899
+ if(item && item.kind==='a'){
2900
+ Object.defineProperty(o,'r',{configurable:true, get(){ return resolveAd(item.a, item.g); }});
2901
+ }
2902
+ return o;
2903
+ }
2904
+ function getVar(item,c,tab,ctx){
2448
2905
  const col=colDefs(tab)[c]; if(!col) return '';
2449
- const row=rowOf(item,col); const v=row?row[col.field]:null;
2906
+ const row=rowOf(item,col); if(!row) return '';
2907
+ if(col.get){ const gv=col.get(row, cellCtx(item,tab,ctx)); return gv==null?'':String(gv); }
2908
+ const v=row[col.field];
2450
2909
  return v==null?'':String(v);
2451
2910
  }
2452
- function setVar(item,c,val,tab){
2911
+ function setVar(item,c,val,tab,ctx){
2453
2912
  const col=colDefs(tab)[c]; if(!col) return;
2454
2913
  if(col.kind==='derived') return;
2455
2914
  if(col.alwaysReadonly) return; /* adGroupId / campaignId */
2456
2915
  if((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col)) return; /* F-2: 既存呼び出し行は基本パラメータ編集不可 */
2457
2916
  const row=rowOf(item,col); if(!row) return;
2458
- row[col.field] = col.coerce?col.coerce(val):val;
2917
+ const v = col.coerce?col.coerce(val):val;
2918
+ if(col.set) col.set(row, v, cellCtx(item,tab,ctx), item);
2919
+ else row[col.field] = v;
2459
2920
  if(col.onSet) col.onSet(item,row);
2460
2921
  }
2922
+ /* 列 field 指定で1セルを書く (change ハンドラを列記述子経由に載せるための入口)。
2923
+ 列が見つからなければ false を返し、呼び出し側の旧経路にフォールバックさせる。 */
2924
+ function setVarByField(item, tab, field, val){
2925
+ const cols=colDefs(tab); const ci=cols.findIndex(c=>c.field===field);
2926
+ if(ci<0) return false;
2927
+ setVar(item, ci, val, tab);
2928
+ return true;
2929
+ }
2461
2930
  function flatVisible(){ const out=[]; if(sheetTab==='groups'){ st.campaigns.forEach(c=>c.adGroups.forEach(g=>out.push({kind:'g',g,c}))); return out; } if(sheetTab==='campaigns'){ st.campaigns.forEach(c=>out.push({kind:'c',c})); return out; } const filter=document.getElementById('sheetFilter').value; st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach(a=>{ const r=resolveAd(a,g); const hasErr=r.errs.length>0; if(filter==='err'&&!hasErr)return; out.push({kind:'a',a,g,c}); }))); return out; }
2462
2931
  let selA=null, selB=null, dragging=false;
2463
2932
  function clearSel(){ selA=selB=null; applySelection(); }
2464
2933
  function selRange(){ if(!selA||!selB) return null; return {r0:Math.min(selA.r,selB.r),r1:Math.max(selA.r,selB.r),c0:Math.min(selA.c,selB.c),c1:Math.max(selA.c,selB.c)}; }
2465
2934
  function applySelection(){ const host=document.getElementById('sheetHost'); if(!host) return; host.querySelectorAll('.selcell,.selanchor').forEach(el=>el.classList.remove('selcell','selanchor')); host.querySelectorAll('.cellcombo.comboSel').forEach(el=>el.classList.remove('comboSel')); host.querySelectorAll('td.rownum.rowsel').forEach(el=>el.classList.remove('rowsel')); const rg=selRange(); if(!rg){ if(typeof updateSeqBtn==='function') updateSeqBtn(); return; } host.querySelectorAll('[data-sel]').forEach(el=>{ const r=+el.dataset.r, c=+el.dataset.c; if(r>=rg.r0&&r<=rg.r1&&c>=rg.c0&&c<=rg.c1){ el.classList.add('selcell'); const cb=el.closest('.cellcombo'); if(cb) cb.classList.add('comboSel'); if(selA&&r===selA.r&&c===selA.c) el.classList.add('selanchor'); } }); host.querySelectorAll('td.rownum').forEach(el=>{ const r=+el.dataset.rownum; if(r>=rg.r0&&r<=rg.r1) el.classList.add('rowsel'); }); if(typeof updateSeqBtn==='function') updateSeqBtn(); }
2466
- function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible(); const lines=[]; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue; const cells=[]; for(let c=rg.c0;c<=rg.c1;c++) cells.push(getVar(it,c)); lines.push(cells.join('\t')); } return lines.join('\n'); }
2935
+ function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible();
2936
+ /* C1: 範囲全セルで getVar → get フック列が resolveAd を呼ぶ。読み取り専用パスなのでメモ化する */
2937
+ return withResolvePass(()=>{ const lines=[]; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue; const cells=[]; for(let c=rg.c0;c<=rg.c1;c++) cells.push(getVar(it,c)); lines.push(cells.join('\t')); } return lines.join('\n'); }); }
2467
2938
  function pasteGrid(txt){
2468
2939
  const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
2469
2940
  const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
@@ -2702,11 +3173,27 @@ function renderDrawer(){
2702
3173
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">広告グループがありません</div>'; return;
2703
3174
  }
2704
3175
  if(drawerTarget.type==='tg'){
2705
- const cfg=pf().targeting[drawerTarget.field]; const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
3176
+ const gcol=colDefs('adGroups').find(c=>c.field===drawerTarget.field);
3177
+ const isRef=!!(gcol && gcol.kind==='refpick');
3178
+ const cfg=isRef?null:pf().targeting[drawerTarget.field];
3179
+ const label=(cfg&&cfg.label)||(gcol&&gcol.label)||'';
3180
+ const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
2706
3181
  const cur=(g&&g[drawerTarget.field]?String(g[drawerTarget.field]):'').split(',').map(s=>s.trim()).filter(Boolean);
2707
- titleEl.textContent=cfg.label+' を選択(複数可)';
2708
- tgtEl.textContent='入力先: '+(g&&g.name?g.name:'広告グループ')+' / '+cfg.label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)');
2709
- let h=''; 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>'; });
3182
+ titleEl.textContent=label+' を選択(複数可)';
3183
+ tgtEl.textContent='入力先: '+(g&&g.name?g.name:agLabel())+' / '+label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)');
3184
+ let h='';
3185
+ if(isRef){
3186
+ /* C1: 選択肢は st.refs[col.refTable] (ID→name)。data-tgval には **実ID** を入れる */
3187
+ const store=refPickStore(gcol);
3188
+ Object.keys(store).forEach(id=>{
3189
+ const nm=refPickName(gcol,id);
3190
+ 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>';
3193
+ });
3194
+ listEl.innerHTML=h||'<div class="empty" style="padding:24px">参照データがありません(アカウントJSONの refs.'+esc(gcol.refTable)+')</div>'; return;
3195
+ }
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>'; });
2710
3197
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
2711
3198
  }
2712
3199
  /* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
@@ -2751,16 +3238,25 @@ function setField(u,f,val){
2751
3238
  if(f==='paramCode'){ if(isExistingGroup(found.g)) return; /* F-2: 既存呼び出しAGのparamCodeは編集不可 */ found.g.paramCode=val; autoResolveGroupIds(found.g,found.c); } // group-level
2752
3239
  else { found.a[f]=val; }
2753
3240
  }
3241
+ /* C1: シートのセル編集は **列記述子経由 (setVar)** に一本化する。
3242
+ こうしないと coerce / set フック (refpick の ID 絞り込み・記事URL正規化・refs 双方向編集) が
3243
+ 「貼付では効くのに手入力では効かない」という不整合になる。
3244
+ 列が見つからない場合だけ旧経路 (setField / 直代入) にフォールバックする。 */
2754
3245
  document.addEventListener('change', e=>{
2755
3246
  const t=e.target; if(!t.dataset||!t.dataset.u||!t.dataset.f) return;
2756
3247
  const f=t.dataset.f; const val = t.type==='checkbox'? t.checked : t.value;
2757
- mutate(()=>setField(t.dataset.u,f,val));
3248
+ mutate(()=>{
3249
+ const found=findAd(t.dataset.u); if(!found) return;
3250
+ if(!setVarByField({kind:'a', a:found.a, g:found.g, c:found.c}, 'ads', f, val)) setField(t.dataset.u,f,val);
3251
+ });
2758
3252
  });
2759
3253
  /* 広告グループ/キャンペーン タブの一覧編集 */
2760
3254
  document.addEventListener('change', e=>{
2761
3255
  const t=e.target; if(!t.dataset) return;
2762
- if(t.dataset.gu && t.dataset.gf){ const gf=t.dataset.gf, val=t.value; mutate(()=>{ const r=findGroup(t.dataset.gu); if(r && !isExistingGroup(r.g)){ r.g[gf]=val; if(gf==='paramCode') autoResolveGroupIds(r.g,r.c); } }); return; } // F-2: 既存呼び出しAGは基本パラメータ編集不可
2763
- if(t.dataset.cu && t.dataset.cf){ const cf=t.dataset.cf; let val=t.value; if(cf==='budget'||cf==='tcpa') val=numOrNull(val); mutate(()=>{ const r=findCp(t.dataset.cu); if(r && !isExistingCampaign(r.c)) r.c[cf]=val; }); return; } // F-2: 既存呼び出しCPは基本パラメータ編集不可
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は基本パラメータ編集不可
2764
3260
  });
2765
3261
 
2766
3262
  /* rename via contenteditable (tree) */
@@ -3004,7 +3500,11 @@ async function writePlanFile(name, text){
3004
3500
  File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
3005
3501
  IndexedDB キー 'accountsDir:<platform>' に第2ハンドルを保存 (入稿計画/ の 'dir' とは別枠)。
3006
3502
  ============================================================ */
3007
- const ACC_JSON_VERSION=1;
3503
+ /* C1: アカウントJSON は v2 (mccId / urlParamsEnabled / refs.audiences,placements を追加) まで受ける。
3504
+ **バージョンで分岐しない**: 追加キーはすべて optional なので、読み手は存在チェックだけで読む。
3505
+ v1 のまま置かれたファイルもそのまま動く。 */
3506
+ const ACC_JSON_VERSION=2;
3507
+ const ACC_JSON_VERSIONS=[1,2];
3008
3508
  let accDirHandle=null, pendingAccDirHandle=null;
3009
3509
  let accountFiles=[]; // フォルダ内の <名前>.json のベース名一覧 (File System Access接続時)
3010
3510
  let httpAccountFiles=[]; // <account.dir>/index.json から取得した <名前> 一覧 (読み取り専用切替)
@@ -3133,7 +3633,7 @@ async function writeAccountJson(name, obj){
3133
3633
  }
3134
3634
  /* 参照データ差し替えのシーム: フォルダ読込・新規作成・E2Eテストが共通で使う */
3135
3635
  window.applyAccountJson=function(obj){
3136
- if(!obj||obj.accountJsonVersion!==ACC_JSON_VERSION||!obj.refs) throw new Error('アカウントJSON形式が不正です(accountJsonVersion:'+ACC_JSON_VERSION+' 必須)');
3636
+ if(!obj||ACC_JSON_VERSIONS.indexOf(Number(obj.accountJsonVersion))<0||!obj.refs) throw new Error('アカウントJSON形式が不正です(accountJsonVersion: '+ACC_JSON_VERSIONS.join(' または ')+')');
3137
3637
  /* S9: platform 検証。未記載は 'yda' (旧アカウントJSONの後方互換)。別媒体なら媒体ごと切り替える。 */
3138
3638
  const declared=String(obj.platform||'yda');
3139
3639
  if(declared!==curPf){
@@ -3196,7 +3696,12 @@ async function selectAccount(name, pfId){
3196
3696
  async function saveActiveAccountJson(){
3197
3697
  if(!accDirHandle) return; const name=st.project.accountName; if(!name) return;
3198
3698
  if(!accountFiles.includes(name)) return; // 同梱デフォルト等フォルダ外は書き戻さない
3199
- const obj={accountJsonVersion:ACC_JSON_VERSION, accountId:String(st.project.accountId||''), name, principal:String(st.project.principal||''), updatedAt:new Date().toISOString(), refs:normalizeRefs(st.refs)};
3699
+ /* ⚠ writeAccountJson は JSON.stringify のフル上書き。ここで組み立てたキーしか残らないため、
3700
+ **必ず現ファイルを読んでマージする**。そうしないと、参照表を1件編集しただけで
3701
+ GUI が知らないキー (mccId / urlParamsEnabled / adNamePolicy / platform / projectName /
3702
+ baseArticleUrl …) がファイルから消え、次回読込で管理画面URLやURLパラメータが化ける。 */
3703
+ const prev=await readAccountJson(name);
3704
+ const obj=Object.assign({}, prev||{}, {accountJsonVersion:ACC_JSON_VERSION, accountId:String(st.project.accountId||''), name, principal:String(st.project.principal||''), updatedAt:new Date().toISOString(), refs:normalizeRefs(st.refs)});
3200
3705
  await writeAccountJson(name, obj);
3201
3706
  }
3202
3707
  /* F-3: 「+新規追加」チューザー — ファイルから追加 / AIで追加 の2択 */
@@ -3398,7 +3903,9 @@ function tgList(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Bool
3398
3903
  警告: pf().notSubmittedFields に値があるがJSON未反映の広告グループ数 (tgDropped)。 */
3399
3904
  function buildSubmitPlanWithStats(){
3400
3905
  const P=pf(), p=st.project;
3401
- const stats={exported:0, unresolved:0, pendingImages:0, pendingImageNos:[], emptyGroups:0, emptyCampaigns:0, tgDropped:0};
3906
+ /* droppedRefIds: 参照ID列 (refpick) に入っていた **数値IDでない値** を落とした件数。
3907
+ 旧・日本語ラベルのまま保存された古いプランを黙って通さないための後方互換カウンタ。 */
3908
+ const stats={exported:0, unresolved:0, pendingImages:0, pendingImageNos:[], emptyGroups:0, emptyCampaigns:0, tgDropped:0, droppedRefIds:0};
3402
3909
  /* 行レベル検証を持つ媒体 (Meta の 日予算/入札上限 必須) だけ rowErrors を集計する */
3403
3910
  if(P.validateRow) stats.rowErrors=0;
3404
3911
  const ctx=Object.assign(planCtx(), {stats});
@@ -3424,7 +3931,7 @@ function buildSubmitPlanWithStats(){
3424
3931
  });
3425
3932
  ag[GK]=kept;
3426
3933
  stats.exported+=kept.length;
3427
- // 未反映列 (YDAは地域/オーディエンス/サーチKW) はAPIのID解決が必要 → 出力ありのグループのみ警告計上
3934
+ // 未反映列 (YDAは地域/サーチKW) はAPIのID解決が必要 → 出力ありのグループのみ警告計上
3428
3935
  if(kept.length && notSub.some(f=>String(g[f]||'').trim()!=='')) stats.tgDropped++;
3429
3936
  return ag;
3430
3937
  }).filter(ag=>{ if(!ag) return false; if(ag[GK].length===0){ stats.emptyGroups++; return false; } return true; }); // B9: 空広告グループを除外 (null = 行検証NG)
@@ -3529,7 +4036,7 @@ async function exportSubmitJson(){
3529
4036
  }
3530
4037
  /* S10: 媒体固有のプロジェクト単位ガード (Meta の pageId / pixelId 必須) */
3531
4038
  if(P.validateProject){
3532
- const perrs=P.validateProject(st.project)||[];
4039
+ const perrs=P.validateProject(st.project, st)||[];
3533
4040
  if(perrs.length){ toast(perrs[0].msg); return; }
3534
4041
  }
3535
4042
  const n=stats.exported;
@@ -3554,6 +4061,7 @@ async function exportSubmitJson(){
3554
4061
  info+='<div class="mfield"><label>除外件数の内訳</label><div style="font-size:12.5px">'+(exc.length?esc(exc.join(' / ')):'なし')+'</div></div>';
3555
4062
  if(stats.pendingImages) info+='<div class="callout amber">🆕 未登録画像 <b>'+stats.pendingImages+'</b> 件(管理番号: '+esc(stats.pendingImageNos.join(', '))+')。これらはアカウントのメディアライブラリに未登録のため、入稿前にローカル画像をアップロードして新規登録が必要です(下記プロンプト参照)。</div>';
3556
4063
  if(stats.tgDropped) info+='<div class="callout amber">⚠ '+esc(pf().labels.tgDroppedWarn)+' を設定した広告グループが '+stats.tgDropped+' 件ありますが、これらはAPIのID解決が必要なため入稿JSONには反映されていません(入稿後に管理画面で設定してください)。</div>';
4064
+ if(stats.droppedRefIds) info+='<div class="callout amber">⚠ オーディエンス/配置の列に <b>'+stats.droppedRefIds+'</b> 件、数値IDでない値(旧・日本語ラベル等)が入っていたため入稿JSONから除外しました。参照データから選び直してください。</div>';
3557
4065
  // Claudeに貼るプロンプト: 保存したJSONを Skillの入稿計画/ に入れて submit-plan で入稿を試みる
3558
4066
  // S7: 文言・コマンドは pf().prompts.submitHelp / pf().output.submitCommand (媒体スキーマ)
3559
4067
  const prompt=pf().prompts.submitHelp({fileName, savedRel, wrote,
@@ -3603,9 +4111,10 @@ document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.targe
3603
4111
  /* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
3604
4112
  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'; }); });
3605
4113
  /* 案件テンプレ: チップ直接編集 */
3606
- 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==='number'? Number(el.value) : el.value; mutate(()=>{ st.project[f]=v; }); });
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; }); });
3607
4115
  document.getElementById('btnSubmitJson').addEventListener('click',exportSubmitJson);
3608
4116
  document.getElementById('btnHelp').addEventListener('click',openHelpModal);
4117
+ document.getElementById('btnConsole').addEventListener('click',()=>openConsole('campaigns'));
3609
4118
  document.getElementById('planWarnClose').addEventListener('click',()=>{ planMismatchMsg=''; renderPlanWarn(); });
3610
4119
  document.getElementById('btnConnectFolder').addEventListener('click',connectFolder);
3611
4120
  /* ---- B-1/B-3: アカウントバー wiring ---- */
@@ -3654,7 +4163,7 @@ sheetHostEl.addEventListener('mousedown',e=>{
3654
4163
  pendingDrawer=null; dragMoved=false;
3655
4164
  // S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
3656
4165
  const col=colDefs()[c];
3657
- if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && col.kind==='tgpick' && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
4166
+ if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
3658
4167
  applySelection();
3659
4168
  });
3660
4169
  sheetHostEl.addEventListener('mouseover',e=>{
@@ -3735,7 +4244,17 @@ window.addEventListener('beforeunload', function(e){ if(_dirty){ e.preventDefaul
3735
4244
  if(r.ok){
3736
4245
  const a=await r.json();
3737
4246
  const tp=String(a.platform||'yda'), tn=String(a.accountName||'');
3738
- if(tn && PLATFORMS[tp] && !(tp===curPf && tn===st.project.accountName)) await selectAccount(tn, tp);
4247
+ if(tn && PLATFORMS[tp] && !(tp===curPf && tn===st.project.accountName)){
4248
+ /* アクティブプラン.json は追跡されるが、実クライアントのアカウントJSONは
4249
+ .gitignore で除外される (公開してよいのは index.json と sample_account.json のみ)。
4250
+ そのため clone 直後・CI・他メンバーの環境では、ここで指すファイルが存在しない。
4251
+ 読めないまま進むとプランが空のままプランナーが立ち上がらないので、
4252
+ **存在確認してから切り替え、無ければ同梱デフォルトのまま起動する**。 */
4253
+ const dir=(PLATFORMS[tp].account&&PLATFORMS[tp].account.dir)||LEGACY_ACCOUNT_DIR;
4254
+ const acc=await fetchJson(dir+'/'+encodeURIComponent(tn)+'.json');
4255
+ if(acc) await selectAccount(tn, tp);
4256
+ else toast('アクティブプランのアカウント「'+tn+'」が見つかりません。同梱の既定アカウントで開きます');
4257
+ }
3739
4258
  }
3740
4259
  }catch(e){}
3741
4260
  })();