// ==UserScript==
// @name タグオートコンプリート for android
// @namespace http://tampermonkey.net/
// @version 1.0
// @description webおすすめ
// @author ぶいぶい
// @match *://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// --- Constants ---
const DB_NAME = 'AITagDB';
const DB_VERSION = 1;
const STORE_NAME = 'tags';
const TRIGGER_CHARS = 3;
// --- Helper: ContentEditable & Insertion Logic ---
const EditorUtils = {
isEditable(el) {
return el.tagName === 'TEXTAREA' ||
(el.tagName === 'INPUT' && el.type === 'text') ||
el.isContentEditable;
},
getValue(el) {
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.value;
return el.textContent;
},
getCursorPos(el) {
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') return el.selectionStart;
let pos = 0;
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(el);
preCaretRange.setEnd(range.endContainer, range.endOffset);
pos = preCaretRange.toString().length;
}
return pos;
},
replaceLastWord(el, wordLengthToReplace, textToInsert) {
el.focus();
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
const start = el.selectionStart - wordLengthToReplace;
const end = el.selectionEnd;
el.setSelectionRange(start, end);
document.execCommand('insertText', false, textToInsert);
} else {
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
if (range.startContainer.nodeType === 3) {
const newStart = Math.max(0, range.startOffset - wordLengthToReplace);
range.setStart(range.startContainer, newStart);
sel.removeAllRanges();
sel.addRange(range);
}
}
document.execCommand('insertText', false, textToInsert);
}
}
};
// --- Helper: CSV Parser ---
function parseCSVLine(text) {
const result = [];
let startValueIndex = 0;
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') inQuotes = !inQuotes;
else if (char === ',' && !inQuotes) {
let val = text.substring(startValueIndex, i).trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1).replace(/""/g, '"');
result.push(val);
startValueIndex = i + 1;
}
}
let val = text.substring(startValueIndex).trim();
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1).replace(/""/g, '"');
result.push(val);
return result;
}
// --- Snippet Manager ---
class SnippetManager {
constructor() { this.snippets = GM_getValue('snippets', {}); }
save(key, value) { this.snippets[key] = value; GM_setValue('snippets', this.snippets); }
remove(key) { delete this.snippets[key]; GM_setValue('snippets', this.snippets); }
get(key) { return this.snippets[key]; }
search(query) {
const keys = Object.keys(this.snippets);
const matched = keys.filter(k => k.startsWith(query));
return matched.map(k => ({ name: k, value: this.snippets[k], type: 'snippet', count: 99999999 }));
}
getAll() { return this.snippets; }
}
// --- IndexedDB Manager (Hybrid Mode) ---
class IndexedDBManager {
constructor() {
this.db = null;
this.cache = null;
this.useMemory = GM_getValue('useMemory', true); // Default to Memory
}
setMode(useMemory) {
this.useMemory = useMemory;
if (this.useMemory && !this.cache) {
this.loadCacheToMemory();
} else if (!this.useMemory) {
this.cache = null; // Free memory
}
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (e) => reject(e.target.error);
request.onsuccess = (e) => {
this.db = e.target.result;
if (this.useMemory) this.loadCacheToMemory();
resolve(this.db);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'name' });
store.createIndex('count', 'count', { unique: false });
}
};
});
}
async loadCacheToMemory() {
if (!this.db) return;
console.log('Loading tags into memory...');
const tx = this.db.transaction([STORE_NAME], 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => {
this.cache = req.result;
console.log(`Cache loaded: ${this.cache.length} tags`);
};
}
async importCSV(csvText) {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction([STORE_NAME], 'readwrite');
const store = tx.objectStore(STORE_NAME);
tx.oncomplete = () => {
if (this.useMemory) this.loadCacheToMemory();
resolve();
};
tx.onerror = (e) => reject(e.target.error);
const lines = csvText.split(/\r?\n/);
lines.forEach(line => {
if (!line.trim()) return;
const parts = parseCSVLine(line);
if (parts.length >= 1) {
store.put({
name: parts[0],
category: parseInt(parts[1]) || 0,
count: parseInt(parts[2]) || 0,
description: parts.slice(3).join(' ')
});
}
});
});
}
async search(query, useDesc, limit = 30) {
if (!this.db) await this.init();
// === Mode A: In-Memory Search (Fast) ===
if (this.useMemory && this.cache) {
const lowerQuery = query.toLowerCase();
const results = [];
for (let i = 0; i < this.cache.length; i++) {
const val = this.cache[i];
const valName = val.name.toLowerCase();
const matchName = valName.startsWith(lowerQuery);
const matchNamePart = valName.includes(lowerQuery);
const matchDesc = useDesc && val.description && val.description.toLowerCase().includes(lowerQuery);
if (matchNamePart || matchDesc) {
let item = val;
if (valName === lowerQuery) item = { ...val, count: val.count + 100000000 };
results.push(item);
}
}
return results.sort((a, b) => b.count - a.count).slice(0, limit);
}
// === Mode B: Disk Search (Low RAM) ===
else {
return new Promise((resolve) => {
const tx = this.db.transaction([STORE_NAME], 'readonly');
const store = tx.objectStore(STORE_NAME);
const results = [];
const lowerQuery = query.toLowerCase();
const req = store.openCursor();
req.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
const val = cursor.value;
const valName = val.name.toLowerCase();
const matchName = valName.startsWith(lowerQuery);
const matchDesc = useDesc && val.description && val.description.toLowerCase().includes(lowerQuery);
if (matchName || matchDesc) {
if (valName === lowerQuery) val.count += 100000000;
results.push(val);
}
if (results.length < limit * 4) cursor.continue();
else resolve(results.sort((a, b) => b.count - a.count).slice(0, limit));
} else {
resolve(results.sort((a, b) => b.count - a.count).slice(0, limit));
}
};
req.onerror = () => resolve([]);
});
}
}
async clear() {
if (!this.db) await this.init();
const tx = this.db.transaction([STORE_NAME], 'readwrite');
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => { this.cache = []; };
}
}
// --- Settings UI ---
class SettingsUI {
constructor(dbMgr, snipMgr) {
this.dbMgr = dbMgr; this.snipMgr = snipMgr; this.overlay = null;
this.config = {
searchDesc: GM_getValue('searchDesc', false),
rowCount: GM_getValue('rowCount', 1),
useMemory: GM_getValue('useMemory', true)
};
}
createOverlay() {
if (this.overlay) return;
this.overlay = document.createElement('div');
Object.assign(this.overlay.style, {
position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
backgroundColor: 'rgba(0,0,0,0.8)', zIndex: '100000', display: 'none',
justifyContent: 'center', alignItems: 'flex-start', paddingTop: '50px',
color: '#fff', fontFamily: 'sans-serif', fontSize: '14px'
});
const container = document.createElement('div');
Object.assign(container.style, {
backgroundColor: '#222', padding: '20px', borderRadius: '8px',
width: '90%', maxWidth: '400px', maxHeight: '80%', overflowY: 'auto',
border: '1px solid #444'
});
const btnStyle = "background: #444; color: white; border: 1px solid #666; padding: 8px; border-radius: 4px; width: 100%; margin-top: 5px;";
const inputStyle = "background: #333; color: white; border: 1px solid #555; padding: 5px; border-radius: 4px;";
container.innerHTML = '
AI Tag Config
';
// --- Mode Setting ---
const modeDiv = document.createElement('div');
modeDiv.style.marginBottom = '10px';
modeDiv.innerHTML = 'Search Mode
';
const modeLabel = document.createElement('label');
modeLabel.style.display = 'flex'; modeLabel.style.alignItems = 'center';
const modeCheck = document.createElement('input'); modeCheck.type = 'checkbox';
modeCheck.checked = this.config.useMemory; modeCheck.style.marginRight = '10px';
modeCheck.onchange = () => {
this.config.useMemory = modeCheck.checked;
GM_setValue('useMemory', modeCheck.checked);
this.dbMgr.setMode(modeCheck.checked);
};
modeLabel.appendChild(modeCheck);
modeLabel.appendChild(document.createTextNode('In-Memory (Fast, High RAM)'));
modeDiv.appendChild(modeLabel);
container.appendChild(modeDiv);
container.appendChild(document.createElement('hr'));
// --- UI Settings ---
const uiDiv = document.createElement('div'); uiDiv.innerHTML = 'UI Settings
';
const rowLabel = document.createElement('div'); rowLabel.textContent = `Display Rows: ${this.config.rowCount}`;
rowLabel.style.marginBottom = '5px';
const rowRange = document.createElement('input');
rowRange.type = 'range'; rowRange.min = '1'; rowRange.max = '4'; rowRange.step = '1';
rowRange.value = this.config.rowCount; rowRange.style.width = '100%';
rowRange.oninput = () => {
this.config.rowCount = parseInt(rowRange.value);
rowLabel.textContent = `Display Rows: ${this.config.rowCount}`;
GM_setValue('rowCount', this.config.rowCount);
};
uiDiv.appendChild(rowLabel); uiDiv.appendChild(rowRange); container.appendChild(uiDiv);
const optDiv = document.createElement('div'); optDiv.style.marginTop = '15px';
const descLabel = document.createElement('label'); descLabel.style.display = 'flex'; descLabel.style.alignItems = 'center';
const descCheck = document.createElement('input'); descCheck.type = 'checkbox';
descCheck.checked = this.config.searchDesc; descCheck.style.marginRight = '10px';
descCheck.onchange = () => { this.config.searchDesc = descCheck.checked; GM_setValue('searchDesc', descCheck.checked); };
descLabel.appendChild(descCheck); descLabel.appendChild(document.createTextNode('Search Description'));
optDiv.appendChild(descLabel); container.appendChild(optDiv); container.appendChild(document.createElement('hr'));
// --- CSV & Snippet ---
const hCsv = document.createElement('h4'); hCsv.textContent = 'CSV Import'; container.appendChild(hCsv);
const fileIn = document.createElement('input'); fileIn.type = 'file'; fileIn.accept = '.csv';
fileIn.style.cssText = inputStyle + "width:95%;"; container.appendChild(fileIn);
const loadBtn = document.createElement('button'); loadBtn.textContent = 'Load CSV'; loadBtn.style.cssText = btnStyle;
loadBtn.onclick = () => {
if(!fileIn.files[0]) return alert('Select CSV');
const r = new FileReader(); loadBtn.textContent = 'Loading...'; loadBtn.disabled = true;
r.onload = async (e) => { await this.dbMgr.importCSV(e.target.result); alert('Done'); loadBtn.textContent = 'Load CSV'; loadBtn.disabled = false; };
r.readAsText(fileIn.files[0]);
};
container.appendChild(loadBtn);
container.appendChild(document.createElement('hr'));
const hSnip = document.createElement('h4'); hSnip.textContent = 'Snippets'; container.appendChild(hSnip);
const snipK = document.createElement('input'); snipK.placeholder = 'Key'; snipK.style.cssText = inputStyle + "width:30%; margin-right:2%;";
const snipV = document.createElement('input'); snipV.placeholder = 'Value'; snipV.style.cssText = inputStyle + "width:60%;";
const snipAdd = document.createElement('button'); snipAdd.textContent = 'Add Snippet'; snipAdd.style.cssText = btnStyle;
snipAdd.onclick = () => { if(snipK.value && snipV.value) { this.snipMgr.save(snipK.value, snipV.value); snipK.value=''; snipV.value=''; } };
container.append(snipK, snipV, snipAdd);
const closeBtn = document.createElement('button'); closeBtn.textContent = 'Close';
closeBtn.style.cssText = btnStyle + "margin-top:20px; background: #666;";
closeBtn.onclick = () => this.hide();
container.appendChild(closeBtn);
this.overlay.appendChild(container); document.body.appendChild(this.overlay);
}
show() { if(!this.overlay) this.createOverlay(); this.overlay.style.display='flex'; }
hide() { if(this.overlay) this.overlay.style.display='none'; }
getConfig() { return this.config; }
}
// --- Suggestion UI ---
class SuggestionUI {
constructor(snipMgr) {
this.snipMgr = snipMgr; this.container = null; this.visible = false; this.activeInput = null; this.lastQueryLength = 0;
this.rowCount = 1;
this.initStyles();
}
initStyles() {
GM_addStyle(`
#ai-tag-ui {
position: fixed; z-index: 999999; background: #121212; color: #e0e0e0;
border-top: 1px solid #333; box-shadow: 0 -4px 12px rgba(0,0,0,0.5);
display: none; left: 0; width: 100%; box-sizing: border-box;
display: grid; grid-auto-flow: column; gap: 4px 8px; padding: 8px;
overflow-x: auto; overflow-y: hidden; touch-action: pan-x;
-webkit-overflow-scrolling: touch; scrollbar-width: none;
padding-bottom: max(8px, env(safe-area-inset-bottom, 0px));
}
.ai-tag-chip {
display: inline-block; padding: 6px 12px; margin: 0;
border-radius: 4px; font-size: 14px; cursor: pointer; background: #2c2c2c;
border-left: 3px solid #555; user-select: none;
white-space: nowrap; height: 32px; line-height: 20px; box-sizing: border-box;
}
.ai-tag-snip { border-left-color: #ffeb3b; background: #424220; }
.ai-tag-c0 { border-left-color: #9e9e9e; } .ai-tag-c1 { border-left-color: #f44336; }
.ai-tag-c3 { border-left-color: #e040fb; } .ai-tag-c4 { border-left-color: #00e676; }
.ai-tag-count { font-size: 0.75em; opacity: 0.7; margin-left: 4px; }
`);
}
createContainer() {
if (this.container) return;
this.container = document.createElement('div'); this.container.id = 'ai-tag-ui';
document.body.appendChild(this.container);
this.container.addEventListener('mousedown', e => e.preventDefault());
this.container.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const chip = e.target.closest('.ai-tag-chip');
if (chip) {
const val = chip.dataset.value;
if (val) this.insert(val);
}
});
}
render(results, inputElement, queryLength, rowCount) {
if (!this.container) this.createContainer();
this.activeInput = inputElement;
this.lastQueryLength = queryLength;
if (this.rowCount !== rowCount) {
this.rowCount = rowCount;
this.container.style.gridTemplateRows = `repeat(${rowCount}, auto)`;
}
let html = '';
results.forEach(item => {
const typeClass = item.type === 'snippet' ? 'ai-tag-snip' : `ai-tag-c${item.category}`;
const dispName = item.name.replace(/_/g, ' ');
const val = item.type === 'snippet' ? item.value : item.name;
const countHtml = item.count > 0 ? `${(item.count/1000).toFixed(0)}k` : '';
const icon = item.type === 'snippet' ? '★ ' : '';
html += `${icon}${dispName}${countHtml}
`;
});
this.container.innerHTML = html;
this.show();
}
insert(text) {
if (!this.activeInput) return;
const el = this.activeInput;
const cleanText = text.replace(/_/g, ' ');
const finalText = cleanText + ', ';
EditorUtils.replaceLastWord(el, this.lastQueryLength, finalText);
this.hide();
}
show() { this.visible = true; this.container.style.display = 'grid'; this.updatePos(); }
hide() { this.visible = false; if (this.container) this.container.style.display = 'none'; }
updatePos() {
if (!this.visible || !this.container) return;
const vv = window.visualViewport;
if (vv) {
const top = vv.offsetTop + vv.height - this.container.offsetHeight;
this.container.style.top = `${top}px`;
} else {
this.container.style.bottom = '0';
}
}
}
// --- Input Logic ---
class InputHandler {
constructor(dbMgr, snipMgr, ui, settingsUI) {
this.db = dbMgr; this.snip = snipMgr; this.ui = ui; this.settings = settingsUI; this.timer = null;
if (window.visualViewport) {
const up = () => this.ui.updatePos();
window.visualViewport.addEventListener('resize', up); window.visualViewport.addEventListener('scroll', up);
}
document.addEventListener('input', e => this.onInput(e));
document.addEventListener('click', e => {
if (this.ui.visible && !this.ui.container.contains(e.target) && e.target !== this.ui.activeInput) {
this.ui.hide();
}
});
}
onInput(e) {
const t = e.target;
if (!EditorUtils.isEditable(t)) return;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(async () => {
const val = EditorUtils.getValue(t);
const cur = EditorUtils.getCursorPos(t);
const prev = val.substring(0, cur);
const lastComma = prev.lastIndexOf(',');
const word = prev.substring(lastComma + 1).trim();
if (word.length >= TRIGGER_CHARS) {
const snipRes = this.snip.search(word);
const config = this.settings.getConfig();
const tagRes = await this.db.search(word, config.searchDesc);
const combined = [...snipRes, ...tagRes];
if (combined.length > 0) this.ui.render(combined, t, word.length, config.rowCount);
else this.ui.hide();
} else {
this.ui.hide();
}
}, 100);
}
}
// --- Boot ---
const snipMgr = new SnippetManager();
const dbMgr = new IndexedDBManager();
const settings = new SettingsUI(dbMgr, snipMgr);
const ui = new SuggestionUI(snipMgr);
new InputHandler(dbMgr, snipMgr, ui, settings);
GM_registerMenuCommand('Tag Autocomplete Settings', () => settings.show());
console.log('AI Tag Autocomplete v1.0 Ready');
})();