addashboard-cli 0.5.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ads.js +34 -5
- package/dist/commands/ads.js.map +1 -1
- package/dist/commands/submission.js +181 -18
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/apiClient.js +4 -0
- package/dist/lib/apiClient.js.map +1 -1
- package/dist/lib/localPath.js +58 -0
- package/dist/lib/localPath.js.map +1 -0
- package/package.json +2 -2
- package/skills/ad-ops/SKILL.md +284 -28
- package/skills/ad-ops/ad-planner.html +783 -175
- package/skills/ad-ops/scripts/adops.sh +243 -0
- package/skills/ad-ops/scripts/plan_lint.py +357 -0
- package/skills/ad-ops/scripts/refs_import.py +348 -0
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/yda/sample_account.json +14 -3
|
@@ -297,13 +297,9 @@
|
|
|
297
297
|
<body>
|
|
298
298
|
<header class="bar">
|
|
299
299
|
<div class="brand"><span class="logo" id="brandLogo">YDA入稿プランナー</span><span class="pjname" id="pjLabel">—</span></div>
|
|
300
|
-
<!--
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
<button data-pf="meta">Meta</button>
|
|
304
|
-
</div>
|
|
305
|
-
<!-- F-3: アカウント切替はヘッダーのプルダウンに一本化 -->
|
|
306
|
-
<span class="acchdr" title="アカウント切替(+新規追加/フォルダ接続/編集もここから)"><select class="chipin" id="accSelect" style="min-width:12ch"></select></span>
|
|
300
|
+
<!-- S11: 「案件 × 媒体」を1つで選ぶ統合プルダウン。option value = "<platform>::<アカウント名>"。
|
|
301
|
+
旧 #pfSeg(媒体セレクタ)/ #mediaTabs(媒体要件タブ)は廃止し、媒体切替もここに一本化した (2026-08-05) -->
|
|
302
|
+
<span class="acchdr" title="案件×媒体を切り替える(+新規追加/編集もここから)"><select class="chipin" id="accSelect" style="min-width:18ch"></select></span>
|
|
307
303
|
<div class="seg" id="viewSeg">
|
|
308
304
|
<button data-v="req">媒体要件</button>
|
|
309
305
|
<button data-v="sheet" class="active">プラン</button>
|
|
@@ -312,6 +308,8 @@
|
|
|
312
308
|
</div>
|
|
313
309
|
<span class="vbadge ok" id="vbadge" title="バリデーション状況">✓ 0件</span>
|
|
314
310
|
<div class="spacer"></div>
|
|
311
|
+
<!-- C9: 管理画面リンクは consoleLink() が媒体スキーマの consoleUrl から組み立てる -->
|
|
312
|
+
<button class="act ghost" id="btnConsole" title="選択中アカウントの管理画面(キャンペーン一覧)を開く">管理画面 ↗</button>
|
|
315
313
|
<button class="act ghost" id="btnHelp" title="このプランナーの使い方">❓ 使い方</button>
|
|
316
314
|
<button class="act ghost" id="btnUndo" title="元に戻す (Ctrl/Cmd+Z)">↶ 戻す</button>
|
|
317
315
|
<button class="act primary" id="btnSubmitJson">✅ 完成 → 保存して入稿</button>
|
|
@@ -324,11 +322,8 @@
|
|
|
324
322
|
<!-- ============ 媒体要件ビュー ============ -->
|
|
325
323
|
<section class="view" id="view-req">
|
|
326
324
|
<div class="doc">
|
|
327
|
-
<!--
|
|
328
|
-
|
|
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>
|
|
331
|
-
</div>
|
|
325
|
+
<!-- S11: 旧・媒体要件タブ (#mediaTabs) は廃止。媒体はヘッダーの統合プルダウンで切り替え、
|
|
326
|
+
このビューは選択中の媒体のドキュメントだけを表示する (showMediaDoc(curPf)) -->
|
|
332
327
|
|
|
333
328
|
<!-- ======== YDA タブ ======== -->
|
|
334
329
|
<div id="mt-yda">
|
|
@@ -462,10 +457,10 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
462
457
|
<details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
|
|
463
458
|
<div class="body">
|
|
464
459
|
<table class="g"><thead><tr><th>場所</th><th>入力項目</th><th>入稿JSONへの反映</th></tr></thead><tbody>
|
|
465
|
-
<tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b
|
|
460
|
+
<tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b>配信開始日</b> / CTA / 配信 / タイプ / 入札既定(tCPA・目標CPA) / 計測 / 主体者 / 表示URL / 記事URL / 予算(新規・ASC)</td><td>accountId・goal・startDate・入札既定・予算既定・finalUrlベース</td></tr>
|
|
466
461
|
<tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 検証変数 / 入稿リンク番号 / 戦略メモ</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
|
|
467
|
-
<tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / <b>ターゲティング列</b
|
|
468
|
-
<tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 記事slug / 手入稿フラグ</td><td>ads[].adName・mediaIds・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
|
|
462
|
+
<tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / スマートTG / <b>ターゲティング列</b>(デバイス・性別・年齢・地域・曜日時間帯・<b>オーディエンス(含む/除外)</b>・<b>配置(含む/除外)</b>・サーチKW)</td><td>name・status・smartTargeting・devices/ages/genders/days(+startHour/endHour)・<b>audiencesInclude/Exclude・placementsInclude/Exclude(実IDで送信)</b>。<b>未反映は 地域・サーチKW のみ</b></td></tr>
|
|
463
|
+
<tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 記事slug / 記事URL(上書き) / 最終リンク先URL / タイトル本文 / 説明文本文 / 画像パス / 広告名 / 手入稿フラグ</td><td>ads[].adName・mediaIds・imagePaths・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
|
|
469
464
|
</tbody></table>
|
|
470
465
|
</div></details>
|
|
471
466
|
<details class="sec" open><summary><span class="num">2</span><span class="stitle">入稿JSON — 構造と入力項目・制約(パラメータ)</span><span class="chev"></span></summary>
|
|
@@ -481,19 +476,20 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
481
476
|
<table class="g"><thead><tr><th>項目</th><th>画面上の出どころ</th><th>変換・制約</th></tr></thead><tbody>
|
|
482
477
|
<tr><td class="mono">accountId</td><td><b>案件設定「アカウントID」</b></td><td>必須。未入力だと書き出し中止</td></tr>
|
|
483
478
|
<tr><td class="mono">goal</td><td><b>案件設定「目的」</b></td><td><code>CONVERSION</code> / <code>CLICK</code> / <code>BRAND_AWARENESS</code> / <code>VIDEO_VIEW</code> / <code>VIEWABLE_IMPRESSION</code></td></tr>
|
|
484
|
-
<tr><td class="mono">startDate</td><td><b
|
|
479
|
+
<tr><td class="mono">startDate</td><td><b>案件設定「配信開始日」</b></td><td>YYYY-MM-DD。空欄=書き出し日</td></tr>
|
|
485
480
|
<tr><td class="mono">status</td><td>キャンペーン行/広告グループ行の「配信設定」</td><td><code>オン→ACTIVE</code> / <code>オフ→PAUSED</code>(既定オフ=誤配信防止)</td></tr>
|
|
486
481
|
<tr><td class="mono">budget</td><td>キャンペーン行「1日の予算」</td><td>未入力時はモード別既定(新規12,000/ASC50,000)</td></tr>
|
|
487
482
|
<tr><td class="mono">biddingStrategy</td><td>キャンペーン行「入札戦略」+ 目標CV単価</td><td><code>tCPA → MAXIMIZE_CONVERSIONS + targetCpa</code>(tCPA値はキャンペーン行→案件既定8,500の順)/ <code>最大配信 → MAXIMIZE_CONVERSIONS</code>(目標なし <span class="pill amber">要実機確認</span>)</td></tr>
|
|
488
483
|
<tr><td class="mono">devices</td><td>広告グループ行「デバイス」</td><td><code>スマートフォン→SMARTPHONE</code> / <code>PC→DESKTOP</code> / <code>タブレット→TABLET</code>。全てのデバイス/空=全配信(<code>[]</code>)</td></tr>
|
|
489
484
|
<tr><td class="mono">ages / genders</td><td>広告グループ行「年齢」「性別」</td><td><code>20-24歳→GT_RANGE20_24</code> 等 / <code>男性→ST_MALE</code>・<code>女性→ST_FEMALE</code>・<code>不明→ST_UNKNOWN</code>。空=全配信</td></tr>
|
|
490
485
|
<tr><td class="mono">days / startHour / endHour</td><td>広告グループ行「曜日・時間帯」</td><td><code>月→MONDAY</code> 等。<code>平日</code>=月〜金、<code>土日祝</code>=土日 に展開。時間帯(午前(6-12時)等)は <code>startHour/endHour</code> に変換(複数選択時は最初の1つ)</td></tr>
|
|
491
|
-
<tr><td class="mono">audiences* / placements*</td><td
|
|
492
|
-
<tr><td class="mono">ads[].adName</td><td
|
|
486
|
+
<tr><td class="mono">audiences* / placements*</td><td>広告グループ行「オーディエンス(除外)」「配置(除外)」</td><td><b>参照表(refs.audiences / refs.placements)の実IDで送信される</b>(表示は name)。数値IDでない値(旧・日本語ラベルや手入力の名前)は落として書き出し時に警告。<b>JSONに反映されない列は「地域」「サーチキーワード」だけ</b>(入稿後に管理画面で設定)</td></tr>
|
|
487
|
+
<tr><td class="mono">ads[].adName</td><td>広告行「広告名」(空欄なら<span class="pill">自動</span>)</td><td>空欄時の生成式 <code>{画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode}</code>。手入力するとその名前が使われ、<code>finalUrl</code> の <code>creative=</code> も変わる</td></tr>
|
|
493
488
|
<tr><td class="mono">ads[].mediaIds</td><td><span class="pill">自動</span></td><td>画像番号+形式 → 参照表の mediaId。未解決は入稿対象外</td></tr>
|
|
489
|
+
<tr><td class="mono">ads[].imagePaths</td><td>広告行「画像パス」</td><td><b>参照表に無い画像番号(未登録)のときだけ</b>送る。CLI が入稿前にアップロードして mediaId を解決する。画像番号が解決済みの行に入れても<b>送らない</b>(CLI は追加アップロードするが広告には既存 mediaId が使われるため。差し替えたいときは画像番号を空にする)</td></tr>
|
|
494
490
|
<tr><td class="mono">ads[].headlines</td><td><span class="pill purple">可変</span></td><td>タイトル番号 → 文言。<b>35字以内</b>(API上のアセット制約はタイトル≤20字・説明文≤90字)</td></tr>
|
|
495
491
|
<tr><td class="mono">ads[].descriptions</td><td><span class="pill purple">可変</span></td><td>説明文番号 → 文言</td></tr>
|
|
496
|
-
<tr><td class="mono">ads[].finalUrl</td><td
|
|
492
|
+
<tr><td class="mono">ads[].finalUrl</td><td>広告行「記事slug / 記事URL(上書き) / 最終リンク先URL」</td><td>生成式 <code>(記事URL(上書き) または baseURL + slug) + "?ad=" + paramCode + "&creative=..."</code>(実データ準拠)。案件設定の <b>URLパラメータ を OFF</b> にするとパラメータを付けない。記事URLに既にクエリがある場合は <code>&</code> で連結する</td></tr>
|
|
497
493
|
</tbody></table>
|
|
498
494
|
<div class="callout">プランナーの保存JSON(バージョン保存)はこれとは別物で、<code>schemaVersion:2</code> の全state(meta / project / campaigns / ui / refs / submit)を保持する。<b>meta</b> = バージョンID・作成/更新日時・作成者・対象媒体・名称・メモ。旧v1 JSONも読込時に自動マイグレーションされる。</div>
|
|
499
495
|
</div></details>
|
|
@@ -517,7 +513,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
517
513
|
<li><b>バージョン保存</b>:ヘッダーの「バージョン保存」。保存のたびに<b>常に新バージョン</b>(v1, v2…)として名称・メモ・作成者付きで保存される(上書きなし)。<b>履歴本体はブラウザ内に自動保存</b>されるため、フォルダ設定なしでも一覧・再読込できる。</li>
|
|
518
514
|
<li><b>保存フォルダ接続(初回に1回だけ)</b>:履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
|
|
519
515
|
<li><b>履歴の確認・再利用</b>:履歴ビューで一覧(Ver/日時/媒体/名称)を確認し、「読込」で過去バージョンを復元、「編集」で名称・メモ変更、「削除」で破棄。</li>
|
|
520
|
-
<li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID
|
|
516
|
+
<li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID・目的・配信開始日は案件設定チップ、ターゲティングは広告グループ行の列から自動変換される(<code>*_入稿計画_*.json</code>)。</li>
|
|
521
517
|
<li><b>入稿(dry-run → 本実行)</b>:<code>webgram submit-plan --plan-file <入稿計画></code>。dry-run が全キャンペーンを一括検証 → OKなら <code>--no-dry-run --yes</code> で本実行(1キャンペーンずつ入稿・進捗書き戻しで中断後も再開可能)。実行後は管理画面の一覧でOK確認。</li>
|
|
522
518
|
</ol>
|
|
523
519
|
</div></details>
|
|
@@ -656,13 +652,8 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
656
652
|
<!-- ============ アカウントアセットビュー ============ -->
|
|
657
653
|
<section class="view" id="view-ref">
|
|
658
654
|
<div class="toolbar2">
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
<button data-rt="descriptions">説明文</button>
|
|
662
|
-
<button data-rt="media">登録画像</button>
|
|
663
|
-
<button data-rt="adGroups">広告グループ</button>
|
|
664
|
-
<button data-rt="campaigns">キャンペーン</button>
|
|
665
|
-
</div>
|
|
655
|
+
<!-- C1: 参照データタブは pf().refTables から動的生成する (renderChrome)。静的ボタンは持たない -->
|
|
656
|
+
<div class="seg" id="refTabs"></div>
|
|
666
657
|
<span class="filt">検索 <input id="refSearch" type="text" placeholder="番号・文言・IDで絞り込み" style="width:220px"></span>
|
|
667
658
|
<div class="spacer" style="flex:1"></div>
|
|
668
659
|
<span class="chip" id="refCount">—</span>
|
|
@@ -714,6 +705,8 @@ const KEY_NS = 'ad_planner_';
|
|
|
714
705
|
const LEGACY_KEY_NS = 'yda_planner_';
|
|
715
706
|
const AUTHOR_KEY = KEY_NS + 'author';
|
|
716
707
|
const MIGRATED_KEY = KEY_NS + 'migrated_v1';
|
|
708
|
+
/* ad-ops スキルが書き出す「いま作業中のプラン」の指定ファイル (媒体 + 案件名)。起動時の既定選択に使う */
|
|
709
|
+
const ACTIVE_PLAN_FILE = 'アクティブプラン.json';
|
|
717
710
|
function stateKey(){ return KEY_NS + 'state_v1:' + curPf; }
|
|
718
711
|
function histKey(){ return KEY_NS + 'history_v1:' + curPf; }
|
|
719
712
|
function bodyKey(vid){ return KEY_NS + 'histbody_' + curPf + ':' + vid; }
|
|
@@ -739,6 +732,14 @@ const uid = p => p + '_' + (Date.now().toString(36)) + (_uc++).toString(36);
|
|
|
739
732
|
const esc = s => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
740
733
|
const glen = s => [...String(s==null?'':s)].length;
|
|
741
734
|
const today = () => { const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
|
|
735
|
+
/* 引数を左から順に見て「trim して空文字でない最初の値」を返す。優先順位を1行で書くための道具。 */
|
|
736
|
+
const firstNonEmpty = (...vs) => { for(const v of vs){ const s=String(v==null?'':v).trim(); if(s!=='') return s; } return ''; };
|
|
737
|
+
/* URLのクエリ値に埋める文字列のエスケープ (媒体非依存のURLユーティリティ)。
|
|
738
|
+
C6 で広告名が自由入力になったため、'&' '#' 空白などを生で連結すると
|
|
739
|
+
creative= の値がそこで切れて別パラメータが生える / リンクが壊れる。
|
|
740
|
+
**URL構造を壊す文字だけ**をパーセントエンコードし、日本語はそのまま残す
|
|
741
|
+
(計測ツール上での可読性を優先。1パスなので %→%25 の二重エンコードは起きない)。 */
|
|
742
|
+
const urlq = s => String(s==null?'':s).replace(/[%&#?=+\s]/g, c=>encodeURIComponent(c));
|
|
742
743
|
function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove('show'),2200); }
|
|
743
744
|
|
|
744
745
|
/* ---- エラー / 警告の安定コード (媒体非依存の語彙) ----
|
|
@@ -749,8 +750,13 @@ const EC = {
|
|
|
749
750
|
DESC_UNRESOLVED:'DESC_UNRESOLVED', TITLE_TOO_LONG:'TITLE_TOO_LONG',
|
|
750
751
|
DESC_TOO_LONG:'DESC_TOO_LONG', PARAM_CODE_EMPTY:'PARAM_CODE_EMPTY',
|
|
751
752
|
MEDIA_MISSING:'MEDIA_MISSING' /* = pendingImage (warn) */,
|
|
753
|
+
IMAGE_PATH_MISSING:'IMAGE_PATH_MISSING' /* 未登録画像なのにローカル画像パス未入力 (warn) */,
|
|
754
|
+
IMAGE_PATH_IGNORED:'IMAGE_PATH_IGNORED' /* 画像番号が解決済みなのにローカル画像パスも入力 (warn) */,
|
|
752
755
|
TITLE_LONG_WARN:'TITLE_LONG_WARN', DESC_LONG_WARN:'DESC_LONG_WARN',
|
|
753
|
-
BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING'
|
|
756
|
+
BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING',
|
|
757
|
+
/* 配信日時 (媒体が日時列を持つ場合に使う。空欄はエラーにしない = 未指定は上位へフォールバック) */
|
|
758
|
+
START_TIME_INVALID:'START_TIME_INVALID', END_TIME_INVALID:'END_TIME_INVALID',
|
|
759
|
+
END_BEFORE_START:'END_BEFORE_START'
|
|
754
760
|
};
|
|
755
761
|
|
|
756
762
|
/* ---- refs (VLOOKUP代替) ----
|
|
@@ -770,23 +776,53 @@ let BUNDLED_ACCOUNT_NAME = '';
|
|
|
770
776
|
var PLATFORMS = {};
|
|
771
777
|
var curPf = 'yda';
|
|
772
778
|
function pf(){ return PLATFORMS[curPf]; }
|
|
773
|
-
/* ----
|
|
774
|
-
|
|
779
|
+
/* ---- S11: 媒体切替はヘッダーの統合プルダウン (#accSelect) に一本化 ----
|
|
780
|
+
媒体要件ビューは選択中の媒体のドキュメントだけを表示する。
|
|
775
781
|
媒体を切り替えると state は当該媒体の既定値で作り直す (プランは媒体をまたげない)。 */
|
|
776
782
|
function showMediaDoc(id){ ['yda','meta'].forEach(k=>{ const el=document.getElementById('mt-'+k); if(el) el.style.display = (k===id?'':'none'); }); }
|
|
777
783
|
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
784
|
showMediaDoc(curPf);
|
|
786
785
|
renderChrome();
|
|
787
786
|
}
|
|
788
787
|
/* ---- S10: 媒体で変わる UI 文言 (タイトル / ブランド名 / シートタブ / 参照データタブ) ----
|
|
789
788
|
媒体語彙 (「広告グループ」「登録画像」…) を静的HTMLに残さず、媒体スキーマから流し込む。 */
|
|
789
|
+
/* ---- C9: 管理画面URL は媒体スキーマの consoleUrl テンプレだけが持つ (手で組み立てない) ----
|
|
790
|
+
{mcc} … YDA の loginAccountId。st.project.consoleMcc → 無ければ accountId にフォールバック
|
|
791
|
+
{accountId} … アカウントID (Meta は act_ 付きのまま)
|
|
792
|
+
{accountIdNum} … act_ を剥いた数値部分 (Meta 用)
|
|
793
|
+
{resource} … campaigns | adGroups | ads (媒体別の語は consoleResourceMap が吸収する) */
|
|
794
|
+
function consoleLink(resource){
|
|
795
|
+
const P=pf(); if(!P||!P.consoleUrl) return '';
|
|
796
|
+
const p=st.project||{};
|
|
797
|
+
const accountId=String(p.accountId||'').trim();
|
|
798
|
+
if(!accountId) return '';
|
|
799
|
+
const mcc=String(p.consoleMcc||'').trim();
|
|
800
|
+
/* ⚠ mcc が要るテンプレ(YDA)で accountId にフォールバックしてはいけない。
|
|
801
|
+
YDA の管理画面は #/{MCCのID}/{アカウントID}/... で、MCC 位置にアカウントIDを入れると
|
|
802
|
+
エラーページ(errors/error404)に飛ぶ。しかも「リンクは開くが404」なので原因が分かりにくい。
|
|
803
|
+
mcc が無いときはリンクを出さず、アカウントJSONの mccId 未設定だと明示する。 */
|
|
804
|
+
if(P.consoleUrl.indexOf('{mcc}')>=0 && !mcc) return '';
|
|
805
|
+
const res=(P.consoleResourceMap&&P.consoleResourceMap[resource])||resource||'campaigns';
|
|
806
|
+
return P.consoleUrl
|
|
807
|
+
.replace('{mcc}', mcc)
|
|
808
|
+
.replace('{accountId}', accountId)
|
|
809
|
+
.replace('{accountIdNum}', accountId.replace(/^act_/,''))
|
|
810
|
+
.replace('{resource}', res);
|
|
811
|
+
}
|
|
812
|
+
window.consoleLink=consoleLink;
|
|
813
|
+
function openConsole(resource){
|
|
814
|
+
const url=consoleLink(resource||'campaigns');
|
|
815
|
+
if(!url){
|
|
816
|
+
const P=pf(), p=st.project||{};
|
|
817
|
+
if(P && P.consoleUrl && P.consoleUrl.indexOf('{mcc}')>=0 && String(p.accountId||'').trim() && !String(p.consoleMcc||'').trim()){
|
|
818
|
+
toast('アカウントJSONに mccId が未設定のため管理画面を開けません(mccId にMCCのアカウントIDを設定してください)');
|
|
819
|
+
} else {
|
|
820
|
+
toast('アカウントを選択してください(上部のアカウントセレクト)');
|
|
821
|
+
}
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
try{ window.open(url,'_blank','noopener'); }catch(e){}
|
|
825
|
+
}
|
|
790
826
|
/* 中間階層のラベル (YDA=広告グループ / Meta=広告セット)。UI 文言はここから供給する */
|
|
791
827
|
function agLabel(){ const P=pf(); return (P&&P.levels&&P.levels.adGroup&&P.levels.adGroup.label)||'広告グループ'; }
|
|
792
828
|
function renderChrome(){
|
|
@@ -797,9 +833,17 @@ function renderChrome(){
|
|
|
797
833
|
document.querySelectorAll('#sheetTabs button[data-st]').forEach(b=>{
|
|
798
834
|
const l=stLabel[b.dataset.st]; if(l) b.textContent=l;
|
|
799
835
|
});
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
836
|
+
/* C1: 参照データタブは refTables 宣言から生成 (参照テーブル追加でボタンも自動で増える)。
|
|
837
|
+
現在の refTab が新媒体に無ければ先頭へフォールバックする。 */
|
|
838
|
+
const rt=document.getElementById('refTabs');
|
|
839
|
+
if(rt){
|
|
840
|
+
const keys=Object.keys(P.refTables);
|
|
841
|
+
if(keys.indexOf(refTab)<0) refTab=keys[0]||refTab;
|
|
842
|
+
rt.innerHTML=keys.map(k=>{
|
|
843
|
+
const T=P.refTables[k];
|
|
844
|
+
return '<button data-rt="'+esc(k)+'"'+(k===refTab?' class="active"':'')+'>'+esc(T.countLabel||T.label||k)+'</button>';
|
|
845
|
+
}).join('');
|
|
846
|
+
}
|
|
803
847
|
renderFixedType();
|
|
804
848
|
}
|
|
805
849
|
/* 固定タイプチップ (変更不可) は媒体定義 pf().fixedType から描画する。
|
|
@@ -817,6 +861,15 @@ window.switchPlatform=function(id){
|
|
|
817
861
|
if(!PLATFORMS[id]){ toast('この媒体はまだ選択できません(媒体スキーマ未登録)'); return false; }
|
|
818
862
|
if(id===curPf){ renderPlatformTabs(); return true; }
|
|
819
863
|
curPf=id;
|
|
864
|
+
/* 接続中のアカウントフォルダは媒体別 (idbKeyAccountsDir() が 'accountsDir:'+curPf)。
|
|
865
|
+
切替時に前媒体のハンドルを掴んだままにすると、前媒体のファイル一覧を新媒体の
|
|
866
|
+
アカウント候補として出し、autoSaveAccountJson が同名ファイル (例: 双方にある
|
|
867
|
+
sample_account) を **別媒体の内容で上書き** してしまう。必ずクリアする。
|
|
868
|
+
新媒体のハンドル復元はここでは行わない (restoreAccountFolder() を切替のたびに
|
|
869
|
+
呼ぶと IndexedDB アクセスで描画が詰まり E2E が軒並みタイムアウトした)。
|
|
870
|
+
切替後にフォルダを使うときは、ユーザーが明示的に再接続する。
|
|
871
|
+
リロード時は boot 経路の restoreAccountFolder() が curPf のぶんを復元する。 */
|
|
872
|
+
accDirHandle=null; pendingAccDirHandle=null; accountFiles=[];
|
|
820
873
|
applyBundle(id); /* S10: 同梱アカウント / サンプルプランは媒体別 */
|
|
821
874
|
refTab=firstRefTabKey(); /* 参照データタブは媒体の宣言順で先頭に戻す */
|
|
822
875
|
st=initialState();
|
|
@@ -909,6 +962,27 @@ const REF_TABLES = {
|
|
|
909
962
|
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
910
963
|
columns:[
|
|
911
964
|
{label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
|
|
965
|
+
] },
|
|
966
|
+
/* C7: オーディエンスリスト / 配置。ID キー + name の形は campaigns/adGroups と同じ。
|
|
967
|
+
required:false なので validRefs() には影響せず、normalizeRefsFor() が旧JSONに {} を補完する。
|
|
968
|
+
GUI は **実IDを入稿し、表示は name** (refpick 列)。 */
|
|
969
|
+
audiences:{ key:'audiences', label:'オーディエンス', countLabel:'オーディエンス', editable:false, required:false,
|
|
970
|
+
keyKind:'string', keyLabel:'オーディエンスID', keyWidth:'120px',
|
|
971
|
+
emptyMsg:'オーディエンスリストがありません(アカウントJSONの refs.audiences)',
|
|
972
|
+
valueFields:[{k:'name',label:'名'},{k:'kind',label:'種別'}],
|
|
973
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
974
|
+
columns:[
|
|
975
|
+
{label:'オーディエンス名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
976
|
+
{label:'種別', width:'140px', cls:'mono', cell:(no,v)=>esc(v&&v.kind||'')}
|
|
977
|
+
] },
|
|
978
|
+
placements:{ key:'placements', label:'配置', countLabel:'配置', editable:false, required:false,
|
|
979
|
+
keyKind:'string', keyLabel:'配置ID', keyWidth:'120px',
|
|
980
|
+
emptyMsg:'配置リストがありません(アカウントJSONの refs.placements)',
|
|
981
|
+
valueFields:[{k:'name',label:'名'},{k:'kind',label:'種別'}],
|
|
982
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
983
|
+
columns:[
|
|
984
|
+
{label:'配置名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
985
|
+
{label:'種別', width:'140px', cls:'mono', cell:(no,v)=>esc(v&&v.kind||'')}
|
|
912
986
|
] }
|
|
913
987
|
};
|
|
914
988
|
/* ============================================================
|
|
@@ -929,6 +1003,15 @@ PLATFORMS.yda = {
|
|
|
929
1003
|
project.accountName=String(obj.name||'');
|
|
930
1004
|
/* E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持 */
|
|
931
1005
|
if(obj.principal!=null) project.principal=String(obj.principal);
|
|
1006
|
+
/* C9: 管理画面URLの {mcc} (loginAccountId)。アカウントJSON v2 の mccId が唯一の供給元。
|
|
1007
|
+
未設定なら consoleLink() が accountId にフォールバックする。
|
|
1008
|
+
⚠ **キーが無いときは必ず既定へ戻す**。同一媒体内のアカウント切替では
|
|
1009
|
+
applyAccountJson が st.project を作り直さないため、「あるときだけ上書き」にすると
|
|
1010
|
+
前のアカウントの mcc / URLパラメータ設定が残って別アカウントの画面を開く・
|
|
1011
|
+
計測パラメータが黙って消える、という事故になる。 */
|
|
1012
|
+
project.consoleMcc = obj.mccId!=null ? String(obj.mccId) : '';
|
|
1013
|
+
/* C3: URLパラメータ ON/OFF の初期値。キー無し = undefined = true (従来挙動) */
|
|
1014
|
+
if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
|
|
932
1015
|
},
|
|
933
1016
|
/* AIプロンプトに埋め込む refs スキーマ例 (媒体固有。ここ1箇所だけで定義する) */
|
|
934
1017
|
schemaLines(opts){
|
|
@@ -957,8 +1040,9 @@ PLATFORMS.yda = {
|
|
|
957
1040
|
},
|
|
958
1041
|
budgetLevel:'campaign',
|
|
959
1042
|
/* 管理画面 (SPAハッシュルート)。{mcc} は YDA の loginAccountId */
|
|
1043
|
+
/* C9: {mcc} は st.project.consoleMcc (アカウントJSON mccId) 由来。ハードコードは持たない */
|
|
960
1044
|
consoleUrl:'https://ads.yahoo.co.jp/manager/#/{mcc}/{accountId}/list/{resource}',
|
|
961
|
-
|
|
1045
|
+
consoleResourceMap:{campaigns:'campaigns', adGroups:'adgroups', ads:'ads'},
|
|
962
1046
|
|
|
963
1047
|
/* ---- 形式(Card/SQ) と クリエイティブ名サフィックス・media 値のキーの対応 ---- */
|
|
964
1048
|
mediaFormats:[
|
|
@@ -975,12 +1059,16 @@ PLATFORMS.yda = {
|
|
|
975
1059
|
{key:'goal', label:'目的', kind:'selectv', fallback:'CONVERSION',
|
|
976
1060
|
options:[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},
|
|
977
1061
|
{v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}]},
|
|
978
|
-
{key:'startDate', label:'
|
|
1062
|
+
{key:'startDate', label:'配信開始日', kind:'text', width:'10ch', fallback:''},
|
|
979
1063
|
{key:'cta', label:'CTA', kind:'text', width:'7ch'},
|
|
980
1064
|
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
|
|
981
1065
|
{key:'attributionDays', label:'計測', kind:'number', width:'3ch', suffix:'日'},
|
|
982
1066
|
{key:'displayUrlFormat',label:'表示URL', kind:'text', width:'13ch'},
|
|
983
|
-
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'}
|
|
1067
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'},
|
|
1068
|
+
/* C3: 案件単位の URLパラメータ ON/OFF。**defaults.project には入れない**
|
|
1069
|
+
(undefined = ON のフォールバック規約に一本化し、旧保存JSONと差を出さない) */
|
|
1070
|
+
{key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
|
|
1071
|
+
title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
|
|
984
1072
|
],
|
|
985
1073
|
/* プランツールバーの固定プルダウン (変更不可) */
|
|
986
1074
|
fixedType:[{label:'広告タイプ',value:'レスポンシブ'},{label:'メイン形式',value:'画像'}],
|
|
@@ -1034,7 +1122,9 @@ PLATFORMS.yda = {
|
|
|
1034
1122
|
return {days, hours};
|
|
1035
1123
|
},
|
|
1036
1124
|
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; }
|
|
1125
|
+
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
|
|
1126
|
+
/* C7: refpick セル (実IDのCSV) → 数値ID配列。落ちた分は ctx.stats.droppedRefIds に計上 */
|
|
1127
|
+
idList(s, ctx){ return refIdList(s, ctx); }
|
|
1038
1128
|
},
|
|
1039
1129
|
|
|
1040
1130
|
/* ---- 既定値 ---- */
|
|
@@ -1048,7 +1138,8 @@ PLATFORMS.yda = {
|
|
|
1048
1138
|
attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
|
|
1049
1139
|
budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
|
|
1050
1140
|
},
|
|
1051
|
-
|
|
1141
|
+
/* C6: adName は空 = 自動生成 (naming.adName)。C4/C1: articleUrl / imagePath は空 = 従来挙動 */
|
|
1142
|
+
ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', articleUrl:'', imagePath:'', adName:'', desc2:''},
|
|
1052
1143
|
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, variable:'', urlNumber:''}
|
|
1053
1144
|
},
|
|
1054
1145
|
|
|
@@ -1056,7 +1147,7 @@ PLATFORMS.yda = {
|
|
|
1056
1147
|
factories:{
|
|
1057
1148
|
newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.yda.defaults.ad, seed||{}); },
|
|
1058
1149
|
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()]}; },
|
|
1150
|
+
return {uid:uid('g'), name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), paramCode:'', isExisting:false, smartTargeting:'オン', ads:[F.newAd()]}; },
|
|
1060
1151
|
/* E-2: 新規キャンペーン (空のAG1を1つ内包)。isExisting:false = 常に編集可 */
|
|
1061
1152
|
newCampaign(seed){ const p=st.project, D=PLATFORMS.yda.defaults, F=PLATFORMS.yda.factories;
|
|
1062
1153
|
return Object.assign({uid:uid('c')}, D.campaign, {
|
|
@@ -1076,10 +1167,50 @@ PLATFORMS.yda = {
|
|
|
1076
1167
|
naming:{
|
|
1077
1168
|
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
1078
1169
|
adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
|
|
1170
|
+
/* C4: 記事URLの base は ad.articleUrl (完全URL上書き) → 無ければ baseArticleUrl + articleSlug。
|
|
1171
|
+
C3: project.urlParams===false なら ?ad= / &creative= を付けない (欠落 undefined は ON = 従来挙動)。
|
|
1172
|
+
⚠ base が既にクエリを持つ (utm_source 等) 場合は '?' を2つ並べない = 繋ぎ文字を選ぶ。
|
|
1173
|
+
並べると parse 時に ad= が消えて utm_source の値に食われる。
|
|
1174
|
+
⚠ paramCode / adName は自由入力なので urlq() でURL構造を壊す文字だけ落とす。 */
|
|
1079
1175
|
finalUrl(ad, group, adName, project){
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1176
|
+
const base = String(ad.articleUrl||'').trim() || ((project.baseArticleUrl||'') + (ad.articleSlug||''));
|
|
1177
|
+
if(project.urlParams===false) return base;
|
|
1178
|
+
const sep = base.indexOf('?')>=0 ? '&' : '?';
|
|
1179
|
+
const code = urlq(group.paramCode||'');
|
|
1180
|
+
return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
|
|
1181
|
+
},
|
|
1182
|
+
/* C4: 記事URL入力の正規化。「完全URLを slug 欄に入れると壊れる」の直接修正。
|
|
1183
|
+
articleSlug / articleUrl / finalUrl の **3列すべての set フック**がこれを通る。
|
|
1184
|
+
① ?ad= / &creative= を除去
|
|
1185
|
+
② baseArticleUrl 配下の完全URLは slug に吸収
|
|
1186
|
+
③ 配下外の完全URLは articleUrl へ
|
|
1187
|
+
④ それ以外 (相対断片) は slug
|
|
1188
|
+
⚠ 空入力は呼び出し元 (applyArticleInput) が扱う。ここで両フィールドを空で返すのは
|
|
1189
|
+
「明示的に両方を消す」意味なので、列ごとの空クリアには使わない。 */
|
|
1190
|
+
normalizeArticleInput(v, project){
|
|
1191
|
+
let s=String(v==null?'':v).trim();
|
|
1192
|
+
if(s==='') return {articleSlug:'', articleUrl:''};
|
|
1193
|
+
const stripped=s.replace(/[?&]ad=[^&#]*/g,'').replace(/[?&]creative=[^&#]*/g,'');
|
|
1194
|
+
const removed = stripped!==s;
|
|
1195
|
+
s=stripped.replace(/\?&/,'?').replace(/[?&]+$/,'');
|
|
1196
|
+
/* 先頭の "?ad=" を消すと '?' ごと失われ、後続クエリ (utm=1 等) が '&' のまま
|
|
1197
|
+
パスに連結されてしまう (…/x&utm=1)。除去が起きたときだけ先頭の & を ? に昇格する。 */
|
|
1198
|
+
if(removed && s.indexOf('?')<0) s=s.replace(/&/,'?');
|
|
1199
|
+
const base=String((project&&project.baseArticleUrl)||'');
|
|
1200
|
+
if(base && s.indexOf(base)===0) return {articleSlug:s.slice(base.length), articleUrl:''};
|
|
1201
|
+
if(/^https?:\/\//i.test(s)) return {articleSlug:'', articleUrl:s};
|
|
1202
|
+
return {articleSlug:s, articleUrl:''};
|
|
1203
|
+
},
|
|
1204
|
+
/* C4: 記事URL3列 (記事slug / 記事URL(上書き) / 最終リンク先URL) の set フック共通処理。
|
|
1205
|
+
**空入力で他の列の値を道連れにしない**のが要点:
|
|
1206
|
+
・空 → 自分の列が持つフィールドだけを消す (finalUrl 列は自分のフィールドを持たないので no-op)
|
|
1207
|
+
・非空 → normalizeArticleInput の振り分け結果 (slug / url の組) をそのまま反映
|
|
1208
|
+
pasteGrid は範囲内の空セルも左→右に setVar するため、ここで分けないと
|
|
1209
|
+
「slug を書いた直後に隣の空セルが slug を消す」というサイレントなデータ消失になる。 */
|
|
1210
|
+
applyArticleInput(row, val, project, ownField){
|
|
1211
|
+
const s=String(val==null?'':val).trim();
|
|
1212
|
+
if(s===''){ if(ownField) row[ownField]=''; return; }
|
|
1213
|
+
Object.assign(row, PLATFORMS.yda.naming.normalizeArticleInput(s, project));
|
|
1083
1214
|
},
|
|
1084
1215
|
/* キャンペーン名 自動採番: {空き番号2桁}_{CR名}_{今日} */
|
|
1085
1216
|
campaignName(urlNumber, firstAd, P){
|
|
@@ -1088,11 +1219,12 @@ PLATFORMS.yda = {
|
|
|
1088
1219
|
}
|
|
1089
1220
|
},
|
|
1090
1221
|
/* 「クリエイティブをクリア」対象フィールド */
|
|
1091
|
-
creativeFields:['imageNo','titleNo','descNo'],
|
|
1092
|
-
/* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)
|
|
1093
|
-
|
|
1222
|
+
creativeFields:['imageNo','titleNo','descNo','imagePath'],
|
|
1223
|
+
/* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)。
|
|
1224
|
+
C7: オーディエンス/配置は実ID入稿に対応したので **未反映列から外した** */
|
|
1225
|
+
notSubmittedFields:['tg_region','tg_searchKw'],
|
|
1094
1226
|
labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode',
|
|
1095
|
-
tgDroppedWarn:'
|
|
1227
|
+
tgDroppedWarn:'地域/サーチKW'},
|
|
1096
1228
|
|
|
1097
1229
|
/* ---- tree ビューの媒体固有バッジ (DOM構造は renderTree 側と一致させる) ---- */
|
|
1098
1230
|
tree:{
|
|
@@ -1110,7 +1242,10 @@ PLATFORMS.yda = {
|
|
|
1110
1242
|
const P=PLATFORMS.yda, p=ctx.project, refs=ctx.refs;
|
|
1111
1243
|
const fmt=P.fmtOf(ad);
|
|
1112
1244
|
const crName=P.naming.crName(ad, P);
|
|
1113
|
-
|
|
1245
|
+
/* C6: 広告名は自由入力。空 = 自動生成 (naming.adName) にフォールバックする。
|
|
1246
|
+
⚠ finalUrl の creative= は adName を使うので、手入力するとURLも変わる (仕様) */
|
|
1247
|
+
const autoAdName=P.naming.adName(ad, group, crName);
|
|
1248
|
+
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
1114
1249
|
const me = refs.media[String(ad.imageNo)];
|
|
1115
1250
|
const mediaId = me ? me[fmt.mediaKey] : undefined;
|
|
1116
1251
|
const titleText = refs.titles[ad.titleNo];
|
|
@@ -1124,16 +1259,29 @@ PLATFORMS.yda = {
|
|
|
1124
1259
|
const imgNoStr=String(ad.imageNo||'').trim();
|
|
1125
1260
|
const pendingImage = (imgNoStr!=='' && mediaId===undefined); // アカウントのメディアライブラリに未登録→先にアップロードして新規作成
|
|
1126
1261
|
if(imgNoStr==='') pushErr('画像未解決', EC.IMAGE_UNRESOLVED);
|
|
1127
|
-
|
|
1128
|
-
|
|
1262
|
+
// 見出し/説明文は「番号空欄=意図的に使わない」を許容 (エラーにしない)。
|
|
1263
|
+
// 番号が入っているのに参照表で引けない場合のみ従来どおり未解決エラー。
|
|
1264
|
+
const titleNoStr=String(ad.titleNo||'').trim();
|
|
1265
|
+
const descNoStr=String(ad.descNo||'').trim();
|
|
1266
|
+
if(titleNoStr!=='' && titleText===undefined) pushErr('タイトル未解決', EC.TITLE_UNRESOLVED);
|
|
1267
|
+
if(descNoStr!=='' && descText===undefined) pushErr('説明文未解決', EC.DESC_UNRESOLVED);
|
|
1129
1268
|
if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
|
|
1130
1269
|
else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
|
|
1131
1270
|
// B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
|
|
1132
1271
|
if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
1133
1272
|
if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
|
|
1273
|
+
// C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
|
|
1274
|
+
// ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
|
|
1275
|
+
const imagePaths=String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
1276
|
+
if(pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
|
|
1277
|
+
/* 逆に mediaID が解決済みの広告に画像パスを足しても、その画像は広告には使われない
|
|
1278
|
+
(CLI はアップロードした mediaId を末尾に **追記** し、backend は mediaIds[0] しか見ない)。
|
|
1279
|
+
黙って古い画像で入稿されるのが最悪なので、書き出しでは落とし、ここで警告する。
|
|
1280
|
+
差し替えたいときは画像番号を空にする (= 未登録画像として新規アップロードさせる)。 */
|
|
1281
|
+
if(!pendingImage && imgNoStr!=='' && imagePaths.length>0) pushWarn('画像パス無視(画像番号が解決済み)', EC.IMAGE_PATH_IGNORED);
|
|
1134
1282
|
// F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
|
|
1135
1283
|
const aid=String(group.adGroupId||'').trim();
|
|
1136
|
-
return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
|
|
1284
|
+
return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
|
|
1137
1285
|
mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
|
|
1138
1286
|
descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
1139
1287
|
},
|
|
@@ -1164,13 +1312,15 @@ PLATFORMS.yda = {
|
|
|
1164
1312
|
const ag={
|
|
1165
1313
|
name:g.name,
|
|
1166
1314
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1167
|
-
|
|
1315
|
+
/* C8: 空欄・旧データ (undefined) はすべて true。明示的に「オフ」を選んだときだけ false */
|
|
1316
|
+
smartTargeting: g.smartTargeting!=='オフ',
|
|
1168
1317
|
devices:P.mappers.devices(g.tg_device, P),
|
|
1169
1318
|
ages:P.mappers.ages(g.tg_age, P),
|
|
1170
1319
|
genders:P.mappers.genders(g.tg_gender, P),
|
|
1171
1320
|
days:dh.days,
|
|
1172
|
-
|
|
1173
|
-
|
|
1321
|
+
/* C7: refpick 列は実IDを保持している。数値IDのみを送り、旧・日本語ラベルは落として警告に計上する */
|
|
1322
|
+
placementsInclude:P.mappers.idList(g.tg_placement, ctx), placementsExclude:P.mappers.idList(g.tg_placementEx, ctx),
|
|
1323
|
+
audiencesInclude:P.mappers.idList(g.tg_audience, ctx), audiencesExclude:P.mappers.idList(g.tg_audienceEx, ctx),
|
|
1174
1324
|
ads:[]
|
|
1175
1325
|
};
|
|
1176
1326
|
if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
|
|
@@ -1183,16 +1333,23 @@ PLATFORMS.yda = {
|
|
|
1183
1333
|
adName:r.adName,
|
|
1184
1334
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1185
1335
|
mediaType:'IMAGE', // レスポンシブ画像広告 (backend ydaAdSchema IMAGE)
|
|
1186
|
-
|
|
1187
|
-
|
|
1336
|
+
// 空欄許容: 空文字を要素に持つ配列を送らない (空なら [])
|
|
1337
|
+
headlines:String(r.titleText||'')===''?[]:[String(r.titleText)],
|
|
1338
|
+
descriptions:String(r.descText||'')===''?[]:[String(r.descText)],
|
|
1188
1339
|
principal:p.principal||'', // E-1: 主体者はアカウントJSON由来 (st.project.principal)
|
|
1189
1340
|
buttonText:P.mappers.cta(p.cta, P), // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
|
|
1190
1341
|
finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
|
|
1191
1342
|
};
|
|
1192
1343
|
if(r.pendingImage){
|
|
1193
|
-
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
|
|
1194
|
-
|
|
1344
|
+
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
|
|
1345
|
+
// C6-1: 画像パス列 (カンマ区切り) を imagePaths に展開する。空なら [] のまま (agent/CLI が補完)
|
|
1346
|
+
ad.mediaIds=[]; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; ad.imagePaths=r.imagePaths.slice();
|
|
1195
1347
|
} else {
|
|
1348
|
+
/* 解決済み: mediaId だけを送る。imagePaths を **同時に載せない** のが重要。
|
|
1349
|
+
CLI (resolveYdaImages) は imagePaths があると必ずアップロードして
|
|
1350
|
+
mediaIds に追記し、backend (createImageAd) は mediaIds[0] しか使わないため、
|
|
1351
|
+
両方載せると「新画像はライブラリに登録されるが広告は旧画像のまま」になる。
|
|
1352
|
+
入力があった場合は resolveAd が IMAGE_PATH_IGNORED で警告している。 */
|
|
1196
1353
|
ad.mediaIds=[String(r.mediaId)];
|
|
1197
1354
|
}
|
|
1198
1355
|
return ad;
|
|
@@ -1256,7 +1413,7 @@ PLATFORMS.yda = {
|
|
|
1256
1413
|
strategy(){
|
|
1257
1414
|
return ['YDA(Yahoo!ディスプレイ広告)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
|
|
1258
1415
|
'# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
|
|
1259
|
-
'- 目的: <コンバージョン獲得 / 認知 など>','- 予算・期間: <月予算 /
|
|
1416
|
+
'- 目的: <コンバージョン獲得 / 認知 など>','- 予算・期間: <月予算 / 配信開始日・配信終了日>',
|
|
1260
1417
|
'- 訴求したいポイント: <強み・オファー・ターゲット層>','',
|
|
1261
1418
|
'# お願い','1. 上記をもとにターゲティング方針・キャンペーン/広告グループ構成・訴求軸を提案してください。',
|
|
1262
1419
|
'2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン.json」に書き出して(上書きして)ください。',
|
|
@@ -1306,6 +1463,50 @@ function normalizeRefsFor(P,r){ const e=emptyRefsFor(P); if(!r) return e; Object
|
|
|
1306
1463
|
function emptyRefs(){ return emptyRefsFor(pf()); }
|
|
1307
1464
|
function validRefs(r){ if(!r) return false; return refTableKeys().filter(k=>refTableDef(k).required).concat(LEGACY_REF_KEYS).every(k=>!!r[k]); }
|
|
1308
1465
|
function normalizeRefs(r){ return normalizeRefsFor(pf(), r); }
|
|
1466
|
+
/* ---- C1: refpick (参照テーブル ID → name の複数選択列) ----
|
|
1467
|
+
セルが持つ値は **カンマ区切りの実ID**。表示だけ name に置き換える (入稿は ID をそのまま送る)。
|
|
1468
|
+
媒体語彙は持たない: 参照先テーブルは col.refTable が指す。 */
|
|
1469
|
+
function refPickStore(col){ return (st.refs && st.refs[col.refTable]) || {}; }
|
|
1470
|
+
function refPickName(col, id){ const v=refPickStore(col)[String(id)]; if(v==null) return ''; return String((typeof v==='object')?(v.name||''):v); }
|
|
1471
|
+
function refPickDisplay(col, val){
|
|
1472
|
+
return tgList(val).map(id=>{ const n=refPickName(col,id); return n? n : String(id); }).join(', ');
|
|
1473
|
+
}
|
|
1474
|
+
/* 実IDだけを残す (旧・日本語ラベルや手入力の名前は捨てる)。順序保持・重複除去。 */
|
|
1475
|
+
function refIdCoerce(v){
|
|
1476
|
+
const out=[]; tgList(v).forEach(x=>{ const s=String(x).trim(); if(/^\d+$/.test(s) && out.indexOf(s)<0) out.push(s); });
|
|
1477
|
+
return out.join(', ');
|
|
1478
|
+
}
|
|
1479
|
+
/* ---- C5: 参照表の本文を直接編集する列 (番号列と双方向) ----
|
|
1480
|
+
media 語彙は持たない: どのテーブル(table)のどの番号列(noField)を見るかは呼び出し側が渡す。
|
|
1481
|
+
・get : refs[table][row[noField]] の文言
|
|
1482
|
+
・set : 番号が空なら空き番号を自動採番して refs へ登録 → 番号列にも書き戻す
|
|
1483
|
+
空文字の set は **参照表を消さない** (番号の解除は番号列で行う)。 */
|
|
1484
|
+
function nextRefNo(store){
|
|
1485
|
+
let m=0; Object.keys(store||{}).forEach(k=>{ const n=parseInt(k,10); if(Number.isFinite(n)&&n>m) m=n; });
|
|
1486
|
+
return m+1;
|
|
1487
|
+
}
|
|
1488
|
+
function makeRefTextCol(o){
|
|
1489
|
+
return {field:o.field, label:o.label, kind:'text', width:o.width||'180px',
|
|
1490
|
+
refTable:o.table, noField:o.noField,
|
|
1491
|
+
title:'参照表の本文を直接編集します(番号が空なら空き番号を自動採番)',
|
|
1492
|
+
get:(row,ctx)=>{ const t=((ctx&&ctx.refs)||st.refs)[o.table]||{}; const v=t[row[o.noField]]; return v==null?'':String(v); },
|
|
1493
|
+
set:(row,val,ctx)=>{
|
|
1494
|
+
const refs=(ctx&&ctx.refs)||st.refs;
|
|
1495
|
+
const t=refs[o.table]||(refs[o.table]={});
|
|
1496
|
+
const s=String(val==null?'':val);
|
|
1497
|
+
if(s==='') return;
|
|
1498
|
+
let no=String(row[o.noField]==null?'':row[o.noField]).trim();
|
|
1499
|
+
if(no===''){ no=String(nextRefNo(t)); row[o.noField]=no; }
|
|
1500
|
+
t[no]=s;
|
|
1501
|
+
}};
|
|
1502
|
+
}
|
|
1503
|
+
/* 入稿ボディ用: セル値 → 数値ID配列。落とした要素数を ctx.stats.droppedRefIds に計上する。 */
|
|
1504
|
+
function refIdList(v, ctx){
|
|
1505
|
+
const keep=[], drop=[];
|
|
1506
|
+
tgList(v).forEach(x=>{ const s=String(x).trim(); if(/^\d+$/.test(s)){ if(keep.indexOf(s)<0) keep.push(s); } else drop.push(s); });
|
|
1507
|
+
if(drop.length && ctx && ctx.stats) ctx.stats.droppedRefIds += drop.length;
|
|
1508
|
+
return keep;
|
|
1509
|
+
}
|
|
1309
1510
|
/* 案A: 同梱デフォルトアカウントの refs を起動時に外部JSONから読み込む(固有名詞ハードコードなし)。
|
|
1310
1511
|
descriptions は独立テーブル(titlesコピーの暫定を廃止)。 */
|
|
1311
1512
|
function buildRefs(){ return bundledRefs ? normalizeRefs(bundledRefs) : emptyRefs(); }
|
|
@@ -1420,6 +1621,7 @@ function snapshot(){ undoStack.push(JSON.stringify({project:st.project,campaigns
|
|
|
1420
1621
|
let _dirty=false; // 未保存の編集があるか (離脱時警告用)
|
|
1421
1622
|
function markDirty(){ _dirty=true; }
|
|
1422
1623
|
function markSaved(){ _dirty=false; }
|
|
1624
|
+
window.isDirty=function(){ return _dirty; }; // 未保存フラグの読み取り口 (E2E から参照。_dirty は let のため window に載らない)
|
|
1423
1625
|
function mutate(fn){ snapshot(); fn(); _dirty=true; persist(); render(); }
|
|
1424
1626
|
function undo(){ const s=undoStack.pop(); if(!s){toast('これ以上戻せません'); return;} const j=JSON.parse(s); st.project=j.project; st.campaigns=j.campaigns; if(j.refs) st.refs=j.refs; _dirty=true; persist(); if(typeof buildDatalists==='function') buildDatalists(); render(); toast('元に戻しました'); }
|
|
1425
1627
|
|
|
@@ -1433,7 +1635,24 @@ function findCp(u){ const i=st.campaigns.findIndex(c=>c.uid===u); return i<0?nul
|
|
|
1433
1635
|
/* S8: 解決本体は pf().resolve(ad, group, ctx) に移設。関数名は据え置き
|
|
1434
1636
|
(E2E が page.evaluate から resolveAd(...) を直接呼ぶため)。 */
|
|
1435
1637
|
function planCtx(){ const P=pf(); return {project:st.project, refs:st.refs, pf:P, resolve:resolveAd}; }
|
|
1436
|
-
|
|
1638
|
+
/* C1: 行単位メモ化。get フックを持つ列が増えると 1行あたり列数ぶん resolveAd が走るため、
|
|
1639
|
+
「1パス」(コピー範囲の走査など state を変えない読み取り) の中だけ結果を共有する。
|
|
1640
|
+
state を変える処理 (pasteGrid 等) は絶対に包まないこと (古い解決結果を掴む)。 */
|
|
1641
|
+
let _resolvePass=null;
|
|
1642
|
+
function withResolvePass(fn){
|
|
1643
|
+
const prev=_resolvePass; _resolvePass=new WeakMap();
|
|
1644
|
+
try{ return fn(); } finally { _resolvePass=prev; }
|
|
1645
|
+
}
|
|
1646
|
+
function resolveAd(ad, group){
|
|
1647
|
+
if(_resolvePass){
|
|
1648
|
+
const hit=_resolvePass.get(ad);
|
|
1649
|
+
if(hit && hit.g===group && hit.pf===curPf) return hit.r;
|
|
1650
|
+
const r=pf().resolve(ad, group, planCtx());
|
|
1651
|
+
_resolvePass.set(ad, {g:group, pf:curPf, r});
|
|
1652
|
+
return r;
|
|
1653
|
+
}
|
|
1654
|
+
return pf().resolve(ad, group, planCtx());
|
|
1655
|
+
}
|
|
1437
1656
|
function validationCounts(){ let err=0,warn=0,ads=0; eachAd((a,g)=>{ ads++; const r=resolveAd(a,g); if(r.errs.length) err++; else if(r.warns.length) warn++; }); return {err,warn,ads}; }
|
|
1438
1657
|
|
|
1439
1658
|
/* ============================================================
|
|
@@ -1473,10 +1692,23 @@ function renderValidationBadge(){
|
|
|
1473
1692
|
}
|
|
1474
1693
|
function renderTplChips(){
|
|
1475
1694
|
const p=st.project;
|
|
1476
|
-
|
|
1477
|
-
|
|
1695
|
+
/* S7b: 記述子の表現力拡張。placeholder は文字列 or (project)=>string、title は文字列。
|
|
1696
|
+
どちらも未指定なら空文字を返すので、無指定の媒体 (YDA) の出力HTMLは 1バイトも変わらない。
|
|
1697
|
+
coerce フックは足していない: 値の正規化 (全角空白・前後空白・区切り) は
|
|
1698
|
+
媒体定義の mappers 側 (meta.mappers.normDateTime) が吸収する方が、
|
|
1699
|
+
生の入力を state に残せて再編集時に壊れないため。 */
|
|
1700
|
+
const hint=(f)=>{
|
|
1701
|
+
if(!f) return '';
|
|
1702
|
+
const ph = typeof f.placeholder==='function' ? f.placeholder(p) : f.placeholder;
|
|
1703
|
+
return (ph? ' placeholder="'+esc(ph)+'"' : '') + (f.title? ' title="'+esc(f.title)+'"' : '');
|
|
1704
|
+
};
|
|
1705
|
+
const txt=(f,v,w,d)=>'<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'"'+hint(d)+'>';
|
|
1706
|
+
const num=(f,v,w,d)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'"'+hint(d)+'>';
|
|
1478
1707
|
const sel=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option'+(o===v?' selected':'')+'>'+esc(o)+'</option>').join('')+'</select>';
|
|
1479
1708
|
const selv=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option value="'+esc(o.v)+'"'+(o.v===v?' selected':'')+'>'+esc(o.t)+'</option>').join('')+'</select>';
|
|
1709
|
+
/* C1: kind:'toggle' = チェックボックス。値は真偽で持ち、**未定義は fallback**(欠落=従来挙動)。
|
|
1710
|
+
`p[key]||fallback` では false が fallback(true) に化けるので、toggle だけは別扱いにする。 */
|
|
1711
|
+
const tgl=(f,v,d)=>'<input class="chipin" type="checkbox" data-proj="'+f+'"'+(v?' checked':'')+hint(d)+'>';
|
|
1480
1712
|
const chip=(label,ctrl)=>'<span class="chip">'+esc(label)+' '+ctrl+'</span>';
|
|
1481
1713
|
// E-1: 案件名 / アカウントID / 主体者 / タイプ チップは撤去。
|
|
1482
1714
|
// - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
|
|
@@ -1484,11 +1716,14 @@ function renderTplChips(){
|
|
|
1484
1716
|
// - タイプ(レスポンシブ/画像)はプランツールバーの固定プルダウン (変更不可)
|
|
1485
1717
|
// S7: チップの並び・種別・幅は pf().projectFields (媒体スキーマ) が持つ
|
|
1486
1718
|
document.getElementById('tplChips').innerHTML = pf().projectFields.map(f=>{
|
|
1487
|
-
const v =
|
|
1488
|
-
|
|
1719
|
+
const v = f.kind==='toggle'
|
|
1720
|
+
? (p[f.key]===undefined ? (f.fallback!==undefined?f.fallback:false) : !!p[f.key])
|
|
1721
|
+
: ((f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key]);
|
|
1722
|
+
const ctrl = f.kind==='toggle' ? tgl(f.key, v, f)
|
|
1723
|
+
: f.kind==='selectv' ? selv(f.key, v, f.options)
|
|
1489
1724
|
: 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);
|
|
1725
|
+
: f.kind==='number' ? num(f.key, v, f.width, f)
|
|
1726
|
+
: txt(f.key, v, f.width, f);
|
|
1492
1727
|
return chip(f.label, ctrl+(f.suffix||''));
|
|
1493
1728
|
}).join('');
|
|
1494
1729
|
// A-1: 入札(tcpaDefault) / 予算新規 / 予算ASC チップは撤去。project既定は新規CP作成時の初期値供給のみに使い、以後はキャンペーン行で個別編集する。
|
|
@@ -1554,6 +1789,9 @@ function renderRow(item, ri, cols, tab, ui){
|
|
|
1554
1789
|
const ctx = {tab:tab, item:item, ri:ri, project:st.project, refs:st.refs};
|
|
1555
1790
|
let cls='adrow';
|
|
1556
1791
|
if(item.kind==='a'){ ctx.r=resolveAd(item.a,item.g); if(ctx.r.errs.length) cls+=' hasErr'; }
|
|
1792
|
+
/* 行レベル検証を持つ媒体だけ、行のエラーコードを ctx に載せる (列記述子 col.errCodes と突き合わせてセルを赤くする)。
|
|
1793
|
+
validateRow が無い媒体 (YDA) では ctx.rowErrCodes 自体が生えないので DOM は一切変わらない。 */
|
|
1794
|
+
if(item.kind==='g' && pf().validateRow) ctx.rowErrCodes=(pf().validateRow(item.g, 'adGroup', ctx)||{}).errCodes||[];
|
|
1557
1795
|
let h='<tr class="'+cls+'" '+ui.rowAttr+'="'+row.uid+'">';
|
|
1558
1796
|
h+='<td class="rownum" data-rownum="'+ri+'"'+(ui.rowNumTitle?ui.rowNumTitle(item):'')+'>'+(ri+1)+'</td>';
|
|
1559
1797
|
if(ui.parentLabel!=null) h+=ui.parentCell(item);
|
|
@@ -1564,7 +1802,7 @@ function renderRow(item, ri, cols, tab, ui){
|
|
|
1564
1802
|
/* 1セル。col.kind で分岐する唯一の場所。 */
|
|
1565
1803
|
function renderCell(col, item, ri, ci, ctx){
|
|
1566
1804
|
const ui=SHEET_UI[ctx.tab], row=rowOf(item);
|
|
1567
|
-
const val=getVar(item, ci, ctx.tab);
|
|
1805
|
+
const val=getVar(item, ci, ctx.tab, ctx);
|
|
1568
1806
|
const ro = col.alwaysReadonly || ((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col));
|
|
1569
1807
|
const sel='data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+'"';
|
|
1570
1808
|
const sty=col.width?(' style="width:'+col.width+'"'):'';
|
|
@@ -1599,16 +1837,23 @@ function renderCell(col, item, ri, ci, ctx){
|
|
|
1599
1837
|
'<button class="combodd" data-dd="'+col.drawerType+'" data-u="'+row.uid+'" data-f="'+col.field+'" tabindex="-1" title="'+esc(col.ddTitle)+'">▾</button></div></td>';
|
|
1600
1838
|
}
|
|
1601
1839
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1840
|
+
/* tgpick = 固定ラベルの複数選択 / refpick = 参照テーブル(ID→name)の複数選択。
|
|
1841
|
+
DOM は同じ (.tgpick) に揃えて CSS・E2E セレクタ・ドラッグ判定を共通化する。
|
|
1842
|
+
ラベルは **col.label フォールバック必須**: refpick 列は pf().targeting にキーを持たないので
|
|
1843
|
+
targeting[col.tgKey].label を無条件参照すると TypeError になる。 */
|
|
1844
|
+
if(col.kind==='tgpick' || col.kind==='refpick'){
|
|
1845
|
+
const tg = col.tgKey ? pf().targeting[col.tgKey] : null;
|
|
1846
|
+
const label=(tg&&tg.label)||col.label||'';
|
|
1847
|
+
const shown = col.kind==='refpick' ? refPickDisplay(col, val) : val;
|
|
1848
|
+
const disp = shown? '<span class="tgval">'+esc(shown)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(label)+'</span></span>';
|
|
1849
|
+
return '<td class="tgtd"><div class="tgpick'+(shown?'':' tgempty')+'" '+sel+' title="'+esc(shown||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
|
|
1606
1850
|
}
|
|
1607
1851
|
|
|
1608
1852
|
/* text / mono / number */
|
|
1609
|
-
const
|
|
1853
|
+
const rowBad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
|
|
1854
|
+
const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'')+(rowBad?' err':'');
|
|
1610
1855
|
return '<td><input class="'+cls+'"'+(col.kind==='number'?' type="number"':'')+' '+sel+' value="'+esc(val)+'"'+
|
|
1611
|
-
(col.placeholder?(' placeholder="'+esc(col.placeholder())+'"'):'')+
|
|
1856
|
+
(col.placeholder?(' placeholder="'+esc(col.placeholder(ctx, row))+'"'):'')+
|
|
1612
1857
|
(col.title?(' title="'+esc(col.title)+'"'):'')+sty+'></td>';
|
|
1613
1858
|
}
|
|
1614
1859
|
|
|
@@ -1814,10 +2059,31 @@ const COLS = {
|
|
|
1814
2059
|
title:'グループ共通(変更で同グループ全広告に反映)',
|
|
1815
2060
|
roStyle:'input', roTitle:'既存広告グループのため編集不可',
|
|
1816
2061
|
onSet:it=>autoResolveGroupIds(it.g,it.c)},
|
|
1817
|
-
|
|
2062
|
+
/* C4: 記事slug / 記事URL / 最終リンク先URL は同じ「記事URLの指定」の3つの見方。
|
|
2063
|
+
どの列に何を貼っても naming.normalizeArticleInput が振り分ける (完全URLを slug 欄に貼っても壊れない)。
|
|
2064
|
+
空セルは **その列が持つフィールドだけ** を消す (applyArticleInput の第4引数)。
|
|
2065
|
+
3列とも一律に両フィールドを消すと、空セルを含む範囲を貼るたびに隣の列の値が飛ぶ。 */
|
|
2066
|
+
{field:'articleSlug', label:'記事slug', kind:'mono', width:'90px',
|
|
2067
|
+
set:(row,val,ctx)=>{ PLATFORMS.yda.naming.applyArticleInput(row, val, ctx.project, 'articleSlug'); }},
|
|
2068
|
+
{field:'articleUrl', label:'記事URL(上書き)', kind:'mono', width:'170px',
|
|
2069
|
+
title:'記事の完全URL。入れると 記事URL(案件設定)+slug より優先されます',
|
|
2070
|
+
set:(row,val,ctx)=>{ PLATFORMS.yda.naming.applyArticleInput(row, val, ctx.project, 'articleUrl'); }},
|
|
2071
|
+
{field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
|
|
2072
|
+
title:'パラメータ込みの完成URL。貼り戻すと記事URL/slugへ逆写像されます(空にしても消えません。消すときは記事slug / 記事URL(上書き) を空にしてください)',
|
|
2073
|
+
get:(row,ctx)=>ctx.r?ctx.r.finalUrl:'',
|
|
2074
|
+
set:(row,val,ctx)=>{ PLATFORMS.yda.naming.applyArticleInput(row, val, ctx.project, null); }},
|
|
2075
|
+
/* C5: 参照表(refs.titles / refs.descriptions)の本文を直接編集する列。
|
|
2076
|
+
番号未入力のまま本文を書くと空き番号を自動採番して参照表に登録する。 */
|
|
2077
|
+
makeRefTextCol({field:'titleText', label:'タイトル本文', table:'titles', noField:'titleNo', width:'180px'}),
|
|
2078
|
+
makeRefTextCol({field:'descText', label:'説明文本文', table:'descriptions', noField:'descNo', width:'180px'}),
|
|
2079
|
+
/* C6-1: 量産時のローカル画像パス担体 (カンマ区切りで複数可)。少数入稿は Skill の画像入稿フローが主導線 */
|
|
2080
|
+
{field:'imagePath', label:'画像パス', kind:'mono', width:'150px',
|
|
2081
|
+
title:'ローカル画像の絶対 / ~/… / 計画JSON基準の相対パス。カンマ区切りで複数可。※画像番号が参照表で解決できる行では入稿JSONに載りません(差し替えたいときは画像番号を空に)'},
|
|
2082
|
+
/* C6: 広告名は自由入力 (空 = 自動生成)。placeholder に自動生成値を出す */
|
|
2083
|
+
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
2084
|
+
title:'空欄なら自動生成({画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
|
|
2085
|
+
placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
|
|
1818
2086
|
/* 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
2087
|
{field:'adGroupId', label:'CP / AG ID', kind:'derived',
|
|
1822
2088
|
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AG '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
1823
2089
|
],
|
|
@@ -1838,9 +2104,23 @@ const COLS = {
|
|
|
1838
2104
|
{field:'tg_age', label:'年齢', kind:'tgpick', tgKey:'tg_age', readonlyOnExisting:true, roEmptyDash:true},
|
|
1839
2105
|
{field:'tg_region', label:'地域', kind:'tgpick', tgKey:'tg_region', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
|
|
1840
2106
|
{field:'tg_dayHour', label:'曜日時間', kind:'tgpick', tgKey:'tg_dayHour', readonlyOnExisting:true, roEmptyDash:true},
|
|
1841
|
-
|
|
2107
|
+
/* C7: オーディエンス/配置は refpick (参照テーブルの 実ID → name)。値は実IDのCSVで持ち、表示だけ name。
|
|
2108
|
+
coerce で数値ID以外 (旧・日本語ラベル / 手入力の名前) を落とす。 */
|
|
2109
|
+
{field:'tg_audience', label:'オーディエンス', kind:'refpick', refTable:'audiences',
|
|
2110
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
2111
|
+
{field:'tg_audienceEx', label:'オーディエンス除外', kind:'refpick', refTable:'audiences',
|
|
2112
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
2113
|
+
{field:'tg_placement', label:'配置', kind:'refpick', refTable:'placements',
|
|
2114
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
2115
|
+
{field:'tg_placementEx',label:'配置除外', kind:'refpick', refTable:'placements',
|
|
2116
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
1842
2117
|
/* 暗黙 else を廃止: pf().targeting に無い列は kind:'text' で明示する */
|
|
1843
|
-
{field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true}
|
|
2118
|
+
{field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
|
|
2119
|
+
/* C8: 空欄 = オン (未設定・旧データはすべて true)。明示的に「オフ」を選んだときだけ false */
|
|
2120
|
+
{field:'smartTargeting', label:'スマートTG', kind:'select', options:['','オン','オフ'], minWidth:'66px',
|
|
2121
|
+
readonlyOnExisting:true, roEmptyDash:true,
|
|
2122
|
+
title:'空欄は「オン」扱い(方針: スマートターゲティングは常時ON)',
|
|
2123
|
+
coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
|
|
1844
2124
|
],
|
|
1845
2125
|
/* E-2: campaignId最左・read-only / mode/status/strategyMemo撤去 */
|
|
1846
2126
|
campaigns:[
|
|
@@ -1959,8 +2239,10 @@ const META_COLS = {
|
|
|
1959
2239
|
title:'広告セット共通(変更で同広告セット全広告に反映)',
|
|
1960
2240
|
roStyle:'input', roTitle:'既存広告セットのため編集不可'},
|
|
1961
2241
|
{field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
|
|
1962
|
-
|
|
1963
|
-
|
|
2242
|
+
/* C6: 広告名は自由入力 (空 = 自動生成)。YDA と同じ規約 */
|
|
2243
|
+
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
2244
|
+
title:'空欄なら自動生成({番号}{i|v}-{見出し番号}L{本文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
|
|
2245
|
+
placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
|
|
1964
2246
|
{field:'adGroupId', label:'CP / AdSet ID', kind:'derived',
|
|
1965
2247
|
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AS '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
1966
2248
|
],
|
|
@@ -1981,10 +2263,34 @@ const META_COLS = {
|
|
|
1981
2263
|
{field:'ageMin', label:'年齢下限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
|
|
1982
2264
|
readonlyOnExisting:true, roEmptyDash:true},
|
|
1983
2265
|
{field:'ageMax', label:'年齢上限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
|
|
1984
|
-
readonlyOnExisting:true, roEmptyDash:true
|
|
2266
|
+
readonlyOnExisting:true, roEmptyDash:true,
|
|
2267
|
+
/* 空欄 = 上限なし (age_max を送らない)。特別広告カテゴリ時は 18-65 固定なので上限なしは無視される */
|
|
2268
|
+
placeholder:ctx=>{
|
|
2269
|
+
const K=PLATFORMS.meta.enums.specialAdCategoryConstraints;
|
|
2270
|
+
const c=ctx&&ctx.item&&ctx.item.c, cat=String((c&&c.specialAdCategory)||'NONE');
|
|
2271
|
+
return K.appliesTo.indexOf(cat)>=0 ? (String(K.forcedAgeMax)+'(固定)') : '上限なし';
|
|
2272
|
+
},
|
|
2273
|
+
title:'空欄にすると上限なし(age_max を送りません)。特別広告カテゴリ選択時は 65 固定となり上限なしは無視されます'},
|
|
1985
2274
|
{field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
|
|
1986
2275
|
{field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
|
|
1987
2276
|
{field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
|
|
2277
|
+
/* 配信日時: 空欄 = 案件設定チップの値を継承(buildPlan 側で解決)。placeholder に継承先を出す */
|
|
2278
|
+
{field:'startDate', label:'配信開始日時', kind:'text', width:'17ch',
|
|
2279
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
|
|
2280
|
+
errCodes:[EC.START_TIME_INVALID, EC.END_BEFORE_START],
|
|
2281
|
+
placeholder:()=>{ const v=String((st.project&&st.project.startDate)||'').trim();
|
|
2282
|
+
return v!=='' ? v : '案件設定 / 今日'; },
|
|
2283
|
+
title:'この広告セットだけの配信開始日時(JST)。空欄なら案件設定「配信開始日時」を継承し、'
|
|
2284
|
+
+'それも空欄なら当日 00:00。日付のみなら 00:00 開始(例 2026-08-01 10:30)。'
|
|
2285
|
+
+'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
|
|
2286
|
+
{field:'endDate', label:'配信終了日時', kind:'text', width:'17ch',
|
|
2287
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
|
|
2288
|
+
errCodes:[EC.END_TIME_INVALID, EC.END_BEFORE_START],
|
|
2289
|
+
placeholder:()=>{ const v=String((st.project&&st.project.endDate)||'').trim();
|
|
2290
|
+
return v!=='' ? v : '無期限'; },
|
|
2291
|
+
title:'この広告セットだけの配信終了日時(JST)。空欄なら案件設定「配信終了日時」を継承し、'
|
|
2292
|
+
+'それも空欄なら無期限配信。日付のみならその日の 23:59:59 まで。'
|
|
2293
|
+
+'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
|
|
1988
2294
|
{field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
|
|
1989
2295
|
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
|
|
1990
2296
|
],
|
|
@@ -2017,6 +2323,10 @@ PLATFORMS.meta = {
|
|
|
2017
2323
|
if(obj.pixelId!=null) project.pixelId=String(obj.pixelId);
|
|
2018
2324
|
if(obj.projectName) project.name=obj.projectName;
|
|
2019
2325
|
if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
|
|
2326
|
+
/* C3: YDA と同じ規約。**キー無しは必ず既定 (undefined = ON) に戻す** —
|
|
2327
|
+
同一媒体内のアカウント切替では st.project が作り直されないため、
|
|
2328
|
+
「あるときだけ上書き」だと前アカウントの OFF が残って計測パラメータが黙って消える */
|
|
2329
|
+
if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
|
|
2020
2330
|
},
|
|
2021
2331
|
schemaLines(opts){
|
|
2022
2332
|
const o=opts||{};
|
|
@@ -2071,9 +2381,21 @@ PLATFORMS.meta = {
|
|
|
2071
2381
|
{key:'cta', label:'CTA', kind:'select',
|
|
2072
2382
|
options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
|
|
2073
2383
|
'予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
|
|
2074
|
-
{key:'startDate', label:'
|
|
2384
|
+
{key:'startDate', label:'配信開始日時', kind:'text', width:'17ch', fallback:'',
|
|
2385
|
+
placeholder:'YYYY-MM-DD [HH:mm]',
|
|
2386
|
+
title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
|
|
2387
|
+
+'日付のみなら 00:00 開始。時刻を入れるとその時刻ちょうどから配信(例 2026-08-01 10:30)。'
|
|
2388
|
+
+'空欄なら当日 00:00 から。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
|
|
2389
|
+
{key:'endDate', label:'配信終了日時', kind:'text', width:'17ch', fallback:'',
|
|
2390
|
+
placeholder:'空欄=無期限',
|
|
2391
|
+
title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
|
|
2392
|
+
+'日付のみならその日の 23:59:59 まで。時刻を入れるとその時刻で終了(例 2026-08-31 18:00)。'
|
|
2393
|
+
+'空欄なら終了日時を送らず無期限配信。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
|
|
2075
2394
|
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
|
|
2076
|
-
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''}
|
|
2395
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''},
|
|
2396
|
+
/* C3: 案件単位の URLパラメータ ON/OFF (欠落 = ON = 従来挙動) */
|
|
2397
|
+
{key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
|
|
2398
|
+
title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
|
|
2077
2399
|
],
|
|
2078
2400
|
fixedType:[{label:'広告タイプ',value:'単一画像 / 単一動画'}],
|
|
2079
2401
|
|
|
@@ -2118,8 +2440,95 @@ PLATFORMS.meta = {
|
|
|
2118
2440
|
},
|
|
2119
2441
|
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
2120
2442
|
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
|
|
2443
|
+
/* 日時の正規化。日付のみ / 日付+時刻 の両方を受け、JST 明示 ISO8601 を返す。
|
|
2444
|
+
- 空 → null
|
|
2445
|
+
- 末尾にオフセット(+09:00 / +0900 / Z)を持つ完全 ISO → そのまま素通し (後方互換)
|
|
2446
|
+
- YYYY-MM-DD → opts.endOfDay ? T23:59:59 : T00:00:00 を補って +0900
|
|
2447
|
+
- YYYY-MM-DD[ T]HH:mm(:ss)? → 時刻が明示されているので endOfDay は適用しない
|
|
2448
|
+
- どれにも当たらない → {invalid:true, input} を返す (例外は投げない)
|
|
2449
|
+
実在しない日付 (2026-02-30 / 2026-13-99) と範囲外の時刻 (24:00 / 10:75) は不正とする。
|
|
2450
|
+
Date は 2/30 を 3/2 へロールオーバーするので、生成した Date の年月日が入力と
|
|
2451
|
+
一致するかを照合しないと非実在日付が素通りする。 */
|
|
2452
|
+
normDateTime(input, opts){
|
|
2453
|
+
const o=opts||{}, p2=n=>String(n).padStart(2,'0');
|
|
2454
|
+
/* 実在する暦日か (ロールオーバー検出) */
|
|
2455
|
+
const realDate=(y,mo,d)=>{
|
|
2456
|
+
const t=new Date(Date.UTC(y, mo-1, d));
|
|
2457
|
+
return t.getUTCFullYear()===y && t.getUTCMonth()===mo-1 && t.getUTCDate()===d;
|
|
2458
|
+
};
|
|
2459
|
+
let s=String(input==null?'':input).replace(/ /g,' ').trim();
|
|
2460
|
+
if(!s) return null;
|
|
2461
|
+
s=s.replace(/\//g,'-');
|
|
2462
|
+
/* 完全 ISO (末尾にオフセット or Z) は **原文のまま返す** (後方互換: 既存プランの
|
|
2463
|
+
'…T00:00:00.000Z' を書き換えない)。ただし素通し=無検証にはしない。
|
|
2464
|
+
検証を飛ばすと 2026-02-30T10:00:00+0900 / 24:00 / 実在しない閏日が
|
|
2465
|
+
プランナーでは valid のまま書き出され、失敗が dry-run (backend 400) まで遅延する。 */
|
|
2466
|
+
const iso=s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:([+-])(\d{2}):?(\d{2})|Z)$/);
|
|
2467
|
+
if(iso){
|
|
2468
|
+
const hh=+iso[4], mi=+iso[5], ss=+(iso[6]||0);
|
|
2469
|
+
if(!realDate(+iso[1], +iso[2], +iso[3])) return {invalid:true, input:String(input)};
|
|
2470
|
+
if(hh>23 || mi>59 || ss>59) return {invalid:true, input:String(input)};
|
|
2471
|
+
/* UTC オフセットの実在範囲は -12:00〜+14:00 (公式の最大は +14:00)。
|
|
2472
|
+
+24:00 や +99:99 は Meta へ渡すと AdSet 作成時に落ち、キャンペーンだけ残る */
|
|
2473
|
+
if(iso[7]){
|
|
2474
|
+
const oh=+iso[8], om=+iso[9];
|
|
2475
|
+
if(om>59) return {invalid:true, input:String(input)};
|
|
2476
|
+
const mins=oh*60+om;
|
|
2477
|
+
if(iso[7]==='+' ? mins>14*60 : mins>12*60) return {invalid:true, input:String(input)};
|
|
2478
|
+
}
|
|
2479
|
+
return s;
|
|
2480
|
+
}
|
|
2481
|
+
if(/(?:[+-]\d{2}:?\d{2}|Z)$/.test(s)) return {invalid:true, input:String(input)}; /* オフセットらしき末尾だが形が不正 */
|
|
2482
|
+
let m=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
2483
|
+
if(m){
|
|
2484
|
+
if(!realDate(+m[1], +m[2], +m[3])) return {invalid:true, input:String(input)};
|
|
2485
|
+
const d=m[1]+'-'+p2(m[2])+'-'+p2(m[3]);
|
|
2486
|
+
return d + (o.endOfDay ? 'T23:59:59' : 'T00:00:00') + '+0900';
|
|
2487
|
+
}
|
|
2488
|
+
m=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
|
2489
|
+
if(m){
|
|
2490
|
+
const hh=+m[4], mi=+m[5], ss=+(m[6]||0);
|
|
2491
|
+
if(!realDate(+m[1], +m[2], +m[3])) return {invalid:true, input:String(input)};
|
|
2492
|
+
/* 24:00 は ISO8601 上は翌日 0:00 だが、意図と実挙動が食い違うので受け付けない */
|
|
2493
|
+
if(hh>23 || mi>59 || ss>59) return {invalid:true, input:String(input)};
|
|
2494
|
+
return m[1]+'-'+p2(m[2])+'-'+p2(m[3])+'T'
|
|
2495
|
+
+ p2(hh)+':'+p2(mi)+':'+p2(ss)+'+0900';
|
|
2496
|
+
}
|
|
2497
|
+
return {invalid:true, input:String(input)};
|
|
2498
|
+
},
|
|
2499
|
+
/* 正規化後の日時を epoch(ms) にする。空 / 不正 / 解釈不能は NaN。
|
|
2500
|
+
normDateTime が返す +0900 (コロン無し) は Date.parse が拒む環境があるのでコロンを補う。 */
|
|
2501
|
+
epochOf(v, o){
|
|
2502
|
+
/* opts はそのまま normDateTime に渡す。終了日時の前後比較では **必ず {endOfDay:true} を渡すこと**。
|
|
2503
|
+
渡さないと日付のみの終了日が 00:00 として比較され、
|
|
2504
|
+
「start=2026-08-31 18:00 / end=2026-08-31」(実出力は 23:59:59 なので正当) を
|
|
2505
|
+
END_BEFORE_START と誤判定してブロックしてしまう。 */
|
|
2506
|
+
const r=PLATFORMS.meta.mappers.normDateTime(v, o);
|
|
2507
|
+
if(!r || typeof r==='object') return NaN;
|
|
2508
|
+
return Date.parse(String(r).replace(/([+-]\d{2})(\d{2})$/, '$1:$2'));
|
|
2509
|
+
},
|
|
2510
|
+
/* 空は valid 扱い (未指定 = 上位/既定へフォールバック)。
|
|
2511
|
+
書式が合っていても実在しない日付 (2026-02-30 / 2026-13-99) や範囲外の時刻 (24:00)
|
|
2512
|
+
は normDateTime が {invalid:true} を返すので不正になる。 */
|
|
2513
|
+
isValidDateTime(v){
|
|
2514
|
+
const r=PLATFORMS.meta.mappers.normDateTime(v);
|
|
2515
|
+
if(r==null) return true; /* 空欄 */
|
|
2516
|
+
if(typeof r==='object') return false; /* 書式不一致 */
|
|
2517
|
+
return !isNaN(PLATFORMS.meta.mappers.epochOf(v));
|
|
2518
|
+
},
|
|
2121
2519
|
/* B13: JST 00:00 を明示した ISO8601 (日付のみだと UTC 解釈で前日にずれる) */
|
|
2122
|
-
startTime(dateStr){
|
|
2520
|
+
startTime(dateStr){
|
|
2521
|
+
const r=PLATFORMS.meta.mappers.normDateTime(dateStr);
|
|
2522
|
+
if(r && typeof r==='object') return String(r.input); /* 不正はそのまま返し検証側で弾く */
|
|
2523
|
+
return r || (today()+'T00:00:00+0900');
|
|
2524
|
+
},
|
|
2525
|
+
/* 終了日は「その日の終わりまで配信」の解釈。日付のみの入力を JST 23:59:59 に寄せる。
|
|
2526
|
+
空欄なら null を返し、呼び出し側で endTime キー自体を出さない (無期限配信)。 */
|
|
2527
|
+
endTime(dateStr){
|
|
2528
|
+
const r=PLATFORMS.meta.mappers.normDateTime(dateStr, {endOfDay:true});
|
|
2529
|
+
if(r && typeof r==='object') return String(r.input);
|
|
2530
|
+
return r;
|
|
2531
|
+
}
|
|
2123
2532
|
},
|
|
2124
2533
|
|
|
2125
2534
|
defaults:{
|
|
@@ -2128,12 +2537,13 @@ PLATFORMS.meta = {
|
|
|
2128
2537
|
cta:'詳しくはこちら', pageId:'', instagramUserId:'', pixelId:'', customEventType:'PURCHASE',
|
|
2129
2538
|
optimizationGoal:'OFFSITE_CONVERSIONS', billingEvent:'IMPRESSIONS',
|
|
2130
2539
|
bidStrategy:'LOWEST_COST_WITHOUT_CAP', buyingType:'AUCTION',
|
|
2131
|
-
deliveryStatus:'オフ', startDate:''
|
|
2540
|
+
deliveryStatus:'オフ', startDate:'', endDate:''
|
|
2132
2541
|
},
|
|
2133
|
-
|
|
2542
|
+
/* C6: adName は空 = 自動生成 (naming.adName) */
|
|
2543
|
+
ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adName:''},
|
|
2134
2544
|
adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
|
|
2135
2545
|
ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
|
|
2136
|
-
tg_placement:'自動配置', deliveryStatus:''},
|
|
2546
|
+
tg_placement:'自動配置', startDate:'', endDate:'', deliveryStatus:''},
|
|
2137
2547
|
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
|
|
2138
2548
|
objective:'OUTCOME_SALES', specialAdCategory:'NONE', variable:'', urlNumber:''}
|
|
2139
2549
|
},
|
|
@@ -2157,10 +2567,14 @@ PLATFORMS.meta = {
|
|
|
2157
2567
|
naming:{
|
|
2158
2568
|
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
2159
2569
|
adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
|
|
2570
|
+
/* C3: project.urlParams===false なら ?ad= / &creative= を付けない (欠落 undefined は ON = 従来挙動)。
|
|
2571
|
+
YDA と同じく base の既存クエリを壊さない (?の二重) / 自由入力の広告名は urlq でエスケープする。 */
|
|
2160
2572
|
finalUrl(ad, group, adName, project){
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2573
|
+
const base=(project.baseArticleUrl||'') + (ad.articleSlug||'');
|
|
2574
|
+
if(project.urlParams===false) return base;
|
|
2575
|
+
const sep = base.indexOf('?')>=0 ? '&' : '?';
|
|
2576
|
+
const code = urlq(group.paramCode||'');
|
|
2577
|
+
return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
|
|
2164
2578
|
},
|
|
2165
2579
|
campaignName(urlNumber, firstAd, P){
|
|
2166
2580
|
const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
|
|
@@ -2188,7 +2602,9 @@ PLATFORMS.meta = {
|
|
|
2188
2602
|
const P=PLATFORMS.meta, p=ctx.project, refs=ctx.refs;
|
|
2189
2603
|
const fmt=P.fmtOf(ad);
|
|
2190
2604
|
const crName=P.naming.crName(ad, P);
|
|
2191
|
-
|
|
2605
|
+
/* C6: 広告名は自由入力。空 = 自動生成にフォールバック */
|
|
2606
|
+
const autoAdName=P.naming.adName(ad, group, crName);
|
|
2607
|
+
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
2192
2608
|
const me = refs.media[String(ad.imageNo)];
|
|
2193
2609
|
const mediaId = me ? me[fmt.mediaKey] : undefined;
|
|
2194
2610
|
const thumbHash = me ? me.thumb : undefined;
|
|
@@ -2203,8 +2619,9 @@ PLATFORMS.meta = {
|
|
|
2203
2619
|
const pendingImage = (noStr!=='' && (mediaId===undefined || mediaId===''));
|
|
2204
2620
|
const T=P.refTables.titles.lengthLimit, D=P.refTables.descriptions.lengthLimit;
|
|
2205
2621
|
if(noStr==='') pushErr('クリエイティブ未解決', EC.IMAGE_UNRESOLVED);
|
|
2206
|
-
|
|
2207
|
-
if(String(ad.
|
|
2622
|
+
/* YDA と同仕様: 番号未入力は許容 (見出し・本文なしの広告)。番号ありで参照表に無い場合のみ未解決エラー */
|
|
2623
|
+
if(String(ad.titleNo||'').trim()!=='' && titleText===undefined) pushErr('見出し未解決', EC.TITLE_UNRESOLVED);
|
|
2624
|
+
if(String(ad.descNo||'').trim()!=='' && descText===undefined) pushErr('本文未解決', EC.DESC_UNRESOLVED);
|
|
2208
2625
|
if(titleText!==undefined && glen(titleText)>T.err) pushErr('見出し'+T.err+'字超', EC.TITLE_TOO_LONG);
|
|
2209
2626
|
else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
|
|
2210
2627
|
if(descText!==undefined && glen(descText)>D.err) pushErr('本文'+D.err+'字超', EC.DESC_TOO_LONG);
|
|
@@ -2212,28 +2629,62 @@ PLATFORMS.meta = {
|
|
|
2212
2629
|
if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
2213
2630
|
if(pendingImage) pushWarn(ad.format==='VIDEO'?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING);
|
|
2214
2631
|
const aid=String(group.adGroupId||'').trim();
|
|
2215
|
-
return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
|
|
2632
|
+
return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
|
|
2216
2633
|
mediaId:mediaId||'', thumbHash:thumbHash||'', pendingImage,
|
|
2217
2634
|
titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText,
|
|
2218
2635
|
finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
2219
2636
|
},
|
|
2220
2637
|
|
|
2221
|
-
/* ---- 広告セット行の追加検証 (
|
|
2638
|
+
/* ---- 広告セット行の追加検証 (予算・入札・配信日時) ---- */
|
|
2222
2639
|
validateRow(row, level, ctx){
|
|
2223
|
-
const P=PLATFORMS.meta, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
|
|
2640
|
+
const P=PLATFORMS.meta, M=P.mappers, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
|
|
2224
2641
|
if(level==='adGroup' && !isExistingGroup(row)){
|
|
2225
2642
|
if(!(Number(row.dailyBudget)>0)) errCodes.push(EC.BUDGET_MISSING);
|
|
2226
2643
|
if(P.enums.bidAmountRequiredFor.indexOf(p.bidStrategy)>=0 && !(Number(row.bidAmount)>0))
|
|
2227
2644
|
errCodes.push('BID_AMOUNT_REQUIRED');
|
|
2645
|
+
/* 配信日時。空欄は正常 (未指定 = 上位へフォールバック) なので isValidDateTime は空を valid とする。
|
|
2646
|
+
前後関係は buildPlan.adGroup と同じ解決順 (行 → 案件設定 → 既定) で比較する。 */
|
|
2647
|
+
const sRaw=String(row.startDate==null?'':row.startDate).trim();
|
|
2648
|
+
const eRaw=String(row.endDate==null?'':row.endDate).trim();
|
|
2649
|
+
const sBad=!M.isValidDateTime(sRaw), eBad=!M.isValidDateTime(eRaw);
|
|
2650
|
+
if(sBad) errCodes.push(EC.START_TIME_INVALID);
|
|
2651
|
+
if(eBad) errCodes.push(EC.END_TIME_INVALID);
|
|
2652
|
+
if(!sBad && !eBad){
|
|
2653
|
+
const s=M.epochOf(firstNonEmpty(sRaw, p.startDate) || today());
|
|
2654
|
+
const e=M.epochOf(firstNonEmpty(eRaw, p.endDate), {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
|
|
2655
|
+
if(!isNaN(s) && !isNaN(e) && e<s) errCodes.push(EC.END_BEFORE_START);
|
|
2656
|
+
}
|
|
2228
2657
|
}
|
|
2229
2658
|
return {errCodes, warnCodes};
|
|
2230
2659
|
},
|
|
2231
|
-
/* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ----
|
|
2232
|
-
|
|
2233
|
-
|
|
2660
|
+
/* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ----
|
|
2661
|
+
第2引数 state は任意。渡された場合は行の配信日時も走査して、不正日時のまま
|
|
2662
|
+
書き出されるのを toast でブロックする (行だけを黙って除外すると気づけないため)。 */
|
|
2663
|
+
validateProject(p, state){
|
|
2664
|
+
const P=PLATFORMS.meta, M=P.mappers, errs=[];
|
|
2234
2665
|
if(!String((p&&p.pageId)||'').trim()) errs.push({code:'PAGE_ID_MISSING', msg:'FBページIDを共通設定に入力してください'});
|
|
2235
2666
|
if(P.enums.pixelRequiredFor.indexOf(p&&p.optimizationGoal)>=0 && !String((p&&p.pixelId)||'').trim())
|
|
2236
2667
|
errs.push({code:EC.PIXEL_MISSING, msg:'最適化が'+p.optimizationGoal+'のためピクセルIDが必須です'});
|
|
2668
|
+
/* 案件設定チップの配信日時 */
|
|
2669
|
+
const ps=String((p&&p.startDate)||'').trim(), pe=String((p&&p.endDate)||'').trim();
|
|
2670
|
+
const psBad=!M.isValidDateTime(ps), peBad=!M.isValidDateTime(pe);
|
|
2671
|
+
if(psBad) errs.push({code:EC.START_TIME_INVALID, msg:'案件設定「配信開始日時」の書式が不正です(例 2026-08-01 10:30): '+ps});
|
|
2672
|
+
if(peBad) errs.push({code:EC.END_TIME_INVALID, msg:'案件設定「配信終了日時」の書式が不正です(例 2026-08-31 23:59): '+pe});
|
|
2673
|
+
if(!psBad && !peBad){
|
|
2674
|
+
const s=M.epochOf(ps||today()), e=M.epochOf(pe, {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
|
|
2675
|
+
if(!isNaN(s) && !isNaN(e) && e<s) errs.push({code:EC.END_BEFORE_START, msg:'案件設定の配信終了日時が配信開始日時より前です'});
|
|
2676
|
+
}
|
|
2677
|
+
/* 広告セット行の配信日時 (日予算・入札の未入力は従来どおり行除外で扱い、ここでは見ない) */
|
|
2678
|
+
const DATE_CODES=[EC.START_TIME_INVALID, EC.END_TIME_INVALID, EC.END_BEFORE_START];
|
|
2679
|
+
const MSG={};
|
|
2680
|
+
MSG[EC.START_TIME_INVALID]='の配信開始日時の書式が不正です(例 2026-08-01 10:30)';
|
|
2681
|
+
MSG[EC.END_TIME_INVALID] ='の配信終了日時の書式が不正です(例 2026-08-31 23:59)';
|
|
2682
|
+
MSG[EC.END_BEFORE_START] ='の配信終了日時が配信開始日時より前です';
|
|
2683
|
+
((state&&state.campaigns)||[]).forEach(c=>(c.adGroups||[]).forEach(g=>{
|
|
2684
|
+
const codes=(P.validateRow(g, 'adGroup', {project:p})||{}).errCodes||[];
|
|
2685
|
+
DATE_CODES.forEach(code=>{ if(codes.indexOf(code)>=0)
|
|
2686
|
+
errs.push({code, msg:'広告セット「'+String(g.name||'')+'」'+MSG[code]}); });
|
|
2687
|
+
}));
|
|
2237
2688
|
return errs;
|
|
2238
2689
|
},
|
|
2239
2690
|
|
|
@@ -2258,6 +2709,12 @@ PLATFORMS.meta = {
|
|
|
2258
2709
|
const cat=String((c&&c.specialAdCategory)||'NONE');
|
|
2259
2710
|
const K=P.enums.specialAdCategoryConstraints;
|
|
2260
2711
|
const constrained = K.appliesTo.indexOf(cat)>=0;
|
|
2712
|
+
/* 配信日時の解決順: 広告セット行 → 案件設定チップ → 既定 (開始のみ今日)。
|
|
2713
|
+
'' は「未指定 = 上位へフォールバック」であって「明示的な無期限」ではない。
|
|
2714
|
+
既知の制約: 案件設定チップに終了日時があるとき、特定の広告セットだけ「終了なし」にはできない
|
|
2715
|
+
(無期限は行・チップの両方を空にして達成する)。 */
|
|
2716
|
+
const startSrc = firstNonEmpty(g.startDate, p.startDate);
|
|
2717
|
+
const endSrc = firstNonEmpty(g.endDate, p.endDate);
|
|
2261
2718
|
const ag={
|
|
2262
2719
|
name:g.name,
|
|
2263
2720
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
@@ -2265,10 +2722,15 @@ PLATFORMS.meta = {
|
|
|
2265
2722
|
billingEvent:p.billingEvent||'IMPRESSIONS',
|
|
2266
2723
|
bidStrategy:p.bidStrategy||'LOWEST_COST_WITHOUT_CAP',
|
|
2267
2724
|
dailyBudget:Number(g.dailyBudget)||0, /* JPY はそのまま (currency_offset=1) */
|
|
2268
|
-
startTime:P.mappers.startTime(
|
|
2725
|
+
startTime:P.mappers.startTime(startSrc), /* 常に送る (空なら today())。JST 明示 ISO8601 */
|
|
2269
2726
|
/* 特別カテゴリ時は 18-65 固定・性別指定不可 (公式制約) */
|
|
2270
2727
|
ageMin: constrained?K.forcedAgeMin:(Number(g.ageMin)||18),
|
|
2271
|
-
ageMax
|
|
2728
|
+
/* 年齢上限は空欄 = 上限なし → ageMax キー自体を出さない (age_max 省略 = Meta 側で上限なし)。
|
|
2729
|
+
ただし特別広告カテゴリ時は forcedAgeMax 固定が最優先 (ポリシー制約なので上限なしは無視する)。 */
|
|
2730
|
+
...(constrained
|
|
2731
|
+
? {ageMax:K.forcedAgeMax}
|
|
2732
|
+
: (String(g.ageMax==null?'':g.ageMax).trim()!=='' && Number(g.ageMax)>0
|
|
2733
|
+
? {ageMax:Number(g.ageMax)} : {})),
|
|
2272
2734
|
genders: constrained?[]:P.mappers.genders(g.tg_gender, P),
|
|
2273
2735
|
devicePlatforms:P.mappers.devices(g.tg_device, P),
|
|
2274
2736
|
placements:P.mappers.placements(g.tg_placement, P),
|
|
@@ -2277,6 +2739,8 @@ PLATFORMS.meta = {
|
|
|
2277
2739
|
pixelId:String(p.pixelId||''), /* promoted_object 用 */
|
|
2278
2740
|
customEventType:p.customEventType||'PURCHASE'
|
|
2279
2741
|
};
|
|
2742
|
+
/* 終了日は任意。空欄なら endTime キー自体を出さない (= 無期限配信) */
|
|
2743
|
+
const endT=P.mappers.endTime(endSrc); if(endT) ag.endTime=endT;
|
|
2280
2744
|
if(P.enums.bidAmountRequiredFor.indexOf(ag.bidStrategy)>=0 || Number(g.bidAmount)>0)
|
|
2281
2745
|
ag.bidAmount=Number(g.bidAmount)||0;
|
|
2282
2746
|
const aid=resolveAdGroupIdOf(g); if(aid) ag.adSetId=aid;
|
|
@@ -2358,7 +2822,7 @@ PLATFORMS.meta = {
|
|
|
2358
2822
|
strategy(){
|
|
2359
2823
|
return ['Meta広告(Facebook / Instagram)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
|
|
2360
2824
|
'# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
|
|
2361
|
-
'- 目的(objective): <OUTCOME_SALES / OUTCOME_LEADS など>','- 予算・期間: <広告セットの日予算 /
|
|
2825
|
+
'- 目的(objective): <OUTCOME_SALES / OUTCOME_LEADS など>','- 予算・期間: <広告セットの日予算 / 配信開始日・配信終了日>',
|
|
2362
2826
|
'- 訴求したいポイント: <強み・オファー・ターゲット層>','',
|
|
2363
2827
|
'# お願い',
|
|
2364
2828
|
'1. 上記をもとにターゲティング方針(年齢・性別・配置)・キャンペーン/広告セット構成・訴求軸を提案してください。',
|
|
@@ -2422,26 +2886,55 @@ function rowIsExisting(item, col){
|
|
|
2422
2886
|
if(item.kind==='c') return isExistingCampaign(item.c);
|
|
2423
2887
|
return false;
|
|
2424
2888
|
}
|
|
2425
|
-
|
|
2889
|
+
/* ---- C1: セル値アクセスの get/set フック ----
|
|
2890
|
+
列記述子に col.get(row, ctx) / col.set(row, val, ctx, item) を足すと、
|
|
2891
|
+
「派生値を持つ列」を derived ではなく **通常の入力列** として宣言できる。
|
|
2892
|
+
copySelText は getVar のみ・pasteGrid は setVar のみを使うので、
|
|
2893
|
+
フックを持つ列はそれだけで コピー&ペーストのラウンドトリップ対象になる。
|
|
2894
|
+
ctx は行コンテキスト {tab, item, project, refs, r(解決結果・遅延)}。
|
|
2895
|
+
renderCell は行で1回作った ctx をそのまま渡すので resolve が列数ぶん走らない。 */
|
|
2896
|
+
function cellCtx(item, tab, ctx){
|
|
2897
|
+
if(ctx) return ctx;
|
|
2898
|
+
const o={tab:normTab(tab===undefined?sheetTab:tab), item, project:st.project, refs:st.refs};
|
|
2899
|
+
if(item && item.kind==='a'){
|
|
2900
|
+
Object.defineProperty(o,'r',{configurable:true, get(){ return resolveAd(item.a, item.g); }});
|
|
2901
|
+
}
|
|
2902
|
+
return o;
|
|
2903
|
+
}
|
|
2904
|
+
function getVar(item,c,tab,ctx){
|
|
2426
2905
|
const col=colDefs(tab)[c]; if(!col) return '';
|
|
2427
|
-
const row=rowOf(item,col);
|
|
2906
|
+
const row=rowOf(item,col); if(!row) return '';
|
|
2907
|
+
if(col.get){ const gv=col.get(row, cellCtx(item,tab,ctx)); return gv==null?'':String(gv); }
|
|
2908
|
+
const v=row[col.field];
|
|
2428
2909
|
return v==null?'':String(v);
|
|
2429
2910
|
}
|
|
2430
|
-
function setVar(item,c,val,tab){
|
|
2911
|
+
function setVar(item,c,val,tab,ctx){
|
|
2431
2912
|
const col=colDefs(tab)[c]; if(!col) return;
|
|
2432
2913
|
if(col.kind==='derived') return;
|
|
2433
2914
|
if(col.alwaysReadonly) return; /* adGroupId / campaignId */
|
|
2434
2915
|
if((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col)) return; /* F-2: 既存呼び出し行は基本パラメータ編集不可 */
|
|
2435
2916
|
const row=rowOf(item,col); if(!row) return;
|
|
2436
|
-
|
|
2917
|
+
const v = col.coerce?col.coerce(val):val;
|
|
2918
|
+
if(col.set) col.set(row, v, cellCtx(item,tab,ctx), item);
|
|
2919
|
+
else row[col.field] = v;
|
|
2437
2920
|
if(col.onSet) col.onSet(item,row);
|
|
2438
2921
|
}
|
|
2922
|
+
/* 列 field 指定で1セルを書く (change ハンドラを列記述子経由に載せるための入口)。
|
|
2923
|
+
列が見つからなければ false を返し、呼び出し側の旧経路にフォールバックさせる。 */
|
|
2924
|
+
function setVarByField(item, tab, field, val){
|
|
2925
|
+
const cols=colDefs(tab); const ci=cols.findIndex(c=>c.field===field);
|
|
2926
|
+
if(ci<0) return false;
|
|
2927
|
+
setVar(item, ci, val, tab);
|
|
2928
|
+
return true;
|
|
2929
|
+
}
|
|
2439
2930
|
function flatVisible(){ const out=[]; if(sheetTab==='groups'){ st.campaigns.forEach(c=>c.adGroups.forEach(g=>out.push({kind:'g',g,c}))); return out; } if(sheetTab==='campaigns'){ st.campaigns.forEach(c=>out.push({kind:'c',c})); return out; } const filter=document.getElementById('sheetFilter').value; st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach(a=>{ const r=resolveAd(a,g); const hasErr=r.errs.length>0; if(filter==='err'&&!hasErr)return; out.push({kind:'a',a,g,c}); }))); return out; }
|
|
2440
2931
|
let selA=null, selB=null, dragging=false;
|
|
2441
2932
|
function clearSel(){ selA=selB=null; applySelection(); }
|
|
2442
2933
|
function selRange(){ if(!selA||!selB) return null; return {r0:Math.min(selA.r,selB.r),r1:Math.max(selA.r,selB.r),c0:Math.min(selA.c,selB.c),c1:Math.max(selA.c,selB.c)}; }
|
|
2443
2934
|
function applySelection(){ const host=document.getElementById('sheetHost'); if(!host) return; host.querySelectorAll('.selcell,.selanchor').forEach(el=>el.classList.remove('selcell','selanchor')); host.querySelectorAll('.cellcombo.comboSel').forEach(el=>el.classList.remove('comboSel')); host.querySelectorAll('td.rownum.rowsel').forEach(el=>el.classList.remove('rowsel')); const rg=selRange(); if(!rg){ if(typeof updateSeqBtn==='function') updateSeqBtn(); return; } host.querySelectorAll('[data-sel]').forEach(el=>{ const r=+el.dataset.r, c=+el.dataset.c; if(r>=rg.r0&&r<=rg.r1&&c>=rg.c0&&c<=rg.c1){ el.classList.add('selcell'); const cb=el.closest('.cellcombo'); if(cb) cb.classList.add('comboSel'); if(selA&&r===selA.r&&c===selA.c) el.classList.add('selanchor'); } }); host.querySelectorAll('td.rownum').forEach(el=>{ const r=+el.dataset.rownum; if(r>=rg.r0&&r<=rg.r1) el.classList.add('rowsel'); }); if(typeof updateSeqBtn==='function') updateSeqBtn(); }
|
|
2444
|
-
function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible();
|
|
2935
|
+
function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible();
|
|
2936
|
+
/* C1: 範囲全セルで getVar → get フック列が resolveAd を呼ぶ。読み取り専用パスなのでメモ化する */
|
|
2937
|
+
return withResolvePass(()=>{ const lines=[]; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue; const cells=[]; for(let c=rg.c0;c<=rg.c1;c++) cells.push(getVar(it,c)); lines.push(cells.join('\t')); } return lines.join('\n'); }); }
|
|
2445
2938
|
function pasteGrid(txt){
|
|
2446
2939
|
const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
|
|
2447
2940
|
const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
|
|
@@ -2680,11 +3173,27 @@ function renderDrawer(){
|
|
|
2680
3173
|
listEl.innerHTML=h||'<div class="empty" style="padding:24px">広告グループがありません</div>'; return;
|
|
2681
3174
|
}
|
|
2682
3175
|
if(drawerTarget.type==='tg'){
|
|
2683
|
-
const
|
|
3176
|
+
const gcol=colDefs('adGroups').find(c=>c.field===drawerTarget.field);
|
|
3177
|
+
const isRef=!!(gcol && gcol.kind==='refpick');
|
|
3178
|
+
const cfg=isRef?null:pf().targeting[drawerTarget.field];
|
|
3179
|
+
const label=(cfg&&cfg.label)||(gcol&&gcol.label)||'';
|
|
3180
|
+
const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
|
|
2684
3181
|
const cur=(g&&g[drawerTarget.field]?String(g[drawerTarget.field]):'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
2685
|
-
titleEl.textContent=
|
|
2686
|
-
tgtEl.textContent='入力先: '+(g&&g.name?g.name:
|
|
2687
|
-
let h='';
|
|
3182
|
+
titleEl.textContent=label+' を選択(複数可)';
|
|
3183
|
+
tgtEl.textContent='入力先: '+(g&&g.name?g.name:agLabel())+' / '+label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)');
|
|
3184
|
+
let h='';
|
|
3185
|
+
if(isRef){
|
|
3186
|
+
/* C1: 選択肢は st.refs[col.refTable] (ID→name)。data-tgval には **実ID** を入れる */
|
|
3187
|
+
const store=refPickStore(gcol);
|
|
3188
|
+
Object.keys(store).forEach(id=>{
|
|
3189
|
+
const nm=refPickName(gcol,id);
|
|
3190
|
+
if(q && !String(id).includes(q) && !nm.toLowerCase().includes(q)) return;
|
|
3191
|
+
const on=cur.indexOf(String(id))>=0;
|
|
3192
|
+
h+='<div class="ddrow'+(on?' cur':'')+'" data-tgval="'+esc(id)+'"><span class="ddno">'+(on?'✓':'')+'</span><span class="ddtx">'+esc(nm||id)+' <span class="sm mono">'+esc(id)+'</span></span></div>';
|
|
3193
|
+
});
|
|
3194
|
+
listEl.innerHTML=h||'<div class="empty" style="padding:24px">参照データがありません(アカウントJSONの refs.'+esc(gcol.refTable)+')</div>'; return;
|
|
3195
|
+
}
|
|
3196
|
+
cfg.opts.forEach(o=>{ if(q&&!o.toLowerCase().includes(q)) return; const on=cur.indexOf(o)>=0; h+='<div class="ddrow'+(on?' cur':'')+'" data-tgval="'+esc(o)+'"><span class="ddno">'+(on?'✓':'')+'</span><span class="ddtx">'+esc(o)+'</span></div>'; });
|
|
2688
3197
|
listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
|
|
2689
3198
|
}
|
|
2690
3199
|
/* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
|
|
@@ -2729,16 +3238,25 @@ function setField(u,f,val){
|
|
|
2729
3238
|
if(f==='paramCode'){ if(isExistingGroup(found.g)) return; /* F-2: 既存呼び出しAGのparamCodeは編集不可 */ found.g.paramCode=val; autoResolveGroupIds(found.g,found.c); } // group-level
|
|
2730
3239
|
else { found.a[f]=val; }
|
|
2731
3240
|
}
|
|
3241
|
+
/* C1: シートのセル編集は **列記述子経由 (setVar)** に一本化する。
|
|
3242
|
+
こうしないと coerce / set フック (refpick の ID 絞り込み・記事URL正規化・refs 双方向編集) が
|
|
3243
|
+
「貼付では効くのに手入力では効かない」という不整合になる。
|
|
3244
|
+
列が見つからない場合だけ旧経路 (setField / 直代入) にフォールバックする。 */
|
|
2732
3245
|
document.addEventListener('change', e=>{
|
|
2733
3246
|
const t=e.target; if(!t.dataset||!t.dataset.u||!t.dataset.f) return;
|
|
2734
3247
|
const f=t.dataset.f; const val = t.type==='checkbox'? t.checked : t.value;
|
|
2735
|
-
mutate(()=>
|
|
3248
|
+
mutate(()=>{
|
|
3249
|
+
const found=findAd(t.dataset.u); if(!found) return;
|
|
3250
|
+
if(!setVarByField({kind:'a', a:found.a, g:found.g, c:found.c}, 'ads', f, val)) setField(t.dataset.u,f,val);
|
|
3251
|
+
});
|
|
2736
3252
|
});
|
|
2737
3253
|
/* 広告グループ/キャンペーン タブの一覧編集 */
|
|
2738
3254
|
document.addEventListener('change', e=>{
|
|
2739
3255
|
const t=e.target; if(!t.dataset) return;
|
|
2740
|
-
if(t.dataset.gu && t.dataset.gf){ const gf=t.dataset.gf, val=t.value; mutate(()=>{ const r=findGroup(t.dataset.gu); if(
|
|
2741
|
-
|
|
3256
|
+
if(t.dataset.gu && t.dataset.gf){ const gf=t.dataset.gf, val=t.value; mutate(()=>{ const r=findGroup(t.dataset.gu); if(!r) return;
|
|
3257
|
+
if(!setVarByField({kind:'g', g:r.g, c:r.c}, 'adGroups', gf, val)){ if(!isExistingGroup(r.g)){ r.g[gf]=val; if(gf==='paramCode') autoResolveGroupIds(r.g,r.c); } } }); return; } // F-2: 既存呼び出しAGは基本パラメータ編集不可 (setVar 側の readonlyOnExisting ガードが担保)
|
|
3258
|
+
if(t.dataset.cu && t.dataset.cf){ const cf=t.dataset.cf; const val=t.value; mutate(()=>{ const r=findCp(t.dataset.cu); if(!r) return;
|
|
3259
|
+
if(!setVarByField({kind:'c', c:r.c}, 'campaigns', cf, val)){ if(!isExistingCampaign(r.c)) r.c[cf]=(cf==='budget'||cf==='tcpa')?numOrNull(val):val; } }); return; } // F-2: 既存呼び出しCPは基本パラメータ編集不可
|
|
2742
3260
|
});
|
|
2743
3261
|
|
|
2744
3262
|
/* rename via contenteditable (tree) */
|
|
@@ -2982,7 +3500,11 @@ async function writePlanFile(name, text){
|
|
|
2982
3500
|
File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
|
|
2983
3501
|
IndexedDB キー 'accountsDir:<platform>' に第2ハンドルを保存 (入稿計画/ の 'dir' とは別枠)。
|
|
2984
3502
|
============================================================ */
|
|
2985
|
-
|
|
3503
|
+
/* C1: アカウントJSON は v2 (mccId / urlParamsEnabled / refs.audiences,placements を追加) まで受ける。
|
|
3504
|
+
**バージョンで分岐しない**: 追加キーはすべて optional なので、読み手は存在チェックだけで読む。
|
|
3505
|
+
v1 のまま置かれたファイルもそのまま動く。 */
|
|
3506
|
+
const ACC_JSON_VERSION=2;
|
|
3507
|
+
const ACC_JSON_VERSIONS=[1,2];
|
|
2986
3508
|
let accDirHandle=null, pendingAccDirHandle=null;
|
|
2987
3509
|
let accountFiles=[]; // フォルダ内の <名前>.json のベース名一覧 (File System Access接続時)
|
|
2988
3510
|
let httpAccountFiles=[]; // <account.dir>/index.json から取得した <名前> 一覧 (読み取り専用切替)
|
|
@@ -2995,12 +3517,7 @@ let httpAccountFiles=[]; // <account.dir>/index.json から取得した <名前>
|
|
|
2995
3517
|
const LEGACY_ACCOUNT_DIR='アカウント';
|
|
2996
3518
|
function accountDir(){ const P=pf(); return (P&&P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR; }
|
|
2997
3519
|
function legacyAccountDir(){ return curPf==='yda' ? LEGACY_ACCOUNT_DIR : null; }
|
|
2998
|
-
|
|
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
|
-
}
|
|
3520
|
+
/* (旧 fetchAccountIndex は fetchAccountNamesFor に統合済み。呼び出し元が無くなったため削除) */
|
|
3004
3521
|
async function fetchAccountJsonByName(name){
|
|
3005
3522
|
const rel=encodeURIComponent(name)+'.json';
|
|
3006
3523
|
const obj=await fetchJson(accountDir()+'/'+rel);
|
|
@@ -3008,16 +3525,34 @@ async function fetchAccountJsonByName(name){
|
|
|
3008
3525
|
const legacy=legacyAccountDir();
|
|
3009
3526
|
return legacy ? await fetchJson(legacy+'/'+rel) : null;
|
|
3010
3527
|
}
|
|
3011
|
-
|
|
3528
|
+
/* S11: 媒体別のアカウント名一覧。統合プルダウン (案件×媒体) はこれを全媒体ぶん結合して描画する。
|
|
3529
|
+
ACCOUNTS_BY_PF[<platform>] = ['<アカウント名>', ...] */
|
|
3530
|
+
const ACCOUNTS_BY_PF={};
|
|
3531
|
+
/* 1媒体ぶんのアカウント名一覧を取得 (index.json → 無ければディレクトリ一覧HTML)。 */
|
|
3532
|
+
async function fetchAccountNamesFor(pfId){
|
|
3533
|
+
const P=PLATFORMS[pfId]; if(!P) return [];
|
|
3534
|
+
const dir=(P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR;
|
|
3535
|
+
const legacy=(pfId==='yda')?LEGACY_ACCOUNT_DIR:null;
|
|
3012
3536
|
const set=new Set();
|
|
3013
3537
|
// (1) マニフェスト <account.dir>/index.json (file://・autoindex無しでも読める。旧レイアウトへフォールバック)
|
|
3014
|
-
try{
|
|
3538
|
+
try{
|
|
3539
|
+
let idx=await fetchJson(dir+'/index.json');
|
|
3540
|
+
if(!idx && legacy) idx=await fetchJson(legacy+'/index.json');
|
|
3541
|
+
if(idx){ const arr=Array.isArray(idx)?idx:(idx.accounts||[]); arr.map(String).filter(Boolean).forEach(n=>set.add(n)); }
|
|
3542
|
+
}catch(e){}
|
|
3015
3543
|
// (2) ディレクトリ一覧(HTML) — http配信時は index.json に無い新規ファイルも自動検出してマージ
|
|
3016
|
-
for(const
|
|
3017
|
-
if(!
|
|
3018
|
-
try{ const r=await fetch(
|
|
3544
|
+
for(const d of [dir, legacy]){
|
|
3545
|
+
if(!d) continue;
|
|
3546
|
+
try{ const r=await fetch(d+'/',{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
3547
|
}
|
|
3020
|
-
|
|
3548
|
+
return [...set].sort();
|
|
3549
|
+
}
|
|
3550
|
+
/* 全媒体のアカウント一覧を取得する。1媒体が読めなくても他媒体は出す (Promise ごとに握り潰す)。 */
|
|
3551
|
+
async function fetchAccountList(){
|
|
3552
|
+
await Promise.all(Object.keys(PLATFORMS).map(async id=>{
|
|
3553
|
+
try{ ACCOUNTS_BY_PF[id]=await fetchAccountNamesFor(id); }catch(e){ if(!ACCOUNTS_BY_PF[id]) ACCOUNTS_BY_PF[id]=[]; }
|
|
3554
|
+
}));
|
|
3555
|
+
httpAccountFiles=(ACCOUNTS_BY_PF[curPf]||[]).slice(); // 既存コード互換: 現在媒体の一覧
|
|
3021
3556
|
}
|
|
3022
3557
|
function accStatusText(){
|
|
3023
3558
|
if(!fsSupported) return 'このブラウザはフォルダ連携非対応';
|
|
@@ -3025,13 +3560,42 @@ function accStatusText(){
|
|
|
3025
3560
|
if(pendingAccDirHandle) return 'フォルダ再接続待ち(📁 フォルダ を押す)';
|
|
3026
3561
|
return 'フォルダ接続してください(現在は同梱の'+BUNDLED_ACCOUNT_NAME+'を使用中)';
|
|
3027
3562
|
}
|
|
3563
|
+
/* S11: 統合プルダウン用ヘルパ。option の value は "<platform>::<アカウント名>"。 */
|
|
3564
|
+
function accOptionValue(pfId, name){ return pfId+'::'+name; }
|
|
3565
|
+
function parseAccOptionValue(v){ const i=String(v).indexOf('::'); return i<0 ? {pf:curPf, name:String(v)} : {pf:String(v).slice(0,i), name:String(v).slice(i+2)}; }
|
|
3566
|
+
/* 媒体 pfId のアカウント名一覧。現在媒体でフォルダ接続中はフォルダ内ファイルを正とする。 */
|
|
3567
|
+
function accountNamesForPf(pfId){
|
|
3568
|
+
if(pfId===curPf && accDirHandle) return accountFiles.slice();
|
|
3569
|
+
const names=(ACCOUNTS_BY_PF[pfId]||[]).slice();
|
|
3570
|
+
const b=BUNDLES[pfId]||{};
|
|
3571
|
+
if(b.accountName && !names.includes(b.accountName)) names.unshift(b.accountName);
|
|
3572
|
+
return names;
|
|
3573
|
+
}
|
|
3028
3574
|
function renderAccountBar(){
|
|
3029
3575
|
const sel=document.getElementById('accSelect'); if(!sel) return;
|
|
3030
3576
|
const cur=st.project.accountName||BUNDLED_ACCOUNT_NAME;
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3577
|
+
const curVal=accOptionValue(curPf, cur);
|
|
3578
|
+
// S11: 「案件 × 媒体」を全媒体ぶん列挙する (片方の媒体の一覧が読めなくても、もう片方は出る)
|
|
3579
|
+
let opts='';
|
|
3580
|
+
Object.keys(PLATFORMS).forEach(pid=>{
|
|
3581
|
+
const names=accountNamesForPf(pid);
|
|
3582
|
+
if(pid===curPf && cur && !names.includes(cur)) names.unshift(cur);
|
|
3583
|
+
const label=(PLATFORMS[pid]&&PLATFORMS[pid].displayName)||pid;
|
|
3584
|
+
/* S11-fix: アカウント一覧が1件も取れない媒体 (file:// / アカウントディレクトリ未配置 / http未起動 で
|
|
3585
|
+
fetch が全滅した状態) でも、媒体そのものには到達できるようにプレースホルダを1件必ず出す。
|
|
3586
|
+
旧 #pfSeg / #mediaTabs が担っていた「媒体切替と媒体要件ドキュメントの閲覧」を統合プルダウンで維持する。
|
|
3587
|
+
value は "<platform>::"(アカウント名が空)で、selectAccount は媒体切替だけ行う。 */
|
|
3588
|
+
if(!names.length){
|
|
3589
|
+
const v=accOptionValue(pid,'');
|
|
3590
|
+
opts+='<option value="'+esc(v)+'"'+(pid===curPf?' selected':'')+'>'+esc(label)+'(アカウント未読込)</option>';
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
names.forEach(n=>{
|
|
3594
|
+
const v=accOptionValue(pid,n);
|
|
3595
|
+
const bundled=(BUNDLES[pid]&&BUNDLES[pid].accountName===n && !(pid===curPf&&accDirHandle));
|
|
3596
|
+
opts+='<option value="'+esc(v)+'"'+(v===curVal?' selected':'')+'>'+esc(n)+' '+esc(label)+(bundled?'(同梱)':'')+'</option>';
|
|
3597
|
+
});
|
|
3598
|
+
});
|
|
3035
3599
|
// F-3: プルダウン内にアクション項目 (新規追加/このアカウントを編集)。フォルダ接続オプションは廃止
|
|
3036
3600
|
opts+='<option disabled>──────────</option>';
|
|
3037
3601
|
opts+='<option value="__add__">+ 新規追加…</option>';
|
|
@@ -3069,7 +3633,7 @@ async function writeAccountJson(name, obj){
|
|
|
3069
3633
|
}
|
|
3070
3634
|
/* 参照データ差し替えのシーム: フォルダ読込・新規作成・E2Eテストが共通で使う */
|
|
3071
3635
|
window.applyAccountJson=function(obj){
|
|
3072
|
-
if(!obj||obj.accountJsonVersion
|
|
3636
|
+
if(!obj||ACC_JSON_VERSIONS.indexOf(Number(obj.accountJsonVersion))<0||!obj.refs) throw new Error('アカウントJSON形式が不正です(accountJsonVersion: '+ACC_JSON_VERSIONS.join(' または ')+')');
|
|
3073
3637
|
/* S9: platform 検証。未記載は 'yda' (旧アカウントJSONの後方互換)。別媒体なら媒体ごと切り替える。 */
|
|
3074
3638
|
const declared=String(obj.platform||'yda');
|
|
3075
3639
|
if(declared!==curPf){
|
|
@@ -3079,32 +3643,65 @@ window.applyAccountJson=function(obj){
|
|
|
3079
3643
|
snapshot();
|
|
3080
3644
|
st.refs=normalizeRefs(obj.refs);
|
|
3081
3645
|
pf().account.apply(obj, st.project); // S7: project への写し込みは媒体スキーマ (accountId書式/追加項目)
|
|
3646
|
+
/* S11-fix2: switchPlatform 内の initialState() は「同梱デフォルトアカウント」を基準に
|
|
3647
|
+
サンプルプランの accountId を照合するため、別アカウントを選ぶと必ず不一致になり
|
|
3648
|
+
campaigns=[] のまま残る。ここでアカウントが確定したので、プランが空のときに限り
|
|
3649
|
+
サンプルプランを読み直す (既存プランがあるときは触らない)。 */
|
|
3650
|
+
if((!st.campaigns || !st.campaigns.length) && samplePlan && Array.isArray(samplePlan.campaigns)
|
|
3651
|
+
&& samplePlan.campaigns.length && String(samplePlan.accountId||'')===String(st.project.accountId||'')){
|
|
3652
|
+
st.campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
|
|
3653
|
+
st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
|
|
3654
|
+
planMismatchMsg='';
|
|
3655
|
+
if(typeof renderPlanWarn==='function') renderPlanWarn();
|
|
3656
|
+
}
|
|
3082
3657
|
persist(); buildDatalists(); render(); renderAccountBar();
|
|
3083
3658
|
};
|
|
3084
|
-
|
|
3659
|
+
/* S11: 統合プルダウンの選択 = 「案件 × 媒体」。
|
|
3660
|
+
媒体が変わる場合は **先に** switchPlatform してから、その媒体のアカウントJSONを読む
|
|
3661
|
+
(逆順だと applyAccountJson の platform 検証・accountId 不一致の安全弁に弾かれる)。 */
|
|
3662
|
+
async function selectAccount(name, pfId){
|
|
3085
3663
|
if(String(name).startsWith('__')){ renderAccountBar(); return; } // アクション項目は change リスナーで処理
|
|
3664
|
+
const target=pfId||curPf;
|
|
3665
|
+
const prevPf=curPf;
|
|
3666
|
+
if(target!==curPf){
|
|
3667
|
+
if(!PLATFORMS[target]){ toast('この媒体はまだ選択できません(媒体スキーマ未登録)'); renderAccountBar(); return; }
|
|
3668
|
+
if(!window.switchPlatform(target)){ renderAccountBar(); return; }
|
|
3669
|
+
}
|
|
3670
|
+
// S11-fix: 「<媒体>(アカウント未読込)」プレースホルダ = 媒体切替のみ (アカウントJSONは読まない)
|
|
3671
|
+
if(!name){ renderAccountBar(); return; }
|
|
3086
3672
|
if(name===st.project.accountName){ renderAccountBar(); return; }
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
}
|
|
3094
|
-
|
|
3095
|
-
else { toast('先にアカウントフォルダに接続してください'); renderAccountBar(); }
|
|
3673
|
+
/* (1) フォルダ接続中で、そのフォルダにあるアカウント。
|
|
3674
|
+
S11-fix: 接続フォルダは媒体別ではないので、媒体をまたぐ選択のときは参照しない
|
|
3675
|
+
(別媒体の同名JSONを掴んで applyAccountJson の platform 検証で媒体が巻き戻る) */
|
|
3676
|
+
if(accDirHandle && target===prevPf && accountFiles.includes(name)){
|
|
3677
|
+
const obj=await readAccountJson(name);
|
|
3678
|
+
if(!obj){ toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); return; }
|
|
3679
|
+
try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }
|
|
3680
|
+
catch(e){ toast(e.message); renderAccountBar(); }
|
|
3096
3681
|
return;
|
|
3097
3682
|
}
|
|
3098
|
-
|
|
3099
|
-
if(
|
|
3100
|
-
|
|
3101
|
-
|
|
3683
|
+
// (2) index.json / ディレクトリ一覧に載っているアカウントを fetch で読み込み(読み取り専用切替)
|
|
3684
|
+
if((ACCOUNTS_BY_PF[curPf]||[]).includes(name)){
|
|
3685
|
+
const obj=await fetchAccountJsonByName(name);
|
|
3686
|
+
if(obj){ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }catch(e){ toast(e.message); renderAccountBar(); } }
|
|
3687
|
+
else { toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); }
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
// (3) 同梱デフォルトアカウント
|
|
3691
|
+
if(name===BUNDLED_ACCOUNT_NAME){ window.applyAccountJson({accountJsonVersion:ACC_JSON_VERSION, platform:curPf, accountId:BUNDLED_ACCOUNT_ID, name:BUNDLED_ACCOUNT_NAME, principal:bundledPrincipal, refs:buildRefs()}); toast('同梱アカウント '+BUNDLED_ACCOUNT_NAME+' に切替'); return; }
|
|
3692
|
+
if(!accDirHandle){ toast('先にアカウントフォルダに接続してください'); renderAccountBar(); return; }
|
|
3693
|
+
toast('アカウントJSONを読み込めません: '+name); renderAccountBar();
|
|
3102
3694
|
}
|
|
3103
3695
|
/* refAdd/refEdit/refDel 後にアクティブアカウントJSONへ自動保存 (フォルダ接続 & フォルダ内アカウント時) */
|
|
3104
3696
|
async function saveActiveAccountJson(){
|
|
3105
3697
|
if(!accDirHandle) return; const name=st.project.accountName; if(!name) return;
|
|
3106
3698
|
if(!accountFiles.includes(name)) return; // 同梱デフォルト等フォルダ外は書き戻さない
|
|
3107
|
-
|
|
3699
|
+
/* ⚠ writeAccountJson は JSON.stringify のフル上書き。ここで組み立てたキーしか残らないため、
|
|
3700
|
+
**必ず現ファイルを読んでマージする**。そうしないと、参照表を1件編集しただけで
|
|
3701
|
+
GUI が知らないキー (mccId / urlParamsEnabled / adNamePolicy / platform / projectName /
|
|
3702
|
+
baseArticleUrl …) がファイルから消え、次回読込で管理画面URLやURLパラメータが化ける。 */
|
|
3703
|
+
const prev=await readAccountJson(name);
|
|
3704
|
+
const obj=Object.assign({}, prev||{}, {accountJsonVersion:ACC_JSON_VERSION, accountId:String(st.project.accountId||''), name, principal:String(st.project.principal||''), updatedAt:new Date().toISOString(), refs:normalizeRefs(st.refs)});
|
|
3108
3705
|
await writeAccountJson(name, obj);
|
|
3109
3706
|
}
|
|
3110
3707
|
/* F-3: 「+新規追加」チューザー — ファイルから追加 / AIで追加 の2択 */
|
|
@@ -3306,7 +3903,9 @@ function tgList(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Bool
|
|
|
3306
3903
|
警告: pf().notSubmittedFields に値があるがJSON未反映の広告グループ数 (tgDropped)。 */
|
|
3307
3904
|
function buildSubmitPlanWithStats(){
|
|
3308
3905
|
const P=pf(), p=st.project;
|
|
3309
|
-
|
|
3906
|
+
/* droppedRefIds: 参照ID列 (refpick) に入っていた **数値IDでない値** を落とした件数。
|
|
3907
|
+
旧・日本語ラベルのまま保存された古いプランを黙って通さないための後方互換カウンタ。 */
|
|
3908
|
+
const stats={exported:0, unresolved:0, pendingImages:0, pendingImageNos:[], emptyGroups:0, emptyCampaigns:0, tgDropped:0, droppedRefIds:0};
|
|
3310
3909
|
/* 行レベル検証を持つ媒体 (Meta の 日予算/入札上限 必須) だけ rowErrors を集計する */
|
|
3311
3910
|
if(P.validateRow) stats.rowErrors=0;
|
|
3312
3911
|
const ctx=Object.assign(planCtx(), {stats});
|
|
@@ -3332,7 +3931,7 @@ function buildSubmitPlanWithStats(){
|
|
|
3332
3931
|
});
|
|
3333
3932
|
ag[GK]=kept;
|
|
3334
3933
|
stats.exported+=kept.length;
|
|
3335
|
-
// 未反映列 (YDA
|
|
3934
|
+
// 未反映列 (YDAは地域/サーチKW) はAPIのID解決が必要 → 出力ありのグループのみ警告計上
|
|
3336
3935
|
if(kept.length && notSub.some(f=>String(g[f]||'').trim()!=='')) stats.tgDropped++;
|
|
3337
3936
|
return ag;
|
|
3338
3937
|
}).filter(ag=>{ if(!ag) return false; if(ag[GK].length===0){ stats.emptyGroups++; return false; } return true; }); // B9: 空広告グループを除外 (null = 行検証NG)
|
|
@@ -3437,7 +4036,7 @@ async function exportSubmitJson(){
|
|
|
3437
4036
|
}
|
|
3438
4037
|
/* S10: 媒体固有のプロジェクト単位ガード (Meta の pageId / pixelId 必須) */
|
|
3439
4038
|
if(P.validateProject){
|
|
3440
|
-
const perrs=P.validateProject(st.project)||[];
|
|
4039
|
+
const perrs=P.validateProject(st.project, st)||[];
|
|
3441
4040
|
if(perrs.length){ toast(perrs[0].msg); return; }
|
|
3442
4041
|
}
|
|
3443
4042
|
const n=stats.exported;
|
|
@@ -3462,6 +4061,7 @@ async function exportSubmitJson(){
|
|
|
3462
4061
|
info+='<div class="mfield"><label>除外件数の内訳</label><div style="font-size:12.5px">'+(exc.length?esc(exc.join(' / ')):'なし')+'</div></div>';
|
|
3463
4062
|
if(stats.pendingImages) info+='<div class="callout amber">🆕 未登録画像 <b>'+stats.pendingImages+'</b> 件(管理番号: '+esc(stats.pendingImageNos.join(', '))+')。これらはアカウントのメディアライブラリに未登録のため、入稿前にローカル画像をアップロードして新規登録が必要です(下記プロンプト参照)。</div>';
|
|
3464
4063
|
if(stats.tgDropped) info+='<div class="callout amber">⚠ '+esc(pf().labels.tgDroppedWarn)+' を設定した広告グループが '+stats.tgDropped+' 件ありますが、これらはAPIのID解決が必要なため入稿JSONには反映されていません(入稿後に管理画面で設定してください)。</div>';
|
|
4064
|
+
if(stats.droppedRefIds) info+='<div class="callout amber">⚠ オーディエンス/配置の列に <b>'+stats.droppedRefIds+'</b> 件、数値IDでない値(旧・日本語ラベル等)が入っていたため入稿JSONから除外しました。参照データから選び直してください。</div>';
|
|
3465
4065
|
// Claudeに貼るプロンプト: 保存したJSONを Skillの入稿計画/ に入れて submit-plan で入稿を試みる
|
|
3466
4066
|
// S7: 文言・コマンドは pf().prompts.submitHelp / pf().output.submitCommand (媒体スキーマ)
|
|
3467
4067
|
const prompt=pf().prompts.submitHelp({fileName, savedRel, wrote,
|
|
@@ -3507,28 +4107,14 @@ document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.targe
|
|
|
3507
4107
|
document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(); renderSheet(); });
|
|
3508
4108
|
/* プランビュー: シート / ツリー モード切替 (1階層下) */
|
|
3509
4109
|
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(); });
|
|
3510
|
-
/*
|
|
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
|
-
});
|
|
4110
|
+
/* S11: 媒体セレクタ (#pfSeg) / 媒体要件タブ (#mediaTabs) は廃止。媒体切替はヘッダーの統合プルダウンに一本化 */
|
|
3526
4111
|
/* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
|
|
3527
4112
|
document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
|
|
3528
4113
|
/* 案件テンプレ: チップ直接編集 */
|
|
3529
|
-
document.getElementById('tplChips').addEventListener('change',e=>{ const el=e.target.closest('[data-proj]'); if(!el) return; const f=el.dataset.proj; const v = el.type==='number'? Number(el.value) : el.value; mutate(()=>{ st.project[f]=v; }); });
|
|
4114
|
+
document.getElementById('tplChips').addEventListener('change',e=>{ const el=e.target.closest('[data-proj]'); if(!el) return; const f=el.dataset.proj; const v = el.type==='checkbox'? el.checked : (el.type==='number'? Number(el.value) : el.value); mutate(()=>{ st.project[f]=v; }); });
|
|
3530
4115
|
document.getElementById('btnSubmitJson').addEventListener('click',exportSubmitJson);
|
|
3531
4116
|
document.getElementById('btnHelp').addEventListener('click',openHelpModal);
|
|
4117
|
+
document.getElementById('btnConsole').addEventListener('click',()=>openConsole('campaigns'));
|
|
3532
4118
|
document.getElementById('planWarnClose').addEventListener('click',()=>{ planMismatchMsg=''; renderPlanWarn(); });
|
|
3533
4119
|
document.getElementById('btnConnectFolder').addEventListener('click',connectFolder);
|
|
3534
4120
|
/* ---- B-1/B-3: アカウントバー wiring ---- */
|
|
@@ -3538,7 +4124,8 @@ document.getElementById('accFileInput').addEventListener('change',e=>{ const f=e
|
|
|
3538
4124
|
document.getElementById('accSelect').addEventListener('change',e=>{ const v=e.target.value;
|
|
3539
4125
|
if(v==='__add__'){ renderAccountBar(); openAddAccountModal(); return; }
|
|
3540
4126
|
if(v==='__edit__'){ renderAccountBar(); openEditAccountModal(); return; }
|
|
3541
|
-
|
|
4127
|
+
const t=parseAccOptionValue(v); // S11: "<platform>::<アカウント名>"
|
|
4128
|
+
selectAccount(t.name, t.pf);
|
|
3542
4129
|
});
|
|
3543
4130
|
/* E-1: キャンペーン追加導線はシート/ツリー内(data-act="add-campaign"/"add-campaign-existing")に配置。ツールバー独立ボタンは廃止 */
|
|
3544
4131
|
document.getElementById('histHost').addEventListener('click',async e=>{
|
|
@@ -3576,7 +4163,7 @@ sheetHostEl.addEventListener('mousedown',e=>{
|
|
|
3576
4163
|
pendingDrawer=null; dragMoved=false;
|
|
3577
4164
|
// S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
|
|
3578
4165
|
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}; }
|
|
4166
|
+
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
|
|
3580
4167
|
applySelection();
|
|
3581
4168
|
});
|
|
3582
4169
|
sheetHostEl.addEventListener('mouseover',e=>{
|
|
@@ -3649,6 +4236,27 @@ window.addEventListener('beforeunload', function(e){ if(_dirty){ e.preventDefaul
|
|
|
3649
4236
|
if(bundledRefs || samplePlan){ st=initialState(); st.project.accountId=BUNDLED_ACCOUNT_ID; st.project.accountName=BUNDLED_ACCOUNT_NAME; if(typeof buildDatalists==='function') buildDatalists(); render(); _dirty=false; }
|
|
3650
4237
|
else { toast('初期データ(アカウント/サンプルプラン)を読み込めません。httpサーバー経由で開くか、アカウントを接続してください'); }
|
|
3651
4238
|
renderAccountBar();
|
|
4239
|
+
/* S11-fix3: 「いま作業中のプラン」を アクティブプラン.json (ad-ops スキルが書き出す) から適用する。
|
|
4240
|
+
これが無いと同梱デフォルト (sample_account) で開くため、毎回プルダウンで案件×媒体を選び直す
|
|
4241
|
+
ことになり、さらにサンプルプランの accountId 不一致でプランが読み込まれない。 */
|
|
4242
|
+
try{
|
|
4243
|
+
const r=await fetch(ACTIVE_PLAN_FILE, {cache:'no-store'});
|
|
4244
|
+
if(r.ok){
|
|
4245
|
+
const a=await r.json();
|
|
4246
|
+
const tp=String(a.platform||'yda'), tn=String(a.accountName||'');
|
|
4247
|
+
if(tn && PLATFORMS[tp] && !(tp===curPf && tn===st.project.accountName)){
|
|
4248
|
+
/* アクティブプラン.json は追跡されるが、実クライアントのアカウントJSONは
|
|
4249
|
+
.gitignore で除外される (公開してよいのは index.json と sample_account.json のみ)。
|
|
4250
|
+
そのため clone 直後・CI・他メンバーの環境では、ここで指すファイルが存在しない。
|
|
4251
|
+
読めないまま進むとプランが空のままプランナーが立ち上がらないので、
|
|
4252
|
+
**存在確認してから切り替え、無ければ同梱デフォルトのまま起動する**。 */
|
|
4253
|
+
const dir=(PLATFORMS[tp].account&&PLATFORMS[tp].account.dir)||LEGACY_ACCOUNT_DIR;
|
|
4254
|
+
const acc=await fetchJson(dir+'/'+encodeURIComponent(tn)+'.json');
|
|
4255
|
+
if(acc) await selectAccount(tn, tp);
|
|
4256
|
+
else toast('アクティブプランのアカウント「'+tn+'」が見つかりません。同梱の既定アカウントで開きます');
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
}catch(e){}
|
|
3652
4260
|
})();
|
|
3653
4261
|
</script>
|
|
3654
4262
|
</body>
|