您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Alt+Sで検索プリセット。UIは2カラム・タブのみ(既定/最新/メディア/ビデオ/写真)。閉じる/検索実行時や追加・編集・削除・並べ替え・インポート直後に時刻付きキーで自動スナップショット保存、起動時は最新を自動読込。履歴は最大 KEEP_SNAPSHOTS 件を保持し古いものを削除。
// ==UserScript== // @name Better Search for Twitter/X // @name:ja ツイッターの検索マシにするやつ // @namespace better-search-preset-for-twitter // @version 1.0.0 // @author Larthraid // @license MIT // @description Alt+Sで検索プリセット。UIは2カラム・タブのみ(既定/最新/メディア/ビデオ/写真)。閉じる/検索実行時や追加・編集・削除・並べ替え・インポート直後に時刻付きキーで自動スナップショット保存、起動時は最新を自動読込。履歴は最大 KEEP_SNAPSHOTS 件を保持し古いものを削除。 // @match https://x.com/* // @match https://twitter.com/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_listValues // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @connect api.github.com // @connect gist.githubusercontent.com // ==/UserScript== (() => { 'use strict'; /** ============== 設定 ============== */ const KEEP_SNAPSHOTS = 30; // 何件残すか(時刻版) const SNAP_PREFIX = 'searchPresets.ts.'; // 実体保存キーの接頭辞(ts = timestamp) const LATEST_PTR = 'searchPresets.latest'; // 最新キーへのポインタ(値はキー名) const HISTORY_KEY = 'searchPresets.history'; // 直近キー配列(新→古) const EXPANDED_KEY = 'searchPresets.expGroups.v1'; // 開閉状態保存キー(別管理) const GROUPS_KEY = 'searchPresets.customGroups.v1'; // カスタムグループ保存キー const OPEN_IN_NEW_TAB = false; // true で新タブ const MIGRATE_FROM_VN = true; // 旧 vN 形式があれば最初に1回だけ移行 /** ================================== */ // 初期値(初回のみ) const DEFAULTS = [ { title: 'Twitter 落ちた', group: '未分類', q: ['Twitter', '落ちた'], exclude: ['エックスくん', 'Twix'], lang: 'ja', tab: 'live', // 既定:'', 最新:'live', メディア:'media', ビデオ:'videos', 写真:'photos' }, ]; /* ================= バージョン管理(時刻版) ================= */ const nowKey = () => { const d = new Date(); const pad = (n, w=2) => String(n).padStart(w,'0'); const key = `${SNAP_PREFIX}${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}-${pad(d.getMilliseconds(),3)}`; return key; }; const listAllKeys = () => (typeof GM_listValues === 'function') ? GM_listValues() : []; const listSnapKeys = () => listAllKeys().filter(k => k.startsWith(SNAP_PREFIX)).sort().reverse(); // 新→古(文字列でOK) function readJSON(key, fallback=null){ try { const raw = GM_getValue(key, null); if (raw == null) return fallback; return (typeof raw === 'string') ? JSON.parse(raw) : raw; } catch { return fallback; } } function writeJSON(key, obj){ GM_setValue(key, JSON.stringify(obj)); } /* ===== GitHub Gist 同期設定 ===== */ const GIST_TOKEN_KEY = 'gist.token'; const GIST_ID_KEY = 'gist.id'; const GIST_FILENAME_KEY = 'gist.filename'; const DEFAULT_GIST_FILENAME = 'better-search-twitter-presets.json'; function getGistSettings(){ return { token: GM_getValue(GIST_TOKEN_KEY, '').trim(), id: GM_getValue(GIST_ID_KEY, '').trim(), filename: (GM_getValue(GIST_FILENAME_KEY, '') || DEFAULT_GIST_FILENAME).trim(), }; } function setGistSettings({token,id,filename}){ if (typeof token === 'string') GM_setValue(GIST_TOKEN_KEY, token); if (typeof id === 'string') GM_setValue(GIST_ID_KEY, id); if (typeof filename === 'string') GM_setValue(GIST_FILENAME_KEY, filename); } // GM_xmlhttpRequest 版のシンプルな GitHub API 呼び出し function ghRequest({method, url, token, data=null}){ return new Promise((resolve, reject)=>{ GM_xmlhttpRequest({ method, url, headers: Object.assign( {'Accept':'application/vnd.github+json','X-GitHub-Api-Version':'2022-11-28'}, token ? {'Authorization': 'token ' + token} : {} ), data: data ? JSON.stringify(data) : null, onload: resp=>{ const ok = resp.status >=200 && resp.status<300; if (!ok) return reject(new Error('GitHub API '+resp.status+': '+resp.responseText)); try { resolve(resp.responseText ? JSON.parse(resp.responseText) : null); } catch { resolve(resp.responseText); } }, onerror: err=>reject(new Error('Network error')), ontimeout: ()=>reject(new Error('Timeout')), }); }); } async function ensureGistExists(){ const s = getGistSettings(); if (!s.token) throw new Error('Token not set'); if (s.id) return s; // 作成(空ファイルを置く) const body = { description: 'Better Search for Twitter/X presets (auto-sync)', public: false, files: { [s.filename]: { content: JSON.stringify({presets:[], savedAt: Date.now()}, null, 2) } } }; const created = await ghRequest({method:'POST', url:'https://api.github.com/gists', token:s.token, data:body}); const newId = created.id; setGistSettings({id:newId}); return getGistSettings(); } function localLatestSavedAt(){ const latestKey = GM_getValue(LATEST_PTR, ''); if (!latestKey) return 0; const pack = readJSON(latestKey, null); return pack && typeof pack.savedAt==='number' ? pack.savedAt : 0; } async function syncToGist(presets){ try{ const s0 = getGistSettings(); if (!s0.token) return; // 設定未入力なら何もしない const s = s0.id ? s0 : await ensureGistExists(); const content = JSON.stringify({presets, savedAt: Date.now()}, null, 2); const body = { files: { [s.filename]: { content } } }; await ghRequest({method:'PATCH', url:`https://api.github.com/gists/${s.id}`, token:s.token, data:body}); // 成功通知は控えめに // console.info('Gist synced.'); }catch(e){ // 失敗してもローカル動作は継続 // console.warn('Gist sync failed:', e); } } async function pullFromGistIfNewer({force=false}={}){ try{ const s = getGistSettings(); if (!s.token || !s.id) { if(force) alert('Gist設定が未入力です'); return null; } const gist = await ghRequest({method:'GET', url:`https://api.github.com/gists/${s.id}`, token:s.token}); const file = gist.files && gist.files[s.filename]; if (!file){ if(force) alert('指定ファイルがGistに見つかりません'); return null; } async function fetchRaw(rawUrl){ return new Promise((resolve,reject)=>{ GM_xmlhttpRequest({ method:'GET', url: rawUrl, onload:r=>resolve(r.responseText), onerror:reject }); }); } const text = (file.truncated && file.raw_url) ? await fetchRaw(file.raw_url) : (file.content || ''); if (!text) return null; let remote; try{ remote = JSON.parse(text); }catch{ if(force) alert('GistのJSONを解析できません'); return null; } const remoteAt = typeof remote.savedAt==='number' ? remote.savedAt : 0; const localAt = localLatestSavedAt(); if (force || remoteAt > localAt){ if (Array.isArray(remote.presets)){ // 最新として保存し、呼び出し元スコープの presets も上書きできるよう返す saveSnapshot(remote.presets, force ? 'pull_gist_manual' : 'pull_gist_auto'); return remote.presets; } } else { if (force) alert('ローカルの方が新しいため読み込みをスキップしました'); } return null; }catch(e){ if(force) alert('Gistからの取得に失敗: '+e.message); return null; } } function saveSnapshot(presets, reason='manual'){ const key = nowKey(); writeJSON(key, { presets, reason, savedAt: Date.now() }); GM_setValue(LATEST_PTR, key); const history = readJSON(HISTORY_KEY, []); history.unshift(key); const uniq = [...new Set(history)].slice(0, KEEP_SNAPSHOTS); writeJSON(HISTORY_KEY, uniq); const all = listSnapKeys(); const toDelete = all.filter(k => !uniq.includes(k)); toDelete.forEach(k => GM_deleteValue(k)); try{ syncToGist(presets); }catch(e){} } function loadLatestPresets(){ const latestKey = GM_getValue(LATEST_PTR, ''); if (latestKey){ const pack = readJSON(latestKey, null); if (pack && Array.isArray(pack.presets)) return migrateRecord(pack.presets); } const snaps = listSnapKeys(); if (snaps.length){ const pack = readJSON(snaps[0], null); if (pack && Array.isArray(pack.presets)){ GM_setValue(LATEST_PTR, snaps[0]); const history = snaps.slice(0, KEEP_SNAPSHOTS); writeJSON(HISTORY_KEY, history); return migrateRecord(pack.presets); } } if (MIGRATE_FROM_VN){ const vn = listAllKeys().map(k => { const m = /^searchPresets\.v(\d+)$/.exec(k); return m ? { key:k, ver: parseInt(m[1],10) } : null; }).filter(Boolean).sort((a,b)=> b.ver-a.ver); if (vn.length){ const raw = readJSON(vn[0].key, DEFAULTS); const presets = Array.isArray(raw) ? raw : DEFAULTS; saveSnapshot(migrateRecord(presets),'migrated_from_vN'); return migrateRecord(presets); } } saveSnapshot(DEFAULTS,'initialized'); return DEFAULTS.slice(); } // 古いフィールドの移行(live/images/videos → tab。images/videos は破棄) function migrateRecord(arr){ return (Array.isArray(arr)?arr:[]).map(p=>{ if (!p || typeof p!=='object') return p; const cp = {...p}; if (cp.live && !cp.tab) cp.tab='live'; if ('images' in cp) delete cp.images; if ('videos' in cp && cp.tab!=='videos') delete cp.videos; return cp; }); } /* ============== ユーティリティ ============== */ const esc = (s)=> (s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const enc = (s)=> encodeURIComponent(s); const loadExpanded = ()=> { try { return new Set(readJSON(EXPANDED_KEY, [])); } catch { return new Set(); } }; const saveExpanded = (set)=> writeJSON(EXPANDED_KEY, Array.from(set)); const loadGroups = ()=> { try { return new Set(readJSON(GROUPS_KEY, [])); } catch { return new Set(); } }; const saveGroups = (set)=> writeJSON(GROUPS_KEY, Array.from(set)); function isJapanese(str=''){ return /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}ー-]/u.test(str); } function toLines(v){ if (!v) return []; if (Array.isArray(v)) { const arr=[]; v.forEach(x=> String(x).split(/\r?\n/).forEach(y=>{ y=y.trim(); if(y) arr.push(y); })); return arr; } return String(v).split(/\r?\n/).map(s=>s.trim()).filter(Boolean); } /* ============== URL 合成 ============== */ function buildSearchUrl(p){ if (p.url) return p.url; const parts=[]; const qLines = toLines(p.q); const exLines = toLines(p.exclude); if (qLines.length){ const hh=qLines.map(line=>{ if(isJapanese(line)) return `"${line}"`; return /\s|OR|AND|NOT|-|:|\(|\)/i.test(line)?`(${line})`:line; }).join(' OR '); if(hh) parts.push(hh); } if (exLines.length){ const ex=exLines.map(w=>isJapanese(w)?`-"${w}"`:`-${w}`).join(' '); if(ex) parts.push(ex); } if (p.lang) parts.push(`lang:${p.lang}`); if (p.tab === 'videos') parts.push('filter:videos'); const qStr = parts.join(' ').trim(); const params=['q='+enc(qStr),'src=typed_query']; if (p.tab==='live') params.push('f=live'); else if (p.tab==='media' || p.tab==='videos') params.push('f=media'); else if (p.tab==='photos') params.push('f=image'); return `https://x.com/search?${params.join('&')}`; } function buildQueryText(p){ const qLines = toLines(p.q); const exLines = toLines(p.exclude); if (qLines.length||exLines.length||p.lang||p.tab){ const parts=[]; if (qLines.length){ const hh=qLines.map(line=>{ if(isJapanese(line)) return `"${line}"`; return /\s|OR|AND|NOT|-|:|\(|\)/i.test(line)?`(${line})`:line; }).join(' OR '); if(hh) parts.push(hh); } if (exLines.length){ const ex=exLines.map(w=>isJapanese(w)?`-"${w}"`:`-${w}`).join(' '); if(ex) parts.push(ex); } if (p.lang) parts.push(`lang:${p.lang}`); if (p.tab==='live') parts.push('[最新]'); if (p.tab==='media') parts.push('[メディア]'); if (p.tab==='videos') parts.push('[ビデオ]'); if (p.tab==='photos') parts.push('[写真]'); return parts.join(' '); } if (p.url) { try { const u=new URL(p.url); let t = u.searchParams.get('q') ? decodeURIComponent(u.searchParams.get('q')) : p.url; const f = u.searchParams.get('f'); if (f==='live') t+=' [最新]'; if (f==='media') t+=' [メディア]'; if (f==='image') t+=' [写真]'; return t; } catch { return p.url; } } return ''; } /* ============== テーマ ============== */ function detectTheme(){ const bg=getComputedStyle(document.body).backgroundColor||'rgb(255,255,255)'; const m=bg.match(/\d+/g); if(!m) return 'dim'; const [r,g,b]=m.map(Number); const max=Math.max(r,g,b), min=Math.min(r,g,b); const l=(max+min)/510; if(l>0.82) return 'light'; if(l<0.10) return 'lightsout'; return 'dim'; } const palettes={ light:{bg:'#fff',card:'#fff',border:'#eff3f4',text:'#0f1419',subtle:'#536471',hover:'rgba(15,20,25,.06)'}, dim:{bg:'#15202b',card:'#192734',border:'#22303c',text:'#e7e9ea',subtle:'#8899a6',hover:'rgba(29,155,240,.12)'}, lightsout:{bg:'#000',card:'#0a0a0a',border:'#2f3336',text:'#e7e9ea',subtle:'#8b98a5',hover:'rgba(29,155,240,.15)'} }; const pal=palettes[detectTheme()]; GM_addStyle(` .psl-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:2147483646;display:none} .psl-modal{position:fixed;left:50%;top:10%;transform:translateX(-50%); width:min(900px,94vw);background:${pal.card};border:1px solid ${pal.border};border-radius:12px; box-shadow:0 8px 28px rgba(0,0,0,.45);z-index:2147483647;color:${pal.text}; font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"} .psl-modal, .psl-modal * { box-sizing:border-box; font:inherit; color:inherit; } .psl-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid ${pal.border};font-weight:700} .psl-small{opacity:.7;font-size:12px;color:${pal.subtle}} .psl-kbd{background:${pal.border};border-radius:6px;padding:0 6px;margin-left:8px;font-size:12px} .psl-list{max-height:48vh;overflow:auto;padding:6px 8px} .psl-group{border:1px solid ${pal.border};border-radius:10px;margin:8px 0;overflow:hidden;background:${pal.bg}} .psl-group-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;user-select:none} .psl-caret{transition:transform .15s} .psl-group-header:hover{background:${pal.hover}} .psl-group-title{font-weight:700;flex:1} .psl-group-actions{display:flex;gap:6px;align-items:center} .psl-group-body{display:none;padding:6px;min-height:36px} .psl-group.open .psl-group-body{display:block} .psl-group.open .psl-caret{transform:rotate(90deg)} .psl-group.empty .psl-group-body{display:block} .psl-group.empty .psl-group-body::after{content:'(空のグループ)ここにドラッグで追加';display:block;color:${pal.subtle};font-size:12px;padding:8px} .psl-card{display:flex;align-items:center;gap:10px;padding:10px;border:1px solid ${pal.border};border-radius:10px;background:${pal.card};margin:6px 4px;cursor:pointer} .psl-card[aria-selected="true"], .psl-card:hover{background:${pal.hover}} .psl-handle{flex:0 0 18px;display:flex;align-items:center;justify-content:center;cursor:grab;user-select:none;opacity:.6} .psl-handle::before{content:"⋮⋮";line-height:1} .psl-card.dragging{opacity:.6} .psl-card-main{display:flex;flex-direction:column;flex:1;min-width:0} .psl-title{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .psl-sub{opacity:.8;font-size:12px;color:${pal.subtle};white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .psl-card-actions{display:flex;gap:6px} .psl-mini{padding:6px 8px;border:1px solid ${pal.border};border-radius:8px;background:${pal.bg};color:${pal.text};cursor:pointer;font-size:12px} .psl-mini:hover{filter:brightness(1.06)} .psl-actions{display:flex;gap:8px;padding:10px 14px;justify-content:flex-end;border-top:1px solid ${pal.border};flex-wrap:wrap;position:relative} .psl-btn{padding:8px 10px;border:1px solid ${pal.border};border-radius:8px;background:${pal.bg};color:${pal.text};cursor:pointer} .psl-btn[disabled]{opacity:.5;cursor:not-allowed} .psl-btn:hover:not([disabled]){filter:brightness(1.06)} .psl-menu{position:absolute;right:14px;bottom:46px;background:${pal.card};border:1px solid ${pal.border};border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.35);display:none;min-width:180px;z-index:10} .psl-menu.open{display:block} .psl-menu button{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid ${pal.border};background:transparent;padding:10px} .psl-menu button:last-child{border-bottom:0} .psl-menu button:hover{background:${pal.hover}} /* 入力域:2カラム固定(URLは全幅)。クエリと除外が左右対称、その下は言語とタブ選択 */ .psl-inputs{display:grid;gap:10px 12px;padding:10px 14px; grid-template-columns: 1fr 1fr; grid-template-areas: "title group" "url url" "q ex" "lang tab";} .psl-row{display:flex;flex-direction:column;gap:6px} .psl-label{font-size:12px;color:${pal.subtle}} .psl-inputs input,.psl-inputs textarea,.psl-inputs select{ padding:8px 10px;border:1px solid ${pal.border};border-radius:8px;background:${pal.bg};color:${pal.text};width:100%; } .psl-inputs textarea{min-height:74px;resize:vertical} .psl-inputs input::placeholder, .psl-inputs textarea::placeholder { color:${pal.subtle}; opacity:.8; } .psl-a-title{grid-area:title} .psl-a-group{grid-area:group} .psl-a-url{grid-area:url} .psl-a-q{grid-area:q} .psl-a-ex{grid-area:ex} .psl-a-lang{grid-area:lang} .psl-a-tab{grid-area:tab} `); // DOM const backdrop=document.createElement('div'); backdrop.className='psl-backdrop'; const modal=document.createElement('div'); modal.className='psl-modal'; backdrop.appendChild(modal); document.body.appendChild(backdrop); // 状態 let presets=loadLatestPresets(); // 起動時:Gist側が新しければ読み込む pullFromGistIfNewer().then(remote=>{ if (remote){ presets=remote; selectedGlobalIndex=0; editIndex=-1; dirty=true; render(); } }); let selectedGlobalIndex=0; let editIndex=-1; let expandedGroups=loadExpanded(); let customGroups=loadGroups(); let dirty=false; // 変更フラグ const groupOf=(p)=> (p.group||'未分類'); function labelWrap(text, control, extraClass=''){ const wrap=document.createElement('div'); wrap.className='psl-row ' + extraClass; const lab=document.createElement('div'); lab.className='psl-label'; lab.textContent=text; wrap.appendChild(lab); wrap.appendChild(control); return wrap; } function btn(label,onClick){ const b=document.createElement('button'); b.className='psl-btn'; b.textContent=label; b.addEventListener('click',onClick); return b; } function mini(label,onClick){ const b=document.createElement('button'); b.className='psl-mini'; b.textContent=label; b.addEventListener('click',(e)=>{ e.stopPropagation(); onClick(e); }); return b; } function rebuildPresetsFromDOM(listEl){ const used=new Set(); const newArr=[]; const groups=[...listEl.querySelectorAll('.psl-group')]; groups.forEach(gEl=>{ const gname=gEl.getAttribute('data-group-name')||'未分類'; const cards=[...gEl.querySelectorAll('.psl-card')]; cards.forEach(card=>{ const idx=Number(card.getAttribute('data-index')); if (Number.isInteger(idx) && presets[idx]){ const obj=Object.assign({}, presets[idx]); obj.group=gname; newArr.push(obj); used.add(idx); } }); }); presets.forEach((p,i)=>{ if(!used.has(i)) newArr.push(p); }); presets=newArr; dirty=true; saveSnapshot(presets,'reorder'); // 並べ替えは即スナップショット } function render(){ modal.innerHTML=''; const head=document.createElement('div'); head.className='psl-head'; head.innerHTML=`<div>検索プリセット</div> <div class="psl-small">Alt+S<span class="psl-kbd">Alt+S</span> / Esc</div>`; modal.appendChild(head); const setNames=new Set(['未分類']); customGroups.forEach(n=>setNames.add(n)); presets.forEach(p=> setNames.add(groupOf(p))); const groupNames=[...setNames]; const list=document.createElement('div'); list.className='psl-list'; modal.appendChild(list); groupNames.forEach(gname=>{ const groupEl=document.createElement('div'); groupEl.className='psl-group'; groupEl.setAttribute('data-group-name', gname); const header=document.createElement('div'); header.className='psl-group-header'; const caret=document.createElement('span'); caret.className='psl-caret'; caret.textContent='▶'; const title=document.createElement('span'); title.className='psl-group-title'; title.textContent=gname; const gActions=document.createElement('div'); gActions.className='psl-group-actions'; if (gname !== '未分類'){ const delG=document.createElement('button'); delG.className='psl-mini'; delG.textContent='🗑'; delG.title='グループ削除(カードは未分類へ移動)'; delG.addEventListener('click',(e)=>{ e.stopPropagation(); const count = presets.filter(p=>groupOf(p)===gname).length; const ok = confirm(`${gname} を削除しますか?\n(含まれる ${count} 件は「未分類」へ移動します)`); if (!ok) return; presets.forEach(p=>{ if(groupOf(p)===gname) p.group='未分類'; }); customGroups.delete(gname); saveGroups(customGroups); expandedGroups.delete(gname); saveExpanded(expandedGroups); dirty=true; saveSnapshot(presets,'delete_group'); render(); }); gActions.appendChild(delG); } header.append(caret, title, gActions); header.addEventListener('click',()=>{ if (groupEl.classList.toggle('open')) expandedGroups.add(gname); else expandedGroups.delete(gname); saveExpanded(expandedGroups); }); if (expandedGroups.has(gname)) groupEl.classList.add('open'); list.appendChild(groupEl); groupEl.appendChild(header); const body=document.createElement('div'); body.className='psl-group-body'; groupEl.appendChild(body); body.addEventListener('dragover',(e)=>{ e.preventDefault(); const dragging=list.querySelector('.psl-card.dragging'); if (dragging){ const after=getDragAfterElement(body, e.clientY); if (after==null) body.appendChild(dragging); else body.insertBefore(dragging, after); } }); body.addEventListener('drop',(e)=>{ e.preventDefault(); rebuildPresetsFromDOM(list); render(); }); const items=presets.map((p,i)=>({p,i})).filter(x=> groupOf(x.p)===gname); if (items.length===0) groupEl.classList.add('empty'); else groupEl.classList.remove('empty'); items.forEach(({p,i})=>{ const card=document.createElement('div'); card.className='psl-card'; card.setAttribute('data-index', String(i)); card.setAttribute('data-group', gname); card.setAttribute('draggable','true'); const handle=document.createElement('div'); handle.className='psl-handle'; card.appendChild(handle); let dragEnabled=false; handle.addEventListener('mousedown',()=>{ dragEnabled=true; }); handle.addEventListener('mouseup',()=>{ dragEnabled=false; }); card.addEventListener('dragstart',(e)=>{ if(!dragEnabled){ e.preventDefault(); return; } card.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; }); card.addEventListener('dragend',()=>{ card.classList.remove('dragging'); }); const main=document.createElement('div'); main.className='psl-card-main'; const title=p.title?.trim()||'(no title)'; const sub=buildQueryText(p); main.innerHTML=`<div class="psl-title">${esc(title)}</div><div class="psl-sub">${esc(sub)}</div>`; card.appendChild(main); const right=document.createElement('div'); right.className='psl-card-actions'; const editLabel = (editIndex===i) ? '更新' : '編集'; const editB=mini(editLabel,()=>{ if (editIndex===i){ const pNew=formToPreset(); if(!pNew.url && (!pNew.q||toLines(pNew.q).length===0)) return; presets[i]=pNew; dirty=true; saveSnapshot(presets,'update_card'); editIndex=-1; render(); } else { editIndex=i; render(); } }); const delB=mini('削除',()=>{ presets.splice(i,1); if(selectedGlobalIndex>=presets.length) selectedGlobalIndex=presets.length-1; dirty=true; saveSnapshot(presets,'delete_card'); render(); }); right.append(editB, delB); card.appendChild(right); if (i===selectedGlobalIndex) card.setAttribute('aria-selected','true'); card.addEventListener('click',(e)=>{ if(e.target===handle) return; openPreset(i); }); card.addEventListener('mouseenter',()=>{ selectedGlobalIndex=i; updateSelection(); }); body.appendChild(card); }); }); // 入力フォーム(2カラム、クエリ/除外は左右対称。下段は言語/タブ) const inputs=document.createElement('div'); inputs.className='psl-inputs'; const t=document.createElement('input'), g=document.createElement('input'), u=document.createElement('input'), q=document.createElement('textarea'), ex=document.createElement('textarea'), lang=document.createElement('input'), tabSel=document.createElement('select'); tabSel.innerHTML=` <option value="">並べ替え既定</option> <option value="live">最新</option> <option value="media">メディア</option> <option value="videos">ビデオ</option> <option value="photos">写真</option>`; t.placeholder='タイトル(例:Twitter 落ちた)'; g.placeholder='グループ(例: 障害情報)'; u.placeholder='検索URL(https://x.com/search?...) ※URL保存ならこちらに記入'; q.placeholder='#タグかキーワードを行区切りで。日本語は自動で""囲まれます(URLを使うなら空でOK)'; ex.placeholder='除外ワード(行区切り / 日本語は自動で引用)'; lang.placeholder='言語コード(例: ja)'; if (editIndex>=0){ const p=presets[editIndex]; t.value=p.title||''; g.value=p.group||''; u.value=p.url||''; q.value=toLines(p.q).join('\n'); ex.value=toLines(p.exclude).join('\n'); lang.value=p.lang||''; tabSel.value = p.tab||''; } else { tabSel.value=''; } inputs.append( labelWrap('タイトル',t,'psl-a-title'), labelWrap('グループ',g,'psl-a-group'), labelWrap('検索URL',u,'psl-a-url'), labelWrap('クエリ(行区切り / OR 連結)',q,'psl-a-q'), labelWrap('除外(行区切り / 日本語は自動で引用)',ex,'psl-a-ex'), labelWrap('言語',lang,'psl-a-lang'), labelWrap('タブ',tabSel,'psl-a-tab'), ); modal.appendChild(inputs); // アクション(追加 / 編集(更新) / 削除 / バックアップ▾ / グループ追加) const actions=document.createElement('div'); actions.className='psl-actions'; const addBtn=btn('追加',()=>{ const p=formToPreset(); if(!p.url && (!p.q||toLines(p.q).length===0)) return; presets.push(p); selectedGlobalIndex=presets.length-1; editIndex=-1; customGroups.add(groupOf(p)); saveGroups(customGroups); dirty=true; saveSnapshot(presets,'add_card'); render(); }); const editToggleBtn=btn(editIndex>=0?'更新':'編集',()=>{ if (editIndex<0){ if(presets.length===0) return; editIndex=selectedGlobalIndex; render(); } else { const p=formToPreset(); if(!p.url && (!p.q||toLines(p.q).length===0)) return; presets[editIndex]=p; customGroups.add(groupOf(p)); saveGroups(customGroups); dirty=true; saveSnapshot(presets,'update_card'); editIndex=-1; render(); } }); const delBtn=btn('削除',()=>{ if(presets.length===0) return; presets.splice(selectedGlobalIndex,1); if(selectedGlobalIndex>=presets.length) selectedGlobalIndex=presets.length-1; dirty=true; saveSnapshot(presets,'delete_card'); render(); }); const backupBtn=btn('オプション',()=>{ menu.classList.toggle('open'); }); const menu=document.createElement('div'); menu.className='psl-menu'; const exportItem=document.createElement('button'); exportItem.textContent='エクスポート (.json)'; exportItem.addEventListener('click',()=>{ const blob=new Blob([JSON.stringify(presets,null,2)],{type:'application/json'}); const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:'better-search-twitter-presets.json'}); document.body.appendChild(a); a.click(); a.remove(); menu.classList.remove('open'); }); const importItem=document.createElement('button'); importItem.textContent='インポート (.json)'; importItem.addEventListener('click',()=>{ const input=Object.assign(document.createElement('input'),{type:'file',accept:'.json,application/json'}); input.onchange=async()=>{ try{ const text=await input.files[0].text(); presets=JSON.parse(text); dirty=true; saveSnapshot(presets,'import'); render(); }catch{} }; input.click(); menu.classList.remove('open'); }); // 認証/同期オプション const authItem=document.createElement('button'); authItem.textContent='認証/同期オプション'; authItem.addEventListener('click',()=>{ const s=getGistSettings(); const t=prompt('GitHub Personal Access Token (gist 権限)', s.token||'')||''; const id=prompt('既存のGist ID(未設定なら空のまま)', s.id||'')||''; const fn=prompt('ファイル名', s.filename||DEFAULT_GIST_FILENAME)||DEFAULT_GIST_FILENAME; setGistSettings({token:t,id:id,filename:fn}); alert('設定を保存しました'); menu.classList.remove('open'); }); // 今すぐ同期(アップロード) const syncItem=document.createElement('button'); syncItem.textContent='今すぐ同期(アップロード)'; syncItem.addEventListener('click',async()=>{ await syncToGist(presets); alert('Gistに同期リクエストを送信しました'); menu.classList.remove('open'); }); // Gistから読み込む(新しい場合のみ上書き) const pullItem=document.createElement('button'); pullItem.textContent='Gistから読み込む(新しい場合のみ)'; pullItem.addEventListener('click',async()=>{ const remote=await pullFromGistIfNewer({force:true}); if (remote){ presets=remote; selectedGlobalIndex=0; editIndex=-1; dirty=true; render(); } menu.classList.remove('open'); }); menu.append(exportItem, importItem, authItem, syncItem, pullItem); actions.append(menu); document.addEventListener('click',(e)=>{ if (!actions.contains(e.target)) menu.classList.remove('open'); }); const newGroupBtn=btn('グループ追加',()=>{ const name=prompt('新しいグループ名を入力してください','新規グループ'); if(!name) return; customGroups.add(name); saveGroups(customGroups); expandedGroups.add(name); saveExpanded(expandedGroups); render(); }); actions.append(addBtn, editToggleBtn, delBtn, backupBtn, newGroupBtn); modal.appendChild(actions); function formToPreset(){ const obj={ title:(t.value||'').trim(), group:(g.value||'').trim()||'未分類' }; const urlVal=(u.value||'').trim(); if(urlVal) obj.url=urlVal; const qLines=(q.value||'').split(/\r?\n/).map(s=>s.trim()).filter(Boolean); const exLines=(ex.value||'').split(/\r?\n/).map(s=>s.trim()).filter(Boolean); if(qLines.length) obj.q=qLines; if(exLines.length) obj.exclude=exLines; const langVal=(lang.value||'').trim(); if(langVal) obj.lang=langVal; obj.tab = tabSel.value || ''; return obj; } } function updateSelection(){ modal.querySelectorAll('.psl-card').forEach(el=>{ const idx=Number(el.getAttribute('data-index')); if(idx===selectedGlobalIndex) el.setAttribute('aria-selected','true'); else el.removeAttribute('aria-selected'); }); } function openPreset(i){ const p=presets[i]; if(!p) return; const url=buildSearchUrl(p); saveSnapshot(presets,'open_search'); if(OPEN_IN_NEW_TAB) window.open(url,'_blank'); else location.href=url; close(); } function show(){ selectedGlobalIndex=Math.min(selectedGlobalIndex, Math.max(0, presets.length-1)); render(); backdrop.style.display='block'; document.documentElement.style.overflow='hidden'; } function close(){ backdrop.style.display='none'; editIndex=-1; document.documentElement.style.overflow=''; if (dirty){ saveSnapshot(presets,'close_modal'); dirty=false; } } function getDragAfterElement(container,y){ const els=[...container.querySelectorAll('.psl-card:not(.dragging)')]; return els.reduce((closest,child)=>{ const box=child.getBoundingClientRect(); const offset=y - box.top - box.height/2; if(offset<0 && offset>closest.offset) return {offset, element: child}; else return closest; }, {offset:Number.NEGATIVE_INFINITY}).element; } // キーイベント:モーダル中はXショートカット抑止、Alt+Sトグル/ESCのみ通す const isModalOpen=()=> backdrop.style.display==='block'; ['keydown','keypress','keyup'].forEach((type)=>{ window.addEventListener(type,(e)=>{ const isAltS = e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && e.code==='KeyS'; const isEsc = e.key==='Escape'; if (type==='keydown'){ if (!isModalOpen() && isAltS){ e.preventDefault(); e.stopImmediatePropagation(); show(); return; } if (isModalOpen() && isAltS){ e.preventDefault(); e.stopImmediatePropagation(); close(); return; } if (isModalOpen() && isEsc){ e.preventDefault(); e.stopImmediatePropagation(); close(); return; } } if (isModalOpen()){ if (!modal.contains(e.target)) e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } }, true); }); backdrop.addEventListener('click',(e)=>{ if(e.target===backdrop) close(); }); })();