addashboard-cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/submission.js +95 -60
- package/dist/commands/submission.js.map +1 -1
- package/dist/schemas/submit-plan.js +11 -0
- package/dist/schemas/submit-plan.js.map +1 -1
- package/package.json +3 -2
- package/skills/ad-ops/SKILL.md +8 -1
- package/skills/ad-ops/ad-planner.html +769 -62
- package/skills/ad-ops/scripts/adops.sh +69 -7
- package/skills/ad-ops/scripts/plan_lint.py +34 -9
- package/skills/ad-ops//345/205/245/347/250/277/350/250/210/347/224/273/.ad-planner-plans +1 -0
|
@@ -190,6 +190,11 @@
|
|
|
190
190
|
.ddrow:hover{background:var(--accent-soft);}
|
|
191
191
|
.ddrow.cur{background:var(--accent-soft); box-shadow:inset 3px 0 0 var(--accent);}
|
|
192
192
|
.ddrow .ddno{font-family:var(--mono); color:var(--accent); font-weight:700; flex:0 0 auto; min-width:40px;}
|
|
193
|
+
/* 一覧の末尾に置く「+ 新規登録」。既存の番号行と地続きに見えないよう上の区切りを強くする */
|
|
194
|
+
.ddrow.ddnew{border-top:1px solid var(--line); border-bottom:none; background:var(--soft);
|
|
195
|
+
position:sticky; bottom:0; font-weight:600;}
|
|
196
|
+
.ddrow.ddnew:hover{background:var(--accent-soft);}
|
|
197
|
+
.ddrow.ddnew .ddno{font-size:15px;}
|
|
193
198
|
.ddrow .ddtx{color:var(--fg); overflow:hidden; text-overflow:ellipsis;}
|
|
194
199
|
/* サムネ (#157)。Finder のリスト表示に寄せて「小さい画像 + 名前」の1行にする。
|
|
195
200
|
画像が無いアカウントでも崩れないよう、読み込み失敗時は onerror で枠ごと畳む。 */
|
|
@@ -344,6 +349,24 @@
|
|
|
344
349
|
.mrow{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
|
|
345
350
|
.modal .mfoot{display:flex; justify-content:flex-end; gap:8px; padding:14px 20px 18px; border-top:1px solid var(--line2); margin-top:8px; position:sticky; bottom:0; background:#fff;}
|
|
346
351
|
.hint{font-size:11.5px; color:var(--faint); margin-top:4px;}
|
|
352
|
+
/* 「+ 新規登録」の2ステップ (保存フォルダ → 素材) */
|
|
353
|
+
.nmstep{border:1px solid var(--line2); border-radius:10px; padding:12px 14px; margin:10px 0;}
|
|
354
|
+
.nmstep.cur{border-color:var(--accent);}
|
|
355
|
+
.nmstep.wait{opacity:.55;}
|
|
356
|
+
.nmstep .nmhead{display:flex; align-items:center; gap:8px; font-size:13px; font-weight:600;}
|
|
357
|
+
.nmstep .nmno{display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%; background:var(--soft); font-size:11.5px;}
|
|
358
|
+
.nmstep.done .nmno{background:var(--green-soft); color:var(--green);}
|
|
359
|
+
.nmstep .nmbody{display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:8px; font-size:12.5px;}
|
|
360
|
+
.nmstep .nmbody .hint{flex-basis:100%; margin-top:0;}
|
|
361
|
+
.nmstep .nmpath{flex-basis:100%; display:flex; align-items:center; gap:8px; background:var(--soft); border-radius:8px; padding:8px 10px;}
|
|
362
|
+
.nmstep .nmpath .mono{flex:1; font-size:12px; word-break:break-all;}
|
|
363
|
+
.nmstep .nmhead .pill{font-weight:500; margin-left:4px;}
|
|
364
|
+
.nmitem{display:flex; gap:12px; align-items:flex-start; border:1px solid var(--line2); border-radius:10px; padding:10px; margin:8px 0;}
|
|
365
|
+
.nmitem img{width:120px; border:1px solid var(--line); border-radius:4px; flex:0 0 auto;}
|
|
366
|
+
.nmitem .nmmeta{flex:1; min-width:0;}
|
|
367
|
+
.nmitem .nmfile{font-size:12px; word-break:break-all; margin-bottom:6px;}
|
|
368
|
+
.nmitem .nmfields{display:flex; gap:10px; flex-wrap:wrap; font-size:12px;}
|
|
369
|
+
.nmitem .nmfields input{width:90px; font-size:13px; border:1px solid var(--line); border-radius:6px; padding:4px 6px;}
|
|
347
370
|
.toast{position:fixed; bottom:22px; left:50%; transform:translateX(-50%); background:#111418; color:#fff; font-size:12.5px; padding:9px 16px; border-radius:9px; z-index:100; box-shadow:0 8px 24px rgba(0,0,0,.25); opacity:0; transition:opacity .2s, transform .2s; max-width:min(720px,92vw); text-align:left; line-height:1.6;}
|
|
348
371
|
.toast.show{opacity:1; transform:translateX(-50%) translateY(-2px);}
|
|
349
372
|
/* 書き出しをブロックしたときは自動で消さない。原因を読み返せないと直しようがない */
|
|
@@ -424,6 +447,7 @@
|
|
|
424
447
|
<button data-yt="overview" class="active">概要</button>
|
|
425
448
|
<button data-yt="resp">レスポンシブ</button>
|
|
426
449
|
<button data-yt="banner">バナー</button>
|
|
450
|
+
<button data-yt="video">動画</button>
|
|
427
451
|
<button data-yt="param">パラメータ</button>
|
|
428
452
|
<button data-yt="guide">使い方</button>
|
|
429
453
|
</div>
|
|
@@ -487,7 +511,7 @@
|
|
|
487
511
|
<table class="g"><thead><tr><th>形式</th><th>入稿経路</th><th>利用</th><th>備考</th></tr></thead><tbody>
|
|
488
512
|
<tr><td><b>レスポンシブ(画像)・固定型</b></td><td><span class="pill green">API入稿 ★主用途</span></td><td>★★★</td><td>タイトル/説明文/画像を人が1通りに固定し、成果を自分で検証。<code>Card</code>(1200x628)/<code>SQ</code>(1080x1080) の2形式。API上は <code>RESPONSIVE_AD + mainMediaFormat:IMAGE</code>、画像は <code>asset.mediaAssets[].mediaId</code>(PR #82・実API疎通は検証中)</td></tr>
|
|
489
513
|
<tr><td>レスポンシブ(画像)・アセット型</td><td><span class="pill amber">非対象</span></td><td>—</td><td>複数タイトル/画像を登録しYahoo側が自動最適化。<b>1広告=単一組合せの本ツールでは非対象</b></td></tr>
|
|
490
|
-
<tr><td>レスポンシブ(動画)</td><td><span class="pill
|
|
514
|
+
<tr><td><b>レスポンシブ(動画)</b></td><td><span class="pill green">API入稿 対応済</span></td><td>★★☆</td><td>広告行の<b>形式列で「動画」</b>を選ぶ(広告タイプはレスポンシブのまま)。参照表は <code>refs.media[番号].video</code>/<code>.thumb</code>。<b>サムネイル画像が必須</b>で、<b>YDA は動画からサムネを自動生成しない</b>(本番実測)ため、クリエイティブ番号セルの ▾ → サイドバー最下部の<b>「+ 新規登録」</b>で動画を選び、0 秒フレームを抜いて入稿計画に載せる。<code>thumbnailFlg:'TRUE'</code> のサムネ画像は動画専用で、画像広告のメイン画像とは排他</td></tr>
|
|
491
515
|
<tr><td><b>バナー(静止画・固定サイズ)</b></td><td><span class="pill green">API入稿 対応済</span></td><td>★★★</td><td>広告行の<b>広告タイプ列で「バナー」</b>を選ぶ(画像は形式列=Card/SQ のまま、<b>レスポンシブと同じ画像プールを共用</b>)。API上は <code>BANNER_AD + mainMediaFormat:IMAGE</code>、画像は <b>operand 直下の <code>mediaId</code></b>。<b>タイトル/説明文/主体者/CTAはAPIフィールド自体が無い</b>(画像に焼き込み)。2026-08-19 に本番実機で add 成功・読み戻し検証済み</td></tr>
|
|
492
516
|
<tr><td>カルーセル / テキスト</td><td><span class="pill gray">未使用</span></td><td>—</td><td>運用では使わない</td></tr>
|
|
493
517
|
</tbody></table>
|
|
@@ -556,6 +580,21 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
556
580
|
<span class="c">// 読み戻すと ad.responsiveAd === null になる</span></div>
|
|
557
581
|
</div></details>
|
|
558
582
|
</div>
|
|
583
|
+
<div id="yt-video" style="display:none">
|
|
584
|
+
<details class="sec" open><summary><span class="num">1</span><span class="stitle">レスポンシブ動画 — 形式列で「動画」を選ぶ</span><span class="chev"></span></summary>
|
|
585
|
+
<div class="body">
|
|
586
|
+
<p class="lead">画像との違いは <b>素材の参照先</b> と <b>サムネイル</b> の 2 つだけ。広告タイプは「レスポンシブ」のまま、<b>形式列で「動画」</b>を選ぶ(API 上は <code>RESPONSIVE_AD + mainMediaFormat:VIDEO</code>)。タイトル・説明文・主体者・CTA は画像のレスポンシブと同じものを使う。</p>
|
|
587
|
+
<div class="callout"><b>サムネイル画像は必ず要る。</b> Yahoo は動画からサムネイルを自動生成しない(本番実測で確認済み)。サムネイルが無いと広告の作成そのものが失敗するので、プランナーが <b>動画の 0 秒フレームを抜いて</b>一緒に入稿する。</div>
|
|
588
|
+
<table class="g"><thead><tr><th>項目</th><th>内容</th></tr></thead><tbody>
|
|
589
|
+
<tr><td>参照表</td><td><code>refs.media[番号].video</code>(動画 mediaID)と <code>.thumb</code>(サムネ mediaID)。画像の <code>.card</code>/<code>.sq</code> とは別枠で、<b>同じ管理番号に画像と動画がぶら下がる</b></td></tr>
|
|
590
|
+
<tr><td>未登録素材</td><td>広告行の<b>「動画パス」「サムネパス」</b>に計画JSON基準の相対パスを入れる。本実行の直前に CLI が <code>/yda/upload-video</code> へ上げて mediaID に解決する(dry-run では上げない)</td></tr>
|
|
591
|
+
<tr><td><b>動画の取り込み</b></td><td>形式=動画 の行でクリエイティブ番号セルの ▾ を押し、サイドバー最下部の <b>「+ 新規登録」</b>を押す。モーダルで <b>① 保存フォルダ(<code>入稿計画/</code>)を選ぶ → ② 動画を選ぶ</b> の順に進めると、<b>動画パスと 0 秒サムネの両方が自動で入る</b>(素材は入稿計画フォルダの <code>素材/</code> に置かれる。保存フォルダは次回から自動でつながる)。<b>審査でサムネだけ落ちたときは、抽出位置(秒)を変えて取り直す</b></td></tr>
|
|
592
|
+
<tr><td>クリエイティブ名</td><td>形式記号は <code>v</code>(Card は <code>c</code>/SQ は <code>s</code>)。例 <code>957v-45L60-jv_Cac_148</code></td></tr>
|
|
593
|
+
<tr><td>できない組み合わせ</td><td><b>広告タイプ=バナー × 形式=動画</b>。入稿計画の書き出し前にエラーで止まる</td></tr>
|
|
594
|
+
</tbody></table>
|
|
595
|
+
<div class="callout">素材の置き場所は<b>入稿計画フォルダ配下の <code>素材/</code></b>。接続フォルダの外にある動画を選んだ場合はそこへコピーする(パスを自動で入れられるようにするため)。</div>
|
|
596
|
+
</div></details>
|
|
597
|
+
</div>
|
|
559
598
|
<div id="yt-param" style="display:none">
|
|
560
599
|
<details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
|
|
561
600
|
<div class="body">
|
|
@@ -617,7 +656,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
617
656
|
<li><b>プラン作成</b>:プランビューは「戦略メモ → 案件設定 → シート/ツリー切替」の順。シート選択時はさらに広告/広告グループ/キャンペーンのタブで編集する。AI が生成した state JSON は「JSON読込」で取り込む(旧v1形式も自動マイグレーション)。</li>
|
|
618
657
|
<li><b>確認</b>:バリデーションバッジ(タイトル35字超・参照未解決・paramCode未払出)が緑になるまで修正。バナーは広告行の「広告タイプ」列で「バナー」を選ぶ(タイトル/説明文は無効化される。画像はレスポンシブと同じ Card/SQ を使う)。</li>
|
|
619
658
|
<li><b>バージョン保存</b>:ヘッダーの「バージョン保存」。保存のたびに<b>常に新バージョン</b>(v1, v2…)として名称・メモ・作成者付きで保存される(上書きなし)。<b>履歴本体はブラウザ内に自動保存</b>されるため、フォルダ設定なしでも一覧・再読込できる。</li>
|
|
620
|
-
<li><b>保存フォルダ接続(初回に1回だけ)</b
|
|
659
|
+
<li><b>保存フォルダ接続(初回に1回だけ)</b>:素材の「+ 新規登録」のモーダル、または履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと(<code>入稿計画/</code> 以外のフォルダは接続できない)、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
|
|
621
660
|
<li><b>履歴の確認・再利用</b>:履歴ビューで一覧(Ver/日時/媒体/名称)を確認し、「読込」で過去バージョンを復元、「編集」で名称・メモ変更、「削除」で破棄。</li>
|
|
622
661
|
<li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID・目的・配信開始日は案件設定チップ、ターゲティングは広告グループ行の列から自動変換される(<code>*_入稿計画_*.json</code>)。</li>
|
|
623
662
|
<li><b>入稿(dry-run → 本実行)</b>:<code>webgram submit-plan --plan-file <入稿計画></code>。dry-run が全キャンペーンを一括検証 → OKなら <code>--no-dry-run --yes</code> で本実行(1キャンペーンずつ入稿・進捗書き戻しで中断後も再開可能)。実行後は管理画面の一覧でOK確認。</li>
|
|
@@ -802,6 +841,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
802
841
|
<div class="toolbar2">
|
|
803
842
|
<button class="act ghost" id="btnDupSel" title="選択した行を複製">⧉ 選択を複製</button>
|
|
804
843
|
<button class="act ghost" id="btnSeqParam" style="display:none" title="選択した列(paramCode / 画像番号 / タイトル番号 / 説明文番号)を先頭セル基点に連番採番">🔢 連番</button>
|
|
844
|
+
|
|
805
845
|
<span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(1セルのコピーは選択範囲全体へ充填)/範囲選択のままプルダウン・入力欄を確定すると同じ列へ一括、ターゲティング列はサイドバーで一括ON/OFF</span>
|
|
806
846
|
</div>
|
|
807
847
|
<!-- シートタブと表示フィルタは同じ行に並べ、表の上辺に接合する (フォルダタブ風)。
|
|
@@ -878,7 +918,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
878
918
|
(cli/test/unit/plannerVersion.test.ts が package.json との一致を検証するので、
|
|
879
919
|
手で書き換えて放置するとテストが落ちる)。
|
|
880
920
|
利用者が「どの版のプランナーを開いているか」を言えないと、不具合報告が噛み合わない。 */
|
|
881
|
-
const APP_VERSION = '0.
|
|
921
|
+
const APP_VERSION = '0.14.0'; /* version:sync */
|
|
882
922
|
/* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
|
|
883
923
|
同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
|
|
884
924
|
固有名詞はハードコードせず、すべて外部JSON由来にする。 */
|
|
@@ -1133,6 +1173,10 @@ const EC = {
|
|
|
1133
1173
|
LONG_HEADLINE_UNRESOLVED:'LONG_HEADLINE_UNRESOLVED',
|
|
1134
1174
|
LONG_HEADLINE_TOO_LONG:'LONG_HEADLINE_TOO_LONG',
|
|
1135
1175
|
LONG_HEADLINE_MISSING:'LONG_HEADLINE_MISSING' /* 動画広告は長見出しが必須 */,
|
|
1176
|
+
VIDEO_EXTRA_UNRESOLVED:'VIDEO_EXTRA_UNRESOLVED' /* 2本目以降の動画が未登録 = 足りない本数で入稿してしまう (Google 動画広告・#347) */,
|
|
1177
|
+
VIDEO_DUPLICATE:'VIDEO_DUPLICATE' /* 同じ YouTube 動画を2回選んでいる (backend が 400) */,
|
|
1178
|
+
VIDEO_TOO_MANY:'VIDEO_TOO_MANY' /* 動画が 5 本を超える */,
|
|
1179
|
+
VIDEO_EXTRA_IGNORED:'VIDEO_EXTRA_IGNORED' /* 画像広告に2本目以降の番号がある (warn・送らない) */,
|
|
1136
1180
|
BUSINESS_NAME_MISSING:'BUSINESS_NAME_MISSING' /* proto 上 REQUIRED */,
|
|
1137
1181
|
LOGO_MISSING:'LOGO_MISSING' /* 画像広告はロゴ 1-5 件が必須 */,
|
|
1138
1182
|
LOGO_FOREIGN_ACCOUNT:'LOGO_FOREIGN_ACCOUNT' /* ロゴが別 Customer の asset = 入稿時に必ず落ちる */,
|
|
@@ -1140,6 +1184,11 @@ const EC = {
|
|
|
1140
1184
|
CAMPAIGN_NAME_DUPLICATE:'CAMPAIGN_NAME_DUPLICATE' /* 既存CPと同名。Google は同名を拒否する */,
|
|
1141
1185
|
CAMPAIGN_NAME_DUPLICATE_IN_PLAN:'CAMPAIGN_NAME_DUPLICATE_IN_PLAN' /* 同じ計画の中で新規CP名が重複 */,
|
|
1142
1186
|
LOCATION_EMPTY:'LOCATION_EMPTY' /* 地域未指定。google は全世界配信 (warn) / bytedance は入稿不可 (err) */,
|
|
1187
|
+
/* YDA レスポンシブ動画 固有 */
|
|
1188
|
+
VIDEO_PATH_MISSING:'VIDEO_PATH_MISSING' /* 動画 mediaID 未登録なのに動画パスも未入力 = 上げるものが無い */,
|
|
1189
|
+
THUMB_MISSING:'THUMB_MISSING' /* YDA は動画からサムネを自動生成しないので必ず要る */,
|
|
1190
|
+
VIDEO_PATH_IGNORED:'VIDEO_PATH_IGNORED' /* 動画 mediaID が解決済みなのにローカル動画パスも入力 (warn) */,
|
|
1191
|
+
BANNER_VIDEO_UNSUPPORTED:'BANNER_VIDEO_UNSUPPORTED' /* 広告タイプ=バナー × 形式=動画 は未対応 */,
|
|
1143
1192
|
/* ByteDance (TikTok) 固有 */
|
|
1144
1193
|
IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
|
|
1145
1194
|
IDENTITY_BC_MISSING:'IDENTITY_BC_MISSING' /* identityType=BC_AUTH_TT なのに BC ID が無い (実運用の identity はほぼ BC_AUTH_TT) */,
|
|
@@ -1153,6 +1202,12 @@ const EC = {
|
|
|
1153
1202
|
同梱デフォルトアカウントは <account.dir>/index.json の先頭エントリを起動時に読み込んで決める(固有名詞のハードコードなし)。 */
|
|
1154
1203
|
let BUNDLED_ACCOUNT_ID = '';
|
|
1155
1204
|
let BUNDLED_ACCOUNT_NAME = '';
|
|
1205
|
+
/* Google 動画広告の動画本数の上限(公式ヘルプ「Upload 1-5 videos per ad」・backend と同じ) */
|
|
1206
|
+
const GOOGLE_MAX_VIDEOS=5;
|
|
1207
|
+
/** 「542, 543」「542、543」「542 543」を番号の配列に。重複は先に出たほうを残す */
|
|
1208
|
+
function splitNos(v){ const out=[]; String(v==null?'':v).split(/[,、\s]+/).map(x=>x.trim()).filter(Boolean).forEach(x=>{ if(out.indexOf(x)<0) out.push(x); }); return out; }
|
|
1209
|
+
/** 広告行の動画番号(先頭が代表 = imageNo)。画像広告や旧プランは imageNo だけ */
|
|
1210
|
+
function videoNosOf(a){ return splitNos([a.imageNo==null?'':a.imageNo, a.format==='VIDEO'?(a.moreVideoNos||''):''].join(',')); }
|
|
1156
1211
|
/* ============================================================
|
|
1157
1212
|
S7: PLATFORMS — 媒体スキーマ定義レジストリ
|
|
1158
1213
|
------------------------------------------------------------
|
|
@@ -1307,24 +1362,34 @@ const REF_TABLES = {
|
|
|
1307
1362
|
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
|
|
1308
1363
|
],
|
|
1309
1364
|
optionLabel:v=>String(v==null?'':v) },
|
|
1310
|
-
media:{ key:'media', label:'
|
|
1311
|
-
keyKind:'number', keyLabel:'
|
|
1312
|
-
addSub:'
|
|
1313
|
-
emptyMsg:'
|
|
1365
|
+
media:{ key:'media', label:'クリエイティブ', countLabel:'登録クリエイティブ', editable:true, required:true,
|
|
1366
|
+
keyKind:'number', keyLabel:'クリエイティブ番号', keyWidth:'110px',
|
|
1367
|
+
addSub:'クリエイティブ番号→Card/SQ/動画 の mediaID。新規作成した素材をメディアに登録',
|
|
1368
|
+
emptyMsg:'該当するクリエイティブがありません',
|
|
1314
1369
|
/* YDA のメディアライブラリはアカウントに1つだけ (バナー専用プールは存在しない)。
|
|
1315
1370
|
バナー広告もここの card / sq をそのまま使う。 */
|
|
1371
|
+
/* video / thumb は **レスポンシブ動画広告** 用 (動画 mediaId とサムネ画像 mediaId の組)。
|
|
1372
|
+
画像の card / sq と同じ番号を共有する = 1つの管理番号に画像・動画がぶら下がる。 */
|
|
1316
1373
|
valueFields:[{k:'card', label:'Card mediaID (1200)', editLabel:'Card mediaID'},
|
|
1317
|
-
{k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'}
|
|
1374
|
+
{k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'},
|
|
1375
|
+
{k:'video', label:'動画 mediaID', editLabel:'動画 mediaID'},
|
|
1376
|
+
{k:'thumb', label:'動画サムネ mediaID', editLabel:'サムネ mediaID'}],
|
|
1318
1377
|
keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
|
|
1319
1378
|
columns:[
|
|
1320
1379
|
{label:'Card mediaID (1200)', cls:'mono', cell:(no,v)=>esc(v&&v.card||'—')},
|
|
1321
1380
|
{label:'SQ mediaID (1080)', cls:'mono', cell:(no,v)=>esc(v&&v.sq||'—')},
|
|
1381
|
+
{label:'動画 mediaID', cls:'mono', cell:(no,v)=>esc(v&&v.video||'—')},
|
|
1382
|
+
{label:'サムネ mediaID', cls:'mono', cell:(no,v)=>esc(v&&v.thumb||'—')},
|
|
1383
|
+
/* 番号が動画として登録されているなら動画のファイル名を出す (画像の固定名を見せない) */
|
|
1322
1384
|
{label:'ファイル名', cls:'mono', style:'color:var(--faint);font-size:11px',
|
|
1323
|
-
cell:no=>
|
|
1385
|
+
cell:(no,v)=>(v&&v.video)
|
|
1386
|
+
? esc(no)+'.mp4'+(v.thumb?(' / '+esc(no)+'-thumb.jpg'):'')
|
|
1387
|
+
: esc(no)+'-1200.jpg / '+esc(no)+'-1080.jpg'}
|
|
1324
1388
|
],
|
|
1325
|
-
optionLabel:v=>[v&&v.card?'Card':'', v&&v.sq?'SQ':''].filter(Boolean).join('/'),
|
|
1389
|
+
optionLabel:v=>[v&&v.card?'Card':'', v&&v.sq?'SQ':'', v&&v.video?'動画':''].filter(Boolean).join('/'),
|
|
1326
1390
|
/* ▼ドロワー(一覧)の1行テキスト。媒体ごとの値の見せ方はここだけが持つ */
|
|
1327
|
-
drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—')
|
|
1391
|
+
drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—')
|
|
1392
|
+
+(v&&v.video?(' / 動画 '+esc(v.video)):'') },
|
|
1328
1393
|
adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
|
|
1329
1394
|
keyKind:'string', keyLabel:'広告グループID', keyWidth:'110px',
|
|
1330
1395
|
emptyMsg:'既存の広告グループがありません(アカウントJSONの refs.adGroups)',
|
|
@@ -1434,9 +1499,14 @@ PLATFORMS.yda = {
|
|
|
1434
1499
|
(アップロードは base64 のみ、広告作成は mediaId のみ)。
|
|
1435
1500
|
→ **バナーもレスポンシブも同じ画像プール (refs.media[番号].card / .sq) を共用する**。
|
|
1436
1501
|
広告タイプ (RESPONSIVE_AD / BANNER_AD) は別の軸で、広告行の「広告タイプ」列 (ad.adKind) が持つ。 */
|
|
1502
|
+
/* 動画 (VIDEO) は画像と同じ「形式」軸に並ぶ第3の値。広告タイプ (レスポンシブ/バナー) とは直交し、
|
|
1503
|
+
レスポンシブ動画 = RESPONSIVE_AD + mainMediaFormat:VIDEO になる。
|
|
1504
|
+
動画は Card/SQ のようなサイズ区分を持たない (1本の動画 mediaId + サムネ画像 mediaId の組)。 */
|
|
1437
1505
|
mediaFormats:[
|
|
1438
1506
|
{id:'Card', crSuffix:'c', mediaKey:'card', apiMediaKind:'IMAGE', label:'Card mediaID (1200)'},
|
|
1439
|
-
{id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'}
|
|
1507
|
+
{id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'},
|
|
1508
|
+
{id:'動画', crSuffix:'v', mediaKey:'video', apiMediaKind:'VIDEO', label:'動画 mediaID',
|
|
1509
|
+
thumbKey:'thumb'}
|
|
1440
1510
|
],
|
|
1441
1511
|
/* 広告タイプがバナー (BANNER_AD) か。**サイズ(形式)ではなく ad.adKind で決まる**。
|
|
1442
1512
|
(タイトル/説明文セルの無効化・広告名・入稿ボディのテキスト系省略の判定はすべてここを見る。
|
|
@@ -1445,6 +1515,9 @@ PLATFORMS.yda = {
|
|
|
1445
1515
|
adKind:' バナー ' のような値で adType だけ BANNER_AD になり、
|
|
1446
1516
|
テキスト系フィールドが入稿計画JSONに混入する。 */
|
|
1447
1517
|
isBanner(ad){ return PLATFORMS.yda.mappers.adType((ad&&ad.adKind), PLATFORMS.yda)==='BANNER_AD'; },
|
|
1518
|
+
/* 形式=動画 か。**広告タイプ (adKind) ではなく形式 (format) の軸**。
|
|
1519
|
+
列の有効/無効・参照するメディアキー・入稿ボディの mediaType はすべてここを見る。 */
|
|
1520
|
+
isVideo(ad){ return PLATFORMS.yda.fmtOf(ad).apiMediaKind==='VIDEO'; },
|
|
1448
1521
|
fmtOf(ad){ const F=PLATFORMS.yda.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
|
|
1449
1522
|
|
|
1450
1523
|
/* ---- 参照テーブル (S6 の REF_TABLES) ---- */
|
|
@@ -1557,7 +1630,8 @@ PLATFORMS.yda = {
|
|
|
1557
1630
|
},
|
|
1558
1631
|
/* C6: adName は空 = 自動生成 (naming.adName)。C1: imagePath は空 = 従来挙動 */
|
|
1559
1632
|
/* adKind = 広告タイプ軸 (レスポンシブ / バナー)。format はサイズ軸 (Card / SQ) で直交する */
|
|
1560
|
-
ad:{format:'Card', adKind:'レスポンシブ', imageNo:'', titleNo:'', descNo:'', imagePath:'',
|
|
1633
|
+
ad:{format:'Card', adKind:'レスポンシブ', imageNo:'', titleNo:'', descNo:'', imagePath:'',
|
|
1634
|
+
videoPath:'', thumbnailPath:'', adName:'', desc2:''},
|
|
1561
1635
|
/* S14: freq* は「案件設定チップの既定を新規CP作成時にコピーする」ので factories.newCampaign 側で入れる */
|
|
1562
1636
|
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:'',
|
|
1563
1637
|
freqMode:'default', freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3}
|
|
@@ -1621,7 +1695,7 @@ PLATFORMS.yda = {
|
|
|
1621
1695
|
}
|
|
1622
1696
|
},
|
|
1623
1697
|
/* 「クリエイティブをクリア」対象フィールド */
|
|
1624
|
-
creativeFields:['imageNo','titleNo','descNo','imagePath'],
|
|
1698
|
+
creativeFields:['imageNo','titleNo','descNo','imagePath','videoPath','thumbnailPath'],
|
|
1625
1699
|
/* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象)。
|
|
1626
1700
|
C7: オーディエンス/配置は実ID入稿に対応したので **未反映列から外した** */
|
|
1627
1701
|
notSubmittedFields:['tg_region','tg_searchKw'],
|
|
@@ -1635,7 +1709,8 @@ PLATFORMS.yda = {
|
|
|
1635
1709
|
+ '<span class="chip">'+esc(c.biddingStrategy||p.biddingStrategyDefault)+' <b>'+esc((c.tcpa??p.tcpaDefault))+'</b></span>';
|
|
1636
1710
|
},
|
|
1637
1711
|
groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'; },
|
|
1638
|
-
|
|
1712
|
+
/* 形式記号は mediaFormats.crSuffix が正 (c / s / v)。format の1文字目を取ると「動画」が「動」になる */
|
|
1713
|
+
adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+esc(PLATFORMS.yda.fmtOf(a).crSuffix)+'</span>'; },
|
|
1639
1714
|
adSub(a, r){ return r.isBanner ? 'バナー' : esc(a.titleNo)+'L'+esc(a.descNo); },
|
|
1640
1715
|
/* バナーにはタイトルが存在しない。シート側は「—」で無効化しているのに
|
|
1641
1716
|
ツリーが「(未入力)」と出すと「埋めるべき欄」に見えるので別表記にする。 */
|
|
@@ -1652,11 +1727,20 @@ PLATFORMS.yda = {
|
|
|
1652
1727
|
const autoAdName=P.naming.adName(ad, group, crName, p);
|
|
1653
1728
|
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
1654
1729
|
const me = refs.media[String(ad.imageNo)];
|
|
1655
|
-
|
|
1730
|
+
/* 参照表の編集モーダル (refEdit) は空欄を **空文字で保存する** ので、'' も未登録として扱う。
|
|
1731
|
+
undefined だけを見ていると ID が空のまま「登録済み」と判定され、警告も出ずに
|
|
1732
|
+
mediaIds:[''] の計画が書き出されて dry-run も通る。YDA には自動ロールバックが無いので、
|
|
1733
|
+
本実行では CP と広告グループだけが残る。Meta の resolve と同じ判定に揃えている。 */
|
|
1734
|
+
const mediaId = (me ? me[fmt.mediaKey] : undefined) || undefined;
|
|
1656
1735
|
/* バナー広告 (BANNER_AD) はタイトル/説明文のAPI項目が存在しない (画像に焼き込む運用) ので、
|
|
1657
1736
|
参照も検証もしない。セル自体も列記述子の col.disabled で無効化している。
|
|
1658
1737
|
判定は「広告タイプ」列 (ad.adKind) — 画像サイズ (Card/SQ) とは独立した軸。 */
|
|
1659
1738
|
const isBanner = P.isBanner(ad);
|
|
1739
|
+
/* 形式=動画 のときは動画 mediaId (me.video) とサムネ mediaId (me.thumb) の組で解決する。
|
|
1740
|
+
サムネは YDA が自動生成しないので、mediaId かローカルパスのどちらかが必ず要る。 */
|
|
1741
|
+
const isVideo = P.isVideo(ad);
|
|
1742
|
+
// mediaId と同じ理由で '' も未登録として扱う(サムネ未設定のまま入稿されるのを防ぐ)
|
|
1743
|
+
const thumbId = ((isVideo && me) ? me[fmt.thumbKey] : undefined) || undefined;
|
|
1660
1744
|
const titleText = isBanner ? undefined : refs.titles[ad.titleNo];
|
|
1661
1745
|
const descText = isBanner ? undefined : (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
|
|
1662
1746
|
const finalUrl = P.naming.finalUrl(ad, group, adName, p);
|
|
@@ -1681,11 +1765,27 @@ PLATFORMS.yda = {
|
|
|
1681
1765
|
// B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
|
|
1682
1766
|
/* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
|
|
1683
1767
|
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
1684
|
-
if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
|
|
1768
|
+
if(pendingImage) pushWarn(isVideo?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
|
|
1769
|
+
/* 動画 (レスポンシブ動画) の検証。画像とは要求が違う:
|
|
1770
|
+
・動画 mediaId も動画パスも無ければ **上げるものが無い** → err (TikTok と同じ。画像の warn 止まりとは違う)
|
|
1771
|
+
・**サムネは必須**。YDA は動画からサムネを自動生成しないので (本番実測)、
|
|
1772
|
+
mediaId かローカルパスのどちらかが無いと広告作成が R0001 で落ちる
|
|
1773
|
+
・バナー × 動画 は backend の zod が弾く組み合わせ。dry-run より手前で気づけるよう err にする */
|
|
1774
|
+
const videoPath=String(ad.videoPath||'').trim();
|
|
1775
|
+
const thumbnailPath=String(ad.thumbnailPath||'').trim();
|
|
1776
|
+
if(isVideo){
|
|
1777
|
+
if(isBanner) pushErr('バナー×動画は未対応', EC.BANNER_VIDEO_UNSUPPORTED);
|
|
1778
|
+
if(imgNoStr!=='' && pendingImage && videoPath==='') pushErr('動画パス未入力(動画mediaID未登録)', EC.VIDEO_PATH_MISSING);
|
|
1779
|
+
if(!pendingImage && videoPath!=='') pushWarn('動画パス無視(動画mediaIDが解決済み)', EC.VIDEO_PATH_IGNORED);
|
|
1780
|
+
/* サムネのローカルパスは **動画を上げるときにしか送れない** (CLI は upload-video の multipart に
|
|
1781
|
+
添える形でしか受け取らない)。動画 mediaID が解決済みの行では thumb mediaID が無いと入稿できない。 */
|
|
1782
|
+
if(imgNoStr!=='' && thumbId===undefined && (thumbnailPath==='' || !pendingImage))
|
|
1783
|
+
pushErr(pendingImage?'サムネ未設定(動画広告に必須)':'サムネ mediaID 未登録(動画が登録済みの行ではパスを使えません)', EC.THUMB_MISSING);
|
|
1784
|
+
}
|
|
1685
1785
|
// C6-1: 未登録画像なのにローカル画像パス未入力 = 入稿時にアップロード元が無い。
|
|
1686
1786
|
// ただし err にはしない (CLI/agent が後から imagePaths を補完する運用と共存する)
|
|
1687
|
-
const imagePaths=String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
1688
|
-
if(pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
|
|
1787
|
+
const imagePaths=isVideo?[]:String(ad.imagePath||'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
1788
|
+
if(!isVideo && pendingImage && imagePaths.length===0) pushWarn('画像パス未入力', EC.IMAGE_PATH_MISSING);
|
|
1689
1789
|
/* 逆に mediaID が解決済みの広告に画像パスを足しても、その画像は広告には使われない
|
|
1690
1790
|
(CLI はアップロードした mediaId を末尾に **追記** し、backend は mediaIds[0] しか見ない)。
|
|
1691
1791
|
黙って古い画像で入稿されるのが最悪なので、書き出しでは落とし、ここで警告する。
|
|
@@ -1694,7 +1794,8 @@ PLATFORMS.yda = {
|
|
|
1694
1794
|
// F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
|
|
1695
1795
|
const aid=String(group.adGroupId||'').trim();
|
|
1696
1796
|
return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind,
|
|
1697
|
-
adType:P.mappers.adType(ad.adKind, P), isBanner, imageNo:imgNoStr,
|
|
1797
|
+
adType:P.mappers.adType(ad.adKind, P), isBanner, isVideo, imageNo:imgNoStr,
|
|
1798
|
+
videoPath, thumbnailPath, thumbId:thumbId||'',
|
|
1698
1799
|
mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
|
|
1699
1800
|
descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
1700
1801
|
},
|
|
@@ -1785,7 +1886,9 @@ PLATFORMS.yda = {
|
|
|
1785
1886
|
const ad={
|
|
1786
1887
|
adName:r.adName,
|
|
1787
1888
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1788
|
-
|
|
1889
|
+
/* 形式 (Card/SQ/動画) 由来。画像広告は IMAGE (バナーもメイン形式は IMAGE)、
|
|
1890
|
+
レスポンシブ動画は VIDEO (backend ydaAdSchema mediaType)。 */
|
|
1891
|
+
mediaType:r.mediaKind,
|
|
1789
1892
|
adType:r.adType,
|
|
1790
1893
|
finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
|
|
1791
1894
|
};
|
|
@@ -1798,6 +1901,17 @@ PLATFORMS.yda = {
|
|
|
1798
1901
|
ad.principal=p.principal||''; // E-1: 主体者はアカウントJSON由来 (st.project.principal)
|
|
1799
1902
|
ad.buttonText=P.mappers.cta(p.cta, P); // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
|
|
1800
1903
|
}
|
|
1904
|
+
/* レスポンシブ動画 (mediaType:VIDEO)。画像の mediaIds ではなく videoMediaId + thumbnailMediaId を送る。
|
|
1905
|
+
未登録なら videoPath / thumbnailPath を残し、CLI が本実行の直前に /yda/upload-video へ上げて
|
|
1906
|
+
両IDに解決する (dry-run では上げない)。**IDとパスを同時に載せない** — 載せると CLI が
|
|
1907
|
+
余計にアップロードして、広告には古いIDが使われる (画像側で踏んだのと同じ罠)。 */
|
|
1908
|
+
if(r.isVideo){
|
|
1909
|
+
if(r.pendingImage){ ad.videoPath=r.videoPath; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; }
|
|
1910
|
+
else ad.videoMediaId=String(r.mediaId);
|
|
1911
|
+
if(r.thumbId) ad.thumbnailMediaId=String(r.thumbId);
|
|
1912
|
+
else if(r.thumbnailPath) ad.thumbnailPath=r.thumbnailPath;
|
|
1913
|
+
return ad;
|
|
1914
|
+
}
|
|
1801
1915
|
if(r.pendingImage){
|
|
1802
1916
|
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
|
|
1803
1917
|
// C6-1: 画像パス列 (カンマ区切り) を imagePaths に展開する。空なら [] のまま (agent/CLI が補完)
|
|
@@ -1907,7 +2021,8 @@ PLATFORMS.yda = {
|
|
|
1907
2021
|
}
|
|
1908
2022
|
},
|
|
1909
2023
|
docTabs:[{id:'overview',label:'概要'},{id:'resp',label:'レスポンシブ'},
|
|
1910
|
-
{id:'banner',label:'バナー'},{id:'
|
|
2024
|
+
{id:'banner',label:'バナー'},{id:'video',label:'動画'},
|
|
2025
|
+
{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
1911
2026
|
};
|
|
1912
2027
|
|
|
1913
2028
|
/* 宣言に載せない旧テーブル。resolveGroupIds/maxParamNum が参照中のため S11 の撤去まで温存する。 */
|
|
@@ -2085,6 +2200,7 @@ function initialState(){
|
|
|
2085
2200
|
campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
|
|
2086
2201
|
migrateArticleSlug(campaigns); // C8: 旧 articleSlug / articleUrl を除去
|
|
2087
2202
|
migratePlatformValues(campaigns); // 媒体側の旧値を現行の語彙へ
|
|
2203
|
+
project.localMedia=samplePlanLocalMedia();
|
|
2088
2204
|
}
|
|
2089
2205
|
}
|
|
2090
2206
|
return {schemaVersion:SCHEMA_VERSION, meta:defaultMeta(), project, refs, campaigns, ui:{expanded:{}}};
|
|
@@ -2603,7 +2719,9 @@ function refEdit(tableKey,no){
|
|
|
2603
2719
|
value:single?cur:((cur||{})[fd.k]||'')})),
|
|
2604
2720
|
onOk:v=>{ refCommit(()=>{
|
|
2605
2721
|
if(single){ store[no]=v[T.valueFields[0].k]; return; }
|
|
2606
|
-
|
|
2722
|
+
/* 空欄は **キーごと落とす**。空文字を入れると resolve が「登録済み」と誤判定し、
|
|
2723
|
+
ID が空のまま入稿されて広告作成で落ちる (作りかけの CP が残る)。 */
|
|
2724
|
+
const e={}; T.valueFields.forEach(fd=>{ const sv=String(v[fd.k]).trim(); if(sv!=='') e[fd.k]=sv; }); store[no]=e;
|
|
2607
2725
|
}, '更新しました'); }});
|
|
2608
2726
|
}
|
|
2609
2727
|
function refDel(tableKey,no){
|
|
@@ -2729,10 +2847,11 @@ const COLS = {
|
|
|
2729
2847
|
ads:[
|
|
2730
2848
|
/* 形式 = **画像のサイズ軸** (Card=1200x628 / SQ=1080x1080)。参照表 refs.media[番号].card / .sq を引く。
|
|
2731
2849
|
広告タイプ (レスポンシブ/バナー) とは直交する軸で、バナーも同じ画像プールを使う。 */
|
|
2732
|
-
{field:'format', label:'形式', kind:'optpick', options:['Card','SQ'], width:'72px',
|
|
2733
|
-
title:'
|
|
2734
|
-
coerce:v=>{ const s=String(v).trim().toUpperCase();
|
|
2735
|
-
if(s==='
|
|
2850
|
+
{field:'format', label:'形式', kind:'optpick', options:['Card','SQ','動画'], width:'72px',
|
|
2851
|
+
title:'素材の種類。Card / SQ は画像のサイズ(バナーも同じ画像プールを使います)、動画はレスポンシブ動画広告',
|
|
2852
|
+
coerce:v=>{ const s=String(v).trim(), u=s.toUpperCase();
|
|
2853
|
+
if(s==='動画'||u==='VIDEO') return '動画';
|
|
2854
|
+
if(u==='SQ') return 'SQ';
|
|
2736
2855
|
return 'Card'; }},
|
|
2737
2856
|
/* 広告タイプ = **RESPONSIVE_AD / BANNER_AD の軸**。行単位で切り替えるので同一キャンペーン内に混在できる。
|
|
2738
2857
|
バナー行はタイトル/説明文のセルが col.disabled で無効化される (PLATFORMS.yda.isBanner)。 */
|
|
@@ -2741,9 +2860,9 @@ const COLS = {
|
|
|
2741
2860
|
get:row=>row.adKind||'レスポンシブ',
|
|
2742
2861
|
coerce:v=>{ const s=String(v).trim();
|
|
2743
2862
|
return (s==='バナー'||s.toUpperCase()==='BANNER'||s.toUpperCase()==='BANNER_AD')?'バナー':'レスポンシブ'; }},
|
|
2744
|
-
{field:'imageNo', label:'
|
|
2863
|
+
{field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
2745
2864
|
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
|
|
2746
|
-
ghostKey:'media', ddTitle:'
|
|
2865
|
+
ghostKey:'media', ddTitle:'登録クリエイティブから選択', ghostTitle:r=>'メディアID '+r.mediaId,
|
|
2747
2866
|
ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
|
|
2748
2867
|
if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>';
|
|
2749
2868
|
if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
|
|
@@ -2784,7 +2903,25 @@ const COLS = {
|
|
|
2784
2903
|
disabledTitle:'バナー広告(BANNER_AD)には説明文のAPI項目がありません(画像に焼き込み)'}),
|
|
2785
2904
|
/* C6-1: 量産時のローカル画像パス担体 (カンマ区切りで複数可)。少数入稿は Skill の画像入稿フローが主導線 */
|
|
2786
2905
|
{field:'imagePath', label:'画像パス', kind:'mono', width:'150px',
|
|
2906
|
+
disabled:it=>it.kind==='a' && PLATFORMS.yda.isVideo(it.a),
|
|
2907
|
+
disabledTitle:'形式=動画の行では画像パスを使いません(動画パス / サムネパスを使います)',
|
|
2787
2908
|
title:'ローカル画像の絶対 / ~/… / 計画JSON基準の相対パス。カンマ区切りで複数可。※画像番号が参照表で解決できる行では入稿JSONに載りません(差し替えたいときは画像番号を空に)'},
|
|
2909
|
+
/* 形式=動画 のローカル素材パス。**クリエイティブ番号の右サイドバー「+ 新規登録」から選ぶと、
|
|
2910
|
+
動画パスと 0 秒サムネ (自動抽出→接続フォルダへ書き出し) の両方が自動で入る**。手入力も可。
|
|
2911
|
+
本実行の直前に CLI が /yda/upload-video へ上げて videoMediaId / thumbnailMediaId に解決する
|
|
2912
|
+
(dry-run では上げない)。動画 mediaID が参照表で解決済みの行では入稿JSONに載らない。 */
|
|
2913
|
+
{field:'videoPath', label:'動画パス', kind:'mono', width:'150px',
|
|
2914
|
+
disabled:it=>it.kind==='a' && !PLATFORMS.yda.isVideo(it.a),
|
|
2915
|
+
disabledTitle:'形式が「動画」の行だけで使います',
|
|
2916
|
+
errCodes:[EC.VIDEO_PATH_MISSING],
|
|
2917
|
+
title:'ローカル動画の絶対 / ~/… / 計画JSON基準の相対パス。※動画 mediaID が参照表で解決できる行では入稿JSONに載りません'},
|
|
2918
|
+
/* 動画広告のサムネイル画像。YDA は **動画からサムネを自動生成しない** (本番実測) ので、
|
|
2919
|
+
0 秒フレームを抽出した画像を必ず一緒に入稿する必要がある。 */
|
|
2920
|
+
{field:'thumbnailPath', label:'サムネパス', kind:'mono', width:'150px',
|
|
2921
|
+
disabled:it=>it.kind==='a' && !PLATFORMS.yda.isVideo(it.a),
|
|
2922
|
+
disabledTitle:'形式が「動画」の行だけで使います',
|
|
2923
|
+
errCodes:[EC.THUMB_MISSING],
|
|
2924
|
+
title:'動画サムネイル画像のパス。クリエイティブ番号のサイドバー「+ 新規登録」で動画を選ぶと、0 秒フレームを自動抽出して埋まります(審査で落ちたときだけ差し替える)'},
|
|
2788
2925
|
/* C6: 広告名は自由入力 (空 = 自動生成)。placeholder に自動生成値を出す */
|
|
2789
2926
|
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
2790
2927
|
title:'空欄なら自動生成(レスポンシブ: {画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode} / バナー: {画像番号}{c|s}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
|
|
@@ -3719,12 +3856,23 @@ const GOOGLE_COLS = {
|
|
|
3719
3856
|
{field:'format', label:'形式', kind:'select', options:['IMAGE','VIDEO'], minWidth:'70px',
|
|
3720
3857
|
coerce:v=>(String(v).toUpperCase()==='VIDEO'?'VIDEO':'IMAGE'),
|
|
3721
3858
|
title:'IMAGE = Demand Gen 画像広告 / VIDEO = Demand Gen 動画広告(YouTube)'},
|
|
3859
|
+
/* 動画広告は 1 広告に最大 5 本(縦・横・正方形など・#347)。セルは「542, 543」のように並べ、
|
|
3860
|
+
先頭(imageNo)が代表 = 広告名・キャンペーン名の番号。2本目以降は moreVideoNos に持つ。
|
|
3861
|
+
imageNo を単一のまま残すのは、命名・連番・新規登録素材のパス補完がすべて imageNo を見ているため。
|
|
3862
|
+
縦横の区別は持たない(Google が配信面ごとに比率で選ぶので、入稿には要らない)。 */
|
|
3722
3863
|
{field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
3723
|
-
width:'
|
|
3724
|
-
|
|
3725
|
-
|
|
3864
|
+
width:'9ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED, EC.VIDEO_EXTRA_UNRESOLVED, EC.VIDEO_DUPLICATE, EC.VIDEO_TOO_MANY],
|
|
3865
|
+
multiMax:a=>(a.format==='VIDEO'?GOOGLE_MAX_VIDEOS:0),
|
|
3866
|
+
get:a=>splitNos([a.imageNo==null?'':a.imageNo, a.moreVideoNos||''].join(',')).join(', '),
|
|
3867
|
+
set:(a,v)=>{ const l=splitNos(v); a.imageNo=l.length?(/^\d+$/.test(l[0])?Number(l[0]):l[0]):''; a.moreVideoNos=l.slice(1).join(', '); },
|
|
3868
|
+
ghostKey:'media', ddTitle:'登録クリエイティブから選択(動画は複数可)',
|
|
3869
|
+
ghostTitle:r=>(r.mediaKind==='VIDEO'?'YouTube動画ID ':'画像アセット ')+[r.mediaId].concat(r.extraVideoIds).join(', '),
|
|
3726
3870
|
ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
|
|
3727
|
-
if(r.
|
|
3871
|
+
if(r.errCodes.includes(EC.VIDEO_TOO_MANY)) return '<span class="err">'+GOOGLE_MAX_VIDEOS+'本まで</span>';
|
|
3872
|
+
if(r.errCodes.includes(EC.VIDEO_DUPLICATE)) return '<span class="err">同じ動画が重複</span>';
|
|
3873
|
+
if(r.errCodes.includes(EC.VIDEO_EXTRA_UNRESOLVED)) return '<span class="err">2本目以降が未登録</span>';
|
|
3874
|
+
const more=r.extraVideoIds.length?' <span class="pill" title="代表を含めて'+(r.extraVideoIds.length+1)+'本">計'+(r.extraVideoIds.length+1)+'本</span>':'';
|
|
3875
|
+
if(r.mediaId!=='') return '<span class="idpill">'+(r.mediaKind==='VIDEO'?'VID ':'IMG ')+esc(r.mediaId)+'</span>'+more;
|
|
3728
3876
|
if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
|
|
3729
3877
|
return '<span class="idpill err">#N/A</span>'; }},
|
|
3730
3878
|
{field:'titleNo', label:'見出し番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
|
|
@@ -3965,7 +4113,7 @@ PLATFORMS.google = {
|
|
|
3965
4113
|
businessName:'', logoAsset:'', logoImagePath:'', cta:'詳しくはこちら', startDate:'',
|
|
3966
4114
|
deliveryStatus:'オフ'
|
|
3967
4115
|
},
|
|
3968
|
-
ad:{format:'VIDEO', imageNo:'', titleNo:'', longNo:'', descNo:'', videoPath:'', adName:''},
|
|
4116
|
+
ad:{format:'VIDEO', imageNo:'', moreVideoNos:'', titleNo:'', longNo:'', descNo:'', videoPath:'', adName:''},
|
|
3969
4117
|
adGroup:{name:'', paramCode:'', isExisting:false,
|
|
3970
4118
|
tg_location:'日本', tg_language:'日本語',
|
|
3971
4119
|
/* 従来の 'YouTube'(3面まとめて)と同じ配信範囲を、選択肢に載っている値で表す。
|
|
@@ -4027,7 +4175,7 @@ PLATFORMS.google = {
|
|
|
4027
4175
|
g.tg_channel=out.join(', ');
|
|
4028
4176
|
}));
|
|
4029
4177
|
},
|
|
4030
|
-
creativeFields:['imageNo','titleNo','longNo','descNo','videoPath'],
|
|
4178
|
+
creativeFields:['imageNo','moreVideoNos','titleNo','longNo','descNo','videoPath'],
|
|
4031
4179
|
notSubmittedFields:[],
|
|
4032
4180
|
labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode', tgDroppedWarn:''},
|
|
4033
4181
|
|
|
@@ -4097,9 +4245,20 @@ PLATFORMS.google = {
|
|
|
4097
4245
|
pushWarn('動画パス無視(動画IDが解決済み)', EC.IMAGE_PATH_IGNORED);
|
|
4098
4246
|
if(fmt.id==='IMAGE' && videoPath!=='')
|
|
4099
4247
|
pushWarn('動画パス無視(画像広告)', EC.IMAGE_PATH_IGNORED);
|
|
4248
|
+
/* 2本目以降の動画(#347)。未登録のローカル動画は先頭(代表)だけ — 2本目以降は登録済みの番号に限る */
|
|
4249
|
+
const extraNos = fmt.id==='VIDEO' ? videoNosOf(ad).slice(1) : [];
|
|
4250
|
+
const extraVideoIds = extraNos.map(n=>String(((refs.media[n]||{}).video)||''));
|
|
4251
|
+
if(fmt.id==='VIDEO' && extraNos.length){
|
|
4252
|
+
if(extraVideoIds.some(v=>v==='')) pushErr('2本目以降の動画が未登録(登録済みの番号を選ぶ)', EC.VIDEO_EXTRA_UNRESOLVED);
|
|
4253
|
+
const ids=[String(mediaId||'')].concat(extraVideoIds).filter(Boolean);
|
|
4254
|
+
if(new Set(ids).size!==ids.length) pushErr('同じ動画を2回選んでいる', EC.VIDEO_DUPLICATE);
|
|
4255
|
+
if(extraNos.length+1>GOOGLE_MAX_VIDEOS) pushErr('動画は'+GOOGLE_MAX_VIDEOS+'本まで', EC.VIDEO_TOO_MANY);
|
|
4256
|
+
}
|
|
4257
|
+
if(fmt.id==='IMAGE' && String(ad.moreVideoNos||'').trim()!=='') pushWarn('2本目以降の番号を無視(画像広告は1つ)', EC.VIDEO_EXTRA_IGNORED);
|
|
4100
4258
|
const aid=String(group.adGroupId||'').trim();
|
|
4101
4259
|
return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
|
|
4102
4260
|
mediaId:mediaId||'', imageAsset:imageAsset||'', squareAsset:squareAsset||'', pendingImage, videoPath,
|
|
4261
|
+
extraVideoIds,
|
|
4103
4262
|
titleText:titleText===undefined?'':titleText,
|
|
4104
4263
|
longText:longText===undefined?'':longText,
|
|
4105
4264
|
descText:descText===undefined?'':descText,
|
|
@@ -4308,6 +4467,13 @@ PLATFORMS.google = {
|
|
|
4308
4467
|
superRefine の「動画が指定されていません」が出ない(zod は基底 parse に失敗すると
|
|
4309
4468
|
refinement を走らせない)。キーごと出さないほうが理由が届く。 */
|
|
4310
4469
|
else if(r.mediaId) out.youtubeVideoId=String(r.mediaId);
|
|
4470
|
+
/* 2本以上のときだけ videos[](#347)。1本なら旧形式のまま出す — 1本の計画まで新形式にすると
|
|
4471
|
+
旧 CLI で何も入稿できなくなる */
|
|
4472
|
+
if(r.extraVideoIds.length && (out.youtubeVideoId || out.videoPath)){
|
|
4473
|
+
out.videos=[out.youtubeVideoId ? {youtubeVideoId:out.youtubeVideoId} : {videoPath:out.videoPath}]
|
|
4474
|
+
.concat(r.extraVideoIds.map(id=>({youtubeVideoId:id})));
|
|
4475
|
+
delete out.youtubeVideoId; delete out.videoPath;
|
|
4476
|
+
}
|
|
4311
4477
|
out.longHeadlines=r.longText?[String(r.longText)]:[];
|
|
4312
4478
|
if(logo) out.logoImages=[logo];
|
|
4313
4479
|
/* 動画広告のロゴは v23 では任意。ローカルパスからの作成は画像広告に絞る
|
|
@@ -5161,7 +5327,9 @@ function applySequentialParamCodes(){
|
|
|
5161
5327
|
const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
5162
5328
|
if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
|
|
5163
5329
|
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
5164
|
-
mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0');
|
|
5330
|
+
mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0');
|
|
5331
|
+
/* この計画で新規登録した番号になったら、その素材のパスも入れる */
|
|
5332
|
+
if(sc.field==='imageNo') applyLocalMediaPaths(it, it.a[sc.field]); }); });
|
|
5165
5333
|
toast((rows.length-1)+'件の'+lbl+'に連番を採番しました('+prefix+(startNum+1)+'〜)');
|
|
5166
5334
|
}
|
|
5167
5335
|
}
|
|
@@ -5446,23 +5614,112 @@ function renderDrawer(){
|
|
|
5446
5614
|
/* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
|
|
5447
5615
|
const dcol=colDefs('ads').find(c=>c.field===drawerTarget.f); const fieldName=dcol?dcol.label:'';
|
|
5448
5616
|
let curNo=''; const fa=findAd(drawerTarget.u); if(fa) curNo=String(fa.a[drawerTarget.f]||'');
|
|
5449
|
-
|
|
5617
|
+
/* 複数選択できる列(Google の動画広告)。押すたびに ON/OFF し、選んだ順に並ぶ。先頭が代表 */
|
|
5618
|
+
const multiMax = (dcol && dcol.multiMax && fa) ? dcol.multiMax(fa.a) : 0;
|
|
5619
|
+
const curList = multiMax ? splitNos(getVar({kind:'a',a:fa.a,g:fa.g,c:fa.c}, colIndex(drawerTarget.f,'ads'), 'ads')) : (curNo?[curNo]:[]);
|
|
5620
|
+
const orderMark = no=>{ const i=curList.indexOf(String(no)); if(!multiMax || i<0) return '';
|
|
5621
|
+
return '<span class="pill"'+(i===0?' title="広告名・キャンペーン名はこの番号から付きます"':'')+'>'+(i===0?'1 代表':String(i+1))+'</span>'; };
|
|
5622
|
+
tgtEl.textContent='入力先: '+fieldName+(curList.length?(' / 現在 '+curList.join(', ')):' / 未設定')
|
|
5623
|
+
+ (multiMax?('(押すたびに追加/解除・最大'+multiMax+'本・先頭が代表)'):'');
|
|
5450
5624
|
/* S10: 参照テーブルは列記述子の refTable から引く。値の見せ方は refTables[key] (label/drawerLabel/dropEmpty)。 */
|
|
5451
5625
|
const rkey=(dcol&&dcol.refTable) || (drawerTarget.type==='titles'?'titles':'media');
|
|
5452
5626
|
const T=refTableDef(rkey)||{}, store=st.refs[rkey]||{};
|
|
5453
5627
|
const single=!!(T.valueFields&&T.valueFields.length===1);
|
|
5454
|
-
|
|
5628
|
+
/* 素材の一覧は **その行の形式に合うものだけ** 出す。動画行に画像しか持たない番号を
|
|
5629
|
+
並べても選べないため。形式ごとの参照キー (YDA=card/sq/video / Meta・Google=image/video
|
|
5630
|
+
/ TikTok=video) は媒体定義の mediaFormats が持つので、コアは mediaKey だけを見る。 */
|
|
5631
|
+
const isMedia = rkey==='media';
|
|
5632
|
+
const fmt = (isMedia && fa) ? pf().fmtOf(fa.a) : null;
|
|
5633
|
+
const hasFmt = v => !fmt || !fmt.mediaKey || String((v||{})[fmt.mediaKey]||'').trim()!=='';
|
|
5634
|
+
titleEl.textContent = single ? ((T.label||'')+'一覧(文言ライブラリ)')
|
|
5635
|
+
: ((T.countLabel||T.label||'')+' 一覧'+(fmt?('('+fmt.id+')'):''));
|
|
5455
5636
|
let html='';
|
|
5456
5637
|
refSortedKeys({keyKind:'number'},store).forEach(no=>{
|
|
5457
5638
|
const v=store[no];
|
|
5458
5639
|
if(T.dropEmpty && String(v==null?'':v).trim()==='') return;
|
|
5640
|
+
if(isMedia && !hasFmt(v)) return;
|
|
5459
5641
|
if(q && !refSearchStrings(T,no,v).some(s=>s.includes(q))) return;
|
|
5460
5642
|
const txt = T.drawerLabel ? T.drawerLabel(v) : esc(String(v==null?'':v));
|
|
5461
|
-
html+='<div class="ddrow'+(String(no)
|
|
5643
|
+
html+='<div class="ddrow'+(curList.indexOf(String(no))>=0?' cur':'')+'" data-no="'+esc(no)+'">'
|
|
5462
5644
|
+(single?'':thumbImgHtml(no))+'<span class="ddno">'+esc(no)+'</span>'
|
|
5463
|
-
+(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
|
|
5645
|
+
+orderMark(no)+(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
|
|
5646
|
+
});
|
|
5647
|
+
/* この計画で新しく登録した素材。**まだ媒体に上がっていないので mediaID が無く、refs には入れない**
|
|
5648
|
+
(空の ID を入れると「登録済み」と誤判定され、CLI のアップロードが走らない)。
|
|
5649
|
+
一覧には出して、2行目以降でも同じ番号を選べるようにする。 */
|
|
5650
|
+
if(isMedia) pendingMediaNos().forEach(no=>{
|
|
5651
|
+
if(q && !String(no).includes(q)) return;
|
|
5652
|
+
const lm=((st.project&&st.project.localMedia)||{})[no];
|
|
5653
|
+
/* 種類 (動画 / 画像) が行と違う素材は出さない */
|
|
5654
|
+
if(lm && fmt && ((fmt.apiMediaKind==='VIDEO') !== !!lm.video)) return;
|
|
5655
|
+
const thumb=lm ? (lm.thumb||lm.image) : '';
|
|
5656
|
+
html+='<div class="ddrow'+(curList.indexOf(String(no))>=0?' cur':'')+'" data-no="'+esc(no)+'">'
|
|
5657
|
+
+(thumb?'<img class="ddthumb" data-localthumb="'+esc(thumb)+'" alt="">':'')+orderMark(no)
|
|
5658
|
+
+'<span class="ddno">'+esc(no)+'</span>'
|
|
5659
|
+
+'<span class="idpill warn">🆕未登録</span>'
|
|
5660
|
+
+'<span class="ddtx">'+(lm&&lm.file?esc(lm.file)+' ':'')+'この計画で追加(入稿時にアップロードします)</span></div>';
|
|
5661
|
+
});
|
|
5662
|
+
listEl.innerHTML=seqRowHtml()+(html||'<div class="empty" style="padding:24px">該当なし</div>')
|
|
5663
|
+
+ ((isMedia && newMediaFields(fmt)) ? newMediaRowHtml() : '');
|
|
5664
|
+
hydrateLocalThumbs(listEl);
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
/** サイドバー末尾の1行。**素材の置き場所が無いと登録できない**ので、
|
|
5668
|
+
未接続のときは登録ではなく接続を促す(ヘッダーに接続ボタンは無く、履歴ビューの奥にある)。 */
|
|
5669
|
+
/** 複数行を選んでいるとき、サイドバーの先頭に「連番」を出す (ツールバーの 🔢 連番 と同じ処理) */
|
|
5670
|
+
function seqRowHtml(){
|
|
5671
|
+
const btn=document.getElementById('btnSeqParam');
|
|
5672
|
+
if(!btn || btn.style.display==='none') return '';
|
|
5673
|
+
const sc=seqColInfo(); if(!sc) return '';
|
|
5674
|
+
return '<div class="ddrow ddseq" data-seq="1"><span class="ddno">🔢</span>'
|
|
5675
|
+
+'<span class="ddtx">選択した行の'+esc(sc.label)+'を連番にする<span class="sm" style="color:var(--faint)"> 先頭のセルの番号から</span></span></div>';
|
|
5676
|
+
}
|
|
5677
|
+
function newMediaRowHtml(){
|
|
5678
|
+
if(!fsSupported)
|
|
5679
|
+
return '<div class="ddrow ddnew" style="cursor:default;color:var(--faint)">'
|
|
5680
|
+
+ '<span class="ddno">—</span><span class="ddtx">このブラウザでは素材を登録できません'
|
|
5681
|
+
+ '(Chrome 系で開いてください)</span></div>';
|
|
5682
|
+
/* 保存フォルダの接続は「+ 新規登録」のモーダル (openNewMediaWizard) の中で案内する */
|
|
5683
|
+
return '<div class="ddrow ddnew" data-newmedia="1"><span class="ddno">+</span>'
|
|
5684
|
+
+ '<span class="ddtx">新規登録(手元のファイルから)'
|
|
5685
|
+
+ (dirHandle ? '<span class="sm" style="color:var(--faint)"> 保存先: '+esc(dirHandle.name)+'/</span>' : '')
|
|
5686
|
+
+ '</span></div>';
|
|
5687
|
+
}
|
|
5688
|
+
|
|
5689
|
+
/** 新規登録で使う**パス列**。素材を置いてもパスを入れる先が無い媒体・形式では登録させない
|
|
5690
|
+
(入れ先が無いまま番号だけ入ると、入稿時にアップロード元が見つからない)。
|
|
5691
|
+
列は field 名で引く — 媒体ごとに名前が違う (YDA/Meta=thumbnailPath / TikTok=coverImagePath)。 */
|
|
5692
|
+
function newMediaFields(fmt){
|
|
5693
|
+
if(!fmt) return null;
|
|
5694
|
+
const has=f=>colDefs('ads').some(c=>c.field===f);
|
|
5695
|
+
if(fmt.apiMediaKind==='VIDEO'){
|
|
5696
|
+
if(!has('videoPath')) return null;
|
|
5697
|
+
const thumb=has('thumbnailPath')?'thumbnailPath':(has('coverImagePath')?'coverImagePath':'');
|
|
5698
|
+
return thumb ? {video:'videoPath', thumb} : null; // サムネの置き場が無ければ登録させない
|
|
5699
|
+
}
|
|
5700
|
+
return has('imagePath') ? {image:'imagePath'} : null;
|
|
5701
|
+
}
|
|
5702
|
+
|
|
5703
|
+
/** 計画内で使われているが参照表に無い管理番号 = 新規登録してまだ上げていない素材。 */
|
|
5704
|
+
function pendingMediaNos(){
|
|
5705
|
+
const store=st.refs.media||{}, seen=new Set();
|
|
5706
|
+
st.campaigns.forEach(c=>(c.adGroups||[]).forEach(g=>(g.ads||[]).forEach(a=>{
|
|
5707
|
+
const no=String(a.imageNo||'').trim();
|
|
5708
|
+
if(no!=='' && !store[no]) seen.add(no);
|
|
5709
|
+
})));
|
|
5710
|
+
Object.keys((st.project&&st.project.localMedia)||{}).forEach(no=>{ if(!store[no]) seen.add(no); });
|
|
5711
|
+
return [...seen].sort((a,b)=>Number(a)-Number(b));
|
|
5712
|
+
}
|
|
5713
|
+
|
|
5714
|
+
/** 空き番号 = 参照表と計画内で使われている番号の最大値 +1。
|
|
5715
|
+
⚠ `Math.max(...nums)` は使わない — 引数がスタックに積まれるので、
|
|
5716
|
+
参照表が大きいアカウントで `Maximum call stack size exceeded` になる。 */
|
|
5717
|
+
function nextMediaNo(){
|
|
5718
|
+
let max=0;
|
|
5719
|
+
Object.keys(st.refs.media||{}).concat(pendingMediaNos()).forEach(k=>{
|
|
5720
|
+
const n=Number(k); if(Number.isFinite(n) && n>max) max=n;
|
|
5464
5721
|
});
|
|
5465
|
-
|
|
5722
|
+
return String(max+1);
|
|
5466
5723
|
}
|
|
5467
5724
|
/* ============ 素材サムネ (#157) ============
|
|
5468
5725
|
媒体側の画像URLはどれも一時URL・CORS 制限つきで、プランナーから直接は表示できない。
|
|
@@ -5534,15 +5791,44 @@ function closeThumbZoom(){
|
|
|
5534
5791
|
|
|
5535
5792
|
function thumbZoomKey(ev){ if(ev.key==='Escape') closeThumbZoom(); }
|
|
5536
5793
|
|
|
5794
|
+
/** 複数選択の列で番号を ON/OFF する。範囲選択中は、押したセルの状態に揃えて選択範囲の同じ列へ。
|
|
5795
|
+
閉じずに描き直す(続けて2本目・3本目を選べるように) */
|
|
5796
|
+
function drawerToggleMulti(col, no){
|
|
5797
|
+
const ci=colIndex(col.field,'ads'), fa=findAd(drawerTarget.u); if(!fa||ci<0) return;
|
|
5798
|
+
const self={kind:'a',a:fa.a,g:fa.g,c:fa.c};
|
|
5799
|
+
const adding = splitNos(getVar(self,ci,'ads')).indexOf(String(no))<0;
|
|
5800
|
+
const rg=selRange(), items=[];
|
|
5801
|
+
if(rg && rg.c0<=ci && rg.c1>=ci){ const vis=flatVisible(); for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.kind==='a') items.push(it); } }
|
|
5802
|
+
if(!items.some(it=>it.a===fa.a)) items.splice(0, items.length, self);
|
|
5803
|
+
let applied=0, full=0;
|
|
5804
|
+
mutate(()=>items.forEach(it=>{
|
|
5805
|
+
const max=col.multiMax(it.a), l=splitNos(getVar(it,ci,'ads')), i=l.indexOf(String(no));
|
|
5806
|
+
if(!max) return;
|
|
5807
|
+
if(adding && i<0){ if(l.length>=max){ full++; return; } l.push(String(no)); }
|
|
5808
|
+
else if(!adding && i>=0) l.splice(i,1);
|
|
5809
|
+
else return;
|
|
5810
|
+
const wasFirst=String(it.a.imageNo==null?'':it.a.imageNo);
|
|
5811
|
+
setVar(it,ci,l.join(', '),'ads');
|
|
5812
|
+
/* 代表が新規登録の素材に変わったら、その素材のパスも入れる */
|
|
5813
|
+
if(String(it.a.imageNo)!==wasFirst) applyLocalMediaPaths(it, it.a.imageNo);
|
|
5814
|
+
applied++;
|
|
5815
|
+
}));
|
|
5816
|
+
toast('番号 '+no+' を'+applied+'セルで'+(adding?'追加':'解除')+(full?('('+full+'セルは上限のため追加せず)'):''));
|
|
5817
|
+
renderDrawer();
|
|
5818
|
+
}
|
|
5537
5819
|
function drawerPick(no){
|
|
5538
5820
|
if(!drawerTarget) return;
|
|
5821
|
+
const mcol=colDefs('ads').find(c=>c.field===drawerTarget.f), mfa=findAd(drawerTarget.u);
|
|
5822
|
+
if(mcol && mcol.multiMax && mfa && mcol.multiMax(mfa.a)){ drawerToggleMulti(mcol, no); return; }
|
|
5539
5823
|
const f=drawerTarget.f, num=/^\d+$/.test(no)?Number(no):no;
|
|
5540
5824
|
// S3: 旧 [1] / [2,3] リテラルを撤去。同じ▼ドロワー表示モードを持つ refcombo 列を記述子から算出する
|
|
5541
5825
|
const compat = compatColsFor(drawerTarget.type, sheetTab);
|
|
5542
5826
|
const rg=selRange(); let applied=0;
|
|
5543
5827
|
mutate(()=>{
|
|
5544
|
-
|
|
5545
|
-
if(
|
|
5828
|
+
/* この計画で新規登録した番号なら、番号に紐づくローカル素材のパスも一緒に入れる */
|
|
5829
|
+
const withPaths=item=>{ if(drawerTarget.type==='media') applyLocalMediaPaths(item, no); };
|
|
5830
|
+
if(rg){ const vis=flatVisible(); for(let rr=rg.r0; rr<=rg.r1; rr++){ const item=vis[rr]; if(!item) continue; for(let cc=rg.c0; cc<=rg.c1; cc++){ if(compat.indexOf(cc)>=0){ setVar(item,cc,num); withPaths(item); applied++; } } } }
|
|
5831
|
+
if(applied===0){ setField(drawerTarget.u,f,num); const fa=findAd(drawerTarget.u); if(fa) withPaths({kind:'a',a:fa.a,g:fa.g,c:fa.c}); applied=1; }
|
|
5546
5832
|
});
|
|
5547
5833
|
toast('番号 '+no+' を'+applied+'セルに入力');
|
|
5548
5834
|
closeDrawer();
|
|
@@ -5842,9 +6128,16 @@ function openModal(cfg){
|
|
|
5842
6128
|
const getVals=()=>{ const o={}; (cfg.fields||[]).forEach(f=>{ const el=document.getElementById(fid(f.key)); if(!el) return; o[f.key]= f.type==='check'? el.checked : el.value; }); return o; };
|
|
5843
6129
|
const setVal=(k,val)=>{ const el=document.getElementById(fid(k)); if(el) el.value=val; };
|
|
5844
6130
|
const close=()=>root.innerHTML='';
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
root.querySelector('[data-mo="
|
|
6131
|
+
/* onCancel: 閉じたときの後片付け (プレビュー用の ObjectURL の解放など) */
|
|
6132
|
+
const bail=()=>{ close(); if(cfg.onCancel) cfg.onCancel(); };
|
|
6133
|
+
root.querySelector('[data-mo="cancel"]').addEventListener('click',bail);
|
|
6134
|
+
root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) bail(); });
|
|
6135
|
+
root.querySelector('[data-mo="ok"]').addEventListener('click',()=>{
|
|
6136
|
+
const v=getVals();
|
|
6137
|
+
/* validate: 空文字なら通す / メッセージを返したら **閉じずに** 差し戻す */
|
|
6138
|
+
if(cfg.validate){ const msg=cfg.validate(v); if(msg){ toast(msg); return; } }
|
|
6139
|
+
close(); if(cfg.onOk) cfg.onOk(v);
|
|
6140
|
+
});
|
|
5848
6141
|
if(cfg.onMount) cfg.onMount(root, getVals, setVal);
|
|
5849
6142
|
const first=root.querySelector('input,select,textarea'); if(first) first.focus();
|
|
5850
6143
|
}
|
|
@@ -5909,13 +6202,66 @@ let _idbPromise=null;
|
|
|
5909
6202
|
function idb(){ if(!_idbPromise) _idbPromise=idbOpen(IDB_NAME).then(async db=>{ await migrateLegacyIdb(db); return db; }); return _idbPromise; }
|
|
5910
6203
|
async function idbSet(k,v){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=res; tx.onerror=()=>rej(tx.error); }); }
|
|
5911
6204
|
async function idbGet(k){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readonly'); const rq=tx.objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>rej(rq.error); }); }
|
|
5912
|
-
|
|
5913
|
-
|
|
6205
|
+
/* ---- 入稿計画フォルダかどうかの判定 ----
|
|
6206
|
+
このフォルダは 素材/ の置き場所であり、「保存して入稿」で入稿計画ファイルを書き出す先でもある。
|
|
6207
|
+
保存後の入稿手順は `入稿計画/<file>` にある前提で CLI を案内し、CLI は計画ファイルの場所を基準に
|
|
6208
|
+
素材/ を探すので、**別のフォルダに接続すると本実行のアップロードまで誰も気づけない**。
|
|
6209
|
+
そのため 入稿計画/ 以外には接続させない。
|
|
6210
|
+
判定: 目印ファイル (PLANS_MARKER) があるか、フォルダ名が 入稿計画 か。
|
|
6211
|
+
名前で通すのは目印の無い旧配布物のためで、そのときは目印を足して以後は目印で判定する。 */
|
|
6212
|
+
const PLANS_DIR_NAME='入稿計画';
|
|
6213
|
+
const PLANS_MARKER='.ad-planner-plans';
|
|
6214
|
+
async function checkPlansDir(h){
|
|
6215
|
+
try{ await h.getFileHandle(PLANS_MARKER); return {ok:true}; }catch(e){}
|
|
6216
|
+
if(h.name===PLANS_DIR_NAME){
|
|
6217
|
+
try{ const fh=await h.getFileHandle(PLANS_MARKER,{create:true});
|
|
6218
|
+
await writeToHandle(fh,'ad-planner の入稿計画フォルダの目印です。消さないでください。\n'); }catch(e){}
|
|
6219
|
+
return {ok:true};
|
|
6220
|
+
}
|
|
6221
|
+
return {ok:false, reason:'選んだフォルダ「'+h.name+'」は入稿計画フォルダではありません。'
|
|
6222
|
+
+(plannerEnv.plansDir ? '下のパスのフォルダを選んでください' : 'ad-ops スキルの中にある「'+PLANS_DIR_NAME+'」フォルダを選んでください')};
|
|
6223
|
+
}
|
|
6224
|
+
/* ブラウザはフォルダの絶対パスを知る手段が無いので、配信元の adops.sh planner が書いた
|
|
6225
|
+
.planner-env.json から 入稿計画/ の場所を読んで案内に使う。別の方法で開いたときは無い。 */
|
|
6226
|
+
/* 読むのは「+ 新規登録」を押したときだけ。起動時に読むと、ファイルの無い環境 (CI・adops.sh を通さない開き方)
|
|
6227
|
+
で 404 がコンソールエラーとして出て、「起動時に JS エラーが出ない」系のテストが一斉に落ちる */
|
|
6228
|
+
let plannerEnv={}, plannerEnvLoaded=false;
|
|
6229
|
+
async function loadPlannerEnv(){
|
|
6230
|
+
if(plannerEnvLoaded) return; plannerEnvLoaded=true;
|
|
6231
|
+
try{ const r=await fetch('.planner-env.json',{cache:'no-store'}); if(r.ok) plannerEnv=(await r.json())||{}; }catch(e){}
|
|
6232
|
+
}
|
|
6233
|
+
async function adoptPlansDir(h){
|
|
6234
|
+
dirHandle=h; pendingDirHandle=null; await idbSet('dir',h); renderHist(); updateSubmitJsonBtn();
|
|
6235
|
+
}
|
|
6236
|
+
/** 入稿計画フォルダを選んで接続する。**クリック起点で呼ぶ** (ピッカーと権限の再要求はユーザー操作が要る)。
|
|
6237
|
+
入稿計画フォルダでなければ接続しない。fresh=true なら記憶中のフォルダの再許可を飛ばして選び直させる。
|
|
6238
|
+
@returns {{ok:boolean, name?:string, reconnected?:boolean, aborted?:boolean, reason?:string}} */
|
|
6239
|
+
async function pickPlansFolder(fresh){
|
|
6240
|
+
if(!fsSupported) return {ok:false, reason:'このブラウザはフォルダ連携に未対応です(Chrome 系で開いてください)'};
|
|
5914
6241
|
try{
|
|
5915
|
-
if(pendingDirHandle
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
6242
|
+
if(pendingDirHandle && !fresh){
|
|
6243
|
+
const h=pendingDirHandle;
|
|
6244
|
+
const p=await h.requestPermission({mode:'readwrite'});
|
|
6245
|
+
if(p==='granted'){
|
|
6246
|
+
const c=await checkPlansDir(h);
|
|
6247
|
+
if(!c.ok){ pendingDirHandle=null; return {ok:false, reason:c.reason}; } // 記憶していたのが別のフォルダ
|
|
6248
|
+
await adoptPlansDir(h); return {ok:true, name:h.name, reconnected:true};
|
|
6249
|
+
}
|
|
6250
|
+
}
|
|
6251
|
+
const h=await window.showDirectoryPicker({id:'ad-planner-plans', mode:'readwrite'});
|
|
6252
|
+
const c=await checkPlansDir(h);
|
|
6253
|
+
if(!c.ok) return {ok:false, reason:c.reason};
|
|
6254
|
+
await adoptPlansDir(h); return {ok:true, name:h.name};
|
|
6255
|
+
}catch(e){
|
|
6256
|
+
if(e && e.name==='AbortError') return {ok:false, aborted:true};
|
|
6257
|
+
return {ok:false, reason:'フォルダ接続に失敗: '+(e&&e.message)};
|
|
6258
|
+
}
|
|
6259
|
+
}
|
|
6260
|
+
/** 履歴ビューの「📁 保存フォルダに接続」 */
|
|
6261
|
+
async function connectFolder(){
|
|
6262
|
+
const r=await pickPlansFolder(false);
|
|
6263
|
+
if(r.ok) toast(r.reconnected?'保存フォルダに再接続しました':'保存フォルダに接続しました: '+r.name);
|
|
6264
|
+
else if(!r.aborted) toast(r.reason, {sticky:true});
|
|
5919
6265
|
}
|
|
5920
6266
|
/* 入稿計画フォルダ未接続なら書き出しボタンを「接続」ボタンに切替(接続後は書き出し) */
|
|
5921
6267
|
function updateSubmitJsonBtn(){ /* ボタンは『サンプルプラン更新』に用途変更したためラベル切替は無効化 */ }
|
|
@@ -5923,8 +6269,14 @@ async function restoreFolder(){
|
|
|
5923
6269
|
if(!fsSupported) return;
|
|
5924
6270
|
try{ const h=await idbGet('dir'); if(!h) return;
|
|
5925
6271
|
const p=await h.queryPermission({mode:'readwrite'});
|
|
5926
|
-
if(p==='granted'){
|
|
5927
|
-
|
|
6272
|
+
if(p==='granted'){
|
|
6273
|
+
/* 記憶しているのが別のフォルダなら自動接続しない (すでに違うフォルダを選んでいた人のため) */
|
|
6274
|
+
const c=await checkPlansDir(h);
|
|
6275
|
+
if(c.ok){ dirHandle=h; toast('入稿計画フォルダ('+h.name+'/)に自動接続しました'); }
|
|
6276
|
+
else toast('記憶していた保存フォルダ「'+h.name+'」は入稿計画フォルダではないため、接続しませんでした。'
|
|
6277
|
+
+'素材の「+ 新規登録」から選び直してください', {sticky:true});
|
|
6278
|
+
}
|
|
6279
|
+
else pendingDirHandle=h; // 権限失効時はボタン押下(ユーザー操作)で1クリック再要求。判定は再許可のあと
|
|
5928
6280
|
}catch(e){}
|
|
5929
6281
|
if(activeView==='hist') renderHist();
|
|
5930
6282
|
}
|
|
@@ -5934,6 +6286,331 @@ async function writePlanFile(name, text){
|
|
|
5934
6286
|
catch(e){ toast('フォルダ書き込み失敗: '+e.message+'(ダウンロードに切替)'); return false; }
|
|
5935
6287
|
}
|
|
5936
6288
|
|
|
6289
|
+
/* ============================================================
|
|
6290
|
+
レスポンシブ動画の取り込み (#324)
|
|
6291
|
+
------------------------------------------------------------
|
|
6292
|
+
ブラウザは列に書いた「パス文字列」だけではローカル動画を読めないので、ファイルピッカーで
|
|
6293
|
+
選んでもらう。選んだ動画から **0 秒フレームを canvas で抜いてサムネにする**。
|
|
6294
|
+
YDA は動画からサムネを自動生成しないので (本番実測。connectors/yahooSubmission.ts uploadVideo
|
|
6295
|
+
のコメント参照)、入稿には必ずサムネ画像が要る。審査でサムネだけ落ちることがあるため、
|
|
6296
|
+
抽出位置(秒)を変えてやり直せるようにしている。
|
|
6297
|
+
|
|
6298
|
+
素材は入稿計画フォルダ配下の 素材/ に置き、列には **計画JSON基準の相対パス** を入れる
|
|
6299
|
+
(CLI の resolveLocalMediaPath が plan ファイルのディレクトリを基準に解決する)。
|
|
6300
|
+
接続フォルダの外にある動画はそこへコピーする — パスを自動で入れられないと
|
|
6301
|
+
「ファイルを選んだら両方埋まる」が成立しないため。
|
|
6302
|
+
============================================================ */
|
|
6303
|
+
const MEDIA_SUBDIR='素材';
|
|
6304
|
+
|
|
6305
|
+
/** 動画の指定秒のフレームを JPEG Blob で返す。
|
|
6306
|
+
sec=0 は seek せず loadeddata のフレームを使う (currentTime=0 の代入では seeked が
|
|
6307
|
+
発火しないブラウザがあり、待ち続けて固まるため)。 */
|
|
6308
|
+
function grabVideoFrame(file, sec){
|
|
6309
|
+
return new Promise((resolve, reject)=>{
|
|
6310
|
+
const url=URL.createObjectURL(file);
|
|
6311
|
+
const v=document.createElement('video');
|
|
6312
|
+
v.preload='auto'; v.muted=true; v.playsInline=true;
|
|
6313
|
+
let settled=false;
|
|
6314
|
+
const fail=e=>{ if(settled) return; settled=true; URL.revokeObjectURL(url); reject(e instanceof Error?e:new Error('動画を読み込めません: '+file.name)); };
|
|
6315
|
+
const shoot=()=>{
|
|
6316
|
+
if(settled) return;
|
|
6317
|
+
try{
|
|
6318
|
+
const c=document.createElement('canvas');
|
|
6319
|
+
c.width=v.videoWidth; c.height=v.videoHeight;
|
|
6320
|
+
if(!c.width||!c.height) return fail(new Error('動画のサイズを取得できません: '+file.name));
|
|
6321
|
+
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
|
|
6322
|
+
c.toBlob(b=>{ if(settled) return; settled=true; URL.revokeObjectURL(url);
|
|
6323
|
+
b?resolve({blob:b, width:c.width, height:c.height, duration:v.duration||0}):reject(new Error('サムネを作れません: '+file.name)); },
|
|
6324
|
+
'image/jpeg', 0.92);
|
|
6325
|
+
}catch(e){ fail(e); }
|
|
6326
|
+
};
|
|
6327
|
+
v.onerror=()=>fail(new Error('動画を読み込めません(対応していない形式かもしれません): '+file.name));
|
|
6328
|
+
v.onseeked=shoot;
|
|
6329
|
+
v.onloadeddata=()=>{
|
|
6330
|
+
const want=Math.max(0, Number(sec)||0);
|
|
6331
|
+
const max=Math.max(0,(v.duration||0)-0.05);
|
|
6332
|
+
const at=Math.min(want, max);
|
|
6333
|
+
if(at<=0) shoot(); else { try{ v.currentTime=at; }catch(e){ fail(e); } }
|
|
6334
|
+
};
|
|
6335
|
+
v.src=url;
|
|
6336
|
+
});
|
|
6337
|
+
}
|
|
6338
|
+
|
|
6339
|
+
/** 入稿計画フォルダ配下 素材/ にファイルを書き、計画JSON基準の相対パスを返す */
|
|
6340
|
+
async function writeMediaFile(name, data){
|
|
6341
|
+
if(!dirHandle) throw new Error('入稿計画フォルダに接続していません(素材の「+ 新規登録」から保存フォルダを選んでください)');
|
|
6342
|
+
const dir=await dirHandle.getDirectoryHandle(MEDIA_SUBDIR,{create:true});
|
|
6343
|
+
const fh=await dir.getFileHandle(name,{create:true});
|
|
6344
|
+
const w=await fh.createWritable(); await w.write(data); await w.close();
|
|
6345
|
+
return MEDIA_SUBDIR+'/'+name;
|
|
6346
|
+
}
|
|
6347
|
+
/** 既に接続フォルダの中にある動画はコピーせず、その相対パスをそのまま使う */
|
|
6348
|
+
async function videoRelPath(handle, file){
|
|
6349
|
+
try{
|
|
6350
|
+
const seg=await dirHandle.resolve(handle);
|
|
6351
|
+
if(seg&&seg.length) return seg.join('/');
|
|
6352
|
+
}catch(e){}
|
|
6353
|
+
return await writeMediaFile(safeFileName(file.name), file);
|
|
6354
|
+
}
|
|
6355
|
+
/** ファイル名に使えない文字を落とす (広告名や日本語はそのまま通す) */
|
|
6356
|
+
function safeFileName(n){ return String(n||'file').replace(/[\/\\:*?"<>|]/g,'_'); }
|
|
6357
|
+
|
|
6358
|
+
/* ---- サイドバーの「+ 新規登録」 ----
|
|
6359
|
+
画像も動画も**番号セルの右サイドバーから**登録する (ツールバーに別動線を作らない)。
|
|
6360
|
+
参照表に無い番号を行に入れ、素材は 素材/ へ置いてパス列に入れるだけ。mediaID は
|
|
6361
|
+
入稿の直前に CLI がアップロードして解決するので、ここでは refs.media に触らない。
|
|
6362
|
+
|
|
6363
|
+
⚠ showOpenFilePicker / showDirectoryPicker は**ユーザー操作の直後**にしか開けない
|
|
6364
|
+
(await を挟むと失効する。フォルダを選んだ直後に続けてファイル選択を開くと失敗する)。
|
|
6365
|
+
そのため「① 保存フォルダ → ② 素材」をモーダルのボタンで1クリックずつ進めてもらい、
|
|
6366
|
+
番号の確認はファイルを選び終わってから出す。 */
|
|
6367
|
+
async function openNewMediaWizard(){
|
|
6368
|
+
if(!drawerTarget || !drawerTarget.u){ return; }
|
|
6369
|
+
const target={u:drawerTarget.u, f:drawerTarget.f}; // await の前に確定させる
|
|
6370
|
+
await loadPlannerEnv();
|
|
6371
|
+
/* ダイアログを開いている間に drawer が閉じられると drawerTarget が null になる。
|
|
6372
|
+
**対象の行はここで確定させて持ち回る** (await をまたいで drawerTarget を読まない)。 */
|
|
6373
|
+
const fa=findAd(target.u); if(!fa) return;
|
|
6374
|
+
const fmt=pf().fmtOf(fa.a);
|
|
6375
|
+
const isVideo=fmt && fmt.apiMediaKind==='VIDEO';
|
|
6376
|
+
const kind=isVideo?'動画':'画像';
|
|
6377
|
+
if(!fsSupported){ toast('このブラウザはローカルフォルダ連携に未対応です(Chrome 系で開いてください)'); return; }
|
|
6378
|
+
|
|
6379
|
+
const root=document.getElementById('modalRoot');
|
|
6380
|
+
let error='', busy=false;
|
|
6381
|
+
const close=()=>{ root.innerHTML=''; };
|
|
6382
|
+
/* キャンセルか、入稿計画フォルダを選んで素材まで進むまで閉じない (オーバーレイのクリックでも閉じない) */
|
|
6383
|
+
const render=()=>{
|
|
6384
|
+
const ok=!!dirHandle;
|
|
6385
|
+
const step1 = ok
|
|
6386
|
+
? '<div class="nmstep done"><div class="nmhead"><span class="nmno">✓</span>保存フォルダ</div>'
|
|
6387
|
+
+'<div class="nmbody"><span class="mono">'+esc(dirHandle.name)+'/</span>'
|
|
6388
|
+
+'<button class="act ghost" data-nm="dirfresh">変更</button></div></div>'
|
|
6389
|
+
: '<div class="nmstep cur"><div class="nmhead"><span class="nmno">1</span>保存フォルダ('+PLANS_DIR_NAME+')を選ぶ'
|
|
6390
|
+
+'<span class="pill">初期設定(初回のみ)</span></div>'
|
|
6391
|
+
+'<div class="nmbody"><div class="hint">最初の1回だけの設定です。次回からは自動でつながります。'
|
|
6392
|
+
+'素材('+MEDIA_SUBDIR+'/)と入稿計画ファイルの保存先になります。</div>'
|
|
6393
|
+
+(plannerEnv.plansDir
|
|
6394
|
+
? '<div class="nmpath"><span class="mono">'+esc(plannerEnv.plansDir)+'</span>'
|
|
6395
|
+
+'<button class="act ghost" data-nm="copypath">パスをコピー</button></div>'
|
|
6396
|
+
+'<div class="hint">「保存フォルダを選ぶ」で開く画面で <b>Cmd+Shift+G</b>(Windows はアドレス欄)にこのパスを貼り付けて移動し、そのまま「選択」を押してください</div>'
|
|
6397
|
+
: '<div class="hint">ad-ops スキルの中にある「'+PLANS_DIR_NAME+'」フォルダを選んでください</div>')
|
|
6398
|
+
+(pendingDirHandle
|
|
6399
|
+
? '<button class="act primary" data-nm="dir">前回のフォルダ('+esc(pendingDirHandle.name)+')を再許可する</button>'
|
|
6400
|
+
+'<button class="act ghost" data-nm="dirfresh">別のフォルダを選ぶ</button>'
|
|
6401
|
+
: '<button class="act primary" data-nm="dir">保存フォルダを選ぶ</button>')
|
|
6402
|
+
+'</div></div>';
|
|
6403
|
+
const step2 = '<div class="nmstep '+(ok?'cur':'wait')+'"><div class="nmhead"><span class="nmno">2</span>'+kind+'を選ぶ</div>'
|
|
6404
|
+
+'<div class="nmbody"><div class="hint">'+(isVideo
|
|
6405
|
+
? '選んだ動画から 0 秒目のフレームをサムネイルとして切り出します(次の画面で確認・変更できます)'
|
|
6406
|
+
: '選んだ画像を '+MEDIA_SUBDIR+'/ に置き、入稿時にアップロードします')
|
|
6407
|
+
+'。<b>複数選べます</b>(選択中の行に上から順に入ります)</div>'
|
|
6408
|
+
+'<button class="act primary" data-nm="file"'+(ok?'':' disabled')+'>'+kind+'を選ぶ</button>'
|
|
6409
|
+
+(ok?'':'<span class="sm" style="color:var(--faint)"> 先に保存フォルダを選んでください</span>')
|
|
6410
|
+
+'</div></div>';
|
|
6411
|
+
root.innerHTML='<div class="overlay"><div class="modal" id="newMediaWizard"><h3>'+kind+'を新規登録</h3>'
|
|
6412
|
+
+'<div class="msub">保存フォルダを決めてから、'+kind+'を選びます</div>'
|
|
6413
|
+
+'<div class="mbody">'+(error?'<div class="callout danger" data-nm-error>'+esc(error)+'</div>':'')+step1+step2+'</div>'
|
|
6414
|
+
+'<div class="mfoot"><button class="act" data-nm="cancel">キャンセル</button></div></div></div>';
|
|
6415
|
+
root.querySelector('[data-nm="cancel"]').addEventListener('click', close);
|
|
6416
|
+
const copyBtn=root.querySelector('[data-nm="copypath"]');
|
|
6417
|
+
if(copyBtn) copyBtn.addEventListener('click', async ()=>{
|
|
6418
|
+
try{ await navigator.clipboard.writeText(plannerEnv.plansDir); copyBtn.textContent='コピーしました'; }
|
|
6419
|
+
catch(e){ toast('コピーできませんでした。パスを選択してコピーしてください'); }
|
|
6420
|
+
});
|
|
6421
|
+
root.querySelectorAll('[data-nm="dir"],[data-nm="dirfresh"]').forEach(b=>b.addEventListener('click', async ()=>{
|
|
6422
|
+
if(busy) return; busy=true;
|
|
6423
|
+
let r;
|
|
6424
|
+
try{ r=await pickPlansFolder(b.dataset.nm==='dirfresh'); }
|
|
6425
|
+
catch(e){ r={ok:false, reason:'フォルダ接続に失敗: '+(e&&e.message)}; }
|
|
6426
|
+
finally{ busy=false; }
|
|
6427
|
+
console.info('[planner] 保存フォルダの選択:', r);
|
|
6428
|
+
/* 黙って元の画面に戻すと「選んだのに進まない」ように見える。キャンセルも理由を出す
|
|
6429
|
+
(フォルダ選択のあとに出る「編集を許可」を閉じてもキャンセル扱いになる) */
|
|
6430
|
+
if(r.ok) error='';
|
|
6431
|
+
else error = r.aborted
|
|
6432
|
+
? 'フォルダの選択、または続けて出る「ファイルの編集を許可」がキャンセルされました。もう一度「保存フォルダを選ぶ」を押し、最後に「編集を許可」を押してください'
|
|
6433
|
+
: r.reason;
|
|
6434
|
+
render();
|
|
6435
|
+
if(r.ok){ try{ renderDrawer(); }catch(e){ recordError('drawer', e, 'サイドバーの再描画'); } }
|
|
6436
|
+
}));
|
|
6437
|
+
const fileBtn=root.querySelector('[data-nm="file"]');
|
|
6438
|
+
if(fileBtn) fileBtn.addEventListener('click', ()=>{
|
|
6439
|
+
if(!dirHandle || busy) return;
|
|
6440
|
+
/* ここで await を挟まずにピッカーを開く (ユーザー操作の有効期限内に呼ぶ) */
|
|
6441
|
+
pickNewMediaFiles(isVideo).then(handles=>{
|
|
6442
|
+
if(!handles.length) return; // ピッカーをキャンセル → モーダルに戻る
|
|
6443
|
+
close();
|
|
6444
|
+
continueNewMedia({handles, isVideo, fmt, target});
|
|
6445
|
+
});
|
|
6446
|
+
});
|
|
6447
|
+
};
|
|
6448
|
+
render();
|
|
6449
|
+
}
|
|
6450
|
+
/** 素材ファイルを選ばせる (複数可)。キャンセルなら [] */
|
|
6451
|
+
async function pickNewMediaFiles(isVideo){
|
|
6452
|
+
try{
|
|
6453
|
+
const handles = isVideo
|
|
6454
|
+
? await window.showOpenFilePicker({ id:'ad-planner-video', multiple:true,
|
|
6455
|
+
types:[{description:'動画', accept:{'video/mp4':['.mp4'],'video/quicktime':['.mov']}}] })
|
|
6456
|
+
: await window.showOpenFilePicker({ id:'ad-planner-image', multiple:true,
|
|
6457
|
+
types:[{description:'画像', accept:{'image/jpeg':['.jpg','.jpeg'],'image/png':['.png']}}] });
|
|
6458
|
+
return handles || [];
|
|
6459
|
+
}catch(e){ return []; } // ユーザーがキャンセル
|
|
6460
|
+
}
|
|
6461
|
+
|
|
6462
|
+
/* ---- この計画で新規登録した素材 (管理番号 → ローカルパス) ----
|
|
6463
|
+
パスは「登録した行」ではなく**番号**に持つ。こうしないと、同じ番号を別の行で選んだときに
|
|
6464
|
+
パスが付かず、一覧にもサムネを出せない。mediaID がまだ無いので refs.media には入れない
|
|
6465
|
+
(空の ID を入れると「登録済み」と誤判定され、CLI のアップロードが走らない)。
|
|
6466
|
+
st.project に置くので保存 JSON と往復する (入稿計画JSONには出ない)。 */
|
|
6467
|
+
function localMedia(){
|
|
6468
|
+
if(!st.project.localMedia || typeof st.project.localMedia!=='object') st.project.localMedia={};
|
|
6469
|
+
return st.project.localMedia;
|
|
6470
|
+
}
|
|
6471
|
+
/** 番号に登録したローカル素材のパスを、その行のパス列へ入れる (行の形式と素材の種類が合うときだけ) */
|
|
6472
|
+
function applyLocalMediaPaths(item, no){
|
|
6473
|
+
if(!item || item.kind!=='a') return;
|
|
6474
|
+
const e=localMedia()[String(no)]; if(!e) return;
|
|
6475
|
+
const F=newMediaFields(pf().fmtOf(item.a)); if(!F) return;
|
|
6476
|
+
if(F.video && e.video){ setVarByField(item,'ads',F.video,e.video); setVarByField(item,'ads',F.thumb,e.thumb||''); }
|
|
6477
|
+
else if(F.image && e.image){ setVarByField(item,'ads',F.image,e.image); }
|
|
6478
|
+
}
|
|
6479
|
+
/* ローカル素材のサムネ。接続フォルダから読んで ObjectURL にする (登録直後は手元の Blob をそのまま使う) */
|
|
6480
|
+
const localThumbUrls=new Map();
|
|
6481
|
+
async function localFileUrl(rel){
|
|
6482
|
+
if(localThumbUrls.has(rel)) return localThumbUrls.get(rel);
|
|
6483
|
+
if(!dirHandle || !rel) return '';
|
|
6484
|
+
try{
|
|
6485
|
+
const parts=String(rel).split('/'); let d=dirHandle;
|
|
6486
|
+
for(let i=0;i<parts.length-1;i++) d=await d.getDirectoryHandle(parts[i]);
|
|
6487
|
+
const f=await (await d.getFileHandle(parts[parts.length-1])).getFile();
|
|
6488
|
+
const u=URL.createObjectURL(f); localThumbUrls.set(rel,u); return u;
|
|
6489
|
+
}catch(e){ return ''; }
|
|
6490
|
+
}
|
|
6491
|
+
function hydrateLocalThumbs(root){
|
|
6492
|
+
root.querySelectorAll('img[data-localthumb]').forEach(async img=>{
|
|
6493
|
+
const u=await localFileUrl(img.dataset.localthumb);
|
|
6494
|
+
if(u) img.src=u; else img.remove();
|
|
6495
|
+
});
|
|
6496
|
+
}
|
|
6497
|
+
|
|
6498
|
+
/** 新規登録の対象行。範囲選択中なら「選択範囲の広告行のうち、同じ種類 (動画 / 画像) の行」を上から順に。
|
|
6499
|
+
選択が無ければ ▾ を押した行だけ。 */
|
|
6500
|
+
function newMediaTargets(target, isVideo){
|
|
6501
|
+
const rg=selRange(), vis=flatVisible(), out=[];
|
|
6502
|
+
if(rg && rg.r1>rg.r0){
|
|
6503
|
+
for(let r=rg.r0;r<=rg.r1;r++){
|
|
6504
|
+
const it=vis[r]; if(!it || it.kind!=='a') continue;
|
|
6505
|
+
const f=pf().fmtOf(it.a); if(!f || (f.apiMediaKind==='VIDEO')!==isVideo) continue;
|
|
6506
|
+
out.push({u:it.a.uid, row:r+1});
|
|
6507
|
+
}
|
|
6508
|
+
}
|
|
6509
|
+
if(!out.length){ const r=vis.findIndex(it=>it && it.kind==='a' && it.a.uid===target.u); out.push({u:target.u, row:r+1}); }
|
|
6510
|
+
return out;
|
|
6511
|
+
}
|
|
6512
|
+
|
|
6513
|
+
/** 選んだ素材から (動画なら 0 秒サムネを抜いて) 番号と行を確認するモーダルへ進む */
|
|
6514
|
+
async function continueNewMedia({handles, isVideo, fmt, target}){
|
|
6515
|
+
const items=[];
|
|
6516
|
+
for(const handle of handles){
|
|
6517
|
+
const file=await handle.getFile();
|
|
6518
|
+
let frame=null;
|
|
6519
|
+
if(isVideo){
|
|
6520
|
+
try{ frame=await grabVideoFrame(file, 0); }
|
|
6521
|
+
catch(e){ recordError('videoFrame', e, '動画サムネの抽出'); toast('動画サムネを作れませんでした('+file.name+'): '+e.message); return; }
|
|
6522
|
+
}
|
|
6523
|
+
items.push({handle, file, frame, sec:0});
|
|
6524
|
+
}
|
|
6525
|
+
if(!items.length) return;
|
|
6526
|
+
openNewMediaModal({items, isVideo, fmt, target, targets:newMediaTargets(target, isVideo)});
|
|
6527
|
+
}
|
|
6528
|
+
|
|
6529
|
+
/** 管理番号と入れる行を決めるモーダル。動画はサムネのプレビューと抽出位置もここで出す。
|
|
6530
|
+
番号は空き番号からの連番を初期値に出し、書き換えられる。行より素材が多い分は一覧にだけ登録する。 */
|
|
6531
|
+
function openNewMediaModal(ctx){
|
|
6532
|
+
const n0=Number(nextMediaNo());
|
|
6533
|
+
ctx.items.forEach((it,i)=>{ it.no=String(n0+i); it.row=ctx.targets[i]||null; });
|
|
6534
|
+
const urls=ctx.items.map(it=>URL.createObjectURL(ctx.isVideo?it.frame.blob:it.file));
|
|
6535
|
+
const revoke=()=>urls.forEach(u=>{ try{ URL.revokeObjectURL(u); }catch(e){} });
|
|
6536
|
+
const kind=ctx.isVideo?'動画':'画像';
|
|
6537
|
+
const root=document.getElementById('modalRoot');
|
|
6538
|
+
const close=()=>{ root.innerHTML=''; };
|
|
6539
|
+
const rowsHtml=()=>ctx.items.map((it,i)=>'<div class="nmitem" data-i="'+i+'">'
|
|
6540
|
+
+'<img src="'+esc(urls[i])+'" alt="">'
|
|
6541
|
+
+'<div class="nmmeta"><div class="mono nmfile">'+esc(it.file.name)+'</div>'
|
|
6542
|
+
+'<div class="nmfields"><label>管理番号 <input type="text" class="nm-no" value="'+esc(it.no)+'"></label>'
|
|
6543
|
+
+(ctx.isVideo?'<label>サムネ位置(秒) <input type="number" min="0" class="nm-sec" value="'+it.sec+'"></label>':'')
|
|
6544
|
+
+'</div><div class="hint">'+(it.row?('広告 '+it.row.row+' 行目に入れます'):'行が足りないため、一覧にだけ登録します(あとで他の行から選べます)')+'</div></div></div>').join('');
|
|
6545
|
+
root.innerHTML='<div class="overlay"><div class="modal" id="newMediaModal"><h3>'+kind+'を新規登録('+ctx.items.length+'件)</h3>'
|
|
6546
|
+
+'<div class="msub">番号は空き番号からの連番です。広告名に出る番号なので、運用の番号に変えられます</div>'
|
|
6547
|
+
+'<div class="mbody">'+rowsHtml()+'</div>'
|
|
6548
|
+
+'<div class="mfoot"><button class="act" data-mo="cancel">キャンセル</button><button class="act primary" data-mo="ok">登録する</button></div></div></div>';
|
|
6549
|
+
root.querySelector('[data-mo="cancel"]').addEventListener('click',()=>{ close(); revoke(); });
|
|
6550
|
+
root.querySelectorAll('.nm-sec').forEach(el=>el.addEventListener('change', async ()=>{
|
|
6551
|
+
const i=Number(el.closest('.nmitem').dataset.i), it=ctx.items[i];
|
|
6552
|
+
const sec=Math.max(0, Number(el.value)||0);
|
|
6553
|
+
try{
|
|
6554
|
+
it.frame=await grabVideoFrame(it.file, sec); it.sec=sec;
|
|
6555
|
+
try{ URL.revokeObjectURL(urls[i]); }catch(e){}
|
|
6556
|
+
urls[i]=URL.createObjectURL(it.frame.blob);
|
|
6557
|
+
el.closest('.nmitem').querySelector('img').src=urls[i];
|
|
6558
|
+
}catch(e){ recordError('videoFrame', e, '動画サムネの再抽出'); toast('サムネを取り直せませんでした: '+e.message); }
|
|
6559
|
+
}));
|
|
6560
|
+
root.querySelector('[data-mo="ok"]').addEventListener('click', async ()=>{
|
|
6561
|
+
const nos=[...root.querySelectorAll('.nm-no')].map(el=>String(el.value||'').trim());
|
|
6562
|
+
/* 既存番号への上書きは許さない。参照表の mediaID を持つ番号にパスが被さると、
|
|
6563
|
+
どちらが入稿に使われるか画面から分からなくなる。 */
|
|
6564
|
+
for(const no of nos){
|
|
6565
|
+
if(no==='') return toast('管理番号を入れてください');
|
|
6566
|
+
if((st.refs.media||{})[no]) return toast('番号 '+no+' は参照データに登録済みです。別の番号にしてください');
|
|
6567
|
+
if(localMedia()[no]) return toast('番号 '+no+' はこの計画で登録済みです。別の番号にしてください');
|
|
6568
|
+
}
|
|
6569
|
+
if(new Set(nos).size!==nos.length) return toast('同じ管理番号が重複しています');
|
|
6570
|
+
ctx.items.forEach((it,i)=>{ it.no=nos[i]; });
|
|
6571
|
+
close(); revoke();
|
|
6572
|
+
for(const it of ctx.items){
|
|
6573
|
+
const ok=await applyNewMedia({isVideo:ctx.isVideo, fmt:ctx.fmt, file:it.file, handle:it.handle, frame:it.frame, sec:it.sec,
|
|
6574
|
+
target:it.row?{u:it.row.u, f:ctx.target.f}:null}, it.no, {quiet:true});
|
|
6575
|
+
if(!ok) return;
|
|
6576
|
+
}
|
|
6577
|
+
const placed=ctx.items.filter(it=>it.row).length;
|
|
6578
|
+
toast(ctx.items.length+'件を登録しました(行に入れたもの '+placed+'件・'+MEDIA_SUBDIR+'/ に書き出し。入稿時にアップロードされます)');
|
|
6579
|
+
});
|
|
6580
|
+
}
|
|
6581
|
+
|
|
6582
|
+
/** 素材を 素材/ へ書き出して番号に登録し、target があればその行に番号とパスを入れる。
|
|
6583
|
+
@returns {Promise<boolean>} 書き出せたか */
|
|
6584
|
+
async function applyNewMedia(ctx, no, opts){
|
|
6585
|
+
const F=newMediaFields(ctx.fmt); if(!F) return false;
|
|
6586
|
+
const entry={file:ctx.file.name};
|
|
6587
|
+
try{
|
|
6588
|
+
if(ctx.isVideo){
|
|
6589
|
+
const base=safeFileName(ctx.file.name).replace(/\.[^.]+$/,'');
|
|
6590
|
+
entry.video=await videoRelPath(ctx.handle, ctx.file);
|
|
6591
|
+
entry.thumb=await writeMediaFile(base+'_'+ctx.sec+'s.jpg', ctx.frame.blob);
|
|
6592
|
+
localThumbUrls.set(entry.thumb, URL.createObjectURL(ctx.frame.blob));
|
|
6593
|
+
}else{
|
|
6594
|
+
entry.image=await videoRelPath(ctx.handle, ctx.file);
|
|
6595
|
+
localThumbUrls.set(entry.image, URL.createObjectURL(ctx.file));
|
|
6596
|
+
}
|
|
6597
|
+
}catch(e){ recordError('mediaWrite', e, '素材の書き出し'); toast('素材を書き出せませんでした: '+e.message); return false; }
|
|
6598
|
+
|
|
6599
|
+
mutate(()=>{ localMedia()[String(no)]=entry; });
|
|
6600
|
+
if(ctx.target){
|
|
6601
|
+
const fa=findAd(ctx.target.u);
|
|
6602
|
+
if(!fa){ toast('対象の広告行が見つかりません'); return false; }
|
|
6603
|
+
/* ⚠ setVar の第1引数は **flatVisible() の行オブジェクト**(`rowOf` が kind で実体を選ぶ)。
|
|
6604
|
+
広告オブジェクトを直接渡すと `rowOf` が行を取れず、**黙って何も書かずに返る**。 */
|
|
6605
|
+
const item={kind:'a', a:fa.a, g:fa.g, c:fa.c};
|
|
6606
|
+
const num=/^\d+$/.test(String(no))?Number(no):no;
|
|
6607
|
+
mutate(()=>{ setVarByField(item,'ads',ctx.target.f||'imageNo',num); applyLocalMediaPaths(item, no); });
|
|
6608
|
+
}
|
|
6609
|
+
if(!(opts&&opts.quiet)) toast('番号 '+no+' で登録しました('+MEDIA_SUBDIR+'/ に書き出し。入稿時にアップロードされます)');
|
|
6610
|
+
if(typeof renderDrawer==='function') renderDrawer();
|
|
6611
|
+
return true;
|
|
6612
|
+
}
|
|
6613
|
+
|
|
5937
6614
|
/* ============================================================
|
|
5938
6615
|
B-1: アカウント別参照データ (アカウント/<媒体>/ フォルダ + <名前>.json)
|
|
5939
6616
|
File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
|
|
@@ -6092,6 +6769,7 @@ window.applyAccountJson=function(obj){
|
|
|
6092
6769
|
st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
|
|
6093
6770
|
migrateArticleSlug(st.campaigns); // C8: 旧 articleSlug / articleUrl を除去
|
|
6094
6771
|
migratePlatformValues(st.campaigns); // 媒体側の旧値を現行の語彙へ
|
|
6772
|
+
st.project.localMedia=samplePlanLocalMedia();
|
|
6095
6773
|
planMismatchMsg='';
|
|
6096
6774
|
if(typeof renderPlanWarn==='function') renderPlanWarn();
|
|
6097
6775
|
}
|
|
@@ -6447,14 +7125,27 @@ function openHelpModal(){
|
|
|
6447
7125
|
|
|
6448
7126
|
/* 「サンプルプラン更新」: 現在のプラン(共通設定+キャンペーン)を サンプルプラン.json に書き戻す。
|
|
6449
7127
|
初回だけ File System Access でファイルを選び、以後は IndexedDB 保持ハンドルで Finder無し更新。 */
|
|
7128
|
+
/** サンプルプラン.json に書き戻す中身。
|
|
7129
|
+
localMedia (この計画で新規登録した素材: 番号 → ローカルパス) も持つ。無いと保存して開き直したときに、
|
|
7130
|
+
一覧にだけ登録した素材と、番号に紐づくパス・サムネが消える (行に入ったパスは campaigns 側に残る)。 */
|
|
7131
|
+
function samplePlanData(){
|
|
7132
|
+
const data={schemaVersion:SCHEMA_VERSION, platform:curPf, accountId:st.project.accountId||'', campaigns:st.campaigns};
|
|
7133
|
+
const lm=st.project.localMedia;
|
|
7134
|
+
if(lm && typeof lm==='object' && Object.keys(lm).length) data.localMedia=lm;
|
|
7135
|
+
return data;
|
|
7136
|
+
}
|
|
7137
|
+
/** サンプルプランの localMedia を読む (無い・壊れているときは空) */
|
|
7138
|
+
function samplePlanLocalMedia(){
|
|
7139
|
+
const lm=samplePlan && samplePlan.localMedia;
|
|
7140
|
+
return (lm && typeof lm==='object' && !Array.isArray(lm)) ? JSON.parse(JSON.stringify(lm)) : {};
|
|
7141
|
+
}
|
|
6450
7142
|
async function updateSamplePlan(){
|
|
6451
7143
|
/* S13: 書き出しスキーマは同梱の サンプルプラン*.json と同形 {schemaVersion, platform, accountId, campaigns} に揃える。
|
|
6452
7144
|
・accountId が無いと initialState() のアカウント不一致ガードが素通りし、別アカウントのプランが
|
|
6453
7145
|
無警告で読み込まれる(誤入稿に直結)ので必ず出す
|
|
6454
7146
|
・project は読み戻し側が存在しない(案件設定はアカウントJSON由来で defaultProject() が作る)。
|
|
6455
7147
|
書いても次回起動で必ず捨てられるため出さない = 「保存したのに戻る」の誤解を断つ */
|
|
6456
|
-
const
|
|
6457
|
-
const json=JSON.stringify(data,null,1);
|
|
7148
|
+
const json=JSON.stringify(samplePlanData(),null,1);
|
|
6458
7149
|
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){} }
|
|
6459
7150
|
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; } }
|
|
6460
7151
|
if(!samplePlanFileHandle){
|
|
@@ -6562,7 +7253,7 @@ document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(
|
|
|
6562
7253
|
document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-m]'); if(!b) return; clearSel(); closeDrawer(); planMode=b.dataset.m; document.querySelectorAll('#modeSeg button').forEach(x=>x.classList.toggle('active',x===b)); render(); });
|
|
6563
7254
|
/* S11: 媒体セレクタ (#pfSeg) / 媒体要件タブ (#mediaTabs) は廃止。媒体切替はヘッダーの統合プルダウンに一本化 */
|
|
6564
7255
|
/* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
|
|
6565
|
-
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'; }); });
|
|
7256
|
+
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','video','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
|
|
6566
7257
|
/* 案件テンプレ: チップ直接編集 */
|
|
6567
7258
|
document.getElementById('tplChips').addEventListener('change',e=>{
|
|
6568
7259
|
/* S12: kind:'datetime' は日付/時刻の2入力で1つのキーを作るので、[data-proj] の汎用処理より先に分岐する
|
|
@@ -6674,7 +7365,7 @@ sheetHostEl.addEventListener('mousedown',e=>{
|
|
|
6674
7365
|
pendingDrawer=null; dragMoved=false;
|
|
6675
7366
|
// S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
|
|
6676
7367
|
const col=colDefs()[c];
|
|
6677
|
-
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}; else if(col && col.kind==='optpick' && cell.dataset.f) pendingDrawer={kind:'opt', u:cell.dataset.u, f:cell.dataset.f}; }
|
|
7368
|
+
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); /* Shift+クリックの範囲選択でもドロワーを開く (開いていれば選択範囲に合わせて描き直す) */ if(col && col.kind==='refcombo' && selA.c===c) pendingDrawer={kind:'adRange', u:cell.dataset.u, f:cell.dataset.f}; /* 同じ列を縦に選んだときだけ (矩形選択は従来どおり開かない) */ } 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}; else if(col && col.kind==='optpick' && cell.dataset.f) pendingDrawer={kind:'opt', u:cell.dataset.u, f:cell.dataset.f}; }
|
|
6678
7369
|
applySelection();
|
|
6679
7370
|
});
|
|
6680
7371
|
sheetHostEl.addEventListener('mouseover',e=>{
|
|
@@ -6698,7 +7389,7 @@ document.getElementById('drawerBackdrop').addEventListener('click',closeDrawer);
|
|
|
6698
7389
|
document.getElementById('drawerSearch').addEventListener('input',renderDrawer);
|
|
6699
7390
|
document.getElementById('drawerList').addEventListener('click',e=>{
|
|
6700
7391
|
/* サムネのクリックは拡大。行の選択には落とさない (見たいだけで選ぶとは限らない) */
|
|
6701
|
-
const th=e.target.closest('.ddthumb');
|
|
7392
|
+
const th=e.target.closest('.ddthumb[data-zoom]'); // ローカル素材のサムネ (data-localthumb) は拡大しない
|
|
6702
7393
|
if(th){ e.stopPropagation(); openThumbZoom(th.dataset.zoom); return; }
|
|
6703
7394
|
const row=e.target.closest('.ddrow'); if(!row) return; if(!drawerTarget) return;
|
|
6704
7395
|
if(drawerTarget.type==='tg'){ drawerTgToggle(row.dataset.tgval); }
|
|
@@ -6707,21 +7398,37 @@ document.getElementById('drawerList').addEventListener('click',e=>{
|
|
|
6707
7398
|
else if(drawerTarget.type==='agref'){ if(row.dataset.agid) addExistingAdGroup(row.dataset.agid); }
|
|
6708
7399
|
else if(drawerTarget.type==='cppick'){ if(row.dataset.cppick){ if(drawerTarget.mode==='addNew') addNewAdGroupToCampaign(row.dataset.cppick); else moveGroupToCampaign(drawerTarget.gUid, row.dataset.cppick); } }
|
|
6709
7400
|
else if(drawerTarget.type==='agpick'){ if(row.dataset.agpick) moveAdToGroup(drawerTarget.adUid, row.dataset.agpick); }
|
|
7401
|
+
/* 「+ 新規登録」。保存フォルダ → 素材 の2ステップをモーダルで案内する */
|
|
7402
|
+
else if(row.dataset.newmedia){ openNewMediaWizard(); }
|
|
7403
|
+
else if(row.dataset.seq){ applySequentialParamCodes(); renderDrawer(); }
|
|
6710
7404
|
else { drawerPick(row.dataset.no); }
|
|
6711
7405
|
});
|
|
6712
7406
|
/* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */
|
|
6713
|
-
document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else if(pendingDrawer.kind==='opt') openOptDrawer(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
|
|
7407
|
+
document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='adRange'){ if(document.getElementById('refDrawer').classList.contains('open') && drawerTarget && drawerTarget.f===pendingDrawer.f) renderDrawer(); else openDrawerFor(pendingDrawer.u,pendingDrawer.f); } else if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else if(pendingDrawer.kind==='opt') openOptDrawer(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
|
|
6714
7408
|
|
|
6715
7409
|
/* ---- コピー / 貼付 ---- */
|
|
7410
|
+
/* シートの自前コピペ (セル範囲 → TSV) を使う場面か。
|
|
7411
|
+
モーダル・サイドバー・チップの入力欄や、シートの外の文字を選んでいるときはブラウザ標準に任せる。
|
|
7412
|
+
以前はシート表示中なら常に横取りしていたため、モーダルのパスやエラー文をコピーできず、
|
|
7413
|
+
モーダルの入力欄にも貼れなかった (セル範囲が選択されたままだと貼付がシートへ流れた)。 */
|
|
7414
|
+
function gridClipboardActive(){
|
|
7415
|
+
if(activeView!=='sheet'||planMode!=='sheet') return false;
|
|
7416
|
+
const mr=document.getElementById('modalRoot'); if(mr && mr.children.length) return false;
|
|
7417
|
+
const ae=document.activeElement;
|
|
7418
|
+
if(ae && ae.matches && ae.matches('input,textarea,select,[contenteditable="true"]') && !ae.classList.contains('cell-in')) return false;
|
|
7419
|
+
const sel=window.getSelection && window.getSelection();
|
|
7420
|
+
if(sel && !sel.isCollapsed && sel.anchorNode){ const host=document.getElementById('sheetHost'); if(!host || !host.contains(sel.anchorNode)) return false; }
|
|
7421
|
+
return true;
|
|
7422
|
+
}
|
|
6716
7423
|
document.addEventListener('copy',e=>{
|
|
6717
|
-
if(
|
|
7424
|
+
if(!gridClipboardActive()) return;
|
|
6718
7425
|
const rg=selRange();
|
|
6719
7426
|
if(rg){ const t=copySelText(); if(t!=null){ e.clipboardData.setData('text/plain',t); e.preventDefault(); } return; } // セル選択→常に自前TSV(ラベル/ボタンのDOMテキスト混入を防ぐ)
|
|
6720
7427
|
const ae=document.activeElement; // セル選択なし: 単一inputの部分テキスト選択のみnative許可
|
|
6721
7428
|
if(ae&&ae.classList&&ae.classList.contains('cell-in')&&ae.selectionStart!=null&&ae.selectionStart!==ae.selectionEnd) return;
|
|
6722
7429
|
e.preventDefault();
|
|
6723
7430
|
});
|
|
6724
|
-
document.addEventListener('paste',e=>{ if(
|
|
7431
|
+
document.addEventListener('paste',e=>{ if(!gridClipboardActive()) return; if(!selRange()) return; const txt=(e.clipboardData||window.clipboardData).getData('text'); if(txt==null) return; e.preventDefault(); pasteGrid(txt); });
|
|
6725
7432
|
document.addEventListener('keydown',e=>{
|
|
6726
7433
|
/* 自動で消えないトーストはどのビューからでも出る(書き出しボタンは常設ヘッダー)。
|
|
6727
7434
|
ビューのガードより手前で閉じないと、title の「閉じる (Esc)」と食い違う */
|