addashboard-cli 0.6.0 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ads.js +66 -8
- package/dist/commands/ads.js.map +1 -1
- package/dist/commands/submission.js +188 -21
- 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 +11 -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 +1 -1
- package/skills/ad-ops/README.md +1 -0
- package/skills/ad-ops/SKILL.md +207 -20
- package/skills/ad-ops/ad-planner.html +1179 -149
- package/skills/ad-ops/scripts/adops.sh +146 -9
- package/skills/ad-ops/scripts/plan_lint.py +66 -6
- 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
- package/skills/ad-ops//343/202/265/343/203/263/343/203/227/343/203/253/343/203/227/343/203/251/343/203/263.json +0 -4
- package/skills/ad-ops//343/202/265/343/203/263/343/203/227/343/203/253/343/203/227/343/203/251/343/203/263_meta.json +3 -4
- package/skills/ad-ops//343/202/242/343/202/257/343/203/206/343/202/243/343/203/226/343/203/227/343/203/251/343/203/263.json +0 -6
|
@@ -104,7 +104,22 @@
|
|
|
104
104
|
.chip .chipin{border:none; background:transparent; font-weight:700; color:var(--fg); font-family:inherit; font-size:11.5px; padding:0 1px; border-bottom:1px dashed var(--line); border-radius:0; min-width:0;}
|
|
105
105
|
.chip .chipin:hover{border-bottom-color:var(--muted);}
|
|
106
106
|
.chip .chipin:focus{outline:none; border-bottom:1px solid var(--accent); background:var(--accent-soft);}
|
|
107
|
+
/* C5: テキストチップは既定幅が狭く(記事URL=16ch 等)、長いURLの末尾が見えず
|
|
108
|
+
「文字数制限で入らない」と誤認される。編集中だけ広げる。
|
|
109
|
+
幅は renderTplChips が style 属性で直に書くのでカスケードでは勝てず !important が必要。
|
|
110
|
+
type 無しの <input>(= txt())だけが対象。number/checkbox/select には効かない。 */
|
|
111
|
+
.chip input.chipin:not([type]):focus{width:44ch !important; max-width:min(44ch, 60vw);}
|
|
107
112
|
.chip select.chipin{-webkit-appearance:none; appearance:none; cursor:pointer;}
|
|
113
|
+
/* kind:'datetime' = 日付ピッカー + 時刻ピッカーの2分割。
|
|
114
|
+
ネイティブの date/time を使うのでカレンダーと時計はブラウザ標準UIが出る。
|
|
115
|
+
時刻は空欄可(= 日付のみの指定)にしたいので datetime-local ではなく2つに分ける。 */
|
|
116
|
+
.chip input.chipin[type=date]{width:12ch;}
|
|
117
|
+
.chip input.chipin[type=time]{width:8ch; margin-left:2px;}
|
|
118
|
+
.chip input.chipin[type=time]:disabled{opacity:.35; cursor:not-allowed;}
|
|
119
|
+
.chip input.chipin[type=date]::-webkit-calendar-picker-indicator,
|
|
120
|
+
.chip input.chipin[type=time]::-webkit-calendar-picker-indicator{cursor:pointer; opacity:.55; padding:0;}
|
|
121
|
+
.chip input.chipin[type=date]:hover::-webkit-calendar-picker-indicator,
|
|
122
|
+
.chip input.chipin[type=time]:hover::-webkit-calendar-picker-indicator{opacity:1;}
|
|
108
123
|
/* 開閉式 戦略メモ */
|
|
109
124
|
.stratbox{border:1px solid var(--line); border-radius:9px; background:#fff; margin:0 0 12px; overflow:hidden;}
|
|
110
125
|
.stratbox>summary{list-style:none; cursor:pointer; padding:9px 14px; font-size:12.5px; font-weight:700; color:var(--fg); background:var(--softer); user-select:none; display:flex; align-items:center; gap:8px;}
|
|
@@ -242,6 +257,40 @@
|
|
|
242
257
|
.tgpick .tgcaret{ flex:0 0 auto; color:var(--muted); font-size:10px;}
|
|
243
258
|
.tgpick.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line); border-color:var(--accent);}
|
|
244
259
|
.tgpick.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
|
|
260
|
+
/* ── S14: フリークエンシー (kind:'freq')。モード + 3プルダウンを1行に並べる ── */
|
|
261
|
+
.freqtd{ vertical-align:middle;}
|
|
262
|
+
.freqbox{ display:flex; align-items:center; gap:4px; flex-wrap:nowrap; min-height:26px;
|
|
263
|
+
border:1px solid var(--line); border-radius:6px; padding:3px 6px; background:var(--soft); line-height:1.3;}
|
|
264
|
+
.freqbox.on{ background:#fff;}
|
|
265
|
+
.freqbox:hover{ border-color:var(--accent);}
|
|
266
|
+
.freqbox.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line); border-color:var(--accent);}
|
|
267
|
+
.freqbox.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
|
|
268
|
+
.freqin{ flex:0 0 auto; font-size:12px; padding:1px 4px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--fg);}
|
|
269
|
+
.freqmode{ min-width:70px;}
|
|
270
|
+
.freqnum{ min-width:58px;}
|
|
271
|
+
/* 「既定値」のとき、案件共通設定の中身を薄く出す (読み取り専用) */
|
|
272
|
+
.freqinherit{ flex:1 1 auto; min-width:0; font-size:11px; color:var(--faint); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
|
|
273
|
+
.freqoff{ font-size:12px; color:var(--faint);}
|
|
274
|
+
/* 案件設定チップ内も同じ1行 */
|
|
275
|
+
.chip .freqin{ margin-left:2px;}
|
|
276
|
+
/* ── S15: セルの日時 (kind:'datetime')。日付ピッカー + 時刻ピッカーの2分割 ──
|
|
277
|
+
チップ側 (S12) と同じ考え方。時刻を空欄にできるようにしたいので datetime-local は使わない。
|
|
278
|
+
data-sel は外側の .dtcell に付き、コピペ・範囲選択は従来どおり1本の文字列で往復する。 */
|
|
279
|
+
.dttd{ vertical-align:middle;}
|
|
280
|
+
.dtcell{ display:flex; align-items:center; gap:3px; flex-wrap:nowrap; min-height:26px;
|
|
281
|
+
border:1px solid var(--line); border-radius:6px; padding:2px 4px; background:#fff;}
|
|
282
|
+
.dtcell:hover{ border-color:var(--accent);}
|
|
283
|
+
.dtcell.err{ border-color:var(--danger);}
|
|
284
|
+
.dtcell.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line); border-color:var(--accent);}
|
|
285
|
+
.dtcell.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
|
|
286
|
+
/* 幅はネイティブ表示 (mm/dd/yyyy + カレンダーアイコン / --:-- + 時計アイコン) が
|
|
287
|
+
切れない実測値。狭いと "09/01/20…" のように末尾が欠けて年が読めなくなる。 */
|
|
288
|
+
.dtin{ flex:0 0 auto; font-size:12px; padding:2px 4px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--fg);}
|
|
289
|
+
.dtin[type=date]{ width:15ch;}
|
|
290
|
+
.dtin[type=time]{ width:11ch;}
|
|
291
|
+
.dtin:disabled{ opacity:.35; cursor:not-allowed;}
|
|
292
|
+
.dtin::-webkit-calendar-picker-indicator{ cursor:pointer; opacity:.55; padding:0;}
|
|
293
|
+
.dtin:hover::-webkit-calendar-picker-indicator{ opacity:1;}
|
|
245
294
|
body.noselect{ user-select:none; -webkit-user-select:none;}
|
|
246
295
|
.hinttxt{ font-size:11px; color:var(--faint);}
|
|
247
296
|
/* ── ref data view ── */
|
|
@@ -308,6 +357,8 @@
|
|
|
308
357
|
</div>
|
|
309
358
|
<span class="vbadge ok" id="vbadge" title="バリデーション状況">✓ 0件</span>
|
|
310
359
|
<div class="spacer"></div>
|
|
360
|
+
<!-- C9: 管理画面リンクは consoleLink() が媒体スキーマの consoleUrl から組み立てる -->
|
|
361
|
+
<button class="act ghost" id="btnConsole" title="選択中アカウントの管理画面(キャンペーン一覧)を開く">管理画面 ↗</button>
|
|
311
362
|
<button class="act ghost" id="btnHelp" title="このプランナーの使い方">❓ 使い方</button>
|
|
312
363
|
<button class="act ghost" id="btnUndo" title="元に戻す (Ctrl/Cmd+Z)">↶ 戻す</button>
|
|
313
364
|
<button class="act primary" id="btnSubmitJson">✅ 完成 → 保存して入稿</button>
|
|
@@ -406,7 +457,8 @@
|
|
|
406
457
|
<div class="body">
|
|
407
458
|
<p class="lead">全広告に共通する固定値は「案件テンプレ」で1箇所定義し全広告へ一括適用する(現行の全行ハードコードを廃止)。</p>
|
|
408
459
|
<table class="g"><thead><tr><th>項目</th><th>既定値</th><th>備考</th></tr></thead><tbody>
|
|
409
|
-
<tr><td>アトリビューション計測期間</td><td><b>1日</b></td><td>媒体既定7日を毎回1
|
|
460
|
+
<tr><td>アトリビューション計測期間</td><td><b>1日</b></td><td>媒体既定7日を毎回1日に変更する慣習。<b>入稿APIの対象外</b>なので管理画面側で設定する(プランナーの案件設定チップからは撤去済み)</td></tr>
|
|
461
|
+
<tr><td>フリークエンシー</td><td><b>未設定</b></td><td>ONにすると <code>viewableFrequencyCap</code>(適用レベル / 期間単位 / 上限回数)をキャンペーンに付ける。案件設定チップが新規キャンペーンの既定になり、キャンペーン行で個別に変更できる</td></tr>
|
|
410
462
|
<tr><td>CTAボタン</td><td><b>もっとみる</b></td><td>画像入稿の実データ既定。動画入稿は「詳細を見る」。<span class="pill amber">要確定</span></td></tr>
|
|
411
463
|
<tr><td>入札戦略</td><td><b>tCPA 8,500円</b></td><td>API値は <code>MAXIMIZE_CONVERSIONS + targetCpa:8500</code>。もう一択は「最大配信」</td></tr>
|
|
412
464
|
<tr><td>予算</td><td>新規 12,000 / ASC 50,000〜</td><td>モードで切替</td></tr>
|
|
@@ -431,7 +483,8 @@ mediaId = media[ 画像番号 ][ 形式 ] <span class="c">→ 2166762
|
|
|
431
483
|
<span class="c">// タイトル/説明文 … 番号で文言ライブラリを引く</span>
|
|
432
484
|
title = titles[ タイトル番号 ] desc = titles[ 説明文番号 ]
|
|
433
485
|
<span class="c">// 最終リンク先URL(入稿JSONの finalUrl)</span>
|
|
434
|
-
finalUrl = baseURL +
|
|
486
|
+
finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
487
|
+
<span class="c">// 案件設定の「URLパラメータ」を OFF にすると finalUrl = baseURL のみ</span></div>
|
|
435
488
|
<div class="grid2" style="margin-top:12px">
|
|
436
489
|
<div class="kv"><h4>キャンペーン名</h4><p class="mono" style="font-size:12px">{空き番号}_{CR名}_{YYYY-MM-DD}</p><p>空き番号は使用済みから未使用を割当(連番でなくてよい・被らないことだけ守る)。</p></div>
|
|
437
490
|
<div class="kv"><h4>入稿リンク末尾番号</h4><p>ベースURLは案件固定、末尾パラメータ番号のみ可変。既存の使用済み番号から空きを自動割当。</p></div>
|
|
@@ -455,10 +508,10 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
455
508
|
<details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
|
|
456
509
|
<div class="body">
|
|
457
510
|
<table class="g"><thead><tr><th>場所</th><th>入力項目</th><th>入稿JSONへの反映</th></tr></thead><tbody>
|
|
458
|
-
<tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b
|
|
459
|
-
<tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 /
|
|
460
|
-
<tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / <b>ターゲティング列</b
|
|
461
|
-
<tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 /
|
|
511
|
+
<tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b>配信開始日時</b> / CTA / 配信 / タイプ / 入札既定(tCPA・目標CPA) / フリークエンシー / 主体者 / 表示URL / 記事URL / 予算(新規・ASC)</td><td>accountId・goal・startDate・入札既定・予算既定・finalUrlベース</td></tr>
|
|
512
|
+
<tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 入稿リンク番号</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
|
|
513
|
+
<tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / スマートTG / <b>ターゲティング列</b>(デバイス・性別・年齢・地域・曜日時間帯・<b>オーディエンス(含む/除外)</b>・<b>配置(含む/除外)</b>・サーチKW)</td><td>name・status・smartTargeting・devices/ages/genders/days(+startHour/endHour)・<b>audiencesInclude/Exclude・placementsInclude/Exclude(実IDで送信)</b>。<b>未反映は 地域・サーチKW のみ</b></td></tr>
|
|
514
|
+
<tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 最終リンク先URL(自動) / タイトル本文 / 説明文本文 / 画像パス / 広告名 / 手入稿フラグ</td><td>ads[].adName・mediaIds・imagePaths・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
|
|
462
515
|
</tbody></table>
|
|
463
516
|
</div></details>
|
|
464
517
|
<details class="sec" open><summary><span class="num">2</span><span class="stitle">入稿JSON — 構造と入力項目・制約(パラメータ)</span><span class="chev"></span></summary>
|
|
@@ -474,19 +527,20 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
474
527
|
<table class="g"><thead><tr><th>項目</th><th>画面上の出どころ</th><th>変換・制約</th></tr></thead><tbody>
|
|
475
528
|
<tr><td class="mono">accountId</td><td><b>案件設定「アカウントID」</b></td><td>必須。未入力だと書き出し中止</td></tr>
|
|
476
529
|
<tr><td class="mono">goal</td><td><b>案件設定「目的」</b></td><td><code>CONVERSION</code> / <code>CLICK</code> / <code>BRAND_AWARENESS</code> / <code>VIDEO_VIEW</code> / <code>VIEWABLE_IMPRESSION</code></td></tr>
|
|
477
|
-
<tr><td class="mono">startDate</td><td><b
|
|
530
|
+
<tr><td class="mono">startDate</td><td><b>案件設定「配信開始日時」</b>(カレンダー+時計から選択)</td><td>YYYY-MM-DD。空欄=書き出し日。<b>時刻を入れても入稿ボディには日付部分だけが載る</b>(Yahoo Ads API の startDate は日付のみのISO。時刻は計画上のメモとして保存JSONに残る)</td></tr>
|
|
478
531
|
<tr><td class="mono">status</td><td>キャンペーン行/広告グループ行の「配信設定」</td><td><code>オン→ACTIVE</code> / <code>オフ→PAUSED</code>(既定オフ=誤配信防止)</td></tr>
|
|
479
532
|
<tr><td class="mono">budget</td><td>キャンペーン行「1日の予算」</td><td>未入力時はモード別既定(新規12,000/ASC50,000)</td></tr>
|
|
480
533
|
<tr><td class="mono">biddingStrategy</td><td>キャンペーン行「入札戦略」+ 目標CV単価</td><td><code>tCPA → MAXIMIZE_CONVERSIONS + targetCpa</code>(tCPA値はキャンペーン行→案件既定8,500の順)/ <code>最大配信 → MAXIMIZE_CONVERSIONS</code>(目標なし <span class="pill amber">要実機確認</span>)</td></tr>
|
|
481
534
|
<tr><td class="mono">devices</td><td>広告グループ行「デバイス」</td><td><code>スマートフォン→SMARTPHONE</code> / <code>PC→DESKTOP</code> / <code>タブレット→TABLET</code>。全てのデバイス/空=全配信(<code>[]</code>)</td></tr>
|
|
482
535
|
<tr><td class="mono">ages / genders</td><td>広告グループ行「年齢」「性別」</td><td><code>20-24歳→GT_RANGE20_24</code> 等 / <code>男性→ST_MALE</code>・<code>女性→ST_FEMALE</code>・<code>不明→ST_UNKNOWN</code>。空=全配信</td></tr>
|
|
483
536
|
<tr><td class="mono">days / startHour / endHour</td><td>広告グループ行「曜日・時間帯」</td><td><code>月→MONDAY</code> 等。<code>平日</code>=月〜金、<code>土日祝</code>=土日 に展開。時間帯(午前(6-12時)等)は <code>startHour/endHour</code> に変換(複数選択時は最初の1つ)</td></tr>
|
|
484
|
-
<tr><td class="mono">audiences* / placements*</td><td
|
|
485
|
-
<tr><td class="mono">ads[].adName</td><td
|
|
537
|
+
<tr><td class="mono">audiences* / placements*</td><td>広告グループ行「オーディエンス(除外)」「配置(除外)」</td><td><b>参照表(refs.audiences / refs.placements)の実IDで送信される</b>(表示は name)。数値IDでない値(旧・日本語ラベルや手入力の名前)は落として書き出し時に警告。<b>JSONに反映されない列は「地域」「サーチキーワード」だけ</b>(入稿後に管理画面で設定)</td></tr>
|
|
538
|
+
<tr><td class="mono">ads[].adName</td><td>広告行「広告名」(空欄なら<span class="pill">自動</span>)</td><td>空欄時の生成式 <code>{画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode}</code>。手入力するとその名前が使われ、<code>finalUrl</code> の <code>creative=</code> も変わる</td></tr>
|
|
486
539
|
<tr><td class="mono">ads[].mediaIds</td><td><span class="pill">自動</span></td><td>画像番号+形式 → 参照表の mediaId。未解決は入稿対象外</td></tr>
|
|
540
|
+
<tr><td class="mono">ads[].imagePaths</td><td>広告行「画像パス」</td><td><b>参照表に無い画像番号(未登録)のときだけ</b>送る。CLI が入稿前にアップロードして mediaId を解決する。画像番号が解決済みの行に入れても<b>送らない</b>(CLI は追加アップロードするが広告には既存 mediaId が使われるため。差し替えたいときは画像番号を空にする)</td></tr>
|
|
487
541
|
<tr><td class="mono">ads[].headlines</td><td><span class="pill purple">可変</span></td><td>タイトル番号 → 文言。<b>35字以内</b>(API上のアセット制約はタイトル≤20字・説明文≤90字)</td></tr>
|
|
488
542
|
<tr><td class="mono">ads[].descriptions</td><td><span class="pill purple">可変</span></td><td>説明文番号 → 文言</td></tr>
|
|
489
|
-
<tr><td class="mono">ads[].finalUrl</td><td
|
|
543
|
+
<tr><td class="mono">ads[].finalUrl</td><td>自動生成(広告行の入力項目ではない)</td><td>YDA は <code>baseURL + paramCode + "?ad=" + paramCode + "&creative=" + paramCode + "-" + 広告名</code> / Meta は <code>baseURL + "?ad=" + paramCode</code>。案件設定の <b>URLパラメータ を OFF</b> にすると <code>baseURL</code> のみ(paramCode 列は非表示になり未入力でもエラーにならない)。記事URLに既にクエリがある場合は <code>&</code> で連結する</td></tr>
|
|
490
544
|
</tbody></table>
|
|
491
545
|
<div class="callout">プランナーの保存JSON(バージョン保存)はこれとは別物で、<code>schemaVersion:2</code> の全state(meta / project / campaigns / ui / refs / submit)を保持する。<b>meta</b> = バージョンID・作成/更新日時・作成者・対象媒体・名称・メモ。旧v1 JSONも読込時に自動マイグレーションされる。</div>
|
|
492
546
|
</div></details>
|
|
@@ -626,7 +680,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
626
680
|
<div class="toolbar2">
|
|
627
681
|
<button class="act ghost" id="btnDupSel" title="選択した行を複製">⧉ 選択を複製</button>
|
|
628
682
|
<button class="act ghost" id="btnSeqParam" style="display:none" title="選択した列(paramCode / 画像番号 / タイトル番号 / 説明文番号)を先頭セル基点に連番採番">🔢 連番</button>
|
|
629
|
-
<span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(
|
|
683
|
+
<span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(1セルのコピーは選択範囲全体へ充填)/範囲選択のままプルダウン・入力欄を確定すると同じ列へ一括、ターゲティング列はサイドバーで一括ON/OFF</span>
|
|
630
684
|
<div class="spacer" style="flex:1"></div>
|
|
631
685
|
<span class="filt">表示 <select id="sheetFilter">
|
|
632
686
|
<option value="all">すべて</option>
|
|
@@ -649,13 +703,8 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
649
703
|
<!-- ============ アカウントアセットビュー ============ -->
|
|
650
704
|
<section class="view" id="view-ref">
|
|
651
705
|
<div class="toolbar2">
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
<button data-rt="descriptions">説明文</button>
|
|
655
|
-
<button data-rt="media">登録画像</button>
|
|
656
|
-
<button data-rt="adGroups">広告グループ</button>
|
|
657
|
-
<button data-rt="campaigns">キャンペーン</button>
|
|
658
|
-
</div>
|
|
706
|
+
<!-- C1: 参照データタブは pf().refTables から動的生成する (renderChrome)。静的ボタンは持たない -->
|
|
707
|
+
<div class="seg" id="refTabs"></div>
|
|
659
708
|
<span class="filt">検索 <input id="refSearch" type="text" placeholder="番号・文言・IDで絞り込み" style="width:220px"></span>
|
|
660
709
|
<div class="spacer" style="flex:1"></div>
|
|
661
710
|
<span class="chip" id="refCount">—</span>
|
|
@@ -734,6 +783,29 @@ const uid = p => p + '_' + (Date.now().toString(36)) + (_uc++).toString(36);
|
|
|
734
783
|
const esc = s => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
735
784
|
const glen = s => [...String(s==null?'':s)].length;
|
|
736
785
|
const today = () => { const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
|
|
786
|
+
/* S12: 日時文字列 "YYYY-MM-DD[ HH:mm[:ss]]" ⇄ {date, time} の相互変換。
|
|
787
|
+
kind:'datetime' チップ (日付ピッカー + 時刻ピッカー) と state の1本文字列を橋渡しする。
|
|
788
|
+
・秒は input[type=time] の既定表示に合わせて分までに丸める (秒付きの旧データも壊さず読める)
|
|
789
|
+
・書式が読めない値は date にも time にも入れない (= 空のピッカー) が、state は触らない */
|
|
790
|
+
const splitDateTime = v => {
|
|
791
|
+
const m=String(v==null?'':v).trim().match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/);
|
|
792
|
+
return m ? {date:m[1], time:m[2]||''} : {date:'', time:''};
|
|
793
|
+
};
|
|
794
|
+
/* 日付が空なら時刻だけ残しても意味がないので全体を空にする (= 未指定) */
|
|
795
|
+
const joinDateTime = (date, time) => {
|
|
796
|
+
const d=String(date||'').trim(); if(!d) return '';
|
|
797
|
+
const t=String(time||'').trim(); return t ? d+' '+t : d;
|
|
798
|
+
};
|
|
799
|
+
/* 入稿ボディ用: 日時文字列から日付部分だけを取り出す (YDA の startDate は日付のみの ISO) */
|
|
800
|
+
const dateOnly = v => splitDateTime(v).date;
|
|
801
|
+
/* 引数を左から順に見て「trim して空文字でない最初の値」を返す。優先順位を1行で書くための道具。 */
|
|
802
|
+
const firstNonEmpty = (...vs) => { for(const v of vs){ const s=String(v==null?'':v).trim(); if(s!=='') return s; } return ''; };
|
|
803
|
+
/* URLのクエリ値に埋める文字列のエスケープ (媒体非依存のURLユーティリティ)。
|
|
804
|
+
C6 で広告名が自由入力になったため、'&' '#' 空白などを生で連結すると
|
|
805
|
+
creative= の値がそこで切れて別パラメータが生える / リンクが壊れる。
|
|
806
|
+
**URL構造を壊す文字だけ**をパーセントエンコードし、日本語はそのまま残す
|
|
807
|
+
(計測ツール上での可読性を優先。1パスなので %→%25 の二重エンコードは起きない)。 */
|
|
808
|
+
const urlq = s => String(s==null?'':s).replace(/[%&#?=+\s]/g, c=>encodeURIComponent(c));
|
|
737
809
|
function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove('show'),2200); }
|
|
738
810
|
|
|
739
811
|
/* ---- エラー / 警告の安定コード (媒体非依存の語彙) ----
|
|
@@ -744,8 +816,13 @@ const EC = {
|
|
|
744
816
|
DESC_UNRESOLVED:'DESC_UNRESOLVED', TITLE_TOO_LONG:'TITLE_TOO_LONG',
|
|
745
817
|
DESC_TOO_LONG:'DESC_TOO_LONG', PARAM_CODE_EMPTY:'PARAM_CODE_EMPTY',
|
|
746
818
|
MEDIA_MISSING:'MEDIA_MISSING' /* = pendingImage (warn) */,
|
|
819
|
+
IMAGE_PATH_MISSING:'IMAGE_PATH_MISSING' /* 未登録画像なのにローカル画像パス未入力 (warn) */,
|
|
820
|
+
IMAGE_PATH_IGNORED:'IMAGE_PATH_IGNORED' /* 画像番号が解決済みなのにローカル画像パスも入力 (warn) */,
|
|
747
821
|
TITLE_LONG_WARN:'TITLE_LONG_WARN', DESC_LONG_WARN:'DESC_LONG_WARN',
|
|
748
|
-
BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING'
|
|
822
|
+
BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING',
|
|
823
|
+
/* 配信日時 (媒体が日時列を持つ場合に使う。空欄はエラーにしない = 未指定は上位へフォールバック) */
|
|
824
|
+
START_TIME_INVALID:'START_TIME_INVALID', END_TIME_INVALID:'END_TIME_INVALID',
|
|
825
|
+
END_BEFORE_START:'END_BEFORE_START'
|
|
749
826
|
};
|
|
750
827
|
|
|
751
828
|
/* ---- refs (VLOOKUP代替) ----
|
|
@@ -775,6 +852,43 @@ function renderPlatformTabs(){
|
|
|
775
852
|
}
|
|
776
853
|
/* ---- S10: 媒体で変わる UI 文言 (タイトル / ブランド名 / シートタブ / 参照データタブ) ----
|
|
777
854
|
媒体語彙 (「広告グループ」「登録画像」…) を静的HTMLに残さず、媒体スキーマから流し込む。 */
|
|
855
|
+
/* ---- C9: 管理画面URL は媒体スキーマの consoleUrl テンプレだけが持つ (手で組み立てない) ----
|
|
856
|
+
{mcc} … YDA の loginAccountId。st.project.consoleMcc → 無ければ accountId にフォールバック
|
|
857
|
+
{accountId} … アカウントID (Meta は act_ 付きのまま)
|
|
858
|
+
{accountIdNum} … act_ を剥いた数値部分 (Meta 用)
|
|
859
|
+
{resource} … campaigns | adGroups | ads (媒体別の語は consoleResourceMap が吸収する) */
|
|
860
|
+
function consoleLink(resource){
|
|
861
|
+
const P=pf(); if(!P||!P.consoleUrl) return '';
|
|
862
|
+
const p=st.project||{};
|
|
863
|
+
const accountId=String(p.accountId||'').trim();
|
|
864
|
+
if(!accountId) return '';
|
|
865
|
+
const mcc=String(p.consoleMcc||'').trim();
|
|
866
|
+
/* ⚠ mcc が要るテンプレ(YDA)で accountId にフォールバックしてはいけない。
|
|
867
|
+
YDA の管理画面は #/{MCCのID}/{アカウントID}/... で、MCC 位置にアカウントIDを入れると
|
|
868
|
+
エラーページ(errors/error404)に飛ぶ。しかも「リンクは開くが404」なので原因が分かりにくい。
|
|
869
|
+
mcc が無いときはリンクを出さず、アカウントJSONの mccId 未設定だと明示する。 */
|
|
870
|
+
if(P.consoleUrl.indexOf('{mcc}')>=0 && !mcc) return '';
|
|
871
|
+
const res=(P.consoleResourceMap&&P.consoleResourceMap[resource])||resource||'campaigns';
|
|
872
|
+
return P.consoleUrl
|
|
873
|
+
.replace('{mcc}', mcc)
|
|
874
|
+
.replace('{accountId}', accountId)
|
|
875
|
+
.replace('{accountIdNum}', accountId.replace(/^act_/,''))
|
|
876
|
+
.replace('{resource}', res);
|
|
877
|
+
}
|
|
878
|
+
window.consoleLink=consoleLink;
|
|
879
|
+
function openConsole(resource){
|
|
880
|
+
const url=consoleLink(resource||'campaigns');
|
|
881
|
+
if(!url){
|
|
882
|
+
const P=pf(), p=st.project||{};
|
|
883
|
+
if(P && P.consoleUrl && P.consoleUrl.indexOf('{mcc}')>=0 && String(p.accountId||'').trim() && !String(p.consoleMcc||'').trim()){
|
|
884
|
+
toast('アカウントJSONに mccId が未設定のため管理画面を開けません(mccId にMCCのアカウントIDを設定してください)');
|
|
885
|
+
} else {
|
|
886
|
+
toast('アカウントを選択してください(上部のアカウントセレクト)');
|
|
887
|
+
}
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
try{ window.open(url,'_blank','noopener'); }catch(e){}
|
|
891
|
+
}
|
|
778
892
|
/* 中間階層のラベル (YDA=広告グループ / Meta=広告セット)。UI 文言はここから供給する */
|
|
779
893
|
function agLabel(){ const P=pf(); return (P&&P.levels&&P.levels.adGroup&&P.levels.adGroup.label)||'広告グループ'; }
|
|
780
894
|
function renderChrome(){
|
|
@@ -785,9 +899,17 @@ function renderChrome(){
|
|
|
785
899
|
document.querySelectorAll('#sheetTabs button[data-st]').forEach(b=>{
|
|
786
900
|
const l=stLabel[b.dataset.st]; if(l) b.textContent=l;
|
|
787
901
|
});
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
902
|
+
/* C1: 参照データタブは refTables 宣言から生成 (参照テーブル追加でボタンも自動で増える)。
|
|
903
|
+
現在の refTab が新媒体に無ければ先頭へフォールバックする。 */
|
|
904
|
+
const rt=document.getElementById('refTabs');
|
|
905
|
+
if(rt){
|
|
906
|
+
const keys=Object.keys(P.refTables);
|
|
907
|
+
if(keys.indexOf(refTab)<0) refTab=keys[0]||refTab;
|
|
908
|
+
rt.innerHTML=keys.map(k=>{
|
|
909
|
+
const T=P.refTables[k];
|
|
910
|
+
return '<button data-rt="'+esc(k)+'"'+(k===refTab?' class="active"':'')+'>'+esc(T.countLabel||T.label||k)+'</button>';
|
|
911
|
+
}).join('');
|
|
912
|
+
}
|
|
791
913
|
renderFixedType();
|
|
792
914
|
}
|
|
793
915
|
/* 固定タイプチップ (変更不可) は媒体定義 pf().fixedType から描画する。
|
|
@@ -906,6 +1028,27 @@ const REF_TABLES = {
|
|
|
906
1028
|
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
907
1029
|
columns:[
|
|
908
1030
|
{label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
|
|
1031
|
+
] },
|
|
1032
|
+
/* C7: オーディエンスリスト / 配置。ID キー + name の形は campaigns/adGroups と同じ。
|
|
1033
|
+
required:false なので validRefs() には影響せず、normalizeRefsFor() が旧JSONに {} を補完する。
|
|
1034
|
+
GUI は **実IDを入稿し、表示は name** (refpick 列)。 */
|
|
1035
|
+
audiences:{ key:'audiences', label:'オーディエンス', countLabel:'オーディエンス', editable:false, required:false,
|
|
1036
|
+
keyKind:'string', keyLabel:'オーディエンスID', keyWidth:'120px',
|
|
1037
|
+
emptyMsg:'オーディエンスリストがありません(アカウントJSONの refs.audiences)',
|
|
1038
|
+
valueFields:[{k:'name',label:'名'},{k:'kind',label:'種別'}],
|
|
1039
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
1040
|
+
columns:[
|
|
1041
|
+
{label:'オーディエンス名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
1042
|
+
{label:'種別', width:'140px', cls:'mono', cell:(no,v)=>esc(v&&v.kind||'')}
|
|
1043
|
+
] },
|
|
1044
|
+
placements:{ key:'placements', label:'配置', countLabel:'配置', editable:false, required:false,
|
|
1045
|
+
keyKind:'string', keyLabel:'配置ID', keyWidth:'120px',
|
|
1046
|
+
emptyMsg:'配置リストがありません(アカウントJSONの refs.placements)',
|
|
1047
|
+
valueFields:[{k:'name',label:'名'},{k:'kind',label:'種別'}],
|
|
1048
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
1049
|
+
columns:[
|
|
1050
|
+
{label:'配置名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
1051
|
+
{label:'種別', width:'140px', cls:'mono', cell:(no,v)=>esc(v&&v.kind||'')}
|
|
909
1052
|
] }
|
|
910
1053
|
};
|
|
911
1054
|
/* ============================================================
|
|
@@ -926,6 +1069,15 @@ PLATFORMS.yda = {
|
|
|
926
1069
|
project.accountName=String(obj.name||'');
|
|
927
1070
|
/* E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持 */
|
|
928
1071
|
if(obj.principal!=null) project.principal=String(obj.principal);
|
|
1072
|
+
/* C9: 管理画面URLの {mcc} (loginAccountId)。アカウントJSON v2 の mccId が唯一の供給元。
|
|
1073
|
+
未設定なら consoleLink() が accountId にフォールバックする。
|
|
1074
|
+
⚠ **キーが無いときは必ず既定へ戻す**。同一媒体内のアカウント切替では
|
|
1075
|
+
applyAccountJson が st.project を作り直さないため、「あるときだけ上書き」にすると
|
|
1076
|
+
前のアカウントの mcc / URLパラメータ設定が残って別アカウントの画面を開く・
|
|
1077
|
+
計測パラメータが黙って消える、という事故になる。 */
|
|
1078
|
+
project.consoleMcc = obj.mccId!=null ? String(obj.mccId) : '';
|
|
1079
|
+
/* C3: URLパラメータ ON/OFF の初期値。キー無し = undefined = true (従来挙動) */
|
|
1080
|
+
if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
|
|
929
1081
|
},
|
|
930
1082
|
/* AIプロンプトに埋め込む refs スキーマ例 (媒体固有。ここ1箇所だけで定義する) */
|
|
931
1083
|
schemaLines(opts){
|
|
@@ -954,8 +1106,9 @@ PLATFORMS.yda = {
|
|
|
954
1106
|
},
|
|
955
1107
|
budgetLevel:'campaign',
|
|
956
1108
|
/* 管理画面 (SPAハッシュルート)。{mcc} は YDA の loginAccountId */
|
|
1109
|
+
/* C9: {mcc} は st.project.consoleMcc (アカウントJSON mccId) 由来。ハードコードは持たない */
|
|
957
1110
|
consoleUrl:'https://ads.yahoo.co.jp/manager/#/{mcc}/{accountId}/list/{resource}',
|
|
958
|
-
|
|
1111
|
+
consoleResourceMap:{campaigns:'campaigns', adGroups:'adgroups', ads:'ads'},
|
|
959
1112
|
|
|
960
1113
|
/* ---- 形式(Card/SQ) と クリエイティブ名サフィックス・media 値のキーの対応 ---- */
|
|
961
1114
|
mediaFormats:[
|
|
@@ -972,12 +1125,25 @@ PLATFORMS.yda = {
|
|
|
972
1125
|
{key:'goal', label:'目的', kind:'selectv', fallback:'CONVERSION',
|
|
973
1126
|
options:[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},
|
|
974
1127
|
{v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}]},
|
|
975
|
-
|
|
1128
|
+
/* S12: カレンダー + 時刻ピッカー。Yahoo Ads API の startDate は日付のみの ISO (YYYY-MM-DD) なので、
|
|
1129
|
+
時刻は運用メモとして state / 保存JSON にだけ残り、入稿ボディへは日付部分だけを送る (buildPlan.campaign)。 */
|
|
1130
|
+
{key:'startDate', label:'配信開始日時', kind:'datetime', fallback:'',
|
|
1131
|
+
title:'配信開始日時(JST)。日付はカレンダー、時刻は時計から選べます。\n'
|
|
1132
|
+
+'空欄なら書き出し日が使われます。Yahoo Ads API へは日付(YYYY-MM-DD)のみ送られ、時刻は計画上のメモ扱いです。'},
|
|
976
1133
|
{key:'cta', label:'CTA', kind:'text', width:'7ch'},
|
|
977
1134
|
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
|
|
978
|
-
|
|
1135
|
+
/* S14: フリークエンシー(viewableFrequencyCap)の案件既定。
|
|
1136
|
+
チェックONのときだけ 適用/期間/回数 を出し、新規キャンペーン作成時の初期値になる。
|
|
1137
|
+
※旧「計測」(attributionDays) チップは撤去。入稿計画JSONにも入稿ボディにも一切載らない
|
|
1138
|
+
デッドフィールドだったため(アトリビューション計測期間はYDA APIの入稿対象外)。 */
|
|
1139
|
+
{key:'freq', label:'フリークエンシー', kind:'freq',
|
|
1140
|
+
title:'ONにすると、以後の新規キャンペーンにこの設定が初期値として入ります(キャンペーン行で個別に変更できます)'},
|
|
979
1141
|
{key:'displayUrlFormat',label:'表示URL', kind:'text', width:'13ch'},
|
|
980
|
-
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'}
|
|
1142
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'},
|
|
1143
|
+
/* C3: 案件単位の URLパラメータ ON/OFF。**defaults.project には入れない**
|
|
1144
|
+
(undefined = ON のフォールバック規約に一本化し、旧保存JSONと差を出さない) */
|
|
1145
|
+
{key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
|
|
1146
|
+
title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
|
|
981
1147
|
],
|
|
982
1148
|
/* プランツールバーの固定プルダウン (変更不可) */
|
|
983
1149
|
fixedType:[{label:'広告タイプ',value:'レスポンシブ'},{label:'メイン形式',value:'画像'}],
|
|
@@ -1009,6 +1175,9 @@ PLATFORMS.yda = {
|
|
|
1009
1175
|
未知ラベルは MORE_DETAIL(=「詳しくはこちら」) にフォールバック(実データ既定)。 */
|
|
1010
1176
|
cta:{'もっとみる':'MORE_DETAIL','詳しくはこちら':'MORE_DETAIL','詳細を見る':'MORE_DETAIL','今すぐ確認':'CONFIRM_NOW','今すぐ申し込む':'APPLY_NOW','購入する':'PURCHASE','今すぐ購入':'PURCHASE_NOW','応募する':'APPLY_HERE','無料体験':'EXPERIENCE','資料請求':'REQUEST_INFO','ダウンロード':'DOWNLOAD','インストール':'INSTALL','予約する':'RESERVE_HERE','今すぐ予約':'RESERVE_NOW','登録する':'REGISTER','今すぐ登録':'REGISTER_NOW','お問い合わせ':'CONTACT_US'},
|
|
1011
1177
|
ctaFallback:'MORE_DETAIL',
|
|
1178
|
+
/* S14: viewableFrequencyCap の enum (正: frontend/src/pages/submission/types.ts の FREQ_*_OPTIONS) */
|
|
1179
|
+
freqLevel:{'広告グループ':'AD_GROUP','キャンペーン':'CAMPAIGN','広告':'AD'},
|
|
1180
|
+
freqUnit:{'週':'WEEK','日':'DAY','月':'MONTH'},
|
|
1012
1181
|
statusOn:'ACTIVE', statusOff:'PAUSED'
|
|
1013
1182
|
},
|
|
1014
1183
|
|
|
@@ -1031,7 +1200,9 @@ PLATFORMS.yda = {
|
|
|
1031
1200
|
return {days, hours};
|
|
1032
1201
|
},
|
|
1033
1202
|
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
1034
|
-
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; }
|
|
1203
|
+
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
|
|
1204
|
+
/* C7: refpick セル (実IDのCSV) → 数値ID配列。落ちた分は ctx.stats.droppedRefIds に計上 */
|
|
1205
|
+
idList(s, ctx){ return refIdList(s, ctx); }
|
|
1035
1206
|
},
|
|
1036
1207
|
|
|
1037
1208
|
/* ---- 既定値 ---- */
|
|
@@ -1042,27 +1213,36 @@ PLATFORMS.yda = {
|
|
|
1042
1213
|
project:{
|
|
1043
1214
|
cta:'もっとみる', adType:'レスポンシブ', mainMediaFormat:'画像',
|
|
1044
1215
|
deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
|
|
1045
|
-
|
|
1216
|
+
biddingStrategyDefault:'tCPA', tcpaDefault:8500,
|
|
1217
|
+
/* S14: フリークエンシーの案件既定。OFF が既定(明示的にONにしたときだけ入稿ボディに載る) */
|
|
1218
|
+
freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3,
|
|
1046
1219
|
budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
|
|
1047
1220
|
},
|
|
1048
|
-
|
|
1049
|
-
|
|
1221
|
+
/* C6: adName は空 = 自動生成 (naming.adName)。C1: imagePath は空 = 従来挙動 */
|
|
1222
|
+
ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', imagePath:'', adName:'', desc2:''},
|
|
1223
|
+
/* S14: freq* は「案件設定チップの既定を新規CP作成時にコピーする」ので factories.newCampaign 側で入れる */
|
|
1224
|
+
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:'',
|
|
1225
|
+
freqMode:'default', freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3}
|
|
1050
1226
|
},
|
|
1051
1227
|
|
|
1052
1228
|
/* ---- factories: 行の生成 (リテラル生成をコアから撤去) ---- */
|
|
1053
1229
|
factories:{
|
|
1054
1230
|
newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.yda.defaults.ad, seed||{}); },
|
|
1055
1231
|
newAdGroup(c, i){ const F=PLATFORMS.yda.factories;
|
|
1056
|
-
return {uid:uid('g'), name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), paramCode:'', isExisting:false, ads:[F.newAd()]}; },
|
|
1232
|
+
return {uid:uid('g'), name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), paramCode:'', isExisting:false, smartTargeting:'オン', ads:[F.newAd()]}; },
|
|
1057
1233
|
/* E-2: 新規キャンペーン (空のAG1を1つ内包)。isExisting:false = 常に編集可 */
|
|
1058
1234
|
newCampaign(seed){ const p=st.project, D=PLATFORMS.yda.defaults, F=PLATFORMS.yda.factories;
|
|
1059
1235
|
return Object.assign({uid:uid('c')}, D.campaign, {
|
|
1060
1236
|
budget:D.newCampaignBudget, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault,
|
|
1237
|
+
/* S14: 既定は 'default' (案件共通設定に追従)。個別指定したい行だけ「オン」にする。
|
|
1238
|
+
3値は「オンへ切り替えた瞬間の初期値」として案件既定を持たせておく。 */
|
|
1239
|
+
freqMode:'default', freqOn:false,
|
|
1240
|
+
freqLevel:p.freqLevel||'AD_GROUP', freqUnit:p.freqUnit||'WEEK', freqCount:freqNum(p.freqCount)||3,
|
|
1061
1241
|
adGroups:[F.newAdGroup(null,0)]
|
|
1062
1242
|
}, seed||{}); },
|
|
1063
1243
|
/* E-3/F-2: 既存キャンペーン呼び出し (campaignIdを持つ広告追加専用の器)。isExisting:true = 基本パラメータ編集不可 */
|
|
1064
1244
|
existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
|
|
1065
|
-
isExisting:true,
|
|
1245
|
+
isExisting:true, urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; },
|
|
1066
1246
|
/* F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める */
|
|
1067
1247
|
existingAdGroup(agId, meta){ const F=PLATFORMS.yda.factories; const m=meta||{};
|
|
1068
1248
|
return {uid:uid('g'), name:m.name||('AG '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
|
|
@@ -1072,11 +1252,26 @@ PLATFORMS.yda = {
|
|
|
1072
1252
|
/* ---- naming: クリエイティブ名 / 広告名 / 最終URL / キャンペーン名 ---- */
|
|
1073
1253
|
naming:{
|
|
1074
1254
|
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
1075
|
-
|
|
1255
|
+
/* C8: URLパラメータ OFF の案件は paramCode を入力しない (列も隠す) ので、
|
|
1256
|
+
広告名の末尾 "-{paramCode}" も付けない (末尾が "-" だけ残るのを避ける)。 */
|
|
1257
|
+
adName(ad, group, crName, project){
|
|
1258
|
+
const head = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'');
|
|
1259
|
+
if(project && project.urlParams===false) return head;
|
|
1260
|
+
return head + '-' + (group.paramCode||'');
|
|
1261
|
+
},
|
|
1262
|
+
/* C8: 最終URL = 案件設定の記事URL(base) + paramCode + ?ad=&creative=。
|
|
1263
|
+
広告ごとのURL指定 (記事slug / 記事URL(上書き)) は廃止し、URLの可変部は paramCode だけ。
|
|
1264
|
+
・project.urlParams===false なら paramCode も ?ad= / &creative= も付けず base のみ
|
|
1265
|
+
(欠落 undefined は ON = 従来挙動)
|
|
1266
|
+
⚠ base が既にクエリを持つ (utm_source 等) 場合は '?' を2つ並べない = 繋ぎ文字を選ぶ。
|
|
1267
|
+
並べると parse 時に ad= が消えて utm_source の値に食われる。
|
|
1268
|
+
⚠ paramCode / adName は自由入力なので urlq() でURL構造を壊す文字だけ落とす。 */
|
|
1076
1269
|
finalUrl(ad, group, adName, project){
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1270
|
+
if(project.urlParams===false) return (project.baseArticleUrl||'');
|
|
1271
|
+
const code = urlq(group.paramCode||'');
|
|
1272
|
+
const base = (project.baseArticleUrl||'') + code;
|
|
1273
|
+
const sep = base.indexOf('?')>=0 ? '&' : '?';
|
|
1274
|
+
return base + sep + 'ad=' + code + '&creative=' + code + '-' + urlq(adName);
|
|
1080
1275
|
},
|
|
1081
1276
|
/* キャンペーン名 自動採番: {空き番号2桁}_{CR名}_{今日} */
|
|
1082
1277
|
campaignName(urlNumber, firstAd, P){
|
|
@@ -1085,11 +1280,12 @@ PLATFORMS.yda = {
|
|
|
1085
1280
|
}
|
|
1086
1281
|
},
|
|
1087
1282
|
/* 「クリエイティブをクリア」対象フィールド */
|
|
1088
|
-
creativeFields:['imageNo','titleNo','descNo'],
|
|
1089
|
-
/* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)
|
|
1090
|
-
|
|
1283
|
+
creativeFields:['imageNo','titleNo','descNo','imagePath'],
|
|
1284
|
+
/* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)。
|
|
1285
|
+
C7: オーディエンス/配置は実ID入稿に対応したので **未反映列から外した** */
|
|
1286
|
+
notSubmittedFields:['tg_region','tg_searchKw'],
|
|
1091
1287
|
labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode',
|
|
1092
|
-
tgDroppedWarn:'
|
|
1288
|
+
tgDroppedWarn:'地域/サーチKW'},
|
|
1093
1289
|
|
|
1094
1290
|
/* ---- tree ビューの媒体固有バッジ (DOM構造は renderTree 側と一致させる) ---- */
|
|
1095
1291
|
tree:{
|
|
@@ -1107,7 +1303,10 @@ PLATFORMS.yda = {
|
|
|
1107
1303
|
const P=PLATFORMS.yda, p=ctx.project, refs=ctx.refs;
|
|
1108
1304
|
const fmt=P.fmtOf(ad);
|
|
1109
1305
|
const crName=P.naming.crName(ad, P);
|
|
1110
|
-
|
|
1306
|
+
/* C6: 広告名は自由入力。空 = 自動生成 (naming.adName) にフォールバックする。
|
|
1307
|
+
⚠ finalUrl の creative= は adName を使うので、手入力するとURLも変わる (仕様) */
|
|
1308
|
+
const autoAdName=P.naming.adName(ad, group, crName, p);
|
|
1309
|
+
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
1111
1310
|
const me = refs.media[String(ad.imageNo)];
|
|
1112
1311
|
const mediaId = me ? me[fmt.mediaKey] : undefined;
|
|
1113
1312
|
const titleText = refs.titles[ad.titleNo];
|
|
@@ -1130,16 +1329,37 @@ PLATFORMS.yda = {
|
|
|
1130
1329
|
if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
|
|
1131
1330
|
else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
|
|
1132
1331
|
// B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
|
|
1133
|
-
|
|
1332
|
+
/* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
|
|
1333
|
+
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
1134
1334
|
if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
|
|
1335
|
+
// C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
|
|
1336
|
+
// ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
|
|
1337
|
+
const imagePaths=String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
1338
|
+
if(pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
|
|
1339
|
+
/* 逆に mediaID が解決済みの広告に画像パスを足しても、その画像は広告には使われない
|
|
1340
|
+
(CLI はアップロードした mediaId を末尾に **追記** し、backend は mediaIds[0] しか見ない)。
|
|
1341
|
+
黙って古い画像で入稿されるのが最悪なので、書き出しでは落とし、ここで警告する。
|
|
1342
|
+
差し替えたいときは画像番号を空にする (= 未登録画像として新規アップロードさせる)。 */
|
|
1343
|
+
if(!pendingImage && imgNoStr!=='' && imagePaths.length>0) pushWarn('画像パス無視(画像番号が解決済み)', EC.IMAGE_PATH_IGNORED);
|
|
1135
1344
|
// F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
|
|
1136
1345
|
const aid=String(group.adGroupId||'').trim();
|
|
1137
|
-
return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
|
|
1346
|
+
return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
|
|
1138
1347
|
mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
|
|
1139
1348
|
descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
1140
1349
|
},
|
|
1141
1350
|
|
|
1142
1351
|
/* ---- S8: 入稿計画JSON (SubmitPlanFile) の3階層ボディ生成 ---- */
|
|
1352
|
+
/* S13: 案件設定の書き出し前ガード。
|
|
1353
|
+
startDate は空欄なら書き出し日にフォールバックする仕様だが、「読めない書式」まで空と同じ扱いにすると
|
|
1354
|
+
入力した日付が黙って当日にすり替わる。GUI (日付/時刻ピッカー) からは不正値は入らないので、
|
|
1355
|
+
ここに来るのは旧保存JSONの読み込みか手編集だけ。その場合は書き出しを止めて気づけるようにする。 */
|
|
1356
|
+
validateProject(p){
|
|
1357
|
+
const s=String((p&&p.startDate)||'').trim();
|
|
1358
|
+
if(s && !dateOnly(s)) return [{code:EC.START_TIME_INVALID,
|
|
1359
|
+
msg:'案件設定「配信開始日時」の書式が不正です(例 2026-08-01 / 2026-08-01 10:30): '+s}];
|
|
1360
|
+
return [];
|
|
1361
|
+
},
|
|
1362
|
+
|
|
1143
1363
|
buildPlan:{
|
|
1144
1364
|
campaign(c, ctx){
|
|
1145
1365
|
const P=PLATFORMS.yda, p=ctx.project;
|
|
@@ -1149,11 +1369,23 @@ PLATFORMS.yda = {
|
|
|
1149
1369
|
name:c.name,
|
|
1150
1370
|
goal:p.goal||'CONVERSION',
|
|
1151
1371
|
status:P.mappers.status(p.deliveryStatus, P), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
|
|
1152
|
-
|
|
1372
|
+
/* S12: 案件設定は時刻付きで持てるが、Yahoo Ads API の startDate は日付のみの ISO。日付部分だけを送る */
|
|
1373
|
+
startDate:dateOnly(p.startDate)||today(),
|
|
1153
1374
|
budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
|
|
1154
1375
|
biddingStrategy,
|
|
1155
1376
|
adGroups:[]
|
|
1156
1377
|
};
|
|
1378
|
+
/* S14: フリークエンシー (viewableFrequencyCap)。OFF / 回数未入力なら **キー自体を出さない**
|
|
1379
|
+
(backend の campaignSchema では全て optional。付けない = 媒体既定のまま) */
|
|
1380
|
+
/* S14: 'default' は案件共通設定を引き、'off' は付けない。小数はここまでに落ちている
|
|
1381
|
+
(backend の vImps は int 制約が無く dry-run も素通りするため GUI 側で担保する) */
|
|
1382
|
+
const fe=freqEffective(c);
|
|
1383
|
+
if(fe){
|
|
1384
|
+
cp.useFrequencyCap=true;
|
|
1385
|
+
cp.frequencyLevel=fe.level;
|
|
1386
|
+
cp.frequencyTimeUnit=fe.unit;
|
|
1387
|
+
cp.vImps=fe.count;
|
|
1388
|
+
}
|
|
1157
1389
|
if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
|
|
1158
1390
|
else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
|
|
1159
1391
|
const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
|
|
@@ -1165,13 +1397,15 @@ PLATFORMS.yda = {
|
|
|
1165
1397
|
const ag={
|
|
1166
1398
|
name:g.name,
|
|
1167
1399
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1168
|
-
|
|
1400
|
+
/* C8: 空欄・旧データ (undefined) はすべて true。明示的に「オフ」を選んだときだけ false */
|
|
1401
|
+
smartTargeting: g.smartTargeting!=='オフ',
|
|
1169
1402
|
devices:P.mappers.devices(g.tg_device, P),
|
|
1170
1403
|
ages:P.mappers.ages(g.tg_age, P),
|
|
1171
1404
|
genders:P.mappers.genders(g.tg_gender, P),
|
|
1172
1405
|
days:dh.days,
|
|
1173
|
-
|
|
1174
|
-
|
|
1406
|
+
/* C7: refpick 列は実IDを保持している。数値IDのみを送り、旧・日本語ラベルは落として警告に計上する */
|
|
1407
|
+
placementsInclude:P.mappers.idList(g.tg_placement, ctx), placementsExclude:P.mappers.idList(g.tg_placementEx, ctx),
|
|
1408
|
+
audiencesInclude:P.mappers.idList(g.tg_audience, ctx), audiencesExclude:P.mappers.idList(g.tg_audienceEx, ctx),
|
|
1175
1409
|
ads:[]
|
|
1176
1410
|
};
|
|
1177
1411
|
if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
|
|
@@ -1192,9 +1426,15 @@ PLATFORMS.yda = {
|
|
|
1192
1426
|
finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
|
|
1193
1427
|
};
|
|
1194
1428
|
if(r.pendingImage){
|
|
1195
|
-
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
|
|
1196
|
-
|
|
1429
|
+
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
|
|
1430
|
+
// C6-1: 画像パス列 (カンマ区切り) を imagePaths に展開する。空なら [] のまま (agent/CLI が補完)
|
|
1431
|
+
ad.mediaIds=[]; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; ad.imagePaths=r.imagePaths.slice();
|
|
1197
1432
|
} else {
|
|
1433
|
+
/* 解決済み: mediaId だけを送る。imagePaths を **同時に載せない** のが重要。
|
|
1434
|
+
CLI (resolveYdaImages) は imagePaths があると必ずアップロードして
|
|
1435
|
+
mediaIds に追記し、backend (createImageAd) は mediaIds[0] しか使わないため、
|
|
1436
|
+
両方載せると「新画像はライブラリに登録されるが広告は旧画像のまま」になる。
|
|
1437
|
+
入力があった場合は resolveAd が IMAGE_PATH_IGNORED で警告している。 */
|
|
1198
1438
|
ad.mediaIds=[String(r.mediaId)];
|
|
1199
1439
|
}
|
|
1200
1440
|
return ad;
|
|
@@ -1308,6 +1548,50 @@ function normalizeRefsFor(P,r){ const e=emptyRefsFor(P); if(!r) return e; Object
|
|
|
1308
1548
|
function emptyRefs(){ return emptyRefsFor(pf()); }
|
|
1309
1549
|
function validRefs(r){ if(!r) return false; return refTableKeys().filter(k=>refTableDef(k).required).concat(LEGACY_REF_KEYS).every(k=>!!r[k]); }
|
|
1310
1550
|
function normalizeRefs(r){ return normalizeRefsFor(pf(), r); }
|
|
1551
|
+
/* ---- C1: refpick (参照テーブル ID → name の複数選択列) ----
|
|
1552
|
+
セルが持つ値は **カンマ区切りの実ID**。表示だけ name に置き換える (入稿は ID をそのまま送る)。
|
|
1553
|
+
媒体語彙は持たない: 参照先テーブルは col.refTable が指す。 */
|
|
1554
|
+
function refPickStore(col){ return (st.refs && st.refs[col.refTable]) || {}; }
|
|
1555
|
+
function refPickName(col, id){ const v=refPickStore(col)[String(id)]; if(v==null) return ''; return String((typeof v==='object')?(v.name||''):v); }
|
|
1556
|
+
function refPickDisplay(col, val){
|
|
1557
|
+
return tgList(val).map(id=>{ const n=refPickName(col,id); return n? n : String(id); }).join(', ');
|
|
1558
|
+
}
|
|
1559
|
+
/* 実IDだけを残す (旧・日本語ラベルや手入力の名前は捨てる)。順序保持・重複除去。 */
|
|
1560
|
+
function refIdCoerce(v){
|
|
1561
|
+
const out=[]; tgList(v).forEach(x=>{ const s=String(x).trim(); if(/^\d+$/.test(s) && out.indexOf(s)<0) out.push(s); });
|
|
1562
|
+
return out.join(', ');
|
|
1563
|
+
}
|
|
1564
|
+
/* ---- C5: 参照表の本文を直接編集する列 (番号列と双方向) ----
|
|
1565
|
+
media 語彙は持たない: どのテーブル(table)のどの番号列(noField)を見るかは呼び出し側が渡す。
|
|
1566
|
+
・get : refs[table][row[noField]] の文言
|
|
1567
|
+
・set : 番号が空なら空き番号を自動採番して refs へ登録 → 番号列にも書き戻す
|
|
1568
|
+
空文字の set は **参照表を消さない** (番号の解除は番号列で行う)。 */
|
|
1569
|
+
function nextRefNo(store){
|
|
1570
|
+
let m=0; Object.keys(store||{}).forEach(k=>{ const n=parseInt(k,10); if(Number.isFinite(n)&&n>m) m=n; });
|
|
1571
|
+
return m+1;
|
|
1572
|
+
}
|
|
1573
|
+
function makeRefTextCol(o){
|
|
1574
|
+
return {field:o.field, label:o.label, kind:'text', width:o.width||'180px',
|
|
1575
|
+
refTable:o.table, noField:o.noField,
|
|
1576
|
+
title:'参照表の本文を直接編集します(番号が空なら空き番号を自動採番)',
|
|
1577
|
+
get:(row,ctx)=>{ const t=((ctx&&ctx.refs)||st.refs)[o.table]||{}; const v=t[row[o.noField]]; return v==null?'':String(v); },
|
|
1578
|
+
set:(row,val,ctx)=>{
|
|
1579
|
+
const refs=(ctx&&ctx.refs)||st.refs;
|
|
1580
|
+
const t=refs[o.table]||(refs[o.table]={});
|
|
1581
|
+
const s=String(val==null?'':val);
|
|
1582
|
+
if(s==='') return;
|
|
1583
|
+
let no=String(row[o.noField]==null?'':row[o.noField]).trim();
|
|
1584
|
+
if(no===''){ no=String(nextRefNo(t)); row[o.noField]=no; }
|
|
1585
|
+
t[no]=s;
|
|
1586
|
+
}};
|
|
1587
|
+
}
|
|
1588
|
+
/* 入稿ボディ用: セル値 → 数値ID配列。落とした要素数を ctx.stats.droppedRefIds に計上する。 */
|
|
1589
|
+
function refIdList(v, ctx){
|
|
1590
|
+
const keep=[], drop=[];
|
|
1591
|
+
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); });
|
|
1592
|
+
if(drop.length && ctx && ctx.stats) ctx.stats.droppedRefIds += drop.length;
|
|
1593
|
+
return keep;
|
|
1594
|
+
}
|
|
1311
1595
|
/* 案A: 同梱デフォルトアカウントの refs を起動時に外部JSONから読み込む(固有名詞ハードコードなし)。
|
|
1312
1596
|
descriptions は独立テーブル(titlesコピーの暫定を廃止)。 */
|
|
1313
1597
|
function buildRefs(){ return bundledRefs ? normalizeRefs(bundledRefs) : emptyRefs(); }
|
|
@@ -1385,6 +1669,18 @@ function cpDefaults(project, mode){ const p=project||st.project; return {budget:
|
|
|
1385
1669
|
/* S7: 新規/既存キャンペーンの生成も pf().factories へ委譲 */
|
|
1386
1670
|
function makeNewCampaign(seed){ return pf().factories.newCampaign(seed); }
|
|
1387
1671
|
function makeExistingCampaign(cpId, name){ return pf().factories.existingCampaign(cpId, name); }
|
|
1672
|
+
/* ---- C8: 記事slug / 記事URL(上書き) 廃止に伴う片方向マイグレーション ----
|
|
1673
|
+
旧プランが持つ広告単位のURL指定は捨てる。新モデルの最終URLは
|
|
1674
|
+
「案件設定の記事URL + paramCode」だけで決まり、広告ごとの可変部は無い。
|
|
1675
|
+
(ここで base+slug の完全URLに固定すると、後から案件設定の記事URLを変えても
|
|
1676
|
+
その行だけ旧baseのまま残る、というサイレントな不整合になる) */
|
|
1677
|
+
function migrateArticleSlug(campaigns){
|
|
1678
|
+
(campaigns||[]).forEach(c=>(c.adGroups||[]).forEach(g=>(g.ads||[]).forEach(a=>{
|
|
1679
|
+
if(!a) return;
|
|
1680
|
+
delete a.articleSlug; delete a.articleUrl;
|
|
1681
|
+
})));
|
|
1682
|
+
return campaigns;
|
|
1683
|
+
}
|
|
1388
1684
|
function initialState(){
|
|
1389
1685
|
const refs=buildRefs();
|
|
1390
1686
|
// 共通設定(project=案件名/主体者/アカウント等)はアカウントJSON由来。サンプルプランには campaigns と accountId のみ。
|
|
@@ -1392,13 +1688,19 @@ function initialState(){
|
|
|
1392
1688
|
let campaigns=[];
|
|
1393
1689
|
planMismatchMsg='';
|
|
1394
1690
|
if(samplePlan && Array.isArray(samplePlan.campaigns)){
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1691
|
+
/* アカウントIDとサンプルプランのaccountIdが異なる場合はエラー(不一致プランは読み込まない)
|
|
1692
|
+
S13: accountId 欠落も「不一致」として扱う fail-safe。旧実装は truthy 判定で短絡していたため、
|
|
1693
|
+
accountId の無いプラン (= 誰のものか分からないプラン) がどのアカウントでも読み込まれていた。 */
|
|
1694
|
+
const planAcc=String(samplePlan.accountId||''), curAcc=String(project.accountId||'');
|
|
1695
|
+
if(planAcc!==curAcc){
|
|
1696
|
+
planMismatchMsg = planAcc
|
|
1697
|
+
? '⚠ サンプルプランのアカウントID('+planAcc+')が現在のアカウント('+curAcc+(project.accountName?' '+project.accountName:'')+')と一致しません。プランは読み込みません。正しいアカウントに切り替えるか、サンプルプランの accountId を合わせてください。'
|
|
1698
|
+
: '⚠ サンプルプランに accountId がありません。どのアカウントのプランか判別できないため読み込みません。サンプルプランに accountId を追加してください。';
|
|
1398
1699
|
if(typeof toast==='function') toast(planMismatchMsg);
|
|
1399
1700
|
} else {
|
|
1400
1701
|
campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
|
|
1401
1702
|
campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
|
|
1703
|
+
migrateArticleSlug(campaigns); // C8: 旧 articleSlug / articleUrl を除去
|
|
1402
1704
|
}
|
|
1403
1705
|
}
|
|
1404
1706
|
return {schemaVersion:SCHEMA_VERSION, meta:defaultMeta(), project, refs, campaigns, ui:{expanded:{}}};
|
|
@@ -1436,7 +1738,24 @@ function findCp(u){ const i=st.campaigns.findIndex(c=>c.uid===u); return i<0?nul
|
|
|
1436
1738
|
/* S8: 解決本体は pf().resolve(ad, group, ctx) に移設。関数名は据え置き
|
|
1437
1739
|
(E2E が page.evaluate から resolveAd(...) を直接呼ぶため)。 */
|
|
1438
1740
|
function planCtx(){ const P=pf(); return {project:st.project, refs:st.refs, pf:P, resolve:resolveAd}; }
|
|
1439
|
-
|
|
1741
|
+
/* C1: 行単位メモ化。get フックを持つ列が増えると 1行あたり列数ぶん resolveAd が走るため、
|
|
1742
|
+
「1パス」(コピー範囲の走査など state を変えない読み取り) の中だけ結果を共有する。
|
|
1743
|
+
state を変える処理 (pasteGrid 等) は絶対に包まないこと (古い解決結果を掴む)。 */
|
|
1744
|
+
let _resolvePass=null;
|
|
1745
|
+
function withResolvePass(fn){
|
|
1746
|
+
const prev=_resolvePass; _resolvePass=new WeakMap();
|
|
1747
|
+
try{ return fn(); } finally { _resolvePass=prev; }
|
|
1748
|
+
}
|
|
1749
|
+
function resolveAd(ad, group){
|
|
1750
|
+
if(_resolvePass){
|
|
1751
|
+
const hit=_resolvePass.get(ad);
|
|
1752
|
+
if(hit && hit.g===group && hit.pf===curPf) return hit.r;
|
|
1753
|
+
const r=pf().resolve(ad, group, planCtx());
|
|
1754
|
+
_resolvePass.set(ad, {g:group, pf:curPf, r});
|
|
1755
|
+
return r;
|
|
1756
|
+
}
|
|
1757
|
+
return pf().resolve(ad, group, planCtx());
|
|
1758
|
+
}
|
|
1440
1759
|
function validationCounts(){ let err=0,warn=0,ads=0; eachAd((a,g)=>{ ads++; const r=resolveAd(a,g); if(r.errs.length) err++; else if(r.warns.length) warn++; }); return {err,warn,ads}; }
|
|
1441
1760
|
|
|
1442
1761
|
/* ============================================================
|
|
@@ -1476,10 +1795,52 @@ function renderValidationBadge(){
|
|
|
1476
1795
|
}
|
|
1477
1796
|
function renderTplChips(){
|
|
1478
1797
|
const p=st.project;
|
|
1479
|
-
|
|
1480
|
-
|
|
1798
|
+
/* S7b: 記述子の表現力拡張。placeholder は文字列 or (project)=>string、title は文字列。
|
|
1799
|
+
どちらも未指定なら空文字を返すので、無指定の媒体 (YDA) の出力HTMLは 1バイトも変わらない。
|
|
1800
|
+
coerce フックは足していない: 値の正規化 (全角空白・前後空白・区切り) は
|
|
1801
|
+
媒体定義の mappers 側 (meta.mappers.normDateTime) が吸収する方が、
|
|
1802
|
+
生の入力を state に残せて再編集時に壊れないため。 */
|
|
1803
|
+
const hint=(f)=>{
|
|
1804
|
+
if(!f) return '';
|
|
1805
|
+
const ph = typeof f.placeholder==='function' ? f.placeholder(p) : f.placeholder;
|
|
1806
|
+
return (ph? ' placeholder="'+esc(ph)+'"' : '') + (f.title? ' title="'+esc(f.title)+'"' : '');
|
|
1807
|
+
};
|
|
1808
|
+
/* C5: 記述子に title が無いテキストチップは、値そのものを title にする。
|
|
1809
|
+
幅が狭くて末尾が切れていてもホバーで全文を確認できる(記事URL・表示URL 等)。
|
|
1810
|
+
記述子側の title が有るときは hint(d) が出すので二重に付けない。 */
|
|
1811
|
+
const txt=(f,v,w,d)=>{
|
|
1812
|
+
const full = (d && d.title) ? '' : (String(v||'') ? ' title="'+esc(String(v))+'"' : '');
|
|
1813
|
+
return '<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'"'+hint(d)+full+'>';
|
|
1814
|
+
};
|
|
1815
|
+
const num=(f,v,w,d)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'"'+hint(d)+'>';
|
|
1481
1816
|
const sel=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option'+(o===v?' selected':'')+'>'+esc(o)+'</option>').join('')+'</select>';
|
|
1482
1817
|
const selv=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option value="'+esc(o.v)+'"'+(o.v===v?' selected':'')+'>'+esc(o.t)+'</option>').join('')+'</select>';
|
|
1818
|
+
/* C1: kind:'toggle' = チェックボックス。値は真偽で持ち、**未定義は fallback**(欠落=従来挙動)。
|
|
1819
|
+
`p[key]||fallback` では false が fallback(true) に化けるので、toggle だけは別扱いにする。 */
|
|
1820
|
+
const tgl=(f,v,d)=>'<input class="chipin" type="checkbox" data-proj="'+f+'"'+(v?' checked':'')+hint(d)+'>';
|
|
1821
|
+
/* S12: kind:'datetime' = <input type="date"> + <input type="time"> の2分割。
|
|
1822
|
+
state には従来どおり 1本の文字列で持つ ("YYYY-MM-DD HH:mm"、時刻が空なら "YYYY-MM-DD")。
|
|
1823
|
+
data-proj は **日付側だけ** に付ける(時刻側は data-projtime)。
|
|
1824
|
+
- 既存の [data-proj="startDate"] を掴むコード/テストがそのまま日付入力に当たる
|
|
1825
|
+
- 汎用の change ハンドラが日付だけで上書きしてしまう事故も防げる(data-dt で先に分岐する)
|
|
1826
|
+
ネイティブ input は不正文字列を弾いて空を返すので、書式異常は state に入らない。 */
|
|
1827
|
+
const dt=(f,v,d)=>{
|
|
1828
|
+
const p=splitDateTime(v);
|
|
1829
|
+
/* S13: 日付が空のうちは時刻を disabled にする。state は1本の文字列で「時刻のみ」を表現できず、
|
|
1830
|
+
joinDateTime が空へ潰して再描画が入力を消すため、打てるのに消える見た目になっていた。 */
|
|
1831
|
+
const noDate=!p.date;
|
|
1832
|
+
return '<input class="chipin" type="date" data-proj="'+f+'" data-dt="'+f+'" value="'+esc(p.date)+'"'+hint(d)+'>'
|
|
1833
|
+
+ '<input class="chipin" type="time" data-projtime="'+f+'" data-dt="'+f+'" value="'+esc(p.time)+'"'
|
|
1834
|
+
+ (noDate?' disabled':'')
|
|
1835
|
+
+ ' title="'+(noDate?'先に日付を選んでください(時刻だけの指定はできません)':'時刻(任意)。空欄なら日付のみの指定になります')+'">';
|
|
1836
|
+
};
|
|
1837
|
+
/* S14: kind:'freq' = チェックでONにしたときだけ 適用/期間/回数 を出す案件既定。
|
|
1838
|
+
キャンペーン行と同じ freqSectionsHtml を使い、data-proj で汎用チップハンドラに乗せる。 */
|
|
1839
|
+
const freq=(d)=>{
|
|
1840
|
+
const on=!!p.freqOn;
|
|
1841
|
+
return '<input class="chipin" type="checkbox" data-proj="freqOn"'+(on?' checked':'')+hint(d)+'>'
|
|
1842
|
+
+ (on? freqSectionsHtml(p, f=>'data-proj="'+f+'"') : '<span class="freqoff">未設定</span>');
|
|
1843
|
+
};
|
|
1483
1844
|
const chip=(label,ctrl)=>'<span class="chip">'+esc(label)+' '+ctrl+'</span>';
|
|
1484
1845
|
// E-1: 案件名 / アカウントID / 主体者 / タイプ チップは撤去。
|
|
1485
1846
|
// - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
|
|
@@ -1487,11 +1848,16 @@ function renderTplChips(){
|
|
|
1487
1848
|
// - タイプ(レスポンシブ/画像)はプランツールバーの固定プルダウン (変更不可)
|
|
1488
1849
|
// S7: チップの並び・種別・幅は pf().projectFields (媒体スキーマ) が持つ
|
|
1489
1850
|
document.getElementById('tplChips').innerHTML = pf().projectFields.map(f=>{
|
|
1490
|
-
const v =
|
|
1491
|
-
|
|
1851
|
+
const v = f.kind==='toggle'
|
|
1852
|
+
? (p[f.key]===undefined ? (f.fallback!==undefined?f.fallback:false) : !!p[f.key])
|
|
1853
|
+
: ((f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key]);
|
|
1854
|
+
const ctrl = f.kind==='freq' ? freq(f)
|
|
1855
|
+
: f.kind==='toggle' ? tgl(f.key, v, f)
|
|
1856
|
+
: f.kind==='datetime' ? dt(f.key, v, f)
|
|
1857
|
+
: f.kind==='selectv' ? selv(f.key, v, f.options)
|
|
1492
1858
|
: f.kind==='select' ? sel(f.key, v, f.options)
|
|
1493
|
-
: f.kind==='number' ? num(f.key, v, f.width)
|
|
1494
|
-
: txt(f.key, v, f.width);
|
|
1859
|
+
: f.kind==='number' ? num(f.key, v, f.width, f)
|
|
1860
|
+
: txt(f.key, v, f.width, f);
|
|
1495
1861
|
return chip(f.label, ctrl+(f.suffix||''));
|
|
1496
1862
|
}).join('');
|
|
1497
1863
|
// A-1: 入札(tcpaDefault) / 予算新規 / 予算ASC チップは撤去。project既定は新規CP作成時の初期値供給のみに使い、以後はキャンペーン行で個別編集する。
|
|
@@ -1557,6 +1923,9 @@ function renderRow(item, ri, cols, tab, ui){
|
|
|
1557
1923
|
const ctx = {tab:tab, item:item, ri:ri, project:st.project, refs:st.refs};
|
|
1558
1924
|
let cls='adrow';
|
|
1559
1925
|
if(item.kind==='a'){ ctx.r=resolveAd(item.a,item.g); if(ctx.r.errs.length) cls+=' hasErr'; }
|
|
1926
|
+
/* 行レベル検証を持つ媒体だけ、行のエラーコードを ctx に載せる (列記述子 col.errCodes と突き合わせてセルを赤くする)。
|
|
1927
|
+
validateRow が無い媒体 (YDA) では ctx.rowErrCodes 自体が生えないので DOM は一切変わらない。 */
|
|
1928
|
+
if(item.kind==='g' && pf().validateRow) ctx.rowErrCodes=(pf().validateRow(item.g, 'adGroup', ctx)||{}).errCodes||[];
|
|
1560
1929
|
let h='<tr class="'+cls+'" '+ui.rowAttr+'="'+row.uid+'">';
|
|
1561
1930
|
h+='<td class="rownum" data-rownum="'+ri+'"'+(ui.rowNumTitle?ui.rowNumTitle(item):'')+'>'+(ri+1)+'</td>';
|
|
1562
1931
|
if(ui.parentLabel!=null) h+=ui.parentCell(item);
|
|
@@ -1567,7 +1936,7 @@ function renderRow(item, ri, cols, tab, ui){
|
|
|
1567
1936
|
/* 1セル。col.kind で分岐する唯一の場所。 */
|
|
1568
1937
|
function renderCell(col, item, ri, ci, ctx){
|
|
1569
1938
|
const ui=SHEET_UI[ctx.tab], row=rowOf(item);
|
|
1570
|
-
const val=getVar(item, ci, ctx.tab);
|
|
1939
|
+
const val=getVar(item, ci, ctx.tab, ctx);
|
|
1571
1940
|
const ro = col.alwaysReadonly || ((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col));
|
|
1572
1941
|
const sel='data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+'"';
|
|
1573
1942
|
const sty=col.width?(' style="width:'+col.width+'"'):'';
|
|
@@ -1587,6 +1956,23 @@ function renderCell(col, item, ri, ci, ctx){
|
|
|
1587
1956
|
return '<td><span class="robadge'+(col.kind==='mono'?' mono':'')+'"'+(col.roTitle?(' title="'+esc(col.roTitle)+'"'):'')+'>'+esc(val)+'</span></td>';
|
|
1588
1957
|
}
|
|
1589
1958
|
|
|
1959
|
+
/* S14: kind:'freq' = セル内に「大きめの入力ボックス」を直接置く。
|
|
1960
|
+
チェックOFF は 1行 (未設定) / ON のときだけ 適用・期間・回数 の3セクションを展開する。
|
|
1961
|
+
data-sel は外側のボックスに付ける (コピー&ペースト・範囲選択は col.get/col.set 経由で往復する)。
|
|
1962
|
+
内側のコントロールは data-cu + data-freqf を持ち、専用の change ハンドラが拾う。 */
|
|
1963
|
+
if(col.kind==='freq'){
|
|
1964
|
+
const m=freqMode(row);
|
|
1965
|
+
const at=f=>'data-'+ui.uAttr+'="'+row.uid+'" data-freqf="'+f+'"';
|
|
1966
|
+
const mode='<select class="freqin freqmode" '+at('freqMode')+'>'
|
|
1967
|
+
+ FREQ_MODES.map(o=>'<option value="'+o.v+'"'+(o.v===m?' selected':'')+'>'+esc(o.t)+'</option>').join('')
|
|
1968
|
+
+ '</select>';
|
|
1969
|
+
const rest = m==='on' ? freqSectionsHtml(row, at)
|
|
1970
|
+
: m==='default' ? '<span class="freqinherit" title="案件共通設定に追従します(案件設定チップで変更)">'+esc(freqInheritText())+'</span>'
|
|
1971
|
+
: '';
|
|
1972
|
+
return '<td class="freqtd"><div class="freqbox'+(m==='on'?' on':'')+'" '+sel+' title="'+esc(freqCellText(row)||'オフ(フリークエンシーを付けない)')+'">'
|
|
1973
|
+
+ mode + rest + '</div></td>';
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1590
1976
|
if(col.kind==='select'){
|
|
1591
1977
|
/* '' は「(既定)」。emptyNoSelected は「(既定)に selected を付けない」現行DOMの踏襲 */
|
|
1592
1978
|
const opts=col.options.map(o=> o===''
|
|
@@ -1602,14 +1988,44 @@ function renderCell(col, item, ri, ci, ctx){
|
|
|
1602
1988
|
'<button class="combodd" data-dd="'+col.drawerType+'" data-u="'+row.uid+'" data-f="'+col.field+'" tabindex="-1" title="'+esc(col.ddTitle)+'">▾</button></div></td>';
|
|
1603
1989
|
}
|
|
1604
1990
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1991
|
+
/* tgpick = 固定ラベルの複数選択 / refpick = 参照テーブル(ID→name)の複数選択。
|
|
1992
|
+
DOM は同じ (.tgpick) に揃えて CSS・E2E セレクタ・ドラッグ判定を共通化する。
|
|
1993
|
+
ラベルは **col.label フォールバック必須**: refpick 列は pf().targeting にキーを持たないので
|
|
1994
|
+
targeting[col.tgKey].label を無条件参照すると TypeError になる。 */
|
|
1995
|
+
if(col.kind==='tgpick' || col.kind==='refpick'){
|
|
1996
|
+
const tg = col.tgKey ? pf().targeting[col.tgKey] : null;
|
|
1997
|
+
const label=(tg&&tg.label)||col.label||'';
|
|
1998
|
+
const shown = col.kind==='refpick' ? refPickDisplay(col, val) : val;
|
|
1999
|
+
const disp = shown? '<span class="tgval">'+esc(shown)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(label)+'</span></span>';
|
|
2000
|
+
return '<td class="tgtd"><div class="tgpick'+(shown?'':' tgempty')+'" '+sel+' title="'+esc(shown||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/* S15: kind:'datetime' = セル内を <input type=date> + <input type=time> の2分割にする。
|
|
2004
|
+
state は従来どおり1本の文字列 ("YYYY-MM-DD HH:mm" / 時刻が空なら "YYYY-MM-DD") なので、
|
|
2005
|
+
col.get/col.set は不要でコピペ・一括入力・連番は既存経路のまま動く。
|
|
2006
|
+
・data-sel は外側の .dtcell に付ける (範囲選択・貼付の対象は従来どおりセル単位)
|
|
2007
|
+
・内側は data-<uAttr> + data-dtf(列名) + data-dtpart(date|time) を持ち、専用 change ハンドラが拾う
|
|
2008
|
+
・focusRestoreG が再描画後にフォーカスを戻せるよう data-<fAttr> も一意に振る (時刻側は末尾 '#t')
|
|
2009
|
+
・日付が空のうちは時刻を disabled にする (joinDateTime が時刻だけの値を空へ潰すため。チップと同じ) */
|
|
2010
|
+
if(col.kind==='datetime'){
|
|
2011
|
+
const p=splitDateTime(val), noDate=!p.date;
|
|
2012
|
+
const ph=col.placeholder?col.placeholder(ctx,row):'';
|
|
2013
|
+
const bad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
|
|
2014
|
+
const tip=(col.title||'')+(ph?('\n空欄なら「'+ph+'」が使われます'):'');
|
|
2015
|
+
const inp=(type,v,part,extra)=>'<input class="dtin" type="'+type+'" '
|
|
2016
|
+
+'data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+(part==='time'?'#t':'')+'" '
|
|
2017
|
+
+'data-dtf="'+col.field+'" data-dtpart="'+part+'" value="'+esc(v)+'"'+(extra||'')+'>';
|
|
2018
|
+
return '<td class="dttd"><div class="dtcell'+(bad?' err':'')+'" '+sel
|
|
2019
|
+
+(tip?(' title="'+esc(tip)+'"'):'')+'>'
|
|
2020
|
+
+ inp('date', p.date, 'date')
|
|
2021
|
+
+ inp('time', p.time, 'time', (noDate?' disabled':'')
|
|
2022
|
+
+' title="'+(noDate?'先に日付を選んでください(時刻だけの指定はできません)':'時刻(任意)。空欄なら日付のみの指定になります')+'"')
|
|
2023
|
+
+ '</div></td>';
|
|
1609
2024
|
}
|
|
1610
2025
|
|
|
1611
2026
|
/* text / mono / number */
|
|
1612
|
-
const
|
|
2027
|
+
const rowBad=!!(ctx.rowErrCodes && (col.errCodes||[]).some(e=>ctx.rowErrCodes.indexOf(e)>=0));
|
|
2028
|
+
const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'')+(rowBad?' err':'');
|
|
1613
2029
|
return '<td><input class="'+cls+'"'+(col.kind==='number'?' type="number"':'')+' '+sel+' value="'+esc(val)+'"'+
|
|
1614
2030
|
(col.placeholder?(' placeholder="'+esc(col.placeholder(ctx, row))+'"'):'')+
|
|
1615
2031
|
(col.title?(' title="'+esc(col.title)+'"'):'')+sty+'></td>';
|
|
@@ -1638,7 +2054,6 @@ function renderTree(){
|
|
|
1638
2054
|
html+='<span class="metae"><span class="ratio">'+esc(ratio)+'</span>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c));
|
|
1639
2055
|
// 予算/入札は編集可(新規扱い)のCPのみ表示 (既存呼び出しCPは広告追加専用で予算等は無視)
|
|
1640
2056
|
if(cpEd) html+=pf().tree.campaignBadges(c, pf(), st.project); // S7: 予算/入札の見せ方は媒体スキーマ
|
|
1641
|
-
if(c.variable) html+='<span class="pill purple">変数:'+esc(c.variable)+'</span>';
|
|
1642
2057
|
if(cerr) html+='<span class="pill danger">要修正</span>';
|
|
1643
2058
|
html+='</span>';
|
|
1644
2059
|
html+='<span class="nodeact">'+(cpEd?'<button class="iconbtn" data-act="cp-edit" data-u="'+c.uid+'" title="設定">⚙</button>':'')+'<button class="iconbtn" data-act="cp-dup" data-u="'+c.uid+'" title="複製/量産">⧉</button><button class="iconbtn danger" data-act="cp-del" data-u="'+c.uid+'" title="削除">✕</button></span>';
|
|
@@ -1793,6 +2208,100 @@ function refDel(tableKey,no){
|
|
|
1793
2208
|
・derive : kind:'derived' のセル内容 (resolveAd 由来の読取専用セル)
|
|
1794
2209
|
・tgKey : pf().targeting のキー
|
|
1795
2210
|
・sequenceable: 連番採番の対象列 / notSubmitted: 入稿JSONに載らない列 (書き出し時の警告対象) */
|
|
2211
|
+
/* ---- S14: フリークエンシー (viewableFrequencyCap) の共通部品 ----
|
|
2212
|
+
キャンペーン行の列 (kind:'freq') と 案件設定チップ (kind:'freq') の両方が使う。
|
|
2213
|
+
値の持ち方は「4フィールドを行/案件にフラットに持つ」+「列としては1本の文字列に畳む」。
|
|
2214
|
+
畳んだ文字列を get/set フックに載せることで、コピー&ペースト・一括入力が他の列と同じ経路で動く。 */
|
|
2215
|
+
const FREQ_FIELDS=['freqOn','freqLevel','freqUnit','freqCount'];
|
|
2216
|
+
/* enum値 → 日本語ラベル / 日本語ラベル (または enum値そのもの) → enum値 */
|
|
2217
|
+
function freqEnumLabel(kind, v){ const m=(pf().enums||{})[kind]||{}; const k=Object.keys(m).find(x=>m[x]===v); return k||''; }
|
|
2218
|
+
function freqEnumValue(kind, label, fb){
|
|
2219
|
+
const m=(pf().enums||{})[kind]||{}, s=String(label||'').trim();
|
|
2220
|
+
if(m[s]) return m[s];
|
|
2221
|
+
if(Object.keys(m).some(x=>m[x]===s)) return s; // "AD_GROUP" のような enum 値の直接指定も許す
|
|
2222
|
+
return fb;
|
|
2223
|
+
}
|
|
2224
|
+
function freqOptions(kind, cur){
|
|
2225
|
+
const m=(pf().enums||{})[kind]||{};
|
|
2226
|
+
return Object.keys(m).map(l=>'<option value="'+esc(m[l])+'"'+(m[l]===cur?' selected':'')+'>'+esc(l)+'</option>').join('');
|
|
2227
|
+
}
|
|
2228
|
+
/* 回数の正規化。**小数を必ず落とす**のが要点:
|
|
2229
|
+
backend の campaignSchema は vImps: z.number().optional() で int 制約が無く、dry-run も素通りするため、
|
|
2230
|
+
2.5 のような値は本実行で初めて Yahoo API に弾かれる。GUI 側で整数化してしまう。
|
|
2231
|
+
"2.5"→2 / "3回"→3 / ""・0・負数→0 (呼び出し側が既定へフォールバックする) */
|
|
2232
|
+
function freqNum(v){
|
|
2233
|
+
const n=Math.floor(Number(String(v==null?'':v).replace(/[^0-9.]/g,'')));
|
|
2234
|
+
return n>0?n:0;
|
|
2235
|
+
}
|
|
2236
|
+
/* ONにする時の欠損補完。旧プラン (サンプルプラン.json 含む) のキャンペーン行は freq* を持たないので、
|
|
2237
|
+
ここで案件設定チップの既定 → 定数の順に埋める。埋めないと「ONにしたのに入稿ボディに載らない」になる。 */
|
|
2238
|
+
function freqEnsure(row){
|
|
2239
|
+
const p=st.project||{};
|
|
2240
|
+
if(!row.freqLevel) row.freqLevel=p.freqLevel||'AD_GROUP';
|
|
2241
|
+
if(!row.freqUnit) row.freqUnit =p.freqUnit ||'WEEK';
|
|
2242
|
+
if(!(freqNum(row.freqCount)>0)) row.freqCount=freqNum(p.freqCount)||3;
|
|
2243
|
+
else row.freqCount=freqNum(row.freqCount);
|
|
2244
|
+
}
|
|
2245
|
+
/* ---- モード (3状態) ----
|
|
2246
|
+
'default' = 案件共通設定に追従 (既定) / 'on' = この行で個別指定 / 'off' = 付けない
|
|
2247
|
+
旧データ (freqMode 無し) は freqOn:true のときだけ 'on'、それ以外は既定の 'default' に寄せる。 */
|
|
2248
|
+
const FREQ_MODES=[{v:'default',t:'既定値'},{v:'on',t:'オン'},{v:'off',t:'オフ'}];
|
|
2249
|
+
function freqMode(row){
|
|
2250
|
+
if(!row) return 'default';
|
|
2251
|
+
if(row.freqMode==='on'||row.freqMode==='off'||row.freqMode==='default') return row.freqMode;
|
|
2252
|
+
return row.freqOn===true?'on':'default';
|
|
2253
|
+
}
|
|
2254
|
+
/* 実際に入稿へ出す値。'default' は案件共通設定を引く。付けないときは null */
|
|
2255
|
+
function freqEffective(row){
|
|
2256
|
+
const m=freqMode(row), p=st.project||{};
|
|
2257
|
+
if(m==='off') return null;
|
|
2258
|
+
const src = m==='on' ? row : (p.freqOn ? p : null);
|
|
2259
|
+
if(!src) return null;
|
|
2260
|
+
const n=freqNum(src.freqCount)||3;
|
|
2261
|
+
return {level:src.freqLevel||'AD_GROUP', unit:src.freqUnit||'WEEK', count:n};
|
|
2262
|
+
}
|
|
2263
|
+
/* 表示・コピー用の1行テキスト。'off'=空文字 / 'default'=「既定値」 / 'on'=「3回/週/広告グループ」 */
|
|
2264
|
+
function freqCellText(row){
|
|
2265
|
+
const m=freqMode(row);
|
|
2266
|
+
if(m==='off') return '';
|
|
2267
|
+
if(m==='default') return '既定値';
|
|
2268
|
+
const n=freqNum(row.freqCount)||3;
|
|
2269
|
+
return n+'回/'+freqEnumLabel('freqUnit', row.freqUnit||'WEEK')+'/'+freqEnumLabel('freqLevel', row.freqLevel||'AD_GROUP');
|
|
2270
|
+
}
|
|
2271
|
+
/* 貼付/一括入力で入ってくる文字列をモード+3値へ戻す。欠けた要素は現在値を残す (完全上書きしない)。 */
|
|
2272
|
+
function freqCellApply(row, val){
|
|
2273
|
+
const s=String(val==null?'':val).trim();
|
|
2274
|
+
if(!s || /^(未設定|なし|off|オフ|-|—)$/i.test(s)){ row.freqMode='off'; row.freqOn=false; return; }
|
|
2275
|
+
if(/^(既定値|規定値|既定|default)$/i.test(s)){ row.freqMode='default'; row.freqOn=false; return; }
|
|
2276
|
+
const parts=s.split(/[\//]/).map(x=>x.trim());
|
|
2277
|
+
const n=freqNum(parts[0]);
|
|
2278
|
+
row.freqMode='on'; row.freqOn=true;
|
|
2279
|
+
if(n>0) row.freqCount=n;
|
|
2280
|
+
if(parts[1]) row.freqUnit =freqEnumValue('freqUnit', parts[1], row.freqUnit ||'WEEK');
|
|
2281
|
+
if(parts[2]) row.freqLevel=freqEnumValue('freqLevel', parts[2], row.freqLevel||'AD_GROUP');
|
|
2282
|
+
freqEnsure(row);
|
|
2283
|
+
}
|
|
2284
|
+
/* 回数の選択肢 (1〜10回)。実運用の範囲に絞る */
|
|
2285
|
+
function freqCountOptions(cur){
|
|
2286
|
+
const c=freqNum(cur)||3; let h='';
|
|
2287
|
+
for(let i=1;i<=10;i++) h+='<option value="'+i+'"'+(i===c?' selected':'')+'>'+i+'回</option>';
|
|
2288
|
+
return h;
|
|
2289
|
+
}
|
|
2290
|
+
/* ON のときに出す3つのプルダウン (適用 / 期間 / 回数)。**ラベルは出さない**(全部ワンライン)。
|
|
2291
|
+
attrs(field) が data-* を返す (キャンペーン行 = data-cu+data-freqf / 案件チップ = data-proj)。 */
|
|
2292
|
+
function freqSectionsHtml(o, attrs){
|
|
2293
|
+
const lv=o.freqLevel||'AD_GROUP', un=o.freqUnit||'WEEK';
|
|
2294
|
+
return '<select class="freqin" title="適用レベル" '+attrs('freqLevel')+'>'+freqOptions('freqLevel', lv)+'</select>'
|
|
2295
|
+
+ '<select class="freqin" title="期間単位" ' +attrs('freqUnit') +'>'+freqOptions('freqUnit', un)+'</select>'
|
|
2296
|
+
+ '<select class="freqin freqnum" title="上限回数" '+attrs('freqCount')+'>'+freqCountOptions(o.freqCount)+'</select>';
|
|
2297
|
+
}
|
|
2298
|
+
/* 'default' のときにセルへ薄く出す、案件共通設定の内容 */
|
|
2299
|
+
function freqInheritText(){
|
|
2300
|
+
const p=st.project||{};
|
|
2301
|
+
if(!p.freqOn) return '共通設定: なし';
|
|
2302
|
+
return freqEnumLabel('freqLevel', p.freqLevel||'AD_GROUP')+'/'+freqEnumLabel('freqUnit', p.freqUnit||'WEEK')+'/'+(freqNum(p.freqCount)||3)+'回';
|
|
2303
|
+
}
|
|
2304
|
+
|
|
1796
2305
|
const COLS = {
|
|
1797
2306
|
ads:[
|
|
1798
2307
|
{field:'format', label:'形式', kind:'select', options:['Card','SQ'], width:'56px',
|
|
@@ -1814,13 +2323,28 @@ const COLS = {
|
|
|
1814
2323
|
ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
|
|
1815
2324
|
{field:'paramCode', label:'paramCode(グループ)', kind:'mono', width:'120px',
|
|
1816
2325
|
scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
|
|
1817
|
-
title:'
|
|
2326
|
+
title:'グループ共通(変更で同グループ全広告に反映)。最終URLは 記事URL + paramCode',
|
|
2327
|
+
hidden:p=>urlParamsOff(p),
|
|
1818
2328
|
roStyle:'input', roTitle:'既存広告グループのため編集不可',
|
|
1819
2329
|
onSet:it=>autoResolveGroupIds(it.g,it.c)},
|
|
1820
|
-
|
|
2330
|
+
/* C8: 記事slug / 記事URL(上書き) 列は廃止。URLの可変部は paramCode だけになったので、
|
|
2331
|
+
最終リンク先URL は **読み取り専用の導出セル** (貼り戻し先が無い)。 */
|
|
2332
|
+
{field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
|
|
2333
|
+
alwaysReadonly:true, roStyle:'input',
|
|
2334
|
+
roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
|
|
2335
|
+
get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
|
|
2336
|
+
/* C5: 参照表(refs.titles / refs.descriptions)の本文を直接編集する列。
|
|
2337
|
+
番号未入力のまま本文を書くと空き番号を自動採番して参照表に登録する。 */
|
|
2338
|
+
makeRefTextCol({field:'titleText', label:'タイトル本文', table:'titles', noField:'titleNo', width:'180px'}),
|
|
2339
|
+
makeRefTextCol({field:'descText', label:'説明文本文', table:'descriptions', noField:'descNo', width:'180px'}),
|
|
2340
|
+
/* C6-1: 量産時のローカル画像パス担体 (カンマ区切りで複数可)。少数入稿は Skill の画像入稿フローが主導線 */
|
|
2341
|
+
{field:'imagePath', label:'画像パス', kind:'mono', width:'150px',
|
|
2342
|
+
title:'ローカル画像の絶対 / ~/… / 計画JSON基準の相対パス。カンマ区切りで複数可。※画像番号が参照表で解決できる行では入稿JSONに載りません(差し替えたいときは画像番号を空に)'},
|
|
2343
|
+
/* C6: 広告名は自由入力 (空 = 自動生成)。placeholder に自動生成値を出す */
|
|
2344
|
+
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
2345
|
+
title:'空欄なら自動生成({画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
|
|
2346
|
+
placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
|
|
1821
2347
|
/* derived: resolveAd() 由来の読取専用セル。tabCols()/貼付/セル選択の対象外 (末尾固定) */
|
|
1822
|
-
{field:'adName', label:'広告名(自動)', kind:'derived',
|
|
1823
|
-
derive:(a,ctx)=>'<span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(ctx.r.finalUrl)+'">'+esc(ctx.r.adName)+'</span>'},
|
|
1824
2348
|
{field:'adGroupId', label:'CP / AG ID', kind:'derived',
|
|
1825
2349
|
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AG '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
1826
2350
|
],
|
|
@@ -1830,8 +2354,8 @@ const COLS = {
|
|
|
1830
2354
|
{field:'name', label:'広告グループ名', kind:'text', width:'150px', required:true,
|
|
1831
2355
|
readonlyOnExisting:true, roTitle:'既存広告グループのため編集不可'},
|
|
1832
2356
|
{field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
|
|
1833
|
-
sequenceable:true, readonlyOnExisting:true,
|
|
1834
|
-
title:'URL
|
|
2357
|
+
sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
|
|
2358
|
+
title:'URL生成用のパラメータ(グループ紐づけには不使用)。最終URLは 記事URL + paramCode',
|
|
1835
2359
|
onSet:it=>autoResolveGroupIds(it.g,it.c)},
|
|
1836
2360
|
{field:'bid', label:'入札', kind:'text', width:'56px', readonlyOnExisting:true, roEmptyDash:true},
|
|
1837
2361
|
{field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
|
|
@@ -1841,9 +2365,23 @@ const COLS = {
|
|
|
1841
2365
|
{field:'tg_age', label:'年齢', kind:'tgpick', tgKey:'tg_age', readonlyOnExisting:true, roEmptyDash:true},
|
|
1842
2366
|
{field:'tg_region', label:'地域', kind:'tgpick', tgKey:'tg_region', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
|
|
1843
2367
|
{field:'tg_dayHour', label:'曜日時間', kind:'tgpick', tgKey:'tg_dayHour', readonlyOnExisting:true, roEmptyDash:true},
|
|
1844
|
-
|
|
2368
|
+
/* C7: オーディエンス/配置は refpick (参照テーブルの 実ID → name)。値は実IDのCSVで持ち、表示だけ name。
|
|
2369
|
+
coerce で数値ID以外 (旧・日本語ラベル / 手入力の名前) を落とす。 */
|
|
2370
|
+
{field:'tg_audience', label:'オーディエンス', kind:'refpick', refTable:'audiences',
|
|
2371
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
2372
|
+
{field:'tg_audienceEx', label:'オーディエンス除外', kind:'refpick', refTable:'audiences',
|
|
2373
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
2374
|
+
{field:'tg_placement', label:'配置', kind:'refpick', refTable:'placements',
|
|
2375
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
2376
|
+
{field:'tg_placementEx',label:'配置除外', kind:'refpick', refTable:'placements',
|
|
2377
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:refIdCoerce},
|
|
1845
2378
|
/* 暗黙 else を廃止: pf().targeting に無い列は kind:'text' で明示する */
|
|
1846
|
-
{field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true}
|
|
2379
|
+
{field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
|
|
2380
|
+
/* C8: 空欄 = オン (未設定・旧データはすべて true)。明示的に「オフ」を選んだときだけ false */
|
|
2381
|
+
{field:'smartTargeting', label:'スマートTG', kind:'select', options:['','オン','オフ'], minWidth:'66px',
|
|
2382
|
+
readonlyOnExisting:true, roEmptyDash:true,
|
|
2383
|
+
title:'空欄は「オン」扱い(方針: スマートターゲティングは常時ON)',
|
|
2384
|
+
coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
|
|
1847
2385
|
],
|
|
1848
2386
|
/* E-2: campaignId最左・read-only / mode/status/strategyMemo撤去 */
|
|
1849
2387
|
campaigns:[
|
|
@@ -1856,7 +2394,13 @@ const COLS = {
|
|
|
1856
2394
|
hideOnExisting:true, emptyNoSelected:true},
|
|
1857
2395
|
{field:'tcpa', label:'tCPA', kind:'number', width:'70px',
|
|
1858
2396
|
placeholder:()=>String(st.project.tcpaDefault), coerce:numOrNull, hideOnExisting:true},
|
|
1859
|
-
|
|
2397
|
+
/* S14: フリークエンシー (viewableFrequencyCap)。YDA API はキャンペーン単位の設定なのでこの行に置く。
|
|
2398
|
+
値は行に freqOn / freqLevel / freqUnit / freqCount の4フィールドで持ち、
|
|
2399
|
+
列としては get/set フックで1本の文字列に畳む → コピー&ペーストがそのまま往復する。
|
|
2400
|
+
文字列形式: OFF は空文字 / ON は "3回/週/広告グループ" (回数/期間/適用レベル) */
|
|
2401
|
+
{field:'freqCap', label:'フリークエンシー', kind:'freq', hideOnExisting:true,
|
|
2402
|
+
get:row=>freqCellText(row),
|
|
2403
|
+
set:(row,val)=>freqCellApply(row,val)},
|
|
1860
2404
|
{field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
|
|
1861
2405
|
]
|
|
1862
2406
|
};
|
|
@@ -1959,11 +2503,18 @@ const META_COLS = {
|
|
|
1959
2503
|
ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
|
|
1960
2504
|
{field:'paramCode', label:'paramCode(広告セット)', kind:'mono', width:'120px',
|
|
1961
2505
|
scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
|
|
1962
|
-
title:'
|
|
2506
|
+
title:'広告セット共通(変更で同広告セット全広告に反映)。最終URLは 記事URL?ad={paramCode}',
|
|
2507
|
+
hidden:p=>urlParamsOff(p),
|
|
1963
2508
|
roStyle:'input', roTitle:'既存広告セットのため編集不可'},
|
|
1964
|
-
|
|
1965
|
-
{field:'
|
|
1966
|
-
|
|
2509
|
+
/* C8: 記事slug / 記事URL(上書き) 列は廃止。最終URL = 記事URL + ?ad={paramCode} の導出セル */
|
|
2510
|
+
{field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
|
|
2511
|
+
alwaysReadonly:true, roStyle:'input',
|
|
2512
|
+
roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
|
|
2513
|
+
get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
|
|
2514
|
+
/* C6: 広告名は自由入力 (空 = 自動生成)。YDA と同じ規約 */
|
|
2515
|
+
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
2516
|
+
title:'空欄なら自動生成({番号}{i|v}-{見出し番号}L{本文番号}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
|
|
2517
|
+
placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
|
|
1967
2518
|
{field:'adGroupId', label:'CP / AdSet ID', kind:'derived',
|
|
1968
2519
|
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AS '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
1969
2520
|
],
|
|
@@ -1972,8 +2523,8 @@ const META_COLS = {
|
|
|
1972
2523
|
{field:'name', label:'広告セット名', kind:'text', width:'150px', required:true,
|
|
1973
2524
|
readonlyOnExisting:true, roTitle:'既存広告セットのため編集不可'},
|
|
1974
2525
|
{field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
|
|
1975
|
-
sequenceable:true, readonlyOnExisting:true,
|
|
1976
|
-
title:'URL
|
|
2526
|
+
sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
|
|
2527
|
+
title:'URL生成用のパラメータ(広告セット紐づけには不使用)。最終URLは 記事URL + paramCode'},
|
|
1977
2528
|
/* budgetLevel='adGroup' → 予算は広告セット行が持つ (CBO は使わない) */
|
|
1978
2529
|
{field:'dailyBudget', label:'日予算(円)', kind:'number', width:'84px', required:true,
|
|
1979
2530
|
coerce:numOrNull, readonlyOnExisting:true, roEmptyDash:true, errCodes:[EC.BUDGET_MISSING],
|
|
@@ -1995,6 +2546,23 @@ const META_COLS = {
|
|
|
1995
2546
|
{field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
|
|
1996
2547
|
{field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
|
|
1997
2548
|
{field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
|
|
2549
|
+
/* 配信日時: 空欄 = 案件設定チップの値を継承(buildPlan 側で解決)。placeholder に継承先を出す */
|
|
2550
|
+
{field:'startDate', label:'配信開始日時', kind:'datetime', width:'17ch',
|
|
2551
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
|
|
2552
|
+
errCodes:[EC.START_TIME_INVALID, EC.END_BEFORE_START],
|
|
2553
|
+
placeholder:()=>{ const v=String((st.project&&st.project.startDate)||'').trim();
|
|
2554
|
+
return v!=='' ? v : '案件設定 / 今日'; },
|
|
2555
|
+
title:'この広告セットだけの配信開始日時(JST)。空欄なら案件設定「配信開始日時」を継承し、'
|
|
2556
|
+
+'それも空欄なら当日 00:00。日付のみなら 00:00 開始(例 2026-08-01 10:30)。'
|
|
2557
|
+
+'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
|
|
2558
|
+
{field:'endDate', label:'配信終了日時', kind:'datetime', width:'17ch',
|
|
2559
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>String(v==null?'':v).trim(),
|
|
2560
|
+
errCodes:[EC.END_TIME_INVALID, EC.END_BEFORE_START],
|
|
2561
|
+
placeholder:()=>{ const v=String((st.project&&st.project.endDate)||'').trim();
|
|
2562
|
+
return v!=='' ? v : '無期限'; },
|
|
2563
|
+
title:'この広告セットだけの配信終了日時(JST)。空欄なら案件設定「配信終了日時」を継承し、'
|
|
2564
|
+
+'それも空欄なら無期限配信。日付のみならその日の 23:59:59 まで。'
|
|
2565
|
+
+'末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される(JST 補完なし)。'},
|
|
1998
2566
|
{field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
|
|
1999
2567
|
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
|
|
2000
2568
|
],
|
|
@@ -2009,7 +2577,6 @@ const META_COLS = {
|
|
|
2009
2577
|
/* 公式7値 + GUIセンチネル NONE (送信時 [] に変換) */
|
|
2010
2578
|
options:['NONE','HOUSING','EMPLOYMENT','CREDIT','ISSUES_ELECTIONS_POLITICS',
|
|
2011
2579
|
'ONLINE_GAMBLING_AND_GAMING','FINANCIAL_PRODUCTS_SERVICES']},
|
|
2012
|
-
{field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
|
|
2013
2580
|
{field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
|
|
2014
2581
|
]
|
|
2015
2582
|
};
|
|
@@ -2027,6 +2594,10 @@ PLATFORMS.meta = {
|
|
|
2027
2594
|
if(obj.pixelId!=null) project.pixelId=String(obj.pixelId);
|
|
2028
2595
|
if(obj.projectName) project.name=obj.projectName;
|
|
2029
2596
|
if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
|
|
2597
|
+
/* C3: YDA と同じ規約。**キー無しは必ず既定 (undefined = ON) に戻す** —
|
|
2598
|
+
同一媒体内のアカウント切替では st.project が作り直されないため、
|
|
2599
|
+
「あるときだけ上書き」だと前アカウントの OFF が残って計測パラメータが黙って消える */
|
|
2600
|
+
if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
|
|
2030
2601
|
},
|
|
2031
2602
|
schemaLines(opts){
|
|
2032
2603
|
const o=opts||{};
|
|
@@ -2081,10 +2652,19 @@ PLATFORMS.meta = {
|
|
|
2081
2652
|
{key:'cta', label:'CTA', kind:'select',
|
|
2082
2653
|
options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
|
|
2083
2654
|
'予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
|
|
2084
|
-
{key:'startDate', label:'
|
|
2085
|
-
|
|
2655
|
+
{key:'startDate', label:'配信開始日時', kind:'datetime', fallback:'',
|
|
2656
|
+
title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
|
|
2657
|
+
+'日付のみなら 00:00 開始。時刻を入れるとその時刻ちょうどから配信(例 2026-08-01 10:30)。'
|
|
2658
|
+
+'空欄なら当日 00:00 から。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
|
|
2659
|
+
{key:'endDate', label:'配信終了日時', kind:'datetime', fallback:'',
|
|
2660
|
+
title:'案件の既定値(広告セット行が空のときに使う)。すべて JST。'
|
|
2661
|
+
+'日付のみならその日の 23:59:59 まで。時刻を入れるとその時刻で終了(例 2026-08-31 18:00)。'
|
|
2662
|
+
+'空欄なら終了日時を送らず無期限配信。末尾に +09:00 / Z などのオフセットを書いた場合はそのまま採用される。'},
|
|
2086
2663
|
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
|
|
2087
|
-
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''}
|
|
2664
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''},
|
|
2665
|
+
/* C3: 案件単位の URLパラメータ ON/OFF (欠落 = ON = 従来挙動) */
|
|
2666
|
+
{key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
|
|
2667
|
+
title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
|
|
2088
2668
|
],
|
|
2089
2669
|
fixedType:[{label:'広告タイプ',value:'単一画像 / 単一動画'}],
|
|
2090
2670
|
|
|
@@ -2104,7 +2684,7 @@ PLATFORMS.meta = {
|
|
|
2104
2684
|
cta:{'詳しくはこちら':'LEARN_MORE','購入する':'SHOP_NOW','登録する':'SIGN_UP',
|
|
2105
2685
|
'ダウンロード':'DOWNLOAD','お問い合わせ':'CONTACT_US','今すぐ申し込む':'APPLY_NOW',
|
|
2106
2686
|
'予約する':'BOOK_NOW','見積もりを取得':'GET_QUOTE','購読する':'SUBSCRIBE',
|
|
2107
|
-
'動画を見る':'WATCH_MORE','ボタンなし':'NO_BUTTON'},
|
|
2687
|
+
'動画を見る':'WATCH_MORE','詳細を見る':'SEE_DETAILS','ボタンなし':'NO_BUTTON'},
|
|
2108
2688
|
ctaFallback:'LEARN_MORE',
|
|
2109
2689
|
statusOn:'ACTIVE', statusOff:'PAUSED',
|
|
2110
2690
|
/* 特別広告カテゴリでターゲティングが強制されるルール (公式) */
|
|
@@ -2129,11 +2709,95 @@ PLATFORMS.meta = {
|
|
|
2129
2709
|
},
|
|
2130
2710
|
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
2131
2711
|
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
|
|
2712
|
+
/* 日時の正規化。日付のみ / 日付+時刻 の両方を受け、JST 明示 ISO8601 を返す。
|
|
2713
|
+
- 空 → null
|
|
2714
|
+
- 末尾にオフセット(+09:00 / +0900 / Z)を持つ完全 ISO → そのまま素通し (後方互換)
|
|
2715
|
+
- YYYY-MM-DD → opts.endOfDay ? T23:59:59 : T00:00:00 を補って +0900
|
|
2716
|
+
- YYYY-MM-DD[ T]HH:mm(:ss)? → 時刻が明示されているので endOfDay は適用しない
|
|
2717
|
+
- どれにも当たらない → {invalid:true, input} を返す (例外は投げない)
|
|
2718
|
+
実在しない日付 (2026-02-30 / 2026-13-99) と範囲外の時刻 (24:00 / 10:75) は不正とする。
|
|
2719
|
+
Date は 2/30 を 3/2 へロールオーバーするので、生成した Date の年月日が入力と
|
|
2720
|
+
一致するかを照合しないと非実在日付が素通りする。 */
|
|
2721
|
+
normDateTime(input, opts){
|
|
2722
|
+
const o=opts||{}, p2=n=>String(n).padStart(2,'0');
|
|
2723
|
+
/* 実在する暦日か (ロールオーバー検出) */
|
|
2724
|
+
const realDate=(y,mo,d)=>{
|
|
2725
|
+
const t=new Date(Date.UTC(y, mo-1, d));
|
|
2726
|
+
return t.getUTCFullYear()===y && t.getUTCMonth()===mo-1 && t.getUTCDate()===d;
|
|
2727
|
+
};
|
|
2728
|
+
let s=String(input==null?'':input).replace(/ /g,' ').trim();
|
|
2729
|
+
if(!s) return null;
|
|
2730
|
+
s=s.replace(/\//g,'-');
|
|
2731
|
+
/* 完全 ISO (末尾にオフセット or Z) は **原文のまま返す** (後方互換: 既存プランの
|
|
2732
|
+
'…T00:00:00.000Z' を書き換えない)。ただし素通し=無検証にはしない。
|
|
2733
|
+
検証を飛ばすと 2026-02-30T10:00:00+0900 / 24:00 / 実在しない閏日が
|
|
2734
|
+
プランナーでは valid のまま書き出され、失敗が dry-run (backend 400) まで遅延する。 */
|
|
2735
|
+
const iso=s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:([+-])(\d{2}):?(\d{2})|Z)$/);
|
|
2736
|
+
if(iso){
|
|
2737
|
+
const hh=+iso[4], mi=+iso[5], ss=+(iso[6]||0);
|
|
2738
|
+
if(!realDate(+iso[1], +iso[2], +iso[3])) return {invalid:true, input:String(input)};
|
|
2739
|
+
if(hh>23 || mi>59 || ss>59) return {invalid:true, input:String(input)};
|
|
2740
|
+
/* UTC オフセットの実在範囲は -12:00〜+14:00 (公式の最大は +14:00)。
|
|
2741
|
+
+24:00 や +99:99 は Meta へ渡すと AdSet 作成時に落ち、キャンペーンだけ残る */
|
|
2742
|
+
if(iso[7]){
|
|
2743
|
+
const oh=+iso[8], om=+iso[9];
|
|
2744
|
+
if(om>59) return {invalid:true, input:String(input)};
|
|
2745
|
+
const mins=oh*60+om;
|
|
2746
|
+
if(iso[7]==='+' ? mins>14*60 : mins>12*60) return {invalid:true, input:String(input)};
|
|
2747
|
+
}
|
|
2748
|
+
return s;
|
|
2749
|
+
}
|
|
2750
|
+
if(/(?:[+-]\d{2}:?\d{2}|Z)$/.test(s)) return {invalid:true, input:String(input)}; /* オフセットらしき末尾だが形が不正 */
|
|
2751
|
+
let m=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
2752
|
+
if(m){
|
|
2753
|
+
if(!realDate(+m[1], +m[2], +m[3])) return {invalid:true, input:String(input)};
|
|
2754
|
+
const d=m[1]+'-'+p2(m[2])+'-'+p2(m[3]);
|
|
2755
|
+
return d + (o.endOfDay ? 'T23:59:59' : 'T00:00:00') + '+0900';
|
|
2756
|
+
}
|
|
2757
|
+
m=s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
|
2758
|
+
if(m){
|
|
2759
|
+
const hh=+m[4], mi=+m[5], ss=+(m[6]||0);
|
|
2760
|
+
if(!realDate(+m[1], +m[2], +m[3])) return {invalid:true, input:String(input)};
|
|
2761
|
+
/* 24:00 は ISO8601 上は翌日 0:00 だが、意図と実挙動が食い違うので受け付けない */
|
|
2762
|
+
if(hh>23 || mi>59 || ss>59) return {invalid:true, input:String(input)};
|
|
2763
|
+
return m[1]+'-'+p2(m[2])+'-'+p2(m[3])+'T'
|
|
2764
|
+
+ p2(hh)+':'+p2(mi)+':'+p2(ss)+'+0900';
|
|
2765
|
+
}
|
|
2766
|
+
return {invalid:true, input:String(input)};
|
|
2767
|
+
},
|
|
2768
|
+
/* 正規化後の日時を epoch(ms) にする。空 / 不正 / 解釈不能は NaN。
|
|
2769
|
+
normDateTime が返す +0900 (コロン無し) は Date.parse が拒む環境があるのでコロンを補う。 */
|
|
2770
|
+
epochOf(v, o){
|
|
2771
|
+
/* opts はそのまま normDateTime に渡す。終了日時の前後比較では **必ず {endOfDay:true} を渡すこと**。
|
|
2772
|
+
渡さないと日付のみの終了日が 00:00 として比較され、
|
|
2773
|
+
「start=2026-08-31 18:00 / end=2026-08-31」(実出力は 23:59:59 なので正当) を
|
|
2774
|
+
END_BEFORE_START と誤判定してブロックしてしまう。 */
|
|
2775
|
+
const r=PLATFORMS.meta.mappers.normDateTime(v, o);
|
|
2776
|
+
if(!r || typeof r==='object') return NaN;
|
|
2777
|
+
return Date.parse(String(r).replace(/([+-]\d{2})(\d{2})$/, '$1:$2'));
|
|
2778
|
+
},
|
|
2779
|
+
/* 空は valid 扱い (未指定 = 上位/既定へフォールバック)。
|
|
2780
|
+
書式が合っていても実在しない日付 (2026-02-30 / 2026-13-99) や範囲外の時刻 (24:00)
|
|
2781
|
+
は normDateTime が {invalid:true} を返すので不正になる。 */
|
|
2782
|
+
isValidDateTime(v){
|
|
2783
|
+
const r=PLATFORMS.meta.mappers.normDateTime(v);
|
|
2784
|
+
if(r==null) return true; /* 空欄 */
|
|
2785
|
+
if(typeof r==='object') return false; /* 書式不一致 */
|
|
2786
|
+
return !isNaN(PLATFORMS.meta.mappers.epochOf(v));
|
|
2787
|
+
},
|
|
2132
2788
|
/* B13: JST 00:00 を明示した ISO8601 (日付のみだと UTC 解釈で前日にずれる) */
|
|
2133
|
-
startTime(dateStr){
|
|
2789
|
+
startTime(dateStr){
|
|
2790
|
+
const r=PLATFORMS.meta.mappers.normDateTime(dateStr);
|
|
2791
|
+
if(r && typeof r==='object') return String(r.input); /* 不正はそのまま返し検証側で弾く */
|
|
2792
|
+
return r || (today()+'T00:00:00+0900');
|
|
2793
|
+
},
|
|
2134
2794
|
/* 終了日は「その日の終わりまで配信」の解釈。日付のみの入力を JST 23:59:59 に寄せる。
|
|
2135
2795
|
空欄なら null を返し、呼び出し側で endTime キー自体を出さない (無期限配信)。 */
|
|
2136
|
-
endTime(dateStr){
|
|
2796
|
+
endTime(dateStr){
|
|
2797
|
+
const r=PLATFORMS.meta.mappers.normDateTime(dateStr, {endOfDay:true});
|
|
2798
|
+
if(r && typeof r==='object') return String(r.input);
|
|
2799
|
+
return r;
|
|
2800
|
+
}
|
|
2137
2801
|
},
|
|
2138
2802
|
|
|
2139
2803
|
defaults:{
|
|
@@ -2144,12 +2808,13 @@ PLATFORMS.meta = {
|
|
|
2144
2808
|
bidStrategy:'LOWEST_COST_WITHOUT_CAP', buyingType:'AUCTION',
|
|
2145
2809
|
deliveryStatus:'オフ', startDate:'', endDate:''
|
|
2146
2810
|
},
|
|
2147
|
-
|
|
2811
|
+
/* C6: adName は空 = 自動生成 (naming.adName) */
|
|
2812
|
+
ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', adName:''},
|
|
2148
2813
|
adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
|
|
2149
2814
|
ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
|
|
2150
|
-
tg_placement:'自動配置', deliveryStatus:''},
|
|
2815
|
+
tg_placement:'自動配置', startDate:'', endDate:'', deliveryStatus:''},
|
|
2151
2816
|
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
|
|
2152
|
-
objective:'OUTCOME_SALES', specialAdCategory:'NONE',
|
|
2817
|
+
objective:'OUTCOME_SALES', specialAdCategory:'NONE', urlNumber:''}
|
|
2153
2818
|
},
|
|
2154
2819
|
factories:{
|
|
2155
2820
|
newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.meta.defaults.ad, seed||{}); },
|
|
@@ -2160,7 +2825,7 @@ PLATFORMS.meta = {
|
|
|
2160
2825
|
return Object.assign({uid:uid('c')}, P.defaults.campaign,
|
|
2161
2826
|
{adGroups:[P.factories.newAdGroup(null,0)]}, seed||{}); },
|
|
2162
2827
|
existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
|
|
2163
|
-
isExisting:true, objective:'', specialAdCategory:'NONE',
|
|
2828
|
+
isExisting:true, objective:'', specialAdCategory:'NONE', urlNumber:'', adGroups:[]}; },
|
|
2164
2829
|
existingAdGroup(agId, meta){ const P=PLATFORMS.meta, m=meta||{};
|
|
2165
2830
|
return Object.assign({uid:uid('g')}, P.defaults.adGroup,
|
|
2166
2831
|
{name:m.name||('AS '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
|
|
@@ -2170,11 +2835,19 @@ PLATFORMS.meta = {
|
|
|
2170
2835
|
/* 命名規則は YDA 踏襲 (形式記号は mediaFormats.crSuffix = i/v) */
|
|
2171
2836
|
naming:{
|
|
2172
2837
|
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
2173
|
-
|
|
2838
|
+
/* C8: URLパラメータ OFF なら末尾 "-{paramCode}" を付けない (YDA と同じ) */
|
|
2839
|
+
adName(ad, group, crName, project){
|
|
2840
|
+
const head = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'');
|
|
2841
|
+
if(project && project.urlParams===false) return head;
|
|
2842
|
+
return head + '-' + (group.paramCode||'');
|
|
2843
|
+
},
|
|
2844
|
+
/* C8: Meta の最終URL = {記事URL}?ad={paramCode} のみ (YDA と違い creative= もパス連結もしない)。
|
|
2845
|
+
urlParams===false なら記事URLのみ。base が既にクエリを持つときは '&' で繋ぐ。 */
|
|
2174
2846
|
finalUrl(ad, group, adName, project){
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2847
|
+
const base = (project.baseArticleUrl||'');
|
|
2848
|
+
if(project.urlParams===false) return base;
|
|
2849
|
+
const sep = base.indexOf('?')>=0 ? '&' : '?';
|
|
2850
|
+
return base + sep + 'ad=' + urlq(group.paramCode||'');
|
|
2178
2851
|
},
|
|
2179
2852
|
campaignName(urlNumber, firstAd, P){
|
|
2180
2853
|
const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
|
|
@@ -2202,7 +2875,9 @@ PLATFORMS.meta = {
|
|
|
2202
2875
|
const P=PLATFORMS.meta, p=ctx.project, refs=ctx.refs;
|
|
2203
2876
|
const fmt=P.fmtOf(ad);
|
|
2204
2877
|
const crName=P.naming.crName(ad, P);
|
|
2205
|
-
|
|
2878
|
+
/* C6: 広告名は自由入力。空 = 自動生成にフォールバック */
|
|
2879
|
+
const autoAdName=P.naming.adName(ad, group, crName, p);
|
|
2880
|
+
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
2206
2881
|
const me = refs.media[String(ad.imageNo)];
|
|
2207
2882
|
const mediaId = me ? me[fmt.mediaKey] : undefined;
|
|
2208
2883
|
const thumbHash = me ? me.thumb : undefined;
|
|
@@ -2224,31 +2899,66 @@ PLATFORMS.meta = {
|
|
|
2224
2899
|
else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
|
|
2225
2900
|
if(descText!==undefined && glen(descText)>D.err) pushErr('本文'+D.err+'字超', EC.DESC_TOO_LONG);
|
|
2226
2901
|
else if(descText!==undefined && glen(descText)>D.warn) pushWarn('本文'+D.warn+'字超', EC.DESC_LONG_WARN);
|
|
2227
|
-
|
|
2902
|
+
/* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
|
|
2903
|
+
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
2228
2904
|
if(pendingImage) pushWarn(ad.format==='VIDEO'?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING);
|
|
2229
2905
|
const aid=String(group.adGroupId||'').trim();
|
|
2230
|
-
return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
|
|
2906
|
+
return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
|
|
2231
2907
|
mediaId:mediaId||'', thumbHash:thumbHash||'', pendingImage,
|
|
2232
2908
|
titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText,
|
|
2233
2909
|
finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
2234
2910
|
},
|
|
2235
2911
|
|
|
2236
|
-
/* ---- 広告セット行の追加検証 (
|
|
2912
|
+
/* ---- 広告セット行の追加検証 (予算・入札・配信日時) ---- */
|
|
2237
2913
|
validateRow(row, level, ctx){
|
|
2238
|
-
const P=PLATFORMS.meta, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
|
|
2914
|
+
const P=PLATFORMS.meta, M=P.mappers, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
|
|
2239
2915
|
if(level==='adGroup' && !isExistingGroup(row)){
|
|
2240
2916
|
if(!(Number(row.dailyBudget)>0)) errCodes.push(EC.BUDGET_MISSING);
|
|
2241
2917
|
if(P.enums.bidAmountRequiredFor.indexOf(p.bidStrategy)>=0 && !(Number(row.bidAmount)>0))
|
|
2242
2918
|
errCodes.push('BID_AMOUNT_REQUIRED');
|
|
2919
|
+
/* 配信日時。空欄は正常 (未指定 = 上位へフォールバック) なので isValidDateTime は空を valid とする。
|
|
2920
|
+
前後関係は buildPlan.adGroup と同じ解決順 (行 → 案件設定 → 既定) で比較する。 */
|
|
2921
|
+
const sRaw=String(row.startDate==null?'':row.startDate).trim();
|
|
2922
|
+
const eRaw=String(row.endDate==null?'':row.endDate).trim();
|
|
2923
|
+
const sBad=!M.isValidDateTime(sRaw), eBad=!M.isValidDateTime(eRaw);
|
|
2924
|
+
if(sBad) errCodes.push(EC.START_TIME_INVALID);
|
|
2925
|
+
if(eBad) errCodes.push(EC.END_TIME_INVALID);
|
|
2926
|
+
if(!sBad && !eBad){
|
|
2927
|
+
const s=M.epochOf(firstNonEmpty(sRaw, p.startDate) || today());
|
|
2928
|
+
const e=M.epochOf(firstNonEmpty(eRaw, p.endDate), {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
|
|
2929
|
+
if(!isNaN(s) && !isNaN(e) && e<s) errCodes.push(EC.END_BEFORE_START);
|
|
2930
|
+
}
|
|
2243
2931
|
}
|
|
2244
2932
|
return {errCodes, warnCodes};
|
|
2245
2933
|
},
|
|
2246
|
-
/* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ----
|
|
2247
|
-
|
|
2248
|
-
|
|
2934
|
+
/* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ----
|
|
2935
|
+
第2引数 state は任意。渡された場合は行の配信日時も走査して、不正日時のまま
|
|
2936
|
+
書き出されるのを toast でブロックする (行だけを黙って除外すると気づけないため)。 */
|
|
2937
|
+
validateProject(p, state){
|
|
2938
|
+
const P=PLATFORMS.meta, M=P.mappers, errs=[];
|
|
2249
2939
|
if(!String((p&&p.pageId)||'').trim()) errs.push({code:'PAGE_ID_MISSING', msg:'FBページIDを共通設定に入力してください'});
|
|
2250
2940
|
if(P.enums.pixelRequiredFor.indexOf(p&&p.optimizationGoal)>=0 && !String((p&&p.pixelId)||'').trim())
|
|
2251
2941
|
errs.push({code:EC.PIXEL_MISSING, msg:'最適化が'+p.optimizationGoal+'のためピクセルIDが必須です'});
|
|
2942
|
+
/* 案件設定チップの配信日時 */
|
|
2943
|
+
const ps=String((p&&p.startDate)||'').trim(), pe=String((p&&p.endDate)||'').trim();
|
|
2944
|
+
const psBad=!M.isValidDateTime(ps), peBad=!M.isValidDateTime(pe);
|
|
2945
|
+
if(psBad) errs.push({code:EC.START_TIME_INVALID, msg:'案件設定「配信開始日時」の書式が不正です(例 2026-08-01 10:30): '+ps});
|
|
2946
|
+
if(peBad) errs.push({code:EC.END_TIME_INVALID, msg:'案件設定「配信終了日時」の書式が不正です(例 2026-08-31 23:59): '+pe});
|
|
2947
|
+
if(!psBad && !peBad){
|
|
2948
|
+
const s=M.epochOf(ps||today()), e=M.epochOf(pe, {endOfDay:true}); /* 日付のみの終了は 23:59:59 で比較 */
|
|
2949
|
+
if(!isNaN(s) && !isNaN(e) && e<s) errs.push({code:EC.END_BEFORE_START, msg:'案件設定の配信終了日時が配信開始日時より前です'});
|
|
2950
|
+
}
|
|
2951
|
+
/* 広告セット行の配信日時 (日予算・入札の未入力は従来どおり行除外で扱い、ここでは見ない) */
|
|
2952
|
+
const DATE_CODES=[EC.START_TIME_INVALID, EC.END_TIME_INVALID, EC.END_BEFORE_START];
|
|
2953
|
+
const MSG={};
|
|
2954
|
+
MSG[EC.START_TIME_INVALID]='の配信開始日時の書式が不正です(例 2026-08-01 10:30)';
|
|
2955
|
+
MSG[EC.END_TIME_INVALID] ='の配信終了日時の書式が不正です(例 2026-08-31 23:59)';
|
|
2956
|
+
MSG[EC.END_BEFORE_START] ='の配信終了日時が配信開始日時より前です';
|
|
2957
|
+
((state&&state.campaigns)||[]).forEach(c=>(c.adGroups||[]).forEach(g=>{
|
|
2958
|
+
const codes=(P.validateRow(g, 'adGroup', {project:p})||{}).errCodes||[];
|
|
2959
|
+
DATE_CODES.forEach(code=>{ if(codes.indexOf(code)>=0)
|
|
2960
|
+
errs.push({code, msg:'広告セット「'+String(g.name||'')+'」'+MSG[code]}); });
|
|
2961
|
+
}));
|
|
2252
2962
|
return errs;
|
|
2253
2963
|
},
|
|
2254
2964
|
|
|
@@ -2273,6 +2983,12 @@ PLATFORMS.meta = {
|
|
|
2273
2983
|
const cat=String((c&&c.specialAdCategory)||'NONE');
|
|
2274
2984
|
const K=P.enums.specialAdCategoryConstraints;
|
|
2275
2985
|
const constrained = K.appliesTo.indexOf(cat)>=0;
|
|
2986
|
+
/* 配信日時の解決順: 広告セット行 → 案件設定チップ → 既定 (開始のみ今日)。
|
|
2987
|
+
'' は「未指定 = 上位へフォールバック」であって「明示的な無期限」ではない。
|
|
2988
|
+
既知の制約: 案件設定チップに終了日時があるとき、特定の広告セットだけ「終了なし」にはできない
|
|
2989
|
+
(無期限は行・チップの両方を空にして達成する)。 */
|
|
2990
|
+
const startSrc = firstNonEmpty(g.startDate, p.startDate);
|
|
2991
|
+
const endSrc = firstNonEmpty(g.endDate, p.endDate);
|
|
2276
2992
|
const ag={
|
|
2277
2993
|
name:g.name,
|
|
2278
2994
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
@@ -2280,7 +2996,7 @@ PLATFORMS.meta = {
|
|
|
2280
2996
|
billingEvent:p.billingEvent||'IMPRESSIONS',
|
|
2281
2997
|
bidStrategy:p.bidStrategy||'LOWEST_COST_WITHOUT_CAP',
|
|
2282
2998
|
dailyBudget:Number(g.dailyBudget)||0, /* JPY はそのまま (currency_offset=1) */
|
|
2283
|
-
startTime:P.mappers.startTime(
|
|
2999
|
+
startTime:P.mappers.startTime(startSrc), /* 常に送る (空なら today())。JST 明示 ISO8601 */
|
|
2284
3000
|
/* 特別カテゴリ時は 18-65 固定・性別指定不可 (公式制約) */
|
|
2285
3001
|
ageMin: constrained?K.forcedAgeMin:(Number(g.ageMin)||18),
|
|
2286
3002
|
/* 年齢上限は空欄 = 上限なし → ageMax キー自体を出さない (age_max 省略 = Meta 側で上限なし)。
|
|
@@ -2298,7 +3014,7 @@ PLATFORMS.meta = {
|
|
|
2298
3014
|
customEventType:p.customEventType||'PURCHASE'
|
|
2299
3015
|
};
|
|
2300
3016
|
/* 終了日は任意。空欄なら endTime キー自体を出さない (= 無期限配信) */
|
|
2301
|
-
const endT=P.mappers.endTime(
|
|
3017
|
+
const endT=P.mappers.endTime(endSrc); if(endT) ag.endTime=endT;
|
|
2302
3018
|
if(P.enums.bidAmountRequiredFor.indexOf(ag.bidStrategy)>=0 || Number(g.bidAmount)>0)
|
|
2303
3019
|
ag.bidAmount=Number(g.bidAmount)||0;
|
|
2304
3020
|
const aid=resolveAdGroupIdOf(g); if(aid) ag.adSetId=aid;
|
|
@@ -2420,7 +3136,18 @@ PLATFORMS.meta = {
|
|
|
2420
3136
|
/* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
|
|
2421
3137
|
function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
|
|
2422
3138
|
/* 列記述子配列。引数省略時は現在のシートタブ */
|
|
2423
|
-
|
|
3139
|
+
/* C8: 列記述子の hidden(project) が true の列は **描画も列index採番もしない**。
|
|
3140
|
+
colIndex / compatColsFor / getVar / setVar / コピペ / 連番 はすべて colDefs を基準に
|
|
3141
|
+
列番号を決めるので、ここで落としておけば列番号のズレが起きない
|
|
3142
|
+
(逆に renderSheetGeneric 側だけで隠すと data-c と setVar の対象がズレる)。 */
|
|
3143
|
+
function colDefs(tab){
|
|
3144
|
+
const S=pf().sheets, list=S[normTab(tab===undefined?sheetTab:tab)] || S.ads;
|
|
3145
|
+
const p=(typeof st!=='undefined' && st) ? st.project : null;
|
|
3146
|
+
return list.filter(c=>!(typeof c.hidden==='function' && c.hidden(p||{})));
|
|
3147
|
+
}
|
|
3148
|
+
/* C8: URLパラメータ OFF の案件では paramCode 列を表に出さない。
|
|
3149
|
+
OFF = 「最終URL は 案件設定の記事URL のみ」なので、paramCode の入力自体が不要になる。 */
|
|
3150
|
+
function urlParamsOff(project){ return !!project && project.urlParams===false; }
|
|
2424
3151
|
/* 列番号リテラル撤去用: field → 列index */
|
|
2425
3152
|
function colIndex(field, tab){ return colDefs(tab).findIndex(c=>c.field===field); }
|
|
2426
3153
|
/* ドロワーで拾った番号を流し込める列index群 (旧 compat=[1] / [2,3] の置換) */
|
|
@@ -2444,30 +3171,91 @@ function rowIsExisting(item, col){
|
|
|
2444
3171
|
if(item.kind==='c') return isExistingCampaign(item.c);
|
|
2445
3172
|
return false;
|
|
2446
3173
|
}
|
|
2447
|
-
|
|
3174
|
+
/* ---- C1: セル値アクセスの get/set フック ----
|
|
3175
|
+
列記述子に col.get(row, ctx) / col.set(row, val, ctx, item) を足すと、
|
|
3176
|
+
「派生値を持つ列」を derived ではなく **通常の入力列** として宣言できる。
|
|
3177
|
+
copySelText は getVar のみ・pasteGrid は setVar のみを使うので、
|
|
3178
|
+
フックを持つ列はそれだけで コピー&ペーストのラウンドトリップ対象になる。
|
|
3179
|
+
ctx は行コンテキスト {tab, item, project, refs, r(解決結果・遅延)}。
|
|
3180
|
+
renderCell は行で1回作った ctx をそのまま渡すので resolve が列数ぶん走らない。 */
|
|
3181
|
+
function cellCtx(item, tab, ctx){
|
|
3182
|
+
if(ctx) return ctx;
|
|
3183
|
+
const o={tab:normTab(tab===undefined?sheetTab:tab), item, project:st.project, refs:st.refs};
|
|
3184
|
+
if(item && item.kind==='a'){
|
|
3185
|
+
Object.defineProperty(o,'r',{configurable:true, get(){ return resolveAd(item.a, item.g); }});
|
|
3186
|
+
}
|
|
3187
|
+
return o;
|
|
3188
|
+
}
|
|
3189
|
+
function getVar(item,c,tab,ctx){
|
|
2448
3190
|
const col=colDefs(tab)[c]; if(!col) return '';
|
|
2449
|
-
const row=rowOf(item,col);
|
|
3191
|
+
const row=rowOf(item,col); if(!row) return '';
|
|
3192
|
+
if(col.get){ const gv=col.get(row, cellCtx(item,tab,ctx)); return gv==null?'':String(gv); }
|
|
3193
|
+
const v=row[col.field];
|
|
2450
3194
|
return v==null?'':String(v);
|
|
2451
3195
|
}
|
|
2452
|
-
function setVar(item,c,val,tab){
|
|
3196
|
+
function setVar(item,c,val,tab,ctx){
|
|
2453
3197
|
const col=colDefs(tab)[c]; if(!col) return;
|
|
2454
3198
|
if(col.kind==='derived') return;
|
|
2455
3199
|
if(col.alwaysReadonly) return; /* adGroupId / campaignId */
|
|
2456
3200
|
if((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col)) return; /* F-2: 既存呼び出し行は基本パラメータ編集不可 */
|
|
2457
3201
|
const row=rowOf(item,col); if(!row) return;
|
|
2458
|
-
|
|
3202
|
+
const v = col.coerce?col.coerce(val):val;
|
|
3203
|
+
if(col.set) col.set(row, v, cellCtx(item,tab,ctx), item);
|
|
3204
|
+
else row[col.field] = v;
|
|
2459
3205
|
if(col.onSet) col.onSet(item,row);
|
|
2460
3206
|
}
|
|
3207
|
+
/* ---- 範囲選択中の1セル編集を、同じ列の選択行すべてへ一括反映する ----
|
|
3208
|
+
select / text / number など「▼ドロワーを持たない列」の一括入力口。
|
|
3209
|
+
同種の列 = 同じ field(=同じ列)に限る。異なる列へ値を流し込むのは意味が壊れるのでしない。
|
|
3210
|
+
編集したセルが選択範囲の外なら 0 を返し、従来どおり1セルだけの編集になる。 */
|
|
3211
|
+
function bulkFillColumn(tab, field, val, editedUid){
|
|
3212
|
+
if(activeView!=='sheet' || planMode!=='sheet') return 0;
|
|
3213
|
+
if(normTab(sheetTab)!==tab) return 0;
|
|
3214
|
+
const rg=selRange(); if(!rg) return 0;
|
|
3215
|
+
const ci=colIndex(field, tab); if(ci<0 || ci<rg.c0 || ci>rg.c1) return 0;
|
|
3216
|
+
const vis=flatVisible();
|
|
3217
|
+
const uidOf=it=>(it.kind==='a'?it.a.uid:(it.kind==='g'?it.g.uid:it.c.uid));
|
|
3218
|
+
let hit=false;
|
|
3219
|
+
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it && uidOf(it)===editedUid){ hit=true; break; } }
|
|
3220
|
+
if(!hit) return 0;
|
|
3221
|
+
let n=0;
|
|
3222
|
+
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||uidOf(it)===editedUid) continue; setVar(it, ci, val, tab); n++; }
|
|
3223
|
+
return n;
|
|
3224
|
+
}
|
|
3225
|
+
function bulkFillToast(tab, field, n){
|
|
3226
|
+
if(!n) return;
|
|
3227
|
+
const col=colDefs(tab).find(c=>c.field===field);
|
|
3228
|
+
toast(((col&&col.label)||field)+' を'+(n+1)+'セルに一括入力');
|
|
3229
|
+
}
|
|
3230
|
+
/* 列 field 指定で1セルを書く (change ハンドラを列記述子経由に載せるための入口)。
|
|
3231
|
+
列が見つからなければ false を返し、呼び出し側の旧経路にフォールバックさせる。 */
|
|
3232
|
+
function setVarByField(item, tab, field, val){
|
|
3233
|
+
const cols=colDefs(tab); const ci=cols.findIndex(c=>c.field===field);
|
|
3234
|
+
if(ci<0) return false;
|
|
3235
|
+
setVar(item, ci, val, tab);
|
|
3236
|
+
return true;
|
|
3237
|
+
}
|
|
2461
3238
|
function flatVisible(){ const out=[]; if(sheetTab==='groups'){ st.campaigns.forEach(c=>c.adGroups.forEach(g=>out.push({kind:'g',g,c}))); return out; } if(sheetTab==='campaigns'){ st.campaigns.forEach(c=>out.push({kind:'c',c})); return out; } const filter=document.getElementById('sheetFilter').value; st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach(a=>{ const r=resolveAd(a,g); const hasErr=r.errs.length>0; if(filter==='err'&&!hasErr)return; out.push({kind:'a',a,g,c}); }))); return out; }
|
|
2462
3239
|
let selA=null, selB=null, dragging=false;
|
|
2463
3240
|
function clearSel(){ selA=selB=null; applySelection(); }
|
|
2464
3241
|
function selRange(){ if(!selA||!selB) return null; return {r0:Math.min(selA.r,selB.r),r1:Math.max(selA.r,selB.r),c0:Math.min(selA.c,selB.c),c1:Math.max(selA.c,selB.c)}; }
|
|
2465
3242
|
function applySelection(){ const host=document.getElementById('sheetHost'); if(!host) return; host.querySelectorAll('.selcell,.selanchor').forEach(el=>el.classList.remove('selcell','selanchor')); host.querySelectorAll('.cellcombo.comboSel').forEach(el=>el.classList.remove('comboSel')); host.querySelectorAll('td.rownum.rowsel').forEach(el=>el.classList.remove('rowsel')); const rg=selRange(); if(!rg){ if(typeof updateSeqBtn==='function') updateSeqBtn(); return; } host.querySelectorAll('[data-sel]').forEach(el=>{ const r=+el.dataset.r, c=+el.dataset.c; if(r>=rg.r0&&r<=rg.r1&&c>=rg.c0&&c<=rg.c1){ el.classList.add('selcell'); const cb=el.closest('.cellcombo'); if(cb) cb.classList.add('comboSel'); if(selA&&r===selA.r&&c===selA.c) el.classList.add('selanchor'); } }); host.querySelectorAll('td.rownum').forEach(el=>{ const r=+el.dataset.rownum; if(r>=rg.r0&&r<=rg.r1) el.classList.add('rowsel'); }); if(typeof updateSeqBtn==='function') updateSeqBtn(); }
|
|
2466
|
-
function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible();
|
|
3243
|
+
function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible();
|
|
3244
|
+
/* C1: 範囲全セルで getVar → get フック列が resolveAd を呼ぶ。読み取り専用パスなのでメモ化する */
|
|
3245
|
+
return withResolvePass(()=>{ const lines=[]; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue; const cells=[]; for(let c=rg.c0;c<=rg.c1;c++) cells.push(getVar(it,c)); lines.push(cells.join('\t')); } return lines.join('\n'); }); }
|
|
2467
3246
|
function pasteGrid(txt){
|
|
2468
3247
|
const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
|
|
2469
3248
|
const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
|
|
2470
3249
|
const vis=flatVisible(); const maxc=tabCols().length-1; if(!vis[rg.r0]){ toast('貼付先がありません'); return; }
|
|
3250
|
+
/* 1セルのコピーを範囲へ貼ったら選択範囲全体へ充填する (スプシ同等のタイル展開)。
|
|
3251
|
+
行を増やさないので既存行の同種セルを一括で埋める用途に限る。 */
|
|
3252
|
+
if(rows.length===1 && rows[0].length===1 && (rg.r1>rg.r0 || rg.c1>rg.c0)){
|
|
3253
|
+
const val=rows[0][0]; let n=0;
|
|
3254
|
+
mutate(()=>{ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue;
|
|
3255
|
+
for(let c=rg.c0;c<=Math.min(rg.c1,maxc);c++){ setVar(it,c,val); n++; } } });
|
|
3256
|
+
clearSel(); toast(n+'セルに貼り付け');
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
2471
3259
|
mutate(()=>{
|
|
2472
3260
|
if(sheetTab==='ads'){
|
|
2473
3261
|
// B8: 可視行基準に統一 (グループ境界・絞り込み表示を尊重)。不足分は末尾の可視グループに新規広告を追加。
|
|
@@ -2486,7 +3274,7 @@ function pasteGrid(txt){
|
|
|
2486
3274
|
function dupSelected(){
|
|
2487
3275
|
const rg=selRange(); if(!rg){ toast('複製する行を選択してください'); return; } const vis=flatVisible(); const items=[]; for(let r=rg.r0;r<=rg.r1;r++){ if(vis[r]) items.push(vis[r]); } if(!items.length){ toast('対象なし'); return; }
|
|
2488
3276
|
mutate(()=>{ items.forEach(it=>{
|
|
2489
|
-
if(it.kind==='g'){ const ng=JSON.parse(JSON.stringify(it.g)); ng.uid=uid('g'); ng.
|
|
3277
|
+
if(it.kind==='g'){ const ng=JSON.parse(JSON.stringify(it.g)); ng.uid=uid('g'); ng.adGroupId=''; ng.isExisting=false; ng.name=dupGroupName(it.g.name); ng.paramCode=nextParamCode(it.g.paramCode); ng.ads.forEach(a=>a.uid=uid('a')); const idx=it.c.adGroups.indexOf(it.g); it.c.adGroups.splice(idx+1,0,ng); autoResolveGroupIds(ng, it.c); }
|
|
2490
3278
|
else if(it.kind==='c'){ const nc=cloneCampaign(it.c,false,false); const idx=st.campaigns.indexOf(it.c); st.campaigns.splice(idx+1,0,nc); }
|
|
2491
3279
|
else { const idx=it.g.ads.indexOf(it.a); it.g.ads.splice(idx+1,0,cloneAd(it.a,null)); }
|
|
2492
3280
|
}); }); clearSel(); toast(items.length+'件を複製');
|
|
@@ -2653,11 +3441,45 @@ function openTargetingDrawer(gUid, field){
|
|
|
2653
3441
|
renderDrawer();
|
|
2654
3442
|
document.getElementById('refDrawer').classList.add('open');
|
|
2655
3443
|
}
|
|
3444
|
+
/* ---- ターゲティング列の一括トグル ----
|
|
3445
|
+
ドロワーで押した項目を「選択範囲 ∩ 同種の列」の全セルへ ON/OFF 一括適用する。
|
|
3446
|
+
同種の判定は列記述子から引く (refpick は同じ refTable / tgpick は同じ tgKey)。
|
|
3447
|
+
選択が無い・対象列に掛かっていない場合はアンカーの1セルだけを返し、従来の単体トグルと同じ挙動になる。 */
|
|
3448
|
+
function tgBulkTargets(gUid, field){
|
|
3449
|
+
const tab=normTab(sheetTab), cols=colDefs(tab);
|
|
3450
|
+
const ci=cols.findIndex(c=>c.field===field), base=cols[ci];
|
|
3451
|
+
const fg=findGroup(gUid);
|
|
3452
|
+
const anchor=(fg&&ci>=0)?[{item:{kind:'g', g:fg.g, c:fg.c}, ci}]:[];
|
|
3453
|
+
if(!base) return anchor;
|
|
3454
|
+
const compat=[]; cols.forEach((c,i)=>{
|
|
3455
|
+
if(base.kind==='refpick'){ if(c.kind==='refpick' && c.refTable===base.refTable) compat.push(i); }
|
|
3456
|
+
else if(c.kind==='tgpick' && c.tgKey===base.tgKey) compat.push(i);
|
|
3457
|
+
});
|
|
3458
|
+
const rg=selRange(); if(!rg) return anchor;
|
|
3459
|
+
const vis=flatVisible(), out=[];
|
|
3460
|
+
for(let r=rg.r0;r<=rg.r1;r++){
|
|
3461
|
+
const it=vis[r]; if(!it||it.kind!=='g') continue;
|
|
3462
|
+
for(let c=rg.c0;c<=rg.c1;c++){
|
|
3463
|
+
if(compat.indexOf(c)<0) continue;
|
|
3464
|
+
if(rowIsExisting(it, cols[c])) continue; /* F-2: 既存呼び出しAGは編集不可 */
|
|
3465
|
+
out.push({item:it, ci:c});
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
return out.length?out:anchor;
|
|
3469
|
+
}
|
|
3470
|
+
function tgListOf(t){ return String(getVar(t.item, t.ci)||'').split(',').map(s=>s.trim()).filter(Boolean); }
|
|
2656
3471
|
function drawerTgToggle(val){
|
|
2657
|
-
if(!drawerTarget||drawerTarget.type!=='tg') return;
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
3472
|
+
if(!drawerTarget||drawerTarget.type!=='tg') return;
|
|
3473
|
+
const targets=tgBulkTargets(drawerTarget.gUid, drawerTarget.field);
|
|
3474
|
+
if(!targets.length) return;
|
|
3475
|
+
/* 全セルに入っていれば一括解除、そうでなければ一括追加 (ON/OFF 一括トグル) */
|
|
3476
|
+
const allOn=targets.every(t=>tgListOf(t).indexOf(val)>=0);
|
|
3477
|
+
mutate(()=>{ targets.forEach(t=>{
|
|
3478
|
+
const cur=tgListOf(t), i=cur.indexOf(val);
|
|
3479
|
+
if(allOn){ if(i>=0) cur.splice(i,1); } else if(i<0) cur.push(val);
|
|
3480
|
+
setVar(t.item, t.ci, cur.join(', '));
|
|
3481
|
+
}); });
|
|
3482
|
+
if(targets.length>1) toast((allOn?'解除':'追加')+':'+targets.length+'セルに一括適用');
|
|
2661
3483
|
renderDrawer();
|
|
2662
3484
|
}
|
|
2663
3485
|
function closeDrawer(){ document.getElementById('refDrawer').classList.remove('open'); drawerTarget=null; }
|
|
@@ -2702,11 +3524,34 @@ function renderDrawer(){
|
|
|
2702
3524
|
listEl.innerHTML=h||'<div class="empty" style="padding:24px">広告グループがありません</div>'; return;
|
|
2703
3525
|
}
|
|
2704
3526
|
if(drawerTarget.type==='tg'){
|
|
2705
|
-
const
|
|
2706
|
-
const
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
3527
|
+
const gcol=colDefs('adGroups').find(c=>c.field===drawerTarget.field);
|
|
3528
|
+
const isRef=!!(gcol && gcol.kind==='refpick');
|
|
3529
|
+
const cfg=isRef?null:pf().targeting[drawerTarget.field];
|
|
3530
|
+
const label=(cfg&&cfg.label)||(gcol&&gcol.label)||'';
|
|
3531
|
+
const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
|
|
3532
|
+
/* 一括対象セル全体で ✓ を判定する: 全セルにあれば ✓ / 一部だけなら −(押すと全セルへ揃える) */
|
|
3533
|
+
const targets=tgBulkTargets(drawerTarget.gUid, drawerTarget.field);
|
|
3534
|
+
const lists=targets.map(tgListOf), N=targets.length;
|
|
3535
|
+
const cur=lists[0]||[];
|
|
3536
|
+
const mark=v=>{ const n=lists.reduce((a,l)=>a+(l.indexOf(v)>=0?1:0),0);
|
|
3537
|
+
return n===0?{cls:'', ic:''}:(n===N?{cls:' cur', ic:'✓'}:{cls:' cur', ic:'−'}); };
|
|
3538
|
+
titleEl.textContent=label+' を選択(複数可)'+(N>1?(' / '+N+'セル一括'):'');
|
|
3539
|
+
tgtEl.textContent = N>1
|
|
3540
|
+
? ('入力先: 選択中の '+N+' セル / '+label+'(押した項目を全セルにON/OFF)')
|
|
3541
|
+
: ('入力先: '+(g&&g.name?g.name:agLabel())+' / '+label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)'));
|
|
3542
|
+
let h='';
|
|
3543
|
+
if(isRef){
|
|
3544
|
+
/* C1: 選択肢は st.refs[col.refTable] (ID→name)。data-tgval には **実ID** を入れる */
|
|
3545
|
+
const store=refPickStore(gcol);
|
|
3546
|
+
Object.keys(store).forEach(id=>{
|
|
3547
|
+
const nm=refPickName(gcol,id);
|
|
3548
|
+
if(q && !String(id).includes(q) && !nm.toLowerCase().includes(q)) return;
|
|
3549
|
+
const mk=mark(String(id));
|
|
3550
|
+
h+='<div class="ddrow'+mk.cls+'" data-tgval="'+esc(id)+'"><span class="ddno">'+mk.ic+'</span><span class="ddtx">'+esc(nm||id)+' <span class="sm mono">'+esc(id)+'</span></span></div>';
|
|
3551
|
+
});
|
|
3552
|
+
listEl.innerHTML=h||'<div class="empty" style="padding:24px">参照データがありません(アカウントJSONの refs.'+esc(gcol.refTable)+')</div>'; return;
|
|
3553
|
+
}
|
|
3554
|
+
cfg.opts.forEach(o=>{ if(q&&!o.toLowerCase().includes(q)) return; const mk=mark(o); h+='<div class="ddrow'+mk.cls+'" data-tgval="'+esc(o)+'"><span class="ddno">'+mk.ic+'</span><span class="ddtx">'+esc(o)+'</span></div>'; });
|
|
2710
3555
|
listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
|
|
2711
3556
|
}
|
|
2712
3557
|
/* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
|
|
@@ -2751,16 +3596,74 @@ function setField(u,f,val){
|
|
|
2751
3596
|
if(f==='paramCode'){ if(isExistingGroup(found.g)) return; /* F-2: 既存呼び出しAGのparamCodeは編集不可 */ found.g.paramCode=val; autoResolveGroupIds(found.g,found.c); } // group-level
|
|
2752
3597
|
else { found.a[f]=val; }
|
|
2753
3598
|
}
|
|
3599
|
+
/* C1: シートのセル編集は **列記述子経由 (setVar)** に一本化する。
|
|
3600
|
+
こうしないと coerce / set フック (refpick の ID 絞り込み・記事URL正規化・refs 双方向編集) が
|
|
3601
|
+
「貼付では効くのに手入力では効かない」という不整合になる。
|
|
3602
|
+
列が見つからない場合だけ旧経路 (setField / 直代入) にフォールバックする。 */
|
|
2754
3603
|
document.addEventListener('change', e=>{
|
|
2755
3604
|
const t=e.target; if(!t.dataset||!t.dataset.u||!t.dataset.f) return;
|
|
2756
3605
|
const f=t.dataset.f; const val = t.type==='checkbox'? t.checked : t.value;
|
|
2757
|
-
|
|
3606
|
+
let n=0;
|
|
3607
|
+
mutate(()=>{
|
|
3608
|
+
const found=findAd(t.dataset.u); if(!found) return;
|
|
3609
|
+
if(!setVarByField({kind:'a', a:found.a, g:found.g, c:found.c}, 'ads', f, val)) setField(t.dataset.u,f,val);
|
|
3610
|
+
n=bulkFillColumn('ads', f, val, t.dataset.u); /* 範囲選択中なら同じ列の選択行すべてへ */
|
|
3611
|
+
});
|
|
3612
|
+
bulkFillToast('ads', f, n);
|
|
2758
3613
|
});
|
|
2759
3614
|
/* 広告グループ/キャンペーン タブの一覧編集 */
|
|
2760
3615
|
document.addEventListener('change', e=>{
|
|
2761
3616
|
const t=e.target; if(!t.dataset) return;
|
|
2762
|
-
if(t.dataset.
|
|
2763
|
-
if(t.dataset.
|
|
3617
|
+
if(t.dataset.dtpart) return; /* S15: 日時セルは下の専用ハンドラが日付+時刻をまとめて確定する */
|
|
3618
|
+
if(t.dataset.gu && t.dataset.gf){ const gf=t.dataset.gf, val=t.value; let n=0; mutate(()=>{ const r=findGroup(t.dataset.gu); if(!r) return;
|
|
3619
|
+
if(!setVarByField({kind:'g', g:r.g, c:r.c}, 'adGroups', gf, val)){ if(!isExistingGroup(r.g)){ r.g[gf]=val; if(gf==='paramCode') autoResolveGroupIds(r.g,r.c); } }
|
|
3620
|
+
n=bulkFillColumn('adGroups', gf, val, t.dataset.gu); }); bulkFillToast('adGroups', gf, n); return; } // F-2: 既存呼び出しAGは基本パラメータ編集不可 (setVar 側の readonlyOnExisting ガードが担保)
|
|
3621
|
+
if(t.dataset.cu && t.dataset.cf){ const cf=t.dataset.cf; const val=t.value; let n=0; mutate(()=>{ const r=findCp(t.dataset.cu); if(!r) return;
|
|
3622
|
+
if(!setVarByField({kind:'c', c:r.c}, 'campaigns', cf, val)){ if(!isExistingCampaign(r.c)) r.c[cf]=(cf==='budget'||cf==='tcpa')?numOrNull(val):val; }
|
|
3623
|
+
n=bulkFillColumn('campaigns', cf, val, t.dataset.cu); }); bulkFillToast('campaigns', cf, n); return; } // F-2: 既存呼び出しCPは基本パラメータ編集不可
|
|
3624
|
+
});
|
|
3625
|
+
|
|
3626
|
+
/* S15: 日時セル(kind:'datetime')のセル内コントロール。
|
|
3627
|
+
1セルに日付+時刻の2入力があり、汎用ハンドラに素通しすると時刻が落ちた値で上書きされるため
|
|
3628
|
+
ここで両方を読んで1本の文字列に畳み、setVar (= 列記述子の coerce/set) 経由で書き込む。
|
|
3629
|
+
範囲選択中は同じ列の選択行すべてへ配る (他の列と同じ一括入力の挙動)。 */
|
|
3630
|
+
document.addEventListener('change', e=>{
|
|
3631
|
+
const t=e.target; if(!t.dataset||!t.dataset.dtpart||!t.dataset.dtf) return;
|
|
3632
|
+
const field=t.dataset.dtf, box=t.closest('.dtcell'); if(!box) return;
|
|
3633
|
+
const d=box.querySelector('[data-dtpart="date"]'), tm=box.querySelector('[data-dtpart="time"]');
|
|
3634
|
+
const val=joinDateTime(d&&d.value, tm&&tm.value);
|
|
3635
|
+
const tab = t.dataset.gu ? 'adGroups' : (t.dataset.cu ? 'campaigns' : null);
|
|
3636
|
+
if(!tab) return;
|
|
3637
|
+
let n=0;
|
|
3638
|
+
mutate(()=>{
|
|
3639
|
+
if(tab==='adGroups'){ const r=findGroup(t.dataset.gu); if(!r) return;
|
|
3640
|
+
setVarByField({kind:'g', g:r.g, c:r.c}, 'adGroups', field, val);
|
|
3641
|
+
n=bulkFillColumn('adGroups', field, val, t.dataset.gu); }
|
|
3642
|
+
else { const r=findCp(t.dataset.cu); if(!r) return;
|
|
3643
|
+
setVarByField({kind:'c', c:r.c}, 'campaigns', field, val);
|
|
3644
|
+
n=bulkFillColumn('campaigns', field, val, t.dataset.cu); }
|
|
3645
|
+
});
|
|
3646
|
+
bulkFillToast(tab, field, n);
|
|
3647
|
+
});
|
|
3648
|
+
|
|
3649
|
+
/* S14: フリークエンシー(kind:'freq')のセル内コントロール。
|
|
3650
|
+
1セルに4フィールドあるため data-cf を持たず (=上の汎用ハンドラは素通しする)、ここで拾う。
|
|
3651
|
+
既存呼び出しCPは列が hideOnExisting なのでそもそも描画されない。 */
|
|
3652
|
+
document.addEventListener('change', e=>{
|
|
3653
|
+
const t=e.target; if(!t.dataset||!t.dataset.cu||!t.dataset.freqf) return;
|
|
3654
|
+
const f=t.dataset.freqf;
|
|
3655
|
+
const val = t.type==='checkbox' ? t.checked : (f==='freqCount' ? freqNum(t.value) : t.value);
|
|
3656
|
+
let n=0;
|
|
3657
|
+
mutate(()=>{
|
|
3658
|
+
const r=findCp(t.dataset.cu); if(!r || isExistingCampaign(r.c)) return;
|
|
3659
|
+
if(f==='freqCount' && !(val>0)) return; /* 0/空/不正は無視 (既存の値をそのまま残す) */
|
|
3660
|
+
if(f==='freqMode'){ r.c.freqMode=val; r.c.freqOn=(val==='on'); }
|
|
3661
|
+
else r.c[f]=val;
|
|
3662
|
+
if(freqMode(r.c)==='on') freqEnsure(r.c); /* 旧プラン行は freq* を持たないのでここで埋める */
|
|
3663
|
+
/* 範囲選択中は同じ列 (freqCap) の選択行すべてへ。畳んだ文字列を setVar 経由で配る */
|
|
3664
|
+
n=bulkFillColumn('campaigns', 'freqCap', freqCellText(r.c), t.dataset.cu);
|
|
3665
|
+
});
|
|
3666
|
+
bulkFillToast('campaigns', 'freqCap', n);
|
|
2764
3667
|
});
|
|
2765
3668
|
|
|
2766
3669
|
/* rename via contenteditable (tree) */
|
|
@@ -2812,12 +3715,51 @@ function addNAds(u){
|
|
|
2812
3715
|
const r=findGroup(u); if(!r) return;
|
|
2813
3716
|
openModal({title:'N本まとめて追加', sub:'空の広告を指定本数追加します(1:1:N 量産)', fields:[{key:'n',label:'本数',type:'number',value:10}], onOk:v=>{ const raw=parseInt(v.n); if(!Number.isFinite(raw)||raw<1){ toast('本数は1以上を入力してください'); return; } const n=Math.min(50,raw); mutate(()=>{ for(let i=0;i<n;i++) r.g.ads.push(newAd()); }); toast(n+'本追加しました'); }});
|
|
2814
3717
|
}
|
|
3718
|
+
/* プラン内の全広告グループ名。複製時の同名回避に使う。 */
|
|
3719
|
+
function allGroupNames(){
|
|
3720
|
+
const s=new Set(); st.campaigns.forEach(c=>c.adGroups.forEach(g=>s.add(String(g.name||''))));
|
|
3721
|
+
return s;
|
|
3722
|
+
}
|
|
3723
|
+
/* 複製した広告グループの名前。名前は引き継ぐが、同名が既にあるときだけ「連番の部分」をずらす。
|
|
3724
|
+
・末尾に数字がある名前 (AG1 / 広告グループ_03) → その数字を +1 しながら空き番号を探す (ゼロ埋め幅は維持)
|
|
3725
|
+
・末尾に数字が無い名前 → 末尾に 2,3,… を足して空きを探す
|
|
3726
|
+
旧実装は無条件に '_copy' を付けていたため、複製を重ねると AG1_copy_copy になっていた。 */
|
|
3727
|
+
function dupGroupName(srcName){
|
|
3728
|
+
const s=String(srcName||'').trim(); if(!s) return '';
|
|
3729
|
+
const used=allGroupNames(); if(!used.has(s)) return s;
|
|
3730
|
+
const m=s.match(/^(.*?)(\d+)$/);
|
|
3731
|
+
const prefix = m ? m[1] : s;
|
|
3732
|
+
const width = m ? m[2].length : 0;
|
|
3733
|
+
let n = m ? parseInt(m[2],10)+1 : 2;
|
|
3734
|
+
for(let i=0;i<1000;i++,n++){
|
|
3735
|
+
const cand = prefix + (width ? String(n).padStart(width,'0') : String(n));
|
|
3736
|
+
if(!used.has(cand)) return cand;
|
|
3737
|
+
}
|
|
3738
|
+
return s;
|
|
3739
|
+
}
|
|
3740
|
+
/* 広告グループの複製はモーダルを介さず即時実行する (中身そのまま複製)。
|
|
3741
|
+
・名前は引き継ぎ、同名のときだけ連番をずらす (dupGroupName)
|
|
3742
|
+
・paramCode は dupCampaign と同じ規則で連番自動採番し、ID を解決し直す
|
|
3743
|
+
(コピーのままだと同じ広告グループを指してしまうため)
|
|
3744
|
+
・adGroupId はクリアして新規扱いにする */
|
|
2815
3745
|
function dupGroup(u){
|
|
2816
3746
|
const r=findGroup(u); if(!r) return;
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
3747
|
+
mutate(()=>{
|
|
3748
|
+
const ng=JSON.parse(JSON.stringify(r.g));
|
|
3749
|
+
ng.uid=uid('g'); ng.adGroupId=''; ng.isExisting=false;
|
|
3750
|
+
ng.name=dupGroupName(r.g.name);
|
|
3751
|
+
ng.paramCode=nextParamCode(r.g.paramCode);
|
|
3752
|
+
ng.ads=r.g.ads.map(a=>cloneAd(a, null));
|
|
3753
|
+
r.c.adGroups.splice(r.i+1,0,ng);
|
|
3754
|
+
autoResolveGroupIds(ng, r.c);
|
|
3755
|
+
});
|
|
3756
|
+
toast('複製しました');
|
|
3757
|
+
}
|
|
3758
|
+
/* paramCode の末尾数字を「プラン内 + 参照表の最大 +1」に振り直す。数字が無い / 空なら元の値のまま。 */
|
|
3759
|
+
function nextParamCode(code){
|
|
3760
|
+
const m=String(code||'').match(/^(.*?)(\d+)\s*$/); if(!m) return code||'';
|
|
3761
|
+
const prefix=m[1], width=m[2].length;
|
|
3762
|
+
return prefix + String(maxParamNum(prefix)+1).padStart(width,'0');
|
|
2821
3763
|
}
|
|
2822
3764
|
function editParam(u){
|
|
2823
3765
|
const r=findGroup(u); if(!r) return; const g=r.g;
|
|
@@ -2843,14 +3785,13 @@ function editCampaign(u){
|
|
|
2843
3785
|
// E-2: モード/campaignID/戦略メモ フィールドは撤去 (新規/既存はID解決で自動判定)
|
|
2844
3786
|
openModal({title:'キャンペーン設定', sub:c.name, fields:[
|
|
2845
3787
|
{key:'name',label:'キャンペーン名',type:'text',value:c.name},
|
|
2846
|
-
{key:'variable',label:'検証する変数(1キャンペーン=1変数)',type:'text',value:c.variable},
|
|
2847
3788
|
{key:'urlNumber',label:'入稿リンク末尾番号(空き: '+suggestUrlNumber()+')',type:'text',value:c.urlNumber},
|
|
2848
3789
|
{key:'budget',label:'予算/日(空で既定 '+pf().defaults.newCampaignBudget+')',type:'number',value:c.budget??''},
|
|
2849
3790
|
{key:'biddingStrategy',label:'配信戦略',type:'select',value:c.biddingStrategy||'tCPA',options:[{v:'tCPA',t:'tCPA'},{v:'最大配信',t:'最大配信'}]},
|
|
2850
3791
|
{key:'tcpa',label:'tCPA(円)',type:'number',value:c.tcpa??''}
|
|
2851
3792
|
], extra:'<button class="act" id="genName" style="margin-right:auto">キャンペーン名を自動生成</button>', onMount:(root,getVals,setVal)=>{
|
|
2852
3793
|
root.querySelector('#genName').addEventListener('click',()=>{ const num=getVals().urlNumber||suggestUrlNumber(); const first=c.adGroups[0]&&c.adGroups[0].ads[0]; setVal('name', pf().naming.campaignName(num, first, pf())); });
|
|
2853
|
-
}, onOk:v=>{ mutate(()=>{ c.name=v.name; c.
|
|
3794
|
+
}, onOk:v=>{ mutate(()=>{ c.name=v.name; c.urlNumber=v.urlNumber; c.budget=numOrNull(v.budget); c.biddingStrategy=v.biddingStrategy; c.tcpa=numOrNull(v.tcpa); }); }});
|
|
2854
3795
|
// B6解消: 配信戦略/tCPAはキャンペーン個別値のみを参照 (project既定を焼き付けない)
|
|
2855
3796
|
}
|
|
2856
3797
|
function cloneCampaign(c, clearCreative, clearParam){
|
|
@@ -2866,10 +3807,19 @@ function maxParamNum(prefix){
|
|
|
2866
3807
|
Object.keys(st.refs.groups||{}).forEach(scan);
|
|
2867
3808
|
return mx;
|
|
2868
3809
|
}
|
|
2869
|
-
/*
|
|
3810
|
+
/* 複製元のキャンペーン名を引き継ぐ。先頭が「{2桁以上の番号}_」形式のときだけ新しい urlNumber に差し替える。
|
|
3811
|
+
名前が空のときだけ従来どおり naming.campaignName で自動生成する。
|
|
3812
|
+
(旧実装は常に自動生成で上書きしていたため、手入力したキャンペーン名が複製で消えていた) */
|
|
3813
|
+
function dupCampaignName(srcName, num, firstAd){
|
|
3814
|
+
const s=String(srcName||'').trim();
|
|
3815
|
+
if(!s) return pf().naming.campaignName(num, firstAd, pf());
|
|
3816
|
+
const m=s.match(/^(\d{2,})(_.*)$/);
|
|
3817
|
+
return m ? num+m[2] : s;
|
|
3818
|
+
}
|
|
3819
|
+
/* E(スクショFB): 複製/量産は「中身そのまま複製 + paramCode連番自動採番 + campaignId/adGroupIdクリア(新規扱い) + キャンペーン名は引き継ぎ(先頭番号のみ差し替え)」で固定。モーダルは本数入力のみ */
|
|
2870
3820
|
function dupCampaign(u){
|
|
2871
3821
|
const r=findCp(u); if(!r) return; const c=r.c;
|
|
2872
|
-
openModal({title:'キャンペーンを複製 / 量産', sub:'中身そのまま複製し、paramCodeは連番で自動採番します(campaignID/広告グループID
|
|
3822
|
+
openModal({title:'キャンペーンを複製 / 量産', sub:'中身そのまま複製し、paramCodeは連番で自動採番します(campaignID/広告グループIDはクリアして新規扱い・キャンペーン名は引き継ぎ、先頭の番号のみ新しい番号に差し替え)。'+c.name, fields:[
|
|
2873
3823
|
{key:'count',label:'複製する本数',type:'number',value:1}
|
|
2874
3824
|
], onOk:v=>{ const raw=parseInt(v.count); if(!Number.isFinite(raw)||raw<1){ toast('本数は1以上を入力してください'); return; } const n=Math.min(50,raw);
|
|
2875
3825
|
mutate(()=>{
|
|
@@ -2878,8 +3828,8 @@ function dupCampaign(u){
|
|
|
2878
3828
|
const nc=cloneCampaign(c, false, false); // 中身そのまま (creative/paramCode保持) + campaignId/adGroupIdクリア
|
|
2879
3829
|
// paramCode 連番自動採番 (prefixごとに現状の最大+1から連番)
|
|
2880
3830
|
nc.adGroups.forEach(g=>{ const m=String(g.paramCode||'').match(/^(.*?)(\d+)\s*$/); if(m){ const prefix=m[1], width=m[2].length; if(nextByPrefix[prefix]==null) nextByPrefix[prefix]=maxParamNum(prefix)+1; g.paramCode=prefix+String(nextByPrefix[prefix]++).padStart(width,'0'); autoResolveGroupIds(g,nc); } });
|
|
2881
|
-
//
|
|
2882
|
-
const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0]; nc.name=
|
|
3831
|
+
// キャンペーン名は複製元を引き継ぐ (先頭の番号のみ新しい urlNumber に差し替え / 空なら自動生成)
|
|
3832
|
+
const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0]; nc.name=dupCampaignName(c.name, num, f);
|
|
2883
3833
|
st.campaigns.splice(base+1+i,0,nc);
|
|
2884
3834
|
}
|
|
2885
3835
|
});
|
|
@@ -3004,7 +3954,11 @@ async function writePlanFile(name, text){
|
|
|
3004
3954
|
File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
|
|
3005
3955
|
IndexedDB キー 'accountsDir:<platform>' に第2ハンドルを保存 (入稿計画/ の 'dir' とは別枠)。
|
|
3006
3956
|
============================================================ */
|
|
3007
|
-
|
|
3957
|
+
/* C1: アカウントJSON は v2 (mccId / urlParamsEnabled / refs.audiences,placements を追加) まで受ける。
|
|
3958
|
+
**バージョンで分岐しない**: 追加キーはすべて optional なので、読み手は存在チェックだけで読む。
|
|
3959
|
+
v1 のまま置かれたファイルもそのまま動く。 */
|
|
3960
|
+
const ACC_JSON_VERSION=2;
|
|
3961
|
+
const ACC_JSON_VERSIONS=[1,2];
|
|
3008
3962
|
let accDirHandle=null, pendingAccDirHandle=null;
|
|
3009
3963
|
let accountFiles=[]; // フォルダ内の <名前>.json のベース名一覧 (File System Access接続時)
|
|
3010
3964
|
let httpAccountFiles=[]; // <account.dir>/index.json から取得した <名前> 一覧 (読み取り専用切替)
|
|
@@ -3133,7 +4087,7 @@ async function writeAccountJson(name, obj){
|
|
|
3133
4087
|
}
|
|
3134
4088
|
/* 参照データ差し替えのシーム: フォルダ読込・新規作成・E2Eテストが共通で使う */
|
|
3135
4089
|
window.applyAccountJson=function(obj){
|
|
3136
|
-
if(!obj||obj.accountJsonVersion
|
|
4090
|
+
if(!obj||ACC_JSON_VERSIONS.indexOf(Number(obj.accountJsonVersion))<0||!obj.refs) throw new Error('アカウントJSON形式が不正です(accountJsonVersion: '+ACC_JSON_VERSIONS.join(' または ')+')');
|
|
3137
4091
|
/* S9: platform 検証。未記載は 'yda' (旧アカウントJSONの後方互換)。別媒体なら媒体ごと切り替える。 */
|
|
3138
4092
|
const declared=String(obj.platform||'yda');
|
|
3139
4093
|
if(declared!==curPf){
|
|
@@ -3151,6 +4105,7 @@ window.applyAccountJson=function(obj){
|
|
|
3151
4105
|
&& samplePlan.campaigns.length && String(samplePlan.accountId||'')===String(st.project.accountId||'')){
|
|
3152
4106
|
st.campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
|
|
3153
4107
|
st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
|
|
4108
|
+
migrateArticleSlug(st.campaigns); // C8: 旧 articleSlug / articleUrl を除去
|
|
3154
4109
|
planMismatchMsg='';
|
|
3155
4110
|
if(typeof renderPlanWarn==='function') renderPlanWarn();
|
|
3156
4111
|
}
|
|
@@ -3196,7 +4151,12 @@ async function selectAccount(name, pfId){
|
|
|
3196
4151
|
async function saveActiveAccountJson(){
|
|
3197
4152
|
if(!accDirHandle) return; const name=st.project.accountName; if(!name) return;
|
|
3198
4153
|
if(!accountFiles.includes(name)) return; // 同梱デフォルト等フォルダ外は書き戻さない
|
|
3199
|
-
|
|
4154
|
+
/* ⚠ writeAccountJson は JSON.stringify のフル上書き。ここで組み立てたキーしか残らないため、
|
|
4155
|
+
**必ず現ファイルを読んでマージする**。そうしないと、参照表を1件編集しただけで
|
|
4156
|
+
GUI が知らないキー (mccId / urlParamsEnabled / adNamePolicy / platform / projectName /
|
|
4157
|
+
baseArticleUrl …) がファイルから消え、次回読込で管理画面URLやURLパラメータが化ける。 */
|
|
4158
|
+
const prev=await readAccountJson(name);
|
|
4159
|
+
const obj=Object.assign({}, prev||{}, {accountJsonVersion:ACC_JSON_VERSION, accountId:String(st.project.accountId||''), name, principal:String(st.project.principal||''), updatedAt:new Date().toISOString(), refs:normalizeRefs(st.refs)});
|
|
3200
4160
|
await writeAccountJson(name, obj);
|
|
3201
4161
|
}
|
|
3202
4162
|
/* F-3: 「+新規追加」チューザー — ファイルから追加 / AIで追加 の2択 */
|
|
@@ -3398,7 +4358,9 @@ function tgList(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Bool
|
|
|
3398
4358
|
警告: pf().notSubmittedFields に値があるがJSON未反映の広告グループ数 (tgDropped)。 */
|
|
3399
4359
|
function buildSubmitPlanWithStats(){
|
|
3400
4360
|
const P=pf(), p=st.project;
|
|
3401
|
-
|
|
4361
|
+
/* droppedRefIds: 参照ID列 (refpick) に入っていた **数値IDでない値** を落とした件数。
|
|
4362
|
+
旧・日本語ラベルのまま保存された古いプランを黙って通さないための後方互換カウンタ。 */
|
|
4363
|
+
const stats={exported:0, unresolved:0, pendingImages:0, pendingImageNos:[], emptyGroups:0, emptyCampaigns:0, tgDropped:0, droppedRefIds:0};
|
|
3402
4364
|
/* 行レベル検証を持つ媒体 (Meta の 日予算/入札上限 必須) だけ rowErrors を集計する */
|
|
3403
4365
|
if(P.validateRow) stats.rowErrors=0;
|
|
3404
4366
|
const ctx=Object.assign(planCtx(), {stats});
|
|
@@ -3424,7 +4386,7 @@ function buildSubmitPlanWithStats(){
|
|
|
3424
4386
|
});
|
|
3425
4387
|
ag[GK]=kept;
|
|
3426
4388
|
stats.exported+=kept.length;
|
|
3427
|
-
// 未反映列 (YDA
|
|
4389
|
+
// 未反映列 (YDAは地域/サーチKW) はAPIのID解決が必要 → 出力ありのグループのみ警告計上
|
|
3428
4390
|
if(kept.length && notSub.some(f=>String(g[f]||'').trim()!=='')) stats.tgDropped++;
|
|
3429
4391
|
return ag;
|
|
3430
4392
|
}).filter(ag=>{ if(!ag) return false; if(ag[GK].length===0){ stats.emptyGroups++; return false; } return true; }); // B9: 空広告グループを除外 (null = 行検証NG)
|
|
@@ -3500,7 +4462,12 @@ function openHelpModal(){
|
|
|
3500
4462
|
/* 「サンプルプラン更新」: 現在のプラン(共通設定+キャンペーン)を サンプルプラン.json に書き戻す。
|
|
3501
4463
|
初回だけ File System Access でファイルを選び、以後は IndexedDB 保持ハンドルで Finder無し更新。 */
|
|
3502
4464
|
async function updateSamplePlan(){
|
|
3503
|
-
|
|
4465
|
+
/* S13: 書き出しスキーマは同梱の サンプルプラン*.json と同形 {schemaVersion, platform, accountId, campaigns} に揃える。
|
|
4466
|
+
・accountId が無いと initialState() のアカウント不一致ガードが素通りし、別アカウントのプランが
|
|
4467
|
+
無警告で読み込まれる(誤入稿に直結)ので必ず出す
|
|
4468
|
+
・project は読み戻し側が存在しない(案件設定はアカウントJSON由来で defaultProject() が作る)。
|
|
4469
|
+
書いても次回起動で必ず捨てられるため出さない = 「保存したのに戻る」の誤解を断つ */
|
|
4470
|
+
const data={schemaVersion:SCHEMA_VERSION, platform:curPf, accountId:st.project.accountId||'', campaigns:st.campaigns};
|
|
3504
4471
|
const json=JSON.stringify(data,null,1);
|
|
3505
4472
|
if(!samplePlanFileHandle){ try{ const h=await idbGet(idbKeySamplePlan()); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted') samplePlanFileHandle=h; else samplePlanFileHandle=h; } }catch(e){} }
|
|
3506
4473
|
if(samplePlanFileHandle){ try{ let perm=await samplePlanFileHandle.queryPermission({mode:'readwrite'}); if(perm!=='granted') perm=await samplePlanFileHandle.requestPermission({mode:'readwrite'}); if(perm!=='granted') samplePlanFileHandle=null; }catch(e){ samplePlanFileHandle=null; } }
|
|
@@ -3529,7 +4496,7 @@ async function exportSubmitJson(){
|
|
|
3529
4496
|
}
|
|
3530
4497
|
/* S10: 媒体固有のプロジェクト単位ガード (Meta の pageId / pixelId 必須) */
|
|
3531
4498
|
if(P.validateProject){
|
|
3532
|
-
const perrs=P.validateProject(st.project)||[];
|
|
4499
|
+
const perrs=P.validateProject(st.project, st)||[];
|
|
3533
4500
|
if(perrs.length){ toast(perrs[0].msg); return; }
|
|
3534
4501
|
}
|
|
3535
4502
|
const n=stats.exported;
|
|
@@ -3554,6 +4521,7 @@ async function exportSubmitJson(){
|
|
|
3554
4521
|
info+='<div class="mfield"><label>除外件数の内訳</label><div style="font-size:12.5px">'+(exc.length?esc(exc.join(' / ')):'なし')+'</div></div>';
|
|
3555
4522
|
if(stats.pendingImages) info+='<div class="callout amber">🆕 未登録画像 <b>'+stats.pendingImages+'</b> 件(管理番号: '+esc(stats.pendingImageNos.join(', '))+')。これらはアカウントのメディアライブラリに未登録のため、入稿前にローカル画像をアップロードして新規登録が必要です(下記プロンプト参照)。</div>';
|
|
3556
4523
|
if(stats.tgDropped) info+='<div class="callout amber">⚠ '+esc(pf().labels.tgDroppedWarn)+' を設定した広告グループが '+stats.tgDropped+' 件ありますが、これらはAPIのID解決が必要なため入稿JSONには反映されていません(入稿後に管理画面で設定してください)。</div>';
|
|
4524
|
+
if(stats.droppedRefIds) info+='<div class="callout amber">⚠ オーディエンス/配置の列に <b>'+stats.droppedRefIds+'</b> 件、数値IDでない値(旧・日本語ラベル等)が入っていたため入稿JSONから除外しました。参照データから選び直してください。</div>';
|
|
3557
4525
|
// Claudeに貼るプロンプト: 保存したJSONを Skillの入稿計画/ に入れて submit-plan で入稿を試みる
|
|
3558
4526
|
// S7: 文言・コマンドは pf().prompts.submitHelp / pf().output.submitCommand (媒体スキーマ)
|
|
3559
4527
|
const prompt=pf().prompts.submitHelp({fileName, savedRel, wrote,
|
|
@@ -3574,7 +4542,7 @@ function applyLoadedJson(j){
|
|
|
3574
4542
|
}
|
|
3575
4543
|
snapshot();
|
|
3576
4544
|
st.project=Object.assign(defaultProject(),j.project);
|
|
3577
|
-
st.campaigns=j.campaigns;
|
|
4545
|
+
st.campaigns=migrateArticleSlug(j.campaigns); // C8: 旧 articleSlug / articleUrl を除去
|
|
3578
4546
|
st.ui=j.ui||{expanded:{}};
|
|
3579
4547
|
if(validRefs(j.refs)){ st.refs=normalizeRefs(j.refs); }
|
|
3580
4548
|
st.meta=Object.assign(defaultMeta(),j.meta||{});
|
|
@@ -3603,9 +4571,42 @@ document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.targe
|
|
|
3603
4571
|
/* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
|
|
3604
4572
|
document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
|
|
3605
4573
|
/* 案件テンプレ: チップ直接編集 */
|
|
3606
|
-
document.getElementById('tplChips').addEventListener('change',e=>{
|
|
4574
|
+
document.getElementById('tplChips').addEventListener('change',e=>{
|
|
4575
|
+
/* S12: kind:'datetime' は日付/時刻の2入力で1つのキーを作るので、[data-proj] の汎用処理より先に分岐する
|
|
4576
|
+
(日付側にも data-proj が付いており、素通しすると時刻が落ちた値で上書きされる) */
|
|
4577
|
+
const dtEl=e.target.closest('[data-dt]');
|
|
4578
|
+
if(dtEl){
|
|
4579
|
+
const f=dtEl.dataset.dt, host=dtEl.closest('.chip')||document.getElementById('tplChips');
|
|
4580
|
+
const d=host.querySelector('[data-dt="'+f+'"][type=date]'), t=host.querySelector('[data-dt="'+f+'"][type=time]');
|
|
4581
|
+
mutate(()=>{ st.project[f]=joinDateTime(d&&d.value, t&&t.value); });
|
|
4582
|
+
return;
|
|
4583
|
+
}
|
|
4584
|
+
const el=e.target.closest('[data-proj]'); if(!el) return; const f=el.dataset.proj; let v = el.type==='checkbox'? el.checked : (el.type==='number'? Number(el.value) : el.value);
|
|
4585
|
+
mutate(()=>{
|
|
4586
|
+
/* S14: フリークエンシーの回数は 0/空を無視する (0回で入稿されるのを防ぐ) */
|
|
4587
|
+
if(f==='freqCount' && !(freqNum(v)>0)) return; /* 0/空/不正は無視 (既存の値をそのまま残す) */
|
|
4588
|
+
if(f==='freqCount') v=freqNum(v);
|
|
4589
|
+
st.project[f]=v;
|
|
4590
|
+
if(st.project.freqOn) freqEnsure(st.project); /* 旧プランの project は freq* を持たない */
|
|
4591
|
+
});
|
|
4592
|
+
});
|
|
4593
|
+
/* C5: テキストチップはクリックするとキャレットが先頭に入り、そのまま打つと
|
|
4594
|
+
既存URLの「前」に挿入されて壊れる。フォーカス時に全選択して打ち直し/貼り付けを置換にする。
|
|
4595
|
+
focus はバブルしないので capture で拾う。部分編集したいときは選択後にクリック or 矢印キーで解除できる。 */
|
|
4596
|
+
(function(){
|
|
4597
|
+
const chipTextInput = el => el && el.tagName==='INPUT' && !el.hasAttribute('type') && !!el.dataset.proj;
|
|
4598
|
+
let selectedOnFocus = null;
|
|
4599
|
+
const chips = document.getElementById('tplChips');
|
|
4600
|
+
chips.addEventListener('focus',e=>{ if(!chipTextInput(e.target)) return; e.target.select(); selectedOnFocus=e.target; }, true);
|
|
4601
|
+
/* クリックで focus した場合、続く mouseup がキャレットを1点に畳んで select() を無効化する。
|
|
4602
|
+
フォーカス取得直後の1回だけ mouseup を止めて全選択を保たせる。
|
|
4603
|
+
2回目以降のクリック(=部分編集したいとき)は素通しする。 */
|
|
4604
|
+
chips.addEventListener('mouseup',e=>{ if(e.target!==selectedOnFocus) return; e.preventDefault(); selectedOnFocus=null; });
|
|
4605
|
+
chips.addEventListener('blur',e=>{ if(e.target===selectedOnFocus) selectedOnFocus=null; }, true);
|
|
4606
|
+
})();
|
|
3607
4607
|
document.getElementById('btnSubmitJson').addEventListener('click',exportSubmitJson);
|
|
3608
4608
|
document.getElementById('btnHelp').addEventListener('click',openHelpModal);
|
|
4609
|
+
document.getElementById('btnConsole').addEventListener('click',()=>openConsole('campaigns'));
|
|
3609
4610
|
document.getElementById('planWarnClose').addEventListener('click',()=>{ planMismatchMsg=''; renderPlanWarn(); });
|
|
3610
4611
|
document.getElementById('btnConnectFolder').addEventListener('click',connectFolder);
|
|
3611
4612
|
/* ---- B-1/B-3: アカウントバー wiring ---- */
|
|
@@ -3646,21 +4647,39 @@ document.getElementById('btnSeqParam').addEventListener('click',applySequentialP
|
|
|
3646
4647
|
/* ---- シート セル選択 (spreadsheet) ---- */
|
|
3647
4648
|
const sheetHostEl=document.getElementById('sheetHost');
|
|
3648
4649
|
function cellFromEvent(e){ let cell=e.target.closest('[data-sel]'); if(!cell){ const cc=e.target.closest('.cellcombo'); if(cc) cell=cc.querySelector('[data-sel]'); } if(!cell){ const td=e.target.closest('td'); if(td && !td.classList.contains('rownum')) cell=td.querySelector('[data-sel]'); } return cell; } // td全体を当たり判定に(広告タブに踏襲)
|
|
3649
|
-
let pendingDrawer=null, dragMoved=false; //
|
|
4650
|
+
let pendingDrawer=null, dragMoved=false; // ドラッグ範囲選択中もドロワーを開き、選択範囲へ一括適用する
|
|
4651
|
+
let deferredCollapse=null; // 選択範囲内の入力欄クリックで保留した「選択の畳み込み」。ドラッグ開始時に実行する
|
|
3650
4652
|
sheetHostEl.addEventListener('mousedown',e=>{
|
|
3651
4653
|
if(e.target.closest('.combodd')) return; // ▼ はドロップダウン専用
|
|
3652
4654
|
const rn=e.target.closest('td.rownum'); if(rn){ const r=+rn.dataset.rownum; if(isNaN(r)) return; selA={r,c:0}; selB={r,c:tabCols().length-1}; dragging='row'; pendingDrawer=null; applySelection(); return; }
|
|
3653
4655
|
const cell=cellFromEvent(e); if(!cell) return; const r=+cell.dataset.r, c=+cell.dataset.c;
|
|
4656
|
+
/* 範囲選択中に選択内の入力欄(select/text/number)をクリックしたら、選択を「すぐには」畳まない。
|
|
4657
|
+
そのまま値を確定すると同じ列の選択行すべてへ一括反映される (bulkFillColumn)。
|
|
4658
|
+
ただしドラッグを始めたら畳む (deferredCollapse) ので、選択範囲内から新しい矩形を引き直せる。 */
|
|
4659
|
+
/* S14: kind:'freq' のセルは <label><span>ラベル</span><input/select></label> 構造なので、
|
|
4660
|
+
label も「入力欄クリック」に含める。含めないとラベル文字クリックで選択が畳まれ、
|
|
4661
|
+
一括入力が黙って1行だけになる。 */
|
|
4662
|
+
if(e.target.closest('input,select,textarea,label')){
|
|
4663
|
+
const rgNow=selRange();
|
|
4664
|
+
if(rgNow && (rgNow.r1>rgNow.r0 || rgNow.c1>rgNow.c0)
|
|
4665
|
+
&& r>=rgNow.r0 && r<=rgNow.r1 && c>=rgNow.c0 && c<=rgNow.c1){
|
|
4666
|
+
pendingDrawer=null; dragMoved=false; dragging='cell'; deferredCollapse={r,c}; return;
|
|
4667
|
+
}
|
|
4668
|
+
}
|
|
4669
|
+
deferredCollapse=null;
|
|
3654
4670
|
pendingDrawer=null; dragMoved=false;
|
|
3655
4671
|
// S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
|
|
3656
4672
|
const col=colDefs()[c];
|
|
3657
|
-
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && col.kind==='tgpick' && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
|
|
4673
|
+
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
|
|
3658
4674
|
applySelection();
|
|
3659
4675
|
});
|
|
3660
4676
|
sheetHostEl.addEventListener('mouseover',e=>{
|
|
3661
4677
|
if(!dragging) return;
|
|
3662
4678
|
if(dragging==='row'){ const rn=e.target.closest('td.rownum'); if(rn){ selB={r:+rn.dataset.rownum,c:tabCols().length-1}; applySelection(); } return; }
|
|
3663
|
-
const cell=cellFromEvent(e); if(cell){ const r=+cell.dataset.r,c=+cell.dataset.c;
|
|
4679
|
+
const cell=cellFromEvent(e); if(cell){ const r=+cell.dataset.r,c=+cell.dataset.c;
|
|
4680
|
+
/* 入力欄クリックで保留していた畳み込みをここで実行し、そのセルを起点に矩形を引き直す */
|
|
4681
|
+
if(deferredCollapse){ selA={r:deferredCollapse.r, c:deferredCollapse.c}; selB={r:deferredCollapse.r, c:deferredCollapse.c}; deferredCollapse=null; }
|
|
4682
|
+
if(!selB||selB.r!==r||selB.c!==c){ dragMoved=true; /* 画像/タイトル/説明文(ad)もターゲティング(tg)も、複数ドラッグ選択でサイドバーを開き選択範囲へ一括適用する */ if(!selA||r!==selA.r||c!==selA.c) document.body.classList.add('noselect'); selB={r,c}; applySelection(); } }
|
|
3664
4683
|
});
|
|
3665
4684
|
sheetHostEl.addEventListener('click',e=>{
|
|
3666
4685
|
// F-6: 広告グループタブのキャンペーンセル → プラン内CP選択(親付け替え)
|
|
@@ -3681,7 +4700,8 @@ document.getElementById('drawerList').addEventListener('click',e=>{ const row=e.
|
|
|
3681
4700
|
else if(drawerTarget.type==='agpick'){ if(row.dataset.agpick) moveAdToGroup(drawerTarget.adUid, row.dataset.agpick); }
|
|
3682
4701
|
else { drawerPick(row.dataset.no); }
|
|
3683
4702
|
});
|
|
3684
|
-
|
|
4703
|
+
/* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */
|
|
4704
|
+
document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
|
|
3685
4705
|
|
|
3686
4706
|
/* ---- コピー / 貼付 ---- */
|
|
3687
4707
|
document.addEventListener('copy',e=>{
|
|
@@ -3735,7 +4755,17 @@ window.addEventListener('beforeunload', function(e){ if(_dirty){ e.preventDefaul
|
|
|
3735
4755
|
if(r.ok){
|
|
3736
4756
|
const a=await r.json();
|
|
3737
4757
|
const tp=String(a.platform||'yda'), tn=String(a.accountName||'');
|
|
3738
|
-
if(tn && PLATFORMS[tp] && !(tp===curPf && tn===st.project.accountName))
|
|
4758
|
+
if(tn && PLATFORMS[tp] && !(tp===curPf && tn===st.project.accountName)){
|
|
4759
|
+
/* アクティブプラン.json は追跡されるが、実クライアントのアカウントJSONは
|
|
4760
|
+
.gitignore で除外される (公開してよいのは index.json と sample_account.json のみ)。
|
|
4761
|
+
そのため clone 直後・CI・他メンバーの環境では、ここで指すファイルが存在しない。
|
|
4762
|
+
読めないまま進むとプランが空のままプランナーが立ち上がらないので、
|
|
4763
|
+
**存在確認してから切り替え、無ければ同梱デフォルトのまま起動する**。 */
|
|
4764
|
+
const dir=(PLATFORMS[tp].account&&PLATFORMS[tp].account.dir)||LEGACY_ACCOUNT_DIR;
|
|
4765
|
+
const acc=await fetchJson(dir+'/'+encodeURIComponent(tn)+'.json');
|
|
4766
|
+
if(acc) await selectAccount(tn, tp);
|
|
4767
|
+
else toast('アクティブプランのアカウント「'+tn+'」が見つかりません。同梱の既定アカウントで開きます');
|
|
4768
|
+
}
|
|
3739
4769
|
}
|
|
3740
4770
|
}catch(e){}
|
|
3741
4771
|
})();
|