addashboard-cli 0.2.4 → 0.3.1

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.
@@ -0,0 +1,2502 @@
1
+ <!DOCTYPE html>
2
+ <!-- bizagent-drive:{"fileId":"19yZQrELdu6UWiBI-0pzBT9uiVH3rEKPi","link":"https://drive.google.com/file/d/19yZQrELdu6UWiBI-0pzBT9uiVH3rEKPi/view?usp=drivesdk"} -->
3
+ <html lang="ja">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <title>YDA入稿プランナー / 媒体要件 — WEBGRAM</title>
8
+ <style>
9
+ :root{
10
+ --fg:#111418; --muted:#5b6470; --faint:#8a92a0; --line:#e6e8ec; --line2:#eef0f3;
11
+ --bg:#ffffff; --soft:#f7f8fa; --softer:#fbfcfd;
12
+ --accent:#4f46e5; --accent-soft:#eef0fe; --accent-line:#c7ccf7;
13
+ --green:#0f7b3f; --green-soft:#e7f4ec;
14
+ --amber:#9a6212; --amber-soft:#fbf1dd;
15
+ --danger:#c02626; --danger-soft:#fdeaea;
16
+ --purple:#6b46c1;
17
+ --mono:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
18
+ --radius:10px;
19
+ }
20
+ *{box-sizing:border-box}
21
+ html,body{margin:0;padding:0;background:var(--soft);color:var(--fg);
22
+ font-family:-apple-system,BlinkMacSystemFont,"Hiragino Kaku Gothic ProN","Yu Gothic UI","Segoe UI",Inter,sans-serif;
23
+ font-size:14px; line-height:1.6; -webkit-font-smoothing:antialiased;}
24
+ h1,h2,h3,h4{margin:0; font-weight:700; letter-spacing:.01em;}
25
+ a{color:var(--accent);}
26
+ button{font-family:inherit;}
27
+ code,kbd{font-family:var(--mono); font-size:.85em; background:var(--line2); padding:1px 5px; border-radius:4px;}
28
+ .mono{font-family:var(--mono); font-variant-numeric:tabular-nums;}
29
+
30
+ /* ── top bar ── */
31
+ header.bar{
32
+ position:sticky; top:0; z-index:40; background:rgba(255,255,255,.92); backdrop-filter:blur(8px);
33
+ border-bottom:1px solid var(--line); padding:10px 20px; display:flex; align-items:center; gap:14px; flex-wrap:wrap;
34
+ }
35
+ header.bar .brand{display:flex; align-items:baseline; gap:10px; margin-right:6px;}
36
+ header.bar .brand .logo{font-size:15px; font-weight:800; letter-spacing:.02em;}
37
+ header.bar .brand .pjname{font-size:12.5px; color:var(--muted);}
38
+ header.bar .acchdr{display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); margin-right:6px;} /* F-3: ヘッダーのアカウント切替 */
39
+ header.bar .acchdr select{font-family:inherit; font-size:12.5px; border:1px solid var(--line); border-radius:7px; padding:4px 6px; background:#fff;}
40
+ .seg{display:inline-flex; background:var(--soft); border:1px solid var(--line); border-radius:8px; padding:2px;}
41
+ .seg button{border:0; background:transparent; padding:6px 14px; font-size:12.5px; font-weight:600; color:var(--muted); border-radius:6px; cursor:pointer;}
42
+ .seg button.active{background:#fff; color:var(--fg); box-shadow:0 1px 2px rgba(16,24,40,.08);}
43
+ .spacer{flex:1;}
44
+ .act{display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600; color:var(--fg); background:#fff; border:1px solid var(--line); padding:6px 11px; border-radius:7px; cursor:pointer;}
45
+ .act:hover{background:var(--soft);}
46
+ .act.primary{background:var(--accent); color:#fff; border-color:var(--accent);}
47
+ .act.primary:hover{background:#4338ca;}
48
+ .act.ghost{background:transparent; border-color:transparent; color:var(--muted);}
49
+ .act.ghost:hover{background:var(--soft);}
50
+ .vbadge{display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:700; padding:5px 10px; border-radius:999px; cursor:pointer;}
51
+ .vbadge.ok{background:var(--green-soft); color:var(--green);}
52
+ .vbadge.err{background:var(--danger-soft); color:var(--danger);}
53
+ .vbadge.warn{background:var(--amber-soft); color:var(--amber);}
54
+
55
+ main{max-width:1400px; margin:0 auto; padding:22px 20px 80px;}
56
+ .view{display:none;}
57
+ .view.active{display:block;}
58
+
59
+ /* ── requirement doc ── */
60
+ .doc{max-width:1000px;}
61
+ .doc .eyebrow{font-size:11.5px; letter-spacing:.14em; text-transform:uppercase; color:var(--faint); font-weight:700;}
62
+ .doc h1.title{font-size:26px; margin:6px 0 4px;}
63
+ .doc .sub{color:var(--muted); font-size:13.5px; margin-bottom:22px;}
64
+ details.sec{border:1px solid var(--line); border-radius:var(--radius); background:#fff; margin:14px 0; overflow:hidden;}
65
+ details.sec>summary{list-style:none; cursor:pointer; padding:14px 18px; display:flex; align-items:center; gap:12px; user-select:none; background:var(--softer);}
66
+ details.sec>summary::-webkit-details-marker{display:none;}
67
+ details.sec>summary .num{display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:6px; background:var(--fg); color:#fff; font-size:11px; font-weight:800; flex-shrink:0;}
68
+ details.sec>summary .stitle{font-size:15.5px; font-weight:700; flex:1;}
69
+ details.sec>summary .chev{width:9px; height:9px; border-right:2px solid var(--faint); border-bottom:2px solid var(--faint); transform:rotate(45deg); transition:transform .15s;}
70
+ details.sec[open]>summary{border-bottom:1px solid var(--line);}
71
+ details.sec[open]>summary .chev{transform:rotate(-135deg);}
72
+ details.sec .body{padding:14px 20px 20px;}
73
+ .doc p{margin:8px 0;}
74
+ .doc h4{font-size:13.5px; margin:16px 0 6px; color:var(--fg);}
75
+ .doc ul{margin:6px 0; padding-left:1.25em;} .doc li{margin:3px 0;}
76
+ .lead{color:var(--muted); font-size:13px; margin:2px 0 14px;}
77
+ table.g{width:100%; border-collapse:collapse; font-size:12.8px; margin:8px 0 6px;}
78
+ table.g th,table.g td{text-align:left; padding:8px 10px; border-bottom:1px solid var(--line2); vertical-align:top;}
79
+ table.g th{background:var(--soft); font-weight:700; color:#3a4150; font-size:11.5px; letter-spacing:.02em; position:sticky; top:0;}
80
+ table.g tr:hover td{background:var(--softer);}
81
+ .pill{display:inline-block; font-size:11px; padding:2px 8px; border-radius:999px; background:var(--accent-soft); color:var(--accent); font-weight:700; letter-spacing:.02em; white-space:nowrap;}
82
+ .pill.green{background:var(--green-soft); color:var(--green);} .pill.amber{background:var(--amber-soft); color:var(--amber);}
83
+ .pill.gray{background:var(--line2); color:#4a5160;} .pill.danger{background:var(--danger-soft); color:var(--danger);}
84
+ .pill.purple{background:#efe9fb; color:var(--purple);}
85
+ .newbadge{display:inline-block; font-size:9px; font-weight:800; color:#fff; background:var(--green); border-radius:4px; padding:1px 5px; letter-spacing:.04em; vertical-align:middle; text-transform:uppercase; margin-left:2px;}
86
+ .callout{border-left:3px solid var(--accent); background:var(--soft); padding:11px 15px; margin:14px 0; border-radius:0 8px 8px 0; font-size:13px;}
87
+ .callout.amber{border-color:var(--amber); background:var(--amber-soft);}
88
+ .callout.green{border-color:var(--green); background:var(--green-soft);}
89
+ .callout.danger{border-color:var(--danger); background:var(--danger-soft);}
90
+ .grid2{display:grid; grid-template-columns:1fr 1fr; gap:14px; margin:10px 0;}
91
+ .kv{border:1px solid var(--line); border-radius:9px; padding:12px 14px; background:#fff;}
92
+ .kv h4{margin:0 0 8px;}
93
+ .fx{font-family:var(--mono); font-size:12px; background:#0f1420; color:#d6e0ff; padding:10px 12px; border-radius:8px; overflow-x:auto; white-space:pre; line-height:1.55;}
94
+ .fx .c{color:#6ee7a8;} .fx .k{color:#f0a868;}
95
+
96
+ /* ── planner shared ── */
97
+ .toolbar2{display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:0 0 14px;}
98
+ .chipset{display:flex; gap:6px; flex-wrap:wrap; align-items:center;}
99
+ .chip{display:inline-flex; align-items:center; gap:6px; font-size:11.5px; background:#fff; border:1px solid var(--line); border-radius:7px; padding:4px 9px; color:var(--muted);}
100
+ .chip b{color:var(--fg); font-weight:700;}
101
+ /* インライン編集チップ(案件テンプレのモーダル廃止→ここで直接編集) */
102
+ .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;}
103
+ .chip .chipin:hover{border-bottom-color:var(--muted);}
104
+ .chip .chipin:focus{outline:none; border-bottom:1px solid var(--accent); background:var(--accent-soft);}
105
+ .chip select.chipin{-webkit-appearance:none; appearance:none; cursor:pointer;}
106
+ /* 開閉式 戦略メモ */
107
+ .stratbox{border:1px solid var(--line); border-radius:9px; background:#fff; margin:0 0 12px; overflow:hidden;}
108
+ .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;}
109
+ .stratbox>summary::-webkit-details-marker{display:none;}
110
+ .stratbox>summary::before{content:'▸'; color:var(--muted); font-size:11px;}
111
+ .stratbox[open]>summary::before{content:'▾';}
112
+ .stratbox textarea{width:100%; border:none; border-top:1px solid var(--line2); padding:10px 14px; font-family:inherit; font-size:12.5px; line-height:1.6; min-height:420px; resize:vertical; box-sizing:border-box; background:#fff; color:var(--fg);}
113
+ .stratbox textarea::placeholder{color:var(--faint);}
114
+ .stratbox textarea:focus{outline:none;}
115
+ /* 案件設定(開閉式) */
116
+ .cfgbox{border:1px solid var(--line); border-radius:9px; background:#fff; margin:0 0 12px; overflow:hidden;}
117
+ .cfgbox>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;}
118
+ .cfgbox>summary::-webkit-details-marker{display:none;}
119
+ .cfgbox>summary::before{content:'▸'; color:var(--muted); font-size:11px;}
120
+ .cfgbox[open]>summary::before{content:'▾';}
121
+ .cfgbox .chipset{padding:12px 14px 12px; border-top:1px solid var(--line2);}
122
+ .filt{display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--muted);}
123
+ .filt select,.filt input{font-family:inherit; font-size:12px; border:1px solid var(--line); border-radius:6px; padding:4px 7px; background:#fff;}
124
+
125
+ /* ── sheet ── */
126
+ .sheetwrap{background:#fff; border:1px solid var(--line); border-radius:var(--radius); overflow:auto; max-height:calc(100vh - 220px);}
127
+ table.sheet{border-collapse:separate; border-spacing:0; width:100%; font-size:12.5px; min-width:1180px; user-select:none; -webkit-user-select:none;}
128
+ table.sheet input.cell-in{user-select:text; -webkit-user-select:text;}
129
+ table.sheet th{position:sticky; top:0; z-index:5; background:var(--soft); color:#3a4150; font-weight:700; font-size:11px; letter-spacing:.02em; text-align:left; padding:8px 9px; border-bottom:1px solid var(--line); white-space:nowrap;}
130
+ table.sheet td{padding:3px 9px; border-bottom:1px solid var(--line2); vertical-align:middle; white-space:nowrap;}
131
+ /* 番号+内容を1ボックスに(インライン表示) */
132
+ .cellcombo{display:inline-flex; align-items:center; gap:0; border:1px solid var(--line); border-radius:6px; padding:1px 7px; background:#fff; max-width:230px; min-width:0;}
133
+ .cellcombo:focus-within{border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft);}
134
+ .cellcombo.comboSel{background:var(--accent-soft); border-color:var(--accent);}
135
+ .cellcombo .cnt{font-variant-numeric:tabular-nums;}
136
+ .cellcombo .cell-in{border:none !important; box-shadow:none !important; background:transparent !important; padding:2px 0; flex:0 0 auto; field-sizing:content; width:auto; min-width:1.6ch; max-width:6ch; text-align:right;}
137
+ .cellcombo .combosep{color:var(--faint); font-size:11px; flex:0 0 auto; margin:0 4px 0 0;}
138
+ .cellcombo .ghost{flex:1 1 auto; min-width:0; max-width:none;}
139
+ .cellcombo.err{border-color:var(--danger); background:var(--danger-soft);}
140
+ .combodd{flex:0 0 auto; margin-left:6px; border:none; background:transparent; color:var(--muted); cursor:pointer; font-size:10px; padding:0 2px; border-radius:4px; line-height:1;}
141
+ .combodd:hover{background:var(--soft); color:var(--fg);}
142
+ /* ▼ 右スライドサイドバー(一覧から選択) */
143
+ .drawer-backdrop{position:fixed; inset:0; background:rgba(17,20,24,.16); z-index:88; opacity:0; pointer-events:none; transition:opacity .2s;}
144
+ .drawer-backdrop.open{opacity:1; pointer-events:auto;}
145
+ .refdrawer{position:fixed; top:0; right:0; height:100vh; width:380px; max-width:92vw; background:#fff; border-left:1px solid var(--line); box-shadow:-14px 0 36px rgba(16,24,40,.16); z-index:89; transform:translateX(100%); transition:transform .22s ease; display:flex; flex-direction:column;}
146
+ .refdrawer.open{transform:translateX(0);}
147
+ .refdrawer .dh{display:flex; align-items:center; gap:10px; padding:14px 16px 10px;}
148
+ .refdrawer .dh .dt{font-size:14px; font-weight:700; flex:1;}
149
+ .refdrawer .dh .dx{border:none; background:transparent; font-size:15px; color:var(--muted); cursor:pointer; padding:2px 8px; border-radius:6px;}
150
+ .refdrawer .dh .dx:hover{background:var(--soft);}
151
+ .refdrawer .dtabs{display:flex; gap:6px; padding:0 16px;}
152
+ .refdrawer .dtabs button{border:1px solid var(--line); background:var(--soft); padding:5px 12px; font-size:12px; font-weight:600; color:var(--muted); border-radius:7px; cursor:pointer;}
153
+ .refdrawer .dtabs button.active{background:var(--accent-soft); color:var(--accent); border-color:var(--accent-line);}
154
+ .refdrawer .dsearch{padding:10px 16px 4px;}
155
+ .refdrawer .dsearch input{width:100%; font-family:inherit; font-size:13px; border:1px solid var(--line); border-radius:8px; padding:8px 10px;}
156
+ .refdrawer .dtarget{padding:2px 16px 8px; font-size:11.5px; color:var(--faint);}
157
+ .refdrawer .dlist{overflow-y:auto; flex:1; border-top:1px solid var(--line2);}
158
+ .ddrow{display:flex; gap:8px; padding:8px 16px; cursor:pointer; font-size:12.5px; border-bottom:1px solid var(--line2); white-space:nowrap; align-items:center;}
159
+ .ddrow:hover{background:var(--accent-soft);}
160
+ .ddrow.cur{background:var(--accent-soft); box-shadow:inset 3px 0 0 var(--accent);}
161
+ .ddrow .ddno{font-family:var(--mono); color:var(--accent); font-weight:700; flex:0 0 auto; min-width:40px;}
162
+ .ddrow .ddtx{color:var(--fg); overflow:hidden; text-overflow:ellipsis;}
163
+ table.sheet tr.cphead td{background:var(--softer); border-top:1px solid var(--line); font-weight:700;}
164
+ table.sheet tr.cphead .cptitle{font-size:12.5px; color:var(--fg); display:flex; align-items:center; gap:8px;}
165
+ table.sheet tr.adrow:hover td{background:#fcfdff;}
166
+ table.sheet tr.adrow.hasErr td{background:var(--danger-soft);}
167
+ table.sheet tr.adrow.banner td{background:#faf9fc;}
168
+ .cell-in{width:100%; border:1px solid transparent; background:transparent; border-radius:5px; padding:4px 6px; font-family:inherit; font-size:12.5px; color:var(--fg);}
169
+ .cell-in:hover{border-color:var(--line);}
170
+ .cell-in:focus{outline:none; border-color:var(--accent); background:#fff; box-shadow:0 0 0 2px var(--accent-soft);}
171
+ .cell-in.num{font-family:var(--mono); font-variant-numeric:tabular-nums;}
172
+ .cell-in.err{border-color:var(--danger); background:var(--danger-soft);}
173
+ .cell-in.warnempty{background:var(--amber-soft); box-shadow:inset 2px 0 0 var(--amber);}
174
+ select.cell-in{-webkit-appearance:none; appearance:none; cursor:pointer;}
175
+ select.cell-in.fmt{min-width:56px; width:auto; padding-right:14px; text-overflow:clip;}
176
+ .ghost{display:inline-block; font-size:11px; color:var(--faint); line-height:1.35; max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:middle;}
177
+ .ghost.err{color:var(--danger);} .ghost b.cnt{font-variant-numeric:tabular-nums;}
178
+ .medibadge{display:inline-flex; align-items:center; gap:6px; font-size:11px; flex-shrink:0; white-space:nowrap;}
179
+ .medibadge .sw{width:26px; height:26px; border-radius:5px; background:linear-gradient(135deg,#e9ecf5,#dfe3ee); display:inline-flex; align-items:center; justify-content:center; font-size:9px; color:#6b7280; font-weight:700; flex-shrink:0;}
180
+ .idpill{font-family:var(--mono); font-size:10.5px; color:var(--muted);}
181
+ .robadge{display:inline-block; font-size:12px; color:var(--muted); padding:2px 2px; max-width:170px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:middle;} /* 既存行の編集不可セル表示 */
182
+ .cell-in.roinput{background:var(--soft); color:var(--muted); cursor:not-allowed;} /* 既存行の read-only 入力欄 */
183
+ .idpill.warn{color:var(--amber);} .idpill.err{color:var(--danger);}
184
+ .breadc{font-size:10.5px; color:var(--faint); white-space:nowrap; max-width:170px; overflow:hidden; text-overflow:ellipsis;}
185
+ .rowact{display:inline-flex; gap:2px;}
186
+ .iconbtn{border:1px solid transparent; background:transparent; width:24px; height:24px; border-radius:6px; cursor:pointer; color:var(--muted); font-size:13px; line-height:1; display:inline-flex; align-items:center; justify-content:center;}
187
+ .iconbtn:hover{background:var(--soft); color:var(--fg); border-color:var(--line);}
188
+ .iconbtn.danger:hover{background:var(--danger-soft); color:var(--danger); border-color:var(--danger-soft);}
189
+ .minick{display:inline-flex; align-items:center; gap:4px; font-size:11px; color:var(--muted); cursor:pointer;}
190
+
191
+ /* ── tree ── */
192
+ .tree{display:flex; flex-direction:column; gap:10px;}
193
+ .cpnode{border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden;}
194
+ .noderow{display:flex; align-items:center; gap:9px; padding:10px 12px; cursor:default;}
195
+ .cpnode>.noderow{background:var(--softer);}
196
+ .caret{width:20px; height:20px; border-radius:5px; border:1px solid var(--line); background:#fff; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); flex-shrink:0;}
197
+ .caret .a{width:6px;height:6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(-45deg);transition:transform .15s;}
198
+ .collapsed .caret .a{transform:rotate(-135deg);}
199
+ .nodename{font-weight:700; font-size:13.5px; color:var(--fg); border:1px solid transparent; border-radius:5px; padding:2px 6px; margin-left:-2px;}
200
+ .nodename:focus{outline:none; border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft);}
201
+ .metae{display:flex; gap:6px; align-items:center; flex-wrap:wrap;}
202
+ .nodeact{display:inline-flex; gap:3px; margin-left:auto;}
203
+ .agwrap{padding:4px 10px 12px 34px; display:flex; flex-direction:column; gap:8px;}
204
+ .cpnode.collapsed .agwrap{display:none;}
205
+ .agnode{border:1px solid var(--line2); border-radius:9px; background:var(--softer); overflow:hidden;}
206
+ .agnode>.noderow{background:#fff;}
207
+ .adwrap{padding:2px 10px 10px 30px; display:flex; flex-direction:column; gap:5px;}
208
+ .agnode.collapsed .adwrap{display:none;}
209
+ .adnode{display:flex; align-items:center; gap:9px; padding:7px 9px; border:1px solid var(--line2); border-radius:8px; background:#fff;}
210
+ .adnode.hasErr{border-color:var(--danger); background:var(--danger-soft);}
211
+ .adnode.banner{background:#faf9fc;}
212
+ .dot{width:8px;height:8px;border-radius:50%; flex-shrink:0;}
213
+ .dot.ok{background:var(--green);} .dot.err{background:var(--danger);} .dot.warn{background:var(--amber);}
214
+ .adttl{font-size:12.5px; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
215
+ .adttl .sm{color:var(--faint); font-size:11px;}
216
+ .cbadge{display:inline-flex; align-items:center; justify-content:center; min-width:24px; height:20px; padding:0 5px; border-radius:5px; background:var(--line2); font-size:10px; font-weight:700; color:#5b6470; font-family:var(--mono);}
217
+ .ratio{font-family:var(--mono); font-size:11px; font-weight:700; color:var(--accent); background:var(--accent-soft); padding:2px 7px; border-radius:5px;}
218
+ .addbtn{align-self:flex-start; font-size:11.5px; color:var(--accent); background:var(--accent-soft); border:1px dashed var(--accent-line); border-radius:7px; padding:5px 10px; cursor:pointer; font-weight:600;}
219
+ .addrow{display:flex; gap:6px; flex-wrap:wrap; align-items:center; margin-top:4px;} /* ツリー: 追加ボタンを横1行に */
220
+ .addrow .addbtn{align-self:auto; margin:0;}
221
+ .addbtn:hover{background:#e3e7fd;}
222
+
223
+ /* ── selection (spreadsheet) ── */
224
+ td.rownum{ text-align:center; color:var(--faint); font-size:10.5px; font-variant-numeric:tabular-nums; cursor:pointer; user-select:none; width:34px; position:sticky; left:0; z-index:3; background:#fff;}
225
+ table.sheet th:first-child{ left:0; z-index:6;}
226
+ td.rownum:hover{ background:var(--soft); color:var(--fg);}
227
+ td.rownum.rowsel{ background:var(--accent-soft); color:var(--accent); font-weight:700;}
228
+ .cell-in.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line);}
229
+ .cell-in.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
230
+ /* 広告グループ ターゲティング: セル全体をクリック→右サイドバー(直接入力せず選択式・1行省略で高さコンパクト、全文はtitle) */
231
+ td.tgtd{ padding:3px 6px;}
232
+ .tgpick{ display:flex; align-items:center; gap:6px; min-width:130px; max-width:230px; min-height:26px; border:1px solid var(--line); border-radius:6px; padding:3px 8px; background:#fff; cursor:pointer; line-height:1.3;}
233
+ .tgpick:hover{ border-color:var(--accent); background:var(--accent-soft);}
234
+ .cppick{ display:inline-flex; align-items:center; gap:5px; min-height:26px; border:1px solid var(--line); border-radius:6px; padding:2px 8px; background:#fff; cursor:pointer;} /* F-6: キャンペーン選択セル */
235
+ .cppick:hover{ border-color:var(--accent); background:var(--accent-soft);}
236
+ .cppick .breadc{color:var(--ink);}
237
+ .tgpick.tgempty{ background:var(--soft);}
238
+ .tgpick .tgval{ flex:1 1 auto; min-width:0; font-size:12px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
239
+ .tgpick .tghint{ color:var(--faint);}
240
+ .tgpick .tgcaret{ flex:0 0 auto; color:var(--muted); font-size:10px;}
241
+ .tgpick.selcell{ background:var(--accent-soft) !important; box-shadow:inset 0 0 0 1px var(--accent-line); border-color:var(--accent);}
242
+ .tgpick.selanchor{ box-shadow:inset 0 0 0 2px var(--accent);}
243
+ body.noselect{ user-select:none; -webkit-user-select:none;}
244
+ .hinttxt{ font-size:11px; color:var(--faint);}
245
+ /* ── ref data view ── */
246
+ table.reftable{ border-collapse:separate; border-spacing:0; width:100%; font-size:12.5px; min-width:640px;}
247
+ table.reftable th{ position:sticky; top:0; z-index:5; background:var(--soft); color:#3a4150; font-weight:700; font-size:11px; text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); white-space:nowrap;}
248
+ table.reftable td{ padding:5px 10px; border-bottom:1px solid var(--line2); vertical-align:middle;}
249
+ table.reftable tr:hover td{ background:#fcfdff;}
250
+ .reftext{ max-width:560px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
251
+ .reflen{ font-variant-numeric:tabular-nums; font-size:11px; color:var(--faint);}
252
+ .reflen.warn{ color:var(--amber); font-weight:700;} .reflen.err{ color:var(--danger); font-weight:700;}
253
+
254
+ /* ── modal ── */
255
+ .overlay{position:fixed; inset:0; background:rgba(17,20,24,.42); z-index:80; display:flex; align-items:center; justify-content:center; padding:20px;}
256
+ .modal{background:#fff; border-radius:14px; box-shadow:0 20px 60px rgba(16,24,40,.28); width:min(560px,100%); max-height:88vh; overflow:auto;}
257
+ .modal h3{font-size:16px; padding:18px 20px 4px;}
258
+ .modal .msub{color:var(--muted); font-size:12.5px; padding:0 20px 6px;}
259
+ .modal .mbody{padding:10px 20px;}
260
+ .mfield{margin:12px 0;}
261
+ .mfield label{display:block; font-size:12px; font-weight:600; color:var(--muted); margin-bottom:5px;}
262
+ .mfield input[type=text],.mfield input[type=number],.mfield select,.mfield textarea{width:100%; font-family:inherit; font-size:13px; border:1px solid var(--line); border-radius:8px; padding:8px 10px; background:#fff;}
263
+ .mfield textarea{min-height:70px; resize:vertical;}
264
+ .mcheck{display:flex; align-items:center; gap:8px; font-size:13px; margin:7px 0; cursor:pointer;}
265
+ .mrow{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
266
+ .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;}
267
+ .hint{font-size:11.5px; color:var(--faint); margin-top:4px;}
268
+ .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;}
269
+ .toast.show{opacity:1; transform:translateX(-50%) translateY(-2px);}
270
+ .empty{padding:40px; text-align:center; color:var(--faint); font-size:13px;}
271
+ /* ── plan warning banner (accountId 不一致など) ── */
272
+ .planwarn{display:flex; align-items:center; gap:10px; background:var(--amber-soft); color:#7a4d00; border-bottom:1px solid var(--amber); font-size:12.5px; padding:9px 16px;}
273
+ .planwarn[hidden]{display:none;}
274
+ .planwarn .planwarnx{margin-left:auto; border:none; background:transparent; color:inherit; cursor:pointer; font-size:13px; line-height:1; padding:2px 6px; border-radius:6px;}
275
+ .planwarn .planwarnx:hover{background:rgba(0,0,0,.06);}
276
+ /* ── help modal ── */
277
+ .modal.help{width:min(760px,100%);}
278
+ .help .hlead{font-size:13px; color:var(--muted); line-height:1.7; margin:2px 0 14px;}
279
+ .help .hsteps{list-style:none; margin:0; padding:0; counter-reset:hstep;}
280
+ .help .hsteps>li{position:relative; padding:0 0 16px 42px; margin:0;}
281
+ .help .hsteps>li:before{counter-increment:hstep; content:counter(hstep); position:absolute; left:0; top:0; width:26px; height:26px; border-radius:50%; background:var(--accent); color:#fff; font-size:13px; font-weight:800; display:flex; align-items:center; justify-content:center;}
282
+ .help .hsteps>li:after{content:""; position:absolute; left:12.5px; top:28px; bottom:2px; width:1px; background:var(--line2);}
283
+ .help .hsteps>li:last-child:after{display:none;}
284
+ .help .hsteps .hst{font-size:13.5px; font-weight:700; margin-bottom:3px;}
285
+ .help .hsteps .hsd{font-size:12.5px; color:var(--muted); line-height:1.65;}
286
+ .help .promptcard{border:1px solid var(--line); border-radius:10px; overflow:hidden; margin:8px 0 4px;}
287
+ .help .promptcard .pchdr{display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 12px; background:var(--soft); border-bottom:1px solid var(--line2);}
288
+ .help .promptcard .pctitle{font-size:12px; font-weight:700; color:var(--fg);}
289
+ .help .promptcard pre{margin:0; padding:12px 14px; background:#0f1420; color:#d6e0ff; font-family:var(--mono); font-size:11.5px; line-height:1.6; white-space:pre-wrap; word-break:break-word; max-height:260px; overflow:auto;}
290
+ .help .promptcard .pccopy{font-size:11.5px; padding:4px 10px; white-space:nowrap;}
291
+ @media (max-width:820px){ .grid2,.mrow{grid-template-columns:1fr;} }
292
+ @media print{ header.bar{position:static;} .view{display:block!important;} .toolbar2,.rowact,.nodeact,.act,.seg{display:none;} details.sec[open]>.body,details.sec>.body{display:block;} }
293
+ </style>
294
+ </head>
295
+ <body>
296
+ <header class="bar">
297
+ <div class="brand"><span class="logo">YDA入稿プランナー</span><span class="pjname" id="pjLabel">—</span></div>
298
+ <!-- F-3: アカウント切替はヘッダーのプルダウンに一本化 -->
299
+ <span class="acchdr" title="アカウント切替(+新規追加/フォルダ接続/編集もここから)"><select class="chipin" id="accSelect" style="min-width:12ch"></select></span>
300
+ <div class="seg" id="viewSeg">
301
+ <button data-v="req">媒体要件</button>
302
+ <button data-v="sheet" class="active">プラン</button>
303
+ <button data-v="ref">アカウントアセット</button>
304
+ <button data-v="hist">履歴</button>
305
+ </div>
306
+ <span class="vbadge ok" id="vbadge" title="バリデーション状況">✓ 0件</span>
307
+ <div class="spacer"></div>
308
+ <button class="act ghost" id="btnHelp" title="このプランナーの使い方">❓ 使い方</button>
309
+ <button class="act ghost" id="btnUndo" title="元に戻す (Ctrl/Cmd+Z)">↶ 戻す</button>
310
+ <button class="act primary" id="btnSubmitJson">✅ 完成 → 保存して入稿</button>
311
+ <input type="file" id="fileInput" accept=".json,application/json" style="display:none">
312
+ <input type="file" id="accFileInput" accept=".json,application/json" style="display:none">
313
+ </header>
314
+ <div id="planWarn" class="planwarn" hidden><span id="planWarnMsg"></span><button id="planWarnClose" class="planwarnx" title="閉じる">✕</button></div>
315
+
316
+ <main>
317
+ <!-- ============ 媒体要件ビュー ============ -->
318
+ <section class="view" id="view-req">
319
+ <div class="doc">
320
+ <div class="seg" id="mediaTabs" style="display:inline-flex; margin-bottom:16px">
321
+ <button data-mt="yda" class="active">YDA</button>
322
+ <button data-mt="meta">Meta広告 <span class="pill amber" style="margin-left:4px">検証中</span></button>
323
+ </div>
324
+
325
+ <!-- ======== YDA タブ ======== -->
326
+ <div id="mt-yda">
327
+ <div class="eyebrow">YDA / Yahoo Display Ads — 媒体要件・仕様・使い方</div>
328
+ <h1 class="title">YDA 広告入稿 媒体要件</h1>
329
+ <p class="sub">定例ヒアリング・運用フィードバック + 実案件の一括入稿シートの実データ解析にもとづく。2026-07-23 よりCSV書き出しを廃止し、保存・入稿受け渡しはJSONに統一。</p>
330
+
331
+
332
+ <div class="seg" id="ydaTabs" style="display:inline-flex; margin-bottom:14px">
333
+ <button data-yt="overview" class="active">概要</button>
334
+ <button data-yt="resp">レスポンシブ</button>
335
+ <button data-yt="banner">バナー(手入稿)</button>
336
+ <button data-yt="param">パラメータ</button>
337
+ <button data-yt="guide">使い方</button>
338
+ </div>
339
+
340
+ <div id="yt-overview">
341
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">全体像 — 3層構造と量産の考え方</span><span class="chev"></span></summary>
342
+ <div class="body">
343
+ <p class="lead">YDA運用の核は「<b>変数=キャンペーン</b>」「<b>クリエイティブ束=広告グループ内の広告</b>」という2軸の量産構造。キャンペーンを横に増やし、広告を縦に増やす。</p>
344
+ <table class="g"><thead><tr><th>階層</th><th>意味</th><th>このツールでの単位</th></tr></thead><tbody>
345
+ <tr><td><b>キャンペーン</b></td><td>検証したい<b>変数の隔離単位</b>。「変数の数だけキャンペーンを分ける」=全部同じにして1点だけ変えて比較する実験設計</td><td>ツリーの最上位ノード</td></tr>
346
+ <tr><td><b>広告グループ</b></td><td>1つの <code>【paramCode】</code> に対応。キャンペーンID・広告グループIDはこの paramCode から解決される</td><td>中間ノード(paramCodeを保持)</td></tr>
347
+ <tr><td><b>広告</b></td><td>1クリエイティブ=画像 × タイトル × 説明文 の1通りの組み合わせ(=固定型)</td><td>葉ノード / シート1行</td></tr>
348
+ </tbody></table>
349
+ <div class="grid2">
350
+ <div class="kv"><h4><span class="ratio">1 : 1 : 4</span> 基本の量産単位</h4><p>1キャンペーン・1広告グループに<b>4本</b>の広告を並べ、アルゴリズムに良いものを選ばせる。</p></div>
351
+ <div class="kv"><h4><span class="ratio">1 : 1 : 10</span> 初期クリエイティブ</h4><p>立ち上げ期は<b>10本</b>を一気に撒いてデータを取り、伸びたものへ配信を寄せる “撒いて選ぶ”。</p></div>
352
+ </div>
353
+ <div class="callout">ツリーの各ノードの複製ボタンで、この <code>1:1:N</code> を1クリックで生成できる(テンプレ複製→中身クリア→変数差し替え)。</div>
354
+ </div></details>
355
+ <details class="sec"><summary><span class="num">2</span><span class="stitle">入稿モード — 新規 / 既存(ASC)</span><span class="chev"></span></summary>
356
+ <div class="body">
357
+ <table class="g"><thead><tr><th>モード</th><th>構造</th><th>予算/日</th><th>頻度・工数</th></tr></thead><tbody>
358
+ <tr><td><b>新規</b></td><td>1キャンペーン=1広告グループ=1広告を基本に、テンプレ複製で量産。変数ごとにキャンペーンを分ける</td><td><b>12,000円</b></td><td>頻度高・主戦場。既存より圧倒的に多い</td></tr>
359
+ <tr><td><b>既存(ASC)</b></td><td>1キャンペーン内に複数広告をまとめ、好調クリエイティブへ配信を寄せる(1:1:10 等)</td><td><b>5万〜10万円</b></td><td>予算大。担当者により幅</td></tr>
360
+ <tr><td>既存 - 広告グループ追加</td><td>既存キャンペーンに新しい広告グループを足す</td><td>既存準拠</td><td>工数中</td></tr>
361
+ <tr><td>既存 - 既存グループへ広告追加</td><td>既存グループに広告(クリエイティブ)だけ足す</td><td>既存準拠</td><td>工数小(操作感は同じ)</td></tr>
362
+ </tbody></table>
363
+ <div class="callout amber">既存モードは Yahoo払出し済みの campaignId / adGroupId の取り込みが前提。paramCode がグループ参照表に無い=<b>新規/未払出</b>として黄色表示になる(実シートの <code>#N/A</code> に相当)。</div>
364
+ </div></details>
365
+ <details class="sec"><summary><span class="num">3</span><span class="stitle">運用フロー — 北さんの複製量産 / 戦略の後乗せ</span><span class="chev"></span></summary>
366
+ <div class="body">
367
+ <ol>
368
+ <li>完成キャンペーンを<b>テンプレとして丸ごと複製</b>する</li>
369
+ <li>複製の中身(クリエイティブ/リンク/固有パラメータ)を<b>一旦消す</b></li>
370
+ <li>変えたい<b>変数だけ差し替える</b></li>
371
+ <li>これを検証したい変数の数だけ繰り返し量産する</li>
372
+ <li>初期は 1:1:10 で撒き、数日データを見る</li>
373
+ <li>伸びた型で追加量産し、<b>戦略(配信戦略・訴求方針)は後から埋める</b></li>
374
+ </ol>
375
+ <div class="callout green">このツールはツリーの複製ダイアログで「何を残し・何をクリアするか」を選べる。キャンペーンの「量産」で M本を一括生成。<b>戦略メモ欄は空のまま量産→後乗せ</b>で埋められる。</div>
376
+ <div class="callout"><b>本ツールの立ち位置(2026-07-16 定例、2026-07-23 更新)</b>:AIで生成できる部分は<b>AIに生成させ(JSON読込)</b>、絶対に人が確定する部分(クリエイティブ選定・確認)を<b>スプシ的コピペ・一括入力・複製</b>で楽にする。ここで確認・修正して <b>OK なら 入稿JSON書き出し(=GO)</b>→ <code>webgram submit-plan --plan-file</code>(dry-run一括検証 → 本実行は1キャンペーンずつ・進捗書き戻しで再開可能)。CSVを介さない直接入稿へ移行済み。今後はキャンペーン/広告グループIDのAPI/MCP自動取得、新規画像のメディア登録の自動化へ。</div>
377
+ </div></details>
378
+ </div>
379
+ <div id="yt-resp" style="display:none">
380
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">レスポンシブ広告 — アセット型 / 固定型(本ツールの主対象)</span><span class="chev"></span></summary>
381
+ <div class="body">
382
+ <p class="lead">レスポンシブ = <b>動画 or 静止画 + テキスト</b> を組み合わせる形式。本ツールが扱うのは<b>画像・固定型</b>(⭐️⭐️ ちゃんと検証する用途)。</p>
383
+ <table class="g"><thead><tr><th>型</th><th>仕組み</th><th>利用</th><th>本ツール</th></tr></thead><tbody>
384
+ <tr><td><b>固定型</b></td><td>タイトル×説明文×画像を<b>人が1通りに固定</b>して入稿し、成果を自分で検証する。1広告=1組み合わせ</td><td>⭐️⭐️ 検証の主戦場</td><td><span class="pill green">主対象</span> シート/ツリーで量産し入稿JSONへ</td></tr>
385
+ <tr><td>アセット型</td><td>複数のタイトル/説明文/画像を登録すると<b>Yahoo側が自動で組み合わせ最適化</b>(「適当に入れたらいい感じにやってくれる」)</td><td>検証には不向き(どの組合せが効いたか分離できない)</td><td><span class="pill amber">非対象</span></td></tr>
386
+ <tr><td>動画</td><td>動画 + テキスト。サムネイル画像は <code>thumbnailFlg:'TRUE'</code> の専用登録</td><td>—</td><td><span class="pill gray">別フロー</span> <code>webgram upload-video → submit</code>(本番実績あり)</td></tr>
387
+ </tbody></table>
388
+ <div class="grid2" style="margin-top:10px">
389
+ <div class="kv"><h4>画像の2形式</h4><p><code>Card</code> = 1200×628 / <code>SQ</code> = 1080×1080。1つの画像番号に両形式の mediaId を持ち、広告行の「フォーマット」で選ぶ。</p></div>
390
+ <div class="kv"><h4>検証の型</h4><p>1キャンペーン=1変数で分離し、初期は <span class="ratio">1:1:10</span> で撒いて選ぶ → 伸びた型で追加量産(詳細は「概要」タブ)。</p></div>
391
+ </div>
392
+ </div></details>
393
+ <details class="sec" open><summary><span class="num">2</span><span class="stitle">広告形式 — 対応/非対応(入稿経路)</span><span class="chev"></span></summary>
394
+ <div class="body">
395
+ <p class="lead">本ツールが扱うのは <b>レスポンシブ(画像)・固定型</b>。入稿は「入稿JSON書き出し(入稿計画JSON)→ <code>webgram submit-plan</code>」のAPI入稿に統一(CSVは2026-07-23廃止)。</p>
396
+ <table class="g"><thead><tr><th>形式</th><th>入稿経路</th><th>利用</th><th>備考</th></tr></thead><tbody>
397
+ <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>
398
+ <tr><td>レスポンシブ(画像)・アセット型</td><td><span class="pill amber">非対象</span></td><td>—</td><td>複数タイトル/画像を登録しYahoo側が自動最適化。<b>1広告=単一組合せの本ツールでは非対象</b></td></tr>
399
+ <tr><td>レスポンシブ(動画)</td><td><span class="pill gray">別フロー(CLI)</span></td><td>—</td><td><code>webgram upload-video → submit</code> のAPI入稿(本番実績あり)。<code>thumbnailFlg:'TRUE'</code> のサムネ画像は動画専用で、画像広告のメイン画像とは排他</td></tr>
400
+ <tr><td>バナー(静止画・固定サイズ)</td><td><span class="pill danger">手入稿</span></td><td>★★★</td><td>API入稿は未対応。Yahoo管理画面で個別入稿。本ツールでは <span class="pill">手入稿</span> フラグで区別し、入稿JSONから除外される</td></tr>
401
+ <tr><td>カルーセル / テキスト</td><td><span class="pill gray">未使用</span></td><td>—</td><td>運用では使わない</td></tr>
402
+ </tbody></table>
403
+ </div></details>
404
+ <details class="sec"><summary><span class="num">3</span><span class="stitle">固定デフォルト値 — 案件テンプレとして一括適用</span><span class="chev"></span></summary>
405
+ <div class="body">
406
+ <p class="lead">全広告に共通する固定値は「案件テンプレ」で1箇所定義し全広告へ一括適用する(現行の全行ハードコードを廃止)。</p>
407
+ <table class="g"><thead><tr><th>項目</th><th>既定値</th><th>備考</th></tr></thead><tbody>
408
+ <tr><td>アトリビューション計測期間</td><td><b>1日</b></td><td>媒体既定7日を毎回1日に変更する慣習</td></tr>
409
+ <tr><td>CTAボタン</td><td><b>もっとみる</b></td><td>画像入稿の実データ既定。動画入稿は「詳細を見る」。<span class="pill amber">要確定</span></td></tr>
410
+ <tr><td>入札戦略</td><td><b>tCPA 8,500円</b></td><td>API値は <code>MAXIMIZE_CONVERSIONS + targetCpa:8500</code>。もう一択は「最大配信」</td></tr>
411
+ <tr><td>予算</td><td>新規 12,000 / ASC 50,000〜</td><td>モードで切替</td></tr>
412
+ <tr><td>配信設定</td><td><b>オフ (PAUSED)</b></td><td>誤配信防止。入稿後に手動オン</td></tr>
413
+ <tr><td>広告タイプ / 形式</td><td>レスポンシブ / 画像</td><td>固定(API: <code>RESPONSIVE_AD + IMAGE</code>)</td></tr>
414
+ <tr><td>主体者表記</td><td><b>アカウントJSON由来</b></td><td>案件固定(薬機/景表対応)。アカウント/&lt;名前&gt;.json の <code>principal</code>。API: <code>responsiveAd.principal</code></td></tr>
415
+ <tr><td>表示URLの形式</td><td>ドメインのみ表示する</td><td>固定(API: <code>displayUrlLevel:'DOMAIN'</code>)</td></tr>
416
+ </tbody></table>
417
+ </div></details>
418
+ <details class="sec"><summary><span class="num">4</span><span class="stitle">命名規則・生成式(実データ突合済み)</span><span class="chev"></span></summary>
419
+ <div class="body">
420
+ <p class="lead">派生値はデータに保存せず、都度この式で算出する(行オフセット依存の数式を排除)。例は実データ <code>957c-45L60-jv_Cac_148</code> で検証。</p>
421
+ <div class="fx"><span class="c">// CR名</span>
422
+ crName = 画像番号 + (形式=="SQ" ? "<span class="k">s</span>" : "<span class="k">c</span>") <span class="c">→ 957c</span>
423
+ <span class="c">// 広告名 (col5)</span>
424
+ adName = crName + "-" + タイトル番号 + "<span class="k">L</span>" + 説明文番号 + "-" + paramCode
425
+ <span class="c">→ 957c-45L60-jv_Cac_148</span>
426
+ <span class="c">// キャンペーン/広告グループID … paramCode で参照表を引く</span>
427
+ campaignId, adGroupId = groups[ paramCode ] <span class="c">→ 32525263 / 451760490</span>
428
+ <span class="c">// メディアID … 画像番号+形式で参照表を引く</span>
429
+ mediaId = media[ 画像番号 ][ 形式 ] <span class="c">→ 21667629</span>
430
+ <span class="c">// タイトル/説明文 … 番号で文言ライブラリを引く</span>
431
+ title = titles[ タイトル番号 ] desc = titles[ 説明文番号 ]
432
+ <span class="c">// 最終リンク先URL(入稿JSONの finalUrl)</span>
433
+ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
434
+ <div class="grid2" style="margin-top:12px">
435
+ <div class="kv"><h4>キャンペーン名</h4><p class="mono" style="font-size:12px">{空き番号}_{CR名}_{YYYY-MM-DD}</p><p>空き番号は使用済みから未使用を割当(連番でなくてよい・被らないことだけ守る)。</p></div>
436
+ <div class="kv"><h4>入稿リンク末尾番号</h4><p>ベースURLは案件固定、末尾パラメータ番号のみ可変。既存の使用済み番号から空きを自動割当。</p></div>
437
+ </div>
438
+ </div></details>
439
+ </div>
440
+ <div id="yt-banner" style="display:none">
441
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">バナー(静止画・固定サイズ)— 手入稿運用 ⭐️⭐️⭐️</span><span class="chev"></span></summary>
442
+ <div class="body">
443
+ <p class="lead">利用頻度は高い(⭐️⭐️⭐️)が、<b>媒体としては対応しているのに自社ツール側が未対応</b>のため、Yahoo広告管理画面での<b>手入稿</b>で運用する形式。</p>
444
+ <table class="g"><thead><tr><th>項目</th><th>内容</th></tr></thead><tbody>
445
+ <tr><td>なぜ手入稿か</td><td>旧: 自社CSVがバナー非対応(1年前に挫折) → 現: API入稿も画像レスポンシブ優先で<b>バナー(BANNER_AD)は未実装</b>。媒体側の機能としては存在する</td></tr>
446
+ <tr><td>プランナーでの扱い</td><td>広告行の <span class="pill">手入稿</span> フラグでバナーを区別。<b>入稿JSONからは自動除外</b>され、バリデーション件数にも「手入稿」として別カウントされる</td></tr>
447
+ <tr><td>手入稿時に使う情報</td><td>プラン上の キャンペーン名 / 広告グループ名(paramCode) / 画像 / 最終リンク先URL(<code>?ad=paramCode</code>)/ 広告名(命名規則はレスポンシブと共通)をそのまま管理画面へ転記</td></tr>
448
+ <tr><td>手順</td><td>① プランでバナー行に「手入稿」フラグを立てる ② レスポンシブ分は通常どおり入稿JSONでAPI入稿 ③ バナー分のみ管理画面から個別入稿 ④ 入稿後に一覧でOK確認</td></tr>
449
+ <tr><td>将来対応</td><td>API での <code>BANNER_AD</code> 作成対応を検討中(未着手)。対応後は手入稿フラグを外すだけで同じフローに乗る想定</td></tr>
450
+ </tbody></table>
451
+ </div></details>
452
+ </div>
453
+ <div id="yt-param" style="display:none">
454
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
455
+ <div class="body">
456
+ <table class="g"><thead><tr><th>場所</th><th>入力項目</th><th>入稿JSONへの反映</th></tr></thead><tbody>
457
+ <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>
458
+ <tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 検証変数 / 入稿リンク番号 / 戦略メモ</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
459
+ <tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / <b>ターゲティング列</b>(デバイス・性別・年齢・地域・曜日時間帯・オーディエンス・サーチKW)</td><td>name・status・devices/ages/genders/days(+startHour/endHour)。地域・オーディエンス・サーチKWは未反映</td></tr>
460
+ <tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 記事slug / 手入稿フラグ</td><td>ads[].adName・mediaIds・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
461
+ </tbody></table>
462
+ </div></details>
463
+ <details class="sec" open><summary><span class="num">2</span><span class="stitle">入稿JSON — 構造と入力項目・制約(パラメータ)</span><span class="chev"></span></summary>
464
+ <div class="body">
465
+ <p class="lead">「入稿JSON書き出し」はワンクリックで、<b>画面のプランをそのまま</b>入稿計画JSON(<code>planVersion:1</code>)に変換する(設定ダイアログなし)。<code>webgram submit-plan --plan-file &lt;このファイル&gt;</code> が dry-run で全キャンペーンを一括検証し、本実行では1キャンペーンずつ入稿・進捗をファイルに書き戻す(再開可能・二重入稿防止)。広告は backend <code>ydaAdSchema</code> の IMAGE 形式。手入稿(バナー)と未解決の広告は自動除外。</p>
466
+ <div class="fx">{ planVersion: 1, platform: "yda", accountId,
467
+ campaigns: [ { name, goal, status, startDate, budget, biddingStrategy, targetCpa?, cpc?,
468
+ adGroups: [ { name, status, smartTargeting, devices, ages, genders, days, startHour?, endHour?,
469
+ placementsInclude, placementsExclude, audiencesInclude, audiencesExclude,
470
+ ads: [ { adName, status, mediaType:"IMAGE", mediaIds[], headlines[], descriptions[],
471
+ principal, finalUrl } ] } ] } ],
472
+ progress?: [...] // submit-plan が書き戻す進捗 (再開・二重入稿防止) }</div>
473
+ <table class="g"><thead><tr><th>項目</th><th>画面上の出どころ</th><th>変換・制約</th></tr></thead><tbody>
474
+ <tr><td class="mono">accountId</td><td><b>案件設定「アカウントID」</b></td><td>必須。未入力だと書き出し中止</td></tr>
475
+ <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>
476
+ <tr><td class="mono">startDate</td><td><b>案件設定「開始日」</b></td><td>YYYY-MM-DD。空欄=書き出し日</td></tr>
477
+ <tr><td class="mono">status</td><td>キャンペーン行/広告グループ行の「配信設定」</td><td><code>オン→ACTIVE</code> / <code>オフ→PAUSED</code>(既定オフ=誤配信防止)</td></tr>
478
+ <tr><td class="mono">budget</td><td>キャンペーン行「1日の予算」</td><td>未入力時はモード別既定(新規12,000/ASC50,000)</td></tr>
479
+ <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>
480
+ <tr><td class="mono">devices</td><td>広告グループ行「デバイス」</td><td><code>スマートフォン→SMARTPHONE</code> / <code>PC→DESKTOP</code> / <code>タブレット→TABLET</code>。全てのデバイス/空=全配信(<code>[]</code>)</td></tr>
481
+ <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>
482
+ <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>
483
+ <tr><td class="mono">audiences* / placements*</td><td>—</td><td>常に空配列で送信(backendスキーマ必須項目)。<b>「地域」「オーディエンスリスト」「サーチキーワード」列はJSONに反映されない</b>(APIのID解決が必要なため。入稿後に管理画面で設定 or 将来対応)</td></tr>
484
+ <tr><td class="mono">ads[].adName</td><td><span class="pill">自動</span></td><td>生成式 <code>{画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode}</code></td></tr>
485
+ <tr><td class="mono">ads[].mediaIds</td><td><span class="pill">自動</span></td><td>画像番号+形式 → 参照表の mediaId。未解決は入稿対象外</td></tr>
486
+ <tr><td class="mono">ads[].headlines</td><td><span class="pill purple">可変</span></td><td>タイトル番号 → 文言。<b>35字以内</b>(API上のアセット制約はタイトル≤20字・説明文≤90字)</td></tr>
487
+ <tr><td class="mono">ads[].descriptions</td><td><span class="pill purple">可変</span></td><td>説明文番号 → 文言</td></tr>
488
+ <tr><td class="mono">ads[].finalUrl</td><td><span class="pill">自動</span></td><td>生成式 <code>baseURL + slug + "?ad=" + paramCode + "&creative=..."</code>(実データ準拠)</td></tr>
489
+ </tbody></table>
490
+ <div class="callout">プランナーの保存JSON(バージョン保存)はこれとは別物で、<code>schemaVersion:2</code> の全state(meta / project / campaigns / ui / refs / submit)を保持する。<b>meta</b> = バージョンID・作成/更新日時・作成者・対象媒体・名称・メモ。旧v1 JSONも読込時に自動マイグレーションされる。</div>
491
+ </div></details>
492
+ <details class="sec"><summary><span class="num">3</span><span class="stitle">制約・バリデーション</span><span class="chev"></span></summary>
493
+ <div class="body">
494
+ <ul>
495
+ <li><b>タイトル 35文字以内</b>(タイトル集ヘッダに明記)。超過はシート/ツリーで赤表示。</li>
496
+ <li><b>メディアID未解決</b>:画像番号が参照表に無い→入稿不可(実シートの <code>#N/A</code>)。赤エラー。</li>
497
+ <li><b>タイトル/説明文番号未解決</b>:文言ライブラリに無い→赤エラー。</li>
498
+ <li><b>paramCode未払出</b>:参照表に無い→黄色警告(新規は想定内、既存は要確認)。</li>
499
+ <li>説明文は同じ文言ライブラリを共有するが、タイトルより長文可のため35字制限は<b>タイトル枠のみ</b>に適用。</li>
500
+ </ul>
501
+ </div></details>
502
+ </div>
503
+ <div id="yt-guide" style="display:none">
504
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">使用手順 — 保存・履歴・入稿の流れ</span><span class="chev"></span></summary>
505
+ <div class="body">
506
+ <ol>
507
+ <li><b>プラン作成</b>:プランビューは「戦略メモ → 案件設定 → シート/ツリー切替」の順。シート選択時はさらに広告/広告グループ/キャンペーンのタブで編集する。AI が生成した state JSON は「JSON読込」で取り込む(旧v1形式も自動マイグレーション)。</li>
508
+ <li><b>確認</b>:バリデーションバッジ(タイトル35字超・参照未解決・paramCode未払出)が緑になるまで修正。手入稿バナーは「手入稿」フラグで区別。</li>
509
+ <li><b>バージョン保存</b>:ヘッダーの「バージョン保存」。保存のたびに<b>常に新バージョン</b>(v1, v2…)として名称・メモ・作成者付きで保存される(上書きなし)。<b>履歴本体はブラウザ内に自動保存</b>されるため、フォルダ設定なしでも一覧・再読込できる。</li>
510
+ <li><b>保存フォルダ接続(初回に1回だけ)</b>:履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
511
+ <li><b>履歴の確認・再利用</b>:履歴ビューで一覧(Ver/日時/媒体/名称)を確認し、「読込」で過去バージョンを復元、「編集」で名称・メモ変更、「削除」で破棄。</li>
512
+ <li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID・目的・開始日は案件設定チップ、ターゲティングは広告グループ行の列から自動変換される(<code>*_入稿計画_*.json</code>)。</li>
513
+ <li><b>入稿(dry-run → 本実行)</b>:<code>webgram submit-plan --plan-file &lt;入稿計画&gt;</code>。dry-run が全キャンペーンを一括検証 → OKなら <code>--no-dry-run --yes</code> で本実行(1キャンペーンずつ入稿・進捗書き戻しで中断後も再開可能)。実行後は管理画面の一覧でOK確認。</li>
514
+ </ol>
515
+ </div></details>
516
+ <details class="sec" open><summary><span class="num">2</span><span class="stitle">注意事項・未対応事項(要確認)</span><span class="chev"></span></summary>
517
+ <div class="body">
518
+ <ul>
519
+ <li><b>バナー(固定サイズ)はAPI未対応</b> → 引き続き管理画面で手入稿。入稿JSONからは自動除外される。</li>
520
+ <li><b>画像レスポンシブのAPI入稿(PR #82)は実API疎通が未検証</b>。本入稿前に必ず dry-run → 少数テスト入稿で確認。</li>
521
+ <li>CTA既定が「もっとみる」(画像) か「詳細を見る」(動画) か。画像/動画でCTA既定が異なる可能性。<code>buttonText</code> のenum値も要実測。</li>
522
+ <li>年齢区分enumは実装内に系統差あり(<code>GT_RANGE*</code> が本番採用)。公式ドキュメント裏取りは未記録のため本入稿前に確認推奨。</li>
523
+ <li>新規モードで campaignId/adGroupId 未払出のときの挙動(キャンペーン新規作成 or 既存への広告追加のみ)の実機確認。</li>
524
+ <li>画像サムネイルURLの入手経路(現状は番号+メディアIDバッジ表示)。</li>
525
+ <li>参照表(タイトル集/メディアID/グループID)の更新運用。Yahoo払出し後に増えるため、参照データビューまたはJSON差し替えで最新化。</li>
526
+ <li>フォルダ連携(File System Access API)はChrome系のみ。Safariなどはダウンロードにフォールバックされる。</li>
527
+ </ul>
528
+ </div></details>
529
+ </div>
530
+ </div><!-- /mt-yda -->
531
+
532
+ <!-- ======== Meta タブ(検証中) ======== -->
533
+ <div id="mt-meta" style="display:none">
534
+ <div class="eyebrow">Meta Ads — 検証範囲・要件一覧(ドラフト)</div>
535
+ <h1 class="title">Meta 広告入稿 <span class="pill amber" style="vertical-align:middle">検証中</span></h1>
536
+ <p class="sub">2026-07-23 時点のドラフト。backend/CLI には入稿実装が存在するが、<b>プランナーGUIは未対応</b>・実運用検証は未着手。北さんヒアリング(YDA入稿Skillブラッシュアップ)と backend 実装(metaSubmission.ts)にもとづく要件案。</p>
537
+
538
+ <details class="sec" open><summary><span class="num">1</span><span class="stitle">概要 — 実装済みの土台</span><span class="chev"></span></summary>
539
+ <div class="body">
540
+ <p class="lead">API側の土台は実装済み。GUI(プランナー)と実運用フローが未整備という状態。</p>
541
+ <table class="g"><thead><tr><th>レイヤ</th><th>状態</th><th>内容</th></tr></thead><tbody>
542
+ <tr><td>backend</td><td><span class="pill green">実装済み</span></td><td><code>/api/submission/meta</code> 系ルート + <code>MetaSubmissionConnector</code>(オーディエンス取得 / 既存キャンペーン取得・インポート / キャンペーン作成 / 動画ライブラリ)</td></tr>
543
+ <tr><td>CLI</td><td><span class="pill green">実装済み</span></td><td><code>webgram submit --platform meta</code>(動画は upload-video で mediaId 解決 → submit。dry-run 対応)</td></tr>
544
+ <tr><td>プランナーGUI</td><td><span class="pill danger">未対応</span></td><td>Meta用のシート/ツリー/参照データ/入稿JSON書き出しは未実装(本タブは仕様整理のみ)</td></tr>
545
+ <tr><td>実運用検証</td><td><span class="pill amber">未着手</span></td><td>本番アカウントでの PoC 入稿・運用フロー確立が未実施</td></tr>
546
+ </tbody></table>
547
+ </div></details>
548
+
549
+ <details class="sec" open><summary><span class="num">2</span><span class="stitle">対応広告形式・入稿パターン(ヒアリングベース)</span><span class="chev"></span></summary>
550
+ <div class="body">
551
+ <table class="g"><thead><tr><th>パターン</th><th>頻度・工数</th><th>内容</th></tr></thead><tbody>
552
+ <tr><td><b>キャンペーンごと作成</b></td><td>★★★ 頻度高・工数大</td><td>キャンペーン設定 → 広告グループ設定 → 広告設定 を毎回一式作る。主戦場</td></tr>
553
+ <tr><td>広告グループごと追加</td><td>★★</td><td>既存キャンペーンに広告グループを追加</td></tr>
554
+ <tr><td>既存広告追加</td><td>★</td><td>既存グループにクリエイティブだけ追加(ASC的運用)</td></tr>
555
+ </tbody></table>
556
+ <p>クリエイティブは動画中心(動画ライブラリ + upload-video 実装済み)。画像・カルーセルの範囲は要確定。</p>
557
+ </div></details>
558
+
559
+ <details class="sec" open><summary><span class="num">3</span><span class="stitle">入力項目と制約(要件一覧ドラフト)</span><span class="chev"></span></summary>
560
+ <div class="body">
561
+ <table class="g"><thead><tr><th>階層</th><th>項目</th><th>備考</th></tr></thead><tbody>
562
+ <tr><td><b>キャンペーン</b></td><td>名前 / 戦略 / 目標値 / 予算 / ステータス</td><td>戦略は「コンバージョン数の最大化」、目標値(目標単価)はキャンペーンごとに別々</td></tr>
563
+ <tr><td rowspan="7"><b>広告グループ<br>(7項目)</b></td><td>スマートターゲティング</td><td>Advantage+ 系の自動ターゲティング可否</td></tr>
564
+ <tr><td>曜日・時間帯</td><td>配信スケジュール</td></tr>
565
+ <tr><td>デバイス</td><td>配信デバイス</td></tr>
566
+ <tr><td>性別</td><td>—</td></tr>
567
+ <tr><td>年齢</td><td>—</td></tr>
568
+ <tr><td>オーディエンスリスト</td><td>どのデータ(カスタム/類似オーディエンス)に配布するか。<code>fetchAudiences</code> で取得可</td></tr>
569
+ <tr><td>プレイスメント</td><td>配信面の指定</td></tr>
570
+ <tr><td><b>広告</b></td><td>名前 / クリエイティブ(動画 or 画像)/ テキスト / リンク先URL / CTA</td><td>動画は動画ライブラリの videoId、画像は imageHash(<code>parseCreative</code> が判別)</td></tr>
571
+ </tbody></table>
572
+ </div></details>
573
+
574
+ <details class="sec" open><summary><span class="num">4</span><span class="stitle">検証範囲(案)と手順</span><span class="chev"></span></summary>
575
+ <div class="body">
576
+ <ol>
577
+ <li><b>契約確認</b>:<code>webgram submit --platform meta --json-file fixtures/meta_poc.json</code> を dry-run で実行し、送信ボディの構造を確認する。</li>
578
+ <li><b>既存構造の取り込み</b>:既存キャンペーンのインポート(<code>importCampaign</code>)で、実運用中のキャンペーン設定(7項目)を吸い上げてテンプレ化できるか検証。</li>
579
+ <li><b>動画アップロード</b>:upload-video → 動画ライブラリ反映 → 広告作成の一連を dry-run→少額 PoC で確認。</li>
580
+ <li><b>本番PoC</b>:PAUSED で1キャンペーン入稿 → 管理画面で目視確認 → 問題なければ配信オン。</li>
581
+ <li><b>GUI対応判断</b>:PoC の結果を踏まえ、プランナーのMeta対応(媒体切替・Meta用参照データ・入稿JSON書き出し)の要件を確定する。</li>
582
+ </ol>
583
+ </div></details>
584
+
585
+ <details class="sec" open><summary><span class="num">5</span><span class="stitle">未確定事項</span><span class="chev"></span></summary>
586
+ <div class="body">
587
+ <ul>
588
+ <li>Meta広告フィードバックの所在と最終確認者(タスク確認事項より・未回答)。</li>
589
+ <li>命名規則(YDAの <code>{画像番号}{c|s}-{title}L{desc}-{paramCode}</code> に相当するもの)をMetaでどう定義するか。</li>
590
+ <li>クリエイティブ検証の単位(YDAの「1キャンペーン=1変数」に相当する実験設計)。</li>
591
+ <li>プランナーGUIでのMeta対応範囲(シート列構成・参照データの持ち方)。</li>
592
+ </ul>
593
+ </div></details>
594
+ </div><!-- /mt-meta -->
595
+ </div>
596
+ </section>
597
+
598
+ <!-- ============ シートビュー ============ -->
599
+ <!-- ============ プランビュー (戦略メモ → 案件設定 → シート/ツリー) ============ -->
600
+ <section class="view active" id="view-sheet">
601
+ <!-- E-2: 戦略メモパネルは撤去 -->
602
+ <details class="cfgbox" id="cfgBox" open>
603
+ <summary>共通設定</summary>
604
+ <div class="chipset" id="tplChips"></div>
605
+ </details>
606
+ <!-- E-1: プランツールバー(タイプ固定プルダウンのみ。キャンペーン追加導線はシート/ツリー側に配置) -->
607
+ <div class="toolbar2" id="planTools" style="margin:0 0 10px">
608
+ <span class="chip" title="このプランナーはレスポンシブ(画像)固定です(変更不可)">タイプ
609
+ <select class="chipin" id="typeAdType" disabled title="広告タイプ(固定)"><option>レスポンシブ</option></select>
610
+ <select class="chipin" id="typeMediaFormat" disabled title="メインメディアの形式(固定)"><option>画像</option></select>
611
+ </span>
612
+ </div>
613
+ <div class="seg" id="modeSeg" style="margin:0 0 10px">
614
+ <button data-m="sheet" class="active">シート</button>
615
+ <button data-m="tree">ツリー</button>
616
+ </div>
617
+ <div id="sheetArea">
618
+ <div class="seg" id="sheetTabs" style="margin:0 0 10px">
619
+ <button data-st="ads" class="active">広告</button>
620
+ <button data-st="groups">広告グループ</button>
621
+ <button data-st="campaigns">キャンペーン</button>
622
+ </div>
623
+ <div class="toolbar2">
624
+ <button class="act ghost" id="btnDupSel" title="選択した行を複製">⧉ 選択を複製</button>
625
+ <button class="act ghost" id="btnSeqParam" style="display:none" title="選択した列(paramCode / 画像番号 / タイトル番号 / 説明文番号)を先頭セル基点に連番採番">🔢 連番</button>
626
+ <span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(スプシから一括)</span>
627
+ <div class="spacer" style="flex:1"></div>
628
+ <span class="filt">表示 <select id="sheetFilter">
629
+ <option value="all">すべて</option>
630
+ <option value="err">エラーのみ</option>
631
+ </select></span>
632
+ </div>
633
+ <div class="sheetwrap"><div id="sheetHost"></div></div>
634
+ </div>
635
+ <div id="treeArea" hidden>
636
+ <div class="toolbar2">
637
+ <div class="chipset"><span class="chip">キャンペーン <b id="cntCp">0</b></span><span class="chip">広告グループ <b id="cntAg">0</b></span><span class="chip">広告 <b id="cntAd">0</b></span></div>
638
+ <div class="spacer" style="flex:1"></div>
639
+ <button class="act ghost" id="btnExpandAll">すべて展開</button>
640
+ <button class="act ghost" id="btnCollapseAll">すべて折畳</button>
641
+ </div>
642
+ <div class="tree" id="treeHost"></div>
643
+ </div>
644
+ </section>
645
+
646
+ <!-- ============ アカウントアセットビュー ============ -->
647
+ <section class="view" id="view-ref">
648
+ <div class="toolbar2">
649
+ <div class="seg" id="refTabs">
650
+ <button data-rt="titles" class="active">タイトル</button>
651
+ <button data-rt="descriptions">説明文</button>
652
+ <button data-rt="media">登録画像</button>
653
+ <button data-rt="adGroups">広告グループ</button>
654
+ <button data-rt="campaigns">キャンペーン</button>
655
+ </div>
656
+ <span class="filt">検索 <input id="refSearch" type="text" placeholder="番号・文言・IDで絞り込み" style="width:220px"></span>
657
+ <div class="spacer" style="flex:1"></div>
658
+ <span class="chip" id="refCount">—</span>
659
+ <button class="act" id="btnRefAdd">+ 新規登録</button>
660
+ <button class="act ghost" id="btnAccCopy" title="他アカウントからタイトル/説明文をコピー">⎘ 他アカウントから文言コピー</button>
661
+ <button class="act ghost" id="btnRefReset" title="埋め込みの初期値に戻す">リセット</button>
662
+ </div>
663
+ <div class="sheetwrap"><div id="refHost"></div></div>
664
+ </section>
665
+
666
+ <!-- ============ 履歴ビュー ============ -->
667
+ <section class="view" id="view-hist">
668
+ <div class="toolbar2">
669
+ <div class="chipset"><span class="chip" id="fsStatus">フォルダ未接続</span></div>
670
+ <div class="spacer" style="flex:1"></div>
671
+ <button class="act" id="btnConnectFolder">📁 保存フォルダに接続</button>
672
+ </div>
673
+ <div class="sheetwrap" style="padding:14px 18px"><div id="histHost"></div></div>
674
+ </section>
675
+ </main>
676
+
677
+ <div id="modalRoot"></div>
678
+ <div id="dlHost" hidden></div>
679
+ <div class="drawer-backdrop" id="drawerBackdrop"></div>
680
+ <aside class="refdrawer" id="refDrawer" aria-hidden="true">
681
+ <div class="dh"><span class="dt" id="drawerTitle">一覧</span><button class="dx" id="drawerClose" title="閉じる">✕</button></div>
682
+ <div class="dsearch"><input type="text" id="drawerSearch" placeholder="番号・内容で検索(任意)"></div>
683
+ <div class="dtarget" id="drawerTarget"></div>
684
+ <div class="dlist" id="drawerList"></div>
685
+ </aside>
686
+ <div class="toast" id="toast"></div>
687
+
688
+ <script>
689
+ "use strict";
690
+ /* ============================================================
691
+ YDA入稿プランナー — 単一state / シート・ツリー両ビュー投影
692
+ ============================================================ */
693
+ /* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
694
+ 同梱デフォルト: アカウント/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
695
+ 固有名詞はハードコードせず、すべて外部JSON由来にする。 */
696
+ let bundledRefs=null; let bundledPrincipal=''; let bundledProjectName=''; let bundledBaseUrl=''; let samplePlan=null;
697
+ let planMismatchMsg=''; // サンプルプランと現在アカウントの accountId 不一致警告 (持続バナー用)
698
+ function fetchJson(rel){ try{ return fetch(rel,{cache:'no-store'}).then(r=>r.ok?r.json():null).catch(()=>null); }catch(e){ return Promise.resolve(null); } }
699
+ const LS_KEY = 'yda_planner_state_v1';
700
+ const HIST_KEY = 'yda_planner_history_v1';
701
+ const AUTHOR_KEY = 'yda_planner_author';
702
+ const SCHEMA_VERSION = 3;
703
+
704
+ /* ---- utils ---- */
705
+ let _uc = 0;
706
+ const uid = p => p + '_' + (Date.now().toString(36)) + (_uc++).toString(36);
707
+ const esc = s => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
708
+ const glen = s => [...String(s==null?'':s)].length;
709
+ const today = () => { const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
710
+ function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove('show'),2200); }
711
+
712
+ /* ---- refs (VLOOKUP代替) ----
713
+ B-1: refs は6テーブル (titles/descriptions/media/campaigns/adGroups/groups)。
714
+ 参照データの正は「アカウント/<名前>.json」で、フォルダ接続で差し替える (applyAccountJson)。
715
+ 同梱デフォルトアカウントは アカウント/index.json の先頭エントリを起動時に読み込んで決める(固有名詞のハードコードなし)。 */
716
+ let BUNDLED_ACCOUNT_ID = '';
717
+ let BUNDLED_ACCOUNT_NAME = '';
718
+ function emptyRefs(){ return {titles:{}, descriptions:{}, media:{}, campaigns:{}, adGroups:{}, groups:{}}; }
719
+ function validRefs(r){ return !!(r&&r.titles&&r.descriptions&&r.media&&r.groups); }
720
+ function normalizeRefs(r){ const e=emptyRefs(); if(!r) return e; return {titles:r.titles||{}, descriptions:r.descriptions||{}, media:r.media||{}, campaigns:r.campaigns||{}, adGroups:r.adGroups||{}, groups:r.groups||{}}; }
721
+ /* 案A: 同梱デフォルトアカウントの refs を起動時に外部JSONから読み込む(固有名詞ハードコードなし)。
722
+ descriptions は独立テーブル(titlesコピーの暫定を廃止)。 */
723
+ function buildRefs(){ return bundledRefs ? normalizeRefs(bundledRefs) : emptyRefs(); }
724
+ function defaultProject(){
725
+ return {
726
+ // 案件名・主体者・ベースURL・accountId は同梱デフォルトアカウント(アカウント/index.json 先頭)由来。未読込時は空
727
+ name: bundledProjectName || '',
728
+ principal: bundledPrincipal,
729
+ cta:'もっとみる', adType:'レスポンシブ', mainMediaFormat:'画像',
730
+ deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
731
+ attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
732
+ budgetNew:12000, budgetAsc:50000, baseArticleUrl: bundledBaseUrl || '',
733
+ // 入稿設定 (プランに内包。入稿JSON書き出し時にそのまま使う)。accountId/Name は同梱デフォルトアカウント
734
+ accountId:BUNDLED_ACCOUNT_ID, accountName:BUNDLED_ACCOUNT_NAME, goal:'CONVERSION', startDate:''
735
+ };
736
+ }
737
+ /* E-2: mode/MODES 廃止。新規/既存はID解決(isNewCampaign/isNewGroup)で自動判定。新規CPの予算は固定 12,000 円 */
738
+ const NEW_CP_BUDGET = 12000;
739
+
740
+ /* ---- initial state ---- */
741
+ function newAd(seed){ return Object.assign({uid:uid('a'), format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adIdOverride:'', desc2:''}, seed||{}); }
742
+ /* ---- schema v2: バージョンメタ ---- */
743
+ function defaultMeta(){ return {versionId:null, name:'', memo:'', author:'', platform:'yda', createdAt:null, updatedAt:null}; }
744
+ /* 数値化 (NaN/不正入力は null 維持) — B10 */
745
+ function numOrNull(v){ if(v===''||v==null) return null; const n=Number(v); return Number.isFinite(n)?n:null; }
746
+ /* 新規キャンペーンの個別既定値 (A-1: project既定はここでのみ供給し、以後はキャンペーン個別値運用) */
747
+ function cpDefaults(project, mode){ const p=project||st.project; return {budget: mode==='new'?p.budgetNew:p.budgetAsc, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault}; }
748
+ /* E-2: 新規キャンペーン生成 (予算は固定 12,000 円・空のAG1を1つ内包)。isExisting:false = 常に編集可 */
749
+ function makeNewCampaign(seed){ const p=st.project; return Object.assign({uid:uid('c'), name:'新規キャンペーン', campaignId:'', isExisting:false, variable:'', urlNumber:'', budget:NEW_CP_BUDGET, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault, adGroups:[{uid:uid('g'),name:'AG1',paramCode:'',isExisting:false,ads:[newAd()]}]}, seed||{}); }
750
+ /* E-3/F-2: 既存キャンペーン呼び出し (campaignIdを持つ広告追加専用の器)。isExisting:true = 基本パラメータ編集不可 */
751
+ function makeExistingCampaign(cpId, name){ return {uid:uid('c'), name:name||'', campaignId:String(cpId), isExisting:true, variable:'', urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; }
752
+ function initialState(){
753
+ const refs=buildRefs();
754
+ // 共通設定(project=案件名/主体者/アカウント等)はアカウントJSON由来。サンプルプランには campaigns と accountId のみ。
755
+ const project=defaultProject();
756
+ let campaigns=[];
757
+ planMismatchMsg='';
758
+ if(samplePlan && Array.isArray(samplePlan.campaigns)){
759
+ // アカウントIDとサンプルプランのaccountIdが異なる場合はエラー(不一致プランは読み込まない)
760
+ if(samplePlan.accountId && String(samplePlan.accountId)!==String(project.accountId)){
761
+ planMismatchMsg='⚠ サンプルプランのアカウントID('+samplePlan.accountId+')が現在のアカウント('+project.accountId+(project.accountName?' '+project.accountName:'')+')と一致しません。プランは読み込みません。正しいアカウントに切り替えるか、サンプルプランの accountId を合わせてください。';
762
+ if(typeof toast==='function') toast(planMismatchMsg);
763
+ } else {
764
+ campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
765
+ campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
766
+ }
767
+ }
768
+ return {schemaVersion:SCHEMA_VERSION, meta:defaultMeta(), project, refs, campaigns, ui:{expanded:{}}};
769
+ }
770
+
771
+ /* ---- persistence ---- */
772
+ function stateJson(){ const {meta,project,campaigns,ui,refs}=st; return {schemaVersion:SCHEMA_VERSION, meta, project, campaigns, ui, refs}; }
773
+ let _persistWarned=false;
774
+ function persist(){ /* localStorageプラン廃止: no-op。プランの永続化はサンプルプラン更新(サンプルプラン.json)で行う */ }
775
+ /* B12: SCHEMA_VERSION 不一致は後方互換なしで破棄→初期化。refs欠落時は再構築。 */
776
+ function load(){ return null; /* LSプラン廃止 */ try{ const j=JSON.parse(localStorage.getItem(LS_KEY)); if(!j||!j.campaigns||j.schemaVersion!==SCHEMA_VERSION) return null; const refs=validRefs(j.refs)?normalizeRefs(j.refs):buildRefs(); return {schemaVersion:SCHEMA_VERSION, meta:Object.assign(defaultMeta(),j.meta||{}), project:Object.assign(defaultProject(),j.project), refs, campaigns:j.campaigns, ui:j.ui||{expanded:{}}}; }catch(e){ return null; } }
777
+
778
+ /* localStorage のプラン永続化は廃止。プランの正はサンプルプラン.json(外部)。起動時に必ず読み込む。 */
779
+ let st = initialState();
780
+
781
+ /* ---- undo (bounded) ---- */
782
+ const undoStack=[];
783
+ function snapshot(){ undoStack.push(JSON.stringify({project:st.project,campaigns:st.campaigns,refs:st.refs})); if(undoStack.length>40) undoStack.shift(); }
784
+ let _dirty=false; // 未保存の編集があるか (離脱時警告用)
785
+ function markDirty(){ _dirty=true; }
786
+ function markSaved(){ _dirty=false; }
787
+ function mutate(fn){ snapshot(); fn(); _dirty=true; persist(); render(); }
788
+ function undo(){ const s=undoStack.pop(); if(!s){toast('これ以上戻せません'); return;} const j=JSON.parse(s); st.project=j.project; st.campaigns=j.campaigns; if(j.refs) st.refs=j.refs; _dirty=true; persist(); if(typeof buildDatalists==='function') buildDatalists(); render(); toast('元に戻しました'); }
789
+
790
+ /* ---- lookups ---- */
791
+ function eachAd(cb){ st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach(a=>cb(a,g,c)))); }
792
+ function findAd(u){ let r=null; st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach((a,i)=>{ if(a.uid===u) r={a,g,c,i}; }))); return r; }
793
+ function findGroup(u){ let r=null; st.campaigns.forEach(c=>c.adGroups.forEach((g,i)=>{ if(g.uid===u) r={g,c,i}; })); return r; }
794
+ function findCp(u){ const i=st.campaigns.findIndex(c=>c.uid===u); return i<0?null:{c:st.campaigns[i],i}; }
795
+
796
+ /* ---- resolve (派生値 / 純関数) ---- */
797
+ function resolveAd(ad, group){
798
+ const p=st.project, refs=st.refs;
799
+ const sizeKey = ad.format==='SQ'?'sq':'card';
800
+ const crName = String(ad.imageNo||'') + (ad.format==='SQ'?'s':'c');
801
+ const adName = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||'');
802
+ const me = refs.media[String(ad.imageNo)];
803
+ const mediaId = me ? me[sizeKey] : undefined;
804
+ const titleText = refs.titles[ad.titleNo];
805
+ const descText = (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
806
+ const finalUrl = (p.baseArticleUrl||'') + (ad.articleSlug||'') + '?ad=' + (group.paramCode||'') + '&creative=' + (group.paramCode||'') + '-' + adName;
807
+ const errs=[], warns=[];
808
+ // 画像を3分類: 番号空=真エラー / 番号あり&refs.mediaに無い=未登録(要アップロード=pendingImage) / 解決OK
809
+ const imgNoStr=String(ad.imageNo||'').trim();
810
+ const pendingImage = (imgNoStr!=='' && mediaId===undefined); // アカウントのメディアライブラリに未登録→先にアップロードして新規作成
811
+ if(imgNoStr==='') errs.push('画像未解決');
812
+ if(String(ad.titleNo||'')==='' || titleText===undefined) errs.push('タイトル未解決');
813
+ if(String(ad.descNo||'')==='' || descText===undefined) errs.push('説明文未解決');
814
+ if(titleText!==undefined && glen(titleText)>35) errs.push('タイトル35字超');
815
+ else if(titleText!==undefined && glen(titleText)>20) warns.push('タイトル20字超');
816
+ // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
817
+ if(String(group.paramCode||'').trim()==='') errs.push('paramCode未入力');
818
+ if(pendingImage) warns.push('画像未登録(要アップロード)'); // errではなくwarn: 新規作成対象として保持
819
+ // F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
820
+ const aid=String(group.adGroupId||'').trim();
821
+ return {crName, adName, campaignId:'', adGroupId:aid, imageNo:imgNoStr, mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText, finalUrl, errs, warns, resolvedGroup:!!aid};
822
+ }
823
+ function validationCounts(){ let err=0,warn=0,ads=0; eachAd((a,g)=>{ ads++; const r=resolveAd(a,g); if(r.errs.length) err++; else if(r.warns.length) warn++; }); return {err,warn,ads}; }
824
+
825
+ /* ============================================================
826
+ RENDER
827
+ ============================================================ */
828
+ let activeView='sheet';
829
+ let planMode='sheet'; // プランビュー内の表示モード: sheet / tree
830
+ let sheetTab='ads'; // シート内サブタブ: ads / groups / campaigns
831
+ function renderPlanWarn(){
832
+ const bar=document.getElementById('planWarn'); if(!bar) return;
833
+ if(planMismatchMsg){ document.getElementById('planWarnMsg').textContent=planMismatchMsg; bar.hidden=false; }
834
+ else bar.hidden=true;
835
+ }
836
+ function render(){
837
+ document.getElementById('pjLabel').textContent = st.project.name + ' / 主体者:' + (st.project.principal||'');
838
+ renderPlanWarn();
839
+ renderTplChips(); renderValidationBadge();
840
+ if(activeView==='sheet'){
841
+ document.getElementById('sheetArea').hidden = planMode!=='sheet';
842
+ document.getElementById('treeArea').hidden = planMode!=='tree';
843
+ if(planMode==='sheet') renderSheet(); else renderTree();
844
+ }
845
+ else if(activeView==='ref') renderRef();
846
+ else if(activeView==='hist') renderHist();
847
+ const c=validationCounts();
848
+ document.getElementById('cntCp').textContent=st.campaigns.length;
849
+ document.getElementById('cntAg').textContent=st.campaigns.reduce((n,c)=>n+c.adGroups.length,0);
850
+ document.getElementById('cntAd').textContent=c.ads;
851
+ }
852
+ function renderValidationBadge(){
853
+ const c=validationCounts(); const b=document.getElementById('vbadge');
854
+ b.className='vbadge '+(c.err?'err':(c.warn?'warn':'ok'));
855
+ b.textContent = c.err? ('⚠ エラー'+c.err+'件') : (c.warn? ('△ 警告'+c.warn+'件') : ('✓ '+c.ads+'件OK'));
856
+ b.title = 'エラー'+c.err+' / 警告'+c.warn+' / 広告'+c.ads;
857
+ }
858
+ function renderTplChips(){
859
+ const p=st.project;
860
+ const txt=(f,v,w)=>'<input class="chipin" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'8ch')+'">';
861
+ const num=(f,v,w)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'">';
862
+ const sel=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option'+(o===v?' selected':'')+'>'+esc(o)+'</option>').join('')+'</select>';
863
+ 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>';
864
+ const chip=(label,ctrl)=>'<span class="chip">'+esc(label)+' '+ctrl+'</span>';
865
+ // E-1: 案件名 / アカウントID / 主体者 / タイプ チップは撤去。
866
+ // - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
867
+ // - 主体者(principal)はアカウントJSON由来 (アカウント編集モーダルで変更)
868
+ // - タイプ(レスポンシブ/画像)はプランツールバーの固定プルダウン (変更不可)
869
+ document.getElementById('tplChips').innerHTML =
870
+ chip('目的', selv('goal',p.goal||'CONVERSION',[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},{v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}])) +
871
+ chip('開始日', txt('startDate',p.startDate||'','10ch')) +
872
+ chip('CTA', txt('cta',p.cta,'7ch')) +
873
+ chip('配信', sel('deliveryStatus',p.deliveryStatus,['オフ','オン'])) +
874
+ chip('計測', num('attributionDays',p.attributionDays,'3ch')+'日') +
875
+ chip('表示URL', txt('displayUrlFormat',p.displayUrlFormat,'13ch')) +
876
+ chip('記事URL', txt('baseArticleUrl',p.baseArticleUrl,'16ch'));
877
+ // A-1: 入札(tcpaDefault) / 予算新規 / 予算ASC チップは撤去。project既定は新規CP作成時の初期値供給のみに使い、以後はキャンペーン行で個別編集する。
878
+ }
879
+
880
+ /* ---- SHEET ---- */
881
+ function renderSheet(){
882
+ document.querySelectorAll('#sheetTabs button').forEach(b=>b.classList.toggle('active', b.dataset.st===sheetTab));
883
+ if(sheetTab==='groups') return renderSheetGroups();
884
+ if(sheetTab==='campaigns') return renderSheetCampaigns();
885
+ const filter=document.getElementById('sheetFilter').value;
886
+ const cols=['','グループ','形式','画像番号','タイトル番号','説明文番号','paramCode(グループ)','記事slug','広告名(自動)','CP / AG ID'];
887
+ let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'<th></th></tr></thead><tbody>';
888
+ let shown=0, ri=-1;
889
+ // F-8: ネスト(キャンペーン見出しエリア)を廃止し、1広告=1行のフラットな表にする
890
+ st.campaigns.forEach(c=>{
891
+ c.adGroups.forEach(g=>{
892
+ g.ads.forEach(a=>{
893
+ const r=resolveAd(a,g);
894
+ const hasErr=r.errs.length>0;
895
+ if(filter==='err' && !hasErr) return;
896
+ shown++; ri++;
897
+ const cls='adrow'+(hasErr?' hasErr':'');
898
+ html+='<tr class="'+cls+'" data-adrow="'+a.uid+'">';
899
+ html+='<td class="rownum" data-rownum="'+ri+'" title="行を選択('+esc(g.name)+')">'+(ri+1)+'</td>';
900
+ html+='<td><div class="cppick" data-agpick-ad="'+a.uid+'" title="クリックで所属グループを変更(プラン内の広告グループから選択)"><span class="breadc">'+esc(g.name)+'</span><span class="tgcaret">▾</span></div></td>';
901
+ html+='<td><select class="cell-in fmt" data-sel="1" data-r="'+ri+'" data-c="0" data-u="'+a.uid+'" data-f="format"><option'+(a.format==='Card'?' selected':'')+'>Card</option><option'+(a.format==='SQ'?' selected':'')+'>SQ</option></select></td>';
902
+ // 画像番号 : メディアID(1ボックス + ▼一覧)
903
+ html+='<td><div class="cellcombo'+(r.errs.includes('画像未解決')?' err':'')+'"><input class="cell-in num" data-sel="1" data-r="'+ri+'" data-c="1" data-u="'+a.uid+'" data-f="imageNo" value="'+esc(a.imageNo)+'"><span class="combosep">:</span><span class="ghost" data-d="'+a.uid+'-media" title="メディアID '+esc(r.mediaId)+'">'+mediaInline(a,r)+'</span><button class="combodd" data-dd="media" data-u="'+a.uid+'" data-f="imageNo" tabindex="-1" title="登録画像から選択">▾</button></div></td>';
904
+ // タイトル番号 : 文言(1ボックス + 字数 + ▼一覧)
905
+ html+='<td><div class="cellcombo'+((r.errs.includes('タイトル未解決')||r.errs.includes('タイトル35字超'))?' err':'')+'"><input class="cell-in num" data-sel="1" data-r="'+ri+'" data-c="2" data-u="'+a.uid+'" data-f="titleNo" value="'+esc(a.titleNo)+'"><span class="combosep">:</span><span class="ghost" data-d="'+a.uid+'-title" title="'+esc(r.titleText)+'">'+titleGhost(r)+'</span><button class="combodd" data-dd="titles" data-u="'+a.uid+'" data-f="titleNo" tabindex="-1" title="タイトル一覧から選択">▾</button></div></td>';
906
+ // 説明文番号 : 文言(1ボックス + ▼一覧)
907
+ html+='<td><div class="cellcombo'+(r.errs.includes('説明文未解決')?' err':'')+'"><input class="cell-in num" data-sel="1" data-r="'+ri+'" data-c="3" data-u="'+a.uid+'" data-f="descNo" value="'+esc(a.descNo)+'"><span class="combosep">:</span><span class="ghost" data-d="'+a.uid+'-desc" title="'+esc(r.descText)+'">'+descGhost(r)+'</span><button class="combodd" data-dd="titles" data-u="'+a.uid+'" data-f="descNo" tabindex="-1" title="説明文一覧から選択">▾</button></div></td>';
908
+ // paramCode (group-level) — 既存AGは read-only (グループの基本パラメータは編集不可・広告追加のみ)
909
+ const pcRo=isExistingGroup(g);
910
+ html+='<td><input class="cell-in mono'+(pcRo?' roinput':'')+'" data-sel="1" data-r="'+ri+'" data-c="4" data-u="'+a.uid+'" data-f="paramCode" value="'+esc(g.paramCode)+'"'+(pcRo?' readonly title="既存広告グループのため編集不可"':' title="グループ共通(変更で同グループ全広告に反映)"')+' style="width:120px"></td>';
911
+ // slug
912
+ html+='<td><input class="cell-in mono" data-sel="1" data-r="'+ri+'" data-c="5" data-u="'+a.uid+'" data-f="articleSlug" value="'+esc(a.articleSlug)+'" style="width:90px"></td>';
913
+ // adName
914
+ html+='<td><span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(r.finalUrl)+'">'+esc(r.adName)+'</span></td>';
915
+ // ids
916
+ html+='<td><span class="idpill '+(r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+idsCell(r)+'</span></td>';
917
+ // actions
918
+ html+='<td><span class="rowact"><button class="iconbtn" data-act="ad-dup" data-u="'+a.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ad-del" data-u="'+a.uid+'" title="削除">✕</button></span></td>';
919
+ html+='</tr>';
920
+ });
921
+ // F-4: グループごとの追加ボタンは廃止(最下部に1つ)
922
+ });
923
+ });
924
+ // F-4: 広告追加は最下部に1行だけ(既定=最後のグループ・グループ列で変更可)
925
+ html+='<tr><td></td><td colspan="10"><button class="addbtn" data-act="add-ad-bottom">+ 広告を追加</button></td></tr>';
926
+ html+='</tbody></table>';
927
+ if(shown===0 && st.campaigns.length===0) html='<div class="empty">キャンペーンがありません。シートの「キャンペーン」タブ →「+ キャンペーンを追加」から。</div>';
928
+ const host=document.getElementById('sheetHost');
929
+ const focus=document.activeElement, fu=focus&&focus.dataset?focus.dataset.u:null, ff=focus&&focus.dataset?focus.dataset.f:null, selS=focus&&focus.selectionStart;
930
+ if(focus && host.contains(focus)){ try{ focus.blur(); }catch(e){} }
931
+ host.innerHTML=html;
932
+ if(fu&&ff){ const el=host.querySelector('[data-u="'+fu+'"][data-f="'+ff+'"]'); if(el){ el.focus(); try{ if(selS!=null&&el.setSelectionRange) el.setSelectionRange(selS,selS); }catch(e){} } }
933
+ applySelection();
934
+ }
935
+ function mediaInline(a,r){ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>'; if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>'; if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>'; return '<span class="idpill err">#N/A</span>'; }
936
+ function titleGhost(r){ if(r.errs.includes('タイトル未解決')) return '<span class="err">未解決</span>'; return esc(r.titleText.slice(0,40)); }
937
+ function descGhost(r){ if(r.errs.includes('説明文未解決')) return '<span class="err">— 未解決</span>'; return esc(r.descText.slice(0,44)); }
938
+ function idsCell(r){ return r.adGroupId ? ('AG '+r.adGroupId) : '新規'; } // F-6: adGroupIdベース表示
939
+
940
+ /* ---- TREE ---- */
941
+ function isCollapsed(u){ return !!st.ui.expanded[u]===false ? false : st.ui.expanded[u]==='c'; }
942
+ function focusRestoreG(host, attrU, attrF){
943
+ const fa=document.activeElement, fu=fa&&fa.dataset?fa.dataset[attrU]:null, ff=fa&&fa.dataset?fa.dataset[attrF]:null, ss=fa&&fa.selectionStart;
944
+ return function(){ if(fu&&ff){ const el=host.querySelector('['+'data-'+(attrU==='gu'?'gu':'cu')+'="'+fu+'"]['+'data-'+(attrU==='gu'?'gf':'cf')+'="'+ff+'"]'); if(el){ el.focus(); try{ if(ss!=null&&el.setSelectionRange) el.setSelectionRange(ss,ss);}catch(_){}}}};
945
+ }
946
+ /* ---- シート: 広告グループ タブ(変更列を一覧編集) ---- */
947
+ function renderSheetGroups(){
948
+ const host=document.getElementById('sheetHost'); const restore=focusRestoreG(host,'gu','gf');
949
+ // F-6 列順: キャンペーン → 広告グループID(左から2番目) → 名前 → paramCode → 入札/配信/ターゲティング
950
+ const cols=['#','キャンペーン','広告グループID','広告グループ名','paramCode','入札','配信','デバイス','性別','年齢','地域','曜日時間','オーディエンス','サーチKW',''];
951
+ let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>'; let ri=-1;
952
+ const gin=(g,ci,w,req)=>{ const f=VARF_GROUPS[ci]; const v=g[f]; const empty=req&&(v==null||v===''); return '<td><input class="cell-in'+(empty?' warnempty':'')+'" data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-gu="'+g.uid+'" data-gf="'+f+'" value="'+esc(v==null?'':v)+'" style="width:'+w+'"></td>'; };
953
+ const roTd=v=>'<td>'+(v?'<span class="robadge">'+esc(v)+'</span>':'<span class="idpill" style="color:var(--faint)">—</span>')+'</td>';
954
+ st.campaigns.forEach(c=>{
955
+ c.adGroups.forEach(g=>{ ri++;
956
+ const gEd=!isExistingGroup(g); // 既存呼び出し行=disabled / 新規行=編集可
957
+ html+='<tr class="adrow" data-gu="'+g.uid+'">';
958
+ html+='<td class="rownum" data-rownum="'+ri+'">'+(ri+1)+'</td>';
959
+ // キャンペーン列: 新規AGはクリックでプラン内キャンペーンをドロワー選択(親付け替え)。既存AGはキャンペーンが確定済みのため変更不可
960
+ if(gEd){
961
+ html+='<td><div class="cppick" data-cppick-g="'+g.uid+'" title="クリックでキャンペーンを選択(プラン内)"><span class="breadc" style="max-width:150px">'+esc(c.name)+'</span><span class="tgcaret">▾</span></div></td>';
962
+ } else {
963
+ html+='<td><span class="robadge" title="既存広告グループのためキャンペーン変更不可" style="max-width:150px; display:inline-block; overflow:hidden; text-overflow:ellipsis; vertical-align:middle">'+esc(c.name)+'</span></td>';
964
+ }
965
+ // 広告グループID (左から2番目): 既存=ID / 新規=「新規」バッジ (手入力なし)
966
+ html+='<td>'+newOrIdBadge(isNewGroup(g), resolveAdGroupIdOf(g))+'</td>';
967
+ if(gEd){
968
+ html+=gin(g,1,'150px',true); // 名前(c1)
969
+ html+='<td><input class="cell-in mono'+(!g.paramCode?' warnempty':'')+'" data-sel="1" data-r="'+ri+'" data-c="2" data-gu="'+g.uid+'" data-gf="paramCode" value="'+esc(g.paramCode||'')+'" title="URL生成用のパラメータ(グループ紐づけには不使用)" style="width:120px"></td>'; // paramCode(c2)
970
+ html+=gin(g,3,'56px',false); // 入札(c3)
971
+ html+='<td><select class="cell-in fmt" style="min-width:66px" data-sel="1" data-r="'+ri+'" data-c="4" data-gu="'+g.uid+'" data-gf="deliveryStatus"><option value=""'+(!g.deliveryStatus?' selected':'')+'>(既定)</option><option'+(g.deliveryStatus==='オフ'?' selected':'')+'>オフ</option><option'+(g.deliveryStatus==='オン'?' selected':'')+'>オン</option></select></td>'; // 配信(c4)
972
+ [5,6,7,8,9,10,11].forEach(ci=>{ const tf=VARF_GROUPS[ci]; if(TG_OPTIONS[tf]){ const val=g[tf]||''; const disp = val? '<span class="tgval">'+esc(val)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(TG_OPTIONS[tf].label)+'</span></span>'; html+='<td class="tgtd"><div class="tgpick'+(val?'':' tgempty')+'" data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-gu="'+g.uid+'" data-gf="'+tf+'" title="'+esc(val||(TG_OPTIONS[tf].label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>'; } else { html+=gin(g,ci,'120px',false); } });
973
+ } else {
974
+ // 既存呼び出し行: 名前/paramCode/入札/配信/ターゲティングを read-only(disabled)。ID起点で自動で埋まった値を表示
975
+ html+='<td><span class="robadge" title="既存広告グループのため編集不可">'+esc(g.name||'')+'</span></td>';
976
+ html+='<td><span class="robadge mono">'+esc(g.paramCode||'')+'</span></td>';
977
+ html+=roTd(g.bid);
978
+ html+=roTd(g.deliveryStatus);
979
+ [5,6,7,8,9,10,11].forEach(ci=>{ const tf=VARF_GROUPS[ci]; html+=roTd(g[tf]); });
980
+ }
981
+ html+='<td><span class="rowact"><button class="iconbtn" data-act="ag-dup" data-u="'+g.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+g.uid+'" title="削除">✕</button></span></td>';
982
+ html+='</tr>';
983
+ });
984
+ });
985
+ // F-5: 追加行はタブ最下部に1行だけ。追加時にキャンペーンを選択(ドロワー)
986
+ html+='<tr><td></td><td colspan="14"><button class="addbtn" data-act="add-adgroup">+ 新規広告グループ</button> <button class="addbtn" data-act="add-adgroup-existing">📂 既存広告グループを呼び出し</button></td></tr>';
987
+ html+='</tbody></table>';
988
+ host.innerHTML=html; restore(); applySelection();
989
+ }
990
+ /* ---- シート: キャンペーン タブ(変更列を一覧編集) ---- */
991
+ function renderSheetCampaigns(){
992
+ const host=document.getElementById('sheetHost'); const restore=focusRestoreG(host,'cu','cf');
993
+ // E-2: campaignId 最左・read-only。モード/配信/戦略メモ列は撤去
994
+ const cols=['#','キャンペーンID','キャンペーン名','予算/日','入札戦略','tCPA','検証変数','リンク番号',''];
995
+ let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
996
+ let RIDX=-1;
997
+ st.campaigns.forEach((c)=>{ RIDX++;
998
+ const cEd=!isExistingCampaign(c); // F-2: 編集可否は明示フラグのみ。表示バッジはID解決ベースで分離
999
+ const a=(f)=>'data-sel="1" data-r="'+RIDX+'" data-c="'+VARF_CAMPAIGNS.indexOf(f)+'" data-cu="'+c.uid+'" data-cf="'+f+'"';
1000
+ html+='<tr class="adrow" data-cu="'+c.uid+'">';
1001
+ html+='<td class="rownum" data-rownum="'+RIDX+'">'+(RIDX+1)+'</td>';
1002
+ // campaignId: 入力欄廃止。既存=「ID xxx」/ 新規=「新規」バッジ (表示はID解決ベース)
1003
+ html+='<td>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c))+'</td>';
1004
+ // 既存呼び出しキャンペーンは広告追加専用 → 基本パラメータ(name/予算/入札/tCPA/検証変数/リンク番号)は全て read-only (backendが無視するため編集齟齬を防ぐ)
1005
+ if(cEd){
1006
+ html+='<td><input class="cell-in'+(!c.name?' warnempty':'')+'" '+a('name')+' value="'+esc(c.name||'')+'" style="width:170px"></td>';
1007
+ html+='<td><input class="cell-in num" type="number" '+a('budget')+' value="'+esc(c.budget??'')+'" placeholder="'+NEW_CP_BUDGET+'" style="width:78px"></td>';
1008
+ html+='<td><select class="cell-in fmt" style="min-width:82px" '+a('biddingStrategy')+'><option value="">(既定)</option><option'+(c.biddingStrategy==='tCPA'?' selected':'')+'>tCPA</option><option'+(c.biddingStrategy==='最大配信'?' selected':'')+'>最大配信</option></select></td>';
1009
+ html+='<td><input class="cell-in num" type="number" '+a('tcpa')+' value="'+esc(c.tcpa??'')+'" placeholder="'+st.project.tcpaDefault+'" style="width:70px"></td>';
1010
+ html+='<td><input class="cell-in" '+a('variable')+' value="'+esc(c.variable||'')+'" style="width:140px"></td>';
1011
+ html+='<td><input class="cell-in mono" '+a('urlNumber')+' value="'+esc(c.urlNumber||'')+'" style="width:54px"></td>';
1012
+ } else {
1013
+ html+='<td><span class="robadge" title="既存キャンペーンのため編集不可">'+esc(c.name||'')+'</span></td>';
1014
+ html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1015
+ html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1016
+ html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1017
+ html+='<td>'+(c.variable?'<span class="robadge">'+esc(c.variable)+'</span>':'<span class="idpill" style="color:var(--faint)">—</span>')+'</td>';
1018
+ html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
1019
+ }
1020
+ html+='<td><span class="rowact"><button class="iconbtn" data-act="cp-dup" data-u="'+c.uid+'" title="複製/量産">⧉</button><button class="iconbtn danger" data-act="cp-del" data-u="'+c.uid+'" title="削除">✕</button></span></td>';
1021
+ html+='</tr>';
1022
+ });
1023
+ html+='<tr><td></td><td colspan="8"><button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button> <button class="addbtn" data-act="add-campaign-existing">📂 既存キャンペーンを呼び出し</button></td></tr>';
1024
+ html+='</tbody></table>'; if(!st.campaigns.length) html='<div class="empty">キャンペーンがありません。「+ 新規キャンペーン」または「📂 既存キャンペーンを呼び出し」から。</div>';
1025
+ host.innerHTML=html; restore(); applySelection();
1026
+ }
1027
+ function renderTree(){
1028
+ if(st.campaigns.length===0){ document.getElementById('treeHost').innerHTML='<div class="empty">キャンペーンがありません。</div><div class="treeadd" style="margin-top:10px"><button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button><button class="addbtn" data-act="add-campaign-existing" style="margin-left:6px">📂 既存キャンペーンを呼び出し</button></div>'; return; }
1029
+ let html='';
1030
+ st.campaigns.forEach(c=>{
1031
+ const col = st.ui.expanded[c.uid]==='c';
1032
+ const totalAds=c.adGroups.reduce((n,g)=>n+g.ads.length,0);
1033
+ const ratio = c.adGroups.length===1 ? ('1:1:'+ (c.adGroups[0].ads.length)) : (c.adGroups.length+':'+c.adGroups.map(g=>g.ads.length).join('/'));
1034
+ const cerr = c.adGroups.some(g=>g.ads.some(a=>resolveAd(a,g).errs.length));
1035
+ const cpEd=!isExistingCampaign(c); // F-2: 編集可否は明示フラグ。バッジ表示はID解決ベースで分離
1036
+ html+='<div class="cpnode'+(col?' collapsed':'')+'" data-cp="'+c.uid+'">';
1037
+ html+='<div class="noderow"><button class="caret" data-act="toggle" data-u="'+c.uid+'"><span class="a"></span></button>';
1038
+ // 既存呼び出しCPは名称もrename不可 (基本パラメータ編集不可)
1039
+ html+= cpEd ? '<span class="nodename" contenteditable="true" data-rename="cp" data-u="'+c.uid+'">'+esc(c.name)+'</span>'
1040
+ : '<span class="nodename" title="既存キャンペーンのため編集不可">'+esc(c.name)+'</span>';
1041
+ html+='<span class="metae"><span class="ratio">'+esc(ratio)+'</span>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c));
1042
+ // 予算/入札は編集可(新規扱い)のCPのみ表示 (既存呼び出しCPは広告追加専用で予算等は無視)
1043
+ if(cpEd){
1044
+ html+='<span class="chip">予算 <b>'+esc((c.budget??NEW_CP_BUDGET).toLocaleString())+'</b>円</span>';
1045
+ html+='<span class="chip">'+esc(c.biddingStrategy||st.project.biddingStrategyDefault)+' <b>'+esc((c.tcpa??st.project.tcpaDefault))+'</b></span>';
1046
+ }
1047
+ if(c.variable) html+='<span class="pill purple">変数:'+esc(c.variable)+'</span>';
1048
+ if(cerr) html+='<span class="pill danger">要修正</span>';
1049
+ html+='</span>';
1050
+ 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>';
1051
+ html+='</div>';
1052
+ html+='<div class="agwrap">';
1053
+ c.adGroups.forEach(g=>{
1054
+ const gcol = st.ui.expanded[g.uid]==='c';
1055
+ html+='<div class="agnode'+(gcol?' collapsed':'')+'" data-ag="'+g.uid+'">';
1056
+ const gEd=!isExistingGroup(g); // F-2: 編集可否は明示フラグ。バッジ表示はID解決ベースで分離
1057
+ html+='<div class="noderow"><button class="caret" data-act="toggle" data-u="'+g.uid+'"><span class="a"></span></button>';
1058
+ html+= gEd ? '<span class="nodename" contenteditable="true" data-rename="ag" data-u="'+g.uid+'">'+esc(g.name)+'</span>'
1059
+ : '<span class="nodename" title="既存広告グループのため編集不可">'+esc(g.name)+'</span>';
1060
+ html+='<span class="metae"><span class="pill mono">【'+esc(g.paramCode||'—')+'】</span><span class="chip">広告 <b>'+g.ads.length+'</b></span>';
1061
+ html+= newOrIdBadge(isNewGroup(g), resolveAdGroupIdOf(g));
1062
+ html+='</span>';
1063
+ html+='<span class="nodeact">'+(gEd?'<button class="iconbtn" data-act="ag-param" data-u="'+g.uid+'" title="paramCode編集">#</button>':'')+'<button class="iconbtn" data-act="ag-dup" data-u="'+g.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+g.uid+'" title="削除">✕</button></span>';
1064
+ html+='</div><div class="adwrap">';
1065
+ g.ads.forEach(a=>{
1066
+ const r=resolveAd(a,g); const dot=r.errs.length?'err':(r.warns.length?'warn':'ok');
1067
+ html+='<div class="adnode'+(r.errs.length?' hasErr':'')+'" data-ad="'+a.uid+'">';
1068
+ html+='<span class="dot '+dot+'"></span>';
1069
+ html+='<span class="cbadge">'+esc(a.imageNo||'—')+(a.format||'Card')[0].toLowerCase()+'</span>';
1070
+ html+='<span class="adttl">'+(r.errs.includes('タイトル未解決')?'<span style="color:var(--danger)">タイトル未解決</span>':esc(r.titleText||'(未入力)'))+' <span class="sm">'+esc(a.titleNo)+'L'+esc(a.descNo)+'</span></span>';
1071
+ html+='<span class="nodeact"><button class="iconbtn" data-act="ad-dup" data-u="'+a.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ad-del" data-u="'+a.uid+'" title="削除">✕</button></span>';
1072
+ html+='</div>';
1073
+ });
1074
+ html+='<div class="addrow"><button class="addbtn" data-act="ag-addad" data-u="'+g.uid+'">+ 広告を追加</button><button class="addbtn" data-act="ag-addn" data-u="'+g.uid+'">+ N本まとめて</button></div>';
1075
+ html+='</div></div>';
1076
+ });
1077
+ html+='<div class="addrow"><button class="addbtn" data-act="cp-addag" data-u="'+c.uid+'">+ 新規広告グループ</button><button class="addbtn" data-act="cp-addag-existing" data-u="'+c.uid+'">📂 既存広告グループを呼び出し</button></div>';
1078
+ html+='</div></div>';
1079
+ });
1080
+ // E-1: ツリー側のキャンペーン追加導線(新規/既存の2択)
1081
+ html+='<div class="treeadd" style="margin-top:10px"><button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button><button class="addbtn" data-act="add-campaign-existing" style="margin-left:6px">📂 既存キャンペーンを呼び出し</button></div>';
1082
+ document.getElementById('treeHost').innerHTML=html;
1083
+ }
1084
+
1085
+ /* ============================================================
1086
+ REFERENCE DATA (タイトル/説明一覧・登録画像一覧) — 閲覧+登録
1087
+ ============================================================ */
1088
+ let refTab='titles';
1089
+ const newRefIds={titles:new Set(), media:new Set()}; // 新規追加に一時的な new ラベル(リロードで消える)
1090
+ function newBadge(kind,no){ return newRefIds[kind].has(String(no)) ? '<span class="newbadge">new</span>' : ''; }
1091
+ function buildDatalists(){
1092
+ const host=document.getElementById('dlHost'); if(!host) return;
1093
+ const tks=Object.keys(st.refs.titles).sort((a,b)=>a-b);
1094
+ const mks=Object.keys(st.refs.media).sort((a,b)=>a-b);
1095
+ const t=tks.map(no=>'<option value="'+esc(no)+'">'+esc(st.refs.titles[no])+'</option>').join('');
1096
+ const m=mks.map(no=>{ const e=st.refs.media[no]; return '<option value="'+esc(no)+'">'+esc((e.card?'Card':'')+(e.card&&e.sq?'/':'')+(e.sq?'SQ':''))+'</option>'; }).join('');
1097
+ host.innerHTML='<datalist id="dl-titles">'+t+'</datalist><datalist id="dl-media">'+m+'</datalist>';
1098
+ }
1099
+ function renderRef(){
1100
+ const q=(document.getElementById('refSearch').value||'').trim().toLowerCase();
1101
+ const host=document.getElementById('refHost'); let html='';
1102
+ // アカウントアセット: 広告グループ/キャンペーンは既存(アカウント)の閲覧のみ → 追加/文言コピーは非表示
1103
+ const editable = (refTab==='titles'||refTab==='descriptions'||refTab==='media');
1104
+ const setDisp=(id,show)=>{ const el=document.getElementById(id); if(el) el.style.display=show?'':'none'; };
1105
+ setDisp('btnRefAdd', editable); setDisp('btnAccCopy', refTab==='titles'||refTab==='descriptions'); setDisp('btnRefReset', editable);
1106
+ if(refTab==='adGroups'){
1107
+ const store=st.refs.adGroups||{};
1108
+ const all=Object.keys(store).map(id=>({id, ...store[id]}));
1109
+ const f=all.filter(e=>!q||String(e.id).includes(q)||String(e.name||'').toLowerCase().includes(q)||String(e.paramCode||'').toLowerCase().includes(q)||String(e.campaignId||'').includes(q)).sort((a,b)=>String(a.id).localeCompare(String(b.id)));
1110
+ document.getElementById('refCount').textContent='広告グループ '+f.length+'/'+all.length+'件';
1111
+ html='<table class="reftable"><thead><tr><th style="width:110px">広告グループID</th><th>広告グループ名</th><th style="width:130px">paramCode</th><th style="width:110px">キャンペーンID</th></tr></thead><tbody>';
1112
+ f.forEach(e=>{ html+='<tr><td class="mono">'+esc(e.id)+'</td><td class="reftext" title="'+esc(e.name||'')+'">'+esc(e.name||'')+'</td><td class="mono">'+esc(e.paramCode||'')+'</td><td class="mono">'+esc(e.campaignId||'')+'</td></tr>'; });
1113
+ html+='</tbody></table>'; if(!f.length) html='<div class="empty">既存の広告グループがありません(アカウントJSONの refs.adGroups)</div>';
1114
+ host.innerHTML=html; return;
1115
+ }
1116
+ if(refTab==='campaigns'){
1117
+ const store=st.refs.campaigns||{};
1118
+ const all=Object.keys(store).map(id=>({id, ...store[id]}));
1119
+ const f=all.filter(e=>!q||String(e.id).includes(q)||String(e.name||'').toLowerCase().includes(q)).sort((a,b)=>String(a.id).localeCompare(String(b.id)));
1120
+ document.getElementById('refCount').textContent='キャンペーン '+f.length+'/'+all.length+'件';
1121
+ html='<table class="reftable"><thead><tr><th style="width:120px">キャンペーンID</th><th>キャンペーン名</th></tr></thead><tbody>';
1122
+ f.forEach(e=>{ html+='<tr><td class="mono">'+esc(e.id)+'</td><td class="reftext" title="'+esc(e.name||'')+'">'+esc(e.name||'')+'</td></tr>'; });
1123
+ html+='</tbody></table>'; if(!f.length) html='<div class="empty">既存のキャンペーンがありません(アカウントJSONの refs.campaigns)</div>';
1124
+ host.innerHTML=html; return;
1125
+ }
1126
+ if(refTab==='titles'||refTab==='descriptions'){
1127
+ const isTitle=refTab==='titles';
1128
+ const store=isTitle?st.refs.titles:(st.refs.descriptions||{});
1129
+ const kind=isTitle?'t':'d';
1130
+ const label=isTitle?'タイトル':'説明文';
1131
+ const all=Object.keys(store).map(no=>({no:+no,text:store[no]})).filter(e=>String(e.text||'').trim()!=='');
1132
+ const f=all.filter(e=>!q||String(e.no).includes(q)||String(e.text).toLowerCase().includes(q)).sort((a,b)=>a.no-b.no);
1133
+ document.getElementById('refCount').textContent=label+' '+f.length+'/'+all.length+'件';
1134
+ html='<table class="reftable"><thead><tr><th style="width:70px">番号</th><th>'+esc(label)+'</th><th style="width:70px">文字数</th><th style="width:70px"></th></tr></thead><tbody>';
1135
+ f.forEach(e=>{ const L=glen(e.text); const cls=(isTitle&&L>35)?'err':(isTitle&&L>20?'warn':''); html+='<tr><td class="mono">'+esc(e.no)+'</td><td class="reftext" title="'+esc(e.text)+'">'+esc(e.text)+'</td><td class="reflen '+cls+'">'+L+'</td><td><button class="iconbtn" data-refedit="'+kind+'" data-no="'+esc(e.no)+'" title="編集">✎</button><button class="iconbtn danger" data-refdel="'+kind+'" data-no="'+esc(e.no)+'" title="削除">✕</button></td></tr>'; });
1136
+ html+='</tbody></table>'; if(!f.length) html='<div class="empty">該当する'+esc(label)+'がありません</div>';
1137
+ } else {
1138
+ const all=Object.keys(st.refs.media).map(no=>({no:+no,m:st.refs.media[no]}));
1139
+ const f=all.filter(e=>!q||String(e.no).includes(q)||String(e.m.card||'').includes(q)||String(e.m.sq||'').includes(q)).sort((a,b)=>a.no-b.no);
1140
+ document.getElementById('refCount').textContent='登録画像 '+f.length+'/'+all.length+'件';
1141
+ html='<table class="reftable"><thead><tr><th style="width:80px">画像番号</th><th>Card mediaID (1200)</th><th>SQ mediaID (1080)</th><th>ファイル名</th><th style="width:70px"></th></tr></thead><tbody>';
1142
+ f.forEach(e=>{ html+='<tr><td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(e.no)+'</span> '+newBadge('media',e.no)+'</td><td class="mono">'+esc(e.m.card||'—')+'</td><td class="mono">'+esc(e.m.sq||'—')+'</td><td class="mono" style="color:var(--faint);font-size:11px">'+esc(e.no)+'-1200.jpg / '+esc(e.no)+'-1080.jpg</td><td><button class="iconbtn" data-refedit="m" data-no="'+esc(e.no)+'" title="編集">✎</button><button class="iconbtn danger" data-refdel="m" data-no="'+esc(e.no)+'" title="削除">✕</button></td></tr>'; });
1143
+ html+='</tbody></table>'; if(!f.length) html='<div class="empty">該当する画像がありません</div>';
1144
+ }
1145
+ host.innerHTML=html;
1146
+ }
1147
+ function refCommit(fn,msg){ snapshot(); fn(); persist(); buildDatalists(); render(); if(typeof saveActiveAccountJson==='function') saveActiveAccountJson(); toast(msg); }
1148
+ function refAdd(){
1149
+ if(refTab==='titles'||refTab==='descriptions'){
1150
+ const isTitle=refTab==='titles'; const label=isTitle?'タイトル':'説明文';
1151
+ openModal({title:label+'を登録', sub:isTitle?'番号→文言。タイトル枠は20字目安・35字上限':'番号→説明文(長文可)', fields:[
1152
+ {key:'no',label:'番号',type:'number',value:''},{key:'text',label:'文言',type:'text',value:''}
1153
+ ], onOk:v=>{ const no=parseInt(v.no); if(!no){toast('番号を入力してください');return;} const store=isTitle?st.refs.titles:st.refs.descriptions; const dup=store[no]!=null; refCommit(()=>{ store[no]=v.text; }, dup?('番号'+no+'を上書き'):'登録しました'); }});
1154
+ } else {
1155
+ openModal({title:'画像を登録', sub:'画像番号→Card/SQ の mediaID。新規作成した画像をメディアに登録', fields:[
1156
+ {key:'no',label:'画像番号',type:'number',value:''},{key:'card',label:'Card mediaID (1200)',type:'text',value:''},{key:'sq',label:'SQ mediaID (1080)',type:'text',value:''}
1157
+ ], onOk:v=>{ const no=parseInt(v.no); if(!no){toast('画像番号を入力してください');return;} const isNew=st.refs.media[no]==null; refCommit(()=>{ const e=Object.assign({},st.refs.media[no]||{}); if(v.card!=='')e.card=v.card.trim(); if(v.sq!=='')e.sq=v.sq.trim(); st.refs.media[no]=e; if(isNew) newRefIds.media.add(String(no)); }, '登録しました'); }});
1158
+ }
1159
+ }
1160
+ function refEdit(kind,no){
1161
+ if(kind==='t'||kind==='d'){ const store=kind==='t'?st.refs.titles:st.refs.descriptions; const cur=store[no]; openModal({title:(kind==='t'?'タイトル':'説明文')+'を編集', sub:'番号 '+no, fields:[{key:'text',label:'文言',type:'text',value:cur}], onOk:v=>{ refCommit(()=>{ store[no]=v.text; }, '更新しました'); }}); }
1162
+ else { const e=st.refs.media[no]||{}; openModal({title:'画像を編集', sub:'画像番号 '+no, fields:[{key:'card',label:'Card mediaID',type:'text',value:e.card||''},{key:'sq',label:'SQ mediaID',type:'text',value:e.sq||''}], onOk:v=>{ refCommit(()=>{ st.refs.media[no]={card:v.card.trim(),sq:v.sq.trim()}; }, '更新しました'); }}); }
1163
+ }
1164
+ function refDel(kind,no){ refCommit(()=>{ if(kind==='t') delete st.refs.titles[no]; else if(kind==='d') delete st.refs.descriptions[no]; else delete st.refs.media[no]; }, '削除しました'); }
1165
+
1166
+ /* ============================================================
1167
+ SPREADSHEET SELECTION / COPY / PASTE
1168
+ ============================================================ */
1169
+ const VARF=['format','imageNo','titleNo','descNo','paramCode','articleSlug']; // 広告タブ列
1170
+ const VARF_GROUPS=['adGroupId','name','paramCode','bid','deliveryStatus','tg_device','tg_gender','tg_age','tg_region','tg_dayHour','tg_audience','tg_searchKw']; // F-6: adGroupId(index0・非編集) → name → paramCode → …
1171
+ const VARF_CAMPAIGNS=['campaignId','name','budget','biddingStrategy','tcpa','variable','urlNumber']; // E-2: campaignId最左・read-only / mode/status/strategyMemo撤去
1172
+ function tabCols(){ return sheetTab==='groups'?VARF_GROUPS : sheetTab==='campaigns'?VARF_CAMPAIGNS : VARF; }
1173
+ function getVar(item,c){ const f=tabCols()[c]; if(item.kind==='g') return item.g[f]==null?'':String(item.g[f]); if(item.kind==='c') return item.c[f]==null?'':String(item.c[f]); if(f==='paramCode') return item.g.paramCode||''; const v=item.a[f]; return v==null?'':String(v); }
1174
+ function setVar(item,c,val){ const f=tabCols()[c]; if(item.kind==='g'){ if(isExistingGroup(item.g)) return; /* F-2: 既存呼び出しAGは基本パラメータ編集不可 */ if(f==='adGroupId') return; /* E-4: read-only */ if(f==='deliveryStatus'){ const s=String(val).trim(); val=(s==='オフ'||s==='オン')?s:''; } item.g[f]=val; if(f==='paramCode') autoResolveGroupIds(item.g,item.c); return; } if(item.kind==='c'){ if(isExistingCampaign(item.c)) return; /* F-2: 既存呼び出しCPは基本パラメータ編集不可 */ if(f==='campaignId') return; /* E-2: read-only */ if(f==='budget'||f==='tcpa') val=numOrNull(val); item.c[f]=val; return; } if(f==='paramCode'){ if(isExistingGroup(item.g)) return; /* F-2: 既存呼び出しAGのparamCodeは編集不可 */ item.g.paramCode=val; autoResolveGroupIds(item.g,item.c); } else if(f==='format') item.a.format=(String(val).toUpperCase()==='SQ'?'SQ':'Card'); else item.a[f]=val; }
1175
+ function flatVisible(){ const out=[]; if(sheetTab==='groups'){ st.campaigns.forEach(c=>c.adGroups.forEach(g=>out.push({kind:'g',g,c}))); return out; } if(sheetTab==='campaigns'){ st.campaigns.forEach(c=>out.push({kind:'c',c})); return out; } const filter=document.getElementById('sheetFilter').value; st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach(a=>{ const r=resolveAd(a,g); const hasErr=r.errs.length>0; if(filter==='err'&&!hasErr)return; out.push({kind:'a',a,g,c}); }))); return out; }
1176
+ let selA=null, selB=null, dragging=false;
1177
+ function clearSel(){ selA=selB=null; applySelection(); }
1178
+ 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)}; }
1179
+ 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(); }
1180
+ function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible(); const lines=[]; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue; const cells=[]; for(let c=rg.c0;c<=rg.c1;c++) cells.push(getVar(it,c)); lines.push(cells.join('\t')); } return lines.join('\n'); }
1181
+ function pasteGrid(txt){
1182
+ const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
1183
+ const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
1184
+ const vis=flatVisible(); const maxc=tabCols().length-1; if(!vis[rg.r0]){ toast('貼付先がありません'); return; }
1185
+ mutate(()=>{
1186
+ if(sheetTab==='ads'){
1187
+ // B8: 可視行基準に統一 (グループ境界・絞り込み表示を尊重)。不足分は末尾の可視グループに新規広告を追加。
1188
+ const anchor=vis[rg.r0]; let lastGroup=anchor.g;
1189
+ rows.forEach((cols,i)=>{
1190
+ let it=vis[rg.r0+i];
1191
+ if(it){ lastGroup=it.g; }
1192
+ else { const ad=newAd(); lastGroup.ads.push(ad); it={kind:'a',a:ad,g:lastGroup}; }
1193
+ cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); });
1194
+ });
1195
+ }
1196
+ else { const anchor=vis[rg.r0]; rows.forEach((cols,i)=>{ let it=vis[rg.r0+i]; if(!it){ if(sheetTab==='groups'){ const ng={uid:uid('g'),name:'AG'+(anchor.c.adGroups.length+1),paramCode:'',ads:[newAd()]}; anchor.c.adGroups.push(ng); it={kind:'g',g:ng,c:anchor.c}; } else { const nc=makeNewCampaign(); st.campaigns.push(nc); it={kind:'c',c:nc}; } } cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); }); }); }
1197
+ });
1198
+ clearSel(); toast(rows.length+'行を貼り付け');
1199
+ }
1200
+ function dupSelected(){
1201
+ 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; }
1202
+ mutate(()=>{ items.forEach(it=>{
1203
+ if(it.kind==='g'){ const ng=JSON.parse(JSON.stringify(it.g)); ng.uid=uid('g'); ng.name=it.g.name+'_copy'; ng.adGroupId=''; ng.isExisting=false; ng.ads.forEach(a=>a.uid=uid('a')); const idx=it.c.adGroups.indexOf(it.g); it.c.adGroups.splice(idx+1,0,ng); }
1204
+ 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); }
1205
+ else { const idx=it.g.ads.indexOf(it.a); it.g.ads.splice(idx+1,0,cloneAd(it.a,null)); }
1206
+ }); }); clearSel(); toast(items.length+'件を複製');
1207
+ }
1208
+
1209
+ /* ---- A-2: paramCode 連番採番 (複数選択→連番一括入力) ---- */
1210
+ function paramCodeColIndex(){ return sheetTab==='groups'?VARF_GROUPS.indexOf('paramCode') : sheetTab==='ads'?VARF.indexOf('paramCode') : -1; }
1211
+ /* 選択範囲が交差する「連番可能な列」を返す。{field, idx, mode:'group'|'ad'} */
1212
+ function seqColInfo(){
1213
+ const rg=selRange(); if(!rg) return null;
1214
+ if(sheetTab==='ads'){
1215
+ const map={}; ['imageNo','titleNo','descNo'].forEach(f=>{ const i=VARF.indexOf(f); if(i>=0) map[i]=f; }); const pcI=VARF.indexOf('paramCode'); if(pcI>=0) map[pcI]='paramCode';
1216
+ for(const ci of Object.keys(map).map(Number).sort((a,b)=>a-b)){ if(rg.c0<=ci && rg.c1>=ci) return {field:map[ci], idx:ci, mode: map[ci]==='paramCode'?'group':'ad'}; }
1217
+ } else if(sheetTab==='groups'){
1218
+ const pc=VARF_GROUPS.indexOf('paramCode'); if(pc>=0 && rg.c0<=pc && rg.c1>=pc) return {field:'paramCode', idx:pc, mode:'group'};
1219
+ }
1220
+ return null;
1221
+ }
1222
+ /* 連番ボタンの表示制御: 連番可能な列(paramCode/画像番号/タイトル番号/説明文番号)を含み対象2件以上で表示 */
1223
+ function updateSeqBtn(){
1224
+ const btn=document.getElementById('btnSeqParam'); if(!btn) return;
1225
+ const rg=selRange(); const sc=seqColInfo(); let show=false;
1226
+ if(rg && sc && rg.r1>rg.r0){
1227
+ const vis=flatVisible();
1228
+ if(sc.mode==='group'){ const seen=new Set(); for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.g) seen.add(it.g.uid); } show=seen.size>=2; }
1229
+ else { let n=0; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.a) n++; } show=n>=2; }
1230
+ }
1231
+ btn.style.display=show?'':'none';
1232
+ }
1233
+ function applySequentialParamCodes(){
1234
+ const rg=selRange(); const sc=seqColInfo();
1235
+ if(!rg||!sc){ toast('連番できる列(paramCode / 画像番号 / タイトル番号 / 説明文番号)のセルを複数選択してください'); return; }
1236
+ const vis=flatVisible();
1237
+ if(sc.mode==='group'){
1238
+ const seen=new Set(); const groups=[];
1239
+ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.g) continue; if(isExistingGroup(it.g)) continue; if(seen.has(it.g.uid)) continue; seen.add(it.g.uid); groups.push({g:it.g, c:it.c}); }
1240
+ if(groups.length<2){ toast('連番対象の広告グループが2件以上必要です'); return; }
1241
+ const m=String(groups[0].g.paramCode||'').trim().match(/^(.*?)(\d+)\s*$/);
1242
+ if(!m){ toast('基点となる paramCode を先頭セルに入力してください(例 jv_Cac_148)'); return; }
1243
+ const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
1244
+ mutate(()=>{ groups.forEach((pair,i)=>{ if(i===0){ autoResolveGroupIds(pair.g,pair.c); return; } pair.g.paramCode=prefix+String(startNum+i).padStart(width,'0'); autoResolveGroupIds(pair.g,pair.c); }); });
1245
+ toast((groups.length-1)+'グループに連番を採番しました('+prefix+(startNum+1)+'〜)');
1246
+ } else {
1247
+ const rows=[];
1248
+ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.a) continue; if(isExistingGroup(it.g)) continue; rows.push(it); }
1249
+ if(rows.length<2){ toast('連番対象の広告が2件以上必要です'); return; }
1250
+ const lbl={imageNo:'画像番号',titleNo:'タイトル番号',descNo:'説明文番号'}[sc.field];
1251
+ const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
1252
+ if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
1253
+ const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
1254
+ mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0'); }); });
1255
+ toast((rows.length-1)+'件の'+lbl+'に連番を採番しました('+prefix+(startNum+1)+'〜)');
1256
+ }
1257
+ }
1258
+
1259
+ /* ---- ▼ 右スライドサイドバー(一覧から番号を選ぶ) ---- */
1260
+ let drawerTarget=null;
1261
+ function drawerTypeForField(f){ return f==='imageNo'?'media':'titles'; }
1262
+ function openDrawerFor(u,f){
1263
+ if(!u||!f) return;
1264
+ const isOpen = document.getElementById('refDrawer').classList.contains('open');
1265
+ const same = drawerTarget && drawerTarget.u===u && drawerTarget.f===f;
1266
+ if(isOpen && same){ closeDrawer(); return; } // 同じセルを再度押すと閉じる(トグル)
1267
+ drawerTarget={u,f,type:drawerTypeForField(f)};
1268
+ if(!same) document.getElementById('drawerSearch').value='';
1269
+ renderDrawer();
1270
+ document.getElementById('refDrawer').classList.add('open'); // 背景は暗くしない(バックドロップ不使用)
1271
+ }
1272
+ function openRefDrawer(btn){ openDrawerFor(btn.dataset.u, btn.dataset.f); }
1273
+ /* E-3: 既存キャンペーン呼び出しドロワー (アカウントJSON refs.campaigns の一覧) */
1274
+ function openCampaignRefDrawer(){
1275
+ const isOpen=document.getElementById('refDrawer').classList.contains('open');
1276
+ const same=drawerTarget && drawerTarget.type==='cpref';
1277
+ if(isOpen && same){ closeDrawer(); return; }
1278
+ drawerTarget={type:'cpref'};
1279
+ document.getElementById('drawerSearch').value='';
1280
+ renderDrawer();
1281
+ document.getElementById('refDrawer').classList.add('open');
1282
+ }
1283
+ /* F-5: 既存広告グループ呼び出しドロワー (アカウントアセット refs.adGroups 全体。campaignIdフィルタ撤廃) */
1284
+ function openAdGroupRefDrawer(){
1285
+ const isOpen=document.getElementById('refDrawer').classList.contains('open');
1286
+ const same=drawerTarget && drawerTarget.type==='agref';
1287
+ if(isOpen && same){ closeDrawer(); return; }
1288
+ drawerTarget={type:'agref'};
1289
+ document.getElementById('drawerSearch').value='';
1290
+ renderDrawer();
1291
+ document.getElementById('refDrawer').classList.add('open');
1292
+ }
1293
+ /* F-6: プラン内キャンペーン選択ドロワー。ctx.gUid=グループ親付け替え / ctx.mode==='addNew'=新規AG追加先選択 */
1294
+ function openCampaignPickDrawer(ctx){
1295
+ if(!st.campaigns.length){ toast('先にキャンペーンを追加してください'); return; }
1296
+ drawerTarget=Object.assign({type:'cppick'}, ctx||{});
1297
+ document.getElementById('drawerSearch').value='';
1298
+ renderDrawer();
1299
+ document.getElementById('refDrawer').classList.add('open');
1300
+ }
1301
+ /* F-4: プラン内広告グループ選択ドロワー (広告の所属変更) */
1302
+ function openAdGroupPickDrawer(adUid){
1303
+ const anyGroup=st.campaigns.some(c=>c.adGroups.length);
1304
+ if(!anyGroup){ toast('先に広告グループを追加してください'); return; }
1305
+ drawerTarget={type:'agpick', adUid};
1306
+ document.getElementById('drawerSearch').value='';
1307
+ renderDrawer();
1308
+ document.getElementById('refDrawer').classList.add('open');
1309
+ }
1310
+ /* 既に呼び出し済みの campaignId 集合 (二重追加防止) */
1311
+ function planCampaignIdSet(){ const s=new Set(); st.campaigns.forEach(c=>{ const id=resolveCampaignIdOf(c); if(id) s.add(String(id)); }); return s; }
1312
+ function planAdGroupIdSet(){ const s=new Set(); st.campaigns.forEach(c=>c.adGroups.forEach(g=>{ const id=resolveAdGroupIdOf(g); if(id) s.add(String(id)); })); return s; }
1313
+ function addExistingCampaign(cpId){
1314
+ cpId=String(cpId);
1315
+ if(planCampaignIdSet().has(cpId)){ toast('キャンペーン '+cpId+' は既にプランにあります'); return; }
1316
+ const meta=(st.refs.campaigns||{})[cpId]||{};
1317
+ mutate(()=>st.campaigns.push(makeExistingCampaign(cpId, meta.name||('CP '+cpId))));
1318
+ toast('既存キャンペーン '+cpId+' を呼び出しました');
1319
+ closeDrawer();
1320
+ }
1321
+ /* F-5/F-6: 既存AG呼び出し。所属キャンペーンは AG.meta.campaignId に一致するプラン内CP、無ければ最後のCP (キャンペーン列で付け替え可) */
1322
+ function addExistingAdGroup(agId){
1323
+ agId=String(agId);
1324
+ if(planAdGroupIdSet().has(agId)){ toast('広告グループ '+agId+' は既にプランにあります'); return; }
1325
+ if(!st.campaigns.length){ toast('先にキャンペーンを追加してください'); return; }
1326
+ const meta=(st.refs.adGroups||{})[agId]||{};
1327
+ let target=null;
1328
+ if(meta.campaignId){ target=st.campaigns.find(c=>String(resolveCampaignIdOf(c))===String(meta.campaignId)); }
1329
+ if(!target) target=st.campaigns[st.campaigns.length-1];
1330
+ // F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める
1331
+ mutate(()=>target.adGroups.push({uid:uid('g'), name:meta.name||('AG '+agId), paramCode:meta.paramCode||'', adGroupId:agId, isExisting:true, ads:[newAd()]}));
1332
+ toast('既存広告グループ '+agId+' を呼び出しました('+esc(target.name)+')');
1333
+ closeDrawer();
1334
+ }
1335
+ /* F-6: グループを別キャンペーンへ付け替え */
1336
+ function moveGroupToCampaign(gUid, targetCUid){
1337
+ const r=findGroup(gUid); const tc=findCp(targetCUid); if(!r||!tc) return;
1338
+ if(r.c.uid===targetCUid){ closeDrawer(); return; }
1339
+ mutate(()=>{ r.c.adGroups.splice(r.i,1); tc.c.adGroups.push(r.g); });
1340
+ toast('広告グループを「'+esc(tc.c.name)+'」へ移動');
1341
+ closeDrawer();
1342
+ }
1343
+ /* F-5: 選択キャンペーンに新規空グループを追加 */
1344
+ function addNewAdGroupToCampaign(cUid){
1345
+ const r=findCp(cUid); if(!r) return;
1346
+ mutate(()=>r.c.adGroups.push({uid:uid('g'),name:'AG'+(r.c.adGroups.length+1),paramCode:'',ads:[newAd()]}));
1347
+ toast('新規広告グループを「'+esc(r.c.name)+'」に追加');
1348
+ closeDrawer();
1349
+ }
1350
+ /* F-4: 広告を別グループへ移動 */
1351
+ function moveAdToGroup(adUid, targetGUid){
1352
+ const r=findAd(adUid); const tg=findGroup(targetGUid); if(!r||!tg) return;
1353
+ if(r.g.uid===targetGUid){ closeDrawer(); return; }
1354
+ mutate(()=>{ r.g.ads.splice(r.i,1); tg.g.ads.push(r.a); });
1355
+ toast('広告を「'+esc(tg.g.name)+'」へ移動');
1356
+ closeDrawer();
1357
+ }
1358
+ /* YDA広告グループ ターゲティング設定値(Web調査ベース・複数選択可) */
1359
+ const TG_OPTIONS = {
1360
+ tg_device:{label:'デバイス', opts:['全てのデバイス','PC','スマートフォン','タブレット']},
1361
+ tg_gender:{label:'性別', opts:['男性','女性','不明']},
1362
+ tg_age:{label:'年齢', opts:['18-19歳','20-24歳','25-29歳','30-34歳','35-39歳','40-44歳','45-49歳','50-54歳','55-59歳','60-64歳','65-69歳','70歳以上','不明']},
1363
+ tg_region:{label:'地域', opts:['全国','北海道','青森県','岩手県','宮城県','秋田県','山形県','福島県','茨城県','栃木県','群馬県','埼玉県','千葉県','東京都','神奈川県','新潟県','富山県','石川県','福井県','山梨県','長野県','岐阜県','静岡県','愛知県','三重県','滋賀県','京都府','大阪府','兵庫県','奈良県','和歌山県','鳥取県','島根県','岡山県','広島県','山口県','徳島県','香川県','愛媛県','高知県','福岡県','佐賀県','長崎県','熊本県','大分県','宮崎県','鹿児島県','沖縄県']},
1364
+ tg_dayHour:{label:'曜日・時間帯', opts:['全ての曜日・時間帯','平日','土日祝','月','火','水','木','金','土','日','午前(6-12時)','午後(12-18時)','夜間(18-24時)','深夜(0-6時)']},
1365
+ tg_audience:{label:'オーディエンスリスト', opts:['サイトリターゲティング','類似ユーザー','オーディエンスカテゴリー','サーチキーワード','購買データ','属性・興味関心']}
1366
+ };
1367
+ function openTargetingDrawer(gUid, field){
1368
+ const isOpen=document.getElementById('refDrawer').classList.contains('open');
1369
+ const same=drawerTarget && drawerTarget.type==='tg' && drawerTarget.gUid===gUid && drawerTarget.field===field;
1370
+ if(isOpen && same){ closeDrawer(); return; }
1371
+ drawerTarget={type:'tg', gUid, field};
1372
+ if(!same) document.getElementById('drawerSearch').value='';
1373
+ renderDrawer();
1374
+ document.getElementById('refDrawer').classList.add('open');
1375
+ }
1376
+ function drawerTgToggle(val){
1377
+ if(!drawerTarget||drawerTarget.type!=='tg') return; const r=findGroup(drawerTarget.gUid); if(!r) return; const f=drawerTarget.field;
1378
+ let cur=(r.g[f]?String(r.g[f]):'').split(',').map(s=>s.trim()).filter(Boolean);
1379
+ const i=cur.indexOf(val); if(i>=0) cur.splice(i,1); else cur.push(val);
1380
+ mutate(()=>{ r.g[f]=cur.join(', '); });
1381
+ renderDrawer();
1382
+ }
1383
+ function closeDrawer(){ document.getElementById('refDrawer').classList.remove('open'); drawerTarget=null; }
1384
+ function renderDrawer(){
1385
+ if(!drawerTarget) return;
1386
+ const q=(document.getElementById('drawerSearch').value||'').trim().toLowerCase();
1387
+ const titleEl=document.getElementById('drawerTitle'), listEl=document.getElementById('drawerList'), tgtEl=document.getElementById('drawerTarget');
1388
+ if(drawerTarget.type==='cpref'){
1389
+ // E-3: 既存キャンペーン一覧 (refs.campaigns)。プラン追加済みはグレーアウト
1390
+ const cps=st.refs.campaigns||{}; const inPlan=planCampaignIdSet();
1391
+ titleEl.textContent='既存キャンペーンを呼び出し';
1392
+ const ids=Object.keys(cps).sort();
1393
+ const shown=ids.filter(id=>!q||String(id).includes(q)||String((cps[id]&&cps[id].name)||'').toLowerCase().includes(q));
1394
+ tgtEl.textContent='アカウント: '+(st.project.accountName||'—')+' / '+ids.length+'件';
1395
+ let h=''; shown.forEach(id=>{ const nm=(cps[id]&&cps[id].name)||''; const dup=inPlan.has(String(id)); h+='<div class="ddrow'+(dup?' cur':'')+'"'+(dup?'':' data-cpid="'+esc(id)+'"')+' title="'+esc(nm)+'"><span class="ddno">'+esc(id)+'</span><span class="ddtx">'+esc(nm)+(dup?' <span class="pill">追加済</span>':'')+'</span></div>'; });
1396
+ listEl.innerHTML=h||'<div class="empty" style="padding:24px">既存キャンペーンがありません(アカウントJSONの refs.campaigns)</div>'; return;
1397
+ }
1398
+ if(drawerTarget.type==='agref'){
1399
+ // F-5: アカウントアセット全体の既存広告グループ (refs.adGroups)。追加済みはグレーアウト
1400
+ const ags=st.refs.adGroups||{}; const inPlan=planAdGroupIdSet();
1401
+ titleEl.textContent='既存広告グループを呼び出し';
1402
+ const ids=Object.keys(ags).sort();
1403
+ const shown=ids.filter(id=>!q||String(id).includes(q)||String((ags[id]&&ags[id].name)||'').toLowerCase().includes(q)||String((ags[id]&&ags[id].paramCode)||'').toLowerCase().includes(q)||String((ags[id]&&ags[id].campaignId)||'').includes(q));
1404
+ tgtEl.textContent='アカウントアセット全体 / '+ids.length+'件';
1405
+ let h=''; shown.forEach(id=>{ const m=ags[id]||{}; const dup=inPlan.has(String(id)); h+='<div class="ddrow'+(dup?' cur':'')+'"'+(dup?'':' data-agid="'+esc(id)+'"')+' title="'+esc(m.name||'')+'"><span class="ddno">'+esc(id)+'</span><span class="ddtx">'+(m.paramCode?'【'+esc(m.paramCode)+'】':'')+esc(m.name||'')+(dup?' <span class="pill">追加済</span>':'')+'</span></div>'; });
1406
+ listEl.innerHTML=h||'<div class="empty" style="padding:24px">既存広告グループがありません(アカウントアセットの refs.adGroups)</div>'; return;
1407
+ }
1408
+ if(drawerTarget.type==='cppick'){
1409
+ // F-6: プラン内キャンペーン選択 (親付け替え or 新規AG追加先)
1410
+ titleEl.textContent = drawerTarget.mode==='addNew' ? '新規広告グループの追加先キャンペーン' : '広告グループのキャンペーンを選択';
1411
+ tgtEl.textContent='プラン内キャンペーン / '+st.campaigns.length+'件';
1412
+ let curCUid=null; if(drawerTarget.gUid){ const r=findGroup(drawerTarget.gUid); if(r) curCUid=r.c.uid; }
1413
+ let h=''; st.campaigns.forEach((c,i)=>{ if(q&&!String(c.name||'').toLowerCase().includes(q)) return; const cur=c.uid===curCUid; h+='<div class="ddrow'+(cur?' cur':'')+'" data-cppick="'+esc(c.uid)+'" title="'+esc(c.name)+'"><span class="ddno">'+(i+1)+'</span><span class="ddtx">'+esc(c.name||'(無名)')+(cur?' <span class="pill">現在</span>':'')+'</span></div>'; });
1414
+ listEl.innerHTML=h||'<div class="empty" style="padding:24px">キャンペーンがありません</div>'; return;
1415
+ }
1416
+ if(drawerTarget.type==='agpick'){
1417
+ // F-4: プラン内広告グループ選択 (広告の所属変更)
1418
+ titleEl.textContent='広告の所属グループを選択';
1419
+ let curGUid=null; { const r=findAd(drawerTarget.adUid); if(r) curGUid=r.g.uid; }
1420
+ let n=0, h=''; st.campaigns.forEach(c=>c.adGroups.forEach(g=>{ n++; const label=c.name+' / '+g.name+(g.paramCode?('【'+g.paramCode+'】'):''); if(q&&!label.toLowerCase().includes(q)) return; const cur=g.uid===curGUid; h+='<div class="ddrow'+(cur?' cur':'')+'" data-agpick="'+esc(g.uid)+'" title="'+esc(label)+'"><span class="ddno">'+n+'</span><span class="ddtx">'+esc(label)+(cur?' <span class="pill">現在</span>':'')+'</span></div>'; }));
1421
+ tgtEl.textContent='プラン内の広告グループ / '+n+'件';
1422
+ listEl.innerHTML=h||'<div class="empty" style="padding:24px">広告グループがありません</div>'; return;
1423
+ }
1424
+ if(drawerTarget.type==='tg'){
1425
+ const cfg=TG_OPTIONS[drawerTarget.field]; const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
1426
+ const cur=(g&&g[drawerTarget.field]?String(g[drawerTarget.field]):'').split(',').map(s=>s.trim()).filter(Boolean);
1427
+ titleEl.textContent=cfg.label+' を選択(複数可)';
1428
+ tgtEl.textContent='入力先: '+(g&&g.name?g.name:'広告グループ')+' / '+cfg.label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)');
1429
+ let h=''; cfg.opts.forEach(o=>{ if(q&&!o.toLowerCase().includes(q)) return; const on=cur.indexOf(o)>=0; h+='<div class="ddrow'+(on?' cur':'')+'" data-tgval="'+esc(o)+'"><span class="ddno">'+(on?'✓':'')+'</span><span class="ddtx">'+esc(o)+'</span></div>'; });
1430
+ listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
1431
+ }
1432
+ const fieldName={imageNo:'画像番号',titleNo:'タイトル番号',descNo:'説明文番号'}[drawerTarget.f]||'';
1433
+ let curNo=''; const fa=findAd(drawerTarget.u); if(fa) curNo=String(fa.a[drawerTarget.f]||'');
1434
+ tgtEl.textContent='入力先: '+fieldName+(curNo?(' / 現在 '+curNo):' / 未設定');
1435
+ let html='';
1436
+ if(drawerTarget.type==='titles'){
1437
+ const isDesc=drawerTarget.f==='descNo';
1438
+ const store=isDesc?(st.refs.descriptions||{}):st.refs.titles;
1439
+ titleEl.textContent=(isDesc?'説明文':'タイトル')+'一覧(文言ライブラリ)';
1440
+ Object.keys(store).sort((a,b)=>a-b).forEach(no=>{ const t=store[no]; if(String(t||'').trim()==='') return; if(q&&!(String(no).includes(q)||String(t).toLowerCase().includes(q))) return; html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'"><span class="ddno">'+esc(no)+'</span><span class="ddtx">'+esc(t)+'</span></div>'; });
1441
+ } else {
1442
+ titleEl.textContent='登録画像 一覧';
1443
+ Object.keys(st.refs.media).sort((a,b)=>a-b).forEach(no=>{ const m=st.refs.media[no]; if(q&&!(String(no).includes(q)||String(m.card||'').includes(q)||String(m.sq||'').includes(q))) return; html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'"><span class="ddno">'+esc(no)+'</span>'+newBadge('media',no)+'<span class="ddtx">Card '+esc(m.card||'—')+' / SQ '+esc(m.sq||'—')+'</span></div>'; });
1444
+ }
1445
+ listEl.innerHTML=html||'<div class="empty" style="padding:24px">該当なし</div>';
1446
+ }
1447
+ function drawerPick(no){
1448
+ if(!drawerTarget) return;
1449
+ const f=drawerTarget.f, num=/^\d+$/.test(no)?Number(no):no;
1450
+ const compat = drawerTarget.type==='media'?[1]:[2,3]; // media=画像番号列 / titles=タイトル・説明列
1451
+ const rg=selRange(); let applied=0;
1452
+ mutate(()=>{
1453
+ if(rg){ const vis=flatVisible(); for(let rr=rg.r0; rr<=rg.r1; rr++){ const item=vis[rr]; if(!item) continue; for(let cc=rg.c0; cc<=rg.c1; cc++){ if(compat.indexOf(cc)>=0){ setVar(item,cc,num); applied++; } } } }
1454
+ if(applied===0){ setField(drawerTarget.u,f,num); applied=1; }
1455
+ });
1456
+ toast('番号 '+no+' を'+applied+'セルに入力');
1457
+ closeDrawer();
1458
+ }
1459
+
1460
+ /* ============================================================
1461
+ EDIT HANDLERS
1462
+ ============================================================ */
1463
+ function setField(u,f,val){
1464
+ const found=findAd(u); if(!found) return;
1465
+ if(f==='paramCode'){ if(isExistingGroup(found.g)) return; /* F-2: 既存呼び出しAGのparamCodeは編集不可 */ found.g.paramCode=val; autoResolveGroupIds(found.g,found.c); } // group-level
1466
+ else { found.a[f]=val; }
1467
+ }
1468
+ document.addEventListener('change', e=>{
1469
+ const t=e.target; if(!t.dataset||!t.dataset.u||!t.dataset.f) return;
1470
+ const f=t.dataset.f; const val = t.type==='checkbox'? t.checked : t.value;
1471
+ mutate(()=>setField(t.dataset.u,f,val));
1472
+ });
1473
+ /* 広告グループ/キャンペーン タブの一覧編集 */
1474
+ document.addEventListener('change', e=>{
1475
+ const t=e.target; if(!t.dataset) return;
1476
+ if(t.dataset.gu && t.dataset.gf){ const gf=t.dataset.gf, val=t.value; mutate(()=>{ const r=findGroup(t.dataset.gu); if(r && !isExistingGroup(r.g)){ r.g[gf]=val; if(gf==='paramCode') autoResolveGroupIds(r.g,r.c); } }); return; } // F-2: 既存呼び出しAGは基本パラメータ編集不可
1477
+ if(t.dataset.cu && t.dataset.cf){ const cf=t.dataset.cf; let val=t.value; if(cf==='budget'||cf==='tcpa') val=numOrNull(val); mutate(()=>{ const r=findCp(t.dataset.cu); if(r && !isExistingCampaign(r.c)) r.c[cf]=val; }); return; } // F-2: 既存呼び出しCPは基本パラメータ編集不可
1478
+ });
1479
+
1480
+ /* rename via contenteditable (tree) */
1481
+ document.addEventListener('blur', e=>{
1482
+ const t=e.target; if(!t.dataset||!t.dataset.rename) return;
1483
+ const val=t.textContent.trim();
1484
+ mutate(()=>{ if(t.dataset.rename==='cp'){ const r=findCp(t.dataset.u); if(r && !isExistingCampaign(r.c)) r.c.name=val; } else { const r=findGroup(t.dataset.u); if(r && !isExistingGroup(r.g)) r.g.name=val; } }); // F-2: 既存呼び出しCP/AGは改名不可
1485
+ }, true);
1486
+ document.addEventListener('keydown', e=>{ if(e.target.dataset&&e.target.dataset.rename&&e.key==='Enter'){ e.preventDefault(); e.target.blur(); } });
1487
+
1488
+ /* action clicks */
1489
+ document.addEventListener('click', e=>{
1490
+ const btn=e.target.closest('[data-act]'); if(!btn) return;
1491
+ const act=btn.dataset.act, u=btn.dataset.u;
1492
+ if(act==='toggle'){ st.ui.expanded[u]= (st.ui.expanded[u]==='c')?undefined:'c'; persist(); renderTree(); return; }
1493
+ if(act==='ad-dup'){ dupAd(u,false); return; }
1494
+ if(act==='ad-del'){ const r=findAd(u); if(r) mutate(()=>r.g.ads.splice(r.i,1)); return; }
1495
+ if(act==='ag-addad'){ const r=findGroup(u); if(r) mutate(()=>r.g.ads.push(newAd())); return; }
1496
+ if(act==='ag-addn'){ addNAds(u); return; }
1497
+ if(act==='ag-dup'){ dupGroup(u); return; }
1498
+ if(act==='ag-del'){ const r=findGroup(u); if(r) mutate(()=>r.c.adGroups.splice(r.i,1)); return; }
1499
+ if(act==='ag-param'){ editParam(u); return; }
1500
+ if(act==='cp-addag'){ const r=findCp(u); if(r) mutate(()=>r.c.adGroups.push({uid:uid('g'),name:'AG'+(r.c.adGroups.length+1),paramCode:'',ads:[newAd()]})); return; }
1501
+ if(act==='cp-addag-existing'){ openAdGroupRefDrawer(); return; } // F-5: アカウントアセット全体から
1502
+ if(act==='cp-edit'){ editCampaign(u); return; }
1503
+ if(act==='cp-dup'){ dupCampaign(u); return; }
1504
+ if(act==='cp-del'){ const r=findCp(u); if(r) mutate(()=>st.campaigns.splice(r.i,1)); return; }
1505
+ if(act==='add-campaign'){ addCampaign(); return; }
1506
+ if(act==='add-campaign-existing'){ openCampaignRefDrawer(); return; }
1507
+ // F-5: 広告グループタブ最下部の統合追加行 (追加時にキャンペーン選択)
1508
+ if(act==='add-adgroup'){ openCampaignPickDrawer({mode:'addNew'}); return; }
1509
+ if(act==='add-adgroup-existing'){ openAdGroupRefDrawer(); return; }
1510
+ // F-4: 広告タブ最下部の広告追加 (既定=最後のグループ)
1511
+ if(act==='add-ad-bottom'){ addAdBottom(); return; }
1512
+ });
1513
+ /* F-4: 広告タブ最下部「広告を追加」→ プラン内の最後のグループに追加 */
1514
+ function addAdBottom(){
1515
+ const cps=st.campaigns; if(!cps.length){ toast('先にキャンペーンを追加してください'); return; }
1516
+ let g=null; for(let i=cps.length-1;i>=0 && !g;i--){ const ags=cps[i].adGroups; if(ags.length) g=ags[ags.length-1]; }
1517
+ if(!g){ toast('先に広告グループを追加してください'); return; }
1518
+ mutate(()=>g.ads.push(newAd())); toast('広告を追加('+esc(g.name)+')');
1519
+ }
1520
+ function addCampaign(){ mutate(()=>st.campaigns.push(makeNewCampaign({urlNumber:suggestUrlNumber()}))); toast('新規キャンペーンを追加'); }
1521
+
1522
+ /* ---- clone helpers ---- */
1523
+ function cloneAd(a, clear){ const n=newAd(Object.assign({},a)); n.uid=uid('a'); if(clear){ clear.forEach(k=>{ if(k==='creative'){n.imageNo='';n.titleNo='';n.descNo='';} else n[k]=''; }); } return n; }
1524
+ function dupAd(u, clear){ const r=findAd(u); if(!r) return; mutate(()=>r.g.ads.splice(r.i+1,0,cloneAd(r.a, clear?['creative']:null))); }
1525
+ function addNAds(u){
1526
+ const r=findGroup(u); if(!r) return;
1527
+ 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+'本追加しました'); }});
1528
+ }
1529
+ function dupGroup(u){
1530
+ const r=findGroup(u); if(!r) return;
1531
+ openModal({title:'広告グループを複製', sub:r.g.name+' を複製します', fields:[
1532
+ {key:'param',label:'新しい paramCode(空ならコピー)',type:'text',value:''},
1533
+ {key:'clear',label:'',type:'check',value:true,text:'広告の中身(画像/タイトル/説明文)をクリアして器だけ残す'}
1534
+ ], onOk:v=>{ mutate(()=>{ const ng=JSON.parse(JSON.stringify(r.g)); ng.uid=uid('g'); ng.name=r.g.name+'_copy'; ng.paramCode=(v.param||r.g.paramCode); ng.adGroupId=''; ng.isExisting=false; ng.ads=r.g.ads.map(a=>cloneAd(a, v.clear?['creative']:null)); r.c.adGroups.splice(r.i+1,0,ng); }); toast('複製しました'); }});
1535
+ }
1536
+ function editParam(u){
1537
+ const r=findGroup(u); if(!r) return; const g=r.g;
1538
+ if(isExistingGroup(g)){ toast('既存広告グループは基本パラメータを編集できません(広告の追加のみ可)'); return; }
1539
+ openModal({title:'広告グループ設定', sub:'paramCode(【code】)でキャンペーン/広告グループIDを解決。既存ID・ターゲティングは任意(広告グループCSVの列)', fields:[
1540
+ {key:'name',label:'広告グループ名',type:'text',value:g.name},
1541
+ {key:'param',label:'paramCode(【code】)',type:'text',value:g.paramCode||''},
1542
+ {key:'adGroupId',label:'広告グループID(既存・空=paramCodeで解決)',type:'text',value:g.adGroupId||''},
1543
+ {key:'bid',label:'入札価格(任意)',type:'text',value:g.bid||''},
1544
+ {key:'tg_device',label:'ターゲティング: デバイス',type:'text',value:g.tg_device||''},
1545
+ {key:'tg_gender',label:'ターゲティング: 性別',type:'text',value:g.tg_gender||''},
1546
+ {key:'tg_age',label:'ターゲティング: 年齢',type:'text',value:g.tg_age||''},
1547
+ {key:'tg_region',label:'ターゲティング: 地域',type:'text',value:g.tg_region||''},
1548
+ {key:'tg_dayHour',label:'ターゲティング: 曜日・時間帯',type:'text',value:g.tg_dayHour||''},
1549
+ {key:'tg_audience',label:'ターゲティング: オーディエンスリスト',type:'text',value:g.tg_audience||''},
1550
+ {key:'tg_searchKw',label:'ターゲティング: サーチキーワード',type:'text',value:g.tg_searchKw||''}
1551
+ ], onOk:v=>{ mutate(()=>{ g.name=v.name; g.paramCode=(v.param||'').trim(); g.adGroupId=v.adGroupId; g.bid=v.bid; g.tg_device=v.tg_device; g.tg_gender=v.tg_gender; g.tg_age=v.tg_age; g.tg_region=v.tg_region; g.tg_dayHour=v.tg_dayHour; g.tg_audience=v.tg_audience; g.tg_searchKw=v.tg_searchKw; autoResolveGroupIds(g,r.c); }); }});
1552
+ }
1553
+ function suggestUrlNumber(){ let mx=0; st.campaigns.forEach(c=>{ const n=parseInt(c.urlNumber); if(!isNaN(n)&&n>mx) mx=n; }); return String(mx+1).padStart(2,'0'); }
1554
+ function editCampaign(u){
1555
+ const r=findCp(u); if(!r) return; const c=r.c;
1556
+ if(isExistingCampaign(c)){ toast('既存キャンペーンは基本パラメータを編集できません(広告グループ/広告の追加のみ可)'); return; }
1557
+ // E-2: モード/campaignID/戦略メモ フィールドは撤去 (新規/既存はID解決で自動判定)
1558
+ openModal({title:'キャンペーン設定', sub:c.name, fields:[
1559
+ {key:'name',label:'キャンペーン名',type:'text',value:c.name},
1560
+ {key:'variable',label:'検証する変数(1キャンペーン=1変数)',type:'text',value:c.variable},
1561
+ {key:'urlNumber',label:'入稿リンク末尾番号(空き: '+suggestUrlNumber()+')',type:'text',value:c.urlNumber},
1562
+ {key:'budget',label:'予算/日(空で既定 '+NEW_CP_BUDGET+')',type:'number',value:c.budget??''},
1563
+ {key:'biddingStrategy',label:'配信戦略',type:'select',value:c.biddingStrategy||'tCPA',options:[{v:'tCPA',t:'tCPA'},{v:'最大配信',t:'最大配信'}]},
1564
+ {key:'tcpa',label:'tCPA(円)',type:'number',value:c.tcpa??''}
1565
+ ], extra:'<button class="act" id="genName" style="margin-right:auto">キャンペーン名を自動生成</button>', onMount:(root,getVals,setVal)=>{
1566
+ root.querySelector('#genName').addEventListener('click',()=>{ const num=getVals().urlNumber||suggestUrlNumber(); const first=c.adGroups[0]&&c.adGroups[0].ads[0]; const cr=first?(String(first.imageNo||'CR')+(first.format==='SQ'?'s':'c')):'CR'; setVal('name', num+'_'+cr+'_'+today()); });
1567
+ }, onOk:v=>{ mutate(()=>{ c.name=v.name; c.variable=v.variable; c.urlNumber=v.urlNumber; c.budget=numOrNull(v.budget); c.biddingStrategy=v.biddingStrategy; c.tcpa=numOrNull(v.tcpa); }); }});
1568
+ // B6解消: 配信戦略/tCPAはキャンペーン個別値のみを参照 (project既定を焼き付けない)
1569
+ }
1570
+ function cloneCampaign(c, clearCreative, clearParam){
1571
+ const nc=JSON.parse(JSON.stringify(c)); nc.uid=uid('c');
1572
+ nc.campaignId=''; nc.isExisting=false; // B7/F-2: 複製はIDを引き継がず必ず新規扱い(編集可)
1573
+ nc.adGroups.forEach(g=>{ g.uid=uid('g'); g.adGroupId=''; g.isExisting=false; if(clearParam) g.paramCode=''; g.ads.forEach(a=>{ a.uid=uid('a'); if(clearCreative){a.imageNo='';a.titleNo='';a.descNo='';} }); });
1574
+ return nc;
1575
+ }
1576
+ /* 指定prefixを持つ paramCode の現在の最大末尾番号 (プラン内 + アカウントJSON refs.groups を走査) */
1577
+ function maxParamNum(prefix){
1578
+ let mx=0; const scan=code=>{ const m=String(code||'').match(/^(.*?)(\d+)\s*$/); if(m && m[1]===prefix){ const n=parseInt(m[2],10); if(n>mx) mx=n; } };
1579
+ st.campaigns.forEach(c=>c.adGroups.forEach(g=>scan(g.paramCode)));
1580
+ Object.keys(st.refs.groups||{}).forEach(scan);
1581
+ return mx;
1582
+ }
1583
+ /* E(スクショFB): 複製/量産は「中身そのまま複製 + paramCode連番自動採番 + campaignId/adGroupIdクリア(新規扱い) + キャンペーン名自動採番」で固定。モーダルは本数入力のみ */
1584
+ function dupCampaign(u){
1585
+ const r=findCp(u); if(!r) return; const c=r.c;
1586
+ openModal({title:'キャンペーンを複製 / 量産', sub:'中身そのまま複製し、paramCodeは連番で自動採番します(campaignID/広告グループIDはクリアして新規扱い・キャンペーン名は自動採番)。'+c.name, fields:[
1587
+ {key:'count',label:'複製する本数',type:'number',value:1}
1588
+ ], onOk:v=>{ const raw=parseInt(v.count); if(!Number.isFinite(raw)||raw<1){ toast('本数は1以上を入力してください'); return; } const n=Math.min(50,raw);
1589
+ mutate(()=>{
1590
+ const base=r.i; const startNum=parseInt(suggestUrlNumber())||1; const nextByPrefix={};
1591
+ for(let i=0;i<n;i++){
1592
+ const nc=cloneCampaign(c, false, false); // 中身そのまま (creative/paramCode保持) + campaignId/adGroupIdクリア
1593
+ // paramCode 連番自動採番 (prefixごとに現状の最大+1から連番)
1594
+ nc.adGroups.forEach(g=>{ const m=String(g.paramCode||'').match(/^(.*?)(\d+)\s*$/); if(m){ const prefix=m[1], width=m[2].length; if(nextByPrefix[prefix]==null) nextByPrefix[prefix]=maxParamNum(prefix)+1; g.paramCode=prefix+String(nextByPrefix[prefix]++).padStart(width,'0'); autoResolveGroupIds(g,nc); } });
1595
+ // キャンペーン名 自動採番 ({空き番号}_{CR名}_{今日})
1596
+ const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0]; const cr=f?(String(f.imageNo||'CR')+(f.format==='SQ'?'s':'c')):'CR'; nc.name=num+'_'+cr+'_'+today();
1597
+ st.campaigns.splice(base+1+i,0,nc);
1598
+ }
1599
+ });
1600
+ toast(n+'本のキャンペーンを複製(中身そのまま・paramCode連番)');
1601
+ }});
1602
+ }
1603
+
1604
+ /* ============================================================
1605
+ MODAL
1606
+ ============================================================ */
1607
+ function openModal(cfg){
1608
+ const root=document.getElementById('modalRoot');
1609
+ const fid=k=>'mf_'+k;
1610
+ let body='';
1611
+ (cfg.fields||[]).forEach(f=>{
1612
+ if(f.type==='check'){ body+='<label class="mcheck"><input type="checkbox" id="'+fid(f.key)+'"'+(f.value?' checked':'')+'> '+esc(f.text||f.label)+'</label>'; return; }
1613
+ body+='<div class="mfield"><label>'+esc(f.label)+'</label>';
1614
+ if(f.type==='select'){ body+='<select id="'+fid(f.key)+'">'+f.options.map(o=>'<option value="'+esc(o.v)+'"'+(o.v==f.value?' selected':'')+'>'+esc(o.t)+'</option>').join('')+'</select>'; }
1615
+ else if(f.type==='textarea'){ body+='<textarea id="'+fid(f.key)+'">'+esc(f.value)+'</textarea>'; }
1616
+ else { body+='<input type="'+(f.type==='number'?'number':'text')+'" id="'+fid(f.key)+'" value="'+esc(f.value)+'">'; }
1617
+ if(f.hint) body+='<div class="hint">'+esc(f.hint)+'</div>';
1618
+ body+='</div>';
1619
+ });
1620
+ root.innerHTML='<div class="overlay"><div class="modal"><h3>'+esc(cfg.title)+'</h3>'+(cfg.sub?'<div class="msub">'+esc(cfg.sub)+'</div>':'')+'<div class="mbody">'+body+'</div><div class="mfoot">'+(cfg.extra||'')+'<button class="act" data-mo="cancel">キャンセル</button><button class="act primary" data-mo="ok">OK</button></div></div></div>';
1621
+ 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; };
1622
+ const setVal=(k,val)=>{ const el=document.getElementById(fid(k)); if(el) el.value=val; };
1623
+ const close=()=>root.innerHTML='';
1624
+ root.querySelector('[data-mo="cancel"]').addEventListener('click',close);
1625
+ root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) close(); });
1626
+ root.querySelector('[data-mo="ok"]').addEventListener('click',()=>{ const v=getVals(); close(); if(cfg.onOk) cfg.onOk(v); });
1627
+ if(cfg.onMount) cfg.onMount(root, getVals, setVal);
1628
+ const first=root.querySelector('input,select,textarea'); if(first) first.focus();
1629
+ }
1630
+
1631
+ /* 案件テンプレは上部チップで直接編集(モーダル廃止・renderTplChips参照) */
1632
+
1633
+ /* ============================================================
1634
+ JSON I/O — バージョン保存 / 履歴 / 入稿JSON書き出し
1635
+ (CSV書き出しは2026-07-23に完全廃止。入稿受け渡しはJSONに統一)
1636
+ ============================================================ */
1637
+ function firstParamOf(c){ const g=c.adGroups.find(g=>g.paramCode); return g?g.paramCode:''; }
1638
+ function resolveGroupIds(paramCode){ const g=st.refs.groups[paramCode]; return {campaignId:g?g.campaignId:'', adGroupId:g?g.adGroupId:''}; }
1639
+ /* ---- F-6: 新規/既存 判定は adGroupId / campaignId の有無のみ (paramCode 紐づけ解消) ---- */
1640
+ function resolveCampaignIdOf(c){ return String((c&&c.campaignId)||'').trim(); }
1641
+ function resolveAdGroupIdOf(g){ return String((g&&g.adGroupId)||'').trim(); }
1642
+ function isNewCampaign(c){ return !resolveCampaignIdOf(c); } // 表示バッジ/export用: campaignId有無ベース
1643
+ function isNewGroup(g){ return !resolveAdGroupIdOf(g); } // 表示バッジ/export用: adGroupId有無ベース
1644
+ /* F-2: 編集可否は明示フラグ isExisting のみで判定。既存呼び出し行(adGroupId/campaignId付き)=isExisting:true。 */
1645
+ function isExistingCampaign(c){ return !!(c && c.isExisting); }
1646
+ function isExistingGroup(g){ return !!(g && g.isExisting); }
1647
+ function newOrIdBadge(isNew, id){ return isNew ? '<span class="newbadge" style="background:var(--accent)">新規</span>' : (id?'<span class="idpill">ID '+esc(id)+'</span>':''); }
1648
+ /* F-6: paramCode によるID自動解決は廃止 (no-op)。既存/新規は adGroupId の有無で判定する。 */
1649
+ function autoResolveGroupIds(g, c){ /* no-op: paramCode紐づけ解消 */ }
1650
+ function download(filename, text, mime){ const blob=new Blob([text],{type:mime||'text/plain;charset=utf-8'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; document.body.appendChild(a); a.click(); setTimeout(()=>{URL.revokeObjectURL(a.href); a.remove();},100); }
1651
+ function tsCompact(){ const d=new Date(); const p=n=>String(n).padStart(2,'0'); return ''+d.getFullYear()+p(d.getMonth()+1)+p(d.getDate())+'-'+p(d.getHours())+p(d.getMinutes())+p(d.getSeconds()); }
1652
+
1653
+ /* ---- 保存フォルダ連携 (File System Access API / Chrome系のみ) ----
1654
+ スキル同梱の「入稿計画/」フォルダを一度指定すると、以後は直接書き込み・読込・削除。
1655
+ 非対応環境(file://等)は自動でダウンロード動作にフォールバックする。 */
1656
+ let dirHandle=null, pendingDirHandle=null;
1657
+ let planFileHandle=null; // 「JSON読込」で開いた入稿計画ファイル。書き出しはこのファイルを上書き更新する
1658
+ let samplePlanFileHandle=null; // サンプルプラン.json の書込ハンドル(初回ピック後 IndexedDB に保持し、以後 Finder無しで更新)
1659
+ const fsSupported = typeof window.showDirectoryPicker==='function';
1660
+ const fsOpenSupported = typeof window.showOpenFilePicker==='function';
1661
+ async function writeToHandle(h,text){ try{ const w=await h.createWritable(); await w.write(text); await w.close(); return true; }catch(e){ return false; } }
1662
+ function idb(){ return new Promise((res,rej)=>{ const r=indexedDB.open('yda_planner_fs',1); r.onupgradeneeded=()=>r.result.createObjectStore('handles'); r.onsuccess=()=>res(r.result); r.onerror=()=>rej(r.error); }); }
1663
+ async function idbSet(k,v){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=res; tx.onerror=()=>rej(tx.error); }); }
1664
+ async function idbGet(k){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readonly'); const rq=tx.objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>rej(rq.error); }); }
1665
+ async function connectFolder(){
1666
+ if(!fsSupported){ toast('このブラウザはフォルダ連携非対応です(保存はダウンロード動作)'); return; }
1667
+ try{
1668
+ if(pendingDirHandle){ const p=await pendingDirHandle.requestPermission({mode:'readwrite'}); if(p==='granted'){ dirHandle=pendingDirHandle; pendingDirHandle=null; renderHist(); toast('保存フォルダに再接続しました'); return; } }
1669
+ dirHandle=await window.showDirectoryPicker({id:'yda-planner-plans', mode:'readwrite'});
1670
+ pendingDirHandle=null; await idbSet('dir',dirHandle); renderHist(); updateSubmitJsonBtn(); toast('保存フォルダに接続しました: '+dirHandle.name);
1671
+ }catch(e){ if(e && e.name!=='AbortError') toast('フォルダ接続に失敗: '+e.message); }
1672
+ }
1673
+ /* 入稿計画フォルダ未接続なら書き出しボタンを「接続」ボタンに切替(接続後は書き出し) */
1674
+ function updateSubmitJsonBtn(){ /* ボタンは『サンプルプラン更新』に用途変更したためラベル切替は無効化 */ }
1675
+ async function restoreFolder(){
1676
+ if(!fsSupported) return;
1677
+ try{ const h=await idbGet('dir'); if(!h) return;
1678
+ const p=await h.queryPermission({mode:'readwrite'});
1679
+ if(p==='granted'){ dirHandle=h; toast('入稿計画フォルダ('+h.name+'/)に自動接続しました'); }
1680
+ else pendingDirHandle=h; // 権限失効時はボタン押下(ユーザー操作)で1クリック再要求
1681
+ }catch(e){}
1682
+ if(activeView==='hist') renderHist();
1683
+ }
1684
+ async function writePlanFile(name, text){
1685
+ if(!dirHandle) return false;
1686
+ try{ const fh=await dirHandle.getFileHandle(name,{create:true}); const w=await fh.createWritable(); await w.write(text); await w.close(); return true; }
1687
+ catch(e){ toast('フォルダ書き込み失敗: '+e.message+'(ダウンロードに切替)'); return false; }
1688
+ }
1689
+
1690
+ /* ============================================================
1691
+ B-1: アカウント別参照データ (アカウント/ フォルダ + <名前>.json)
1692
+ File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(アカウント/index.json 先頭)を使用。
1693
+ IndexedDB キー 'yda_planner_fs_accounts' に第2ハンドルを保存 (入稿計画/ の 'dir' とは別枠)。
1694
+ ============================================================ */
1695
+ const ACC_JSON_VERSION=1;
1696
+ let accDirHandle=null, pendingAccDirHandle=null;
1697
+ let accountFiles=[]; // フォルダ内の <名前>.json のベース名一覧 (File System Access接続時)
1698
+ let httpAccountFiles=[]; // アカウント/index.json から取得した <名前> 一覧 (読み取り専用切替)
1699
+ /* アカウント一覧は マニフェスト アカウント/index.json (直接ファイルfetch)から取得する。
1700
+ サンプルプランと同じ「ファイル読み込み」なので、file:///autoindex無し環境でも読める。
1701
+ フォールバック: index.json が無ければディレクトリ一覧(HTML)を解析。 */
1702
+ async function fetchAccountList(){
1703
+ const set=new Set();
1704
+ // (1) マニフェスト アカウント/index.json (file://・autoindex無しでも読める)
1705
+ try{ const idx=await fetchJson('アカウント/index.json'); if(idx){ const arr=Array.isArray(idx)?idx:(idx.accounts||[]); arr.map(String).filter(Boolean).forEach(n=>set.add(n)); } }catch(e){}
1706
+ // (2) ディレクトリ一覧(HTML) — http配信時は index.json に無い新規ファイルも自動検出してマージ
1707
+ try{ const r=await fetch('アカウント/',{cache:'no-store'}); if(r&&r.ok){ const html=await r.text(); const re=/href="([^"]+\.json)"/gi; let m; while((m=re.exec(html))){ let n=decodeURIComponent(m[1]).replace(/^.*\//,'').replace(/\.json$/i,''); if(n&&n!=='index') set.add(n); } } }catch(e){}
1708
+ httpAccountFiles=[...set].sort();
1709
+ }
1710
+ function accStatusText(){
1711
+ if(!fsSupported) return 'このブラウザはフォルダ連携非対応';
1712
+ if(accDirHandle) return '📁 '+accDirHandle.name+'/ ('+accountFiles.length+'アカウント)';
1713
+ if(pendingAccDirHandle) return 'フォルダ再接続待ち(📁 フォルダ を押す)';
1714
+ return 'フォルダ接続してください(現在は同梱の'+BUNDLED_ACCOUNT_NAME+'を使用中)';
1715
+ }
1716
+ function renderAccountBar(){
1717
+ const sel=document.getElementById('accSelect'); if(!sel) return;
1718
+ const cur=st.project.accountName||BUNDLED_ACCOUNT_NAME;
1719
+ let names = accDirHandle ? accountFiles.slice() : (httpAccountFiles.length ? httpAccountFiles.slice() : [BUNDLED_ACCOUNT_NAME]);
1720
+ if(!accDirHandle && !names.includes(BUNDLED_ACCOUNT_NAME)) names.unshift(BUNDLED_ACCOUNT_NAME);
1721
+ if(!names.includes(cur)) names.unshift(cur);
1722
+ let opts=names.map(n=>'<option value="'+esc(n)+'"'+(n===cur?' selected':'')+'>'+esc(n)+((n===BUNDLED_ACCOUNT_NAME&&!accDirHandle)?'(同梱)':'')+'</option>').join('');
1723
+ // F-3: プルダウン内にアクション項目 (新規追加/このアカウントを編集)。フォルダ接続オプションは廃止
1724
+ opts+='<option disabled>──────────</option>';
1725
+ opts+='<option value="__add__">+ 新規追加…</option>';
1726
+ opts+='<option value="__edit__">✎ このアカウントを編集…</option>';
1727
+ sel.innerHTML=opts;
1728
+ const s2=document.getElementById('accStatus'); if(s2) s2.textContent=accStatusText(); // accStatusは廃止(要素なし)だが後方安全のためguard
1729
+ }
1730
+ async function connectAccountFolder(){
1731
+ if(!fsSupported){ toast('このブラウザはフォルダ連携非対応です'); return; }
1732
+ try{
1733
+ if(pendingAccDirHandle){ const p=await pendingAccDirHandle.requestPermission({mode:'readwrite'}); if(p==='granted'){ accDirHandle=pendingAccDirHandle; pendingAccDirHandle=null; await refreshAccountFiles(); toast('アカウントフォルダに再接続しました'); return; } }
1734
+ accDirHandle=await window.showDirectoryPicker({id:'yda-planner-accounts', mode:'readwrite'});
1735
+ pendingAccDirHandle=null; await idbSet('yda_planner_fs_accounts', accDirHandle);
1736
+ await refreshAccountFiles(); toast('アカウントフォルダに接続: '+accDirHandle.name);
1737
+ }catch(e){ if(e && e.name!=='AbortError') toast('フォルダ接続に失敗: '+e.message); }
1738
+ }
1739
+ async function restoreAccountFolder(){
1740
+ if(!fsSupported){ renderAccountBar(); return; }
1741
+ try{ const h=await idbGet('yda_planner_fs_accounts'); if(h){ const p=await h.queryPermission({mode:'readwrite'}); if(p==='granted'){ accDirHandle=h; await refreshAccountFiles(); } else pendingAccDirHandle=h; } }catch(e){}
1742
+ renderAccountBar();
1743
+ }
1744
+ async function refreshAccountFiles(){
1745
+ accountFiles=[];
1746
+ if(accDirHandle){ try{ for await(const [name,handle] of accDirHandle.entries()){ if(handle.kind==='file' && /\.json$/i.test(name)) accountFiles.push(name.replace(/\.json$/i,'')); } }catch(e){} accountFiles.sort(); }
1747
+ renderAccountBar();
1748
+ }
1749
+ async function readAccountJson(name){
1750
+ if(!accDirHandle) return null;
1751
+ try{ const fh=await accDirHandle.getFileHandle(name+'.json'); const f=await fh.getFile(); return JSON.parse(await f.text()); }catch(e){ return null; }
1752
+ }
1753
+ async function writeAccountJson(name, obj){
1754
+ if(!accDirHandle) return false;
1755
+ try{ const fh=await accDirHandle.getFileHandle(name+'.json',{create:true}); const w=await fh.createWritable(); await w.write(JSON.stringify(obj,null,2)); await w.close(); return true; }
1756
+ catch(e){ toast('アカウントJSON書き込み失敗: '+e.message); return false; }
1757
+ }
1758
+ /* 参照データ差し替えのシーム: フォルダ読込・新規作成・E2Eテストが共通で使う */
1759
+ window.applyAccountJson=function(obj){
1760
+ if(!obj||obj.accountJsonVersion!==ACC_JSON_VERSION||!obj.refs) throw new Error('アカウントJSON形式が不正です(accountJsonVersion:'+ACC_JSON_VERSION+' 必須)');
1761
+ snapshot();
1762
+ st.refs=normalizeRefs(obj.refs);
1763
+ st.project.accountId=String(obj.accountId||'');
1764
+ st.project.accountName=String(obj.name||'');
1765
+ // E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持
1766
+ if(obj.principal!=null) st.project.principal=String(obj.principal);
1767
+ persist(); buildDatalists(); render(); renderAccountBar();
1768
+ };
1769
+ async function selectAccount(name){
1770
+ if(String(name).startsWith('__')){ renderAccountBar(); return; } // アクション項目は change リスナーで処理
1771
+ if(name===st.project.accountName){ renderAccountBar(); return; }
1772
+ if(!accDirHandle){
1773
+ if(httpAccountFiles.includes(name)){ // http一覧のアカウントを fetch で読み込み(読み取り専用切替)
1774
+ const obj=await fetchJson('アカウント/'+encodeURIComponent(name)+'.json');
1775
+ if(obj){ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }catch(e){ toast(e.message); renderAccountBar(); } }
1776
+ else { toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); }
1777
+ return;
1778
+ }
1779
+ if(name===BUNDLED_ACCOUNT_NAME){ window.applyAccountJson({accountJsonVersion:1, accountId:BUNDLED_ACCOUNT_ID, name:BUNDLED_ACCOUNT_NAME, principal:bundledPrincipal, refs:buildRefs()}); toast('同梱アカウント '+BUNDLED_ACCOUNT_NAME+' に切替'); }
1780
+ else { toast('先にアカウントフォルダに接続してください'); renderAccountBar(); }
1781
+ return;
1782
+ }
1783
+ const obj=await readAccountJson(name);
1784
+ if(!obj){ toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); return; }
1785
+ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }
1786
+ catch(e){ toast(e.message); renderAccountBar(); }
1787
+ }
1788
+ /* refAdd/refEdit/refDel 後にアクティブアカウントJSONへ自動保存 (フォルダ接続 & フォルダ内アカウント時) */
1789
+ async function saveActiveAccountJson(){
1790
+ if(!accDirHandle) return; const name=st.project.accountName; if(!name) return;
1791
+ if(!accountFiles.includes(name)) return; // 同梱デフォルト等フォルダ外は書き戻さない
1792
+ const obj={accountJsonVersion:ACC_JSON_VERSION, accountId:String(st.project.accountId||''), name, principal:String(st.project.principal||''), updatedAt:new Date().toISOString(), refs:normalizeRefs(st.refs)};
1793
+ await writeAccountJson(name, obj);
1794
+ }
1795
+ /* F-3: 「+新規追加」チューザー — ファイルから追加 / AIで追加 の2択 */
1796
+ function openAddAccountModal(){
1797
+ const root=document.getElementById('modalRoot');
1798
+ const close=()=>root.innerHTML='';
1799
+ root.innerHTML='<div class="overlay"><div class="modal"><h3>新規アカウントを追加</h3><div class="msub">追加方法を選んでください</div>'
1800
+ +'<div class="mbody"><div style="display:flex; flex-direction:column; gap:10px">'
1801
+ +'<button class="act" id="addFromFile" style="justify-content:flex-start; text-align:left">📄 ファイルから追加<br><span class="hinttxt">アカウントJSONファイルを選んで読み込み</span></button>'
1802
+ +'<button class="act" id="addFromAi" style="justify-content:flex-start; text-align:left">🤖 AIで追加<br><span class="hinttxt">CLIから収集するプロンプト+JSONフォーマットを表示</span></button>'
1803
+ +'</div></div><div class="mfoot"><button class="act" data-mo="cancel">閉じる</button></div></div></div>';
1804
+ root.querySelector('[data-mo="cancel"]').addEventListener('click',close);
1805
+ root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) close(); });
1806
+ root.querySelector('#addFromFile').addEventListener('click',()=>{ close(); addAccountFromFile(); });
1807
+ root.querySelector('#addFromAi').addEventListener('click',()=>{ close(); openAiAddAccountModal(); });
1808
+ }
1809
+ /* F-3: ファイルから追加 — アカウントJSONを選んで読み込み (フォルダ接続時はフォルダにも保存して一覧化) */
1810
+ function addAccountFromFile(){ const inp=document.getElementById('accFileInput'); if(inp) inp.click(); }
1811
+ async function loadAccountFile(file){
1812
+ try{
1813
+ const obj=JSON.parse(await file.text());
1814
+ window.applyAccountJson(obj); // accountJsonVersion/refs を検証し project(name/id/principal)へ反映
1815
+ if(accDirHandle && obj.name){ await writeAccountJson(String(obj.name), obj); await refreshAccountFiles(); } // フォルダへコピー保存
1816
+ renderAccountBar();
1817
+ toast('アカウント '+(obj.name||'')+' を読み込みました'+(accDirHandle?'(フォルダにも保存)':''));
1818
+ }catch(e){ toast('読み込み失敗: '+(e&&e.message?e.message:'不正なアカウントJSON')); }
1819
+ }
1820
+ /* F-3: AIで追加 — フォーム入力は無し。プロンプトだけを表示し、アカウント名/ID/主体者は Claude Code 上でAIと対話して決めさせる */
1821
+ function openAiAddAccountModal(){
1822
+ const schema=[
1823
+ '{',
1824
+ ' "accountJsonVersion": 1,',
1825
+ ' "accountId": "<ID>",',
1826
+ ' "name": "<アカウント名>",',
1827
+ ' "principal": "<主体者表記(例: ○○製薬)>",',
1828
+ ' "updatedAt": "<ISO8601>",',
1829
+ ' "refs": {',
1830
+ ' "titles": {"1": "見出し文言"},',
1831
+ ' "descriptions": {"1": "説明文文言"},',
1832
+ ' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
1833
+ ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
1834
+ ' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
1835
+ ' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
1836
+ ' }',
1837
+ '}'
1838
+ ].join('\n');
1839
+ const prompt=[
1840
+ 'YDA入稿プランナー用に、新規アカウントの参照データJSONを生成してください。',
1841
+ '',
1842
+ '1. まず対象アカウントを対話で決めてください:',
1843
+ ' - webgram list --platform yda --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認してアカウント名・accountId・主体者表記を確定する',
1844
+ '2. 確定したアカウントについて、登録画像・既存キャンペーン/広告グループを取得:',
1845
+ ' webgram list --platform yda --resource media --account-id <ID>',
1846
+ ' webgram list --platform yda --resource campaigns --account-id <ID>',
1847
+ ' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
1848
+ '3. 取得結果を下記スキーマに整形し、アカウント/<アカウント名>.json として書き出す:',
1849
+ ' - media は 画像番号 → {card, sq}(mediaId)',
1850
+ ' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
1851
+ ' - campaigns/adGroups は campaignId/adGroupId をキーに',
1852
+ '',
1853
+ 'スキーマ:',
1854
+ schema,
1855
+ '',
1856
+ '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加 →「📄 ファイルから追加」で その JSON を読み込めます。'
1857
+ ].join('\n');
1858
+ showPromptModal({title:'AIで追加(CLIから収集)', sub:'このプロンプトをClaude Codeに貼ってください。アカウント名はAIと対話して決めます', prompt, hint:'生成された アカウント/<名前>.json は「+新規追加 →「📄 ファイルから追加」で読み込めます'});
1859
+ }
1860
+ /* E-1: アクティブアカウントの主体者/アカウントIDを編集 (アカウントJSONへ書き戻し) */
1861
+ function openEditAccountModal(){
1862
+ const name=st.project.accountName||'';
1863
+ openModal({title:'アカウント設定を編集', sub:name||'アカウント', fields:[
1864
+ {key:'accountId',label:'アカウントID',type:'text',value:st.project.accountId||''},
1865
+ {key:'principal',label:'主体者表記(広告のprincipal)',type:'text',value:st.project.principal||''}
1866
+ ], onOk: async v=>{
1867
+ mutate(()=>{ st.project.accountId=String(v.accountId||'').trim(); st.project.principal=String(v.principal||'').trim(); });
1868
+ await saveActiveAccountJson(); // フォルダ接続かつフォルダ内アカウント時はJSONへ書き戻し
1869
+ toast('アカウント設定を更新しました');
1870
+ }});
1871
+ }
1872
+ /* B-3: CLI経由インポートのプロンプトを表示 (静的HTMLは直接APIを叩かず Claude に生成させる) */
1873
+ function openImportModal(){
1874
+ const name=st.project.accountName||''; const accId=st.project.accountId||'';
1875
+ const schema=[
1876
+ '{',
1877
+ ' "accountJsonVersion": 1,',
1878
+ ' "accountId": "<ID>",',
1879
+ ' "name": "<アカウント名>",',
1880
+ ' "principal": "<主体者表記(例: ○○製薬)>",',
1881
+ ' "updatedAt": "<ISO8601>",',
1882
+ ' "refs": {',
1883
+ ' "titles": {"1": "見出し文言"},',
1884
+ ' "descriptions": {"1": "説明文文言"},',
1885
+ ' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
1886
+ ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
1887
+ ' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
1888
+ ' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
1889
+ ' }',
1890
+ '}'
1891
+ ].join('\n');
1892
+ const prompt=[
1893
+ 'YDA入稿プランナー用に、アカウント「'+(name||'<アカウント名>')+'」'+(accId?'(accountId: '+accId+')':'')+'の参照データJSONを生成してください。',
1894
+ '',
1895
+ '手順:',
1896
+ '1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
1897
+ ' webgram list --platform yda --resource accounts',
1898
+ ' webgram list --platform yda --resource media --account-id '+(accId||'<ID>'),
1899
+ ' webgram list --platform yda --resource campaigns --account-id '+(accId||'<ID>'),
1900
+ ' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
1901
+ '2. 取得結果を下記スキーマに整形して アカウント/'+(name||'<アカウント名>')+'.json として書き出す:',
1902
+ ' - media は 画像番号 → {card, sq}(mediaId)',
1903
+ ' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
1904
+ ' - campaigns/adGroups は campaignId/adGroupId をキーに',
1905
+ '',
1906
+ 'スキーマ:',
1907
+ schema,
1908
+ '',
1909
+ '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
1910
+ ].join('\n');
1911
+ showPromptModal({title:'アカウント情報をインポート', sub:'このプロンプトをClaude Codeに貼ってCLIでアカウントJSONを生成してください', prompt, hint:'アカウント/<名前>.json が生成されたら「📁 フォルダ」再接続で自動的にセレクトに現れます'});
1912
+ }
1913
+ /* 参照ビュー: 他アカウントから タイトル/説明文 をコピー (文言はアカウント間で共通のことが多い) */
1914
+ function openCopyRefsModal(){
1915
+ if(!accDirHandle){ toast('先にアカウントフォルダに接続してください'); return; }
1916
+ const others=accountFiles.filter(n=>n!==st.project.accountName);
1917
+ if(!others.length){ toast('コピー元になる他アカウントがありません'); return; }
1918
+ openModal({title:'他アカウントから文言をコピー', sub:'選んだアカウントの タイトル/説明文 を現在の参照データにマージ(既存番号は上書きしません)', fields:[
1919
+ {key:'src',label:'コピー元アカウント',type:'select',value:others[0],options:others.map(n=>({v:n,t:n}))}
1920
+ ], onOk: async v=>{
1921
+ const obj=await readAccountJson(v.src); if(!obj||!obj.refs){ toast('読み込めません'); return; }
1922
+ const src=normalizeRefs(obj.refs); let n=0; snapshot();
1923
+ ['titles','descriptions'].forEach(k=>{ Object.keys(src[k]||{}).forEach(no=>{ if(st.refs[k][no]==null){ st.refs[k][no]=src[k][no]; n++; } }); });
1924
+ persist(); buildDatalists(); render(); saveActiveAccountJson();
1925
+ toast(v.src+' から '+n+' 件の文言をコピーしました');
1926
+ }});
1927
+ }
1928
+
1929
+ /* ---- 履歴 (localStorage: メタ一覧 + バージョン本体) ----
1930
+ 本体もブラウザ内に自動保存する → フォルダ選択なしで一覧・再読込・削除が完結する。
1931
+ フォルダ(入稿計画/)接続時はファイルにも書き出す(二重保存)。 */
1932
+ const BODY_KEY = 'yda_planner_histbody_';
1933
+ function histList(){ try{ return JSON.parse(localStorage.getItem(HIST_KEY))||[]; }catch(e){ return []; } }
1934
+ function histSave(list){ try{ localStorage.setItem(HIST_KEY, JSON.stringify(list)); }catch(e){} }
1935
+ function nextVersionId(){ return histList().reduce((m,h)=>Math.max(m,h.versionId||0),0)+1; }
1936
+ function histGetBody(vid){ try{ return localStorage.getItem(BODY_KEY+vid); }catch(e){ return null; } }
1937
+ function histDelBody(vid){ try{ localStorage.removeItem(BODY_KEY+vid); }catch(e){} }
1938
+ /* 容量超過時は古い本体から捨てる (メタ一覧は残す = ファイル側から読み直せる) */
1939
+ function histPutBody(vid, json){
1940
+ for(;;){
1941
+ try{ localStorage.setItem(BODY_KEY+vid, json); return true; }
1942
+ catch(e){
1943
+ const stored=histList().filter(h=>h.versionId!==vid && histGetBody(h.versionId)!=null)
1944
+ .sort((a,b)=>(a.versionId||0)-(b.versionId||0));
1945
+ if(!stored.length) return false;
1946
+ histDelBody(stored[0].versionId);
1947
+ }
1948
+ }
1949
+ }
1950
+
1951
+ /* ---- バージョン保存: 保存のたびに常に新バージョン (上書きなし) ---- */
1952
+ function saveVersion(){
1953
+ const prevAuthor = st.meta.author || localStorage.getItem(AUTHOR_KEY) || '';
1954
+ openModal({title:'バージョン保存', sub:'常に新バージョンとして保存します(上書きなし)/ 媒体: YDA / 次: v'+nextVersionId()+' / 保存先: '+(dirHandle?dirHandle.name+'/':'ダウンロード(フォルダ未接続)'), fields:[
1955
+ {key:'name',label:'名称',type:'text',value:st.meta.name||st.project.name,hint:'履歴一覧に表示される名前'},
1956
+ {key:'memo',label:'メモ',type:'textarea',value:st.meta.memo||''},
1957
+ {key:'author',label:'作成者',type:'text',value:prevAuthor,hint:'空欄可。次回保存時も記憶されます'}
1958
+ ], onOk: async v=>{
1959
+ const now=new Date().toISOString();
1960
+ const versionId=nextVersionId();
1961
+ st.meta={versionId, name:v.name||st.project.name, memo:v.memo||'', author:v.author||'', platform:'yda', createdAt:now, updatedAt:now};
1962
+ try{ localStorage.setItem(AUTHOR_KEY, v.author||''); }catch(e){}
1963
+ const c=validationCounts();
1964
+ const fileName=(st.project.name||'plan')+'_v'+versionId+'_'+tsCompact()+'.json';
1965
+ const json=JSON.stringify(stateJson(),null,2);
1966
+ const inLocal=histPutBody(versionId, json); // ブラウザ内に常時保存 (フォルダ不要で再読込可)
1967
+ const wrote=await writePlanFile(fileName,json); // フォルダ(入稿計画/)接続時はファイルにも書き出し
1968
+ if(!wrote) download(fileName,json,'application/json'); // 未接続時はDLでファイルも残す(入稿計画へ手動格納可)
1969
+ const list=histList();
1970
+ list.push({versionId, name:st.meta.name, memo:st.meta.memo, author:st.meta.author, platform:'yda', createdAt:now, fileName, inFolder:wrote, inLocal, campaigns:st.campaigns.length, ads:c.ads});
1971
+ histSave(list); persist();
1972
+ if(activeView==='hist') renderHist();
1973
+ toast('v'+versionId+' を保存しました'+(wrote?'(入稿計画フォルダにも書き出し)':''));
1974
+ // A-3: 保存完了時の次アクション(プロンプト)表示
1975
+ let info='<div class="callout green" style="margin-top:0">プラン「'+esc(st.meta.name)+'」<b>v'+versionId+'</b> を保存しました(CP '+st.campaigns.length+' / 広告 '+c.ads+' 件)</div>';
1976
+ info+='<div class="mfield"><label>保存先 / ファイル名</label><div class="mono" style="font-size:12px; word-break:break-all">'+esc((wrote&&dirHandle?dirHandle.name+'/':'')+fileName)+'</div></div>';
1977
+ if(c.err) info+='<div class="callout amber">エラー '+c.err+' 件・警告 '+c.warn+' 件があります。書き出し時に未解決広告は除外されます。</div>';
1978
+ const prompt='プラン「'+(st.meta.name||st.project.name)+'」v'+versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_入稿計画_*.json を指定して dry-run してください:\n\nwebgram submit-plan --plan-file "<入稿計画JSONのパス>"';
1979
+ showPromptModal({title:'v'+versionId+' を保存しました', sub:'次のアクション(入稿)用のプロンプトです', infoHtml:info, prompt, hint:'まず「入稿JSON書き出し」でプランを入稿計画JSONに変換してください'});
1980
+ }});
1981
+ }
1982
+
1983
+ /* ---- 履歴ビュー: 入稿計画(planVersion:1)ファイル一覧 ----
1984
+ 「✅完成→保存して入稿」が入稿計画フォルダに書き出した *_入稿計画_*.json を一覧化する。
1985
+ (バージョン保存/localStorage履歴は廃止) */
1986
+ function tsFromFileName(name){ const m=String(name||'').match(/_(\d{8})-(\d{6})\.json$/i); if(!m) return ''; const d=m[1], t=m[2]; return d.slice(0,4)+'-'+d.slice(4,6)+'-'+d.slice(6,8)+' '+t.slice(0,2)+':'+t.slice(2,4)+':'+t.slice(4,6); }
1987
+ async function folderPlanList(){
1988
+ if(!dirHandle) return [];
1989
+ const out=[];
1990
+ try{ for await(const [name,h] of dirHandle.entries()){
1991
+ if(h.kind!=='file' || !/\.json$/i.test(name)) continue;
1992
+ try{ const f=await h.getFile(); const j=JSON.parse(await f.text());
1993
+ if(j && j.planVersion===1 && Array.isArray(j.campaigns)){
1994
+ const ads=j.campaigns.reduce((n,c)=>n+(c.adGroups||[]).reduce((m,g)=>m+(g.ads||[]).length,0),0);
1995
+ out.push({fileName:name, accountId:String(j.accountId||''), campaigns:j.campaigns.length, ads, savedAt:tsFromFileName(name)||new Date(f.lastModified).toISOString().replace('T',' ').slice(0,19)});
1996
+ }
1997
+ }catch(e){}
1998
+ } }catch(e){}
1999
+ return out;
2000
+ }
2001
+ async function renderHist(){
2002
+ const s=document.getElementById('fsStatus');
2003
+ if(!fsSupported) s.textContent='このブラウザはフォルダ連携非対応です(履歴表示・入稿計画の保存にはChrome系が必要)';
2004
+ else if(dirHandle) s.textContent='📁 入稿計画フォルダ接続中: '+dirHandle.name+'/(「完成→保存して入稿」で書き出した入稿計画を一覧表示)';
2005
+ else if(pendingDirHandle) s.textContent='フォルダ再接続待ち(右のボタンで1クリック再接続)';
2006
+ else s.textContent='「保存フォルダに接続」で 入稿計画/ を接続すると、保存済みの入稿計画がここに一覧表示されます(初回のみ選択)';
2007
+ const list=(await folderPlanList()).sort((a,b)=>String(b.savedAt).localeCompare(String(a.savedAt)));
2008
+ const host=document.getElementById('histHost');
2009
+ if(!list.length){ host.innerHTML='<div class="hinttxt" style="padding:24px; font-size:13px">保存済みの入稿計画はまだありません。「✅ 完成 → 保存して入稿」で書き出すと、ここに日時・アカウント・規模・ファイル名が一覧表示されます。'+(dirHandle?'':'(先に「保存フォルダに接続」で 入稿計画/ を接続してください)')+'</div>'; return; }
2010
+ host.innerHTML='<table class="g"><thead><tr><th>保存日時</th><th>アカウントID</th><th>規模</th><th>ファイル名</th><th style="width:80px"></th></tr></thead><tbody>'+
2011
+ list.map(h=>'<tr data-hf="'+esc(h.fileName)+'">'+
2012
+ '<td class="mono">'+esc(h.savedAt)+'</td>'+
2013
+ '<td class="mono">'+esc(h.accountId||'—')+'</td>'+
2014
+ '<td class="mono">CP '+h.campaigns+' / 広告 '+h.ads+'</td>'+
2015
+ '<td class="mono" style="font-size:11px; word-break:break-all">'+esc(h.fileName)+'</td>'+
2016
+ '<td><button class="act ghost" data-hdelplan="'+esc(h.fileName)+'">削除</button></td>'+
2017
+ '</tr>').join('')+'</tbody></table>';
2018
+ }
2019
+ async function histLoadVersion(vid){ /* 旧バージョン読込は履歴の入稿計画ベース化で未使用 (data-hload廃止) */ }
2020
+ function histEditVersion(vid){
2021
+ const list=histList(); const h=list.find(x=>x.versionId===vid); if(!h) return;
2022
+ openModal({title:'v'+vid+' の名称・メモを編集', fields:[
2023
+ {key:'name',label:'名称',type:'text',value:h.name||''},
2024
+ {key:'memo',label:'メモ',type:'textarea',value:h.memo||''}
2025
+ ], onOk: async v=>{
2026
+ h.name=v.name; h.memo=v.memo; histSave(list);
2027
+ const patch=j=>{ j.meta=Object.assign({},j.meta,{name:v.name,memo:v.memo,updatedAt:new Date().toISOString()}); return j; };
2028
+ const local=histGetBody(vid);
2029
+ if(local){ try{ histPutBody(vid, JSON.stringify(patch(JSON.parse(local)),null,2)); }catch(e){} }
2030
+ if(h.inFolder && dirHandle && h.fileName){
2031
+ try{ const fh=await dirHandle.getFileHandle(h.fileName); const f=await fh.getFile(); const j=patch(JSON.parse(await f.text()));
2032
+ const w=await(await dirHandle.getFileHandle(h.fileName,{create:true})).createWritable(); await w.write(JSON.stringify(j,null,2)); await w.close();
2033
+ }catch(e){}
2034
+ }
2035
+ if(st.meta.versionId===vid){ st.meta.name=v.name; st.meta.memo=v.memo; persist(); }
2036
+ renderHist(); toast('v'+vid+' を更新しました');
2037
+ }});
2038
+ }
2039
+ function histDeleteVersion(vid){
2040
+ const list=histList(); const h=list.find(x=>x.versionId===vid); if(!h) return;
2041
+ openModal({title:'v'+vid+' を削除しますか?', sub:(h.name||'')+'('+(h.fileName||'')+')'+(h.inFolder&&dirHandle?' — フォルダ内のファイルも削除されます':' — 履歴から削除します(ダウンロード済みファイルは残ります)'), fields:[], onOk: async ()=>{
2042
+ if(h.inFolder && dirHandle && h.fileName){ try{ await dirHandle.removeEntry(h.fileName); }catch(e){} }
2043
+ histDelBody(vid);
2044
+ histSave(list.filter(x=>x.versionId!==vid)); renderHist(); toast('v'+vid+' を削除しました');
2045
+ }});
2046
+ }
2047
+
2048
+ /* ---- 入稿JSON書き出し (webgram submit-plan 用の入稿計画JSON) ----
2049
+ 画面のプラン (案件設定チップ / キャンペーン行 / 広告グループ行のターゲティング列) を
2050
+ そのまま SubmitPlanFile {planVersion:1, platform:'yda', accountId, campaigns} に変換する。
2051
+ 広告は backend ydaAdSchema の IMAGE 形式 (mediaIds/headlines/descriptions/principal)。
2052
+ 設定モーダルは持たない。手入稿(バナー)と未解決(参照表で引けない)広告は除外する。 */
2053
+
2054
+ /* 画面の日本語ラベル → API enum 変換表 (正: frontend/src/pages/submission/types.ts) */
2055
+ const TG_ENUM = {
2056
+ device:{'スマートフォン':'SMARTPHONE','PC':'DESKTOP','タブレット':'TABLET'},
2057
+ gender:{'男性':'ST_MALE','女性':'ST_FEMALE','不明':'ST_UNKNOWN'},
2058
+ age:{'18-19歳':'GT_RANGE18_19','20-24歳':'GT_RANGE20_24','25-29歳':'GT_RANGE25_29','30-34歳':'GT_RANGE30_34','35-39歳':'GT_RANGE35_39','40-44歳':'GT_RANGE40_44','45-49歳':'GT_RANGE45_49','50-54歳':'GT_RANGE50_54','55-59歳':'GT_RANGE55_59','60-64歳':'GT_RANGE60_64','65-69歳':'GT_RANGE65_69','70歳以上':'GT_RANGE70_UL2','不明':'GT_UNKNOWN2'},
2059
+ day:{'月':'MONDAY','火':'TUESDAY','水':'WEDNESDAY','木':'THURSDAY','金':'FRIDAY','土':'SATURDAY','日':'SUNDAY'},
2060
+ // tCPA=コンバージョン数最大化+目標CPA / 最大配信=コンバージョン数最大化(目標なし・要実機確認)
2061
+ bidding:{'tCPA':'MAXIMIZE_CONVERSIONS','最大配信':'MAXIMIZE_CONVERSIONS'}
2062
+ };
2063
+ function tgList(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Boolean); }
2064
+ function mapDevices(s){ const l=tgList(s); if(!l.length||l.indexOf('全てのデバイス')>=0) return []; return l.map(x=>TG_ENUM.device[x]).filter(Boolean); }
2065
+ /* 曜日・時間帯セル → {days[], hours{start,end}|null}。「平日」「土日祝」は展開、
2066
+ 時間帯(午前(6-12時)等)は startHour/endHour に変換 (複数選択時は最初の1つ) */
2067
+ function mapDays(s){
2068
+ const l=tgList(s); const days=[]; let hours=null;
2069
+ if(!l.length||l.indexOf('全ての曜日・時間帯')>=0) return {days:[], hours:null};
2070
+ const add=d=>{ if(days.indexOf(d)<0) days.push(d); };
2071
+ l.forEach(x=>{
2072
+ if(x==='平日'){ ['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY'].forEach(add); }
2073
+ else if(x==='土日祝'){ add('SATURDAY'); add('SUNDAY'); }
2074
+ else if(TG_ENUM.day[x]){ add(TG_ENUM.day[x]); }
2075
+ else { const m=x.match(/\((\d+)-(\d+)時\)/); if(m&&!hours) hours={start:+m[1], end:+m[2]}; }
2076
+ });
2077
+ return {days, hours};
2078
+ }
2079
+ const toStatus=s=>s==='オン'?'ACTIVE':'PAUSED';
2080
+
2081
+ /* A-4: plan本体 + 除外/警告集計を返す。plan本体だけ必要な既存呼び出し(E2E含む)は buildSubmitPlan() を使う。
2082
+ 除外: 未解決(errs)広告・手入稿バナー・空広告グループ(ads:[])・空キャンペーン(adGroups:[])。
2083
+ 警告: 地域/オーディエンス/サーチKW に値があるがJSON未反映の広告グループ数。 */
2084
+ function buildSubmitPlanWithStats(){
2085
+ const p=st.project;
2086
+ const stats={exported:0, unresolved:0, pendingImages:0, pendingImageNos:[], emptyGroups:0, emptyCampaigns:0, tgDropped:0};
2087
+ const campaigns=st.campaigns.map(c=>{
2088
+ const bidLabel=String(c.biddingStrategy||'').trim(); // A-1: キャンペーン個別値のみ (project既定へフォールバックしない)
2089
+ const biddingStrategy=TG_ENUM.bidding[bidLabel]||bidLabel||'MAXIMIZE_CONVERSIONS';
2090
+ const adGroups=c.adGroups.map(g=>{
2091
+ const dh=mapDays(g.tg_dayHour);
2092
+ const agStatus=toStatus(g.deliveryStatus||p.deliveryStatus);
2093
+ const rated=g.ads.map(a=>({a, r:resolveAd(a,g)}));
2094
+ rated.forEach(x=>{ if(x.r.errs.length) stats.unresolved++; });
2095
+ // Part A: 未登録画像(pendingImage)は除外せず「新規アップロード対象」として保持。真エラー(errs)のみ除外。
2096
+ const keptAds=rated.filter(x=>!x.r.errs.length).map(x=>{
2097
+ const ad={
2098
+ adName:x.r.adName,
2099
+ status:agStatus,
2100
+ mediaType:'IMAGE', // レスポンシブ画像広告 (backend ydaAdSchema IMAGE)
2101
+ headlines:[String(x.r.titleText)],
2102
+ descriptions:[String(x.r.descText)],
2103
+ principal:p.principal||'', // E-1: 主体者はアカウントJSON由来 (st.project.principal)
2104
+ finalUrl:x.r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
2105
+ };
2106
+ if(x.r.pendingImage){
2107
+ // 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する (imagePaths は agent/CLI が補完)
2108
+ ad.mediaIds=[]; ad.imageNo=x.r.imageNo; ad.pendingImageNo=x.r.imageNo; ad.imagePaths=[];
2109
+ stats.pendingImages++; if(stats.pendingImageNos.indexOf(x.r.imageNo)<0) stats.pendingImageNos.push(x.r.imageNo);
2110
+ } else {
2111
+ ad.mediaIds=[String(x.r.mediaId)];
2112
+ }
2113
+ return ad;
2114
+ });
2115
+ stats.exported+=keptAds.length;
2116
+ // 地域/オーディエンス/サーチKW列はAPIのID解決が必要なためJSON未反映 (出力ありのグループのみ警告計上)
2117
+ if(keptAds.length && ((g.tg_region&&tgList(g.tg_region).length)||(g.tg_audience&&tgList(g.tg_audience).length)||String(g.tg_searchKw||'').trim()!=='')) stats.tgDropped++;
2118
+ const ag={
2119
+ name:g.name,
2120
+ status:agStatus,
2121
+ smartTargeting:false, // GUI未対応 (将来ターゲティング列に追加)
2122
+ devices:mapDevices(g.tg_device),
2123
+ ages:tgList(g.tg_age).map(x=>TG_ENUM.age[x]).filter(Boolean),
2124
+ genders:tgList(g.tg_gender).map(x=>TG_ENUM.gender[x]).filter(Boolean),
2125
+ days:dh.days,
2126
+ placementsInclude:[], placementsExclude:[],
2127
+ audiencesInclude:[], audiencesExclude:[],
2128
+ ads:keptAds
2129
+ };
2130
+ if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
2131
+ const aid=resolveAdGroupIdOf(g); if(aid) ag.adGroupId=aid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
2132
+ return ag;
2133
+ }).filter(ag=>{ if(ag.ads.length===0){ stats.emptyGroups++; return false; } return true; }); // B9: 空広告グループを除外
2134
+ const cp={
2135
+ name:c.name,
2136
+ goal:p.goal||'CONVERSION',
2137
+ status:toStatus(p.deliveryStatus), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
2138
+ startDate:p.startDate||today(),
2139
+ budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
2140
+ biddingStrategy,
2141
+ adGroups
2142
+ };
2143
+ if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
2144
+ else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
2145
+ const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
2146
+ return cp;
2147
+ }).filter(cp=>{ if(cp.adGroups.length===0){ stats.emptyCampaigns++; return false; } return true; }); // B9: 空キャンペーンを除外
2148
+ const plan={planVersion:1, platform:'yda', accountId:String(p.accountId||'').trim(), campaigns};
2149
+ return {plan, stats};
2150
+ }
2151
+ function buildSubmitPlan(){ return buildSubmitPlanWithStats().plan; }
2152
+ function submitAdCount(body){ return body.campaigns.reduce((n,c)=>n+c.adGroups.reduce((m,g)=>m+g.ads.length,0),0); }
2153
+ /* Claudeに貼るプロンプト + コピーボタン付きモーダル (A-3) */
2154
+ function showPromptModal(opts){
2155
+ const root=document.getElementById('modalRoot');
2156
+ const close=()=>root.innerHTML='';
2157
+ root.innerHTML='<div class="overlay"><div class="modal"><h3>'+esc(opts.title)+'</h3>'
2158
+ +(opts.sub?'<div class="msub">'+esc(opts.sub)+'</div>':'')
2159
+ +'<div class="mbody">'+(opts.infoHtml||'')
2160
+ +'<div class="mfield"><label>Claudeに貼るプロンプト</label>'
2161
+ +'<textarea id="promptText" readonly style="min-height:120px; font-family:var(--mono); font-size:12px; white-space:pre-wrap">'+esc(opts.prompt)+'</textarea>'
2162
+ +(opts.hint?'<div class="hint">'+esc(opts.hint)+'</div>':'')+'</div>'
2163
+ +'</div><div class="mfoot"><button class="act" data-mo="cancel">閉じる</button><button class="act primary" id="btnCopyPrompt">📋 プロンプトをコピー</button></div></div></div>';
2164
+ root.querySelector('[data-mo="cancel"]').addEventListener('click',close);
2165
+ root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) close(); });
2166
+ root.querySelector('#btnCopyPrompt').addEventListener('click',()=>{ const ta=document.getElementById('promptText'); try{ ta.select(); document.execCommand('copy'); }catch(e){} if(navigator.clipboard){ navigator.clipboard.writeText(opts.prompt).catch(()=>{}); } toast('プロンプトをコピーしました'); });
2167
+ }
2168
+ /* ============================================================
2169
+ 使い方モーダル (❓ 使い方)
2170
+ 全体の流れ(戦略壁打ち→プランナー→保存→入稿)と
2171
+ 初回セットアップ(アカウント取込)・戦略壁打ちの
2172
+ プロンプト例をコピーボタン付きで提示する。
2173
+ ============================================================ */
2174
+ /* 戦略壁打ちを始めるためのプロンプト例(アピール用の即コピー素材) */
2175
+ const HELP_STRATEGY_PROMPT=[
2176
+ 'YDA(Yahoo!ディスプレイ広告)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。',
2177
+ '',
2178
+ '# 前提',
2179
+ '- アカウント: <アカウント名(日本語OK)>',
2180
+ '- 商材 / LP: <商材名・URL>',
2181
+ '- 目的: <コンバージョン獲得 / 認知 など>',
2182
+ '- 予算・期間: <月予算 / 開始日>',
2183
+ '- 訴求したいポイント: <強み・オファー・ターゲット層>',
2184
+ '',
2185
+ '# お願い',
2186
+ '1. 上記をもとにターゲティング方針・キャンペーン/広告グループ構成・訴求軸を提案してください。',
2187
+ '2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン.json」に書き出して(上書きして)ください。',
2188
+ ' - 保存先: .claude/skills/ad-ops/サンプルプラン.json',
2189
+ ' - フォーマット: {"schemaVersion":3, "accountId":"<対象アカウントID>", "campaigns":[ ... ]}',
2190
+ ' - campaigns には キャンペーン → 広告グループ(paramCode) → 広告 の構成を入れてください。',
2191
+ '3. 書き出したら、プランナーを開き直す(またはリロードする)と サンプルプラン.json の内容が自動で表示されます。'
2192
+ ].join('\n');
2193
+ /* 初回セットアップ: 対象アカウントの参照データ(画像・見出し・既存CP/AG)をCLIで取り込むプロンプト例 */
2194
+ const HELP_IMPORT_PROMPT=[
2195
+ 'YDA入稿プランナー用に、アカウント「<アカウント名(日本語OK)>」の参照データJSONを生成してください。',
2196
+ '',
2197
+ '手順:',
2198
+ '1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
2199
+ ' webgram list --platform yda --resource accounts',
2200
+ ' webgram list --platform yda --resource media --account-id <ID>',
2201
+ ' webgram list --platform yda --resource campaigns --account-id <ID>',
2202
+ '2. 取得結果を下記スキーマに整形して アカウント/<アカウント名>.json として書き出す:',
2203
+ ' - media は 画像番号 → {card, sq}(mediaId)',
2204
+ ' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
2205
+ ' - campaigns/adGroups は campaignId/adGroupId をキーに',
2206
+ '',
2207
+ 'スキーマ:',
2208
+ '{',
2209
+ ' "accountJsonVersion": 1,',
2210
+ ' "accountId": "<ID>",',
2211
+ ' "name": "<アカウント名(日本語OK)>",',
2212
+ ' "principal": "<主体者表記(例: ○○製薬)>",',
2213
+ ' "refs": {',
2214
+ ' "titles": {"1": "見出し文言"},',
2215
+ ' "descriptions": {"1": "説明文文言"},',
2216
+ ' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
2217
+ ' "campaigns": {"<campaignId>": {"name": "CP名"}},',
2218
+ ' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
2219
+ ' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
2220
+ ' }',
2221
+ '}',
2222
+ '',
2223
+ '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。'
2224
+ ].join('\n');
2225
+ function openHelpModal(){
2226
+ const root=document.getElementById('modalRoot');
2227
+ const close=()=>root.innerHTML='';
2228
+ const promptCard=(id,title,text)=>
2229
+ '<div class="promptcard"><div class="pchdr"><span class="pctitle">'+esc(title)+'</span>'
2230
+ +'<button class="act primary pccopy" data-copy="'+id+'">📋 コピー</button></div>'
2231
+ +'<pre id="'+id+'">'+esc(text)+'</pre></div>';
2232
+ const steps=[
2233
+ ['戦略を壁打ちする','これは広告を<b>YDAに入稿するための仕組み</b>です。まずAI(Claude Code)に商材・目的・予算を伝えて戦略を壁打ちします。下の「戦略壁打ちプロンプト例」をコピーして貼るところから始めてください。'],
2234
+ ['戦略OK → プランナーに反映','壁打ちで<b>OKを出す</b>と、その内容が <code>サンプルプラン.json</code> に書き出されます(AIが上書き)。<b>プランナーを開き直す/リロードする</b>と、キャンペーン・広告グループ・広告としてこの画面に自動表示されます。'],
2235
+ ['見て、AIに再指示 or 自分で編集','表示された内容を見て、<b>もう一度AIに指示を投げても</b>いいですし、<b>プランナー内でそのまま作業しても</b>OK。広告の追加・複製・文言/画像/パラメータの編集がこの画面でできます(セル選択でコピペも可)。'],
2236
+ ['右上から保存','編集が終わったら、<b>右上の「バージョン保存」</b>を押してください。常に新バージョン(v1, v2…)として保存されます。'],
2237
+ ['表示されたプロンプトをAIに貼る','保存すると<b>次のアクション用のプロンプトが表示</b>されます。そのプロンプトをコピーしてAI(Claude Code)に貼ると、入稿(dry-run→本実行)まで進められます。']
2238
+ ];
2239
+ root.innerHTML='<div class="overlay"><div class="modal help"><h3>YDA入稿プランナーの使い方</h3>'
2240
+ +'<div class="msub">戦略を壁打ち → プランナーで確認・編集 → 保存 → 表示されたプロンプトで入稿、という流れです。</div>'
2241
+ +'<div class="mbody">'
2242
+ +'<div class="callout"><b>全体像</b>:AIで作れる部分はAIに作らせ(戦略壁打ち→JSON)、人が確定すべきクリエイティブ選定・確認をこのプランナーで楽に行います。最後に表示されるプロンプトでそのまま入稿できます。</div>'
2243
+ +'<ol class="hsteps">'
2244
+ +steps.map(s=>'<li><div class="hst">'+s[0]+'</div><div class="hsd">'+s[1]+'</div></li>').join('')
2245
+ +'</ol>'
2246
+ +'<div class="mfield"><label>🚀 まず最初に:戦略壁打ちプロンプト例</label>'
2247
+ +'<div class="hint" style="margin-bottom:6px">これをコピーしてClaude Codeに貼り、&lt;&gt;の部分を埋めて壁打ちを始めてください。</div>'
2248
+ +promptCard('helpPromptStrategy','戦略壁打ち用プロンプト',HELP_STRATEGY_PROMPT)
2249
+ +'</div>'
2250
+ +'<div class="mfield"><label>🔧 初回セットアップ:アカウント取込プロンプト例</label>'
2251
+ +'<div class="hint" style="margin-bottom:6px">初めて使うアカウントは、まずこのプロンプトで登録画像・見出し・既存キャンペーン等を取り込みます(アカウント名は日本語でOK)。</div>'
2252
+ +promptCard('helpPromptImport','アカウント取込プロンプト',HELP_IMPORT_PROMPT)
2253
+ +'</div>'
2254
+ +'</div><div class="mfoot"><button class="act primary" data-mo="cancel">閉じて始める</button></div></div></div>';
2255
+ root.querySelector('[data-mo="cancel"]').addEventListener('click',close);
2256
+ root.querySelector('.overlay').addEventListener('mousedown',ev=>{ if(ev.target.classList.contains('overlay')) close(); });
2257
+ root.querySelectorAll('[data-copy]').forEach(btn=>btn.addEventListener('click',()=>{
2258
+ const txt=document.getElementById(btn.getAttribute('data-copy')).textContent;
2259
+ if(navigator.clipboard){ navigator.clipboard.writeText(txt).catch(()=>{}); }
2260
+ else { try{ const r=document.createRange(); r.selectNode(document.getElementById(btn.getAttribute('data-copy'))); const s=getSelection(); s.removeAllRanges(); s.addRange(r); document.execCommand('copy'); s.removeAllRanges(); }catch(e){} }
2261
+ toast('プロンプトをコピーしました');
2262
+ }));
2263
+ }
2264
+
2265
+ /* 「サンプルプラン更新」: 現在のプラン(共通設定+キャンペーン)を サンプルプラン.json に書き戻す。
2266
+ 初回だけ File System Access でファイルを選び、以後は IndexedDB 保持ハンドルで Finder無し更新。 */
2267
+ async function updateSamplePlan(){
2268
+ const data={schemaVersion:SCHEMA_VERSION, project:st.project, campaigns:st.campaigns};
2269
+ const json=JSON.stringify(data,null,1);
2270
+ if(!samplePlanFileHandle){ try{ const h=await idbGet('samplePlanFile'); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted') samplePlanFileHandle=h; else samplePlanFileHandle=h; } }catch(e){} }
2271
+ 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; } }
2272
+ if(!samplePlanFileHandle){
2273
+ if(!fsOpenSupported){ download('サンプルプラン.json',json,'application/json'); toast('サンプルプラン.json をダウンロードしました(同名で差し替えてください)'); return; }
2274
+ try{ const [h]=await window.showOpenFilePicker({id:'yda-sample-plan', types:[{description:'JSON', accept:{'application/json':['.json']}}]}); samplePlanFileHandle=h; await idbSet('samplePlanFile',h); }
2275
+ catch(e){ if(e && e.name==='AbortError') return; toast('サンプルプラン.json を選べませんでした'); return; }
2276
+ }
2277
+ const ok=await writeToHandle(samplePlanFileHandle, json);
2278
+ if(ok){ samplePlan=data; toast('サンプルプランを更新しました('+samplePlanFileHandle.name+')'); }
2279
+ else toast('サンプルプランの更新に失敗しました');
2280
+ }
2281
+ async function exportSubmitJson(){
2282
+ const {plan,stats}=buildSubmitPlanWithStats();
2283
+ if(!plan.accountId){
2284
+ toast('アカウントを選択してください(上部のアカウントセレクト)');
2285
+ document.getElementById('cfgBox').open=true;
2286
+ const el=document.getElementById('accSelect'); if(el) el.focus();
2287
+ return;
2288
+ }
2289
+ const n=stats.exported;
2290
+ if(n===0){ toast('入稿対象の広告が0件です(空入稿を防止しました)'); return; }
2291
+ const json=JSON.stringify(plan,null,2);
2292
+ // 完成したプランは「新規ファイル」として保存する(上書きしない・毎回タイムスタンプ付き)。
2293
+ // 接続済み入稿計画フォルダがあればそこへ、無ければダウンロード。Finderは出さない。
2294
+ const fileName=(st.project.name||'plan')+'_入稿計画_'+tsCompact()+'.json';
2295
+ const wrote=await writePlanFile(fileName,json);
2296
+ if(!wrote) download(fileName,json,'application/json');
2297
+ _dirty=false; // 保存(入稿計画書き出し)で未保存フラグをクリア
2298
+ const savedRel = '.claude/skills/ad-ops/入稿計画/'+fileName; // Skill同梱の入稿計画フォルダ内の想定パス
2299
+ const savedPath=(wrote&&dirHandle?dirHandle.name+'/':'')+fileName;
2300
+ const savedLabel = wrote?'入稿計画フォルダに新規保存':'ダウンロード(フォルダ未接続)';
2301
+ // 除外内訳
2302
+ const exc=[];
2303
+ if(stats.unresolved) exc.push('未解決 '+stats.unresolved+'件');
2304
+ if(stats.emptyGroups) exc.push('空広告グループ '+stats.emptyGroups+'件');
2305
+ if(stats.emptyCampaigns) exc.push('空キャンペーン '+stats.emptyCampaigns+'件');
2306
+ let info='<div class="callout green" style="margin-top:0">広告 <b>'+n+'</b> 件を新規保存しました('+esc(savedLabel)+')</div>';
2307
+ info+='<div class="mfield"><label>保存先 / ファイル名</label><div class="mono" style="font-size:12px; word-break:break-all">'+esc(savedPath||fileName)+'</div></div>';
2308
+ info+='<div class="mfield"><label>除外件数の内訳</label><div style="font-size:12.5px">'+(exc.length?esc(exc.join(' / ')):'なし')+'</div></div>';
2309
+ if(stats.pendingImages) info+='<div class="callout amber">🆕 未登録画像 <b>'+stats.pendingImages+'</b> 件(管理番号: '+esc(stats.pendingImageNos.join(', '))+')。これらはアカウントのメディアライブラリに未登録のため、入稿前にローカル画像をアップロードして新規登録が必要です(下記プロンプト参照)。</div>';
2310
+ if(stats.tgDropped) info+='<div class="callout amber">⚠ 地域/オーディエンス/サーチKW を設定した広告グループが '+stats.tgDropped+' 件ありますが、これらはAPIのID解決が必要なため入稿JSONには反映されていません(入稿後に管理画面で設定してください)。</div>';
2311
+ // Claudeに貼るプロンプト: 保存したJSONを Skillの入稿計画/ に入れて submit-plan で入稿を試みる
2312
+ const promptLines=[
2313
+ '入稿を試みてください。手順:',
2314
+ '1) 入稿計画JSON('+fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+savedRel+')。',
2315
+ ' ・入稿計画フォルダ(.claude/skills/ad-ops/入稿計画/)に無ければ、ダウンロードフォルダ(~/Downloads/'+fileName+')を探して上記パスへ移動する。',
2316
+ (wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。')
2317
+ ];
2318
+ if(stats.pendingImages){
2319
+ promptLines.push('2) 【未登録画像の新規登録】入稿計画内に mediaIds が空で pendingImageNo が付いた広告があります(管理番号: '+stats.pendingImageNos.join(', ')+')。');
2320
+ promptLines.push(' これらは各管理番号に対応するローカル画像ファイルのパスを、その広告の "imagePaths":[] に設定してください(submit-plan 実行時に自動で /yda/upload-image → mediaId 解決されます)。');
2321
+ promptLines.push('3) 置いたパスで dry-run 入稿を実行:');
2322
+ } else {
2323
+ promptLines.push('2) 置いたパスで dry-run 入稿を実行:');
2324
+ }
2325
+ promptLines.push(' webgram submit-plan --plan-file "'+savedRel+'"');
2326
+ promptLines.push((stats.pendingImages?'4':'3')+') dry-run が全件OKなら本実行:');
2327
+ promptLines.push(' webgram submit-plan --plan-file "'+savedRel+'" --no-dry-run --yes');
2328
+ const prompt=promptLines.join('\n');
2329
+ showPromptModal({title:'入稿計画JSONを保存しました', sub:'このプロンプトをClaude Codeに貼って入稿してください', infoHtml:info, prompt, hint:'submit-plan は1キャンペーンずつ入稿・進捗書き戻しで再開可(submitted済みはスキップ)'});
2330
+ }
2331
+
2332
+ /* ---- JSON読込 (v1/v2 両対応) ---- */
2333
+ /* B12: 後方互換なし。SCHEMA_VERSION 不一致のJSONは読み込まない。 */
2334
+ function applyLoadedJson(j){
2335
+ if(!j||!j.campaigns) throw new Error('読み込み失敗: 不正なJSON');
2336
+ if(j.schemaVersion!==SCHEMA_VERSION) throw new Error('スキーマ形式が異なるため読み込めません(planner v'+SCHEMA_VERSION+' のJSONのみ対応)');
2337
+ snapshot();
2338
+ st.project=Object.assign(defaultProject(),j.project);
2339
+ st.campaigns=j.campaigns;
2340
+ st.ui=j.ui||{expanded:{}};
2341
+ if(validRefs(j.refs)){ st.refs=normalizeRefs(j.refs); }
2342
+ st.meta=Object.assign(defaultMeta(),j.meta||{});
2343
+ if(typeof buildDatalists==='function') buildDatalists();
2344
+ persist(); render(); if(typeof renderAccountBar==='function') renderAccountBar();
2345
+ }
2346
+ function loadJson(file){ planFileHandle=null; const rd=new FileReader(); rd.onload=()=>{ try{ applyLoadedJson(JSON.parse(rd.result)); toast('JSONを読み込みました'+(st.meta&&st.meta.versionId?('(v'+st.meta.versionId+')'):'')); }catch(e){ toast(e instanceof SyntaxError ? '読み込み失敗: 不正なJSONです' : (e&&e.message?e.message:'読み込み失敗: 不正なJSON')); } }; rd.readAsText(file); }
2347
+ /* JSON読込: File System Access 対応環境ではハンドル付きで開く → 書き出しで同じ元ファイルを上書き更新できる */
2348
+ async function openPlanFile(){
2349
+ if(!fsOpenSupported){ document.getElementById('fileInput').click(); return; }
2350
+ try{
2351
+ const [h]=await window.showOpenFilePicker({id:'yda-planner-plans', types:[{description:'JSON', accept:{'application/json':['.json']}}]});
2352
+ const f=await h.getFile(); applyLoadedJson(JSON.parse(await f.text())); planFileHandle=h; updateSubmitJsonBtn();
2353
+ toast('JSONを読み込みました(このファイルを「入稿JSON書き出し」で上書き更新します)');
2354
+ }catch(e){ if(e && e.name!=='AbortError') toast(e instanceof SyntaxError ? '読み込み失敗: 不正なJSONです' : ('読み込み失敗: '+(e&&e.message||''))); }
2355
+ }
2356
+
2357
+ /* ============================================================
2358
+ WIRING
2359
+ ============================================================ */
2360
+ document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-v]'); if(!b) return; clearSel(); closeDrawer(); activeView=b.dataset.v; document.querySelectorAll('#viewSeg button').forEach(x=>x.classList.toggle('active',x===b)); document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active', v.id==='view-'+activeView)); render(); });
2361
+ document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(); renderSheet(); });
2362
+ /* プランビュー: シート / ツリー モード切替 (1階層下) */
2363
+ 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(); });
2364
+ /* 媒体要件ビュー: YDA / Meta タブ切替 */
2365
+ document.getElementById('mediaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-mt]'); if(!b) return; document.querySelectorAll('#mediaTabs button').forEach(x=>x.classList.toggle('active',x===b)); document.getElementById('mt-yda').style.display = b.dataset.mt==='yda'?'':'none'; document.getElementById('mt-meta').style.display = b.dataset.mt==='meta'?'':'none'; });
2366
+ /* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
2367
+ 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'; }); });
2368
+ /* 案件テンプレ: チップ直接編集 */
2369
+ document.getElementById('tplChips').addEventListener('change',e=>{ const el=e.target.closest('[data-proj]'); if(!el) return; const f=el.dataset.proj; const v = el.type==='number'? Number(el.value) : el.value; mutate(()=>{ st.project[f]=v; }); });
2370
+ document.getElementById('btnSubmitJson').addEventListener('click',exportSubmitJson);
2371
+ document.getElementById('btnHelp').addEventListener('click',openHelpModal);
2372
+ document.getElementById('planWarnClose').addEventListener('click',()=>{ planMismatchMsg=''; renderPlanWarn(); });
2373
+ document.getElementById('btnConnectFolder').addEventListener('click',connectFolder);
2374
+ /* ---- B-1/B-3: アカウントバー wiring ---- */
2375
+ /* F-3: 文言コピーは参照データビューへ移設。フォルダ接続/編集/インポートはヘッダープルダウンのアクション項目に集約 */
2376
+ document.getElementById('btnAccCopy').addEventListener('click',openCopyRefsModal);
2377
+ document.getElementById('accFileInput').addEventListener('change',e=>{ const f=e.target.files[0]; e.target.value=''; if(f) loadAccountFile(f); });
2378
+ document.getElementById('accSelect').addEventListener('change',e=>{ const v=e.target.value;
2379
+ if(v==='__add__'){ renderAccountBar(); openAddAccountModal(); return; }
2380
+ if(v==='__edit__'){ renderAccountBar(); openEditAccountModal(); return; }
2381
+ selectAccount(v);
2382
+ });
2383
+ /* E-1: キャンペーン追加導線はシート/ツリー内(data-act="add-campaign"/"add-campaign-existing")に配置。ツールバー独立ボタンは廃止 */
2384
+ document.getElementById('histHost').addEventListener('click',async e=>{
2385
+ // 履歴=入稿計画ファイル一覧。削除のみ (フォルダ内ファイルを削除)
2386
+ const dp=e.target.closest('[data-hdelplan]');
2387
+ if(dp){ const name=dp.dataset.hdelplan;
2388
+ openModal({title:'入稿計画を削除しますか?', sub:name+' をフォルダから削除します', fields:[], onOk: async ()=>{ if(dirHandle){ try{ await dirHandle.removeEntry(name); }catch(err){ toast('削除に失敗: '+(err&&err.message||'')); } } toast('削除しました'); renderHist(); }});
2389
+ }
2390
+ });
2391
+ /* #btnLoad(JSON読込)/#btnSave(バージョン保存) ボタンは撤去。#fileInput は E2E/内部読込のため温存(loadJson) */
2392
+ document.getElementById('fileInput').addEventListener('change',e=>{ if(e.target.files[0]) loadJson(e.target.files[0]); e.target.value=''; });
2393
+ document.getElementById('btnUndo').addEventListener('click',undo);
2394
+ document.getElementById('sheetTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-st]'); if(!b) return; sheetTab=b.dataset.st; clearSel(); closeDrawer(); renderSheet(); });
2395
+ document.getElementById('btnExpandAll').addEventListener('click',()=>{ st.ui.expanded={}; persist(); renderTree(); });
2396
+ document.getElementById('btnCollapseAll').addEventListener('click',()=>{ st.campaigns.forEach(c=>{ st.ui.expanded[c.uid]='c'; }); persist(); renderTree(); });
2397
+ document.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='z'){ e.preventDefault(); undo(); } });
2398
+
2399
+ /* ---- 参照データ wiring ---- */
2400
+ document.getElementById('refTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-rt]'); if(!b) return; refTab=b.dataset.rt; document.querySelectorAll('#refTabs button').forEach(x=>x.classList.toggle('active',x===b)); renderRef(); });
2401
+ document.getElementById('refSearch').addEventListener('input',renderRef);
2402
+ document.getElementById('btnRefAdd').addEventListener('click',refAdd);
2403
+ document.getElementById('btnRefReset').addEventListener('click',()=>{ snapshot(); st.refs=buildRefs(); persist(); buildDatalists(); render(); toast('参照データを初期値に戻しました'); });
2404
+ document.getElementById('refHost').addEventListener('click',e=>{ const ed=e.target.closest('[data-refedit]'); if(ed){ refEdit(ed.dataset.refedit, ed.dataset.no); return; } const dl=e.target.closest('[data-refdel]'); if(dl){ refDel(dl.dataset.refdel, dl.dataset.no); } });
2405
+ document.getElementById('btnDupSel').addEventListener('click',dupSelected);
2406
+ document.getElementById('btnSeqParam').addEventListener('click',applySequentialParamCodes);
2407
+
2408
+ /* ---- シート セル選択 (spreadsheet) ---- */
2409
+ const sheetHostEl=document.getElementById('sheetHost');
2410
+ 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全体を当たり判定に(広告タブに踏襲)
2411
+ let pendingDrawer=null, dragMoved=false; // ドラッグ範囲選択中はドロワーを開かない(mouseup時に単一クリックのみ開く)
2412
+ sheetHostEl.addEventListener('mousedown',e=>{
2413
+ if(e.target.closest('.combodd')) return; // ▼ はドロップダウン専用
2414
+ 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; }
2415
+ const cell=cellFromEvent(e); if(!cell) return; const r=+cell.dataset.r, c=+cell.dataset.c;
2416
+ pendingDrawer=null; dragMoved=false;
2417
+ if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(sheetTab==='ads' && [1,2,3].indexOf(c)>=0) pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(sheetTab==='groups' && cell.dataset.gf && TG_OPTIONS[cell.dataset.gf]) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
2418
+ applySelection();
2419
+ });
2420
+ sheetHostEl.addEventListener('mouseover',e=>{
2421
+ if(!dragging) return;
2422
+ if(dragging==='row'){ const rn=e.target.closest('td.rownum'); if(rn){ selB={r:+rn.dataset.rownum,c:tabCols().length-1}; applySelection(); } return; }
2423
+ const cell=cellFromEvent(e); if(cell){ const r=+cell.dataset.r,c=+cell.dataset.c; if(!selB||selB.r!==r||selB.c!==c){ dragMoved=true; if(pendingDrawer && pendingDrawer.kind!=='ad') pendingDrawer=null; /* 画像/タイトル/説明文(ad)は複数ドラッグ選択でもサイドバーを開く。ターゲティング(tg)は単体クリックのみ */ if(!selA||r!==selA.r||c!==selA.c) document.body.classList.add('noselect'); selB={r,c}; applySelection(); } }
2424
+ });
2425
+ sheetHostEl.addEventListener('click',e=>{
2426
+ // F-6: 広告グループタブのキャンペーンセル → プラン内CP選択(親付け替え)
2427
+ const cpp=e.target.closest('[data-cppick-g]'); if(cpp){ e.preventDefault(); openCampaignPickDrawer({gUid:cpp.dataset.cppickG}); return; }
2428
+ // F-4: 広告タブのグループセル → プラン内AG選択(所属変更)
2429
+ const agp=e.target.closest('[data-agpick-ad]'); if(agp){ e.preventDefault(); openAdGroupPickDrawer(agp.dataset.agpickAd); return; }
2430
+ const dd=e.target.closest('.combodd'); if(!dd) return; e.preventDefault(); if(dd.dataset.tg){ openTargetingDrawer(dd.dataset.gu, dd.dataset.tg); } else { openRefDrawer(dd); }
2431
+ });
2432
+ /* ---- 右ドロワー wiring ---- */
2433
+ document.getElementById('drawerClose').addEventListener('click',closeDrawer);
2434
+ document.getElementById('drawerBackdrop').addEventListener('click',closeDrawer);
2435
+ document.getElementById('drawerSearch').addEventListener('input',renderDrawer);
2436
+ document.getElementById('drawerList').addEventListener('click',e=>{ const row=e.target.closest('.ddrow'); if(!row) return; if(!drawerTarget) return;
2437
+ if(drawerTarget.type==='tg'){ drawerTgToggle(row.dataset.tgval); }
2438
+ else if(drawerTarget.type==='cpref'){ if(row.dataset.cpid) addExistingCampaign(row.dataset.cpid); }
2439
+ else if(drawerTarget.type==='agref'){ if(row.dataset.agid) addExistingAdGroup(row.dataset.agid); }
2440
+ else if(drawerTarget.type==='cppick'){ if(row.dataset.cppick){ if(drawerTarget.mode==='addNew') addNewAdGroupToCampaign(row.dataset.cppick); else moveGroupToCampaign(drawerTarget.gUid, row.dataset.cppick); } }
2441
+ else if(drawerTarget.type==='agpick'){ if(row.dataset.agpick) moveAdToGroup(drawerTarget.adUid, row.dataset.agpick); }
2442
+ else { drawerPick(row.dataset.no); }
2443
+ });
2444
+ document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); /* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */ else if(!dragMoved) openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
2445
+
2446
+ /* ---- コピー / 貼付 ---- */
2447
+ document.addEventListener('copy',e=>{
2448
+ if(activeView!=='sheet'||planMode!=='sheet') return;
2449
+ const rg=selRange();
2450
+ if(rg){ const t=copySelText(); if(t!=null){ e.clipboardData.setData('text/plain',t); e.preventDefault(); } return; } // セル選択→常に自前TSV(ラベル/ボタンのDOMテキスト混入を防ぐ)
2451
+ const ae=document.activeElement; // セル選択なし: 単一inputの部分テキスト選択のみnative許可
2452
+ if(ae&&ae.classList&&ae.classList.contains('cell-in')&&ae.selectionStart!=null&&ae.selectionStart!==ae.selectionEnd) return;
2453
+ e.preventDefault();
2454
+ });
2455
+ document.addEventListener('paste',e=>{ if(activeView!=='sheet'||planMode!=='sheet') return; if(!selRange()) return; const txt=(e.clipboardData||window.clipboardData).getData('text'); if(txt==null) return; e.preventDefault(); pasteGrid(txt); });
2456
+ document.addEventListener('keydown',e=>{
2457
+ if(activeView!=='sheet'||planMode!=='sheet') return;
2458
+ if(e.key==='Escape'){ closeDrawer(); clearSel(); return; }
2459
+ if(e.shiftKey && selB && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)){
2460
+ e.preventDefault(); const vis=flatVisible(); let r=selB.r,c=selB.c;
2461
+ if(e.key==='ArrowUp')r=Math.max(0,r-1); if(e.key==='ArrowDown')r=Math.min(vis.length-1,r+1);
2462
+ if(e.key==='ArrowLeft')c=Math.max(0,c-1); if(e.key==='ArrowRight')c=Math.min(tabCols().length-1,c+1);
2463
+ selB={r,c}; applySelection();
2464
+ }
2465
+ });
2466
+
2467
+ buildDatalists();
2468
+ render();
2469
+ renderAccountBar(); // アカウントセレクト初期化 (同梱デフォルト)
2470
+ restoreFolder(); // 入稿計画フォルダのhandleを復元 (権限失効時は再接続待ちに)
2471
+ restoreAccountFolder(); // B-1: アカウントフォルダのhandleを復元 + セレクト再列挙
2472
+ /* 離脱時: 未保存の編集があればブラウザの確認ダイアログを出す */
2473
+ window.addEventListener('beforeunload', function(e){ if(_dirty){ e.preventDefault(); e.returnValue='未保存の変更があります。「完成 → 保存して入稿」で保存してから離れてください。'; return e.returnValue; } });
2474
+ (async()=>{ try{ const h=await idbGet('samplePlanFile'); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted'||perm==='prompt') samplePlanFileHandle=h; } }catch(e){} })(); // サンプルプラン更新用ハンドル復元
2475
+ /* 案A: 起動時に外部JSON(同梱デフォルトアカウント + サンプルプラン)を読み込む。localStorage無しの初回のみ作り直して再描画。 */
2476
+ (async function bootstrapBundled(){
2477
+ try{
2478
+ // 同梱デフォルトアカウント = アカウント/index.json の先頭エントリ(固有名詞をハードコードしない)
2479
+ let firstName='';
2480
+ try{ const idx=await fetchJson('アカウント/index.json'); const arr=idx?(Array.isArray(idx)?idx:(idx.accounts||[])):[]; firstName=String((arr||[])[0]||''); }catch(e){}
2481
+ if(firstName){
2482
+ const acc=await fetchJson('アカウント/'+encodeURIComponent(firstName)+'.json');
2483
+ if(acc){
2484
+ BUNDLED_ACCOUNT_NAME=firstName;
2485
+ if(acc.accountId) BUNDLED_ACCOUNT_ID=String(acc.accountId);
2486
+ if(acc.refs) bundledRefs=normalizeRefs(acc.refs);
2487
+ if(acc.principal) bundledPrincipal=acc.principal;
2488
+ if(acc.projectName) bundledProjectName=acc.projectName;
2489
+ if(acc.baseArticleUrl) bundledBaseUrl=acc.baseArticleUrl;
2490
+ }
2491
+ }
2492
+ const plan=await fetchJson('サンプルプラン.json'); if(plan) samplePlan=plan;
2493
+ await fetchAccountList();
2494
+ }catch(e){}
2495
+ // 開いたときは必ずサンプルプランを読み込む(localStorage廃止)
2496
+ if(bundledRefs || samplePlan){ st=initialState(); st.project.accountId=BUNDLED_ACCOUNT_ID; st.project.accountName=BUNDLED_ACCOUNT_NAME; if(typeof buildDatalists==='function') buildDatalists(); render(); _dirty=false; }
2497
+ else { toast('初期データ(アカウント/サンプルプラン)を読み込めません。httpサーバー経由で開くか、アカウントを接続してください'); }
2498
+ renderAccountBar();
2499
+ })();
2500
+ </script>
2501
+ </body>
2502
+ </html>