addashboard-cli 0.4.0 → 0.5.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.
@@ -40,6 +40,8 @@
40
40
  .seg{display:inline-flex; background:var(--soft); border:1px solid var(--line); border-radius:8px; padding:2px;}
41
41
  .seg button{border:0; background:transparent; padding:6px 14px; font-size:12.5px; font-weight:600; color:var(--muted); border-radius:6px; cursor:pointer;}
42
42
  .seg button.active{background:#fff; color:var(--fg); box-shadow:0 1px 2px rgba(16,24,40,.08);}
43
+ /* S9: 媒体セレクタ — 未登録媒体 (PLATFORMS に無い) は disabled 表示。要件ドキュメントは閲覧できる */
44
+ .seg button.pfoff{opacity:.55;}
43
45
  .spacer{flex:1;}
44
46
  .act{display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600; color:var(--fg); background:#fff; border:1px solid var(--line); padding:6px 11px; border-radius:7px; cursor:pointer;}
45
47
  .act:hover{background:var(--soft);}
@@ -294,7 +296,12 @@
294
296
  </head>
295
297
  <body>
296
298
  <header class="bar">
297
- <div class="brand"><span class="logo">YDA入稿プランナー</span><span class="pjname" id="pjLabel">—</span></div>
299
+ <div class="brand"><span class="logo" id="brandLogo">YDA入稿プランナー</span><span class="pjname" id="pjLabel">—</span></div>
300
+ <!-- S10: 媒体セレクタ (ヘッダー常設)。data-pf = PLATFORMS のキー。未登録媒体は pfoff 表示 -->
301
+ <div class="seg" id="pfSeg" title="媒体を切り替える(プランは媒体ごとに別管理)">
302
+ <button data-pf="yda" class="active">YDA</button>
303
+ <button data-pf="meta">Meta</button>
304
+ </div>
298
305
  <!-- F-3: アカウント切替はヘッダーのプルダウンに一本化 -->
299
306
  <span class="acchdr" title="アカウント切替(+新規追加/フォルダ接続/編集もここから)"><select class="chipin" id="accSelect" style="min-width:12ch"></select></span>
300
307
  <div class="seg" id="viewSeg">
@@ -317,9 +324,10 @@
317
324
  <!-- ============ 媒体要件ビュー ============ -->
318
325
  <section class="view" id="view-req">
319
326
  <div class="doc">
327
+ <!-- S9: 媒体セレクタ (要件ドキュメントのタブ兼 媒体切替)。data-pf = PLATFORMS のキー -->
320
328
  <div class="seg" id="mediaTabs" style="display:inline-flex; margin-bottom:16px">
321
- <button data-mt="yda" class="active">YDA</button>
322
- <button data-mt="meta">Meta広告 <span class="pill amber" style="margin-left:4px">検証中</span></button>
329
+ <button data-mt="yda" data-pf="yda" class="active">YDA</button>
330
+ <button data-mt="meta" data-pf="meta">Meta広告 <span class="pill amber" style="margin-left:4px">検証中</span></button>
323
331
  </div>
324
332
 
325
333
  <!-- ======== YDA タブ ======== -->
@@ -529,20 +537,20 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
529
537
  </div>
530
538
  </div><!-- /mt-yda -->
531
539
 
532
- <!-- ======== Meta タブ(検証中) ======== -->
540
+ <!-- ======== Meta タブ ======== -->
533
541
  <div id="mt-meta" style="display:none">
534
- <div class="eyebrow">Meta Ads — 検証範囲・要件一覧(ドラフト)</div>
535
- <h1 class="title">Meta 広告入稿 <span class="pill amber" style="vertical-align:middle">検証中</span></h1>
536
- <p class="sub">2026-07-23 時点のドラフト。backend/CLI には入稿実装が存在するが、<b>プランナーGUIは未対応</b>・実運用検証は未着手。北さんヒアリング(YDA入稿Skillブラッシュアップ)と backend 実装(metaSubmission.ts)にもとづく要件案。</p>
542
+ <div class="eyebrow">Meta Ads — 対応範囲・要件一覧</div>
543
+ <h1 class="title">Meta 広告入稿 <span class="pill amber" style="vertical-align:middle">本番PoC前</span></h1>
544
+ <p class="sub">2026-07-30 更新。backend / CLI / プランナーGUI はすべて実装済み(媒体セレクタで切替)。<b>本番アカウントでの入稿 PoC のみ未実施</b>。北さんヒアリング(YDA入稿Skillブラッシュアップ)と backend 実装(metaSubmission.ts)、Meta Marketing API 公式ドキュメント照合にもとづく。</p>
537
545
 
538
- <details class="sec" open><summary><span class="num">1</span><span class="stitle">概要 — 実装済みの土台</span><span class="chev"></span></summary>
546
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">概要 — 実装状況</span><span class="chev"></span></summary>
539
547
  <div class="body">
540
- <p class="lead">API側の土台は実装済み。GUI(プランナー)と実運用フローが未整備という状態。</p>
548
+ <p class="lead">全レイヤ実装済み。残るのは本番アカウントでの実入稿検証(PAUSED で1キャンペーンから)。</p>
541
549
  <table class="g"><thead><tr><th>レイヤ</th><th>状態</th><th>内容</th></tr></thead><tbody>
542
- <tr><td>backend</td><td><span class="pill green">実装済み</span></td><td><code>/api/submission/meta</code> 系ルート + <code>MetaSubmissionConnector</code>(オーディエンス取得 / 既存キャンペーン取得・インポート / キャンペーン作成 / 動画ライブラリ)</td></tr>
543
- <tr><td>CLI</td><td><span class="pill green">実装済み</span></td><td><code>webgram submit --platform meta</code>(動画は upload-video で mediaId 解決 → submit。dry-run 対応)</td></tr>
544
- <tr><td>プランナーGUI</td><td><span class="pill danger">未対応</span></td><td>Meta用のシート/ツリー/参照データ/入稿JSON書き出しは未実装(本タブは仕様整理のみ)</td></tr>
545
- <tr><td>実運用検証</td><td><span class="pill amber">未着手</span></td><td>本番アカウントでの PoC 入稿・運用フロー確立が未実施</td></tr>
550
+ <tr><td>backend</td><td><span class="pill green">実装済み</span></td><td><code>/api/submission/meta</code> 系ルート + <code>MetaSubmissionConnector</code>(オーディエンス取得 / 既存キャンペーン取得・インポート / キャンペーン作成 / 動画・画像ライブラリ / <code>upload-video</code>・<code>upload-image</code>)。2026-07-30 に公式Docs照合で12件の乖離を修正(API v26.0 集約 / <code>instagram_user_id</code> / <code>promoted_object</code> / JPY 予算 / schema 一本化)</td></tr>
551
+ <tr><td>CLI</td><td><span class="pill green">実装済み</span></td><td><code>webgram submit --platform meta</code>(単発)と <code>webgram submit-plan</code>(入稿計画JSON・dry-run一括検証 → 1CPずつ本実行・進捗書き戻しで再開可)</td></tr>
552
+ <tr><td>プランナーGUI</td><td><span class="pill green">実装済み</span></td><td>ヘッダーの媒体セレクタで Meta に切替。シート(広告 / 広告セット / キャンペーン)・ツリー・参照データ・入稿JSON書き出しに対応。画像・動画の両形式(形式列 IMAGE/VIDEO)</td></tr>
553
+ <tr><td>本番入稿検証</td><td><span class="pill amber">未着手</span></td><td>本番アカウントでの PoC 入稿・運用フロー確立が未実施。<b>PAUSED・1キャンペーン・最小予算</b>から始め、管理画面で目視確認する</td></tr>
546
554
  </tbody></table>
547
555
  </div></details>
548
556
 
@@ -577,18 +585,20 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
577
585
  <li><b>契約確認</b>:<code>webgram submit --platform meta --json-file fixtures/meta_poc.json</code> を dry-run で実行し、送信ボディの構造を確認する。</li>
578
586
  <li><b>既存構造の取り込み</b>:既存キャンペーンのインポート(<code>importCampaign</code>)で、実運用中のキャンペーン設定(7項目)を吸い上げてテンプレ化できるか検証。</li>
579
587
  <li><b>動画アップロード</b>:upload-video → 動画ライブラリ反映 → 広告作成の一連を dry-run→少額 PoC で確認。</li>
580
- <li><b>本番PoC</b>:PAUSED で1キャンペーン入稿 → 管理画面で目視確認 → 問題なければ配信オン。</li>
581
- <li><b>GUI対応判断</b>:PoC の結果を踏まえ、プランナーのMeta対応(媒体切替・Meta用参照データ・入稿JSON書き出し)の要件を確定する。</li>
588
+ <li><b>本番PoC(残作業)</b>:プランナーで1キャンペーンだけ計画 → 入稿JSON書き出し → <code>submit-plan</code> dry-run → <b>配信オフ(PAUSED)・最小予算</b>で本実行 → Ads Manager で目視確認 → 問題なければ配信オン。</li>
582
589
  </ol>
583
590
  </div></details>
584
591
 
585
- <details class="sec" open><summary><span class="num">5</span><span class="stitle">未確定事項</span><span class="chev"></span></summary>
592
+ <details class="sec" open><summary><span class="num">5</span><span class="stitle">未確定事項・未対応</span><span class="chev"></span></summary>
586
593
  <div class="body">
594
+ <p class="lead">命名規則は <b>YDA 踏襲で確定</b>(<code>{番号}{i|v}-{見出し}L{本文}-{paramCode}</code> / URL は <code>?ad=&amp;creative=</code>)。以下が残っている。</p>
587
595
  <ul>
588
596
  <li>Meta広告フィードバックの所在と最終確認者(タスク確認事項より・未回答)。</li>
589
- <li>命名規則(YDAの <code>{画像番号}{c|s}-{title}L{desc}-{paramCode}</code> に相当するもの)をMetaでどう定義するか。</li>
590
597
  <li>クリエイティブ検証の単位(YDAの「1キャンペーン=1変数」に相当する実験設計)。</li>
591
- <li>プランナーGUIでのMeta対応範囲(シート列構成・参照データの持ち方)。</li>
598
+ <li><b>未対応</b>:カルーセル / コレクション広告(1広告=1メディアの形式軸を超えるため)。</li>
599
+ <li><b>未対応</b>:カスタム・類似オーディエンスの ID 解決(列自体を出していない。YDA の <code>tg_audience</code> と同根)。</li>
600
+ <li><b>未対応</b>:地域ターゲティングの細分化(国単位 <code>JP</code> 固定。都道府県・市区町村は未対応)。</li>
601
+ <li><b>未対応</b>:通算予算(<code>lifetimeBudget</code>)。日予算一本に固定している。</li>
592
602
  </ul>
593
603
  </div></details>
594
604
  </div><!-- /mt-meta -->
@@ -691,14 +701,36 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
691
701
  YDA入稿プランナー — 単一state / シート・ツリー両ビュー投影
692
702
  ============================================================ */
693
703
  /* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
694
- 同梱デフォルト: アカウント/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
704
+ 同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
695
705
  固有名詞はハードコードせず、すべて外部JSON由来にする。 */
696
706
  let bundledRefs=null; let bundledPrincipal=''; let bundledProjectName=''; let bundledBaseUrl=''; let samplePlan=null;
697
707
  let planMismatchMsg=''; // サンプルプランと現在アカウントの accountId 不一致警告 (持続バナー用)
698
708
  function fetchJson(rel){ try{ return fetch(rel,{cache:'no-store'}).then(r=>r.ok?r.json():null).catch(()=>null); }catch(e){ return Promise.resolve(null); } }
699
- const LS_KEY = 'yda_planner_state_v1';
700
- const HIST_KEY = 'yda_planner_history_v1';
701
- const AUTHOR_KEY = 'yda_planner_author';
709
+ /* ---- S9: 永続化キー (ad_planner_* / 媒体別) ----
710
+ 媒体スキーマ駆動化に伴い、localStorage / IndexedDB のキーを `ad_planner_` 名前空間へ移し、
711
+ プラン state と履歴は媒体別 (`…:<platform>`) に分ける。作成者名だけは媒体共通。
712
+ 旧 `yda_planner_*` キーからは起動時に1回だけ片方向コピーする (migrateLegacyLocalStorage)。 */
713
+ const KEY_NS = 'ad_planner_';
714
+ const LEGACY_KEY_NS = 'yda_planner_';
715
+ const AUTHOR_KEY = KEY_NS + 'author';
716
+ const MIGRATED_KEY = KEY_NS + 'migrated_v1';
717
+ function stateKey(){ return KEY_NS + 'state_v1:' + curPf; }
718
+ function histKey(){ return KEY_NS + 'history_v1:' + curPf; }
719
+ function bodyKey(vid){ return KEY_NS + 'histbody_' + curPf + ':' + vid; }
720
+ /* 旧キー → 新キー の片方向コピー (旧キーは消さない: 旧版HTMLと併用しても壊れないように)。 */
721
+ function migrateLegacyLocalStorage(){
722
+ try{
723
+ if(localStorage.getItem(MIGRATED_KEY)) return;
724
+ const copy=(from,to)=>{ try{ const v=localStorage.getItem(from); if(v!=null && localStorage.getItem(to)==null) localStorage.setItem(to,v); }catch(e){} };
725
+ copy(LEGACY_KEY_NS+'state_v1', KEY_NS+'state_v1:yda');
726
+ copy(LEGACY_KEY_NS+'history_v1', KEY_NS+'history_v1:yda');
727
+ copy(LEGACY_KEY_NS+'author', AUTHOR_KEY);
728
+ const legacyBody = LEGACY_KEY_NS+'histbody_';
729
+ const keys=[]; for(let i=0;i<localStorage.length;i++){ const k=localStorage.key(i); if(k && k.indexOf(legacyBody)===0) keys.push(k); }
730
+ keys.forEach(k=>copy(k, KEY_NS+'histbody_yda:'+k.slice(legacyBody.length)));
731
+ localStorage.setItem(MIGRATED_KEY, new Date().toISOString());
732
+ }catch(e){}
733
+ }
702
734
  const SCHEMA_VERSION = 3;
703
735
 
704
736
  /* ---- utils ---- */
@@ -709,46 +741,648 @@ const glen = s => [...String(s==null?'':s)].length;
709
741
  const today = () => { const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
710
742
  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); }
711
743
 
744
+ /* ---- エラー / 警告の安定コード (媒体非依存の語彙) ----
745
+ S2: UI判定 (セル赤枠 / ghost / ツリーのドット) は日本語文字列ではなくこのコードで行う。
746
+ resolveAd().errs / warns の日本語ラベルは E2E 互換のため据え置き、errCodes / warnCodes を並走させる。 */
747
+ const EC = {
748
+ IMAGE_UNRESOLVED:'IMAGE_UNRESOLVED', TITLE_UNRESOLVED:'TITLE_UNRESOLVED',
749
+ DESC_UNRESOLVED:'DESC_UNRESOLVED', TITLE_TOO_LONG:'TITLE_TOO_LONG',
750
+ DESC_TOO_LONG:'DESC_TOO_LONG', PARAM_CODE_EMPTY:'PARAM_CODE_EMPTY',
751
+ MEDIA_MISSING:'MEDIA_MISSING' /* = pendingImage (warn) */,
752
+ TITLE_LONG_WARN:'TITLE_LONG_WARN', DESC_LONG_WARN:'DESC_LONG_WARN',
753
+ BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING'
754
+ };
755
+
712
756
  /* ---- refs (VLOOKUP代替) ----
713
757
  B-1: refs は6テーブル (titles/descriptions/media/campaigns/adGroups/groups)。
714
- 参照データの正は「アカウント/<名前>.json」で、フォルダ接続で差し替える (applyAccountJson)。
715
- 同梱デフォルトアカウントは アカウント/index.json の先頭エントリを起動時に読み込んで決める(固有名詞のハードコードなし)。 */
758
+ 参照データの正は「アカウント/<媒体>/<名前>.json」で、フォルダ接続で差し替える (applyAccountJson)。
759
+ 同梱デフォルトアカウントは <account.dir>/index.json の先頭エントリを起動時に読み込んで決める(固有名詞のハードコードなし)。 */
716
760
  let BUNDLED_ACCOUNT_ID = '';
717
761
  let BUNDLED_ACCOUNT_NAME = '';
718
- function emptyRefs(){ return {titles:{}, descriptions:{}, media:{}, campaigns:{}, adGroups:{}, groups:{}}; }
719
- function validRefs(r){ return !!(r&&r.titles&&r.descriptions&&r.media&&r.groups); }
720
- function normalizeRefs(r){ const e=emptyRefs(); if(!r) return e; return {titles:r.titles||{}, descriptions:r.descriptions||{}, media:r.media||{}, campaigns:r.campaigns||{}, adGroups:r.adGroups||{}, groups:r.groups||{}}; }
762
+ /* ============================================================
763
+ S7: PLATFORMS — 媒体スキーマ定義レジストリ
764
+ ------------------------------------------------------------
765
+ コアは PLATFORMS[curPf] (= pf()) しか見ない。媒体固有の語彙・enum・命名規則・
766
+ 既定値・プロンプト文言・入稿ボディ生成はすべてこのオブジェクトに閉じる。
767
+ window.PLATFORMS / window.curPf として公開する (E2E が媒体切替を検査するため
768
+ const/let ではなく var で宣言している)。
769
+ ============================================================ */
770
+ var PLATFORMS = {};
771
+ var curPf = 'yda';
772
+ function pf(){ return PLATFORMS[curPf]; }
773
+ /* ---- S9: 媒体セレクタ (#mediaTabs の data-pf ボタン) ----
774
+ 未登録媒体 (PLATFORMS に定義が無い) は disabled 表示で、媒体要件ドキュメントの閲覧のみ許す。
775
+ 媒体を切り替えると state は当該媒体の既定値で作り直す (プランは媒体をまたげない)。 */
776
+ function showMediaDoc(id){ ['yda','meta'].forEach(k=>{ const el=document.getElementById('mt-'+k); if(el) el.style.display = (k===id?'':'none'); }); }
777
+ function renderPlatformTabs(){
778
+ document.querySelectorAll('#pfSeg button[data-pf], #mediaTabs button[data-pf]').forEach(b=>{
779
+ const id=b.dataset.pf, ok=!!PLATFORMS[id];
780
+ b.classList.toggle('pfoff', !ok); /* 未登録媒体 = 選択不可の見え方 (要件ドキュメントは開ける) */
781
+ b.dataset.pfstate = ok?'ready':'disabled';
782
+ b.title = ok ? (PLATFORMS[id].displayName+' に切り替える') : '準備中(媒体スキーマ未登録): 媒体要件の閲覧のみ';
783
+ b.classList.toggle('active', id===curPf);
784
+ });
785
+ showMediaDoc(curPf);
786
+ renderChrome();
787
+ }
788
+ /* ---- S10: 媒体で変わる UI 文言 (タイトル / ブランド名 / シートタブ / 参照データタブ) ----
789
+ 媒体語彙 (「広告グループ」「登録画像」…) を静的HTMLに残さず、媒体スキーマから流し込む。 */
790
+ /* 中間階層のラベル (YDA=広告グループ / Meta=広告セット)。UI 文言はここから供給する */
791
+ function agLabel(){ const P=pf(); return (P&&P.levels&&P.levels.adGroup&&P.levels.adGroup.label)||'広告グループ'; }
792
+ function renderChrome(){
793
+ const P=pf(); if(!P) return;
794
+ try{ document.title = P.brandTitle+' / 媒体要件 — WEBGRAM'; }catch(e){}
795
+ const bl=document.getElementById('brandLogo'); if(bl) bl.textContent=P.brandTitle;
796
+ const stLabel={ads:P.levels.ad.label, groups:P.levels.adGroup.label, campaigns:P.levels.campaign.label};
797
+ document.querySelectorAll('#sheetTabs button[data-st]').forEach(b=>{
798
+ const l=stLabel[b.dataset.st]; if(l) b.textContent=l;
799
+ });
800
+ document.querySelectorAll('#refTabs button[data-rt]').forEach(b=>{
801
+ const T=P.refTables[b.dataset.rt]; if(T) b.textContent=(T.countLabel||T.label);
802
+ });
803
+ renderFixedType();
804
+ }
805
+ /* 固定タイプチップ (変更不可) は媒体定義 pf().fixedType から描画する。
806
+ YDA=レスポンシブ/画像、Meta=単一画像 / 単一動画。コアに媒体語彙を持たない。 */
807
+ function renderFixedType(){
808
+ const host=document.getElementById('planTools'); if(!host) return;
809
+ const list=(pf()&&pf().fixedType)||[];
810
+ if(!list.length){ host.innerHTML=''; host.hidden=true; return; }
811
+ host.hidden=false;
812
+ host.innerHTML='<span class="chip" title="このプランナーは固定タイプです(変更不可)">タイプ'
813
+ + list.map(f=>'<select class="chipin" disabled title="'+esc(f.label)+'(固定)"><option>'+esc(f.value)+'</option></select>').join('')
814
+ + '</span>';
815
+ }
816
+ window.switchPlatform=function(id){
817
+ if(!PLATFORMS[id]){ toast('この媒体はまだ選択できません(媒体スキーマ未登録)'); return false; }
818
+ if(id===curPf){ renderPlatformTabs(); return true; }
819
+ curPf=id;
820
+ applyBundle(id); /* S10: 同梱アカウント / サンプルプランは媒体別 */
821
+ refTab=firstRefTabKey(); /* 参照データタブは媒体の宣言順で先頭に戻す */
822
+ st=initialState();
823
+ if(typeof clearSel==='function') clearSel();
824
+ if(typeof closeDrawer==='function') closeDrawer();
825
+ buildDatalists(); render(); renderAccountBar(); renderPlatformTabs();
826
+ fetchAccountList().then(()=>renderAccountBar()).catch(()=>{});
827
+ toast(pf().displayName+' に切り替えました');
828
+ return true;
829
+ };
830
+ /* ラベル配列 → API enum 配列。allLabel (「全てのデバイス」等) を含むか空なら [] = 指定なし。 */
831
+ function mapByEnum(s, table, allLabel){
832
+ const l=tgList(s);
833
+ if(!l.length || (allLabel && l.indexOf(allLabel)>=0)) return [];
834
+ return l.map(x=>table[x]).filter(Boolean);
835
+ }
836
+
837
+ /* ---- S6: 参照テーブル記述子 (REF_TABLES = PLATFORMS.yda.refTables) ----
838
+ refs の各テーブル定義。参照データビューの表・件数チップ・新規登録/編集/削除モーダル・datalist・
839
+ emptyRefs/normalizeRefs/validRefs はすべてこの宣言だけを見る。
840
+ ・key/label : st.refs のキー / モーダル見出しに使う名称 ('画像' など)
841
+ ・countLabel : #refCount の文言 ('登録画像 633/633件')
842
+ ・editable : 新規登録/編集/削除/リセットを許可 (=行末に操作列を出す)
843
+ ・copyable : 「他アカウントから文言コピー」対象
844
+ ・required : validRefs() の必須テーブル
845
+ ・keyKind : 'number' = 数値ソート / 'string' = localeCompare ソート
846
+ ・keyLabel/keyWidth : キー列の見出しと幅 (モーダルのキー項目ラベル・警告文にも使う)
847
+ ・dropEmpty : 値が空文字のエントリを一覧から除外 (タイトル/説明文)
848
+ ・valueFields : 値のフィールド。1件=文言そのものを格納 / 複数=オブジェクトを格納
849
+ ・columns : キー列の右に並ぶ表示列 {label,width,cls,style,attr,cell}
850
+ ・keyCell : キー列の <td> (媒体固有の見せ方: 画像は sw ピル + new バッジ)
851
+ ・optionLabel : datalist の option テキスト
852
+ ・lengthLimit : 文字数セルの warn/err 閾値 (null = 判定なし) */
853
+ const REF_TABLES = {
854
+ titles:{ key:'titles', label:'タイトル', countLabel:'タイトル', editable:true, copyable:true, required:true,
855
+ keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
856
+ addSub:'番号→文言。タイトル枠は20字目安・35字上限',
857
+ emptyMsg:'該当するタイトルがありません',
858
+ lengthLimit:{warn:20, err:35},
859
+ valueFields:[{k:'text', label:'文言'}],
860
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
861
+ columns:[
862
+ {label:'タイトル', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
863
+ {label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(REF_TABLES.titles,v), cell:(no,v)=>glen(v)}
864
+ ],
865
+ optionLabel:v=>String(v==null?'':v) },
866
+ descriptions:{ key:'descriptions', label:'説明文', countLabel:'説明文', editable:true, copyable:true, required:true,
867
+ keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
868
+ addSub:'番号→説明文(長文可)',
869
+ emptyMsg:'該当する説明文がありません',
870
+ /* 現行は説明文に文字数チェックなし (docs の90字上限を err にするのは別フェーズ) */
871
+ lengthLimit:null,
872
+ valueFields:[{k:'text', label:'文言'}],
873
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
874
+ columns:[
875
+ {label:'説明文', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
876
+ {label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
877
+ ],
878
+ optionLabel:v=>String(v==null?'':v) },
879
+ media:{ key:'media', label:'画像', countLabel:'登録画像', editable:true, required:true,
880
+ keyKind:'number', keyLabel:'画像番号', keyWidth:'80px',
881
+ addSub:'画像番号→Card/SQ の mediaID。新規作成した画像をメディアに登録',
882
+ emptyMsg:'該当する画像がありません',
883
+ valueFields:[{k:'card', label:'Card mediaID (1200)', editLabel:'Card mediaID'},
884
+ {k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'}],
885
+ keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
886
+ columns:[
887
+ {label:'Card mediaID (1200)', cls:'mono', cell:(no,v)=>esc(v&&v.card||'—')},
888
+ {label:'SQ mediaID (1080)', cls:'mono', cell:(no,v)=>esc(v&&v.sq||'—')},
889
+ {label:'ファイル名', cls:'mono', style:'color:var(--faint);font-size:11px',
890
+ cell:no=>esc(no)+'-1200.jpg / '+esc(no)+'-1080.jpg'}
891
+ ],
892
+ optionLabel:v=>((v&&v.card?'Card':'')+(v&&v.card&&v.sq?'/':'')+(v&&v.sq?'SQ':'')),
893
+ /* ▼ドロワー(一覧)の1行テキスト。媒体ごとの値の見せ方はここだけが持つ */
894
+ drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—') },
895
+ adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
896
+ keyKind:'string', keyLabel:'広告グループID', keyWidth:'110px',
897
+ emptyMsg:'既存の広告グループがありません(アカウントJSONの refs.adGroups)',
898
+ valueFields:[{k:'name',label:'名'},{k:'paramCode',label:'paramCode'},{k:'campaignId',label:'キャンペーンID'}],
899
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
900
+ columns:[
901
+ {label:'広告グループ名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
902
+ {label:'paramCode', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.paramCode||'')},
903
+ {label:'キャンペーンID', width:'110px', cls:'mono', cell:(no,v)=>esc(v&&v.campaignId||'')}
904
+ ] },
905
+ campaigns:{ key:'campaigns', label:'キャンペーン', countLabel:'キャンペーン', editable:false, required:false,
906
+ keyKind:'string', keyLabel:'キャンペーンID', keyWidth:'120px',
907
+ emptyMsg:'既存のキャンペーンがありません(アカウントJSONの refs.campaigns)',
908
+ valueFields:[{k:'name',label:'名'}],
909
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
910
+ columns:[
911
+ {label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
912
+ ] }
913
+ };
914
+ /* ============================================================
915
+ PLATFORMS.yda — YDA (Yahoo!ディスプレイ広告) の媒体スキーマ
916
+ ------------------------------------------------------------
917
+ S7/S8: 値・関数はすべて S6 以前のコア実装からそのまま移設したもの (挙動不変)。
918
+ コア側に媒体語彙 (imageNo / GT_RANGE / MORE_DETAIL / paramCode 既定値 …) を残さない。
919
+ ============================================================ */
920
+ PLATFORMS.yda = {
921
+ id:'yda', displayName:'YDA', brandTitle:'YDA入稿プランナー',
922
+
923
+ /* ---- account: アカウントJSON (アカウント/yda/<名前>.json) の扱い ---- */
924
+ account:{
925
+ idPattern:/^\d+$/, idHint:'YDA アカウントID(数値)', dir:'アカウント/yda',
926
+ /* アカウントJSON → st.project への写し込み (refs は コア側で normalizeRefs) */
927
+ apply(obj, project){
928
+ project.accountId=String(obj.accountId||'');
929
+ project.accountName=String(obj.name||'');
930
+ /* E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持 */
931
+ if(obj.principal!=null) project.principal=String(obj.principal);
932
+ },
933
+ /* AIプロンプトに埋め込む refs スキーマ例 (媒体固有。ここ1箇所だけで定義する) */
934
+ schemaLines(opts){
935
+ const o=opts||{};
936
+ return ['{',' "accountJsonVersion": 1,',' "accountId": "<ID>",',
937
+ ' "name": "'+(o.nameHint||'<アカウント名>')+'",',
938
+ ' "principal": "<主体者表記(例: ○○製薬)>",']
939
+ .concat(o.updatedAt===false?[]:[' "updatedAt": "<ISO8601>",'])
940
+ .concat([
941
+ ' "refs": {',
942
+ ' "titles": {"1": "見出し文言"},',
943
+ ' "descriptions": {"1": "説明文文言"},',
944
+ ' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
945
+ ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
946
+ ' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
947
+ ' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
948
+ ' }','}']);
949
+ }
950
+ },
951
+
952
+ /* ---- 3階層のラベルと入稿計画JSONのキー ---- */
953
+ levels:{
954
+ campaign:{label:'キャンペーン', childKey:'adGroups', planKey:'adGroups'},
955
+ adGroup :{label:'広告グループ', childKey:'ads', planKey:'ads'},
956
+ ad :{label:'広告'}
957
+ },
958
+ budgetLevel:'campaign',
959
+ /* 管理画面 (SPAハッシュルート)。{mcc} は YDA の loginAccountId */
960
+ consoleUrl:'https://ads.yahoo.co.jp/manager/#/{mcc}/{accountId}/list/{resource}',
961
+ consoleMcc:'1002385608',
962
+
963
+ /* ---- 形式(Card/SQ) と クリエイティブ名サフィックス・media 値のキーの対応 ---- */
964
+ mediaFormats:[
965
+ {id:'Card', crSuffix:'c', mediaKey:'card', apiMediaKind:'IMAGE', label:'Card mediaID (1200)'},
966
+ {id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'}
967
+ ],
968
+ fmtOf(ad){ const F=PLATFORMS.yda.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
969
+
970
+ /* ---- 参照テーブル (S6 の REF_TABLES) ---- */
971
+ refTables:REF_TABLES,
972
+
973
+ /* ---- 共通設定チップ (renderTplChips のループ元) ---- */
974
+ projectFields:[
975
+ {key:'goal', label:'目的', kind:'selectv', fallback:'CONVERSION',
976
+ options:[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},
977
+ {v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}]},
978
+ {key:'startDate', label:'開始日', kind:'text', width:'10ch', fallback:''},
979
+ {key:'cta', label:'CTA', kind:'text', width:'7ch'},
980
+ {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
981
+ {key:'attributionDays', label:'計測', kind:'number', width:'3ch', suffix:'日'},
982
+ {key:'displayUrlFormat',label:'表示URL', kind:'text', width:'13ch'},
983
+ {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'}
984
+ ],
985
+ /* プランツールバーの固定プルダウン (変更不可) */
986
+ fixedType:[{label:'広告タイプ',value:'レスポンシブ'},{label:'メイン形式',value:'画像'}],
987
+
988
+ /* ---- ターゲティング列の選択肢 (旧 TG_OPTIONS)。キーは COLS の tgKey ---- */
989
+ targeting:{
990
+ tg_device:{label:'デバイス', multi:true, allLabel:'全てのデバイス',
991
+ opts:['全てのデバイス','PC','スマートフォン','タブレット']},
992
+ tg_gender:{label:'性別', multi:true, opts:['男性','女性','不明']},
993
+ tg_age:{label:'年齢', multi:true, opts:['18-19歳','20-24歳','25-29歳','30-34歳','35-39歳','40-44歳','45-49歳','50-54歳','55-59歳','60-64歳','65-69歳','70歳以上','不明']},
994
+ tg_region:{label:'地域', multi:true, opts:['全国','北海道','青森県','岩手県','宮城県','秋田県','山形県','福島県','茨城県','栃木県','群馬県','埼玉県','千葉県','東京都','神奈川県','新潟県','富山県','石川県','福井県','山梨県','長野県','岐阜県','静岡県','愛知県','三重県','滋賀県','京都府','大阪府','兵庫県','奈良県','和歌山県','鳥取県','島根県','岡山県','広島県','山口県','徳島県','香川県','愛媛県','高知県','福岡県','佐賀県','長崎県','熊本県','大分県','宮崎県','鹿児島県','沖縄県']},
995
+ tg_dayHour:{label:'曜日・時間帯', multi:true, allLabel:'全ての曜日・時間帯',
996
+ opts:['全ての曜日・時間帯','平日','土日祝','月','火','水','木','金','土','日','午前(6-12時)','午後(12-18時)','夜間(18-24時)','深夜(0-6時)']},
997
+ tg_audience:{label:'オーディエンスリスト', multi:true,
998
+ opts:['サイトリターゲティング','類似ユーザー','オーディエンスカテゴリー','サーチキーワード','購買データ','属性・興味関心']}
999
+ },
1000
+
1001
+ /* ---- 画面の日本語ラベル → API enum 変換表 (正: frontend/src/pages/submission/types.ts) ---- */
1002
+ enums:{
1003
+ device:{'スマートフォン':'SMARTPHONE','PC':'DESKTOP','タブレット':'TABLET'},
1004
+ gender:{'男性':'ST_MALE','女性':'ST_FEMALE','不明':'ST_UNKNOWN'},
1005
+ age:{'18-19歳':'GT_RANGE18_19','20-24歳':'GT_RANGE20_24','25-29歳':'GT_RANGE25_29','30-34歳':'GT_RANGE30_34','35-39歳':'GT_RANGE35_39','40-44歳':'GT_RANGE40_44','45-49歳':'GT_RANGE45_49','50-54歳':'GT_RANGE50_54','55-59歳':'GT_RANGE55_59','60-64歳':'GT_RANGE60_64','65-69歳':'GT_RANGE65_69','70歳以上':'GT_RANGE70_UL2','不明':'GT_UNKNOWN2'},
1006
+ day:{'月':'MONDAY','火':'TUESDAY','水':'WEDNESDAY','木':'THURSDAY','金':'FRIDAY','土':'SATURDAY','日':'SUNDAY'},
1007
+ dayGroups:{'平日':['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY'],'土日祝':['SATURDAY','SUNDAY']},
1008
+ // tCPA=コンバージョン数最大化+目標CPA / 最大配信=コンバージョン数最大化(目標なし・要実機確認)
1009
+ bidding:{'tCPA':'MAXIMIZE_CONVERSIONS','最大配信':'MAXIMIZE_CONVERSIONS'},
1010
+ biddingFallback:'MAXIMIZE_CONVERSIONS',
1011
+ /* CTA(案件設定の日本語ラベル) → YDA buttonText enum。入稿ADDでは必須。
1012
+ 未知ラベルは MORE_DETAIL(=「詳しくはこちら」) にフォールバック(実データ既定)。 */
1013
+ 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'},
1014
+ ctaFallback:'MORE_DETAIL',
1015
+ statusOn:'ACTIVE', statusOff:'PAUSED'
1016
+ },
1017
+
1018
+ /* ---- mappers: セル値(日本語ラベルのCSV) → API 値 ---- */
1019
+ mappers:{
1020
+ devices(s, P){ return mapByEnum(s, P.enums.device, P.targeting.tg_device.allLabel); },
1021
+ ages(s, P){ return mapByEnum(s, P.enums.age); },
1022
+ genders(s, P){ return mapByEnum(s, P.enums.gender); },
1023
+ /* 曜日・時間帯セル → {days[], hours{start,end}|null}。「平日」「土日祝」は展開、
1024
+ 時間帯(午前(6-12時)等)は startHour/endHour に変換 (複数選択時は最初の1つ) */
1025
+ dayHour(s, P){
1026
+ const l=tgList(s), days=[]; let hours=null;
1027
+ if(!l.length || l.indexOf(P.targeting.tg_dayHour.allLabel)>=0) return {days:[], hours:null};
1028
+ const add=d=>{ if(days.indexOf(d)<0) days.push(d); };
1029
+ l.forEach(x=>{
1030
+ if(P.enums.dayGroups[x]) P.enums.dayGroups[x].forEach(add);
1031
+ else if(P.enums.day[x]) add(P.enums.day[x]);
1032
+ else { const m=x.match(/\((\d+)-(\d+)時\)/); if(m&&!hours) hours={start:+m[1], end:+m[2]}; }
1033
+ });
1034
+ return {days, hours};
1035
+ },
1036
+ status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
1037
+ cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; }
1038
+ },
1039
+
1040
+ /* ---- 既定値 ---- */
1041
+ defaults:{
1042
+ /* E-2: 新規CPの予算は固定 12,000 円 */
1043
+ newCampaignBudget:12000,
1044
+ /* 案件名・主体者・ベースURL・accountId は同梱デフォルトアカウント由来なのでコア側 defaultProject() が足す */
1045
+ project:{
1046
+ cta:'もっとみる', adType:'レスポンシブ', mainMediaFormat:'画像',
1047
+ deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
1048
+ attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
1049
+ budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
1050
+ },
1051
+ ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adIdOverride:'', desc2:''},
1052
+ campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, variable:'', urlNumber:''}
1053
+ },
1054
+
1055
+ /* ---- factories: 行の生成 (リテラル生成をコアから撤去) ---- */
1056
+ factories:{
1057
+ newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.yda.defaults.ad, seed||{}); },
1058
+ newAdGroup(c, i){ const F=PLATFORMS.yda.factories;
1059
+ return {uid:uid('g'), name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), paramCode:'', isExisting:false, ads:[F.newAd()]}; },
1060
+ /* E-2: 新規キャンペーン (空のAG1を1つ内包)。isExisting:false = 常に編集可 */
1061
+ newCampaign(seed){ const p=st.project, D=PLATFORMS.yda.defaults, F=PLATFORMS.yda.factories;
1062
+ return Object.assign({uid:uid('c')}, D.campaign, {
1063
+ budget:D.newCampaignBudget, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault,
1064
+ adGroups:[F.newAdGroup(null,0)]
1065
+ }, seed||{}); },
1066
+ /* E-3/F-2: 既存キャンペーン呼び出し (campaignIdを持つ広告追加専用の器)。isExisting:true = 基本パラメータ編集不可 */
1067
+ existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
1068
+ isExisting:true, variable:'', urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; },
1069
+ /* F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める */
1070
+ existingAdGroup(agId, meta){ const F=PLATFORMS.yda.factories; const m=meta||{};
1071
+ return {uid:uid('g'), name:m.name||('AG '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
1072
+ isExisting:true, ads:[F.newAd()]}; }
1073
+ },
1074
+
1075
+ /* ---- naming: クリエイティブ名 / 広告名 / 最終URL / キャンペーン名 ---- */
1076
+ naming:{
1077
+ crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
1078
+ adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
1079
+ finalUrl(ad, group, adName, project){
1080
+ return (project.baseArticleUrl||'') + (ad.articleSlug||'')
1081
+ + '?ad=' + (group.paramCode||'')
1082
+ + '&creative=' + (group.paramCode||'') + '-' + adName;
1083
+ },
1084
+ /* キャンペーン名 自動採番: {空き番号2桁}_{CR名}_{今日} */
1085
+ campaignName(urlNumber, firstAd, P){
1086
+ const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
1087
+ return String(urlNumber) + '_' + cr + '_' + today();
1088
+ }
1089
+ },
1090
+ /* 「クリエイティブをクリア」対象フィールド */
1091
+ creativeFields:['imageNo','titleNo','descNo'],
1092
+ /* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象) */
1093
+ notSubmittedFields:['tg_region','tg_audience','tg_searchKw'],
1094
+ labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode',
1095
+ tgDroppedWarn:'地域/オーディエンス/サーチKW'},
1096
+
1097
+ /* ---- tree ビューの媒体固有バッジ (DOM構造は renderTree 側と一致させる) ---- */
1098
+ tree:{
1099
+ campaignBadges(c, P, p){
1100
+ return '<span class="chip">予算 <b>'+esc((c.budget??P.defaults.newCampaignBudget).toLocaleString())+'</b>円</span>'
1101
+ + '<span class="chip">'+esc(c.biddingStrategy||p.biddingStrategyDefault)+' <b>'+esc((c.tcpa??p.tcpaDefault))+'</b></span>';
1102
+ },
1103
+ groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'; },
1104
+ adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+(a.format||'Card')[0].toLowerCase()+'</span>'; },
1105
+ adSub(a, r){ return esc(a.titleNo)+'L'+esc(a.descNo); }
1106
+ },
1107
+
1108
+ /* ---- S8: 解決 (派生値 / 純関数) ---- */
1109
+ resolve(ad, group, ctx){
1110
+ const P=PLATFORMS.yda, p=ctx.project, refs=ctx.refs;
1111
+ const fmt=P.fmtOf(ad);
1112
+ const crName=P.naming.crName(ad, P);
1113
+ const adName=P.naming.adName(ad, group, crName);
1114
+ const me = refs.media[String(ad.imageNo)];
1115
+ const mediaId = me ? me[fmt.mediaKey] : undefined;
1116
+ const titleText = refs.titles[ad.titleNo];
1117
+ const descText = (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
1118
+ const finalUrl = P.naming.finalUrl(ad, group, adName, p);
1119
+ const errs=[], warns=[], errCodes=[], warnCodes=[];
1120
+ // S2: 日本語ラベル(errs/warns)と安定コード(errCodes/warnCodes)を同順で1:1に積む
1121
+ const pushErr=(label,code)=>{ errs.push(label); errCodes.push(code); };
1122
+ const pushWarn=(label,code)=>{ warns.push(label); warnCodes.push(code); };
1123
+ // 画像を3分類: 番号空=真エラー / 番号あり&refs.mediaに無い=未登録(要アップロード=pendingImage) / 解決OK
1124
+ const imgNoStr=String(ad.imageNo||'').trim();
1125
+ const pendingImage = (imgNoStr!=='' && mediaId===undefined); // アカウントのメディアライブラリに未登録→先にアップロードして新規作成
1126
+ if(imgNoStr==='') pushErr('画像未解決', EC.IMAGE_UNRESOLVED);
1127
+ if(String(ad.titleNo||'')==='' || titleText===undefined) pushErr('タイトル未解決', EC.TITLE_UNRESOLVED);
1128
+ if(String(ad.descNo||'')==='' || descText===undefined) pushErr('説明文未解決', EC.DESC_UNRESOLVED);
1129
+ if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
1130
+ else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
1131
+ // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
1132
+ if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
1133
+ if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
1134
+ // F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
1135
+ const aid=String(group.adGroupId||'').trim();
1136
+ return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
1137
+ mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
1138
+ descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
1139
+ },
1140
+
1141
+ /* ---- S8: 入稿計画JSON (SubmitPlanFile) の3階層ボディ生成 ---- */
1142
+ buildPlan:{
1143
+ campaign(c, ctx){
1144
+ const P=PLATFORMS.yda, p=ctx.project;
1145
+ const bidLabel=String(c.biddingStrategy||'').trim(); // A-1: キャンペーン個別値のみ (project既定へフォールバックしない)
1146
+ const biddingStrategy=P.enums.bidding[bidLabel]||bidLabel||P.enums.biddingFallback;
1147
+ const cp={
1148
+ name:c.name,
1149
+ goal:p.goal||'CONVERSION',
1150
+ status:P.mappers.status(p.deliveryStatus, P), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
1151
+ startDate:p.startDate||today(),
1152
+ budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
1153
+ biddingStrategy,
1154
+ adGroups:[]
1155
+ };
1156
+ if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
1157
+ else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
1158
+ const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
1159
+ return cp;
1160
+ },
1161
+ adGroup(g, c, ctx){
1162
+ const P=PLATFORMS.yda, p=ctx.project;
1163
+ const dh=P.mappers.dayHour(g.tg_dayHour, P);
1164
+ const ag={
1165
+ name:g.name,
1166
+ status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
1167
+ smartTargeting:false, // GUI未対応 (将来ターゲティング列に追加)
1168
+ devices:P.mappers.devices(g.tg_device, P),
1169
+ ages:P.mappers.ages(g.tg_age, P),
1170
+ genders:P.mappers.genders(g.tg_gender, P),
1171
+ days:dh.days,
1172
+ placementsInclude:[], placementsExclude:[],
1173
+ audiencesInclude:[], audiencesExclude:[],
1174
+ ads:[]
1175
+ };
1176
+ if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
1177
+ const aid=resolveAdGroupIdOf(g); if(aid) ag.adGroupId=aid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
1178
+ return ag;
1179
+ },
1180
+ ad(a, r, g, ctx){
1181
+ const P=PLATFORMS.yda, p=ctx.project;
1182
+ const ad={
1183
+ adName:r.adName,
1184
+ status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
1185
+ mediaType:'IMAGE', // レスポンシブ画像広告 (backend ydaAdSchema IMAGE)
1186
+ headlines:[String(r.titleText)],
1187
+ descriptions:[String(r.descText)],
1188
+ principal:p.principal||'', // E-1: 主体者はアカウントJSON由来 (st.project.principal)
1189
+ buttonText:P.mappers.cta(p.cta, P), // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
1190
+ finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
1191
+ };
1192
+ if(r.pendingImage){
1193
+ // 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する (imagePaths は agent/CLI が補完)
1194
+ ad.mediaIds=[]; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; ad.imagePaths=[];
1195
+ } else {
1196
+ ad.mediaIds=[String(r.mediaId)];
1197
+ }
1198
+ return ad;
1199
+ }
1200
+ },
1201
+
1202
+ /* ---- 入稿計画ファイル / CLI コマンド ---- */
1203
+ output:{
1204
+ planVersion:1,
1205
+ planDir:'.claude/skills/ad-ops/入稿計画',
1206
+ samplePlanFile:'サンプルプラン.json',
1207
+ fileNamePrefix:p=>(p.name||'plan')+'_入稿計画_',
1208
+ submitCommand:(rel,opts)=>'webgram submit-plan --plan-file "'+rel+'"'+((opts&&opts.dryRun===false)?' --no-dry-run --yes':'')
1209
+ },
1210
+
1211
+ /* ---- Claude Code に貼るプロンプト (媒体固有の文言はここ1箇所) ---- */
1212
+ prompts:{
1213
+ /* アカウント参照データJSONの生成プロンプト。mode: 'ai'(対話で選ぶ) / 'import'(対象確定済) / 'help'(使い方モーダル) */
1214
+ importAccount(ctx){
1215
+ const P=PLATFORMS.yda, c=ctx||{}, mode=c.mode||'help';
1216
+ const name=c.name||'', accId=c.accountId||'';
1217
+ const idArg=accId||'<ID>';
1218
+ const headName = mode==='help' ? '<アカウント名(日本語OK)>' : (name||'<アカウント名>');
1219
+ const fileName = mode==='help' ? '<アカウント名>' : (name||'<アカウント名>');
1220
+ const dir=P.account.dir;
1221
+ const shape=[' - media は 画像番号 → {card, sq}(mediaId)',
1222
+ ' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
1223
+ ' - campaigns/adGroups は campaignId/adGroupId をキーに'];
1224
+ const lines=[];
1225
+ if(mode==='ai'){
1226
+ lines.push('YDA入稿プランナー用に、新規アカウントの参照データJSONを生成してください。','',
1227
+ '1. まず対象アカウントを対話で決めてください:',
1228
+ ' - webgram list --platform yda --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認してアカウント名・accountId・主体者表記を確定する',
1229
+ '2. 確定したアカウントについて、登録画像・既存キャンペーン/広告グループを取得:',
1230
+ ' webgram list --platform yda --resource media --account-id <ID>',
1231
+ ' webgram list --platform yda --resource campaigns --account-id <ID>',
1232
+ ' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
1233
+ '3. 取得結果を下記スキーマに整形し、'+dir+'/<アカウント名>.json として書き出す:');
1234
+ shape.forEach(l=>lines.push(l));
1235
+ } else {
1236
+ lines.push('YDA入稿プランナー用に、アカウント「'+headName+'」'+(mode==='import'&&accId?('(accountId: '+accId+')'):'')+'の参照データJSONを生成してください。','',
1237
+ '手順:',
1238
+ '1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
1239
+ ' webgram list --platform yda --resource accounts',
1240
+ ' webgram list --platform yda --resource media --account-id '+idArg,
1241
+ ' webgram list --platform yda --resource campaigns --account-id '+idArg);
1242
+ if(mode==='import') lines.push(' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)');
1243
+ lines.push('2. 取得結果を下記スキーマに整形して '+dir+'/'+fileName+'.json として書き出す:');
1244
+ shape.forEach(l=>lines.push(l));
1245
+ }
1246
+ lines.push('', 'スキーマ:');
1247
+ P.account.schemaLines({updatedAt:mode!=='help',
1248
+ nameHint:mode==='help'?'<アカウント名(日本語OK)>':'<アカウント名>'}).forEach(l=>lines.push(l));
1249
+ lines.push('');
1250
+ lines.push(mode==='ai' ? '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加 →「📄 ファイルから追加」で その JSON を読み込めます。'
1251
+ : mode==='import' ? '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
1252
+ : '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。');
1253
+ return lines.join('\n');
1254
+ },
1255
+ /* 戦略壁打ちを始めるためのプロンプト例(アピール用の即コピー素材) */
1256
+ strategy(){
1257
+ return ['YDA(Yahoo!ディスプレイ広告)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
1258
+ '# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
1259
+ '- 目的: <コンバージョン獲得 / 認知 など>','- 予算・期間: <月予算 / 開始日>',
1260
+ '- 訴求したいポイント: <強み・オファー・ターゲット層>','',
1261
+ '# お願い','1. 上記をもとにターゲティング方針・キャンペーン/広告グループ構成・訴求軸を提案してください。',
1262
+ '2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン.json」に書き出して(上書きして)ください。',
1263
+ ' - 保存先: .claude/skills/ad-ops/サンプルプラン.json',
1264
+ ' - フォーマット: {"schemaVersion":'+SCHEMA_VERSION+', "accountId":"<対象アカウントID>", "campaigns":[ ... ]}',
1265
+ ' - campaigns には キャンペーン → 広告グループ(paramCode) → 広告 の構成を入れてください。',
1266
+ '3. 書き出したら、プランナーを開き直す(またはリロードする)と サンプルプラン.json の内容が自動で表示されます。'].join('\n');
1267
+ },
1268
+ /* 入稿JSON書き出し後の「次アクション(入稿)」プロンプト */
1269
+ submitHelp(ctx){
1270
+ const P=PLATFORMS.yda, c=ctx||{}, rel=c.savedRel, dir=P.output.planDir;
1271
+ const lines=['入稿を試みてください。手順:',
1272
+ '1) 入稿計画JSON('+c.fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+rel+')。',
1273
+ ' ・入稿計画フォルダ('+dir+'/)に無ければ、ダウンロードフォルダ(~/Downloads/'+c.fileName+')を探して上記パスへ移動する。',
1274
+ (c.wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。')];
1275
+ if(c.pendingImages){
1276
+ lines.push('2) 【未登録画像の新規登録】入稿計画内に mediaIds が空で pendingImageNo が付いた広告があります(管理番号: '+(c.pendingImageNos||[]).join(', ')+')。');
1277
+ lines.push(' これらは各管理番号に対応するローカル画像ファイルのパスを、その広告の "imagePaths":[] に設定してください(submit-plan 実行時に自動で /yda/upload-image → mediaId 解決されます)。');
1278
+ lines.push('3) 置いたパスで dry-run 入稿を実行:');
1279
+ } else {
1280
+ lines.push('2) 置いたパスで dry-run 入稿を実行:');
1281
+ }
1282
+ lines.push(' '+P.output.submitCommand(rel,{dryRun:true}));
1283
+ lines.push((c.pendingImages?'4':'3')+') dry-run が全件OKなら本実行:');
1284
+ lines.push(' '+P.output.submitCommand(rel,{dryRun:false}));
1285
+ return lines.join('\n');
1286
+ },
1287
+ /* バージョン保存後の「次アクション(入稿)」プロンプト */
1288
+ afterSave(ctx){
1289
+ const P=PLATFORMS.yda, c=ctx||{};
1290
+ return 'プラン「'+c.name+'」v'+c.versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_入稿計画_*.json を指定して dry-run してください:\n\n'
1291
+ + P.output.submitCommand('<入稿計画JSONのパス>',{dryRun:true});
1292
+ }
1293
+ },
1294
+ docTabs:[{id:'overview',label:'概要'},{id:'resp',label:'レスポンシブ'},
1295
+ {id:'banner',label:'バナー'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
1296
+ };
1297
+
1298
+ /* 宣言に載せない旧テーブル。resolveGroupIds/maxParamNum が参照中のため S11 の撤去まで温存する。 */
1299
+ const LEGACY_REF_KEYS = ['groups'];
1300
+ function refTableKeys(){ return Object.keys(pf().refTables); }
1301
+ function refTableDef(key){ return pf().refTables[key] || null; }
1302
+ function refLenCls(T,v){ const L=glen(v), lim=T&&T.lengthLimit; if(!lim) return ''; return L>lim.err?'err':(L>lim.warn?'warn':''); }
1303
+ /* S10: 媒体を明示して使える版 (起動時に他媒体の同梱refsも正規化するため)。既存名は現媒体版の薄いラッパ。 */
1304
+ function emptyRefsFor(P){ const o={}; Object.keys(P.refTables).forEach(k=>{ o[k]={}; }); LEGACY_REF_KEYS.forEach(k=>{ o[k]={}; }); return o; }
1305
+ function normalizeRefsFor(P,r){ const e=emptyRefsFor(P); if(!r) return e; Object.keys(e).forEach(k=>{ e[k]=r[k]||{}; }); return e; }
1306
+ function emptyRefs(){ return emptyRefsFor(pf()); }
1307
+ function validRefs(r){ if(!r) return false; return refTableKeys().filter(k=>refTableDef(k).required).concat(LEGACY_REF_KEYS).every(k=>!!r[k]); }
1308
+ function normalizeRefs(r){ return normalizeRefsFor(pf(), r); }
721
1309
  /* 案A: 同梱デフォルトアカウントの refs を起動時に外部JSONから読み込む(固有名詞ハードコードなし)。
722
1310
  descriptions は独立テーブル(titlesコピーの暫定を廃止)。 */
723
1311
  function buildRefs(){ return bundledRefs ? normalizeRefs(bundledRefs) : emptyRefs(); }
1312
+ /* ---- S10: 同梱データ (デフォルトアカウント + サンプルプラン) は媒体別 ----
1313
+ BUNDLES[<platform>] = {accountName, accountId, account(JSON全体), refs, principal, projectName, baseUrl, samplePlan}
1314
+ 起動時に登録済み全媒体を先読みし、媒体切替 (switchPlatform) では applyBundle() で同期的に差し替える
1315
+ (非同期 fetch を切替後に走らせると applyAccountJson と競合するため)。 */
1316
+ const BUNDLES = {};
1317
+ async function loadBundle(pfId){
1318
+ const P=PLATFORMS[pfId]; if(!P) return null;
1319
+ const b={accountName:'', accountId:'', account:null, refs:null, principal:'', projectName:'', baseUrl:'', samplePlan:null};
1320
+ const dir=(P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR;
1321
+ const legacy=(pfId==='yda')?LEGACY_ACCOUNT_DIR:null;
1322
+ const readJson=async rel=>{ let j=await fetchJson(dir+'/'+rel); if(!j && legacy) j=await fetchJson(legacy+'/'+rel); return j; };
1323
+ try{
1324
+ const idx=await readJson('index.json');
1325
+ const arr=idx?(Array.isArray(idx)?idx:(idx.accounts||[])):[];
1326
+ const firstName=String((arr||[])[0]||'');
1327
+ if(firstName){
1328
+ const acc=await readJson(encodeURIComponent(firstName)+'.json');
1329
+ if(acc){
1330
+ b.accountName=firstName; b.account=acc;
1331
+ if(acc.accountId) b.accountId=String(acc.accountId);
1332
+ if(acc.refs) b.refs=normalizeRefsFor(P, acc.refs);
1333
+ if(acc.principal) b.principal=acc.principal;
1334
+ if(acc.projectName) b.projectName=acc.projectName;
1335
+ if(acc.baseArticleUrl) b.baseUrl=acc.baseArticleUrl;
1336
+ }
1337
+ }
1338
+ }catch(e){}
1339
+ try{ const plan=await fetchJson(P.output.samplePlanFile); if(plan) b.samplePlan=plan; }catch(e){}
1340
+ BUNDLES[pfId]=b;
1341
+ return b;
1342
+ }
1343
+ function applyBundle(pfId){
1344
+ const b=BUNDLES[pfId]||{};
1345
+ BUNDLED_ACCOUNT_NAME=b.accountName||''; BUNDLED_ACCOUNT_ID=b.accountId||'';
1346
+ bundledRefs=b.refs||null; bundledPrincipal=b.principal||'';
1347
+ bundledProjectName=b.projectName||''; bundledBaseUrl=b.baseUrl||'';
1348
+ samplePlan=b.samplePlan||null;
1349
+ }
1350
+ /* S7: 媒体既定値 (pf().defaults.project) + 同梱デフォルトアカウント由来の値。
1351
+ 案件名・主体者・ベースURL・accountId は <account.dir>/index.json 先頭由来。未読込時は空。 */
724
1352
  function defaultProject(){
725
- return {
726
- // 案件名・主体者・ベースURL・accountId は同梱デフォルトアカウント(アカウント/index.json 先頭)由来。未読込時は空
1353
+ const P=pf();
1354
+ const p=Object.assign({}, P.defaults.project, {
727
1355
  name: bundledProjectName || '',
728
1356
  principal: bundledPrincipal,
729
- cta:'もっとみる', adType:'レスポンシブ', mainMediaFormat:'画像',
730
- deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
731
- attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
732
- budgetNew:12000, budgetAsc:50000, baseArticleUrl: bundledBaseUrl || '',
1357
+ baseArticleUrl: bundledBaseUrl || '',
733
1358
  // 入稿設定 (プランに内包。入稿JSON書き出し時にそのまま使う)。accountId/Name は同梱デフォルトアカウント
734
- accountId:BUNDLED_ACCOUNT_ID, accountName:BUNDLED_ACCOUNT_NAME, goal:'CONVERSION', startDate:''
735
- };
736
- }
737
- /* E-2: mode/MODES 廃止。新規/既存はID解決(isNewCampaign/isNewGroup)で自動判定。新規CPの予算は固定 12,000 円 */
738
- const NEW_CP_BUDGET = 12000;
1359
+ accountId:BUNDLED_ACCOUNT_ID, accountName:BUNDLED_ACCOUNT_NAME
1360
+ });
1361
+ /* S10: 同梱アカウントJSONの媒体固有項目 (Meta の pageId / instagramUserId / pixelId 等) も既定値に載せる。
1362
+ 写し込み規則は媒体スキーマ (account.apply) が唯一の定義元。 */
1363
+ const acc=BUNDLES[curPf] && BUNDLES[curPf].account;
1364
+ if(acc){ try{ P.account.apply(acc, p); }catch(e){} }
1365
+ return p;
1366
+ }
1367
+ /* 参照データビューの先頭タブ (媒体の refTables 宣言順) */
1368
+ function firstRefTabKey(){ return Object.keys(pf().refTables)[0] || 'titles'; }
1369
+ /* E-2: mode/MODES 廃止。新規/既存はID解決(isNewCampaign/isNewGroup)で自動判定。 */
739
1370
 
740
1371
  /* ---- initial state ---- */
741
- function newAd(seed){ return Object.assign({uid:uid('a'), format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adIdOverride:'', desc2:''}, seed||{}); }
1372
+ /* S7: 行生成は pf().factories へ委譲 (既存名は呼び出し側/E2E互換のため薄いラッパで残す) */
1373
+ function newAd(seed){ return pf().factories.newAd(seed); }
1374
+ function newAdGroup(c, i){ return pf().factories.newAdGroup(c, i); }
742
1375
  /* ---- schema v2: バージョンメタ ---- */
743
- function defaultMeta(){ return {versionId:null, name:'', memo:'', author:'', platform:'yda', createdAt:null, updatedAt:null}; }
1376
+ function defaultMeta(){ return {versionId:null, name:'', memo:'', author:'', platform:curPf, createdAt:null, updatedAt:null}; }
744
1377
  /* 数値化 (NaN/不正入力は null 維持) — B10 */
745
1378
  function numOrNull(v){ if(v===''||v==null) return null; const n=Number(v); return Number.isFinite(n)?n:null; }
1379
+ /* 整数化 + 範囲クランプ (Meta の ageMin/ageMax: 13〜65)。不正入力は null 維持 */
1380
+ function clampInt(v,min,max){ if(v===''||v==null) return null; const n=parseInt(v,10); if(!Number.isFinite(n)) return null; return Math.min(max,Math.max(min,n)); }
746
1381
  /* 新規キャンペーンの個別既定値 (A-1: project既定はここでのみ供給し、以後はキャンペーン個別値運用) */
747
1382
  function cpDefaults(project, mode){ const p=project||st.project; return {budget: mode==='new'?p.budgetNew:p.budgetAsc, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault}; }
748
- /* E-2: 新規キャンペーン生成 (予算は固定 12,000 円・空のAG1を1つ内包)。isExisting:false = 常に編集可 */
749
- function makeNewCampaign(seed){ const p=st.project; return Object.assign({uid:uid('c'), name:'新規キャンペーン', campaignId:'', isExisting:false, variable:'', urlNumber:'', budget:NEW_CP_BUDGET, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault, adGroups:[{uid:uid('g'),name:'AG1',paramCode:'',isExisting:false,ads:[newAd()]}]}, seed||{}); }
750
- /* E-3/F-2: 既存キャンペーン呼び出し (campaignIdを持つ広告追加専用の器)。isExisting:true = 基本パラメータ編集不可 */
751
- function makeExistingCampaign(cpId, name){ return {uid:uid('c'), name:name||'', campaignId:String(cpId), isExisting:true, variable:'', urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; }
1383
+ /* S7: 新規/既存キャンペーンの生成も pf().factories へ委譲 */
1384
+ function makeNewCampaign(seed){ return pf().factories.newCampaign(seed); }
1385
+ function makeExistingCampaign(cpId, name){ return pf().factories.existingCampaign(cpId, name); }
752
1386
  function initialState(){
753
1387
  const refs=buildRefs();
754
1388
  // 共通設定(project=案件名/主体者/アカウント等)はアカウントJSON由来。サンプルプランには campaigns と accountId のみ。
@@ -773,10 +1407,12 @@ function stateJson(){ const {meta,project,campaigns,ui,refs}=st; return {schemaV
773
1407
  let _persistWarned=false;
774
1408
  function persist(){ /* localStorageプラン廃止: no-op。プランの永続化はサンプルプラン更新(サンプルプラン.json)で行う */ }
775
1409
  /* B12: SCHEMA_VERSION 不一致は後方互換なしで破棄→初期化。refs欠落時は再構築。 */
776
- function load(){ return null; /* LSプラン廃止 */ try{ const j=JSON.parse(localStorage.getItem(LS_KEY)); if(!j||!j.campaigns||j.schemaVersion!==SCHEMA_VERSION) return null; const refs=validRefs(j.refs)?normalizeRefs(j.refs):buildRefs(); return {schemaVersion:SCHEMA_VERSION, meta:Object.assign(defaultMeta(),j.meta||{}), project:Object.assign(defaultProject(),j.project), refs, campaigns:j.campaigns, ui:j.ui||{expanded:{}}}; }catch(e){ return null; } }
1410
+ function load(){ return null; /* LSプラン廃止 */ try{ const j=JSON.parse(localStorage.getItem(stateKey())); if(!j||!j.campaigns||j.schemaVersion!==SCHEMA_VERSION) return null; const refs=validRefs(j.refs)?normalizeRefs(j.refs):buildRefs(); return {schemaVersion:SCHEMA_VERSION, meta:Object.assign(defaultMeta(),j.meta||{}), project:Object.assign(defaultProject(),j.project), refs, campaigns:j.campaigns, ui:j.ui||{expanded:{}}}; }catch(e){ return null; } }
777
1411
 
778
1412
  /* localStorage のプラン永続化は廃止。プランの正はサンプルプラン.json(外部)。起動時に必ず読み込む。 */
779
- let st = initialState();
1413
+ /* S10: window.st として参照できるよう var 宣言 (let だとグローバルオブジェクトに載らない)。
1414
+ E2E / デバッグコンソールから状態を読むための唯一のシーム。 */
1415
+ var st = initialState();
780
1416
 
781
1417
  /* ---- undo (bounded) ---- */
782
1418
  const undoStack=[];
@@ -794,32 +1430,10 @@ function findGroup(u){ let r=null; st.campaigns.forEach(c=>c.adGroups.forEach((g
794
1430
  function findCp(u){ const i=st.campaigns.findIndex(c=>c.uid===u); return i<0?null:{c:st.campaigns[i],i}; }
795
1431
 
796
1432
  /* ---- resolve (派生値 / 純関数) ---- */
797
- function resolveAd(ad, group){
798
- const p=st.project, refs=st.refs;
799
- const sizeKey = ad.format==='SQ'?'sq':'card';
800
- const crName = String(ad.imageNo||'') + (ad.format==='SQ'?'s':'c');
801
- const adName = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||'');
802
- const me = refs.media[String(ad.imageNo)];
803
- const mediaId = me ? me[sizeKey] : undefined;
804
- const titleText = refs.titles[ad.titleNo];
805
- const descText = (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
806
- const finalUrl = (p.baseArticleUrl||'') + (ad.articleSlug||'') + '?ad=' + (group.paramCode||'') + '&creative=' + (group.paramCode||'') + '-' + adName;
807
- const errs=[], warns=[];
808
- // 画像を3分類: 番号空=真エラー / 番号あり&refs.mediaに無い=未登録(要アップロード=pendingImage) / 解決OK
809
- const imgNoStr=String(ad.imageNo||'').trim();
810
- const pendingImage = (imgNoStr!=='' && mediaId===undefined); // アカウントのメディアライブラリに未登録→先にアップロードして新規作成
811
- if(imgNoStr==='') errs.push('画像未解決');
812
- if(String(ad.titleNo||'')==='' || titleText===undefined) errs.push('タイトル未解決');
813
- if(String(ad.descNo||'')==='' || descText===undefined) errs.push('説明文未解決');
814
- if(titleText!==undefined && glen(titleText)>35) errs.push('タイトル35字超');
815
- else if(titleText!==undefined && glen(titleText)>20) warns.push('タイトル20字超');
816
- // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
817
- if(String(group.paramCode||'').trim()==='') errs.push('paramCode未入力');
818
- if(pendingImage) warns.push('画像未登録(要アップロード)'); // errではなくwarn: 新規作成対象として保持
819
- // F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
820
- const aid=String(group.adGroupId||'').trim();
821
- return {crName, adName, campaignId:'', adGroupId:aid, imageNo:imgNoStr, mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText, finalUrl, errs, warns, resolvedGroup:!!aid};
822
- }
1433
+ /* S8: 解決本体は pf().resolve(ad, group, ctx) に移設。関数名は据え置き
1434
+ (E2E が page.evaluate から resolveAd(...) を直接呼ぶため)。 */
1435
+ function planCtx(){ const P=pf(); return {project:st.project, refs:st.refs, pf:P, resolve:resolveAd}; }
1436
+ function resolveAd(ad, group){ return pf().resolve(ad, group, planCtx()); }
823
1437
  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}; }
824
1438
 
825
1439
  /* ============================================================
@@ -834,7 +1448,9 @@ function renderPlanWarn(){
834
1448
  else bar.hidden=true;
835
1449
  }
836
1450
  function render(){
837
- document.getElementById('pjLabel').textContent = st.project.name + ' / 主体者:' + (st.project.principal||'');
1451
+ /* 主体者(principal)は YDA 固有の概念。持たない媒体では空ラベルを出さない */
1452
+ const _pr=String(st.project.principal||'').trim();
1453
+ document.getElementById('pjLabel').textContent = st.project.name + (_pr ? (' / 主体者:'+_pr) : '');
838
1454
  renderPlanWarn();
839
1455
  renderTplChips(); renderValidationBadge();
840
1456
  if(activeView==='sheet'){
@@ -866,163 +1482,141 @@ function renderTplChips(){
866
1482
  // - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
867
1483
  // - 主体者(principal)はアカウントJSON由来 (アカウント編集モーダルで変更)
868
1484
  // - タイプ(レスポンシブ/画像)はプランツールバーの固定プルダウン (変更不可)
869
- document.getElementById('tplChips').innerHTML =
870
- chip('目的', selv('goal',p.goal||'CONVERSION',[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},{v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}])) +
871
- chip('開始日', txt('startDate',p.startDate||'','10ch')) +
872
- chip('CTA', txt('cta',p.cta,'7ch')) +
873
- chip('配信', sel('deliveryStatus',p.deliveryStatus,['オフ','オン'])) +
874
- chip('計測', num('attributionDays',p.attributionDays,'3ch')+'日') +
875
- chip('表示URL', txt('displayUrlFormat',p.displayUrlFormat,'13ch')) +
876
- chip('記事URL', txt('baseArticleUrl',p.baseArticleUrl,'16ch'));
1485
+ // S7: チップの並び・種別・幅は pf().projectFields (媒体スキーマ) が持つ
1486
+ document.getElementById('tplChips').innerHTML = pf().projectFields.map(f=>{
1487
+ const v = (f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key];
1488
+ const ctrl = f.kind==='selectv' ? selv(f.key, v, f.options)
1489
+ : f.kind==='select' ? sel(f.key, v, f.options)
1490
+ : f.kind==='number' ? num(f.key, v, f.width)
1491
+ : txt(f.key, v, f.width);
1492
+ return chip(f.label, ctrl+(f.suffix||''));
1493
+ }).join('');
877
1494
  // A-1: 入札(tcpaDefault) / 予算新規 / 予算ASC チップは撤去。project既定は新規CP作成時の初期値供給のみに使い、以後はキャンペーン行で個別編集する。
878
1495
  }
879
1496
 
880
1497
  /* ---- SHEET ---- */
881
1498
  function renderSheet(){
882
1499
  document.querySelectorAll('#sheetTabs button').forEach(b=>b.classList.toggle('active', b.dataset.st===sheetTab));
883
- if(sheetTab==='groups') return renderSheetGroups();
884
- if(sheetTab==='campaigns') return renderSheetCampaigns();
885
- const filter=document.getElementById('sheetFilter').value;
886
- const cols=['','グループ','形式','画像番号','タイトル番号','説明文番号','paramCode(グループ)','記事slug','広告名(自動)','CP / AG ID'];
887
- let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'<th></th></tr></thead><tbody>';
888
- let shown=0, ri=-1;
889
- // F-8: ネスト(キャンペーン見出しエリア)を廃止し、1広告=1行のフラットな表にする
890
- st.campaigns.forEach(c=>{
891
- c.adGroups.forEach(g=>{
892
- g.ads.forEach(a=>{
893
- const r=resolveAd(a,g);
894
- const hasErr=r.errs.length>0;
895
- if(filter==='err' && !hasErr) return;
896
- shown++; ri++;
897
- const cls='adrow'+(hasErr?' hasErr':'');
898
- html+='<tr class="'+cls+'" data-adrow="'+a.uid+'">';
899
- html+='<td class="rownum" data-rownum="'+ri+'" title="行を選択('+esc(g.name)+')">'+(ri+1)+'</td>';
900
- html+='<td><div class="cppick" data-agpick-ad="'+a.uid+'" title="クリックで所属グループを変更(プラン内の広告グループから選択)"><span class="breadc">'+esc(g.name)+'</span><span class="tgcaret">▾</span></div></td>';
901
- html+='<td><select class="cell-in fmt" data-sel="1" data-r="'+ri+'" data-c="0" data-u="'+a.uid+'" data-f="format"><option'+(a.format==='Card'?' selected':'')+'>Card</option><option'+(a.format==='SQ'?' selected':'')+'>SQ</option></select></td>';
902
- // 画像番号 : メディアID(1ボックス + ▼一覧)
903
- html+='<td><div class="cellcombo'+(r.errs.includes('画像未解決')?' err':'')+'"><input class="cell-in num" data-sel="1" data-r="'+ri+'" data-c="1" data-u="'+a.uid+'" data-f="imageNo" value="'+esc(a.imageNo)+'"><span class="combosep">:</span><span class="ghost" data-d="'+a.uid+'-media" title="メディアID '+esc(r.mediaId)+'">'+mediaInline(a,r)+'</span><button class="combodd" data-dd="media" data-u="'+a.uid+'" data-f="imageNo" tabindex="-1" title="登録画像から選択">▾</button></div></td>';
904
- // タイトル番号 : 文言(1ボックス + 字数 + ▼一覧)
905
- html+='<td><div class="cellcombo'+((r.errs.includes('タイトル未解決')||r.errs.includes('タイトル35字超'))?' err':'')+'"><input class="cell-in num" data-sel="1" data-r="'+ri+'" data-c="2" data-u="'+a.uid+'" data-f="titleNo" value="'+esc(a.titleNo)+'"><span class="combosep">:</span><span class="ghost" data-d="'+a.uid+'-title" title="'+esc(r.titleText)+'">'+titleGhost(r)+'</span><button class="combodd" data-dd="titles" data-u="'+a.uid+'" data-f="titleNo" tabindex="-1" title="タイトル一覧から選択">▾</button></div></td>';
906
- // 説明文番号 : 文言(1ボックス + ▼一覧)
907
- html+='<td><div class="cellcombo'+(r.errs.includes('説明文未解決')?' err':'')+'"><input class="cell-in num" data-sel="1" data-r="'+ri+'" data-c="3" data-u="'+a.uid+'" data-f="descNo" value="'+esc(a.descNo)+'"><span class="combosep">:</span><span class="ghost" data-d="'+a.uid+'-desc" title="'+esc(r.descText)+'">'+descGhost(r)+'</span><button class="combodd" data-dd="titles" data-u="'+a.uid+'" data-f="descNo" tabindex="-1" title="説明文一覧から選択">▾</button></div></td>';
908
- // paramCode (group-level) — 既存AGは read-only (グループの基本パラメータは編集不可・広告追加のみ)
909
- const pcRo=isExistingGroup(g);
910
- html+='<td><input class="cell-in mono'+(pcRo?' roinput':'')+'" data-sel="1" data-r="'+ri+'" data-c="4" data-u="'+a.uid+'" data-f="paramCode" value="'+esc(g.paramCode)+'"'+(pcRo?' readonly title="既存広告グループのため編集不可"':' title="グループ共通(変更で同グループ全広告に反映)"')+' style="width:120px"></td>';
911
- // slug
912
- html+='<td><input class="cell-in mono" data-sel="1" data-r="'+ri+'" data-c="5" data-u="'+a.uid+'" data-f="articleSlug" value="'+esc(a.articleSlug)+'" style="width:90px"></td>';
913
- // adName
914
- html+='<td><span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(r.finalUrl)+'">'+esc(r.adName)+'</span></td>';
915
- // ids
916
- html+='<td><span class="idpill '+(r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+idsCell(r)+'</span></td>';
917
- // actions
918
- html+='<td><span class="rowact"><button class="iconbtn" data-act="ad-dup" data-u="'+a.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ad-del" data-u="'+a.uid+'" title="削除">✕</button></span></td>';
919
- html+='</tr>';
920
- });
921
- // F-4: グループごとの追加ボタンは廃止(最下部に1つ)
922
- });
923
- });
924
- // F-4: 広告追加は最下部に1行だけ(既定=最後のグループ・グループ列で変更可)
925
- html+='<tr><td></td><td colspan="10"><button class="addbtn" data-act="add-ad-bottom">+ 広告を追加</button></td></tr>';
1500
+ renderSheetGeneric(normTab(sheetTab));
1501
+ }
1502
+ /* ---- S4: シート描画の統合 ----
1503
+ 旧 renderSheet / renderSheetGroups / renderSheetCampaigns の手書きHTML3本を
1504
+ renderSheetGeneric(tab) + renderRow + renderCell に統合した。
1505
+ ・列ごとの差異 (ラベル/幅/入力種別/read-only表現/ゴースト/派生セル) は COLS の記述子だけが持つ
1506
+ ・タブごとの差異 (行番号ラベル / 親列 / 操作ボタン / 追加行 / 空表示 / data-* 属性名) は SHEET_UI が持つ
1507
+ DOM構造 (class名 / data-c / data-sel / colspan / td の個数と順序) は統合前と完全一致させている。 */
1508
+ const SHEET_UI = {
1509
+ ads:{
1510
+ uAttr:'u', fAttr:'f', rowAttr:'data-adrow', rowNumLabel:'', parentLabel:'グループ',
1511
+ /* 旧 renderSheet だけが再描画前に activeElement を blur していた (change を先に確定させる) */
1512
+ blurBeforeRender:true,
1513
+ rowNumTitle:it=>' title="行を選択('+esc(it.g.name)+')"',
1514
+ parentCell:it=>'<td><div class="cppick" data-agpick-ad="'+it.a.uid+'" title="クリックで所属グループを変更(プラン内の広告グループから選択)"><span class="breadc">'+esc(it.g.name)+'</span><span class="tgcaret">▾</span></div></td>',
1515
+ actions:uid=>'<button class="iconbtn" data-act="ad-dup" data-u="'+uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ad-del" data-u="'+uid+'" title="削除">✕</button>',
1516
+ addRow:'<button class="addbtn" data-act="add-ad-bottom">+ 広告を追加</button>',
1517
+ emptyHtml:rows=>(rows.length===0&&st.campaigns.length===0)?'<div class="empty">キャンペーンがありません。シートの「キャンペーン」タブ →「+ キャンペーンを追加」から。</div>':null
1518
+ },
1519
+ adGroups:{
1520
+ uAttr:'gu', fAttr:'gf', rowAttr:'data-gu', rowNumLabel:'#', parentLabel:'キャンペーン',
1521
+ /* 新規AGはクリックでプラン内キャンペーンをドロワー選択(親付け替え)。既存AGは変更不可 */
1522
+ parentCell:it=>isExistingGroup(it.g)
1523
+ ? '<td><span class="robadge" title="既存広告グループのためキャンペーン変更不可" style="max-width:150px; display:inline-block; overflow:hidden; text-overflow:ellipsis; vertical-align:middle">'+esc(it.c.name)+'</span></td>'
1524
+ : '<td><div class="cppick" data-cppick-g="'+it.g.uid+'" title="クリックでキャンペーンを選択(プラン内)"><span class="breadc" style="max-width:150px">'+esc(it.c.name)+'</span><span class="tgcaret">▾</span></div></td>',
1525
+ actions:uid=>'<button class="iconbtn" data-act="ag-dup" data-u="'+uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+uid+'" title="削除">✕</button>',
1526
+ /* 文言は媒体の階層ラベル (YDA=広告グループ / Meta=広告セット) から供給する */
1527
+ addRow:()=>'<button class="addbtn" data-act="add-adgroup">+ 新規'+esc(agLabel())+'</button> <button class="addbtn" data-act="add-adgroup-existing">📂 既存'+esc(agLabel())+'を呼び出し</button>',
1528
+ emptyHtml:()=>null
1529
+ },
1530
+ campaigns:{
1531
+ uAttr:'cu', fAttr:'cf', rowAttr:'data-cu', rowNumLabel:'#', parentLabel:null,
1532
+ actions:uid=>'<button class="iconbtn" data-act="cp-dup" data-u="'+uid+'" title="複製/量産">⧉</button><button class="iconbtn danger" data-act="cp-del" data-u="'+uid+'" title="削除">✕</button>',
1533
+ addRow:'<button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button> <button class="addbtn" data-act="add-campaign-existing">📂 既存キャンペーンを呼び出し</button>',
1534
+ emptyHtml:()=>st.campaigns.length?null:'<div class="empty">キャンペーンがありません。「+ 新規キャンペーン」または「📂 既存キャンペーンを呼び出し」から。</div>'
1535
+ }
1536
+ };
1537
+ /* tab は正規タブID ('ads'|'adGroups'|'campaigns')。行は flatVisible() の可視順 = 選択範囲の行番号と一致 */
1538
+ function renderSheetGeneric(tab){
1539
+ const ui=SHEET_UI[tab], cols=colDefs(tab), host=document.getElementById('sheetHost');
1540
+ const restore=focusRestoreG(host, ui.uAttr, ui.fAttr);
1541
+ const ths=[ui.rowNumLabel].concat(ui.parentLabel!=null?[ui.parentLabel]:[]).concat(cols.map(c=>c.label)).concat(['']);
1542
+ const rows=flatVisible();
1543
+ let html='<table class="sheet"><thead><tr>'+ths.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
1544
+ rows.forEach((it,ri)=>{ html+=renderRow(it, ri, cols, tab, ui); });
1545
+ html+='<tr><td></td><td colspan="'+(ths.length-1)+'">'+(typeof ui.addRow==='function'?ui.addRow():ui.addRow)+'</td></tr>';
926
1546
  html+='</tbody></table>';
927
- if(shown===0 && st.campaigns.length===0) html='<div class="empty">キャンペーンがありません。シートの「キャンペーン」タブ →「+ キャンペーンを追加」から。</div>';
928
- const host=document.getElementById('sheetHost');
929
- const focus=document.activeElement, fu=focus&&focus.dataset?focus.dataset.u:null, ff=focus&&focus.dataset?focus.dataset.f:null, selS=focus&&focus.selectionStart;
930
- if(focus && host.contains(focus)){ try{ focus.blur(); }catch(e){} }
931
- host.innerHTML=html;
932
- if(fu&&ff){ const el=host.querySelector('[data-u="'+fu+'"][data-f="'+ff+'"]'); if(el){ el.focus(); try{ if(selS!=null&&el.setSelectionRange) el.setSelectionRange(selS,selS); }catch(e){} } }
933
- applySelection();
1547
+ const em=ui.emptyHtml(rows); if(em) html=em;
1548
+ if(ui.blurBeforeRender){ const fo=document.activeElement; if(fo && host.contains(fo)){ try{ fo.blur(); }catch(e){} } }
1549
+ host.innerHTML=html; restore(); applySelection();
1550
+ }
1551
+ /* 1行 = 行番号td + (親td) + 列td... + 操作td */
1552
+ function renderRow(item, ri, cols, tab, ui){
1553
+ const row = rowOf(item);
1554
+ const ctx = {tab:tab, item:item, ri:ri, project:st.project, refs:st.refs};
1555
+ let cls='adrow';
1556
+ if(item.kind==='a'){ ctx.r=resolveAd(item.a,item.g); if(ctx.r.errs.length) cls+=' hasErr'; }
1557
+ let h='<tr class="'+cls+'" '+ui.rowAttr+'="'+row.uid+'">';
1558
+ h+='<td class="rownum" data-rownum="'+ri+'"'+(ui.rowNumTitle?ui.rowNumTitle(item):'')+'>'+(ri+1)+'</td>';
1559
+ if(ui.parentLabel!=null) h+=ui.parentCell(item);
1560
+ cols.forEach((col,ci)=>{ h+=renderCell(col,item,ri,ci,ctx); });
1561
+ h+='<td><span class="rowact">'+ui.actions(row.uid)+'</span></td>';
1562
+ return h+'</tr>';
1563
+ }
1564
+ /* 1セル。col.kind で分岐する唯一の場所。 */
1565
+ function renderCell(col, item, ri, ci, ctx){
1566
+ const ui=SHEET_UI[ctx.tab], row=rowOf(item);
1567
+ const val=getVar(item, ci, ctx.tab);
1568
+ const ro = col.alwaysReadonly || ((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col));
1569
+ const sel='data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+'"';
1570
+ const sty=col.width?(' style="width:'+col.width+'"'):'';
1571
+ const DASH='<span class="idpill" style="color:var(--faint)">—</span>';
1572
+ const roDash=v=>'<td>'+(v?'<span class="robadge">'+esc(v)+'</span>':DASH)+'</td>';
1573
+
1574
+ if(col.kind==='badge'){ const id=String(row[col.field]||'').trim(); return '<td>'+newOrIdBadge(!id, id)+'</td>'; }
1575
+ if(col.kind==='derived') return '<td>'+col.derive(row, ctx)+'</td>';
1576
+
1577
+ if(ro){
1578
+ /* 既存呼び出し行の read-only 表現は列記述子で明示する:
1579
+ hideOnExisting = 常に「—」/ roEmptyDash = 値ありは robadge・空は「—」/ roStyle:'input' = readonly入力欄 /
1580
+ 既定 = 常に robadge (空でもバッジを出す) */
1581
+ if(col.hideOnExisting) return '<td>'+DASH+'</td>';
1582
+ if(col.roEmptyDash) return roDash(val);
1583
+ if(col.roStyle==='input') return '<td><input class="cell-in'+(col.kind==='mono'?' mono':'')+' roinput" '+sel+' value="'+esc(val)+'" readonly title="'+esc(col.roTitle)+'"'+sty+'></td>';
1584
+ return '<td><span class="robadge'+(col.kind==='mono'?' mono':'')+'"'+(col.roTitle?(' title="'+esc(col.roTitle)+'"'):'')+'>'+esc(val)+'</span></td>';
1585
+ }
1586
+
1587
+ if(col.kind==='select'){
1588
+ /* '' は「(既定)」。emptyNoSelected は「(既定)に selected を付けない」現行DOMの踏襲 */
1589
+ const opts=col.options.map(o=> o===''
1590
+ ? '<option value=""'+((val===''&&!col.emptyNoSelected)?' selected':'')+'>(既定)</option>'
1591
+ : '<option'+(o===val?' selected':'')+'>'+esc(o)+'</option>').join('');
1592
+ return '<td><select class="cell-in fmt"'+(col.minWidth?(' style="min-width:'+col.minWidth+'"'):'')+' '+sel+'>'+opts+'</select></td>';
1593
+ }
1594
+
1595
+ if(col.kind==='refcombo'){
1596
+ const bad=(col.errCodes||[]).some(e=>ctx.r.errCodes.includes(e));
1597
+ return '<td><div class="cellcombo'+(bad?' err':'')+'"><input class="cell-in num" '+sel+' value="'+esc(val)+'">'+
1598
+ '<span class="combosep">:</span><span class="ghost" data-d="'+row.uid+'-'+col.ghostKey+'" title="'+esc(col.ghostTitle(ctx.r))+'">'+col.ghost(ctx.r,row)+'</span>'+
1599
+ '<button class="combodd" data-dd="'+col.drawerType+'" data-u="'+row.uid+'" data-f="'+col.field+'" tabindex="-1" title="'+esc(col.ddTitle)+'">▾</button></div></td>';
1600
+ }
1601
+
1602
+ if(col.kind==='tgpick'){
1603
+ const label=pf().targeting[col.tgKey].label;
1604
+ const disp = val? '<span class="tgval">'+esc(val)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(label)+'</span></span>';
1605
+ return '<td class="tgtd"><div class="tgpick'+(val?'':' tgempty')+'" '+sel+' title="'+esc(val||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
1606
+ }
1607
+
1608
+ /* text / mono / number */
1609
+ const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'');
1610
+ return '<td><input class="'+cls+'"'+(col.kind==='number'?' type="number"':'')+' '+sel+' value="'+esc(val)+'"'+
1611
+ (col.placeholder?(' placeholder="'+esc(col.placeholder())+'"'):'')+
1612
+ (col.title?(' title="'+esc(col.title)+'"'):'')+sty+'></td>';
934
1613
  }
935
- function mediaInline(a,r){ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>'; if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>'; if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>'; return '<span class="idpill err">#N/A</span>'; }
936
- function titleGhost(r){ if(r.errs.includes('タイトル未解決')) return '<span class="err">未解決</span>'; return esc(r.titleText.slice(0,40)); }
937
- function descGhost(r){ if(r.errs.includes('説明文未解決')) return '<span class="err">— 未解決</span>'; return esc(r.descText.slice(0,44)); }
938
- function idsCell(r){ return r.adGroupId ? ('AG '+r.adGroupId) : '新規'; } // F-6: adGroupIdベース表示
939
1614
 
940
1615
  /* ---- TREE ---- */
941
1616
  function isCollapsed(u){ return !!st.ui.expanded[u]===false ? false : st.ui.expanded[u]==='c'; }
942
1617
  function focusRestoreG(host, attrU, attrF){
943
1618
  const fa=document.activeElement, fu=fa&&fa.dataset?fa.dataset[attrU]:null, ff=fa&&fa.dataset?fa.dataset[attrF]:null, ss=fa&&fa.selectionStart;
944
- return function(){ if(fu&&ff){ const el=host.querySelector('['+'data-'+(attrU==='gu'?'gu':'cu')+'="'+fu+'"]['+'data-'+(attrU==='gu'?'gf':'cf')+'="'+ff+'"]'); if(el){ el.focus(); try{ if(ss!=null&&el.setSelectionRange) el.setSelectionRange(ss,ss);}catch(_){}}}};
945
- }
946
- /* ---- シート: 広告グループ タブ(変更列を一覧編集) ---- */
947
- function renderSheetGroups(){
948
- const host=document.getElementById('sheetHost'); const restore=focusRestoreG(host,'gu','gf');
949
- // F-6 列順: キャンペーン → 広告グループID(左から2番目) → 名前 → paramCode → 入札/配信/ターゲティング
950
- const cols=['#','キャンペーン','広告グループID','広告グループ名','paramCode','入札','配信','デバイス','性別','年齢','地域','曜日時間','オーディエンス','サーチKW',''];
951
- let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>'; let ri=-1;
952
- const gin=(g,ci,w,req)=>{ const f=VARF_GROUPS[ci]; const v=g[f]; const empty=req&&(v==null||v===''); return '<td><input class="cell-in'+(empty?' warnempty':'')+'" data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-gu="'+g.uid+'" data-gf="'+f+'" value="'+esc(v==null?'':v)+'" style="width:'+w+'"></td>'; };
953
- const roTd=v=>'<td>'+(v?'<span class="robadge">'+esc(v)+'</span>':'<span class="idpill" style="color:var(--faint)">—</span>')+'</td>';
954
- st.campaigns.forEach(c=>{
955
- c.adGroups.forEach(g=>{ ri++;
956
- const gEd=!isExistingGroup(g); // 既存呼び出し行=disabled / 新規行=編集可
957
- html+='<tr class="adrow" data-gu="'+g.uid+'">';
958
- html+='<td class="rownum" data-rownum="'+ri+'">'+(ri+1)+'</td>';
959
- // キャンペーン列: 新規AGはクリックでプラン内キャンペーンをドロワー選択(親付け替え)。既存AGはキャンペーンが確定済みのため変更不可
960
- if(gEd){
961
- html+='<td><div class="cppick" data-cppick-g="'+g.uid+'" title="クリックでキャンペーンを選択(プラン内)"><span class="breadc" style="max-width:150px">'+esc(c.name)+'</span><span class="tgcaret">▾</span></div></td>';
962
- } else {
963
- html+='<td><span class="robadge" title="既存広告グループのためキャンペーン変更不可" style="max-width:150px; display:inline-block; overflow:hidden; text-overflow:ellipsis; vertical-align:middle">'+esc(c.name)+'</span></td>';
964
- }
965
- // 広告グループID (左から2番目): 既存=ID / 新規=「新規」バッジ (手入力なし)
966
- html+='<td>'+newOrIdBadge(isNewGroup(g), resolveAdGroupIdOf(g))+'</td>';
967
- if(gEd){
968
- html+=gin(g,1,'150px',true); // 名前(c1)
969
- html+='<td><input class="cell-in mono'+(!g.paramCode?' warnempty':'')+'" data-sel="1" data-r="'+ri+'" data-c="2" data-gu="'+g.uid+'" data-gf="paramCode" value="'+esc(g.paramCode||'')+'" title="URL生成用のパラメータ(グループ紐づけには不使用)" style="width:120px"></td>'; // paramCode(c2)
970
- html+=gin(g,3,'56px',false); // 入札(c3)
971
- html+='<td><select class="cell-in fmt" style="min-width:66px" data-sel="1" data-r="'+ri+'" data-c="4" data-gu="'+g.uid+'" data-gf="deliveryStatus"><option value=""'+(!g.deliveryStatus?' selected':'')+'>(既定)</option><option'+(g.deliveryStatus==='オフ'?' selected':'')+'>オフ</option><option'+(g.deliveryStatus==='オン'?' selected':'')+'>オン</option></select></td>'; // 配信(c4)
972
- [5,6,7,8,9,10,11].forEach(ci=>{ const tf=VARF_GROUPS[ci]; if(TG_OPTIONS[tf]){ const val=g[tf]||''; const disp = val? '<span class="tgval">'+esc(val)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(TG_OPTIONS[tf].label)+'</span></span>'; html+='<td class="tgtd"><div class="tgpick'+(val?'':' tgempty')+'" data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-gu="'+g.uid+'" data-gf="'+tf+'" title="'+esc(val||(TG_OPTIONS[tf].label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>'; } else { html+=gin(g,ci,'120px',false); } });
973
- } else {
974
- // 既存呼び出し行: 名前/paramCode/入札/配信/ターゲティングを read-only(disabled)。ID起点で自動で埋まった値を表示
975
- html+='<td><span class="robadge" title="既存広告グループのため編集不可">'+esc(g.name||'')+'</span></td>';
976
- html+='<td><span class="robadge mono">'+esc(g.paramCode||'')+'</span></td>';
977
- html+=roTd(g.bid);
978
- html+=roTd(g.deliveryStatus);
979
- [5,6,7,8,9,10,11].forEach(ci=>{ const tf=VARF_GROUPS[ci]; html+=roTd(g[tf]); });
980
- }
981
- html+='<td><span class="rowact"><button class="iconbtn" data-act="ag-dup" data-u="'+g.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+g.uid+'" title="削除">✕</button></span></td>';
982
- html+='</tr>';
983
- });
984
- });
985
- // F-5: 追加行はタブ最下部に1行だけ。追加時にキャンペーンを選択(ドロワー)
986
- html+='<tr><td></td><td colspan="14"><button class="addbtn" data-act="add-adgroup">+ 新規広告グループ</button> <button class="addbtn" data-act="add-adgroup-existing">📂 既存広告グループを呼び出し</button></td></tr>';
987
- html+='</tbody></table>';
988
- host.innerHTML=html; restore(); applySelection();
989
- }
990
- /* ---- シート: キャンペーン タブ(変更列を一覧編集) ---- */
991
- function renderSheetCampaigns(){
992
- const host=document.getElementById('sheetHost'); const restore=focusRestoreG(host,'cu','cf');
993
- // E-2: campaignId 最左・read-only。モード/配信/戦略メモ列は撤去
994
- const cols=['#','キャンペーンID','キャンペーン名','予算/日','入札戦略','tCPA','検証変数','リンク番号',''];
995
- let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
996
- let RIDX=-1;
997
- st.campaigns.forEach((c)=>{ RIDX++;
998
- const cEd=!isExistingCampaign(c); // F-2: 編集可否は明示フラグのみ。表示バッジはID解決ベースで分離
999
- const a=(f)=>'data-sel="1" data-r="'+RIDX+'" data-c="'+VARF_CAMPAIGNS.indexOf(f)+'" data-cu="'+c.uid+'" data-cf="'+f+'"';
1000
- html+='<tr class="adrow" data-cu="'+c.uid+'">';
1001
- html+='<td class="rownum" data-rownum="'+RIDX+'">'+(RIDX+1)+'</td>';
1002
- // campaignId: 入力欄廃止。既存=「ID xxx」/ 新規=「新規」バッジ (表示はID解決ベース)
1003
- html+='<td>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c))+'</td>';
1004
- // 既存呼び出しキャンペーンは広告追加専用 → 基本パラメータ(name/予算/入札/tCPA/検証変数/リンク番号)は全て read-only (backendが無視するため編集齟齬を防ぐ)
1005
- if(cEd){
1006
- html+='<td><input class="cell-in'+(!c.name?' warnempty':'')+'" '+a('name')+' value="'+esc(c.name||'')+'" style="width:170px"></td>';
1007
- html+='<td><input class="cell-in num" type="number" '+a('budget')+' value="'+esc(c.budget??'')+'" placeholder="'+NEW_CP_BUDGET+'" style="width:78px"></td>';
1008
- html+='<td><select class="cell-in fmt" style="min-width:82px" '+a('biddingStrategy')+'><option value="">(既定)</option><option'+(c.biddingStrategy==='tCPA'?' selected':'')+'>tCPA</option><option'+(c.biddingStrategy==='最大配信'?' selected':'')+'>最大配信</option></select></td>';
1009
- html+='<td><input class="cell-in num" type="number" '+a('tcpa')+' value="'+esc(c.tcpa??'')+'" placeholder="'+st.project.tcpaDefault+'" style="width:70px"></td>';
1010
- html+='<td><input class="cell-in" '+a('variable')+' value="'+esc(c.variable||'')+'" style="width:140px"></td>';
1011
- html+='<td><input class="cell-in mono" '+a('urlNumber')+' value="'+esc(c.urlNumber||'')+'" style="width:54px"></td>';
1012
- } else {
1013
- html+='<td><span class="robadge" title="既存キャンペーンのため編集不可">'+esc(c.name||'')+'</span></td>';
1014
- html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1015
- html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1016
- html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1017
- html+='<td>'+(c.variable?'<span class="robadge">'+esc(c.variable)+'</span>':'<span class="idpill" style="color:var(--faint)">—</span>')+'</td>';
1018
- html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1019
- }
1020
- html+='<td><span class="rowact"><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></td>';
1021
- html+='</tr>';
1022
- });
1023
- html+='<tr><td></td><td colspan="8"><button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button> <button class="addbtn" data-act="add-campaign-existing">📂 既存キャンペーンを呼び出し</button></td></tr>';
1024
- html+='</tbody></table>'; if(!st.campaigns.length) html='<div class="empty">キャンペーンがありません。「+ 新規キャンペーン」または「📂 既存キャンペーンを呼び出し」から。</div>';
1025
- host.innerHTML=html; restore(); applySelection();
1619
+ return function(){ if(fu&&ff){ const el=host.querySelector('[data-'+attrU+'="'+fu+'"][data-'+attrF+'="'+ff+'"]'); if(el){ el.focus(); try{ if(ss!=null&&el.setSelectionRange) el.setSelectionRange(ss,ss);}catch(_){}}}};
1026
1620
  }
1027
1621
  function renderTree(){
1028
1622
  if(st.campaigns.length===0){ document.getElementById('treeHost').innerHTML='<div class="empty">キャンペーンがありません。</div><div class="treeadd" style="margin-top:10px"><button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button><button class="addbtn" data-act="add-campaign-existing" style="margin-left:6px">📂 既存キャンペーンを呼び出し</button></div>'; return; }
@@ -1040,10 +1634,7 @@ function renderTree(){
1040
1634
  : '<span class="nodename" title="既存キャンペーンのため編集不可">'+esc(c.name)+'</span>';
1041
1635
  html+='<span class="metae"><span class="ratio">'+esc(ratio)+'</span>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c));
1042
1636
  // 予算/入札は編集可(新規扱い)のCPのみ表示 (既存呼び出しCPは広告追加専用で予算等は無視)
1043
- if(cpEd){
1044
- html+='<span class="chip">予算 <b>'+esc((c.budget??NEW_CP_BUDGET).toLocaleString())+'</b>円</span>';
1045
- html+='<span class="chip">'+esc(c.biddingStrategy||st.project.biddingStrategyDefault)+' <b>'+esc((c.tcpa??st.project.tcpaDefault))+'</b></span>';
1046
- }
1637
+ if(cpEd) html+=pf().tree.campaignBadges(c, pf(), st.project); // S7: 予算/入札の見せ方は媒体スキーマ
1047
1638
  if(c.variable) html+='<span class="pill purple">変数:'+esc(c.variable)+'</span>';
1048
1639
  if(cerr) html+='<span class="pill danger">要修正</span>';
1049
1640
  html+='</span>';
@@ -1057,7 +1648,7 @@ function renderTree(){
1057
1648
  html+='<div class="noderow"><button class="caret" data-act="toggle" data-u="'+g.uid+'"><span class="a"></span></button>';
1058
1649
  html+= gEd ? '<span class="nodename" contenteditable="true" data-rename="ag" data-u="'+g.uid+'">'+esc(g.name)+'</span>'
1059
1650
  : '<span class="nodename" title="既存広告グループのため編集不可">'+esc(g.name)+'</span>';
1060
- html+='<span class="metae"><span class="pill mono">【'+esc(g.paramCode||'—')+'】</span><span class="chip">広告 <b>'+g.ads.length+'</b></span>';
1651
+ html+='<span class="metae">'+pf().tree.groupBadges(g)+'<span class="chip">広告 <b>'+g.ads.length+'</b></span>';
1061
1652
  html+= newOrIdBadge(isNewGroup(g), resolveAdGroupIdOf(g));
1062
1653
  html+='</span>';
1063
1654
  html+='<span class="nodeact">'+(gEd?'<button class="iconbtn" data-act="ag-param" data-u="'+g.uid+'" title="paramCode編集">#</button>':'')+'<button class="iconbtn" data-act="ag-dup" data-u="'+g.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+g.uid+'" title="削除">✕</button></span>';
@@ -1066,15 +1657,15 @@ function renderTree(){
1066
1657
  const r=resolveAd(a,g); const dot=r.errs.length?'err':(r.warns.length?'warn':'ok');
1067
1658
  html+='<div class="adnode'+(r.errs.length?' hasErr':'')+'" data-ad="'+a.uid+'">';
1068
1659
  html+='<span class="dot '+dot+'"></span>';
1069
- html+='<span class="cbadge">'+esc(a.imageNo||'—')+(a.format||'Card')[0].toLowerCase()+'</span>';
1070
- html+='<span class="adttl">'+(r.errs.includes('タイトル未解決')?'<span style="color:var(--danger)">タイトル未解決</span>':esc(r.titleText||'(未入力)'))+' <span class="sm">'+esc(a.titleNo)+'L'+esc(a.descNo)+'</span></span>';
1660
+ html+=pf().tree.adBadge(a,r); // S7: クリエイティブ名バッジ (媒体スキーマ)
1661
+ html+='<span class="adttl">'+(r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span style="color:var(--danger)">タイトル未解決</span>':esc(r.titleText||'(未入力)'))+' <span class="sm">'+pf().tree.adSub(a,r)+'</span></span>';
1071
1662
  html+='<span class="nodeact"><button class="iconbtn" data-act="ad-dup" data-u="'+a.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ad-del" data-u="'+a.uid+'" title="削除">✕</button></span>';
1072
1663
  html+='</div>';
1073
1664
  });
1074
1665
  html+='<div class="addrow"><button class="addbtn" data-act="ag-addad" data-u="'+g.uid+'">+ 広告を追加</button><button class="addbtn" data-act="ag-addn" data-u="'+g.uid+'">+ N本まとめて</button></div>';
1075
1666
  html+='</div></div>';
1076
1667
  });
1077
- html+='<div class="addrow"><button class="addbtn" data-act="cp-addag" data-u="'+c.uid+'">+ 新規広告グループ</button><button class="addbtn" data-act="cp-addag-existing" data-u="'+c.uid+'">📂 既存広告グループを呼び出し</button></div>';
1668
+ html+='<div class="addrow"><button class="addbtn" data-act="cp-addag" data-u="'+c.uid+'">+ 新規'+esc(agLabel())+'</button><button class="addbtn" data-act="cp-addag-existing" data-u="'+c.uid+'">📂 既存'+esc(agLabel())+'を呼び出し</button></div>';
1078
1669
  html+='</div></div>';
1079
1670
  });
1080
1671
  // E-1: ツリー側のキャンペーン追加導線(新規/既存の2択)
@@ -1086,92 +1677,765 @@ function renderTree(){
1086
1677
  REFERENCE DATA (タイトル/説明一覧・登録画像一覧) — 閲覧+登録
1087
1678
  ============================================================ */
1088
1679
  let refTab='titles';
1089
- const newRefIds={titles:new Set(), media:new Set()}; // 新規追加に一時的な new ラベル(リロードで消える)
1090
- function newBadge(kind,no){ return newRefIds[kind].has(String(no)) ? '<span class="newbadge">new</span>' : ''; }
1680
+ /* 新規追加に一時的な new ラベル(リロードで消える)。テーブルキーごとに1セット。 */
1681
+ const newRefIds=(()=>{ const o={}; refTableKeys().forEach(k=>{ o[k]=new Set(); }); return o; })();
1682
+ function newBadge(key,no){ const s=newRefIds[key]; return (s&&s.has(String(no))) ? '<span class="newbadge">new</span>' : ''; }
1683
+ /* refcombo 列が引く参照テーブルの key 群 (宣言順・重複排除) */
1684
+ function refDatalistKeys(){
1685
+ const out=[], S=pf().sheets; Object.keys(S).forEach(t=>S[t].forEach(c=>{
1686
+ if(c.kind==='refcombo' && c.refTable && refTableDef(c.refTable) && out.indexOf(c.refTable)<0) out.push(c.refTable); }));
1687
+ return out;
1688
+ }
1689
+ function refSortedKeys(T,store){
1690
+ const ks=Object.keys(store||{});
1691
+ return T.keyKind==='number' ? ks.sort((a,b)=>a-b) : ks.sort((a,b)=>String(a).localeCompare(String(b)));
1692
+ }
1091
1693
  function buildDatalists(){
1092
1694
  const host=document.getElementById('dlHost'); if(!host) return;
1093
- const tks=Object.keys(st.refs.titles).sort((a,b)=>a-b);
1094
- const mks=Object.keys(st.refs.media).sort((a,b)=>a-b);
1095
- const t=tks.map(no=>'<option value="'+esc(no)+'">'+esc(st.refs.titles[no])+'</option>').join('');
1096
- const m=mks.map(no=>{ const e=st.refs.media[no]; return '<option value="'+esc(no)+'">'+esc((e.card?'Card':'')+(e.card&&e.sq?'/':'')+(e.sq?'SQ':''))+'</option>'; }).join('');
1097
- host.innerHTML='<datalist id="dl-titles">'+t+'</datalist><datalist id="dl-media">'+m+'</datalist>';
1695
+ host.innerHTML=refDatalistKeys().map(k=>{
1696
+ const T=refTableDef(k), store=st.refs[k]||{};
1697
+ const opts=refSortedKeys(T,store).map(no=>'<option value="'+esc(no)+'">'+esc(T.optionLabel?T.optionLabel(store[no]):'')+'</option>').join('');
1698
+ return '<datalist id="dl-'+k+'">'+opts+'</datalist>';
1699
+ }).join('');
1700
+ }
1701
+ /* 検索対象文字列 (キー + 全 valueFields の値) */
1702
+ function refSearchStrings(T,no,val){
1703
+ const out=[String(no).toLowerCase()];
1704
+ if(T.valueFields.length===1) out.push(String(val==null?'':val).toLowerCase());
1705
+ else T.valueFields.forEach(f=>out.push(String((val||{})[f.k]||'').toLowerCase()));
1706
+ return out;
1707
+ }
1708
+ function refTh(w,label){ return '<th'+(w?' style="width:'+w+'"':'')+'>'+esc(label)+'</th>'; }
1709
+ function refTd(col,no,val){
1710
+ const cls=typeof col.cls==='function'?col.cls(no,val):col.cls;
1711
+ return '<td'+(cls?' class="'+cls+'"':'')+(col.style?' style="'+col.style+'"':'')+(col.attr?col.attr(no,val):'')+'>'+col.cell(no,val)+'</td>';
1712
+ }
1713
+ function refActionTd(T,no){
1714
+ return '<td><button class="iconbtn" data-refedit="'+T.key+'" data-no="'+esc(no)+'" title="編集">✎</button>'
1715
+ + '<button class="iconbtn danger" data-refdel="'+T.key+'" data-no="'+esc(no)+'" title="削除">✕</button></td>';
1098
1716
  }
1099
1717
  function renderRef(){
1100
1718
  const q=(document.getElementById('refSearch').value||'').trim().toLowerCase();
1101
- const host=document.getElementById('refHost'); let html='';
1719
+ const host=document.getElementById('refHost');
1720
+ const T=refTableDef(refTab); if(!T){ host.innerHTML=''; return; }
1102
1721
  // アカウントアセット: 広告グループ/キャンペーンは既存(アカウント)の閲覧のみ → 追加/文言コピーは非表示
1103
- const editable = (refTab==='titles'||refTab==='descriptions'||refTab==='media');
1104
1722
  const setDisp=(id,show)=>{ const el=document.getElementById(id); if(el) el.style.display=show?'':'none'; };
1105
- setDisp('btnRefAdd', editable); setDisp('btnAccCopy', refTab==='titles'||refTab==='descriptions'); setDisp('btnRefReset', editable);
1106
- if(refTab==='adGroups'){
1107
- const store=st.refs.adGroups||{};
1108
- const all=Object.keys(store).map(id=>({id, ...store[id]}));
1109
- const f=all.filter(e=>!q||String(e.id).includes(q)||String(e.name||'').toLowerCase().includes(q)||String(e.paramCode||'').toLowerCase().includes(q)||String(e.campaignId||'').includes(q)).sort((a,b)=>String(a.id).localeCompare(String(b.id)));
1110
- document.getElementById('refCount').textContent='広告グループ '+f.length+'/'+all.length+'件';
1111
- html='<table class="reftable"><thead><tr><th style="width:110px">広告グループID</th><th>広告グループ名</th><th style="width:130px">paramCode</th><th style="width:110px">キャンペーンID</th></tr></thead><tbody>';
1112
- f.forEach(e=>{ html+='<tr><td class="mono">'+esc(e.id)+'</td><td class="reftext" title="'+esc(e.name||'')+'">'+esc(e.name||'')+'</td><td class="mono">'+esc(e.paramCode||'')+'</td><td class="mono">'+esc(e.campaignId||'')+'</td></tr>'; });
1113
- html+='</tbody></table>'; if(!f.length) html='<div class="empty">既存の広告グループがありません(アカウントJSONの refs.adGroups)</div>';
1114
- host.innerHTML=html; return;
1115
- }
1116
- if(refTab==='campaigns'){
1117
- const store=st.refs.campaigns||{};
1118
- const all=Object.keys(store).map(id=>({id, ...store[id]}));
1119
- const f=all.filter(e=>!q||String(e.id).includes(q)||String(e.name||'').toLowerCase().includes(q)).sort((a,b)=>String(a.id).localeCompare(String(b.id)));
1120
- document.getElementById('refCount').textContent='キャンペーン '+f.length+'/'+all.length+'件';
1121
- html='<table class="reftable"><thead><tr><th style="width:120px">キャンペーンID</th><th>キャンペーン名</th></tr></thead><tbody>';
1122
- f.forEach(e=>{ html+='<tr><td class="mono">'+esc(e.id)+'</td><td class="reftext" title="'+esc(e.name||'')+'">'+esc(e.name||'')+'</td></tr>'; });
1123
- html+='</tbody></table>'; if(!f.length) html='<div class="empty">既存のキャンペーンがありません(アカウントJSONの refs.campaigns)</div>';
1124
- host.innerHTML=html; return;
1125
- }
1126
- if(refTab==='titles'||refTab==='descriptions'){
1127
- const isTitle=refTab==='titles';
1128
- const store=isTitle?st.refs.titles:(st.refs.descriptions||{});
1129
- const kind=isTitle?'t':'d';
1130
- const label=isTitle?'タイトル':'説明文';
1131
- const all=Object.keys(store).map(no=>({no:+no,text:store[no]})).filter(e=>String(e.text||'').trim()!=='');
1132
- const f=all.filter(e=>!q||String(e.no).includes(q)||String(e.text).toLowerCase().includes(q)).sort((a,b)=>a.no-b.no);
1133
- document.getElementById('refCount').textContent=label+' '+f.length+'/'+all.length+'件';
1134
- html='<table class="reftable"><thead><tr><th style="width:70px">番号</th><th>'+esc(label)+'</th><th style="width:70px">文字数</th><th style="width:70px"></th></tr></thead><tbody>';
1135
- f.forEach(e=>{ const L=glen(e.text); const cls=(isTitle&&L>35)?'err':(isTitle&&L>20?'warn':''); html+='<tr><td class="mono">'+esc(e.no)+'</td><td class="reftext" title="'+esc(e.text)+'">'+esc(e.text)+'</td><td class="reflen '+cls+'">'+L+'</td><td><button class="iconbtn" data-refedit="'+kind+'" data-no="'+esc(e.no)+'" title="編集">✎</button><button class="iconbtn danger" data-refdel="'+kind+'" data-no="'+esc(e.no)+'" title="削除">✕</button></td></tr>'; });
1136
- html+='</tbody></table>'; if(!f.length) html='<div class="empty">該当する'+esc(label)+'がありません</div>';
1137
- } else {
1138
- const all=Object.keys(st.refs.media).map(no=>({no:+no,m:st.refs.media[no]}));
1139
- const f=all.filter(e=>!q||String(e.no).includes(q)||String(e.m.card||'').includes(q)||String(e.m.sq||'').includes(q)).sort((a,b)=>a.no-b.no);
1140
- document.getElementById('refCount').textContent='登録画像 '+f.length+'/'+all.length+'件';
1141
- html='<table class="reftable"><thead><tr><th style="width:80px">画像番号</th><th>Card mediaID (1200)</th><th>SQ mediaID (1080)</th><th>ファイル名</th><th style="width:70px"></th></tr></thead><tbody>';
1142
- f.forEach(e=>{ html+='<tr><td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(e.no)+'</span> '+newBadge('media',e.no)+'</td><td class="mono">'+esc(e.m.card||'—')+'</td><td class="mono">'+esc(e.m.sq||'—')+'</td><td class="mono" style="color:var(--faint);font-size:11px">'+esc(e.no)+'-1200.jpg / '+esc(e.no)+'-1080.jpg</td><td><button class="iconbtn" data-refedit="m" data-no="'+esc(e.no)+'" title="編集">✎</button><button class="iconbtn danger" data-refdel="m" data-no="'+esc(e.no)+'" title="削除">✕</button></td></tr>'; });
1143
- html+='</tbody></table>'; if(!f.length) html='<div class="empty">該当する画像がありません</div>';
1144
- }
1723
+ setDisp('btnRefAdd', !!T.editable); setDisp('btnAccCopy', !!T.copyable); setDisp('btnRefReset', !!T.editable);
1724
+ const store=st.refs[T.key]||{};
1725
+ let all=refSortedKeys(T,store).map(k=>({no:(T.keyKind==='number'?+k:k), val:store[k]}));
1726
+ if(T.dropEmpty) all=all.filter(e=>String(e.val==null?'':e.val).trim()!=='');
1727
+ const f=all.filter(e=>!q||refSearchStrings(T,e.no,e.val).some(s=>s.includes(q)));
1728
+ document.getElementById('refCount').textContent=T.countLabel+' '+f.length+'/'+all.length+'件';
1729
+ let html='<table class="reftable"><thead><tr>'+refTh(T.keyWidth,T.keyLabel)
1730
+ +T.columns.map(c=>refTh(c.width,c.label)).join('')+(T.editable?refTh('70px',''):'')+'</tr></thead><tbody>';
1731
+ f.forEach(e=>{ html+='<tr>'+T.keyCell(e.no,e.val)+T.columns.map(c=>refTd(c,e.no,e.val)).join('')
1732
+ +(T.editable?refActionTd(T,e.no):'')+'</tr>'; });
1733
+ html+='</tbody></table>';
1734
+ if(!f.length) html='<div class="empty">'+esc(T.emptyMsg)+'</div>';
1145
1735
  host.innerHTML=html;
1146
1736
  }
1147
1737
  function refCommit(fn,msg){ snapshot(); fn(); persist(); buildDatalists(); render(); if(typeof saveActiveAccountJson==='function') saveActiveAccountJson(); toast(msg); }
1738
+ /* valueFields が1件のテーブルは文言そのもの(文字列)、複数件はオブジェクトを格納する */
1739
+ function refIsSingle(T){ return T.valueFields.length===1; }
1148
1740
  function refAdd(){
1149
- if(refTab==='titles'||refTab==='descriptions'){
1150
- const isTitle=refTab==='titles'; const label=isTitle?'タイトル':'説明文';
1151
- openModal({title:label+'を登録', sub:isTitle?'番号→文言。タイトル枠は20字目安・35字上限':'番号→説明文(長文可)', fields:[
1152
- {key:'no',label:'番号',type:'number',value:''},{key:'text',label:'文言',type:'text',value:''}
1153
- ], onOk:v=>{ const no=parseInt(v.no); if(!no){toast('番号を入力してください');return;} const store=isTitle?st.refs.titles:st.refs.descriptions; const dup=store[no]!=null; refCommit(()=>{ store[no]=v.text; }, dup?('番号'+no+'を上書き'):'登録しました'); }});
1154
- } else {
1155
- openModal({title:'画像を登録', sub:'画像番号→Card/SQ の mediaID。新規作成した画像をメディアに登録', fields:[
1156
- {key:'no',label:'画像番号',type:'number',value:''},{key:'card',label:'Card mediaID (1200)',type:'text',value:''},{key:'sq',label:'SQ mediaID (1080)',type:'text',value:''}
1157
- ], onOk:v=>{ const no=parseInt(v.no); if(!no){toast('画像番号を入力してください');return;} const isNew=st.refs.media[no]==null; refCommit(()=>{ const e=Object.assign({},st.refs.media[no]||{}); if(v.card!=='')e.card=v.card.trim(); if(v.sq!=='')e.sq=v.sq.trim(); st.refs.media[no]=e; if(isNew) newRefIds.media.add(String(no)); }, '登録しました'); }});
1158
- }
1159
- }
1160
- function refEdit(kind,no){
1161
- if(kind==='t'||kind==='d'){ const store=kind==='t'?st.refs.titles:st.refs.descriptions; const cur=store[no]; openModal({title:(kind==='t'?'タイトル':'説明文')+'を編集', sub:'番号 '+no, fields:[{key:'text',label:'文言',type:'text',value:cur}], onOk:v=>{ refCommit(()=>{ store[no]=v.text; }, '更新しました'); }}); }
1162
- else { const e=st.refs.media[no]||{}; openModal({title:'画像を編集', sub:'画像番号 '+no, fields:[{key:'card',label:'Card mediaID',type:'text',value:e.card||''},{key:'sq',label:'SQ mediaID',type:'text',value:e.sq||''}], onOk:v=>{ refCommit(()=>{ st.refs.media[no]={card:v.card.trim(),sq:v.sq.trim()}; }, '更新しました'); }}); }
1741
+ const T=refTableDef(refTab); if(!T||!T.editable) return;
1742
+ const single=refIsSingle(T);
1743
+ openModal({title:T.label+'を登録', sub:T.addSub,
1744
+ fields:[{key:'no',label:T.keyLabel,type:'number',value:''}]
1745
+ .concat(T.valueFields.map(fd=>({key:fd.k,label:fd.label,type:'text',value:''}))),
1746
+ onOk:v=>{
1747
+ const no=parseInt(v.no); if(!no){ toast(T.keyLabel+'を入力してください'); return; }
1748
+ const store=st.refs[T.key];
1749
+ if(single){ const k=T.valueFields[0].k, dup=store[no]!=null;
1750
+ refCommit(()=>{ store[no]=v[k]; }, dup?(T.keyLabel+no+'を上書き'):'登録しました'); return; }
1751
+ const isNew=store[no]==null;
1752
+ refCommit(()=>{ const e=Object.assign({},store[no]||{});
1753
+ T.valueFields.forEach(fd=>{ if(v[fd.k]!=='') e[fd.k]=String(v[fd.k]).trim(); });
1754
+ store[no]=e; if(isNew) newRefIds[T.key].add(String(no)); }, '登録しました');
1755
+ }});
1756
+ }
1757
+ function refEdit(tableKey,no){
1758
+ const T=refTableDef(tableKey); if(!T||!T.editable) return;
1759
+ const store=st.refs[T.key], cur=store[no], single=refIsSingle(T);
1760
+ openModal({title:T.label+'を編集', sub:T.keyLabel+' '+no,
1761
+ fields:T.valueFields.map(fd=>({key:fd.k, label:fd.editLabel||fd.label, type:'text',
1762
+ value:single?cur:((cur||{})[fd.k]||'')})),
1763
+ onOk:v=>{ refCommit(()=>{
1764
+ if(single){ store[no]=v[T.valueFields[0].k]; return; }
1765
+ const e={}; T.valueFields.forEach(fd=>{ e[fd.k]=String(v[fd.k]).trim(); }); store[no]=e;
1766
+ }, '更新しました'); }});
1767
+ }
1768
+ function refDel(tableKey,no){
1769
+ const T=refTableDef(tableKey); if(!T||!T.editable) return;
1770
+ refCommit(()=>{ delete st.refs[T.key][no]; }, '削除しました');
1163
1771
  }
1164
- function refDel(kind,no){ refCommit(()=>{ if(kind==='t') delete st.refs.titles[no]; else if(kind==='d') delete st.refs.descriptions[no]; else delete st.refs.media[no]; }, '削除しました'); }
1165
1772
 
1166
1773
  /* ============================================================
1167
1774
  SPREADSHEET SELECTION / COPY / PASTE
1168
1775
  ============================================================ */
1169
- const VARF=['format','imageNo','titleNo','descNo','paramCode','articleSlug']; // 広告タブ列
1170
- const VARF_GROUPS=['adGroupId','name','paramCode','bid','deliveryStatus','tg_device','tg_gender','tg_age','tg_region','tg_dayHour','tg_audience','tg_searchKw']; // F-6: adGroupId(index0・非編集) → name → paramCode → …
1171
- const VARF_CAMPAIGNS=['campaignId','name','budget','biddingStrategy','tcpa','variable','urlNumber']; // E-2: campaignId最左・read-only / mode/status/strategyMemo撤去
1172
- function tabCols(){ return sheetTab==='groups'?VARF_GROUPS : sheetTab==='campaigns'?VARF_CAMPAIGNS : VARF; }
1173
- function getVar(item,c){ const f=tabCols()[c]; if(item.kind==='g') return item.g[f]==null?'':String(item.g[f]); if(item.kind==='c') return item.c[f]==null?'':String(item.c[f]); if(f==='paramCode') return item.g.paramCode||''; const v=item.a[f]; return v==null?'':String(v); }
1174
- function setVar(item,c,val){ const f=tabCols()[c]; if(item.kind==='g'){ if(isExistingGroup(item.g)) return; /* F-2: 既存呼び出しAGは基本パラメータ編集不可 */ if(f==='adGroupId') return; /* E-4: read-only */ if(f==='deliveryStatus'){ const s=String(val).trim(); val=(s==='オフ'||s==='オン')?s:''; } item.g[f]=val; if(f==='paramCode') autoResolveGroupIds(item.g,item.c); return; } if(item.kind==='c'){ if(isExistingCampaign(item.c)) return; /* F-2: 既存呼び出しCPは基本パラメータ編集不可 */ if(f==='campaignId') return; /* E-2: read-only */ if(f==='budget'||f==='tcpa') val=numOrNull(val); item.c[f]=val; return; } if(f==='paramCode'){ if(isExistingGroup(item.g)) return; /* F-2: 既存呼び出しAGのparamCodeは編集不可 */ item.g.paramCode=val; autoResolveGroupIds(item.g,item.c); } else if(f==='format') item.a.format=(String(val).toUpperCase()==='SQ'?'SQ':'Card'); else item.a[f]=val; }
1776
+ /* ---- S3/S4/S5: 列記述子 (COLS) ----
1777
+ シート描画 (renderCell) / セル値アクセス (getVar/setVar) / 貼付 / 連番 / ドロワーが見る唯一の列定義。
1778
+ 列番号リテラルはコードから撤去済み。S7 で PLATFORMS[curPf].sheets へ移設する。
1779
+ ・kind : 'text'|'number'|'mono'|'select'|'refcombo'|'tgpick'|'badge'|'derived'
1780
+ ・width : 入力欄の style="width:…"(select は minWidth で min-width、refcombo/tgpick は無指定)
1781
+ ・scope : 'parentGroup' = 広告行から所属グループの値を読み書きする (YDA paramCode)
1782
+ ・coerce : setVar 時の値正規化 / onSet: 書き込み後フック (paramCode の ID 再解決)
1783
+ ・required : 空欄で .warnempty (黄枠)
1784
+ ・alwaysReadonly / readonlyOnExisting / hideOnExisting / roEmptyDash / roStyle / roTitle
1785
+ : read-only 表現の指定 (renderCell の ro 分岐 / setVar の書込ガードを兼ねる)
1786
+ ・refTable : refcombo が引く参照テーブル (media/titles/descriptions)
1787
+ ・drawerType: ▼ドロワーの表示モード ('media' = 登録画像一覧 / 'titles' = 文言ライブラリ)。
1788
+ 説明文番号は store が descriptions でも表示モードは 'titles' (現行踏襲)
1789
+ ・ghostKey / ghostTitle / ghost / ddTitle : refcombo の下段ゴースト表示と ▼ボタン
1790
+ ・derive : kind:'derived' のセル内容 (resolveAd 由来の読取専用セル)
1791
+ ・tgKey : pf().targeting のキー
1792
+ ・sequenceable: 連番採番の対象列 / notSubmitted: 入稿JSONに載らない列 (書き出し時の警告対象) */
1793
+ const COLS = {
1794
+ ads:[
1795
+ {field:'format', label:'形式', kind:'select', options:['Card','SQ'], width:'56px',
1796
+ coerce:v=>(String(v).toUpperCase()==='SQ'?'SQ':'Card')},
1797
+ {field:'imageNo', label:'画像番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
1798
+ width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
1799
+ ghostKey:'media', ddTitle:'登録画像から選択', ghostTitle:r=>'メディアID '+r.mediaId,
1800
+ ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
1801
+ if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>';
1802
+ if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
1803
+ return '<span class="idpill err">#N/A</span>'; }},
1804
+ {field:'titleNo', label:'タイトル番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
1805
+ width:'6ch', sequenceable:true, errCodes:[EC.TITLE_UNRESOLVED, EC.TITLE_TOO_LONG],
1806
+ ghostKey:'title', ddTitle:'タイトル一覧から選択', ghostTitle:r=>r.titleText,
1807
+ ghost:r=>r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span class="err">未解決</span>':esc(r.titleText.slice(0,40))},
1808
+ {field:'descNo', label:'説明文番号', kind:'refcombo', refTable:'descriptions', drawerType:'titles',
1809
+ width:'6ch', sequenceable:true, errCodes:[EC.DESC_UNRESOLVED],
1810
+ ghostKey:'desc', ddTitle:'説明文一覧から選択', ghostTitle:r=>r.descText,
1811
+ ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
1812
+ {field:'paramCode', label:'paramCode(グループ)', kind:'mono', width:'120px',
1813
+ scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
1814
+ title:'グループ共通(変更で同グループ全広告に反映)',
1815
+ roStyle:'input', roTitle:'既存広告グループのため編集不可',
1816
+ onSet:it=>autoResolveGroupIds(it.g,it.c)},
1817
+ {field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
1818
+ /* derived: resolveAd() 由来の読取専用セル。tabCols()/貼付/セル選択の対象外 (末尾固定) */
1819
+ {field:'adName', label:'広告名(自動)', kind:'derived',
1820
+ derive:(a,ctx)=>'<span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(ctx.r.finalUrl)+'">'+esc(ctx.r.adName)+'</span>'},
1821
+ {field:'adGroupId', label:'CP / AG ID', kind:'derived',
1822
+ derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AG '+ctx.r.adGroupId):'新規')+'</span>'}
1823
+ ],
1824
+ /* F-6 列順: adGroupId(index0・非編集バッジ) → name → paramCode → 入札/配信/ターゲティング */
1825
+ adGroups:[
1826
+ {field:'adGroupId', label:'広告グループID', kind:'badge', alwaysReadonly:true},
1827
+ {field:'name', label:'広告グループ名', kind:'text', width:'150px', required:true,
1828
+ readonlyOnExisting:true, roTitle:'既存広告グループのため編集不可'},
1829
+ {field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
1830
+ sequenceable:true, readonlyOnExisting:true,
1831
+ title:'URL生成用のパラメータ(グループ紐づけには不使用)',
1832
+ onSet:it=>autoResolveGroupIds(it.g,it.c)},
1833
+ {field:'bid', label:'入札', kind:'text', width:'56px', readonlyOnExisting:true, roEmptyDash:true},
1834
+ {field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
1835
+ readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}},
1836
+ {field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
1837
+ {field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
1838
+ {field:'tg_age', label:'年齢', kind:'tgpick', tgKey:'tg_age', readonlyOnExisting:true, roEmptyDash:true},
1839
+ {field:'tg_region', label:'地域', kind:'tgpick', tgKey:'tg_region', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
1840
+ {field:'tg_dayHour', label:'曜日時間', kind:'tgpick', tgKey:'tg_dayHour', readonlyOnExisting:true, roEmptyDash:true},
1841
+ {field:'tg_audience', label:'オーディエンス', kind:'tgpick', tgKey:'tg_audience', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
1842
+ /* 暗黙 else を廃止: pf().targeting に無い列は kind:'text' で明示する */
1843
+ {field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true}
1844
+ ],
1845
+ /* E-2: campaignId最左・read-only / mode/status/strategyMemo撤去 */
1846
+ campaigns:[
1847
+ {field:'campaignId', label:'キャンペーンID', kind:'badge', alwaysReadonly:true},
1848
+ {field:'name', label:'キャンペーン名', kind:'text', width:'170px', required:true,
1849
+ readonlyOnExisting:true, roTitle:'既存キャンペーンのため編集不可'},
1850
+ {field:'budget', label:'予算/日', kind:'number', width:'78px',
1851
+ placeholder:()=>String(pf().defaults.newCampaignBudget), coerce:numOrNull, hideOnExisting:true},
1852
+ {field:'biddingStrategy', label:'入札戦略', kind:'select', options:['','tCPA','最大配信'], minWidth:'82px',
1853
+ hideOnExisting:true, emptyNoSelected:true},
1854
+ {field:'tcpa', label:'tCPA', kind:'number', width:'70px',
1855
+ placeholder:()=>String(st.project.tcpaDefault), coerce:numOrNull, hideOnExisting:true},
1856
+ {field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
1857
+ {field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
1858
+ ]
1859
+ };
1860
+ /* S7: 列記述子を媒体スキーマレジストリへ接続 (宣言順の都合で後付け。COLS が唯一の定義元) */
1861
+ PLATFORMS.yda.sheets = COLS;
1862
+
1863
+ /* ============================================================
1864
+ S10: PLATFORMS.meta — Meta (Facebook / Instagram) の媒体スキーマ
1865
+ ------------------------------------------------------------
1866
+ コア (renderCell / renderSheetGeneric / buildSubmitPlanWithStats / resolveAd) は無変更で、
1867
+ この宣言だけで Meta 版プランナーが成立する。
1868
+ 公式Docs照合 (20260730_Meta_MarketingAPI公式Docs照合ギャップ.md) を先回りして反映:
1869
+ ・instagram_actor_id ではなく instagramUserId (B2)
1870
+ ・promoted_object 用の pixelId + customEventType を必須ガード (B3)
1871
+ ・日予算は円のまま (JPY currency_offset=1 / ×100 しない) (B4)
1872
+ ・bid_amount が必須になる入札戦略で bidAmount>0 を強制 (B5)
1873
+ ・bidStrategy は公式4値のみ (MINIMUM_ROAS は載せない) (B6)
1874
+ ・特別広告カテゴリで genders を送らず age を 18-65 に固定 (B9)
1875
+ ・startTime は JST 明示 ISO8601 (B13)
1876
+ ============================================================ */
1877
+ const META_REF_TABLES = {
1878
+ titles:{ key:'titles', label:'見出し', countLabel:'見出し', editable:true, copyable:true, required:true,
1879
+ keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
1880
+ addSub:'番号→見出し。推奨27字・上限40字',
1881
+ emptyMsg:'該当する見出しがありません',
1882
+ lengthLimit:{warn:27, err:40},
1883
+ valueFields:[{k:'text', label:'文言'}],
1884
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
1885
+ columns:[
1886
+ {label:'見出し', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
1887
+ {label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(META_REF_TABLES.titles,v), cell:(no,v)=>glen(v)}
1888
+ ],
1889
+ optionLabel:v=>String(v==null?'':v) },
1890
+ descriptions:{ key:'descriptions', label:'メインテキスト', countLabel:'メインテキスト', editable:true, copyable:true, required:true,
1891
+ keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
1892
+ addSub:'番号→メインテキスト(推奨125字・運用上限500字)',
1893
+ emptyMsg:'該当するメインテキストがありません',
1894
+ lengthLimit:{warn:125, err:500},
1895
+ valueFields:[{k:'text', label:'文言'}],
1896
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
1897
+ columns:[
1898
+ {label:'メインテキスト', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
1899
+ {label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(META_REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
1900
+ ],
1901
+ optionLabel:v=>String(v==null?'':v) },
1902
+ media:{ key:'media', label:'クリエイティブ', countLabel:'クリエイティブ', editable:true, required:true,
1903
+ keyKind:'number', keyLabel:'番号', keyWidth:'80px',
1904
+ addSub:'番号→画像 imageHash / 動画 videoId / 動画サムネ imageHash',
1905
+ emptyMsg:'該当するクリエイティブがありません',
1906
+ valueFields:[{k:'image', label:'画像 imageHash', editLabel:'画像 imageHash'},
1907
+ {k:'video', label:'動画 videoId', editLabel:'動画 videoId'},
1908
+ {k:'thumb', label:'動画サムネ imageHash', editLabel:'サムネ imageHash'}],
1909
+ keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
1910
+ columns:[
1911
+ {label:'画像 imageHash', cls:'mono', cell:(no,v)=>esc(v&&v.image||'—')},
1912
+ {label:'動画 videoId', cls:'mono', cell:(no,v)=>esc(v&&v.video||'—')},
1913
+ {label:'サムネ hash', cls:'mono', cell:(no,v)=>esc(v&&v.thumb||'—')}
1914
+ ],
1915
+ optionLabel:v=>((v&&v.image?'IMG':'')+(v&&v.image&&v.video?'/':'')+(v&&v.video?'VID':'')),
1916
+ drawerLabel:v=>'IMG '+esc(v&&v.image||'—')+' / VID '+esc(v&&v.video||'—') },
1917
+ adGroups:{ key:'adGroups', label:'広告セット', countLabel:'広告セット', editable:false, required:false,
1918
+ keyKind:'string', keyLabel:'広告セットID', keyWidth:'130px',
1919
+ emptyMsg:'既存の広告セットがありません(アカウントJSONの refs.adGroups)',
1920
+ valueFields:[{k:'name',label:'名'},{k:'paramCode',label:'paramCode'},{k:'campaignId',label:'キャンペーンID'}],
1921
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
1922
+ columns:[
1923
+ {label:'広告セット名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
1924
+ {label:'paramCode', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.paramCode||'')},
1925
+ {label:'キャンペーンID', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.campaignId||'')}
1926
+ ] },
1927
+ campaigns:{ key:'campaigns', label:'キャンペーン', countLabel:'キャンペーン', editable:false, required:false,
1928
+ keyKind:'string', keyLabel:'キャンペーンID', keyWidth:'130px',
1929
+ emptyMsg:'既存のキャンペーンがありません(アカウントJSONの refs.campaigns)',
1930
+ valueFields:[{k:'name',label:'名'}],
1931
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
1932
+ columns:[
1933
+ {label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
1934
+ ] }
1935
+ };
1936
+ /* 列記述子 (Meta)。役割・意味は COLS のコメント参照 */
1937
+ const META_COLS = {
1938
+ ads:[
1939
+ {field:'format', label:'形式', kind:'select', options:['IMAGE','VIDEO'], minWidth:'70px',
1940
+ coerce:v=>(String(v).toUpperCase()==='VIDEO'?'VIDEO':'IMAGE')},
1941
+ {field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
1942
+ width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
1943
+ ghostKey:'media', ddTitle:'登録クリエイティブから選択',
1944
+ ghostTitle:r=>(r.mediaKind==='VIDEO'?'動画ID ':'画像ハッシュ ')+r.mediaId,
1945
+ ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
1946
+ if(r.mediaId!=='') return '<span class="idpill">'+(r.mediaKind==='VIDEO'?'VID ':'HASH ')+esc(r.mediaId)+'</span>';
1947
+ if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
1948
+ return '<span class="idpill err">#N/A</span>'; }},
1949
+ {field:'titleNo', label:'見出し番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
1950
+ width:'6ch', sequenceable:true, errCodes:[EC.TITLE_UNRESOLVED, EC.TITLE_TOO_LONG],
1951
+ ghostKey:'title', ddTitle:'見出し一覧から選択', ghostTitle:r=>r.titleText,
1952
+ ghost:r=>r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span class="err">未解決</span>':esc(r.titleText.slice(0,40))},
1953
+ {field:'descNo', label:'本文番号', kind:'refcombo', refTable:'descriptions', drawerType:'titles',
1954
+ width:'6ch', sequenceable:true, errCodes:[EC.DESC_UNRESOLVED, EC.DESC_TOO_LONG],
1955
+ ghostKey:'desc', ddTitle:'メインテキスト一覧から選択', ghostTitle:r=>r.descText,
1956
+ ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
1957
+ {field:'paramCode', label:'paramCode(広告セット)', kind:'mono', width:'120px',
1958
+ scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
1959
+ title:'広告セット共通(変更で同広告セット全広告に反映)',
1960
+ roStyle:'input', roTitle:'既存広告セットのため編集不可'},
1961
+ {field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
1962
+ {field:'adName', label:'広告名(自動)', kind:'derived',
1963
+ derive:(a,ctx)=>'<span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(ctx.r.finalUrl)+'">'+esc(ctx.r.adName)+'</span>'},
1964
+ {field:'adGroupId', label:'CP / AdSet ID', kind:'derived',
1965
+ derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AS '+ctx.r.adGroupId):'新規')+'</span>'}
1966
+ ],
1967
+ adGroups:[
1968
+ {field:'adGroupId', label:'広告セットID', kind:'badge', alwaysReadonly:true},
1969
+ {field:'name', label:'広告セット名', kind:'text', width:'150px', required:true,
1970
+ readonlyOnExisting:true, roTitle:'既存広告セットのため編集不可'},
1971
+ {field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
1972
+ sequenceable:true, readonlyOnExisting:true,
1973
+ title:'URL生成用のパラメータ(広告セット紐づけには不使用)'},
1974
+ /* budgetLevel='adGroup' → 予算は広告セット行が持つ (CBO は使わない) */
1975
+ {field:'dailyBudget', label:'日予算(円)', kind:'number', width:'84px', required:true,
1976
+ coerce:numOrNull, readonlyOnExisting:true, roEmptyDash:true, errCodes:[EC.BUDGET_MISSING],
1977
+ placeholder:()=>String(pf().defaults.newCampaignBudget)},
1978
+ {field:'bidAmount', label:'入札上限(円)', kind:'number', width:'84px', coerce:numOrNull,
1979
+ readonlyOnExisting:true, roEmptyDash:true,
1980
+ title:'COST_CAP / LOWEST_COST_WITH_BID_CAP のときは必須'},
1981
+ {field:'ageMin', label:'年齢下限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
1982
+ readonlyOnExisting:true, roEmptyDash:true},
1983
+ {field:'ageMax', label:'年齢上限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
1984
+ readonlyOnExisting:true, roEmptyDash:true},
1985
+ {field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
1986
+ {field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
1987
+ {field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
1988
+ {field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
1989
+ readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
1990
+ ],
1991
+ campaigns:[
1992
+ {field:'campaignId', label:'キャンペーンID', kind:'badge', alwaysReadonly:true},
1993
+ {field:'name', label:'キャンペーン名', kind:'text', width:'170px', required:true,
1994
+ readonlyOnExisting:true, roTitle:'既存キャンペーンのため編集不可'},
1995
+ {field:'objective', label:'目的', kind:'select', minWidth:'150px', readonlyOnExisting:true,
1996
+ options:['OUTCOME_SALES','OUTCOME_LEADS','OUTCOME_TRAFFIC','OUTCOME_AWARENESS',
1997
+ 'OUTCOME_ENGAGEMENT','OUTCOME_APP_PROMOTION']},
1998
+ {field:'specialAdCategory', label:'特別カテゴリ', kind:'select', minWidth:'150px', readonlyOnExisting:true,
1999
+ /* 公式7値 + GUIセンチネル NONE (送信時 [] に変換) */
2000
+ options:['NONE','HOUSING','EMPLOYMENT','CREDIT','ISSUES_ELECTIONS_POLITICS',
2001
+ 'ONLINE_GAMBLING_AND_GAMING','FINANCIAL_PRODUCTS_SERVICES']},
2002
+ {field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
2003
+ {field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
2004
+ ]
2005
+ };
2006
+ PLATFORMS.meta = {
2007
+ id:'meta', displayName:'Meta', brandTitle:'Meta入稿プランナー',
2008
+
2009
+ account:{
2010
+ idPattern:/^act_\d+$/, idHint:'Meta 広告アカウントID(act_ 付き)', dir:'アカウント/meta',
2011
+ apply(obj, project){
2012
+ if(obj.accountId!=null){ const s=String(obj.accountId); project.accountId = /^act_/.test(s)?s:('act_'+s); }
2013
+ if(obj.name!=null) project.accountName=String(obj.name);
2014
+ if(obj.principal!=null) project.principal=String(obj.principal);
2015
+ if(obj.pageId!=null) project.pageId=String(obj.pageId);
2016
+ if(obj.instagramUserId!=null) project.instagramUserId=String(obj.instagramUserId);
2017
+ if(obj.pixelId!=null) project.pixelId=String(obj.pixelId);
2018
+ if(obj.projectName) project.name=obj.projectName;
2019
+ if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
2020
+ },
2021
+ schemaLines(opts){
2022
+ const o=opts||{};
2023
+ return ['{',' "accountJsonVersion": 1,',' "platform": "meta",',' "accountId": "act_<ID>",',
2024
+ ' "name": "'+(o.nameHint||'<アカウント名>')+'",',
2025
+ ' "pageId": "<FacebookページID>",',
2026
+ ' "instagramUserId": "<InstagramユーザーID(任意)>",',
2027
+ ' "pixelId": "<ピクセルID>",']
2028
+ .concat(o.updatedAt===false?[]:[' "updatedAt": "<ISO8601>",'])
2029
+ .concat([
2030
+ ' "refs": {',
2031
+ ' "titles": {"1": "見出し(40字以内 / 推奨27字)"},',
2032
+ ' "descriptions": {"1": "メインテキスト(推奨125字)"},',
2033
+ ' "media": {"1": {"image": "<imageHash>", "video": "<videoId>", "thumb": "<imageHash>"}},',
2034
+ ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
2035
+ ' "adGroups": {"<adSetId>": {"name": "広告セット名", "campaignId": "<campaignId>", "paramCode": "<code>"}}',
2036
+ ' }','}']);
2037
+ }
2038
+ },
2039
+
2040
+ levels:{
2041
+ campaign:{label:'キャンペーン', childKey:'adGroups', planKey:'adSets'}, /* 内部は adGroups / 入稿計画は adSets */
2042
+ adGroup :{label:'広告セット', childKey:'ads', planKey:'ads'},
2043
+ ad :{label:'広告'}
2044
+ },
2045
+ budgetLevel:'adGroup',
2046
+ consoleUrl:'https://adsmanager.facebook.com/adsmanager/manage/{resource}?act={accountIdNum}',
2047
+ consoleResourceMap:{campaigns:'campaigns', adGroups:'adsets', ads:'ads'},
2048
+
2049
+ mediaFormats:[
2050
+ {id:'IMAGE', crSuffix:'i', mediaKey:'image', apiMediaKind:'IMAGE', label:'画像 imageHash'},
2051
+ {id:'VIDEO', crSuffix:'v', mediaKey:'video', apiMediaKind:'VIDEO', label:'動画 videoId'}
2052
+ ],
2053
+ fmtOf(ad){ const F=PLATFORMS.meta.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
2054
+
2055
+ refTables:META_REF_TABLES,
2056
+ sheets:META_COLS,
2057
+
2058
+ projectFields:[
2059
+ {key:'pageId', label:'FBページID', kind:'text', width:'16ch', fallback:''},
2060
+ {key:'instagramUserId', label:'IGユーザーID', kind:'text', width:'16ch', fallback:''},
2061
+ {key:'pixelId', label:'ピクセルID', kind:'text', width:'16ch', fallback:''},
2062
+ {key:'customEventType', label:'CVイベント', kind:'select',
2063
+ options:['PURCHASE','LEAD','COMPLETE_REGISTRATION','ADD_TO_CART','INITIATED_CHECKOUT','SUBMIT_APPLICATION','CONTACT']},
2064
+ {key:'optimizationGoal', label:'最適化', kind:'select',
2065
+ options:['OFFSITE_CONVERSIONS','VALUE','LINK_CLICKS','LANDING_PAGE_VIEWS','IMPRESSIONS','REACH','THRUPLAY','LEAD_GENERATION','APP_INSTALLS']},
2066
+ {key:'billingEvent', label:'課金', kind:'select', options:['IMPRESSIONS','LINK_CLICKS','THRUPLAY']},
2067
+ /* 公式4値のみ (MINIMUM_ROAS は不正値なので載せない) */
2068
+ {key:'bidStrategy', label:'入札戦略', kind:'select',
2069
+ options:['LOWEST_COST_WITHOUT_CAP','LOWEST_COST_WITH_BID_CAP','COST_CAP','LOWEST_COST_WITH_MIN_ROAS']},
2070
+ {key:'buyingType', label:'購入タイプ', kind:'select', options:['AUCTION']},
2071
+ {key:'cta', label:'CTA', kind:'select',
2072
+ options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
2073
+ '予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
2074
+ {key:'startDate', label:'開始日', kind:'text', width:'10ch', fallback:''},
2075
+ {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
2076
+ {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''}
2077
+ ],
2078
+ fixedType:[{label:'広告タイプ',value:'単一画像 / 単一動画'}],
2079
+
2080
+ targeting:{
2081
+ tg_gender:{label:'性別', multi:true, allLabel:'すべて', opts:['すべて','男性','女性']},
2082
+ tg_device:{label:'デバイス', multi:true, allLabel:'すべてのデバイス',
2083
+ opts:['すべてのデバイス','モバイル','デスクトップ']},
2084
+ tg_placement:{label:'配置', multi:true, allLabel:'自動配置',
2085
+ opts:['自動配置','Facebook','Instagram','Audience Network','Messenger']}
2086
+ },
2087
+
2088
+ enums:{
2089
+ gender:{'男性':1,'女性':2}, /* 未指定 = 全性別 (genders を送らない) */
2090
+ device:{'モバイル':'mobile','デスクトップ':'desktop'},
2091
+ placement:{'Facebook':'facebook','Instagram':'instagram',
2092
+ 'Audience Network':'audience_network','Messenger':'messenger'},
2093
+ cta:{'詳しくはこちら':'LEARN_MORE','購入する':'SHOP_NOW','登録する':'SIGN_UP',
2094
+ 'ダウンロード':'DOWNLOAD','お問い合わせ':'CONTACT_US','今すぐ申し込む':'APPLY_NOW',
2095
+ '予約する':'BOOK_NOW','見積もりを取得':'GET_QUOTE','購読する':'SUBSCRIBE',
2096
+ '動画を見る':'WATCH_MORE','ボタンなし':'NO_BUTTON'},
2097
+ ctaFallback:'LEARN_MORE',
2098
+ statusOn:'ACTIVE', statusOff:'PAUSED',
2099
+ /* 特別広告カテゴリでターゲティングが強制されるルール (公式) */
2100
+ specialAdCategoryConstraints:{
2101
+ forcedAgeMin:18, forcedAgeMax:65, dropGenders:true, dropAudiences:true,
2102
+ appliesTo:['HOUSING','EMPLOYMENT','CREDIT','FINANCIAL_PRODUCTS_SERVICES']
2103
+ },
2104
+ bidAmountRequiredFor:['LOWEST_COST_WITH_BID_CAP','COST_CAP'],
2105
+ pixelRequiredFor:['OFFSITE_CONVERSIONS','VALUE']
2106
+ },
2107
+
2108
+ mappers:{
2109
+ devices(s, P){ return mapByEnum(s, P.enums.device, P.targeting.tg_device.allLabel); },
2110
+ genders(s, P){ return mapByEnum(s, P.enums.gender, P.targeting.tg_gender.allLabel); },
2111
+ placements(s, P){
2112
+ const l=tgList(s);
2113
+ if(!l.length || l.indexOf(P.targeting.tg_placement.allLabel)>=0)
2114
+ return {auto:true, facebook:true, instagram:true, audienceNetwork:false, messenger:false};
2115
+ return {auto:false,
2116
+ facebook:l.indexOf('Facebook')>=0, instagram:l.indexOf('Instagram')>=0,
2117
+ audienceNetwork:l.indexOf('Audience Network')>=0, messenger:l.indexOf('Messenger')>=0};
2118
+ },
2119
+ status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
2120
+ cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
2121
+ /* B13: JST 00:00 を明示した ISO8601 (日付のみだと UTC 解釈で前日にずれる) */
2122
+ startTime(dateStr){ return (dateStr||today())+'T00:00:00+0900'; }
2123
+ },
2124
+
2125
+ defaults:{
2126
+ newCampaignBudget:1000, /* Meta は広告セット予算なので「新規広告セットの日予算」既定 */
2127
+ project:{
2128
+ cta:'詳しくはこちら', pageId:'', instagramUserId:'', pixelId:'', customEventType:'PURCHASE',
2129
+ optimizationGoal:'OFFSITE_CONVERSIONS', billingEvent:'IMPRESSIONS',
2130
+ bidStrategy:'LOWEST_COST_WITHOUT_CAP', buyingType:'AUCTION',
2131
+ deliveryStatus:'オフ', startDate:''
2132
+ },
2133
+ ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:''},
2134
+ adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
2135
+ ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
2136
+ tg_placement:'自動配置', deliveryStatus:''},
2137
+ campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
2138
+ objective:'OUTCOME_SALES', specialAdCategory:'NONE', variable:'', urlNumber:''}
2139
+ },
2140
+ factories:{
2141
+ newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.meta.defaults.ad, seed||{}); },
2142
+ newAdGroup(c, i){ const P=PLATFORMS.meta;
2143
+ return Object.assign({uid:uid('g')}, P.defaults.adGroup,
2144
+ {name:'AS'+((i!=null?i:(c?c.adGroups.length:0))+1), ads:[P.factories.newAd()]}); },
2145
+ newCampaign(seed){ const P=PLATFORMS.meta;
2146
+ return Object.assign({uid:uid('c')}, P.defaults.campaign,
2147
+ {adGroups:[P.factories.newAdGroup(null,0)]}, seed||{}); },
2148
+ existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
2149
+ isExisting:true, objective:'', specialAdCategory:'NONE', variable:'', urlNumber:'', adGroups:[]}; },
2150
+ existingAdGroup(agId, meta){ const P=PLATFORMS.meta, m=meta||{};
2151
+ return Object.assign({uid:uid('g')}, P.defaults.adGroup,
2152
+ {name:m.name||('AS '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
2153
+ isExisting:true, ads:[P.factories.newAd()]}); }
2154
+ },
2155
+
2156
+ /* 命名規則は YDA 踏襲 (形式記号は mediaFormats.crSuffix = i/v) */
2157
+ naming:{
2158
+ crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
2159
+ adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
2160
+ finalUrl(ad, group, adName, project){
2161
+ return (project.baseArticleUrl||'') + (ad.articleSlug||'')
2162
+ + '?ad=' + (group.paramCode||'')
2163
+ + '&creative=' + (group.paramCode||'') + '-' + adName;
2164
+ },
2165
+ campaignName(urlNumber, firstAd, P){
2166
+ const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
2167
+ return String(urlNumber) + '_' + cr + '_' + today();
2168
+ }
2169
+ },
2170
+ creativeFields:['imageNo','titleNo','descNo'],
2171
+ /* Meta は「値はあるが送らない列」を作らない */
2172
+ notSubmittedFields:[],
2173
+ labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode', tgDroppedWarn:''},
2174
+
2175
+ tree:{
2176
+ campaignBadges(c, P, p){
2177
+ return '<span class="chip">'+esc(c.objective||'OUTCOME_SALES')+'</span>'
2178
+ + ((c.specialAdCategory && c.specialAdCategory!=='NONE') ? '<span class="chip">'+esc(c.specialAdCategory)+'</span>' : '');
2179
+ },
2180
+ groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'
2181
+ + (g.dailyBudget!=null&&g.dailyBudget!=='' ? '<span class="chip">¥'+esc(g.dailyBudget)+'/日</span>' : ''); },
2182
+ adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+((a.format==='VIDEO')?'v':'i')+'</span>'; },
2183
+ adSub(a, r){ return esc(a.titleNo)+'L'+esc(a.descNo); }
2184
+ },
2185
+
2186
+ /* ---- 解決 (派生値 / 純関数) ---- */
2187
+ resolve(ad, group, ctx){
2188
+ const P=PLATFORMS.meta, p=ctx.project, refs=ctx.refs;
2189
+ const fmt=P.fmtOf(ad);
2190
+ const crName=P.naming.crName(ad, P);
2191
+ const adName=P.naming.adName(ad, group, crName);
2192
+ const me = refs.media[String(ad.imageNo)];
2193
+ const mediaId = me ? me[fmt.mediaKey] : undefined;
2194
+ const thumbHash = me ? me.thumb : undefined;
2195
+ const titleText = refs.titles[ad.titleNo];
2196
+ const descText = (refs.descriptions||{})[ad.descNo];
2197
+ const finalUrl = P.naming.finalUrl(ad, group, adName, p);
2198
+ const errs=[], warns=[], errCodes=[], warnCodes=[];
2199
+ const pushErr=(label,code)=>{ errs.push(label); errCodes.push(code); };
2200
+ const pushWarn=(label,code)=>{ warns.push(label); warnCodes.push(code); };
2201
+ const noStr=String(ad.imageNo||'').trim();
2202
+ /* 番号ありだが当該形式のIDが未登録 (空文字含む) = アップロード対象 */
2203
+ const pendingImage = (noStr!=='' && (mediaId===undefined || mediaId===''));
2204
+ const T=P.refTables.titles.lengthLimit, D=P.refTables.descriptions.lengthLimit;
2205
+ if(noStr==='') pushErr('クリエイティブ未解決', EC.IMAGE_UNRESOLVED);
2206
+ if(String(ad.titleNo||'')==='' || titleText===undefined) pushErr('見出し未解決', EC.TITLE_UNRESOLVED);
2207
+ if(String(ad.descNo||'')==='' || descText===undefined) pushErr('本文未解決', EC.DESC_UNRESOLVED);
2208
+ if(titleText!==undefined && glen(titleText)>T.err) pushErr('見出し'+T.err+'字超', EC.TITLE_TOO_LONG);
2209
+ else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
2210
+ if(descText!==undefined && glen(descText)>D.err) pushErr('本文'+D.err+'字超', EC.DESC_TOO_LONG);
2211
+ else if(descText!==undefined && glen(descText)>D.warn) pushWarn('本文'+D.warn+'字超', EC.DESC_LONG_WARN);
2212
+ if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
2213
+ if(pendingImage) pushWarn(ad.format==='VIDEO'?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING);
2214
+ const aid=String(group.adGroupId||'').trim();
2215
+ return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
2216
+ mediaId:mediaId||'', thumbHash:thumbHash||'', pendingImage,
2217
+ titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText,
2218
+ finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
2219
+ },
2220
+
2221
+ /* ---- 広告セット行の追加検証 (予算・入札) ---- */
2222
+ validateRow(row, level, ctx){
2223
+ const P=PLATFORMS.meta, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
2224
+ if(level==='adGroup' && !isExistingGroup(row)){
2225
+ if(!(Number(row.dailyBudget)>0)) errCodes.push(EC.BUDGET_MISSING);
2226
+ if(P.enums.bidAmountRequiredFor.indexOf(p.bidStrategy)>=0 && !(Number(row.bidAmount)>0))
2227
+ errCodes.push('BID_AMOUNT_REQUIRED');
2228
+ }
2229
+ return {errCodes, warnCodes};
2230
+ },
2231
+ /* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ---- */
2232
+ validateProject(p){
2233
+ const P=PLATFORMS.meta, errs=[];
2234
+ if(!String((p&&p.pageId)||'').trim()) errs.push({code:'PAGE_ID_MISSING', msg:'FBページIDを共通設定に入力してください'});
2235
+ if(P.enums.pixelRequiredFor.indexOf(p&&p.optimizationGoal)>=0 && !String((p&&p.pixelId)||'').trim())
2236
+ errs.push({code:EC.PIXEL_MISSING, msg:'最適化が'+p.optimizationGoal+'のためピクセルIDが必須です'});
2237
+ return errs;
2238
+ },
2239
+
2240
+ /* ---- 入稿計画JSON (SubmitPlanFile) の3階層ボディ生成 ---- */
2241
+ buildPlan:{
2242
+ campaign(c, ctx){
2243
+ const P=PLATFORMS.meta, p=ctx.project;
2244
+ const cat=String(c.specialAdCategory||'NONE');
2245
+ const cp={
2246
+ name:c.name,
2247
+ objective:c.objective||'OUTCOME_SALES',
2248
+ status:P.mappers.status(p.deliveryStatus, P),
2249
+ buyingType:p.buyingType||'AUCTION',
2250
+ specialAdCategories: cat==='NONE'?[]:[cat] /* NONE は GUI センチネル。API へは [] */
2251
+ };
2252
+ const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid;
2253
+ /* CBO は使わない (予算は広告セット階層 = budgetLevel)。×100 バグの経路に入れない */
2254
+ return cp;
2255
+ },
2256
+ adGroup(g, c, ctx){
2257
+ const P=PLATFORMS.meta, p=ctx.project;
2258
+ const cat=String((c&&c.specialAdCategory)||'NONE');
2259
+ const K=P.enums.specialAdCategoryConstraints;
2260
+ const constrained = K.appliesTo.indexOf(cat)>=0;
2261
+ const ag={
2262
+ name:g.name,
2263
+ status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
2264
+ optimizationGoal:p.optimizationGoal||'OFFSITE_CONVERSIONS',
2265
+ billingEvent:p.billingEvent||'IMPRESSIONS',
2266
+ bidStrategy:p.bidStrategy||'LOWEST_COST_WITHOUT_CAP',
2267
+ dailyBudget:Number(g.dailyBudget)||0, /* JPY はそのまま (currency_offset=1) */
2268
+ startTime:P.mappers.startTime(p.startDate), /* JST 明示 ISO8601 */
2269
+ /* 特別カテゴリ時は 18-65 固定・性別指定不可 (公式制約) */
2270
+ ageMin: constrained?K.forcedAgeMin:(Number(g.ageMin)||18),
2271
+ ageMax: constrained?K.forcedAgeMax:(Number(g.ageMax)||65),
2272
+ genders: constrained?[]:P.mappers.genders(g.tg_gender, P),
2273
+ devicePlatforms:P.mappers.devices(g.tg_device, P),
2274
+ placements:P.mappers.placements(g.tg_placement, P),
2275
+ customAudiences:[], excludedAudiences:[], /* ID解決が必要なため常に空 */
2276
+ geoCountries:['JP'],
2277
+ pixelId:String(p.pixelId||''), /* promoted_object 用 */
2278
+ customEventType:p.customEventType||'PURCHASE'
2279
+ };
2280
+ if(P.enums.bidAmountRequiredFor.indexOf(ag.bidStrategy)>=0 || Number(g.bidAmount)>0)
2281
+ ag.bidAmount=Number(g.bidAmount)||0;
2282
+ const aid=resolveAdGroupIdOf(g); if(aid) ag.adSetId=aid;
2283
+ return ag;
2284
+ },
2285
+ ad(a, r, g, ctx){
2286
+ const P=PLATFORMS.meta, p=ctx.project;
2287
+ const out={
2288
+ adName:r.adName,
2289
+ status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
2290
+ facebookPageId:String(p.pageId||''),
2291
+ mediaType:r.mediaKind, /* 'IMAGE' | 'VIDEO' */
2292
+ bodyText:String(r.descText),
2293
+ title:String(r.titleText),
2294
+ cta:P.mappers.cta(p.cta, P),
2295
+ finalUrl:r.finalUrl
2296
+ };
2297
+ /* B2: instagram_actor_id は廃止。キー名は instagramUserId で統一し、未設定ならキー自体を出さない */
2298
+ if(String(p.instagramUserId||'').trim()) out.instagramUserId=String(p.instagramUserId).trim();
2299
+ if(r.mediaKind==='VIDEO'){
2300
+ if(r.pendingImage){ out.videoPath=''; out.pendingMediaNo=r.imageNo; } /* CLI が本実行時に upload */
2301
+ else out.videoId=String(r.mediaId);
2302
+ if(r.thumbHash) out.imageHash=String(r.thumbHash); /* サムネは adimages の hash */
2303
+ } else {
2304
+ if(r.pendingImage){ out.imagePaths=[]; out.pendingMediaNo=r.imageNo; }
2305
+ else out.imageHash=String(r.mediaId);
2306
+ }
2307
+ return out;
2308
+ }
2309
+ },
2310
+
2311
+ output:{
2312
+ planVersion:1,
2313
+ planDir:'.claude/skills/ad-ops/入稿計画',
2314
+ samplePlanFile:'サンプルプラン_meta.json',
2315
+ fileNamePrefix:p=>(p.name||'plan')+'_Meta入稿計画_',
2316
+ submitCommand:(rel,opts)=>'webgram submit-plan --plan-file "'+rel+'"'+((opts&&opts.dryRun===false)?' --no-dry-run --yes':'')
2317
+ },
2318
+
2319
+ prompts:{
2320
+ importAccount(ctx){
2321
+ const P=PLATFORMS.meta, c=ctx||{}, mode=c.mode||'help';
2322
+ const name=c.name||'', accId=c.accountId||'', idArg=accId||'act_<ID>';
2323
+ const headName = mode==='help' ? '<アカウント名(日本語OK)>' : (name||'<アカウント名>');
2324
+ const fileName = mode==='help' ? '<アカウント名>' : (name||'<アカウント名>');
2325
+ const dir=P.account.dir;
2326
+ const shape=[' - media は 管理番号 → {image(imageHash), video(videoId), thumb(サムネのimageHash)}',
2327
+ ' - campaigns/adGroups は campaignId/adSetId をキーに(adGroups.paramCode はURLパラメータ)'];
2328
+ const lines=[];
2329
+ if(mode==='ai'){
2330
+ lines.push('Meta入稿プランナー用に、新規アカウントの参照データJSONを生成してください。','',
2331
+ '1. まず対象アカウントを対話で決めてください:',
2332
+ ' - webgram list --platform meta --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認して アカウント名・accountId(act_付き)・FBページID・IGユーザーID・ピクセルID を確定する',
2333
+ '2. 確定したアカウントについて、登録画像/動画・既存キャンペーン/広告セットを取得:',
2334
+ ' webgram list --platform meta --resource image-library --account-id '+idArg,
2335
+ ' webgram list --platform meta --resource video-library --account-id '+idArg,
2336
+ ' webgram list --platform meta --resource campaigns --account-id '+idArg,
2337
+ '3. 取得結果を下記スキーマに整形し、'+dir+'/<アカウント名>.json として書き出す:');
2338
+ shape.forEach(l=>lines.push(l));
2339
+ } else {
2340
+ lines.push('Meta入稿プランナー用に、アカウント「'+headName+'」'+(mode==='import'&&accId?('(accountId: '+accId+')'):'')+'の参照データJSONを生成してください。','',
2341
+ '手順:',
2342
+ '1. webgram CLI で対象アカウントの登録画像/動画・既存キャンペーン/広告セットを取得:',
2343
+ ' webgram list --platform meta --resource image-library --account-id '+idArg,
2344
+ ' webgram list --platform meta --resource video-library --account-id '+idArg,
2345
+ ' webgram list --platform meta --resource campaigns --account-id '+idArg,
2346
+ '2. 取得結果を下記スキーマに整形して '+dir+'/'+fileName+'.json として書き出す:');
2347
+ shape.forEach(l=>lines.push(l));
2348
+ }
2349
+ lines.push('', 'スキーマ:');
2350
+ P.account.schemaLines({updatedAt:mode!=='help',
2351
+ nameHint:mode==='help'?'<アカウント名(日本語OK)>':'<アカウント名>'}).forEach(l=>lines.push(l));
2352
+ lines.push('');
2353
+ lines.push(mode==='ai' ? '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加」→「📄 ファイルから追加」で その JSON を読み込めます。'
2354
+ : mode==='import' ? '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
2355
+ : '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。');
2356
+ return lines.join('\n');
2357
+ },
2358
+ strategy(){
2359
+ return ['Meta広告(Facebook / Instagram)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
2360
+ '# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
2361
+ '- 目的(objective): <OUTCOME_SALES / OUTCOME_LEADS など>','- 予算・期間: <広告セットの日予算 / 開始日>',
2362
+ '- 訴求したいポイント: <強み・オファー・ターゲット層>','',
2363
+ '# お願い',
2364
+ '1. 上記をもとにターゲティング方針(年齢・性別・配置)・キャンペーン/広告セット構成・訴求軸を提案してください。',
2365
+ '2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン_meta.json」に書き出して(上書きして)ください。',
2366
+ ' - 保存先: .claude/skills/ad-ops/サンプルプラン_meta.json',
2367
+ ' - フォーマット: {"schemaVersion":'+SCHEMA_VERSION+', "platform":"meta", "accountId":"act_<対象アカウントID>", "campaigns":[ ... ]}',
2368
+ ' - campaigns には キャンペーン → 広告セット(paramCode / dailyBudget / 年齢 / 性別 / 配置) → 広告(形式 IMAGE|VIDEO / クリエイティブ番号 / 見出し番号 / 本文番号) の構成を入れてください。',
2369
+ '3. 書き出したら、プランナーを開き直す(またはリロードして媒体を Meta に切り替える)と内容が自動で表示されます。'].join('\n');
2370
+ },
2371
+ submitHelp(ctx){
2372
+ const P=PLATFORMS.meta, c=ctx||{}, rel=c.savedRel, dir=P.output.planDir;
2373
+ const lines=['入稿を試みてください。手順:',
2374
+ '1) 入稿計画JSON('+c.fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+rel+')。',
2375
+ ' ・入稿計画フォルダ('+dir+'/)に無ければ、ダウンロードフォルダ(~/Downloads/'+c.fileName+')を探して上記パスへ移動する。',
2376
+ (c.wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。')];
2377
+ if(c.pendingImages){
2378
+ lines.push('2) 【未登録クリエイティブの新規登録】pendingMediaNo が付いた広告があります(管理番号: '+(c.pendingImageNos||[]).join(', ')+')。');
2379
+ lines.push(' 画像広告は "imagePaths":[] に、動画広告は "videoPath" に対応するローカルファイルのパスを設定してください(本実行時に imageHash / videoId へ自動解決されます)。');
2380
+ lines.push('3) 置いたパスで dry-run 入稿を実行:');
2381
+ } else {
2382
+ lines.push('2) 置いたパスで dry-run 入稿を実行:');
2383
+ }
2384
+ lines.push(' '+P.output.submitCommand(rel,{dryRun:true}));
2385
+ lines.push((c.pendingImages?'4':'3')+') dry-run が全件OKなら本実行:');
2386
+ lines.push(' '+P.output.submitCommand(rel,{dryRun:false}));
2387
+ return lines.join('\n');
2388
+ },
2389
+ afterSave(ctx){
2390
+ const P=PLATFORMS.meta, c=ctx||{};
2391
+ return 'プラン「'+c.name+'」v'+c.versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_Meta入稿計画_*.json を指定して dry-run してください:\n\n'
2392
+ + P.output.submitCommand('<入稿計画JSONのパス>',{dryRun:true});
2393
+ }
2394
+ },
2395
+ docTabs:[{id:'overview',label:'概要'},{id:'image',label:'画像広告'},
2396
+ {id:'video',label:'動画広告'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
2397
+ };
2398
+ /* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
2399
+ function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
2400
+ /* 列記述子配列。引数省略時は現在のシートタブ */
2401
+ function colDefs(tab){ const S=pf().sheets; return S[normTab(tab===undefined?sheetTab:tab)] || S.ads; }
2402
+ /* 列番号リテラル撤去用: field → 列index */
2403
+ function colIndex(field, tab){ return colDefs(tab).findIndex(c=>c.field===field); }
2404
+ /* ドロワーで拾った番号を流し込める列index群 (旧 compat=[1] / [2,3] の置換) */
2405
+ function compatColsFor(drawerType, tab){
2406
+ const out=[]; colDefs(tab).forEach((c,i)=>{ if(c.kind==='refcombo' && c.drawerType===drawerType) out.push(i); }); return out;
2407
+ }
2408
+ /* 貼付/コピー/セル選択が扱う列の field 配列。derived (広告名/CP・AG ID) は入力セルを持たないので除外する。
2409
+ derived は常に配列末尾なので、tabCols() の index と colDefs() の index (= data-c) は一致する。 */
2410
+ function tabCols(){ return colDefs().filter(c=>c.kind!=='derived').map(c=>c.field); }
2411
+ /* ---- S5: セル値アクセスの記述子駆動化 ----
2412
+ 行の実体 (広告 / 広告グループ / キャンペーン)。scope:'parentGroup' の列だけ所属グループを見る。 */
2413
+ function rowOf(item, col){
2414
+ if(col && col.scope==='parentGroup') return item.g;
2415
+ return item.kind==='c'?item.c:(item.kind==='g'?item.g:item.a);
2416
+ }
2417
+ /* その列の値の持ち主が「既存呼び出し行」か。既存行では readonlyOnExisting / hideOnExisting 列を触らせない。
2418
+ 広告自身のフィールドは既存グループ配下でも編集可 (広告の追加・差し替えは許可)。 */
2419
+ function rowIsExisting(item, col){
2420
+ if(col && col.scope==='parentGroup') return isExistingGroup(item.g);
2421
+ if(item.kind==='g') return isExistingGroup(item.g);
2422
+ if(item.kind==='c') return isExistingCampaign(item.c);
2423
+ return false;
2424
+ }
2425
+ function getVar(item,c,tab){
2426
+ const col=colDefs(tab)[c]; if(!col) return '';
2427
+ const row=rowOf(item,col); const v=row?row[col.field]:null;
2428
+ return v==null?'':String(v);
2429
+ }
2430
+ function setVar(item,c,val,tab){
2431
+ const col=colDefs(tab)[c]; if(!col) return;
2432
+ if(col.kind==='derived') return;
2433
+ if(col.alwaysReadonly) return; /* adGroupId / campaignId */
2434
+ if((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col)) return; /* F-2: 既存呼び出し行は基本パラメータ編集不可 */
2435
+ const row=rowOf(item,col); if(!row) return;
2436
+ row[col.field] = col.coerce?col.coerce(val):val;
2437
+ if(col.onSet) col.onSet(item,row);
2438
+ }
1175
2439
  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; }
1176
2440
  let selA=null, selB=null, dragging=false;
1177
2441
  function clearSel(){ selA=selB=null; applySelection(); }
@@ -1193,7 +2457,7 @@ function pasteGrid(txt){
1193
2457
  cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); });
1194
2458
  });
1195
2459
  }
1196
- else { const anchor=vis[rg.r0]; rows.forEach((cols,i)=>{ let it=vis[rg.r0+i]; if(!it){ if(sheetTab==='groups'){ const ng={uid:uid('g'),name:'AG'+(anchor.c.adGroups.length+1),paramCode:'',ads:[newAd()]}; anchor.c.adGroups.push(ng); it={kind:'g',g:ng,c:anchor.c}; } else { const nc=makeNewCampaign(); st.campaigns.push(nc); it={kind:'c',c:nc}; } } cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); }); }); }
2460
+ else { const anchor=vis[rg.r0]; rows.forEach((cols,i)=>{ let it=vis[rg.r0+i]; if(!it){ if(sheetTab==='groups'){ const ng=newAdGroup(anchor.c); anchor.c.adGroups.push(ng); it={kind:'g',g:ng,c:anchor.c}; } else { const nc=makeNewCampaign(); st.campaigns.push(nc); it={kind:'c',c:nc}; } } cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); }); }); }
1197
2461
  });
1198
2462
  clearSel(); toast(rows.length+'行を貼り付け');
1199
2463
  }
@@ -1207,15 +2471,16 @@ function dupSelected(){
1207
2471
  }
1208
2472
 
1209
2473
  /* ---- A-2: paramCode 連番採番 (複数選択→連番一括入力) ---- */
1210
- function paramCodeColIndex(){ return sheetTab==='groups'?VARF_GROUPS.indexOf('paramCode') : sheetTab==='ads'?VARF.indexOf('paramCode') : -1; }
1211
- /* 選択範囲が交差する「連番可能な列」を返す。{field, idx, mode:'group'|'ad'} */
2474
+ /* 選択範囲が交差する「連番可能な列」を返す。{field, idx, mode:'group'|'ad'}
2475
+ S3: 旧 paramCodeColIndex() は本関数へ吸収。候補列は列記述子の sequenceable から算出する。
2476
+ mode は「値の書き込み先が広告グループか広告か」= 広告グループタブ or scope:'parentGroup' の列なら 'group'。 */
1212
2477
  function seqColInfo(){
1213
2478
  const rg=selRange(); if(!rg) return null;
1214
- if(sheetTab==='ads'){
1215
- const map={}; ['imageNo','titleNo','descNo'].forEach(f=>{ const i=VARF.indexOf(f); if(i>=0) map[i]=f; }); const pcI=VARF.indexOf('paramCode'); if(pcI>=0) map[pcI]='paramCode';
1216
- for(const ci of Object.keys(map).map(Number).sort((a,b)=>a-b)){ if(rg.c0<=ci && rg.c1>=ci) return {field:map[ci], idx:ci, mode: map[ci]==='paramCode'?'group':'ad'}; }
1217
- } else if(sheetTab==='groups'){
1218
- const pc=VARF_GROUPS.indexOf('paramCode'); if(pc>=0 && rg.c0<=pc && rg.c1>=pc) return {field:'paramCode', idx:pc, mode:'group'};
2479
+ const tab=normTab(sheetTab), cols=colDefs(tab);
2480
+ for(let ci=0; ci<cols.length; ci++){
2481
+ const col=cols[ci]; if(!col.sequenceable) continue;
2482
+ if(rg.c0<=ci && rg.c1>=ci) return {field:col.field, idx:ci, label:col.label,
2483
+ mode:(tab==='adGroups'||col.scope==='parentGroup')?'group':'ad'};
1219
2484
  }
1220
2485
  return null;
1221
2486
  }
@@ -1238,16 +2503,16 @@ function applySequentialParamCodes(){
1238
2503
  const seen=new Set(); const groups=[];
1239
2504
  for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.g) continue; if(isExistingGroup(it.g)) continue; if(seen.has(it.g.uid)) continue; seen.add(it.g.uid); groups.push({g:it.g, c:it.c}); }
1240
2505
  if(groups.length<2){ toast('連番対象の広告グループが2件以上必要です'); return; }
1241
- const m=String(groups[0].g.paramCode||'').trim().match(/^(.*?)(\d+)\s*$/);
2506
+ const m=String(groups[0].g[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
1242
2507
  if(!m){ toast('基点となる paramCode を先頭セルに入力してください(例 jv_Cac_148)'); return; }
1243
2508
  const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
1244
- mutate(()=>{ groups.forEach((pair,i)=>{ if(i===0){ autoResolveGroupIds(pair.g,pair.c); return; } pair.g.paramCode=prefix+String(startNum+i).padStart(width,'0'); autoResolveGroupIds(pair.g,pair.c); }); });
2509
+ mutate(()=>{ groups.forEach((pair,i)=>{ if(i===0){ autoResolveGroupIds(pair.g,pair.c); return; } pair.g[sc.field]=prefix+String(startNum+i).padStart(width,'0'); autoResolveGroupIds(pair.g,pair.c); }); });
1245
2510
  toast((groups.length-1)+'グループに連番を採番しました('+prefix+(startNum+1)+'〜)');
1246
2511
  } else {
1247
2512
  const rows=[];
1248
2513
  for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.a) continue; if(isExistingGroup(it.g)) continue; rows.push(it); }
1249
2514
  if(rows.length<2){ toast('連番対象の広告が2件以上必要です'); return; }
1250
- const lbl={imageNo:'画像番号',titleNo:'タイトル番号',descNo:'説明文番号'}[sc.field];
2515
+ const lbl=sc.label; // S3: 列記述子のラベル
1251
2516
  const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
1252
2517
  if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
1253
2518
  const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
@@ -1258,7 +2523,8 @@ function applySequentialParamCodes(){
1258
2523
 
1259
2524
  /* ---- ▼ 右スライドサイドバー(一覧から番号を選ぶ) ---- */
1260
2525
  let drawerTarget=null;
1261
- function drawerTypeForField(f){ return f==='imageNo'?'media':'titles'; }
2526
+ /* S3: ▼ドロワーの表示モードは列記述子の drawerType から引く (旧 imageNo?'media':'titles' の三項を撤去) */
2527
+ function drawerTypeForField(f){ const col=colDefs('ads').find(c=>c.field===f); return (col&&col.drawerType)||'titles'; }
1262
2528
  function openDrawerFor(u,f){
1263
2529
  if(!u||!f) return;
1264
2530
  const isOpen = document.getElementById('refDrawer').classList.contains('open');
@@ -1301,7 +2567,7 @@ function openCampaignPickDrawer(ctx){
1301
2567
  /* F-4: プラン内広告グループ選択ドロワー (広告の所属変更) */
1302
2568
  function openAdGroupPickDrawer(adUid){
1303
2569
  const anyGroup=st.campaigns.some(c=>c.adGroups.length);
1304
- if(!anyGroup){ toast('先に広告グループを追加してください'); return; }
2570
+ if(!anyGroup){ toast('先に'+agLabel()+'を追加してください'); return; }
1305
2571
  drawerTarget={type:'agpick', adUid};
1306
2572
  document.getElementById('drawerSearch').value='';
1307
2573
  renderDrawer();
@@ -1327,8 +2593,8 @@ function addExistingAdGroup(agId){
1327
2593
  let target=null;
1328
2594
  if(meta.campaignId){ target=st.campaigns.find(c=>String(resolveCampaignIdOf(c))===String(meta.campaignId)); }
1329
2595
  if(!target) target=st.campaigns[st.campaigns.length-1];
1330
- // F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める
1331
- mutate(()=>target.adGroups.push({uid:uid('g'), name:meta.name||('AG '+agId), paramCode:meta.paramCode||'', adGroupId:agId, isExisting:true, ads:[newAd()]}));
2596
+ // F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める (行生成は pf().factories)
2597
+ mutate(()=>target.adGroups.push(pf().factories.existingAdGroup(agId, meta)));
1332
2598
  toast('既存広告グループ '+agId+' を呼び出しました('+esc(target.name)+')');
1333
2599
  closeDrawer();
1334
2600
  }
@@ -1343,8 +2609,8 @@ function moveGroupToCampaign(gUid, targetCUid){
1343
2609
  /* F-5: 選択キャンペーンに新規空グループを追加 */
1344
2610
  function addNewAdGroupToCampaign(cUid){
1345
2611
  const r=findCp(cUid); if(!r) return;
1346
- mutate(()=>r.c.adGroups.push({uid:uid('g'),name:'AG'+(r.c.adGroups.length+1),paramCode:'',ads:[newAd()]}));
1347
- toast('新規広告グループを「'+esc(r.c.name)+'」に追加');
2612
+ mutate(()=>r.c.adGroups.push(newAdGroup(r.c)));
2613
+ toast('新規'+agLabel()+'を「'+esc(r.c.name)+'」に追加');
1348
2614
  closeDrawer();
1349
2615
  }
1350
2616
  /* F-4: 広告を別グループへ移動 */
@@ -1355,15 +2621,7 @@ function moveAdToGroup(adUid, targetGUid){
1355
2621
  toast('広告を「'+esc(tg.g.name)+'」へ移動');
1356
2622
  closeDrawer();
1357
2623
  }
1358
- /* YDA広告グループ ターゲティング設定値(Web調査ベース・複数選択可) */
1359
- const TG_OPTIONS = {
1360
- tg_device:{label:'デバイス', opts:['全てのデバイス','PC','スマートフォン','タブレット']},
1361
- tg_gender:{label:'性別', opts:['男性','女性','不明']},
1362
- tg_age:{label:'年齢', opts:['18-19歳','20-24歳','25-29歳','30-34歳','35-39歳','40-44歳','45-49歳','50-54歳','55-59歳','60-64歳','65-69歳','70歳以上','不明']},
1363
- tg_region:{label:'地域', opts:['全国','北海道','青森県','岩手県','宮城県','秋田県','山形県','福島県','茨城県','栃木県','群馬県','埼玉県','千葉県','東京都','神奈川県','新潟県','富山県','石川県','福井県','山梨県','長野県','岐阜県','静岡県','愛知県','三重県','滋賀県','京都府','大阪府','兵庫県','奈良県','和歌山県','鳥取県','島根県','岡山県','広島県','山口県','徳島県','香川県','愛媛県','高知県','福岡県','佐賀県','長崎県','熊本県','大分県','宮崎県','鹿児島県','沖縄県']},
1364
- tg_dayHour:{label:'曜日・時間帯', opts:['全ての曜日・時間帯','平日','土日祝','月','火','水','木','金','土','日','午前(6-12時)','午後(12-18時)','夜間(18-24時)','深夜(0-6時)']},
1365
- tg_audience:{label:'オーディエンスリスト', opts:['サイトリターゲティング','類似ユーザー','オーディエンスカテゴリー','サーチキーワード','購買データ','属性・興味関心']}
1366
- };
2624
+ /* S7: ターゲティング列の選択肢は pf().targeting (媒体スキーマ) が持つ */
1367
2625
  function openTargetingDrawer(gUid, field){
1368
2626
  const isOpen=document.getElementById('refDrawer').classList.contains('open');
1369
2627
  const same=drawerTarget && drawerTarget.type==='tg' && drawerTarget.gUid===gUid && drawerTarget.field===field;
@@ -1398,7 +2656,7 @@ function renderDrawer(){
1398
2656
  if(drawerTarget.type==='agref'){
1399
2657
  // F-5: アカウントアセット全体の既存広告グループ (refs.adGroups)。追加済みはグレーアウト
1400
2658
  const ags=st.refs.adGroups||{}; const inPlan=planAdGroupIdSet();
1401
- titleEl.textContent='既存広告グループを呼び出し';
2659
+ titleEl.textContent='既存'+agLabel()+'を呼び出し';
1402
2660
  const ids=Object.keys(ags).sort();
1403
2661
  const shown=ids.filter(id=>!q||String(id).includes(q)||String((ags[id]&&ags[id].name)||'').toLowerCase().includes(q)||String((ags[id]&&ags[id].paramCode)||'').toLowerCase().includes(q)||String((ags[id]&&ags[id].campaignId)||'').includes(q));
1404
2662
  tgtEl.textContent='アカウントアセット全体 / '+ids.length+'件';
@@ -1407,7 +2665,7 @@ function renderDrawer(){
1407
2665
  }
1408
2666
  if(drawerTarget.type==='cppick'){
1409
2667
  // F-6: プラン内キャンペーン選択 (親付け替え or 新規AG追加先)
1410
- titleEl.textContent = drawerTarget.mode==='addNew' ? '新規広告グループの追加先キャンペーン' : '広告グループのキャンペーンを選択';
2668
+ titleEl.textContent = drawerTarget.mode==='addNew' ? ('新規'+agLabel()+'の追加先キャンペーン') : (agLabel()+'のキャンペーンを選択');
1411
2669
  tgtEl.textContent='プラン内キャンペーン / '+st.campaigns.length+'件';
1412
2670
  let curCUid=null; if(drawerTarget.gUid){ const r=findGroup(drawerTarget.gUid); if(r) curCUid=r.c.uid; }
1413
2671
  let h=''; st.campaigns.forEach((c,i)=>{ if(q&&!String(c.name||'').toLowerCase().includes(q)) return; const cur=c.uid===curCUid; h+='<div class="ddrow'+(cur?' cur':'')+'" data-cppick="'+esc(c.uid)+'" title="'+esc(c.name)+'"><span class="ddno">'+(i+1)+'</span><span class="ddtx">'+esc(c.name||'(無名)')+(cur?' <span class="pill">現在</span>':'')+'</span></div>'; });
@@ -1422,32 +2680,38 @@ function renderDrawer(){
1422
2680
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">広告グループがありません</div>'; return;
1423
2681
  }
1424
2682
  if(drawerTarget.type==='tg'){
1425
- const cfg=TG_OPTIONS[drawerTarget.field]; const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
2683
+ const cfg=pf().targeting[drawerTarget.field]; const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
1426
2684
  const cur=(g&&g[drawerTarget.field]?String(g[drawerTarget.field]):'').split(',').map(s=>s.trim()).filter(Boolean);
1427
2685
  titleEl.textContent=cfg.label+' を選択(複数可)';
1428
2686
  tgtEl.textContent='入力先: '+(g&&g.name?g.name:'広告グループ')+' / '+cfg.label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)');
1429
2687
  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>'; });
1430
2688
  listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
1431
2689
  }
1432
- const fieldName={imageNo:'画像番号',titleNo:'タイトル番号',descNo:'説明文番号'}[drawerTarget.f]||'';
2690
+ /* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
2691
+ const dcol=colDefs('ads').find(c=>c.field===drawerTarget.f); const fieldName=dcol?dcol.label:'';
1433
2692
  let curNo=''; const fa=findAd(drawerTarget.u); if(fa) curNo=String(fa.a[drawerTarget.f]||'');
1434
2693
  tgtEl.textContent='入力先: '+fieldName+(curNo?(' / 現在 '+curNo):' / 未設定');
2694
+ /* S10: 参照テーブルは列記述子の refTable から引く。値の見せ方は refTables[key] (label/drawerLabel/dropEmpty)。 */
2695
+ const rkey=(dcol&&dcol.refTable) || (drawerTarget.type==='titles'?'titles':'media');
2696
+ const T=refTableDef(rkey)||{}, store=st.refs[rkey]||{};
2697
+ const single=!!(T.valueFields&&T.valueFields.length===1);
2698
+ titleEl.textContent = single ? ((T.label||'')+'一覧(文言ライブラリ)') : ((T.countLabel||T.label||'')+' 一覧');
1435
2699
  let html='';
1436
- if(drawerTarget.type==='titles'){
1437
- const isDesc=drawerTarget.f==='descNo';
1438
- const store=isDesc?(st.refs.descriptions||{}):st.refs.titles;
1439
- titleEl.textContent=(isDesc?'説明文':'タイトル')+'一覧(文言ライブラリ)';
1440
- Object.keys(store).sort((a,b)=>a-b).forEach(no=>{ const t=store[no]; if(String(t||'').trim()==='') return; if(q&&!(String(no).includes(q)||String(t).toLowerCase().includes(q))) return; html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'"><span class="ddno">'+esc(no)+'</span><span class="ddtx">'+esc(t)+'</span></div>'; });
1441
- } else {
1442
- titleEl.textContent='登録画像 一覧';
1443
- Object.keys(st.refs.media).sort((a,b)=>a-b).forEach(no=>{ const m=st.refs.media[no]; if(q&&!(String(no).includes(q)||String(m.card||'').includes(q)||String(m.sq||'').includes(q))) return; html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'"><span class="ddno">'+esc(no)+'</span>'+newBadge('media',no)+'<span class="ddtx">Card '+esc(m.card||'—')+' / SQ '+esc(m.sq||'—')+'</span></div>'; });
1444
- }
2700
+ refSortedKeys({keyKind:'number'},store).forEach(no=>{
2701
+ const v=store[no];
2702
+ if(T.dropEmpty && String(v==null?'':v).trim()==='') return;
2703
+ if(q && !refSearchStrings(T,no,v).some(s=>s.includes(q))) return;
2704
+ const txt = T.drawerLabel ? T.drawerLabel(v) : esc(String(v==null?'':v));
2705
+ html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'"><span class="ddno">'+esc(no)+'</span>'
2706
+ +(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
2707
+ });
1445
2708
  listEl.innerHTML=html||'<div class="empty" style="padding:24px">該当なし</div>';
1446
2709
  }
1447
2710
  function drawerPick(no){
1448
2711
  if(!drawerTarget) return;
1449
2712
  const f=drawerTarget.f, num=/^\d+$/.test(no)?Number(no):no;
1450
- const compat = drawerTarget.type==='media'?[1]:[2,3]; // media=画像番号列 / titles=タイトル・説明列
2713
+ // S3: 旧 [1] / [2,3] リテラルを撤去。同じ▼ドロワー表示モードを持つ refcombo 列を記述子から算出する
2714
+ const compat = compatColsFor(drawerTarget.type, sheetTab);
1451
2715
  const rg=selRange(); let applied=0;
1452
2716
  mutate(()=>{
1453
2717
  if(rg){ const vis=flatVisible(); for(let rr=rg.r0; rr<=rg.r1; rr++){ const item=vis[rr]; if(!item) continue; for(let cc=rg.c0; cc<=rg.c1; cc++){ if(compat.indexOf(cc)>=0){ setVar(item,cc,num); applied++; } } } }
@@ -1497,7 +2761,7 @@ document.addEventListener('click', e=>{
1497
2761
  if(act==='ag-dup'){ dupGroup(u); return; }
1498
2762
  if(act==='ag-del'){ const r=findGroup(u); if(r) mutate(()=>r.c.adGroups.splice(r.i,1)); return; }
1499
2763
  if(act==='ag-param'){ editParam(u); return; }
1500
- if(act==='cp-addag'){ const r=findCp(u); if(r) mutate(()=>r.c.adGroups.push({uid:uid('g'),name:'AG'+(r.c.adGroups.length+1),paramCode:'',ads:[newAd()]})); return; }
2764
+ if(act==='cp-addag'){ const r=findCp(u); if(r) mutate(()=>r.c.adGroups.push(newAdGroup(r.c))); return; }
1501
2765
  if(act==='cp-addag-existing'){ openAdGroupRefDrawer(); return; } // F-5: アカウントアセット全体から
1502
2766
  if(act==='cp-edit'){ editCampaign(u); return; }
1503
2767
  if(act==='cp-dup'){ dupCampaign(u); return; }
@@ -1514,13 +2778,13 @@ document.addEventListener('click', e=>{
1514
2778
  function addAdBottom(){
1515
2779
  const cps=st.campaigns; if(!cps.length){ toast('先にキャンペーンを追加してください'); return; }
1516
2780
  let g=null; for(let i=cps.length-1;i>=0 && !g;i--){ const ags=cps[i].adGroups; if(ags.length) g=ags[ags.length-1]; }
1517
- if(!g){ toast('先に広告グループを追加してください'); return; }
2781
+ if(!g){ toast('先に'+agLabel()+'を追加してください'); return; }
1518
2782
  mutate(()=>g.ads.push(newAd())); toast('広告を追加('+esc(g.name)+')');
1519
2783
  }
1520
2784
  function addCampaign(){ mutate(()=>st.campaigns.push(makeNewCampaign({urlNumber:suggestUrlNumber()}))); toast('新規キャンペーンを追加'); }
1521
2785
 
1522
2786
  /* ---- clone helpers ---- */
1523
- function cloneAd(a, clear){ const n=newAd(Object.assign({},a)); n.uid=uid('a'); if(clear){ clear.forEach(k=>{ if(k==='creative'){n.imageNo='';n.titleNo='';n.descNo='';} else n[k]=''; }); } return n; }
2787
+ function cloneAd(a, clear){ const n=newAd(Object.assign({},a)); n.uid=uid('a'); if(clear){ clear.forEach(k=>{ if(k==='creative'){ pf().creativeFields.forEach(f=>{ n[f]=''; }); } else n[k]=''; }); } return n; }
1524
2788
  function dupAd(u, clear){ const r=findAd(u); if(!r) return; mutate(()=>r.g.ads.splice(r.i+1,0,cloneAd(r.a, clear?['creative']:null))); }
1525
2789
  function addNAds(u){
1526
2790
  const r=findGroup(u); if(!r) return;
@@ -1559,18 +2823,18 @@ function editCampaign(u){
1559
2823
  {key:'name',label:'キャンペーン名',type:'text',value:c.name},
1560
2824
  {key:'variable',label:'検証する変数(1キャンペーン=1変数)',type:'text',value:c.variable},
1561
2825
  {key:'urlNumber',label:'入稿リンク末尾番号(空き: '+suggestUrlNumber()+')',type:'text',value:c.urlNumber},
1562
- {key:'budget',label:'予算/日(空で既定 '+NEW_CP_BUDGET+')',type:'number',value:c.budget??''},
2826
+ {key:'budget',label:'予算/日(空で既定 '+pf().defaults.newCampaignBudget+')',type:'number',value:c.budget??''},
1563
2827
  {key:'biddingStrategy',label:'配信戦略',type:'select',value:c.biddingStrategy||'tCPA',options:[{v:'tCPA',t:'tCPA'},{v:'最大配信',t:'最大配信'}]},
1564
2828
  {key:'tcpa',label:'tCPA(円)',type:'number',value:c.tcpa??''}
1565
2829
  ], extra:'<button class="act" id="genName" style="margin-right:auto">キャンペーン名を自動生成</button>', onMount:(root,getVals,setVal)=>{
1566
- root.querySelector('#genName').addEventListener('click',()=>{ const num=getVals().urlNumber||suggestUrlNumber(); const first=c.adGroups[0]&&c.adGroups[0].ads[0]; const cr=first?(String(first.imageNo||'CR')+(first.format==='SQ'?'s':'c')):'CR'; setVal('name', num+'_'+cr+'_'+today()); });
2830
+ 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())); });
1567
2831
  }, 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); }); }});
1568
2832
  // B6解消: 配信戦略/tCPAはキャンペーン個別値のみを参照 (project既定を焼き付けない)
1569
2833
  }
1570
2834
  function cloneCampaign(c, clearCreative, clearParam){
1571
2835
  const nc=JSON.parse(JSON.stringify(c)); nc.uid=uid('c');
1572
2836
  nc.campaignId=''; nc.isExisting=false; // B7/F-2: 複製はIDを引き継がず必ず新規扱い(編集可)
1573
- 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){a.imageNo='';a.titleNo='';a.descNo='';} }); });
2837
+ 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]=''; }); }); });
1574
2838
  return nc;
1575
2839
  }
1576
2840
  /* 指定prefixを持つ paramCode の現在の最大末尾番号 (プラン内 + アカウントJSON refs.groups を走査) */
@@ -1593,7 +2857,7 @@ function dupCampaign(u){
1593
2857
  // paramCode 連番自動採番 (prefixごとに現状の最大+1から連番)
1594
2858
  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); } });
1595
2859
  // キャンペーン名 自動採番 ({空き番号}_{CR名}_{今日})
1596
- const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0]; const cr=f?(String(f.imageNo||'CR')+(f.format==='SQ'?'s':'c')):'CR'; nc.name=num+'_'+cr+'_'+today();
2860
+ 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());
1597
2861
  st.campaigns.splice(base+1+i,0,nc);
1598
2862
  }
1599
2863
  });
@@ -1659,14 +2923,40 @@ let samplePlanFileHandle=null; // サンプルプラン.json の書込ハンド
1659
2923
  const fsSupported = typeof window.showDirectoryPicker==='function';
1660
2924
  const fsOpenSupported = typeof window.showOpenFilePicker==='function';
1661
2925
  async function writeToHandle(h,text){ try{ const w=await h.createWritable(); await w.write(text); await w.close(); return true; }catch(e){ return false; } }
1662
- function idb(){ return new Promise((res,rej)=>{ const r=indexedDB.open('yda_planner_fs',1); r.onupgradeneeded=()=>r.result.createObjectStore('handles'); r.onsuccess=()=>res(r.result); r.onerror=()=>rej(r.error); }); }
2926
+ /* S9: IndexedDB も ad_planner_fs へ改名。キーは 'dir'(入稿計画/・媒体共通) /
2927
+ 'accountsDir:<platform>' / 'samplePlanFile:<platform>'。旧 'yda_planner_fs' からは1回だけコピーする。 */
2928
+ const IDB_NAME='ad_planner_fs';
2929
+ const LEGACY_IDB_NAME='yda_planner_fs';
2930
+ function idbKeyAccountsDir(){ return 'accountsDir:'+curPf; }
2931
+ function idbKeySamplePlan(){ return 'samplePlanFile:'+curPf; }
2932
+ function idbOpen(name){ return new Promise((res,rej)=>{ const r=indexedDB.open(name,1); r.onupgradeneeded=()=>{ try{ r.result.createObjectStore('handles'); }catch(e){} }; r.onsuccess=()=>res(r.result); r.onerror=()=>rej(r.error); }); }
2933
+ function idbGetIn(db,k){ return new Promise(res=>{ try{ const rq=db.transaction('handles','readonly').objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>res(undefined); }catch(e){ res(undefined); } }); }
2934
+ function idbPutIn(db,k,v){ return new Promise(res=>{ try{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=()=>res(true); tx.onerror=()=>res(false); }catch(e){ res(false); } }); }
2935
+ async function migrateLegacyIdb(db){
2936
+ try{
2937
+ if(typeof indexedDB.databases==='function'){
2938
+ const list=await indexedDB.databases();
2939
+ if(!(list||[]).some(d=>d&&d.name===LEGACY_IDB_NAME)) return; // 旧DBが無ければ何もしない(空DBを作らない)
2940
+ }
2941
+ const old=await idbOpen(LEGACY_IDB_NAME);
2942
+ const pairs=[['dir','dir'],[LEGACY_KEY_NS+'fs_accounts','accountsDir:yda'],['samplePlanFile','samplePlanFile:yda']];
2943
+ for(const [from,to] of pairs){
2944
+ const v=await idbGetIn(old,from);
2945
+ if(v==null) continue;
2946
+ if((await idbGetIn(db,to))==null) await idbPutIn(db,to,v);
2947
+ }
2948
+ try{ old.close(); }catch(e){}
2949
+ }catch(e){}
2950
+ }
2951
+ let _idbPromise=null;
2952
+ function idb(){ if(!_idbPromise) _idbPromise=idbOpen(IDB_NAME).then(async db=>{ await migrateLegacyIdb(db); return db; }); return _idbPromise; }
1663
2953
  async function idbSet(k,v){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=res; tx.onerror=()=>rej(tx.error); }); }
1664
2954
  async function idbGet(k){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readonly'); const rq=tx.objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>rej(rq.error); }); }
1665
2955
  async function connectFolder(){
1666
2956
  if(!fsSupported){ toast('このブラウザはフォルダ連携非対応です(保存はダウンロード動作)'); return; }
1667
2957
  try{
1668
2958
  if(pendingDirHandle){ const p=await pendingDirHandle.requestPermission({mode:'readwrite'}); if(p==='granted'){ dirHandle=pendingDirHandle; pendingDirHandle=null; renderHist(); toast('保存フォルダに再接続しました'); return; } }
1669
- dirHandle=await window.showDirectoryPicker({id:'yda-planner-plans', mode:'readwrite'});
2959
+ dirHandle=await window.showDirectoryPicker({id:'ad-planner-plans', mode:'readwrite'});
1670
2960
  pendingDirHandle=null; await idbSet('dir',dirHandle); renderHist(); updateSubmitJsonBtn(); toast('保存フォルダに接続しました: '+dirHandle.name);
1671
2961
  }catch(e){ if(e && e.name!=='AbortError') toast('フォルダ接続に失敗: '+e.message); }
1672
2962
  }
@@ -1688,23 +2978,45 @@ async function writePlanFile(name, text){
1688
2978
  }
1689
2979
 
1690
2980
  /* ============================================================
1691
- B-1: アカウント別参照データ (アカウント/ フォルダ + <名前>.json)
1692
- File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(アカウント/index.json 先頭)を使用。
1693
- IndexedDB キー 'yda_planner_fs_accounts' に第2ハンドルを保存 (入稿計画/ の 'dir' とは別枠)。
2981
+ B-1: アカウント別参照データ (アカウント/<媒体>/ フォルダ + <名前>.json)
2982
+ File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
2983
+ IndexedDB キー 'accountsDir:<platform>' に第2ハンドルを保存 (入稿計画/ の 'dir' とは別枠)。
1694
2984
  ============================================================ */
1695
2985
  const ACC_JSON_VERSION=1;
1696
2986
  let accDirHandle=null, pendingAccDirHandle=null;
1697
2987
  let accountFiles=[]; // フォルダ内の <名前>.json のベース名一覧 (File System Access接続時)
1698
- let httpAccountFiles=[]; // アカウント/index.json から取得した <名前> 一覧 (読み取り専用切替)
1699
- /* アカウント一覧は マニフェスト アカウント/index.json (直接ファイルfetch)から取得する。
2988
+ let httpAccountFiles=[]; // <account.dir>/index.json から取得した <名前> 一覧 (読み取り専用切替)
2989
+ /* アカウント一覧は マニフェスト <account.dir>/index.json (直接ファイルfetch)から取得する。
1700
2990
  サンプルプランと同じ「ファイル読み込み」なので、file:///autoindex無し環境でも読める。
1701
2991
  フォールバック: index.json が無ければディレクトリ一覧(HTML)を解析。 */
2992
+ /* S9: アカウントJSON は媒体別サブディレクトリ (pf().account.dir = 'アカウント/<platform>') に置く。
2993
+ 旧レイアウト (アカウント/ 直下) も引き続き読めるようフォールバックする
2994
+ (他環境・配布済みスキルを壊さないため。旧レイアウトは YDA のみ存在した)。 */
2995
+ const LEGACY_ACCOUNT_DIR='アカウント';
2996
+ function accountDir(){ const P=pf(); return (P&&P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR; }
2997
+ function legacyAccountDir(){ return curPf==='yda' ? LEGACY_ACCOUNT_DIR : null; }
2998
+ async function fetchAccountIndex(){
2999
+ const idx=await fetchJson(accountDir()+'/index.json');
3000
+ if(idx) return idx;
3001
+ const legacy=legacyAccountDir();
3002
+ return legacy ? await fetchJson(legacy+'/index.json') : null;
3003
+ }
3004
+ async function fetchAccountJsonByName(name){
3005
+ const rel=encodeURIComponent(name)+'.json';
3006
+ const obj=await fetchJson(accountDir()+'/'+rel);
3007
+ if(obj) return obj;
3008
+ const legacy=legacyAccountDir();
3009
+ return legacy ? await fetchJson(legacy+'/'+rel) : null;
3010
+ }
1702
3011
  async function fetchAccountList(){
1703
3012
  const set=new Set();
1704
- // (1) マニフェスト アカウント/index.json (file://・autoindex無しでも読める)
1705
- try{ const idx=await fetchJson('アカウント/index.json'); if(idx){ const arr=Array.isArray(idx)?idx:(idx.accounts||[]); arr.map(String).filter(Boolean).forEach(n=>set.add(n)); } }catch(e){}
3013
+ // (1) マニフェスト <account.dir>/index.json (file://・autoindex無しでも読める。旧レイアウトへフォールバック)
3014
+ try{ const idx=await fetchAccountIndex(); if(idx){ const arr=Array.isArray(idx)?idx:(idx.accounts||[]); arr.map(String).filter(Boolean).forEach(n=>set.add(n)); } }catch(e){}
1706
3015
  // (2) ディレクトリ一覧(HTML) — http配信時は index.json に無い新規ファイルも自動検出してマージ
1707
- try{ const r=await fetch('アカウント/',{cache:'no-store'}); if(r&&r.ok){ const html=await r.text(); const re=/href="([^"]+\.json)"/gi; let m; while((m=re.exec(html))){ let n=decodeURIComponent(m[1]).replace(/^.*\//,'').replace(/\.json$/i,''); if(n&&n!=='index') set.add(n); } } }catch(e){}
3016
+ for(const dir of [accountDir(), legacyAccountDir()]){
3017
+ if(!dir) continue;
3018
+ try{ const r=await fetch(dir+'/',{cache:'no-store'}); if(r&&r.ok){ const html=await r.text(); const re=/href="([^"]+\.json)"/gi; let m; while((m=re.exec(html))){ let n=decodeURIComponent(m[1]).replace(/^.*\//,'').replace(/\.json$/i,''); if(n&&n!=='index') set.add(n); } } }catch(e){}
3019
+ }
1708
3020
  httpAccountFiles=[...set].sort();
1709
3021
  }
1710
3022
  function accStatusText(){
@@ -1731,14 +3043,14 @@ async function connectAccountFolder(){
1731
3043
  if(!fsSupported){ toast('このブラウザはフォルダ連携非対応です'); return; }
1732
3044
  try{
1733
3045
  if(pendingAccDirHandle){ const p=await pendingAccDirHandle.requestPermission({mode:'readwrite'}); if(p==='granted'){ accDirHandle=pendingAccDirHandle; pendingAccDirHandle=null; await refreshAccountFiles(); toast('アカウントフォルダに再接続しました'); return; } }
1734
- accDirHandle=await window.showDirectoryPicker({id:'yda-planner-accounts', mode:'readwrite'});
1735
- pendingAccDirHandle=null; await idbSet('yda_planner_fs_accounts', accDirHandle);
3046
+ accDirHandle=await window.showDirectoryPicker({id:'ad-planner-accounts-'+curPf, mode:'readwrite'});
3047
+ pendingAccDirHandle=null; await idbSet(idbKeyAccountsDir(), accDirHandle);
1736
3048
  await refreshAccountFiles(); toast('アカウントフォルダに接続: '+accDirHandle.name);
1737
3049
  }catch(e){ if(e && e.name!=='AbortError') toast('フォルダ接続に失敗: '+e.message); }
1738
3050
  }
1739
3051
  async function restoreAccountFolder(){
1740
3052
  if(!fsSupported){ renderAccountBar(); return; }
1741
- try{ const h=await idbGet('yda_planner_fs_accounts'); if(h){ const p=await h.queryPermission({mode:'readwrite'}); if(p==='granted'){ accDirHandle=h; await refreshAccountFiles(); } else pendingAccDirHandle=h; } }catch(e){}
3053
+ try{ const h=await idbGet(idbKeyAccountsDir()); if(h){ const p=await h.queryPermission({mode:'readwrite'}); if(p==='granted'){ accDirHandle=h; await refreshAccountFiles(); } else pendingAccDirHandle=h; } }catch(e){}
1742
3054
  renderAccountBar();
1743
3055
  }
1744
3056
  async function refreshAccountFiles(){
@@ -1758,12 +3070,15 @@ async function writeAccountJson(name, obj){
1758
3070
  /* 参照データ差し替えのシーム: フォルダ読込・新規作成・E2Eテストが共通で使う */
1759
3071
  window.applyAccountJson=function(obj){
1760
3072
  if(!obj||obj.accountJsonVersion!==ACC_JSON_VERSION||!obj.refs) throw new Error('アカウントJSON形式が不正です(accountJsonVersion:'+ACC_JSON_VERSION+' 必須)');
3073
+ /* S9: platform 検証。未記載は 'yda' (旧アカウントJSONの後方互換)。別媒体なら媒体ごと切り替える。 */
3074
+ const declared=String(obj.platform||'yda');
3075
+ if(declared!==curPf){
3076
+ if(!PLATFORMS[declared]) throw new Error('このアカウントJSONの媒体は未対応です(platform: '+declared+')');
3077
+ window.switchPlatform(declared);
3078
+ }
1761
3079
  snapshot();
1762
3080
  st.refs=normalizeRefs(obj.refs);
1763
- st.project.accountId=String(obj.accountId||'');
1764
- st.project.accountName=String(obj.name||'');
1765
- // E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持
1766
- if(obj.principal!=null) st.project.principal=String(obj.principal);
3081
+ pf().account.apply(obj, st.project); // S7: project への写し込みは媒体スキーマ (accountId書式/追加項目)
1767
3082
  persist(); buildDatalists(); render(); renderAccountBar();
1768
3083
  };
1769
3084
  async function selectAccount(name){
@@ -1771,7 +3086,7 @@ async function selectAccount(name){
1771
3086
  if(name===st.project.accountName){ renderAccountBar(); return; }
1772
3087
  if(!accDirHandle){
1773
3088
  if(httpAccountFiles.includes(name)){ // http一覧のアカウントを fetch で読み込み(読み取り専用切替)
1774
- const obj=await fetchJson('アカウント/'+encodeURIComponent(name)+'.json');
3089
+ const obj=await fetchAccountJsonByName(name);
1775
3090
  if(obj){ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }catch(e){ toast(e.message); renderAccountBar(); } }
1776
3091
  else { toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); }
1777
3092
  return;
@@ -1819,43 +3134,9 @@ async function loadAccountFile(file){
1819
3134
  }
1820
3135
  /* F-3: AIで追加 — フォーム入力は無し。プロンプトだけを表示し、アカウント名/ID/主体者は Claude Code 上でAIと対話して決めさせる */
1821
3136
  function openAiAddAccountModal(){
1822
- const schema=[
1823
- '{',
1824
- ' "accountJsonVersion": 1,',
1825
- ' "accountId": "<ID>",',
1826
- ' "name": "<アカウント名>",',
1827
- ' "principal": "<主体者表記(例: ○○製薬)>",',
1828
- ' "updatedAt": "<ISO8601>",',
1829
- ' "refs": {',
1830
- ' "titles": {"1": "見出し文言"},',
1831
- ' "descriptions": {"1": "説明文文言"},',
1832
- ' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
1833
- ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
1834
- ' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
1835
- ' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
1836
- ' }',
1837
- '}'
1838
- ].join('\n');
1839
- const prompt=[
1840
- 'YDA入稿プランナー用に、新規アカウントの参照データJSONを生成してください。',
1841
- '',
1842
- '1. まず対象アカウントを対話で決めてください:',
1843
- ' - webgram list --platform yda --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認してアカウント名・accountId・主体者表記を確定する',
1844
- '2. 確定したアカウントについて、登録画像・既存キャンペーン/広告グループを取得:',
1845
- ' webgram list --platform yda --resource media --account-id <ID>',
1846
- ' webgram list --platform yda --resource campaigns --account-id <ID>',
1847
- ' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
1848
- '3. 取得結果を下記スキーマに整形し、アカウント/<アカウント名>.json として書き出す:',
1849
- ' - media は 画像番号 → {card, sq}(mediaId)',
1850
- ' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
1851
- ' - campaigns/adGroups は campaignId/adGroupId をキーに',
1852
- '',
1853
- 'スキーマ:',
1854
- schema,
1855
- '',
1856
- '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加 →「📄 ファイルから追加」で その JSON を読み込めます。'
1857
- ].join('\n');
1858
- showPromptModal({title:'AIで追加(CLIから収集)', sub:'このプロンプトをClaude Codeに貼ってください。アカウント名はAIと対話して決めます', prompt, hint:'生成された アカウント/<名前>.json は「+新規追加 →「📄 ファイルから追加」で読み込めます'});
3137
+ /* S7: プロンプト文言は pf().prompts.importAccount (媒体スキーマ) に集約 */
3138
+ const prompt=pf().prompts.importAccount({mode:'ai'});
3139
+ showPromptModal({title:'AIで追加(CLIから収集)', sub:'このプロンプトをClaude Codeに貼ってください。アカウント名はAIと対話して決めます', prompt, hint:'生成された '+accountDir()+'/<名前>.json は「+新規追加 →「📄 ファイルから追加」で読み込めます'});
1859
3140
  }
1860
3141
  /* E-1: アクティブアカウントの主体者/アカウントIDを編集 (アカウントJSONへ書き戻し) */
1861
3142
  function openEditAccountModal(){
@@ -1871,44 +3152,9 @@ function openEditAccountModal(){
1871
3152
  }
1872
3153
  /* B-3: CLI経由インポートのプロンプトを表示 (静的HTMLは直接APIを叩かず Claude に生成させる) */
1873
3154
  function openImportModal(){
1874
- const name=st.project.accountName||''; const accId=st.project.accountId||'';
1875
- const schema=[
1876
- '{',
1877
- ' "accountJsonVersion": 1,',
1878
- ' "accountId": "<ID>",',
1879
- ' "name": "<アカウント名>",',
1880
- ' "principal": "<主体者表記(例: ○○製薬)>",',
1881
- ' "updatedAt": "<ISO8601>",',
1882
- ' "refs": {',
1883
- ' "titles": {"1": "見出し文言"},',
1884
- ' "descriptions": {"1": "説明文文言"},',
1885
- ' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
1886
- ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
1887
- ' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
1888
- ' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
1889
- ' }',
1890
- '}'
1891
- ].join('\n');
1892
- const prompt=[
1893
- 'YDA入稿プランナー用に、アカウント「'+(name||'<アカウント名>')+'」'+(accId?'(accountId: '+accId+')':'')+'の参照データJSONを生成してください。',
1894
- '',
1895
- '手順:',
1896
- '1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
1897
- ' webgram list --platform yda --resource accounts',
1898
- ' webgram list --platform yda --resource media --account-id '+(accId||'<ID>'),
1899
- ' webgram list --platform yda --resource campaigns --account-id '+(accId||'<ID>'),
1900
- ' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
1901
- '2. 取得結果を下記スキーマに整形して アカウント/'+(name||'<アカウント名>')+'.json として書き出す:',
1902
- ' - media は 画像番号 → {card, sq}(mediaId)',
1903
- ' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
1904
- ' - campaigns/adGroups は campaignId/adGroupId をキーに',
1905
- '',
1906
- 'スキーマ:',
1907
- schema,
1908
- '',
1909
- '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
1910
- ].join('\n');
1911
- showPromptModal({title:'アカウント情報をインポート', sub:'このプロンプトをClaude Codeに貼ってCLIでアカウントJSONを生成してください', prompt, hint:'アカウント/<名前>.json が生成されたら「📁 フォルダ」再接続で自動的にセレクトに現れます'});
3155
+ const name=st.project.accountName||'', accId=st.project.accountId||'';
3156
+ const prompt=pf().prompts.importAccount({mode:'import', name, accountId:accId});
3157
+ showPromptModal({title:'アカウント情報をインポート', sub:'このプロンプトをClaude Codeに貼ってCLIでアカウントJSONを生成してください', prompt, hint:accountDir()+'/<名前>.json が生成されたら「📁 フォルダ」再接続で自動的にセレクトに現れます'});
1912
3158
  }
1913
3159
  /* 参照ビュー: 他アカウントから タイトル/説明文 をコピー (文言はアカウント間で共通のことが多い) */
1914
3160
  function openCopyRefsModal(){
@@ -1920,7 +3166,7 @@ function openCopyRefsModal(){
1920
3166
  ], onOk: async v=>{
1921
3167
  const obj=await readAccountJson(v.src); if(!obj||!obj.refs){ toast('読み込めません'); return; }
1922
3168
  const src=normalizeRefs(obj.refs); let n=0; snapshot();
1923
- ['titles','descriptions'].forEach(k=>{ Object.keys(src[k]||{}).forEach(no=>{ if(st.refs[k][no]==null){ st.refs[k][no]=src[k][no]; n++; } }); });
3169
+ refTableKeys().filter(k=>refTableDef(k).copyable).forEach(k=>{ Object.keys(src[k]||{}).forEach(no=>{ if(st.refs[k][no]==null){ st.refs[k][no]=src[k][no]; n++; } }); });
1924
3170
  persist(); buildDatalists(); render(); saveActiveAccountJson();
1925
3171
  toast(v.src+' から '+n+' 件の文言をコピーしました');
1926
3172
  }});
@@ -1929,16 +3175,16 @@ function openCopyRefsModal(){
1929
3175
  /* ---- 履歴 (localStorage: メタ一覧 + バージョン本体) ----
1930
3176
  本体もブラウザ内に自動保存する → フォルダ選択なしで一覧・再読込・削除が完結する。
1931
3177
  フォルダ(入稿計画/)接続時はファイルにも書き出す(二重保存)。 */
1932
- const BODY_KEY = 'yda_planner_histbody_';
1933
- function histList(){ try{ return JSON.parse(localStorage.getItem(HIST_KEY))||[]; }catch(e){ return []; } }
1934
- function histSave(list){ try{ localStorage.setItem(HIST_KEY, JSON.stringify(list)); }catch(e){} }
3178
+ /* S9: 履歴・本体キーは媒体別 (histKey() / bodyKey(vid))。 */
3179
+ function histList(){ try{ return JSON.parse(localStorage.getItem(histKey()))||[]; }catch(e){ return []; } }
3180
+ function histSave(list){ try{ localStorage.setItem(histKey(), JSON.stringify(list)); }catch(e){} }
1935
3181
  function nextVersionId(){ return histList().reduce((m,h)=>Math.max(m,h.versionId||0),0)+1; }
1936
- function histGetBody(vid){ try{ return localStorage.getItem(BODY_KEY+vid); }catch(e){ return null; } }
1937
- function histDelBody(vid){ try{ localStorage.removeItem(BODY_KEY+vid); }catch(e){} }
3182
+ function histGetBody(vid){ try{ return localStorage.getItem(bodyKey(vid)); }catch(e){ return null; } }
3183
+ function histDelBody(vid){ try{ localStorage.removeItem(bodyKey(vid)); }catch(e){} }
1938
3184
  /* 容量超過時は古い本体から捨てる (メタ一覧は残す = ファイル側から読み直せる) */
1939
3185
  function histPutBody(vid, json){
1940
3186
  for(;;){
1941
- try{ localStorage.setItem(BODY_KEY+vid, json); return true; }
3187
+ try{ localStorage.setItem(bodyKey(vid), json); return true; }
1942
3188
  catch(e){
1943
3189
  const stored=histList().filter(h=>h.versionId!==vid && histGetBody(h.versionId)!=null)
1944
3190
  .sort((a,b)=>(a.versionId||0)-(b.versionId||0));
@@ -1951,14 +3197,14 @@ function histPutBody(vid, json){
1951
3197
  /* ---- バージョン保存: 保存のたびに常に新バージョン (上書きなし) ---- */
1952
3198
  function saveVersion(){
1953
3199
  const prevAuthor = st.meta.author || localStorage.getItem(AUTHOR_KEY) || '';
1954
- openModal({title:'バージョン保存', sub:'常に新バージョンとして保存します(上書きなし)/ 媒体: YDA / 次: v'+nextVersionId()+' / 保存先: '+(dirHandle?dirHandle.name+'/':'ダウンロード(フォルダ未接続)'), fields:[
3200
+ openModal({title:'バージョン保存', sub:'常に新バージョンとして保存します(上書きなし)/ 媒体: '+pf().displayName+' / 次: v'+nextVersionId()+' / 保存先: '+(dirHandle?dirHandle.name+'/':'ダウンロード(フォルダ未接続)'), fields:[
1955
3201
  {key:'name',label:'名称',type:'text',value:st.meta.name||st.project.name,hint:'履歴一覧に表示される名前'},
1956
3202
  {key:'memo',label:'メモ',type:'textarea',value:st.meta.memo||''},
1957
3203
  {key:'author',label:'作成者',type:'text',value:prevAuthor,hint:'空欄可。次回保存時も記憶されます'}
1958
3204
  ], onOk: async v=>{
1959
3205
  const now=new Date().toISOString();
1960
3206
  const versionId=nextVersionId();
1961
- st.meta={versionId, name:v.name||st.project.name, memo:v.memo||'', author:v.author||'', platform:'yda', createdAt:now, updatedAt:now};
3207
+ st.meta={versionId, name:v.name||st.project.name, memo:v.memo||'', author:v.author||'', platform:curPf, createdAt:now, updatedAt:now};
1962
3208
  try{ localStorage.setItem(AUTHOR_KEY, v.author||''); }catch(e){}
1963
3209
  const c=validationCounts();
1964
3210
  const fileName=(st.project.name||'plan')+'_v'+versionId+'_'+tsCompact()+'.json';
@@ -1967,7 +3213,7 @@ function saveVersion(){
1967
3213
  const wrote=await writePlanFile(fileName,json); // フォルダ(入稿計画/)接続時はファイルにも書き出し
1968
3214
  if(!wrote) download(fileName,json,'application/json'); // 未接続時はDLでファイルも残す(入稿計画へ手動格納可)
1969
3215
  const list=histList();
1970
- list.push({versionId, name:st.meta.name, memo:st.meta.memo, author:st.meta.author, platform:'yda', createdAt:now, fileName, inFolder:wrote, inLocal, campaigns:st.campaigns.length, ads:c.ads});
3216
+ list.push({versionId, name:st.meta.name, memo:st.meta.memo, author:st.meta.author, platform:curPf, createdAt:now, fileName, inFolder:wrote, inLocal, campaigns:st.campaigns.length, ads:c.ads});
1971
3217
  histSave(list); persist();
1972
3218
  if(activeView==='hist') renderHist();
1973
3219
  toast('v'+versionId+' を保存しました'+(wrote?'(入稿計画フォルダにも書き出し)':''));
@@ -1975,7 +3221,7 @@ function saveVersion(){
1975
3221
  let info='<div class="callout green" style="margin-top:0">プラン「'+esc(st.meta.name)+'」<b>v'+versionId+'</b> を保存しました(CP '+st.campaigns.length+' / 広告 '+c.ads+' 件)</div>';
1976
3222
  info+='<div class="mfield"><label>保存先 / ファイル名</label><div class="mono" style="font-size:12px; word-break:break-all">'+esc((wrote&&dirHandle?dirHandle.name+'/':'')+fileName)+'</div></div>';
1977
3223
  if(c.err) info+='<div class="callout amber">エラー '+c.err+' 件・警告 '+c.warn+' 件があります。書き出し時に未解決広告は除外されます。</div>';
1978
- const prompt='プラン「'+(st.meta.name||st.project.name)+'」v'+versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_入稿計画_*.json を指定して dry-run してください:\n\nwebgram submit-plan --plan-file "<入稿計画JSONのパス>"';
3224
+ const prompt=pf().prompts.afterSave({name:(st.meta.name||st.project.name), versionId});
1979
3225
  showPromptModal({title:'v'+versionId+' を保存しました', sub:'次のアクション(入稿)用のプロンプトです', infoHtml:info, prompt, hint:'まず「入稿JSON書き出し」でプランを入稿計画JSONに変換してください'});
1980
3226
  }});
1981
3227
  }
@@ -2047,110 +3293,52 @@ function histDeleteVersion(vid){
2047
3293
 
2048
3294
  /* ---- 入稿JSON書き出し (webgram submit-plan 用の入稿計画JSON) ----
2049
3295
  画面のプラン (案件設定チップ / キャンペーン行 / 広告グループ行のターゲティング列) を
2050
- そのまま SubmitPlanFile {planVersion:1, platform:'yda', accountId, campaigns} に変換する。
2051
- 広告は backend ydaAdSchema の IMAGE 形式 (mediaIds/headlines/descriptions/principal)。
2052
- 設定モーダルは持たない。手入稿(バナー)と未解決(参照表で引けない)広告は除外する。 */
2053
-
2054
- /* 画面の日本語ラベル → API enum 変換表 (正: frontend/src/pages/submission/types.ts) */
2055
- const TG_ENUM = {
2056
- device:{'スマートフォン':'SMARTPHONE','PC':'DESKTOP','タブレット':'TABLET'},
2057
- gender:{'男性':'ST_MALE','女性':'ST_FEMALE','不明':'ST_UNKNOWN'},
2058
- age:{'18-19歳':'GT_RANGE18_19','20-24歳':'GT_RANGE20_24','25-29歳':'GT_RANGE25_29','30-34歳':'GT_RANGE30_34','35-39歳':'GT_RANGE35_39','40-44歳':'GT_RANGE40_44','45-49歳':'GT_RANGE45_49','50-54歳':'GT_RANGE50_54','55-59歳':'GT_RANGE55_59','60-64歳':'GT_RANGE60_64','65-69歳':'GT_RANGE65_69','70歳以上':'GT_RANGE70_UL2','不明':'GT_UNKNOWN2'},
2059
- day:{'月':'MONDAY','火':'TUESDAY','水':'WEDNESDAY','木':'THURSDAY','金':'FRIDAY','土':'SATURDAY','日':'SUNDAY'},
2060
- // tCPA=コンバージョン数最大化+目標CPA / 最大配信=コンバージョン数最大化(目標なし・要実機確認)
2061
- bidding:{'tCPA':'MAXIMIZE_CONVERSIONS','最大配信':'MAXIMIZE_CONVERSIONS'}
2062
- };
3296
+ そのまま SubmitPlanFile {planVersion, platform, accountId, campaigns} に変換する。
3297
+ 3階層のボディ形状・enum変換は媒体スキーマ (pf().buildPlan / pf().enums / pf().mappers) 側。
3298
+ 設定モーダルは持たない。未解決(参照表で引けない)広告は除外する。 */
3299
+
2063
3300
  function tgList(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Boolean); }
2064
- function mapDevices(s){ const l=tgList(s); if(!l.length||l.indexOf('全てのデバイス')>=0) return []; return l.map(x=>TG_ENUM.device[x]).filter(Boolean); }
2065
- /* 曜日・時間帯セル → {days[], hours{start,end}|null}。「平日」「土日祝」は展開、
2066
- 時間帯(午前(6-12時)等)は startHour/endHour に変換 (複数選択時は最初の1つ) */
2067
- function mapDays(s){
2068
- const l=tgList(s); const days=[]; let hours=null;
2069
- if(!l.length||l.indexOf('全ての曜日・時間帯')>=0) return {days:[], hours:null};
2070
- const add=d=>{ if(days.indexOf(d)<0) days.push(d); };
2071
- l.forEach(x=>{
2072
- if(x==='平日'){ ['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY'].forEach(add); }
2073
- else if(x==='土日祝'){ add('SATURDAY'); add('SUNDAY'); }
2074
- else if(TG_ENUM.day[x]){ add(TG_ENUM.day[x]); }
2075
- else { const m=x.match(/\((\d+)-(\d+)時\)/); if(m&&!hours) hours={start:+m[1], end:+m[2]}; }
2076
- });
2077
- return {days, hours};
2078
- }
2079
- const toStatus=s=>s==='オン'?'ACTIVE':'PAUSED';
2080
- /* CTA(案件設定の日本語ラベル) → YDA buttonText enum。入稿ADDでは必須。
2081
- 未知ラベルは MORE_DETAIL(=「詳しくはこちら」) にフォールバック(実データ既定)。 */
2082
- const CTA_ENUM={'もっとみる':'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'};
2083
- function ctaToEnum(label){ return CTA_ENUM[String(label||'').trim()]||'MORE_DETAIL'; }
2084
3301
 
2085
3302
  /* A-4: plan本体 + 除外/警告集計を返す。plan本体だけ必要な既存呼び出し(E2E含む)は buildSubmitPlan() を使う。
2086
- 除外: 未解決(errs)広告・手入稿バナー・空広告グループ(ads:[])・空キャンペーン(adGroups:[])。
2087
- 警告: 地域/オーディエンス/サーチKW に値があるがJSON未反映の広告グループ数。 */
3303
+ S8: 媒体依存の3階層ボディ生成は pf().buildPlan.{campaign,adGroup,ad} に委譲し、
3304
+ ここには走査・除外・stats集計の骨格だけを残す (媒体語彙ゼロ)。
3305
+ 除外: 未解決(errs)広告・空広告グループ(ads:[])・空キャンペーン(adGroups:[])。
3306
+ 警告: pf().notSubmittedFields に値があるがJSON未反映の広告グループ数 (tgDropped)。 */
2088
3307
  function buildSubmitPlanWithStats(){
2089
- const p=st.project;
3308
+ const P=pf(), p=st.project;
2090
3309
  const stats={exported:0, unresolved:0, pendingImages:0, pendingImageNos:[], emptyGroups:0, emptyCampaigns:0, tgDropped:0};
3310
+ /* 行レベル検証を持つ媒体 (Meta の 日予算/入札上限 必須) だけ rowErrors を集計する */
3311
+ if(P.validateRow) stats.rowErrors=0;
3312
+ const ctx=Object.assign(planCtx(), {stats});
3313
+ const CK=P.levels.campaign.planKey, GK=P.levels.adGroup.planKey;
3314
+ const notSub=P.notSubmittedFields||[];
2091
3315
  const campaigns=st.campaigns.map(c=>{
2092
- const bidLabel=String(c.biddingStrategy||'').trim(); // A-1: キャンペーン個別値のみ (project既定へフォールバックしない)
2093
- const biddingStrategy=TG_ENUM.bidding[bidLabel]||bidLabel||'MAXIMIZE_CONVERSIONS';
2094
- const adGroups=c.adGroups.map(g=>{
2095
- const dh=mapDays(g.tg_dayHour);
2096
- const agStatus=toStatus(g.deliveryStatus||p.deliveryStatus);
2097
- const rated=g.ads.map(a=>({a, r:resolveAd(a,g)}));
2098
- rated.forEach(x=>{ if(x.r.errs.length) stats.unresolved++; });
2099
- // Part A: 未登録画像(pendingImage)は除外せず「新規アップロード対象」として保持。真エラー(errs)のみ除外。
2100
- const keptAds=rated.filter(x=>!x.r.errs.length).map(x=>{
2101
- const ad={
2102
- adName:x.r.adName,
2103
- status:agStatus,
2104
- mediaType:'IMAGE', // レスポンシブ画像広告 (backend ydaAdSchema IMAGE)
2105
- headlines:[String(x.r.titleText)],
2106
- descriptions:[String(x.r.descText)],
2107
- principal:p.principal||'', // E-1: 主体者はアカウントJSON由来 (st.project.principal)
2108
- buttonText:ctaToEnum(p.cta), // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
2109
- finalUrl:x.r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
2110
- };
2111
- if(x.r.pendingImage){
2112
- // 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する (imagePaths は agent/CLI が補完)
2113
- ad.mediaIds=[]; ad.imageNo=x.r.imageNo; ad.pendingImageNo=x.r.imageNo; ad.imagePaths=[];
2114
- stats.pendingImages++; if(stats.pendingImageNos.indexOf(x.r.imageNo)<0) stats.pendingImageNos.push(x.r.imageNo);
2115
- } else {
2116
- ad.mediaIds=[String(x.r.mediaId)];
2117
- }
2118
- return ad;
3316
+ const cp=P.buildPlan.campaign(c, ctx);
3317
+ cp[CK]=c.adGroups.map(g=>{
3318
+ /* 行レベル検証NG (Meta: 日予算未入力 / COST_CAP で入札上限0) は行ごと除外して計上 */
3319
+ if(P.validateRow){
3320
+ const v=P.validateRow(g, 'adGroup', ctx)||{};
3321
+ if((v.errCodes||[]).length){ stats.rowErrors++; return null; }
3322
+ }
3323
+ const ag=P.buildPlan.adGroup(g, c, ctx);
3324
+ const kept=[];
3325
+ g.ads.forEach(a=>{
3326
+ const r=resolveAd(a,g);
3327
+ // Part A: 未登録画像(pendingImage)は除外せず「新規アップロード対象」として保持。真エラー(errs)のみ除外。
3328
+ if(r.errs.length){ stats.unresolved++; return; }
3329
+ kept.push(P.buildPlan.ad(a, r, g, ctx));
3330
+ if(r.pendingImage){ stats.pendingImages++;
3331
+ if(stats.pendingImageNos.indexOf(r.imageNo)<0) stats.pendingImageNos.push(r.imageNo); }
2119
3332
  });
2120
- stats.exported+=keptAds.length;
2121
- // 地域/オーディエンス/サーチKW列はAPIのID解決が必要なためJSON未反映 (出力ありのグループのみ警告計上)
2122
- if(keptAds.length && ((g.tg_region&&tgList(g.tg_region).length)||(g.tg_audience&&tgList(g.tg_audience).length)||String(g.tg_searchKw||'').trim()!=='')) stats.tgDropped++;
2123
- const ag={
2124
- name:g.name,
2125
- status:agStatus,
2126
- smartTargeting:false, // GUI未対応 (将来ターゲティング列に追加)
2127
- devices:mapDevices(g.tg_device),
2128
- ages:tgList(g.tg_age).map(x=>TG_ENUM.age[x]).filter(Boolean),
2129
- genders:tgList(g.tg_gender).map(x=>TG_ENUM.gender[x]).filter(Boolean),
2130
- days:dh.days,
2131
- placementsInclude:[], placementsExclude:[],
2132
- audiencesInclude:[], audiencesExclude:[],
2133
- ads:keptAds
2134
- };
2135
- if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
2136
- const aid=resolveAdGroupIdOf(g); if(aid) ag.adGroupId=aid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
3333
+ ag[GK]=kept;
3334
+ stats.exported+=kept.length;
3335
+ // 未反映列 (YDAは地域/オーディエンス/サーチKW) はAPIのID解決が必要 → 出力ありのグループのみ警告計上
3336
+ if(kept.length && notSub.some(f=>String(g[f]||'').trim()!=='')) stats.tgDropped++;
2137
3337
  return ag;
2138
- }).filter(ag=>{ if(ag.ads.length===0){ stats.emptyGroups++; return false; } return true; }); // B9: 空広告グループを除外
2139
- const cp={
2140
- name:c.name,
2141
- goal:p.goal||'CONVERSION',
2142
- status:toStatus(p.deliveryStatus), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
2143
- startDate:p.startDate||today(),
2144
- budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
2145
- biddingStrategy,
2146
- adGroups
2147
- };
2148
- if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
2149
- else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
2150
- const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
3338
+ }).filter(ag=>{ if(!ag) return false; if(ag[GK].length===0){ stats.emptyGroups++; return false; } return true; }); // B9: 空広告グループを除外 (null = 行検証NG)
2151
3339
  return cp;
2152
- }).filter(cp=>{ if(cp.adGroups.length===0){ stats.emptyCampaigns++; return false; } return true; }); // B9: 空キャンペーンを除外
2153
- const plan={planVersion:1, platform:'yda', accountId:String(p.accountId||'').trim(), campaigns};
3340
+ }).filter(cp=>{ if(cp[CK].length===0){ stats.emptyCampaigns++; return false; } return true; }); // B9: 空キャンペーンを除外
3341
+ const plan={planVersion:P.output.planVersion, platform:P.id, accountId:String(p.accountId||'').trim(), campaigns};
2154
3342
  return {plan, stats};
2155
3343
  }
2156
3344
  function buildSubmitPlan(){ return buildSubmitPlanWithStats().plan; }
@@ -2176,57 +3364,7 @@ function showPromptModal(opts){
2176
3364
  初回セットアップ(アカウント取込)・戦略壁打ちの
2177
3365
  プロンプト例をコピーボタン付きで提示する。
2178
3366
  ============================================================ */
2179
- /* 戦略壁打ちを始めるためのプロンプト例(アピール用の即コピー素材) */
2180
- const HELP_STRATEGY_PROMPT=[
2181
- 'YDA(Yahoo!ディスプレイ広告)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。',
2182
- '',
2183
- '# 前提',
2184
- '- アカウント: <アカウント名(日本語OK)>',
2185
- '- 商材 / LP: <商材名・URL>',
2186
- '- 目的: <コンバージョン獲得 / 認知 など>',
2187
- '- 予算・期間: <月予算 / 開始日>',
2188
- '- 訴求したいポイント: <強み・オファー・ターゲット層>',
2189
- '',
2190
- '# お願い',
2191
- '1. 上記をもとにターゲティング方針・キャンペーン/広告グループ構成・訴求軸を提案してください。',
2192
- '2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン.json」に書き出して(上書きして)ください。',
2193
- ' - 保存先: .claude/skills/ad-ops/サンプルプラン.json',
2194
- ' - フォーマット: {"schemaVersion":3, "accountId":"<対象アカウントID>", "campaigns":[ ... ]}',
2195
- ' - campaigns には キャンペーン → 広告グループ(paramCode) → 広告 の構成を入れてください。',
2196
- '3. 書き出したら、プランナーを開き直す(またはリロードする)と サンプルプラン.json の内容が自動で表示されます。'
2197
- ].join('\n');
2198
- /* 初回セットアップ: 対象アカウントの参照データ(画像・見出し・既存CP/AG)をCLIで取り込むプロンプト例 */
2199
- const HELP_IMPORT_PROMPT=[
2200
- 'YDA入稿プランナー用に、アカウント「<アカウント名(日本語OK)>」の参照データJSONを生成してください。',
2201
- '',
2202
- '手順:',
2203
- '1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
2204
- ' webgram list --platform yda --resource accounts',
2205
- ' webgram list --platform yda --resource media --account-id <ID>',
2206
- ' webgram list --platform yda --resource campaigns --account-id <ID>',
2207
- '2. 取得結果を下記スキーマに整形して アカウント/<アカウント名>.json として書き出す:',
2208
- ' - media は 画像番号 → {card, sq}(mediaId)',
2209
- ' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
2210
- ' - campaigns/adGroups は campaignId/adGroupId をキーに',
2211
- '',
2212
- 'スキーマ:',
2213
- '{',
2214
- ' "accountJsonVersion": 1,',
2215
- ' "accountId": "<ID>",',
2216
- ' "name": "<アカウント名(日本語OK)>",',
2217
- ' "principal": "<主体者表記(例: ○○製薬)>",',
2218
- ' "refs": {',
2219
- ' "titles": {"1": "見出し文言"},',
2220
- ' "descriptions": {"1": "説明文文言"},',
2221
- ' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
2222
- ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
2223
- ' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
2224
- ' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
2225
- ' }',
2226
- '}',
2227
- '',
2228
- '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。'
2229
- ].join('\n');
3367
+ /* S7: 使い方モーダルのプロンプト例は pf().prompts (媒体スキーマ) に集約 */
2230
3368
  function openHelpModal(){
2231
3369
  const root=document.getElementById('modalRoot');
2232
3370
  const close=()=>root.innerHTML='';
@@ -2241,7 +3379,7 @@ function openHelpModal(){
2241
3379
  ['右上から保存','編集が終わったら、<b>右上の「バージョン保存」</b>を押してください。常に新バージョン(v1, v2…)として保存されます。'],
2242
3380
  ['表示されたプロンプトをAIに貼る','保存すると<b>次のアクション用のプロンプトが表示</b>されます。そのプロンプトをコピーしてAI(Claude Code)に貼ると、入稿(dry-run→本実行)まで進められます。']
2243
3381
  ];
2244
- root.innerHTML='<div class="overlay"><div class="modal help"><h3>YDA入稿プランナーの使い方</h3>'
3382
+ root.innerHTML='<div class="overlay"><div class="modal help"><h3>'+esc(pf().brandTitle)+'の使い方</h3>'
2245
3383
  +'<div class="msub">戦略を壁打ち → プランナーで確認・編集 → 保存 → 表示されたプロンプトで入稿、という流れです。</div>'
2246
3384
  +'<div class="mbody">'
2247
3385
  +'<div class="callout"><b>全体像</b>:AIで作れる部分はAIに作らせ(戦略壁打ち→JSON)、人が確定すべきクリエイティブ選定・確認をこのプランナーで楽に行います。最後に表示されるプロンプトでそのまま入稿できます。</div>'
@@ -2250,11 +3388,11 @@ function openHelpModal(){
2250
3388
  +'</ol>'
2251
3389
  +'<div class="mfield"><label>🚀 まず最初に:戦略壁打ちプロンプト例</label>'
2252
3390
  +'<div class="hint" style="margin-bottom:6px">これをコピーしてClaude Codeに貼り、&lt;&gt;の部分を埋めて壁打ちを始めてください。</div>'
2253
- +promptCard('helpPromptStrategy','戦略壁打ち用プロンプト',HELP_STRATEGY_PROMPT)
3391
+ +promptCard('helpPromptStrategy','戦略壁打ち用プロンプト',pf().prompts.strategy())
2254
3392
  +'</div>'
2255
3393
  +'<div class="mfield"><label>🔧 初回セットアップ:アカウント取込プロンプト例</label>'
2256
3394
  +'<div class="hint" style="margin-bottom:6px">初めて使うアカウントは、まずこのプロンプトで登録画像・見出し・既存キャンペーン等を取り込みます(アカウント名は日本語でOK)。</div>'
2257
- +promptCard('helpPromptImport','アカウント取込プロンプト',HELP_IMPORT_PROMPT)
3395
+ +promptCard('helpPromptImport','アカウント取込プロンプト',pf().prompts.importAccount({mode:'help'}))
2258
3396
  +'</div>'
2259
3397
  +'</div><div class="mfoot"><button class="act primary" data-mo="cancel">閉じて始める</button></div></div></div>';
2260
3398
  root.querySelector('[data-mo="cancel"]').addEventListener('click',close);
@@ -2272,11 +3410,11 @@ function openHelpModal(){
2272
3410
  async function updateSamplePlan(){
2273
3411
  const data={schemaVersion:SCHEMA_VERSION, project:st.project, campaigns:st.campaigns};
2274
3412
  const json=JSON.stringify(data,null,1);
2275
- if(!samplePlanFileHandle){ try{ const h=await idbGet('samplePlanFile'); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted') samplePlanFileHandle=h; else samplePlanFileHandle=h; } }catch(e){} }
3413
+ 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){} }
2276
3414
  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; } }
2277
3415
  if(!samplePlanFileHandle){
2278
3416
  if(!fsOpenSupported){ download('サンプルプラン.json',json,'application/json'); toast('サンプルプラン.json をダウンロードしました(同名で差し替えてください)'); return; }
2279
- try{ const [h]=await window.showOpenFilePicker({id:'yda-sample-plan', types:[{description:'JSON', accept:{'application/json':['.json']}}]}); samplePlanFileHandle=h; await idbSet('samplePlanFile',h); }
3417
+ try{ const [h]=await window.showOpenFilePicker({id:'ad-planner-sample-plan-'+curPf, types:[{description:'JSON', accept:{'application/json':['.json']}}]}); samplePlanFileHandle=h; await idbSet(idbKeySamplePlan(),h); }
2280
3418
  catch(e){ if(e && e.name==='AbortError') return; toast('サンプルプラン.json を選べませんでした'); return; }
2281
3419
  }
2282
3420
  const ok=await writeToHandle(samplePlanFileHandle, json);
@@ -2284,6 +3422,7 @@ async function updateSamplePlan(){
2284
3422
  else toast('サンプルプランの更新に失敗しました');
2285
3423
  }
2286
3424
  async function exportSubmitJson(){
3425
+ const P=pf();
2287
3426
  const {plan,stats}=buildSubmitPlanWithStats();
2288
3427
  if(!plan.accountId){
2289
3428
  toast('アカウントを選択してください(上部のアカウントセレクト)');
@@ -2291,16 +3430,26 @@ async function exportSubmitJson(){
2291
3430
  const el=document.getElementById('accSelect'); if(el) el.focus();
2292
3431
  return;
2293
3432
  }
3433
+ /* S10: 媒体スキーマの accountId 書式ガード (Meta は act_ 付き必須) */
3434
+ const idPat=P.account&&P.account.idPattern;
3435
+ if(idPat && !idPat.test(String(plan.accountId))){
3436
+ toast('アカウントIDの形式が不正です('+P.account.idHint+')'); return;
3437
+ }
3438
+ /* S10: 媒体固有のプロジェクト単位ガード (Meta の pageId / pixelId 必須) */
3439
+ if(P.validateProject){
3440
+ const perrs=P.validateProject(st.project)||[];
3441
+ if(perrs.length){ toast(perrs[0].msg); return; }
3442
+ }
2294
3443
  const n=stats.exported;
2295
3444
  if(n===0){ toast('入稿対象の広告が0件です(空入稿を防止しました)'); return; }
2296
3445
  const json=JSON.stringify(plan,null,2);
2297
3446
  // 完成したプランは「新規ファイル」として保存する(上書きしない・毎回タイムスタンプ付き)。
2298
3447
  // 接続済み入稿計画フォルダがあればそこへ、無ければダウンロード。Finderは出さない。
2299
- const fileName=(st.project.name||'plan')+'_入稿計画_'+tsCompact()+'.json';
3448
+ const fileName=pf().output.fileNamePrefix(st.project)+tsCompact()+'.json';
2300
3449
  const wrote=await writePlanFile(fileName,json);
2301
3450
  if(!wrote) download(fileName,json,'application/json');
2302
3451
  _dirty=false; // 保存(入稿計画書き出し)で未保存フラグをクリア
2303
- const savedRel = '.claude/skills/ad-ops/入稿計画/'+fileName; // Skill同梱の入稿計画フォルダ内の想定パス
3452
+ const savedRel = pf().output.planDir+'/'+fileName; // Skill同梱の入稿計画フォルダ内の想定パス
2304
3453
  const savedPath=(wrote&&dirHandle?dirHandle.name+'/':'')+fileName;
2305
3454
  const savedLabel = wrote?'入稿計画フォルダに新規保存':'ダウンロード(フォルダ未接続)';
2306
3455
  // 除外内訳
@@ -2312,25 +3461,11 @@ async function exportSubmitJson(){
2312
3461
  info+='<div class="mfield"><label>保存先 / ファイル名</label><div class="mono" style="font-size:12px; word-break:break-all">'+esc(savedPath||fileName)+'</div></div>';
2313
3462
  info+='<div class="mfield"><label>除外件数の内訳</label><div style="font-size:12.5px">'+(exc.length?esc(exc.join(' / ')):'なし')+'</div></div>';
2314
3463
  if(stats.pendingImages) info+='<div class="callout amber">🆕 未登録画像 <b>'+stats.pendingImages+'</b> 件(管理番号: '+esc(stats.pendingImageNos.join(', '))+')。これらはアカウントのメディアライブラリに未登録のため、入稿前にローカル画像をアップロードして新規登録が必要です(下記プロンプト参照)。</div>';
2315
- if(stats.tgDropped) info+='<div class="callout amber">⚠ 地域/オーディエンス/サーチKW を設定した広告グループが '+stats.tgDropped+' 件ありますが、これらはAPIのID解決が必要なため入稿JSONには反映されていません(入稿後に管理画面で設定してください)。</div>';
3464
+ if(stats.tgDropped) info+='<div class="callout amber">⚠ '+esc(pf().labels.tgDroppedWarn)+' を設定した広告グループが '+stats.tgDropped+' 件ありますが、これらはAPIのID解決が必要なため入稿JSONには反映されていません(入稿後に管理画面で設定してください)。</div>';
2316
3465
  // Claudeに貼るプロンプト: 保存したJSONを Skillの入稿計画/ に入れて submit-plan で入稿を試みる
2317
- const promptLines=[
2318
- '入稿を試みてください。手順:',
2319
- '1) 入稿計画JSON('+fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+savedRel+')。',
2320
- ' ・入稿計画フォルダ(.claude/skills/ad-ops/入稿計画/)に無ければ、ダウンロードフォルダ(~/Downloads/'+fileName+')を探して上記パスへ移動する。',
2321
- (wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。')
2322
- ];
2323
- if(stats.pendingImages){
2324
- promptLines.push('2) 【未登録画像の新規登録】入稿計画内に mediaIds が空で pendingImageNo が付いた広告があります(管理番号: '+stats.pendingImageNos.join(', ')+')。');
2325
- promptLines.push(' これらは各管理番号に対応するローカル画像ファイルのパスを、その広告の "imagePaths":[] に設定してください(submit-plan 実行時に自動で /yda/upload-image → mediaId 解決されます)。');
2326
- promptLines.push('3) 置いたパスで dry-run 入稿を実行:');
2327
- } else {
2328
- promptLines.push('2) 置いたパスで dry-run 入稿を実行:');
2329
- }
2330
- promptLines.push(' webgram submit-plan --plan-file "'+savedRel+'"');
2331
- promptLines.push((stats.pendingImages?'4':'3')+') dry-run が全件OKなら本実行:');
2332
- promptLines.push(' webgram submit-plan --plan-file "'+savedRel+'" --no-dry-run --yes');
2333
- const prompt=promptLines.join('\n');
3466
+ // S7: 文言・コマンドは pf().prompts.submitHelp / pf().output.submitCommand (媒体スキーマ)
3467
+ const prompt=pf().prompts.submitHelp({fileName, savedRel, wrote,
3468
+ pendingImages:stats.pendingImages, pendingImageNos:stats.pendingImageNos});
2334
3469
  showPromptModal({title:'入稿計画JSONを保存しました', sub:'このプロンプトをClaude Codeに貼って入稿してください', infoHtml:info, prompt, hint:'submit-plan は1キャンペーンずつ入稿・進捗書き戻しで再開可(submitted済みはスキップ)'});
2335
3470
  }
2336
3471
 
@@ -2339,6 +3474,12 @@ async function exportSubmitJson(){
2339
3474
  function applyLoadedJson(j){
2340
3475
  if(!j||!j.campaigns) throw new Error('読み込み失敗: 不正なJSON');
2341
3476
  if(j.schemaVersion!==SCHEMA_VERSION) throw new Error('スキーマ形式が異なるため読み込めません(planner v'+SCHEMA_VERSION+' のJSONのみ対応)');
3477
+ /* S9: platform 検証。meta.platform / platform 未記載は 'yda' (旧JSONの後方互換)。別媒体なら媒体ごと切り替える。 */
3478
+ const declared=String((j.meta&&j.meta.platform)||j.platform||'yda');
3479
+ if(declared!==curPf){
3480
+ if(!PLATFORMS[declared]) throw new Error('媒体が異なるため読み込めません(platform: '+declared+' は未対応)');
3481
+ window.switchPlatform(declared);
3482
+ }
2342
3483
  snapshot();
2343
3484
  st.project=Object.assign(defaultProject(),j.project);
2344
3485
  st.campaigns=j.campaigns;
@@ -2353,7 +3494,7 @@ function loadJson(file){ planFileHandle=null; const rd=new FileReader(); rd.onlo
2353
3494
  async function openPlanFile(){
2354
3495
  if(!fsOpenSupported){ document.getElementById('fileInput').click(); return; }
2355
3496
  try{
2356
- const [h]=await window.showOpenFilePicker({id:'yda-planner-plans', types:[{description:'JSON', accept:{'application/json':['.json']}}]});
3497
+ const [h]=await window.showOpenFilePicker({id:'ad-planner-plans', types:[{description:'JSON', accept:{'application/json':['.json']}}]});
2357
3498
  const f=await h.getFile(); applyLoadedJson(JSON.parse(await f.text())); planFileHandle=h; updateSubmitJsonBtn();
2358
3499
  toast('JSONを読み込みました(このファイルを「入稿JSON書き出し」で上書き更新します)');
2359
3500
  }catch(e){ if(e && e.name!=='AbortError') toast(e instanceof SyntaxError ? '読み込み失敗: 不正なJSONです' : ('読み込み失敗: '+(e&&e.message||''))); }
@@ -2366,8 +3507,22 @@ document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.targe
2366
3507
  document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(); renderSheet(); });
2367
3508
  /* プランビュー: シート / ツリー モード切替 (1階層下) */
2368
3509
  document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-m]'); if(!b) return; clearSel(); closeDrawer(); planMode=b.dataset.m; document.querySelectorAll('#modeSeg button').forEach(x=>x.classList.toggle('active',x===b)); render(); });
2369
- /* 媒体要件ビュー: YDA / Meta タブ切替 */
2370
- document.getElementById('mediaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-mt]'); if(!b) return; document.querySelectorAll('#mediaTabs button').forEach(x=>x.classList.toggle('active',x===b)); document.getElementById('mt-yda').style.display = b.dataset.mt==='yda'?'':'none'; document.getElementById('mt-meta').style.display = b.dataset.mt==='meta'?'':'none'; });
3510
+ /* S10: ヘッダーの媒体セレクタ (常設)。登録済み媒体なら媒体を切り替える */
3511
+ document.getElementById('pfSeg').addEventListener('click',e=>{
3512
+ const b=e.target.closest('button[data-pf]'); if(!b) return;
3513
+ const id=b.dataset.pf;
3514
+ if(PLATFORMS[id]){ if(id!==curPf) window.switchPlatform(id); else renderPlatformTabs(); }
3515
+ else toast('この媒体はまだ選択できません(媒体スキーマ未登録・媒体要件の閲覧のみ)');
3516
+ });
3517
+ /* 媒体セレクタ: 媒体要件ドキュメントのタブ切替 + 登録済み媒体なら媒体そのものを切り替える (S9) */
3518
+ document.getElementById('mediaTabs').addEventListener('click',e=>{
3519
+ const b=e.target.closest('button[data-mt]'); if(!b) return;
3520
+ document.querySelectorAll('#mediaTabs button').forEach(x=>x.classList.toggle('active',x===b));
3521
+ showMediaDoc(b.dataset.mt);
3522
+ const id=b.dataset.pf; if(!id) return;
3523
+ if(PLATFORMS[id]){ if(id!==curPf) window.switchPlatform(id); }
3524
+ else toast('この媒体はまだ選択できません(媒体スキーマ未登録・媒体要件の閲覧のみ)');
3525
+ });
2371
3526
  /* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
2372
3527
  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'; }); });
2373
3528
  /* 案件テンプレ: チップ直接編集 */
@@ -2419,7 +3574,9 @@ sheetHostEl.addEventListener('mousedown',e=>{
2419
3574
  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; }
2420
3575
  const cell=cellFromEvent(e); if(!cell) return; const r=+cell.dataset.r, c=+cell.dataset.c;
2421
3576
  pendingDrawer=null; dragMoved=false;
2422
- if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(sheetTab==='ads' && [1,2,3].indexOf(c)>=0) pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(sheetTab==='groups' && cell.dataset.gf && TG_OPTIONS[cell.dataset.gf]) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
3577
+ // S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
3578
+ const col=colDefs()[c];
3579
+ 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}; }
2423
3580
  applySelection();
2424
3581
  });
2425
3582
  sheetHostEl.addEventListener('mouseover',e=>{
@@ -2469,32 +3626,23 @@ document.addEventListener('keydown',e=>{
2469
3626
  }
2470
3627
  });
2471
3628
 
3629
+ migrateLegacyLocalStorage(); // S9: 旧 yda_planner_* キー → ad_planner_* へ1回だけコピー
2472
3630
  buildDatalists();
2473
3631
  render();
3632
+ renderPlatformTabs(); // S9: 媒体セレクタ (未登録媒体は disabled 表示)
2474
3633
  renderAccountBar(); // アカウントセレクト初期化 (同梱デフォルト)
2475
3634
  restoreFolder(); // 入稿計画フォルダのhandleを復元 (権限失効時は再接続待ちに)
2476
3635
  restoreAccountFolder(); // B-1: アカウントフォルダのhandleを復元 + セレクト再列挙
2477
3636
  /* 離脱時: 未保存の編集があればブラウザの確認ダイアログを出す */
2478
3637
  window.addEventListener('beforeunload', function(e){ if(_dirty){ e.preventDefault(); e.returnValue='未保存の変更があります。「完成 → 保存して入稿」で保存してから離れてください。'; return e.returnValue; } });
2479
- (async()=>{ try{ const h=await idbGet('samplePlanFile'); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted'||perm==='prompt') samplePlanFileHandle=h; } }catch(e){} })(); // サンプルプラン更新用ハンドル復元
3638
+ (async()=>{ try{ const h=await idbGet(idbKeySamplePlan()); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted'||perm==='prompt') samplePlanFileHandle=h; } }catch(e){} })(); // サンプルプラン更新用ハンドル復元
2480
3639
  /* 案A: 起動時に外部JSON(同梱デフォルトアカウント + サンプルプラン)を読み込む。localStorage無しの初回のみ作り直して再描画。 */
2481
3640
  (async function bootstrapBundled(){
2482
3641
  try{
2483
- // 同梱デフォルトアカウント = アカウント/index.json の先頭エントリ(固有名詞をハードコードしない)
2484
- let firstName='';
2485
- try{ const idx=await fetchJson('アカウント/index.json'); const arr=idx?(Array.isArray(idx)?idx:(idx.accounts||[])):[]; firstName=String((arr||[])[0]||''); }catch(e){}
2486
- if(firstName){
2487
- const acc=await fetchJson('アカウント/'+encodeURIComponent(firstName)+'.json');
2488
- if(acc){
2489
- BUNDLED_ACCOUNT_NAME=firstName;
2490
- if(acc.accountId) BUNDLED_ACCOUNT_ID=String(acc.accountId);
2491
- if(acc.refs) bundledRefs=normalizeRefs(acc.refs);
2492
- if(acc.principal) bundledPrincipal=acc.principal;
2493
- if(acc.projectName) bundledProjectName=acc.projectName;
2494
- if(acc.baseArticleUrl) bundledBaseUrl=acc.baseArticleUrl;
2495
- }
2496
- }
2497
- const plan=await fetchJson('サンプルプラン.json'); if(plan) samplePlan=plan;
3642
+ /* S10: 登録済み全媒体の同梱データ (アカウント/<媒体>/index.json 先頭 + 媒体別サンプルプラン) を先読みする。
3643
+ 媒体切替は同期処理にしたいので、切替時に fetch しない (applyBundle でキャッシュを差し替えるだけ)。 */
3644
+ await Promise.all(Object.keys(PLATFORMS).map(id=>loadBundle(id)));
3645
+ applyBundle(curPf);
2498
3646
  await fetchAccountList();
2499
3647
  }catch(e){}
2500
3648
  // 開いたときは必ずサンプルプランを読み込む(localStorage廃止)