// ==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(); });
})();